chore: rename relay to gateway

This commit is contained in:
Peter Steinberger
2025-12-09 18:00:01 +00:00
parent bc3a14cde2
commit a3bf2bdd8c
50 changed files with 2022 additions and 2570 deletions

View File

@@ -38,7 +38,7 @@ READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Agent-Specific Notes ## Agent-Specific Notes
- Relay currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the relay via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there. - Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- When asked to open a “session” file, open the Pi/Tau session logs under `~/.tau/agent/sessions/clawdis/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. - When asked to open a “session” file, open the Pi/Tau session logs under `~/.tau/agent/sessions/clawdis/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.

View File

@@ -2,19 +2,19 @@
## 2.0.0 — Unreleased ## 2.0.0 — Unreleased
First Clawdis release after the Warelay rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app. First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app.
### Breaking ### Breaking
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run. - Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
- Pi/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker. - Pi/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed. - WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`. - Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
- Relay background helpers were removed; run `clawdis relay --verbose` under your supervisor of choice if you want it detached. - Gateway background helpers were removed; run `clawdis gateway --verbose` under your supervisor of choice if you want it detached.
### macOS companion app ### macOS companion app
- **Clawdis.app menu bar companion**: packaged, signed bundle with relay start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states. - **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and an SSH forwarder + test harness that runs `clawdis-mac agent --message …` on your target machine and surfaces errors clearly. - **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and an SSH forwarder + test harness that runs `clawdis-mac agent --message …` on your target machine and surfaces errors clearly.
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), relay restart, health probes, and scrollable settings panes. - **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
### WhatsApp & agent experience ### WhatsApp & agent experience
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when youre @mentioned, and safer handling of view-once/ephemeral media. - Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when youre @mentioned, and safer handling of view-once/ephemeral media.
@@ -36,7 +36,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
### Docs ### Docs
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits. - Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
- CLI relay now auto-starts WhatsApp and Telegram when configured (single `relay` command with `--provider` selector); text/media sends still use `--provider telegram`; webhook/proxy options documented. - CLI gateway now auto-starts WhatsApp and Telegram when configured (single `gateway` command with `--provider` selector); text/media sends still use `--provider telegram`; webhook/proxy options documented.
## 1.5.0 — 2025-12-05 ## 1.5.0 — 2025-12-05
@@ -48,7 +48,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact. - Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical. - Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`. - Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
- Simplified send/agent/relay/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code. - Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
- Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only. - Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
- Pi/Tau sessions now write to `~/.clawdis/sessions/` by default (legacy `~/.tau/agent/sessions/clawdis` files are copied over when present). - Pi/Tau sessions now write to `~/.clawdis/sessions/` by default (legacy `~/.tau/agent/sessions/clawdis` files are copied over when present).
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent. - Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
@@ -84,7 +84,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
### Reliability & UX ### Reliability & UX
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp). - Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately. - Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
- IPC relay send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity. - IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression. - Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces. - All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
- `--verbose` now forces log level `trace` (was `debug`) to capture every event. - `--verbose` now forces log level `trace` (was `debug`) to capture every event.
@@ -148,7 +148,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
### Changes ### Changes
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips dont refresh session; session `heartbeatIdleMinutes` support. - Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips dont refresh session; session `heartbeatIdleMinutes` support.
- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `relay`) for immediate startup probes. - Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `gateway`) for immediate startup probes.
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days. - Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op. - Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup. - Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup.
@@ -163,7 +163,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`. - Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
- Optional audio transcription via external CLI. - Optional audio transcription via external CLI.
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages. - Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
- Web provider refactor; logout command; web-only relay start helper. - Web provider refactor; logout command; web-only gateway start helper.
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide. - Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
- Relay help prints effective heartbeat/backoff when in web mode. - Relay help prints effective heartbeat/backoff when in web mode.
@@ -171,7 +171,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
### Changes ### Changes
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added. - Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
- Web relay auto-reconnects after Baileys/WebSocket drops; close propagation tests. - Web gateway auto-reconnects after Baileys/WebSocket drops; close propagation tests.
## 0.1.3 — 2025-11-25 ## 0.1.3 — 2025-11-25

View File

@@ -9,12 +9,12 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/steipete/warelay/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/warelay/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a> <a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://www.npmjs.com/package/warelay"><img src="https://img.shields.io/npm/v/warelay.svg?style=for-the-badge" alt="npm version"></a> <a href="https://www.npmjs.com/package/clawdis"><img src="https://img.shields.io/npm/v/clawdis.svg?style=for-the-badge" alt="npm version"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p> </p>
**CLAWDIS** (formerly Warelay) is a WhatsApp- and Telegram-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7. **CLAWDIS** is a WhatsApp- and Telegram-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7.
``` ```
┌─────────────┐ ┌──────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────────┐
@@ -52,7 +52,7 @@ Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI bot
```bash ```bash
# Install # Install
npm install -g warelay # (still warelay on npm for now) npm install -g clawdis
# Link your WhatsApp # Link your WhatsApp
clawdis login clawdis login
@@ -125,11 +125,11 @@ CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setu
### WhatsApp Web ### WhatsApp Web
```bash ```bash
clawdis login # Scan QR code clawdis login # Scan QR code
clawdis relay # Start listening clawdis gateway # Start listening
``` ```
### Telegram (Bot API) ### Telegram (Bot API)
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`. The unified `clawdis relay` starts WhatsApp and, when `TELEGRAM_BOT_TOKEN` or `telegram.botToken` is set, Telegram too (use `--provider` to force web|telegram|all). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits. Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`. The unified `clawdis gateway` starts WhatsApp and, when `TELEGRAM_BOT_TOKEN` or `telegram.botToken` is set, Telegram too (use `--provider` to force web|telegram|all). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits.
## Commands ## Commands
@@ -138,7 +138,7 @@ Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebCha
| `clawdis login` | Link WhatsApp Web via QR | | `clawdis login` | Link WhatsApp Web via QR |
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode, text + media) | | `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode, text + media) |
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) | | `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
| `clawdis relay` | Start auto-reply loop (WhatsApp + Telegram when configured) | | `clawdis gateway` | Start auto-reply loop (WhatsApp + Telegram when configured) |
| `clawdis status` | Web session health + session store summary | | `clawdis status` | Web session health + session store summary |
| `clawdis heartbeat` | Trigger a heartbeat | | `clawdis heartbeat` | Trigger a heartbeat |

View File

@@ -13,7 +13,7 @@ actor AgentRPC {
private var configured = false private var configured = false
private var gatewayURL: URL { private var gatewayURL: URL {
let port = RelayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")! return URL(string: "ws://127.0.0.1:\(port)")!
} }

View File

@@ -111,7 +111,7 @@ struct ConfigSettings: View {
} }
Text( Text(
""" """
Mac app connects to the relays loopback web chat on this port. Mac app connects to the gateways loopback web chat on this port.
Remote mode uses SSH -L to forward it. Remote mode uses SSH -L to forward it.
""") """)
.font(.footnote) .font(.footnote)

View File

@@ -57,7 +57,7 @@ final class ControlChannel: ObservableObject {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private let gateway = GatewayChannel() private let gateway = GatewayChannel()
private var gatewayURL: URL { private var gatewayURL: URL {
let port = RelayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")! return URL(string: "ws://127.0.0.1:\(port)")!
} }
@@ -130,16 +130,16 @@ final class ControlChannel: ObservableObject {
} }
if let urlError = error as? URLError { if let urlError = error as? URLError {
let port = RelayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
switch urlError.code { switch urlError.code {
case .cancelled: case .cancelled:
return "Gateway connection was closed; start the relay (localhost:\(port)) and retry." return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
case .cannotFindHost, .cannotConnectToHost: case .cannotFindHost, .cannotConnectToHost:
return "Cannot reach gateway at localhost:\(port); ensure the relay is running." return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
case .networkConnectionLost: case .networkConnectionLost:
return "Gateway connection dropped; relay likely restarted—retry." return "Gateway connection dropped; gateway likely restarted—retry."
case .timedOut: case .timedOut:
return "Gateway request timed out; check relay on localhost:\(port)." return "Gateway request timed out; check gateway on localhost:\(port)."
case .notConnectedToInternet: case .notConnectedToInternet:
return "No network connectivity; cannot reach gateway." return "No network connectivity; cannot reach gateway."
default: default:

View File

@@ -7,7 +7,7 @@ struct CritterStatusLabel: View {
var earBoostActive: Bool var earBoostActive: Bool
var blinkTick: Int var blinkTick: Int
var sendCelebrationTick: Int var sendCelebrationTick: Int
var relayStatus: RelayProcessManager.Status var gatewayStatus: GatewayProcessManager.Status
var animationsEnabled: Bool var animationsEnabled: Bool
var iconState: IconState var iconState: IconState
@@ -98,9 +98,9 @@ struct CritterStatusLabel: View {
} }
} }
if self.relayNeedsAttention { if self.gatewayNeedsAttention {
Circle() Circle()
.fill(self.relayBadgeColor) .fill(self.gatewayBadgeColor)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.offset(x: 4, y: 4) .offset(x: 4, y: 4)
} }
@@ -192,8 +192,8 @@ struct CritterStatusLabel: View {
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0)) self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
} }
private var relayNeedsAttention: Bool { private var gatewayNeedsAttention: Bool {
switch self.relayStatus { switch self.gatewayStatus {
case .failed, .stopped: case .failed, .stopped:
!self.isPaused !self.isPaused
case .starting, .restarting, .running: case .starting, .restarting, .running:
@@ -201,8 +201,8 @@ struct CritterStatusLabel: View {
} }
} }
private var relayBadgeColor: Color { private var gatewayBadgeColor: Color {
switch self.relayStatus { switch self.gatewayStatus {
case .failed: .red case .failed: .red
case .stopped: .orange case .stopped: .orange
default: .clear default: .clear

View File

@@ -85,9 +85,9 @@ enum DebugActions {
static func restartGateway() { static func restartGateway() {
Task { @MainActor in Task { @MainActor in
RelayProcessManager.shared.stop() GatewayProcessManager.shared.stop()
try? await Task.sleep(nanoseconds: 300_000_000) try? await Task.sleep(nanoseconds: 300_000_000)
RelayProcessManager.shared.setActive(true) GatewayProcessManager.shared.setActive(true)
} }
} }

View File

@@ -10,9 +10,9 @@ struct DebugSettings: View {
@State private var modelsCount: Int? @State private var modelsCount: Int?
@State private var modelsLoading = false @State private var modelsLoading = false
@State private var modelsError: String? @State private var modelsError: String?
@ObservedObject private var relayManager = RelayProcessManager.shared @ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared @ObservedObject private var healthStore = HealthStore.shared
@State private var relayRootInput: String = RelayProcessManager.shared.projectRootPath() @State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath @State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String? @State private var sessionStoreSaveError: String?
@State private var debugSendInFlight = false @State private var debugSendInFlight = false
@@ -53,19 +53,19 @@ struct DebugSettings: View {
.textSelection(.enabled) .textSelection(.enabled)
} }
LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) } LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) }
LabeledContent("Relay status") { LabeledContent("Gateway status") {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(self.relayManager.status.label) Text(self.gatewayManager.status.label)
Text("Restarts: \(self.relayManager.restartCount)") Text("Restarts: \(self.gatewayManager.restartCount)")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Relay stdout/stderr") Text("Gateway stdout/stderr")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
ScrollView { ScrollView {
Text(self.relayManager.log.isEmpty ? "" : self.relayManager.log) Text(self.gatewayManager.log.isEmpty ? "" : self.gatewayManager.log)
.font(.caption.monospaced()) .font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled) .textSelection(.enabled)
@@ -77,7 +77,7 @@ struct DebugSettings: View {
Text("Clawdis project root") Text("Clawdis project root")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("Path to clawdis repo", text: self.$relayRootInput) TextField("Path to clawdis repo", text: self.$gatewayRootInput)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.font(.caption.monospaced()) .font(.caption.monospaced())
.onSubmit { self.saveRelayRoot() } .onSubmit { self.saveRelayRoot() }
@@ -86,12 +86,12 @@ struct DebugSettings: View {
Button("Reset") { Button("Reset") {
let def = FileManager.default.homeDirectoryForCurrentUser let def = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdis").path .appendingPathComponent("Projects/clawdis").path
self.relayRootInput = def self.gatewayRootInput = def
self.saveRelayRoot() self.saveRelayRoot()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
Text("Used for pnpm/node fallback and PATH population when launching the relay.") Text("Used for pnpm/node fallback and PATH population when launching the gateway.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -281,7 +281,7 @@ struct DebugSettings: View {
} }
private func saveRelayRoot() { private func saveRelayRoot() {
RelayProcessManager.shared.setProjectRoot(path: self.relayRootInput) GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
} }
private func loadSessionStorePath() { private func loadSessionStorePath() {

View File

@@ -8,9 +8,9 @@ struct GeneralSettings: View {
@State private var cliStatus: String? @State private var cliStatus: String?
@State private var cliInstalled = false @State private var cliInstalled = false
@State private var cliInstallLocation: String? @State private var cliInstallLocation: String?
@State private var relayStatus: RelayEnvironmentStatus = .checking @State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var relayInstallMessage: String? @State private var gatewayInstallMessage: String?
@State private var relayInstalling = false @State private var gatewayInstalling = false
@State private var remoteStatus: RemoteStatus = .idle @State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
@@ -68,7 +68,7 @@ struct GeneralSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.refreshCLIStatus() self.refreshCLIStatus()
self.refreshRelayStatus() self.refreshGatewayStatus()
} }
} }
@@ -92,7 +92,7 @@ struct GeneralSettings: View {
.frame(width: 380, alignment: .leading) .frame(width: 380, alignment: .leading)
if self.state.connectionMode == .local { if self.state.connectionMode == .local {
self.relayInstallerCard self.gatewayInstallerCard
self.healthRow self.healthRow
} }
@@ -248,31 +248,31 @@ struct GeneralSettings: View {
} }
} }
private var relayInstallerCard: some View { private var gatewayInstallerCard: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) { HStack(spacing: 10) {
Circle() Circle()
.fill(self.relayStatusColor) .fill(self.gatewayStatusColor)
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
Text(self.relayStatus.message) Text(self.gatewayStatus.message)
.font(.callout) .font(.callout)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if let relayVersion = self.relayStatus.relayVersion, if let gatewayVersion = self.gatewayStatus.gatewayVersion,
let required = self.relayStatus.requiredRelay, let required = self.gatewayStatus.requiredGateway,
relayVersion != required gatewayVersion != required
{ {
Text("Installed: \(relayVersion) · Required: \(required)") Text("Installed: \(gatewayVersion) · Required: \(required)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let relayVersion = self.relayStatus.relayVersion { } else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
Text("Relay \(relayVersion) detected") Text("Gateway \(gatewayVersion) detected")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let node = self.relayStatus.nodeVersion { if let node = self.gatewayStatus.nodeVersion {
Text("Node \(node)") Text("Node \(node)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -280,24 +280,24 @@ struct GeneralSettings: View {
HStack(spacing: 10) { HStack(spacing: 10) {
Button { Button {
Task { await self.installRelay() } Task { await self.installGateway() }
} label: { } label: {
if self.relayInstalling { if self.gatewayInstalling {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
} else { } else {
Text("Install/Update relay") Text("Install/Update gateway")
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.relayInstalling) .disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshRelayStatus() } Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.relayInstalling) .disabled(self.gatewayInstalling)
} }
Text(self Text(self
.relayInstallMessage ?? .gatewayInstallMessage ??
"Installs the global \"clawdis\" package and expects the gateway on port 18789.") "Installs the global \"clawdis\" package and expects the gateway on port 18789.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -326,27 +326,27 @@ struct GeneralSettings: View {
self.cliInstalled = installLocation != nil self.cliInstalled = installLocation != nil
} }
private func refreshRelayStatus() { private func refreshGatewayStatus() {
self.relayStatus = RelayEnvironment.check() self.gatewayStatus = GatewayEnvironment.check()
} }
private func installRelay() async { private func installGateway() async {
guard !self.relayInstalling else { return } guard !self.gatewayInstalling else { return }
self.relayInstalling = true self.gatewayInstalling = true
defer { self.relayInstalling = false } defer { self.gatewayInstalling = false }
self.relayInstallMessage = nil self.gatewayInstallMessage = nil
let expected = RelayEnvironment.expectedRelayVersion() let expected = GatewayEnvironment.expectedGatewayVersion()
await RelayEnvironment.installGlobal(version: expected) { message in await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.relayInstallMessage = message } Task { @MainActor in self.gatewayInstallMessage = message }
} }
self.refreshRelayStatus() self.refreshGatewayStatus()
} }
private var relayStatusColor: Color { private var gatewayStatusColor: Color {
switch self.relayStatus.kind { switch self.gatewayStatus.kind {
case .ok: .green case .ok: .green
case .checking: .secondary case .checking: .secondary
case .missingNode, .missingRelay, .incompatible, .error: .orange case .missingNode, .missingGateway, .incompatible, .error: .orange
} }
} }

View File

@@ -157,7 +157,7 @@ final class HealthStore: ObservableObject {
return "The gateway control port (127.0.0.1:18789) isnt listening — restart Clawdis to bring it back." return "The gateway control port (127.0.0.1:18789) isnt listening — restart Clawdis to bring it back."
} }
if lower.contains("timeout") { if lower.contains("timeout") {
return "Timed out waiting for the control server; the relay may be crashed or still starting." return "Timed out waiting for the control server; the gateway may be crashed or still starting."
} }
return error return error
} }

View File

@@ -129,7 +129,7 @@ final class InstancesStore: ObservableObject {
self.logger.error("instances fetch returned empty payload") self.logger.error("instances fetch returned empty payload")
self.instances = [self.localFallbackInstance(reason: "no presence payload")] self.instances = [self.localFallbackInstance(reason: "no presence payload")]
self.lastError = nil self.lastError = nil
self.statusMessage = "No presence payload from relay; showing local fallback + health probe." self.statusMessage = "No presence payload from gateway; showing local fallback + health probe."
await self.probeHealthIfNeeded(reason: "no payload") await self.probeHealthIfNeeded(reason: "no payload")
return return
} }
@@ -255,7 +255,7 @@ final class InstancesStore: ObservableObject {
guard let snap = decodeHealthSnapshot(from: data) else { return } guard let snap = decodeHealthSnapshot(from: data) else { return }
let entry = InstanceInfo( let entry = InstanceInfo(
id: "health-\(snap.ts)", id: "health-\(snap.ts)",
host: "relay (health)", host: "gateway (health)",
ip: nil, ip: nil,
version: nil, version: nil,
lastInputSeconds: nil, lastInputSeconds: nil,
@@ -317,14 +317,14 @@ extension InstancesStore {
text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3", text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3",
ts: Date().timeIntervalSince1970 * 1000), ts: Date().timeIntervalSince1970 * 1000),
InstanceInfo( InstanceInfo(
id: "relay", id: "gateway",
host: "relay", host: "gateway",
ip: "100.64.0.2", ip: "100.64.0.2",
version: "1.2.3", version: "1.2.3",
lastInputSeconds: 45, lastInputSeconds: 45,
mode: "remote", mode: "remote",
reason: "preview", reason: "preview",
text: "Relay node · tunnel ok", text: "Gateway node · tunnel ok",
ts: Date().timeIntervalSince1970 * 1000 - 45000), ts: Date().timeIntervalSince1970 * 1000 - 45000),
]) -> InstancesStore { ]) -> InstancesStore {
let store = InstancesStore(isPreview: true) let store = InstancesStore(isPreview: true)

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct ClawdisApp: App { struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState @StateObject private var state: AppState
@StateObject private var relayManager = RelayProcessManager.shared @StateObject private var gatewayManager = GatewayProcessManager.shared
@StateObject private var activityStore = WorkActivityStore.shared @StateObject private var activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem? @State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false @State private var isMenuPresented = false
@@ -27,7 +27,7 @@ struct ClawdisApp: App {
earBoostActive: self.state.earBoostActive, earBoostActive: self.state.earBoostActive,
blinkTick: self.state.blinkTick, blinkTick: self.state.blinkTick,
sendCelebrationTick: self.state.sendCelebrationTick, sendCelebrationTick: self.state.sendCelebrationTick,
relayStatus: self.relayManager.status, gatewayStatus: self.gatewayManager.status,
animationsEnabled: self.state.iconAnimationsEnabled, animationsEnabled: self.state.iconAnimationsEnabled,
iconState: self.effectiveIconState) iconState: self.effectiveIconState)
} }
@@ -38,7 +38,7 @@ struct ClawdisApp: App {
} }
.onChange(of: self.state.isPaused) { _, paused in .onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused) self.applyStatusItemAppearance(paused: paused)
self.relayManager.setActive(!paused) self.gatewayManager.setActive(!paused)
} }
Settings { Settings {
@@ -86,7 +86,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
self.state = AppStateStore.shared self.state = AppStateStore.shared
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
if let state { if let state {
RelayProcessManager.shared.setActive(!state.isPaused) GatewayProcessManager.shared.setActive(!state.isPaused)
} }
Task { Task {
await ControlChannel.shared.configure() await ControlChannel.shared.configure()
@@ -104,7 +104,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
RelayProcessManager.shared.stop() GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop() PresenceReporter.shared.stop()
WebChatManager.shared.close() WebChatManager.shared.close()
Task { await AgentRPC.shared.shutdown() } Task { await AgentRPC.shared.shutdown() }

View File

@@ -7,7 +7,7 @@ import SwiftUI
struct MenuContent: View { struct MenuContent: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
let updater: UpdaterProviding? let updater: UpdaterProviding?
@ObservedObject private var relayManager = RelayProcessManager.shared @ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared @ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared @ObservedObject private var heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared @ObservedObject private var controlChannel = ControlChannel.shared

View File

@@ -46,9 +46,9 @@ struct OnboardingView: View {
@State private var monitoringPermissions = false @State private var monitoringPermissions = false
@State private var cliInstalled = false @State private var cliInstalled = false
@State private var cliInstallLocation: String? @State private var cliInstallLocation: String?
@State private var relayStatus: RelayEnvironmentStatus = .checking @State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var relayInstalling = false @State private var gatewayInstalling = false
@State private var relayInstallMessage: String? @State private var gatewayInstallMessage: String?
@ObservedObject private var state = AppStateStore.shared @ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared @ObservedObject private var permissionMonitor = PermissionMonitor.shared
@@ -70,7 +70,7 @@ struct OnboardingView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
self.welcomePage().frame(width: self.pageWidth) self.welcomePage().frame(width: self.pageWidth)
self.connectionPage().frame(width: self.pageWidth) self.connectionPage().frame(width: self.pageWidth)
self.relayPage().frame(width: self.pageWidth) self.gatewayPage().frame(width: self.pageWidth)
self.permissionsPage().frame(width: self.pageWidth) self.permissionsPage().frame(width: self.pageWidth)
self.cliPage().frame(width: self.pageWidth) self.cliPage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth) self.whatsappPage().frame(width: self.pageWidth)
@@ -100,7 +100,7 @@ struct OnboardingView: View {
.task { .task {
await self.refreshPerms() await self.refreshPerms()
self.refreshCLIStatus() self.refreshCLIStatus()
self.refreshRelayStatus() self.refreshGatewayStatus()
} }
} }
@@ -177,9 +177,9 @@ struct OnboardingView: View {
} }
} }
private func relayPage() -> some View { private func gatewayPage() -> some View {
self.onboardingPage { self.onboardingPage {
Text("Install the relay") Text("Install the gateway")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text( Text(
"Clawdis now runs the WebSocket gateway from the global \"clawdis\" package. Install/update it here and well check Node for you.") "Clawdis now runs the WebSocket gateway from the global \"clawdis\" package. Install/update it here and well check Node for you.")
@@ -193,27 +193,27 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) { HStack(spacing: 10) {
Circle() Circle()
.fill(self.relayStatusColor) .fill(self.gatewayStatusColor)
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
Text(self.relayStatus.message) Text(self.gatewayStatus.message)
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if let relayVersion = self.relayStatus.relayVersion, if let gatewayVersion = self.gatewayStatus.gatewayVersion,
let required = self.relayStatus.requiredRelay, let required = self.gatewayStatus.requiredGateway,
relayVersion != required gatewayVersion != required
{ {
Text("Installed: \(relayVersion) · Required: \(required)") Text("Installed: \(gatewayVersion) · Required: \(required)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let relayVersion = self.relayStatus.relayVersion { } else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
Text("Relay \(relayVersion) detected") Text("Gateway \(gatewayVersion) detected")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let node = self.relayStatus.nodeVersion { if let node = self.gatewayStatus.nodeVersion {
Text("Node \(node)") Text("Node \(node)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -221,24 +221,24 @@ struct OnboardingView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button { Button {
Task { await self.installRelay() } Task { await self.installGateway() }
} label: { } label: {
if self.relayInstalling { if self.gatewayInstalling {
ProgressView() ProgressView()
} else { } else {
Text("Install / Update relay") Text("Install / Update gateway")
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.relayInstalling) .disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshRelayStatus() } Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.relayInstalling) .disabled(self.gatewayInstalling)
} }
if let relayInstallMessage { if let gatewayInstallMessage {
Text(relayInstallMessage) Text(gatewayInstallMessage)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
@@ -350,7 +350,7 @@ struct OnboardingView: View {
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text( Text(
""" """
Run `clawdis login` where the relay runs (local if local mode, remote if remote). Run `clawdis login` where the gateway runs (local if local mode, remote if remote).
Scan the QR to pair your account. Scan the QR to pair your account.
""") """)
.font(.body) .font(.body)
@@ -368,7 +368,7 @@ struct OnboardingView: View {
title: "Run `clawdis login --verbose`", title: "Run `clawdis login --verbose`",
subtitle: """ subtitle: """
Scan the QR code with WhatsApp on your phone. Scan the QR code with WhatsApp on your phone.
We only use your personal session; no cloud relay involved. We only use your personal session; no cloud gateway involved.
""", """,
systemImage: "qrcode.viewfinder") systemImage: "qrcode.viewfinder")
self.featureRow( self.featureRow(
@@ -568,27 +568,27 @@ struct OnboardingView: View {
self.cliInstalled = installLocation != nil self.cliInstalled = installLocation != nil
} }
private func refreshRelayStatus() { private func refreshGatewayStatus() {
self.relayStatus = RelayEnvironment.check() self.gatewayStatus = GatewayEnvironment.check()
} }
private func installRelay() async { private func installGateway() async {
guard !self.relayInstalling else { return } guard !self.gatewayInstalling else { return }
self.relayInstalling = true self.gatewayInstalling = true
defer { self.relayInstalling = false } defer { self.gatewayInstalling = false }
self.relayInstallMessage = nil self.gatewayInstallMessage = nil
let expected = RelayEnvironment.expectedRelayVersion() let expected = GatewayEnvironment.expectedGatewayVersion()
await RelayEnvironment.installGlobal(version: expected) { message in await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.relayInstallMessage = message } Task { @MainActor in self.gatewayInstallMessage = message }
} }
self.refreshRelayStatus() self.refreshGatewayStatus()
} }
private var relayStatusColor: Color { private var gatewayStatusColor: Color {
switch self.relayStatus.kind { switch self.gatewayStatus.kind {
case .ok: .green case .ok: .green
case .checking: .secondary case .checking: .secondary
case .missingNode, .missingRelay, .incompatible, .error: .orange case .missingNode, .missingGateway, .incompatible, .error: .orange
} }
} }

View File

@@ -1,196 +0,0 @@
import ClawdisIPC
import Foundation
// Lightweight SemVer helper (major.minor.patch only) for relay compatibility checks.
struct Semver: Comparable, CustomStringConvertible, Sendable {
let major: Int
let minor: Int
let patch: Int
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
static func < (lhs: Semver, rhs: Semver) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
return lhs.patch < rhs.patch
}
static func parse(_ raw: String?) -> Semver? {
guard let raw, !raw.isEmpty else { return nil }
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
let parts = cleaned.split(separator: ".")
guard parts.count >= 3,
let major = Int(parts[0]),
let minor = Int(parts[1])
else { return nil }
let patch = Int(parts[2]) ?? 0
return Semver(major: major, minor: minor, patch: patch)
}
func compatible(with required: Semver) -> Bool {
// Same major and not older than required.
self.major == required.major && self >= required
}
}
enum RelayEnvironmentKind: Equatable {
case checking
case ok
case missingNode
case missingRelay
case incompatible(found: String, required: String)
case error(String)
}
struct RelayEnvironmentStatus: Equatable {
let kind: RelayEnvironmentKind
let nodeVersion: String?
let relayVersion: String?
let requiredRelay: String?
let message: String
static var checking: Self {
.init(kind: .checking, nodeVersion: nil, relayVersion: nil, requiredRelay: nil, message: "Checking…")
}
}
struct RelayCommandResolution {
let status: RelayEnvironmentStatus
let command: [String]?
}
enum RelayEnvironment {
static func gatewayPort() -> Int {
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789
}
static func expectedRelayVersion() -> Semver? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
return Semver.parse(bundleVersion)
}
static func check() -> RelayEnvironmentStatus {
let expected = self.expectedRelayVersion()
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.relayEntrypoint(in: projectRoot)
switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) {
case let .failure(err):
return RelayEnvironmentStatus(
kind: .missingNode,
nodeVersion: nil,
relayVersion: nil,
requiredRelay: expected?.description,
message: RuntimeLocator.describeFailure(err))
case let .success(runtime):
let relayBin = CommandResolver.clawdisExecutable()
if relayBin == nil, projectEntrypoint == nil {
return RelayEnvironmentStatus(
kind: .missingRelay,
nodeVersion: runtime.version.description,
relayVersion: nil,
requiredRelay: expected?.description,
message: "clawdis CLI not found in PATH; install the global package.")
}
let installedRelay = relayBin.flatMap { self.readRelayVersion(binary: $0) }
?? self.readLocalRelayVersion(projectRoot: projectRoot)
if let expected, let installed = installedRelay, !installed.compatible(with: expected) {
return RelayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: runtime.version.description,
relayVersion: installed.description,
requiredRelay: expected.description,
message: "Relay version \(installed.description) is incompatible with app \(expected.description); install/update the global package.")
}
let relayLabel = relayBin != nil ? "global" : "local"
let relayVersionText = installedRelay?.description ?? "unknown"
return RelayEnvironmentStatus(
kind: .ok,
nodeVersion: runtime.version.description,
relayVersion: relayVersionText,
requiredRelay: expected?.description,
message: "Node \(runtime.version.description); relay \(relayVersionText) (\(relayLabel))")
}
}
static func resolveGatewayCommand() -> RelayCommandResolution {
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.relayEntrypoint(in: projectRoot)
let status = self.check()
let relayBin = CommandResolver.clawdisExecutable()
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
guard case .ok = status.kind else {
return RelayCommandResolution(status: status, command: nil)
}
let port = self.gatewayPort()
if let relayBin {
let cmd = [relayBin, "gateway", "--port", "\(port)"]
return RelayCommandResolution(status: status, command: cmd)
}
if let entry = projectEntrypoint,
case let .success(resolvedRuntime) = runtime
{
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
return RelayCommandResolution(status: status, command: cmd)
}
return RelayCommandResolution(status: status, command: nil)
}
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let target = version?.description ?? "latest"
let pnpm = CommandResolver.findExecutable(named: "pnpm") ?? "pnpm"
let cmd = [pnpm, "add", "-g", "clawdis@\(target)"]
statusHandler("Installing clawdis@\(target) via pnpm…")
let response = await ShellExecutor.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
if response.ok {
statusHandler("Installed clawdis@\(target)")
} else {
let detail = response.message ?? "install failed"
statusHandler("Install failed: \(detail)")
}
}
// MARK: - Internals
private static func readRelayVersion(binary: String) -> Semver? {
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["--version"]
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
} catch {
return nil
}
}
private static func readLocalRelayVersion(projectRoot: URL) -> Semver? {
let pkg = projectRoot.appendingPathComponent("package.json")
guard let data = try? Data(contentsOf: pkg) else { return nil }
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let version = json["version"] as? String
else { return nil }
return Semver.parse(version)
}
}

View File

@@ -1,230 +0,0 @@
import Foundation
import OSLog
import Subprocess
#if canImport(Darwin)
import Darwin
#endif
#if canImport(System)
import System
#else
import SystemPackage
#endif
@MainActor
final class RelayProcessManager: ObservableObject {
static let shared = RelayProcessManager()
enum Status: Equatable {
case stopped
case starting
case running(pid: Int32)
case restarting
case failed(String)
var label: String {
switch self {
case .stopped: "Stopped"
case .starting: "Starting…"
case let .running(pid): "Running (pid \(pid))"
case .restarting: "Restarting…"
case let .failed(reason): "Failed: \(reason)"
}
}
}
@Published private(set) var status: Status = .stopped
@Published private(set) var log: String = ""
@Published private(set) var restartCount: Int = 0
@Published private(set) var environmentStatus: RelayEnvironmentStatus = .checking
private var execution: Execution?
private var desiredActive = false
private var stopping = false
private var recentCrashes: [Date] = []
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "relay")
private let logLimit = 20000 // characters to keep in-memory
private let maxCrashes = 3
private let crashWindow: TimeInterval = 120 // seconds
func setActive(_ active: Bool) {
self.desiredActive = active
self.refreshEnvironmentStatus()
if active {
self.startIfNeeded()
} else {
self.stop()
}
}
func startIfNeeded() {
guard self.execution == nil, self.desiredActive else { return }
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; giving up")
return
}
self.status = self.status == .restarting ? .restarting : .starting
Task.detached { [weak self] in
guard let self else { return }
await self.spawnRelay()
}
}
func stop() {
self.desiredActive = false
self.stopping = true
guard let execution else {
self.status = .stopped
return
}
self.status = .stopped
Task {
await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))])
}
self.execution = nil
}
func refreshEnvironmentStatus() {
self.environmentStatus = RelayEnvironment.check()
}
// MARK: - Internals
private func spawnRelay() async {
let resolution = RelayEnvironment.resolveGatewayCommand()
await MainActor.run { self.environmentStatus = resolution.status }
guard let command = resolution.command else {
await MainActor.run {
self.status = .failed(resolution.status.message)
}
return
}
let cwd = self.defaultProjectRoot().path
self.appendLog("[relay] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
do {
let result = try await run(
.name(command.first ?? "clawdis"),
arguments: Arguments(Array(command.dropFirst())),
environment: self.makeEnvironment(),
workingDirectory: FilePath(cwd))
{ execution, stdin, stdout, stderr in
self.didStart(execution)
// Consume stdout/stderr eagerly so the relay can't block on full pipes.
async let out: Void = self.stream(output: stdout, label: "stdout")
async let err: Void = self.stream(output: stderr, label: "stderr")
try await stdin.finish()
await out
await err
}
await self.handleTermination(status: result.terminationStatus)
} catch {
await self.handleError(error)
}
}
private func didStart(_ execution: Execution) {
self.execution = execution
self.stopping = false
self.status = .running(pid: execution.processIdentifier.value)
self.logger.info("relay started pid \(execution.processIdentifier.value)")
}
private func handleTermination(status: TerminationStatus) async {
let code: Int32 = switch status {
case let .exited(exitCode): exitCode
case let .unhandledException(sig): -Int32(sig)
}
self.execution = nil
if self.stopping || !self.desiredActive {
self.status = .stopped
self.stopping = false
return
}
self.recentCrashes.append(Date())
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
self.restartCount += 1
self.appendLog("[relay] exited (\(code)).\n")
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; stopped auto-restart.")
self.logger.error("relay crash loop detected; giving up")
return
}
self.status = .restarting
self.logger.warning("relay crashed (code \(code)); restarting")
// Slight backoff to avoid hammering the system in case of immediate crash-on-start.
try? await Task.sleep(nanoseconds: 750_000_000)
self.startIfNeeded()
}
private func handleError(_ error: any Error) async {
self.execution = nil
var message = error.localizedDescription
if let sp = error as? SubprocessError {
message = "SubprocessError \(sp.code.value): \(sp)"
}
self.appendLog("[relay] failed: \(message)\n")
self.logger.error("relay failed: \(message, privacy: .public)")
if self.desiredActive, !self.shouldGiveUpAfterCrashes() {
self.status = .restarting
self.recentCrashes.append(Date())
self.startIfNeeded()
} else {
self.status = .failed(error.localizedDescription)
}
}
private func shouldGiveUpAfterCrashes() -> Bool {
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
return self.recentCrashes.count >= self.maxCrashes
}
private func stream(output: AsyncBufferSequence, label: String) async {
do {
for try await line in output.lines() {
await MainActor.run {
self.appendLog(line + "\n")
}
}
} catch {
await MainActor.run {
self.appendLog("[relay \(label)] stream error: \(error.localizedDescription)\n")
}
}
}
private func appendLog(_ chunk: String) {
self.log.append(chunk)
if self.log.count > self.logLimit {
self.log = String(self.log.suffix(self.logLimit))
}
}
private func makeEnvironment() -> Environment {
let merged = CommandResolver.preferredPaths().joined(separator: ":")
return .inherit.updating([
"PATH": merged,
"PNPM_HOME": FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/pnpm").path,
"CLAWDIS_PROJECT_ROOT": CommandResolver.projectRoot().path,
])
}
private func defaultProjectRoot() -> URL {
CommandResolver.projectRoot()
}
func setProjectRoot(path: String) {
CommandResolver.setProjectRoot(path)
}
func projectRootPath() -> String {
CommandResolver.projectRootPath()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -204,10 +204,10 @@ enum CLIInstaller {
} }
enum CommandResolver { enum CommandResolver {
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath" private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
private static let helperName = "clawdis" private static let helperName = "clawdis"
static func relayEntrypoint(in root: URL) -> String? { static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry } if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
let binEntry = root.appendingPathComponent("bin/clawdis.js").path let binEntry = root.appendingPathComponent("bin/clawdis.js").path
@@ -326,7 +326,7 @@ enum CommandResolver {
return [clawdisPath, subcommand] + extraArgs return [clawdisPath, subcommand] + extraArgs
} }
if let entry = self.relayEntrypoint(in: self.projectRoot()) { if let entry = self.gatewayEntrypoint(in: self.projectRoot()) {
return self.makeRuntimeCommand( return self.makeRuntimeCommand(
runtime: runtime, runtime: runtime,
entrypoint: entry, entrypoint: entry,

View File

@@ -26,8 +26,8 @@ import Testing
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis") let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
try self.makeExec(at: clawdisPath) try self.makeExec(at: clawdisPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "relay") let cmd = CommandResolver.clawdisCommand(subcommand: "gateway")
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"])) #expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "gateway"]))
} }
@Test func fallsBackToNodeAndScript() async throws { @Test func fallsBackToNodeAndScript() async throws {

View File

@@ -31,7 +31,7 @@ Run the Node-based Clawdis/clawdis gateway as a direct child of the LSUIElement
- If you ever embed Node that *must* touch TCC, wrap that call in a tiny signed helper target inside the app bundle and have Node exec that helper instead of calling the API directly. - If you ever embed Node that *must* touch TCC, wrap that call in a tiny signed helper target inside the app bundle and have Node exec that helper instead of calling the API directly.
## Process manager design (Swift Subprocess) ## Process manager design (Swift Subprocess)
- Add a small `RelayProcessManager` (Swift) that owns: - Add a small `GatewayProcessManager` (Swift) that owns:
- `execution: Execution?` from `Swift Subprocess` to track the child. - `execution: Execution?` from `Swift Subprocess` to track the child.
- `start(config)` called when “Clawdis Active” flips ON: - `start(config)` called when “Clawdis Active” flips ON:
- binary: host Node running the bundled gateway under `Clawdis.app/Contents/Resources/Gateway/` - binary: host Node running the bundled gateway under `Clawdis.app/Contents/Resources/Gateway/`
@@ -41,8 +41,8 @@ Run the Node-based Clawdis/clawdis gateway as a direct child of the LSUIElement
- restart: optional linear/backoff restart if exit was non-zero and Active is still true - restart: optional linear/backoff restart if exit was non-zero and Active is still true
- `stop()` called when Active flips OFF or app terminates: cancel the execution and `waitUntilExit`. - `stop()` called when Active flips OFF or app terminates: cancel the execution and `waitUntilExit`.
- Wire SwiftUI toggle: - Wire SwiftUI toggle:
- ON: `RelayProcessManager.start(...)` - ON: `GatewayProcessManager.start(...)`
- OFF: `RelayProcessManager.stop()` (no launchctl calls in this mode) - OFF: `GatewayProcessManager.stop()` (no launchctl calls in this mode)
- Keep the existing `LaunchdManager` around so we can switch back if needed; the toggle can choose between launchd or child mode with a flag if we want both. - Keep the existing `LaunchdManager` around so we can switch back if needed; the toggle can choose between launchd or child mode with a flag if we want both.
## Packaging and signing ## Packaging and signing
@@ -67,5 +67,5 @@ Run the Node-based Clawdis/clawdis gateway as a direct child of the LSUIElement
## Decision snapshot (current recommendation) ## Decision snapshot (current recommendation)
- Keep all TCC surfaces in the Swift app/XPC. - Keep all TCC surfaces in the Swift app/XPC.
- Implement `RelayProcessManager` with Swift Subprocess to start/stop the gateway on the “Clawdis Active” toggle. - Implement `GatewayProcessManager` with Swift Subprocess to start/stop the gateway on the “Clawdis Active” toggle.
- Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable. - Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable.

View File

@@ -1,37 +0,0 @@
---
summary: "Troubleshooting guide for the web gateway/Baileys relay"
read_when:
- Diagnosing web relay socket or login issues
---
# Web Gateway Troubleshooting (Nov 26, 2025)
## Symptoms & quick fixes
- **Stream Errored / Conflict / status 409515:** WhatsApp closed the socket because another session is active or creds went stale. Run `clawdis logout` then `clawdis login --provider web` and restart the gateway.
- **Logged out:** Console prints “session logged out”; re-link with `clawdis login --provider web`.
- **Repeated retries then exit:** Reconnects are capped (default 12 attempts). Tune with `--web-retries`, `--web-retry-initial`, `--web-retry-max`, or config `web.reconnect`.
- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection.
- **Fast nuke:** From an allowed WhatsApp sender you can send `/restart` to kick `com.steipete.clawdis` via launchd; wait a few seconds for it to relink.
## Helpful commands
- Start gateway web-only: `pnpm clawdis gateway --provider web --verbose`
- Show who is linked: `pnpm clawdis gateway --provider web --verbose` (first line prints the linked E.164)
- Logout (clear creds): `pnpm clawdis logout`
- Relink: `pnpm clawdis login --provider web`
- Tail logs (default): `tail -f /tmp/clawdis/clawdis.log`
## Reading the logs
- `web-reconnect`: close reasons, retry/backoff, max-attempt exit.
- `web-heartbeat`: connectionId, messagesHandled, authAgeMs, uptimeMs (every 60s by default).
- `web-auto-reply`: inbound/outbound message records with correlation IDs.
## When to tweak knobs
- High churn networks: increase `web.reconnect.maxAttempts` or `--web-retries`.
- Slow links: raise `--web-retry-max` to give more headroom before bailing.
- Chatty monitors: increase `--web-heartbeat` interval if log volume is high.
## If it keeps failing
1) `clawdis logout``clawdis login --provider web` (fresh QR link).
2) Ensure no other device/browser is using the same WA Web session.
3) Check WhatsApp mobile app is online and not in low-power mode.
4) If status is 515, let the client restart once after pairing (already handled automatically).
5) Capture the last `web-reconnect` entry and the status code before escalating.

View File

@@ -62,7 +62,7 @@ LOG FLOW ARCHITECTURE:
LOG CATEGORIES (examples): LOG CATEGORIES (examples):
• voicewake - Voice wake detection/test harness • voicewake - Voice wake detection/test harness
relay - Relay process manager gateway - Gateway process manager
• xpc - XPC service calls • xpc - XPC service calls
• notifications - Notification helper • notifications - Notification helper
• screenshot - Screenshotter • screenshot - Screenshotter

View File

@@ -82,10 +82,10 @@ if [ -f "$APP_BUNDLE/Contents/MacOS/ClawdisCLI" ]; then
echo "Signing CLI helper"; sign_item "$APP_BUNDLE/Contents/MacOS/ClawdisCLI" echo "Signing CLI helper"; sign_item "$APP_BUNDLE/Contents/MacOS/ClawdisCLI"
fi fi
# Sign bundled relay payload (native addons, libvips dylibs) # Sign bundled gateway payload (native addons, libvips dylibs)
if [ -d "$APP_BUNDLE/Contents/Resources/Relay" ]; then if [ -d "$APP_BUNDLE/Contents/Resources/Relay" ]; then
find "$APP_BUNDLE/Contents/Resources/Relay" -type f \( -name "*.node" -o -name "*.dylib" \) -print0 | while IFS= read -r -d '' f; do find "$APP_BUNDLE/Contents/Resources/Relay" -type f \( -name "*.node" -o -name "*.dylib" \) -print0 | while IFS= read -r -d '' f; do
echo "Signing relay payload: $f"; sign_item "$f" echo "Signing gateway payload: $f"; sign_item "$f"
done done
fi fi

View File

@@ -118,8 +118,8 @@ rm -rf "$APP_ROOT/Contents/Resources/WebChat/vendor/pdfjs-dist/legacy"
RELAY_DIR="$APP_ROOT/Contents/Resources/Relay" RELAY_DIR="$APP_ROOT/Contents/Resources/Relay"
if [[ "${SKIP_RELAY_PACKAGE:-0}" != "1" ]]; then if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
echo "🧰 Staging relay payload (dist + node_modules; expects system Node ≥22)" echo "🧰 Staging gateway payload (dist + node_modules; expects system Node ≥22)"
rsync -a --delete --exclude "Clawdis.app" "$ROOT_DIR/dist/" "$RELAY_DIR/dist/" rsync -a --delete --exclude "Clawdis.app" "$ROOT_DIR/dist/" "$RELAY_DIR/dist/"
cp "$ROOT_DIR/package.json" "$RELAY_DIR/" cp "$ROOT_DIR/package.json" "$RELAY_DIR/"
cp "$ROOT_DIR/pnpm-lock.yaml" "$RELAY_DIR/" cp "$ROOT_DIR/pnpm-lock.yaml" "$RELAY_DIR/"
@@ -170,7 +170,7 @@ if [[ "${SKIP_RELAY_PACKAGE:-0}" != "1" ]]; then
"$RELAY_DIR/node_modules"/{vite,rolldown,vitest,ts-node,ts-node-dev,typescript,@types,docx-preview,jszip,lucide,ollama} 2>/dev/null || true "$RELAY_DIR/node_modules"/{vite,rolldown,vitest,ts-node,ts-node-dev,typescript,@types,docx-preview,jszip,lucide,ollama} 2>/dev/null || true
rm -rf "$TMP_DEPLOY" rm -rf "$TMP_DEPLOY"
else else
echo "🧰 Skipping relay payload packaging (SKIP_RELAY_PACKAGE=1)" echo "🧰 Skipping gateway payload packaging (SKIP_GATEWAY_PACKAGE=1)"
fi fi
if [ -f "$CLI_BIN" ]; then if [ -f "$CLI_BIN" ]; then

View File

@@ -59,8 +59,8 @@ run_step "bundle webchat" bash -lc "cd '${ROOT_DIR}' && pnpm webchat:bundle"
run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true" run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true"
run_step "swift build" bash -lc "cd '${ROOT_DIR}/apps/macos' && swift build -q --product Clawdis" run_step "swift build" bash -lc "cd '${ROOT_DIR}/apps/macos' && swift build -q --product Clawdis"
# 3) Package app (skip TS + relay staging; rely on global/custom install for relay JS). # 3) Package app (skip TS + gateway staging; rely on global/custom install for gateway JS).
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=1 SKIP_RELAY_PACKAGE=1 '${ROOT_DIR}/scripts/package-mac-app.sh'" run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=1 SKIP_GATEWAY_PACKAGE=1 '${ROOT_DIR}/scripts/package-mac-app.sh'"
choose_app_bundle() { choose_app_bundle() {
if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then

View File

@@ -8,7 +8,7 @@ import type {
} from "@mariozechner/pi-ai"; } from "@mariozechner/pi-ai";
import { piSpec } from "../agents/pi.js"; import { piSpec } from "../agents/pi.js";
import type { AgentMeta, AgentToolResult } from "../agents/types.js"; import type { AgentMeta, AgentToolResult } from "../agents/types.js";
import type { WarelayConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { isVerbose, logVerbose } from "../globals.js"; import { isVerbose, logVerbose } from "../globals.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { logError } from "../logger.js"; import { logError } from "../logger.js";
@@ -141,7 +141,7 @@ function extractAssistantTextLoosely(raw: string): string | undefined {
return last ? last.replace(/\\n/g, "\n").trim() : undefined; return last ? last.replace(/\\n/g, "\n").trim() : undefined;
} }
type CommandReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"] & { type CommandReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"] & {
mode: "command"; mode: "command";
}; };

View File

@@ -3,7 +3,7 @@ import crypto from "node:crypto";
import { lookupContextTokens } from "../agents/context.js"; import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
import { resolveBundledPiBinary } from "../agents/pi-path.js"; import { resolveBundledPiBinary } from "../agents/pi-path.js";
import { loadConfig, type WarelayConfig } from "../config/config.js"; import { loadConfig, type ClawdisConfig } from "../config/config.js";
import { import {
DEFAULT_IDLE_MINUTES, DEFAULT_IDLE_MINUTES,
DEFAULT_RESET_TRIGGER, DEFAULT_RESET_TRIGGER,
@@ -15,7 +15,7 @@ import {
} from "../config/sessions.js"; } from "../config/sessions.js";
import { isVerbose, logVerbose } from "../globals.js"; import { isVerbose, logVerbose } from "../globals.js";
import { buildProviderSummary } from "../infra/provider-summary.js"; import { buildProviderSummary } from "../infra/provider-summary.js";
import { triggerWarelayRestart } from "../infra/restart.js"; import { triggerClawdisRestart } from "../infra/restart.js";
import { drainSystemEvents } from "../infra/system-events.js"; import { drainSystemEvents } from "../infra/system-events.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js";
@@ -42,7 +42,7 @@ const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
const ABORT_MEMORY = new Map<string, boolean>(); const ABORT_MEMORY = new Map<string, boolean>();
const SYSTEM_MARK = "⚙️"; const SYSTEM_MARK = "⚙️";
type ReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"]; type ReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"];
export function extractThinkDirective(body?: string): { export function extractThinkDirective(body?: string): {
cleaned: string; cleaned: string;
@@ -112,7 +112,7 @@ function stripStructuralPrefixes(text: string): string {
function stripMentions( function stripMentions(
text: string, text: string,
ctx: MsgContext, ctx: MsgContext,
cfg: WarelayConfig | undefined, cfg: ClawdisConfig | undefined,
): string { ): string {
let result = text; let result = text;
const patterns = cfg?.inbound?.groupChat?.mentionPatterns ?? []; const patterns = cfg?.inbound?.groupChat?.mentionPatterns ?? [];
@@ -161,7 +161,7 @@ function makeDefaultPiReply(): ReplyConfig {
export async function getReplyFromConfig( export async function getReplyFromConfig(
ctx: MsgContext, ctx: MsgContext,
opts?: GetReplyOptions, opts?: GetReplyOptions,
configOverride?: WarelayConfig, configOverride?: ClawdisConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> { ): Promise<ReplyPayload | ReplyPayload[] | undefined> {
// Choose reply from config: static text or external command stdout. // Choose reply from config: static text or external command stdout.
const cfg = configOverride ?? loadConfig(); const cfg = configOverride ?? loadConfig();
@@ -503,7 +503,7 @@ export async function getReplyFromConfig(
rawBodyNormalized === "restart" || rawBodyNormalized === "restart" ||
rawBodyNormalized.startsWith("/restart ") rawBodyNormalized.startsWith("/restart ")
) { ) {
triggerWarelayRestart(); triggerClawdisRestart();
cleanupTyping(); cleanupTyping();
return { return {
text: "⚙️ Restarting clawdis via launchctl; give me a few seconds to come back online.", text: "⚙️ Restarting clawdis via launchctl; give me a few seconds to come back online.",

View File

@@ -5,11 +5,11 @@ import path from "node:path";
import { lookupContextTokens } from "../agents/context.js"; import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
import type { WarelayConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import type { SessionEntry, SessionScope } from "../config/sessions.js"; import type { SessionEntry, SessionScope } from "../config/sessions.js";
import type { ThinkLevel, VerboseLevel } from "./thinking.js"; import type { ThinkLevel, VerboseLevel } from "./thinking.js";
type ReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"]; type ReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"];
type StatusArgs = { type StatusArgs = {
reply: ReplyConfig; reply: ReplyConfig;

View File

@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { WarelayConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { isVerbose, logVerbose } from "../globals.js"; import { isVerbose, logVerbose } from "../globals.js";
import { runExec } from "../process/exec.js"; import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -14,7 +14,7 @@ export function isAudio(mediaType?: string | null) {
} }
export async function transcribeInboundAudio( export async function transcribeInboundAudio(
cfg: WarelayConfig, cfg: ClawdisConfig,
ctx: MsgContext, ctx: MsgContext,
runtime: RuntimeEnv, runtime: RuntimeEnv,
): Promise<{ text: string } | undefined> { ): Promise<{ text: string } | undefined> {

View File

@@ -279,7 +279,7 @@ Examples:
}); });
program program
.command("gateway") .command("gateway")
.description("Run the WebSocket Gateway (replaces relay)") .description("Run the WebSocket Gateway")
.option("--port <port>", "Port for the gateway WebSocket", "18789") .option("--port <port>", "Port for the gateway WebSocket", "18789")
.option( .option(
"--token <token>", "--token <token>",

View File

@@ -11,7 +11,7 @@ import {
vi, vi,
} from "vitest"; } from "vitest";
import * as commandReply from "../auto-reply/command-reply.js"; import * as commandReply from "../auto-reply/command-reply.js";
import type { WarelayConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import * as configModule from "../config/config.js"; import * as configModule from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { agentCommand } from "./agent.js"; import { agentCommand } from "./agent.js";
@@ -36,7 +36,7 @@ function makeStorePath() {
function mockConfig( function mockConfig(
storePath: string, storePath: string,
replyOverrides?: Partial<NonNullable<WarelayConfig["inbound"]>["reply"]>, replyOverrides?: Partial<NonNullable<ClawdisConfig["inbound"]>["reply"]>,
) { ) {
configSpy.mockReturnValue({ configSpy.mockReturnValue({
inbound: { inbound: {

View File

@@ -12,7 +12,7 @@ import {
type VerboseLevel, type VerboseLevel,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; import { type CliDeps, createDefaultDeps } from "../cli/deps.js";
import { loadConfig, type WarelayConfig } from "../config/config.js"; import { loadConfig, type ClawdisConfig } from "../config/config.js";
import { import {
DEFAULT_IDLE_MINUTES, DEFAULT_IDLE_MINUTES,
loadSessionStore, loadSessionStore,
@@ -50,7 +50,7 @@ type SessionResolution = {
persistedVerbose?: VerboseLevel; persistedVerbose?: VerboseLevel;
}; };
function assertCommandConfig(cfg: WarelayConfig) { function assertCommandConfig(cfg: ClawdisConfig) {
const reply = cfg.inbound?.reply; const reply = cfg.inbound?.reply;
if (!reply || reply.mode !== "command" || !reply.command?.length) { if (!reply || reply.mode !== "command" || !reply.command?.length) {
throw new Error( throw new Error(
@@ -58,14 +58,14 @@ function assertCommandConfig(cfg: WarelayConfig) {
); );
} }
return reply as NonNullable< return reply as NonNullable<
NonNullable<WarelayConfig["inbound"]>["reply"] NonNullable<ClawdisConfig["inbound"]>["reply"]
> & { mode: "command"; command: string[] }; > & { mode: "command"; command: string[] };
} }
function resolveSession(opts: { function resolveSession(opts: {
to?: string; to?: string;
sessionId?: string; sessionId?: string;
replyCfg: NonNullable<NonNullable<WarelayConfig["inbound"]>["reply"]>; replyCfg: NonNullable<NonNullable<ClawdisConfig["inbound"]>["reply"]>;
}): SessionResolution { }): SessionResolution {
const sessionCfg = opts.replyCfg?.session; const sessionCfg = opts.replyCfg?.session;
const scope = sessionCfg?.scope ?? "per-sender"; const scope = sessionCfg?.scope ?? "per-sender";

View File

@@ -53,12 +53,12 @@ export async function sendCommand(
return; return;
} }
// Try to send via IPC to running relay first (avoids Signal session corruption) // Try to send via IPC to running gateway first (avoids Signal session corruption)
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media); const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
if (ipcResult) { if (ipcResult) {
if (ipcResult.success) { if (ipcResult.success) {
runtime.log( runtime.log(
success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`), success(`✅ Sent via gateway IPC. Message ID: ${ipcResult.messageId}`),
); );
if (opts.json) { if (opts.json) {
runtime.log( runtime.log(
@@ -77,7 +77,7 @@ export async function sendCommand(
} }
return; return;
} }
// IPC failed but relay is running - warn and fall back // IPC failed but gateway is running - warn and fall back
runtime.log( runtime.log(
info( info(
`IPC send failed (${ipcResult.error}), falling back to direct connection`, `IPC send failed (${ipcResult.error}), falling back to direct connection`,

View File

@@ -66,7 +66,7 @@ export type GroupChatConfig = {
historyLimit?: number; historyLimit?: number;
}; };
export type WarelayConfig = { export type ClawdisConfig = {
logging?: LoggingConfig; logging?: LoggingConfig;
inbound?: { inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
@@ -179,7 +179,7 @@ const ReplySchema = z
}, },
); );
const WarelaySchema = z.object({ const ClawdisSchema = z.object({
logging: z logging: z
.object({ .object({
level: z level: z
@@ -252,7 +252,7 @@ const WarelaySchema = z.object({
.optional(), .optional(),
}); });
export function loadConfig(): WarelayConfig { export function loadConfig(): ClawdisConfig {
// Read config file (JSON5) if present. // Read config file (JSON5) if present.
const configPath = CONFIG_PATH_CLAWDIS; const configPath = CONFIG_PATH_CLAWDIS;
try { try {
@@ -260,7 +260,7 @@ export function loadConfig(): WarelayConfig {
const raw = fs.readFileSync(configPath, "utf-8"); const raw = fs.readFileSync(configPath, "utf-8");
const parsed = JSON5.parse(raw); const parsed = JSON5.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {}; if (typeof parsed !== "object" || parsed === null) return {};
const validated = WarelaySchema.safeParse(parsed); const validated = ClawdisSchema.safeParse(parsed);
if (!validated.success) { if (!validated.success) {
console.error("Invalid config:"); console.error("Invalid config:");
for (const iss of validated.error.issues) { for (const iss of validated.error.issues) {
@@ -268,7 +268,7 @@ export function loadConfig(): WarelayConfig {
} }
return {}; return {};
} }
return validated.data as WarelayConfig; return validated.data as ClawdisConfig;
} catch (err) { } catch (err) {
console.error(`Failed to read config at ${configPath}`, err); console.error(`Failed to read config at ${configPath}`, err);
return {}; return {};

View File

@@ -1,5 +1,5 @@
import chalk from "chalk"; import chalk from "chalk";
import { loadConfig, type WarelayConfig } from "../config/config.js"; import { loadConfig, type ClawdisConfig } from "../config/config.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { import {
getWebAuthAgeMs, getWebAuthAgeMs,
@@ -10,7 +10,7 @@ import {
const DEFAULT_WEBCHAT_PORT = 18788; const DEFAULT_WEBCHAT_PORT = 18788;
export async function buildProviderSummary( export async function buildProviderSummary(
cfg?: WarelayConfig, cfg?: ClawdisConfig,
): Promise<string[]> { ): Promise<string[]> {
const effective = cfg ?? loadConfig(); const effective = cfg ?? loadConfig();
const lines: string[] = []; const lines: string[] = [];

View File

@@ -1,34 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireRelayLock, RelayLockError } from "./relay-lock.js";
const newLockPath = () =>
path.join(
os.tmpdir(),
`clawdis-relay-lock-test-${process.pid}-${Math.random().toString(16).slice(2)}.sock`,
);
describe("relay-lock", () => {
it("prevents concurrent relay instances and releases cleanly", async () => {
const lockPath = newLockPath();
const release1 = await acquireRelayLock(lockPath);
expect(fs.existsSync(lockPath)).toBe(true);
await expect(acquireRelayLock(lockPath)).rejects.toBeInstanceOf(
RelayLockError,
);
await release1();
expect(fs.existsSync(lockPath)).toBe(false);
// After release, lock can be reacquired.
const release2 = await acquireRelayLock(lockPath);
await release2();
expect(fs.existsSync(lockPath)).toBe(false);
});
});

View File

@@ -1,102 +0,0 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
const DEFAULT_LOCK_PATH = path.join(os.tmpdir(), "clawdis-relay.lock");
export class RelayLockError extends Error {}
type ReleaseFn = () => Promise<void>;
/**
* Acquire an exclusive single-instance lock for the relay using a Unix domain socket.
*
* Why a socket? If the process crashes or is SIGKILLed, the socket file remains but
* the next start will detect ECONNREFUSED when connecting and clean the stale path
* before retrying. This keeps the lock self-healing without manual pidfile cleanup.
*/
export async function acquireRelayLock(
lockPath = DEFAULT_LOCK_PATH,
): Promise<ReleaseFn> {
// Fast path: try to listen on the lock path.
const attemptListen = (): Promise<net.Server> =>
new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", async (err: NodeJS.ErrnoException) => {
if (err.code !== "EADDRINUSE") {
reject(new RelayLockError(`lock listen failed: ${err.message}`));
return;
}
// Something is already bound. Try to connect to see if it is alive.
const client = net.connect({ path: lockPath });
client.once("connect", () => {
client.destroy();
reject(
new RelayLockError("another relay instance is already running"),
);
});
client.once("error", (connErr: NodeJS.ErrnoException) => {
// Nothing is listening -> stale socket file. Remove and retry once.
if (connErr.code === "ECONNREFUSED" || connErr.code === "ENOENT") {
try {
fs.rmSync(lockPath, { force: true });
} catch (rmErr) {
reject(
new RelayLockError(
`failed to clean stale lock at ${lockPath}: ${String(rmErr)}`,
),
);
return;
}
attemptListen().then(resolve, reject);
return;
}
reject(
new RelayLockError(
`failed to connect to existing lock (${lockPath}): ${connErr.message}`,
),
);
});
});
server.listen(lockPath, () => resolve(server));
});
const server = await attemptListen();
let released = false;
const release = async (): Promise<void> => {
if (released) return;
released = true;
await new Promise<void>((resolve) => server.close(() => resolve()));
try {
fs.rmSync(lockPath, { force: true });
} catch {
/* ignore */
}
};
const cleanupSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
const handleSignal = async () => {
await release();
process.exit(0);
};
for (const sig of cleanupSignals) {
process.once(sig, () => {
void handleSignal();
});
}
process.once("exit", () => {
// Exit handler must be sync-safe; release is async but close+rm are fast.
void release();
});
return release;
}

View File

@@ -2,9 +2,8 @@ import { spawn } from "node:child_process";
const DEFAULT_LAUNCHD_LABEL = "com.steipete.clawdis"; const DEFAULT_LAUNCHD_LABEL = "com.steipete.clawdis";
export function triggerWarelayRestart(): void { export function triggerClawdisRestart(): void {
const label = const label =
process.env.WARELAY_LAUNCHD_LABEL ||
process.env.CLAWDIS_LAUNCHD_LABEL || process.env.CLAWDIS_LAUNCHD_LABEL ||
DEFAULT_LAUNCHD_LABEL; DEFAULT_LAUNCHD_LABEL;
const uid = const uid =

View File

@@ -56,7 +56,7 @@ function initSelfPresence() {
function ensureSelfPresence() { function ensureSelfPresence() {
// If the map was somehow cleared (e.g., hot reload or a new worker spawn that // If the map was somehow cleared (e.g., hot reload or a new worker spawn that
// skipped module evaluation), re-seed with a local entry so UIs always show // skipped module evaluation), re-seed with a local entry so UIs always show
// at least the current relay. // at least the current gateway.
if (entries.size === 0) { if (entries.size === 0) {
initSelfPresence(); initSelfPresence();
} }

View File

@@ -150,7 +150,7 @@ export async function ensureFunnel(
); );
runtime.error( runtime.error(
info( info(
"Tip: Funnel is optional for CLAWDIS. You can keep running the web relay without it: `pnpm clawdis gateway`", "Tip: Funnel is optional for CLAWDIS. You can keep running the web gateway without it: `pnpm clawdis gateway`",
), ),
); );
if (isVerbose()) { if (isVerbose()) {

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import util from "node:util"; import util from "node:util";
import { Logger as TsLogger } from "tslog"; import { Logger as TsLogger } from "tslog";
import { loadConfig, type WarelayConfig } from "./config/config.js"; import { loadConfig, type ClawdisConfig } from "./config/config.js";
import { isVerbose } from "./globals.js"; import { isVerbose } from "./globals.js";
// Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user // Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user
@@ -55,7 +55,7 @@ function normalizeLevel(level?: string): Level {
} }
function resolveSettings(): ResolvedSettings { function resolveSettings(): ResolvedSettings {
const cfg: WarelayConfig["logging"] | undefined = const cfg: ClawdisConfig["logging"] | undefined =
overrideSettings ?? loadConfig().logging; overrideSettings ?? loadConfig().logging;
const level = normalizeLevel(cfg?.level); const level = normalizeLevel(cfg?.level);
const file = cfg?.file ?? defaultRollingPathForToday(); const file = cfg?.file ?? defaultRollingPathForToday();

View File

@@ -131,7 +131,7 @@ class TauRpcClient {
if (!ok) child.stdin.once("drain", () => resolve()); if (!ok) child.stdin.once("drain", () => resolve());
}); });
return await new Promise<TauRpcResult>((resolve, reject) => { return await new Promise<TauRpcResult>((resolve, reject) => {
// Hard cap to avoid stuck relays; resets on every line received. // Hard cap to avoid stuck gateways; resets on every line received.
const capMs = Math.min(timeoutMs, 5 * 60 * 1000); const capMs = Math.min(timeoutMs, 5 * 60 * 1000);
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.pending = undefined; this.pending = undefined;

View File

@@ -20,7 +20,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const token = (opts.token ?? process.env.TELEGRAM_BOT_TOKEN)?.trim(); const token = (opts.token ?? process.env.TELEGRAM_BOT_TOKEN)?.trim();
if (!token) { if (!token) {
throw new Error( throw new Error(
"TELEGRAM_BOT_TOKEN or telegram.botToken is required for Telegram relay", "TELEGRAM_BOT_TOKEN or telegram.botToken is required for Telegram gateway",
); );
} }

View File

@@ -6,7 +6,7 @@ import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WarelayConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from "../logging.js";
import * as commandQueue from "../process/command-queue.js"; import * as commandQueue from "../process/command-queue.js";
import { import {
@@ -65,7 +65,7 @@ describe("heartbeat helpers", () => {
}); });
it("resolves heartbeat minutes with default and overrides", () => { it("resolves heartbeat minutes with default and overrides", () => {
const cfgBase: WarelayConfig = { const cfgBase: ClawdisConfig = {
inbound: { inbound: {
reply: { mode: "command" as const }, reply: { mode: "command" as const },
}, },
@@ -94,7 +94,7 @@ describe("resolveHeartbeatRecipients", () => {
it("returns the sole session recipient", async () => { it("returns the sole session recipient", async () => {
const now = Date.now(); const now = Date.now();
const store = await makeSessionStore({ "+1000": { updatedAt: now } }); const store = await makeSessionStore({ "+1000": { updatedAt: now } });
const cfg: WarelayConfig = { const cfg: ClawdisConfig = {
inbound: { inbound: {
allowFrom: ["+1999"], allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } }, reply: { mode: "command", session: { store: store.storePath } },
@@ -112,7 +112,7 @@ describe("resolveHeartbeatRecipients", () => {
"+1000": { updatedAt: now }, "+1000": { updatedAt: now },
"+2000": { updatedAt: now - 10 }, "+2000": { updatedAt: now - 10 },
}); });
const cfg: WarelayConfig = { const cfg: ClawdisConfig = {
inbound: { inbound: {
allowFrom: ["+1999"], allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } }, reply: { mode: "command", session: { store: store.storePath } },
@@ -126,7 +126,7 @@ describe("resolveHeartbeatRecipients", () => {
it("filters wildcard allowFrom when no sessions exist", async () => { it("filters wildcard allowFrom when no sessions exist", async () => {
const store = await makeSessionStore({}); const store = await makeSessionStore({});
const cfg: WarelayConfig = { const cfg: ClawdisConfig = {
inbound: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
reply: { mode: "command", session: { store: store.storePath } }, reply: { mode: "command", session: { store: store.storePath } },
@@ -141,7 +141,7 @@ describe("resolveHeartbeatRecipients", () => {
it("merges sessions and allowFrom when --all is set", async () => { it("merges sessions and allowFrom when --all is set", async () => {
const now = Date.now(); const now = Date.now();
const store = await makeSessionStore({ "+1000": { updatedAt: now } }); const store = await makeSessionStore({ "+1000": { updatedAt: now } });
const cfg: WarelayConfig = { const cfg: ClawdisConfig = {
inbound: { inbound: {
allowFrom: ["+1999"], allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } }, reply: { mode: "command", session: { store: store.storePath } },
@@ -162,7 +162,7 @@ describe("partial reply gating", () => {
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
const mockConfig: WarelayConfig = { const mockConfig: ClawdisConfig = {
inbound: { inbound: {
reply: { mode: "command" }, reply: { mode: "command" },
allowFrom: ["*"], allowFrom: ["*"],
@@ -342,7 +342,7 @@ describe("runWebHeartbeatOnce", () => {
const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN });
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
const cfg: WarelayConfig = { const cfg: ClawdisConfig = {
inbound: { inbound: {
allowFrom: ["+4367"], allowFrom: ["+4367"],
reply: { reply: {
@@ -385,7 +385,7 @@ describe("runWebHeartbeatOnce", () => {
})); }));
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
const cfg: WarelayConfig = { const cfg: ClawdisConfig = {
inbound: { inbound: {
allowFrom: ["+1999"], allowFrom: ["+1999"],
reply: { reply: {

View File

@@ -41,7 +41,7 @@ export function setHeartbeatsEnabled(enabled: boolean) {
} }
/** /**
* Send a message via IPC if relay is running, otherwise fall back to direct. * Send a message via IPC if gateway is running, otherwise fall back to direct.
* This avoids Signal session corruption from multiple Baileys connections. * This avoids Signal session corruption from multiple Baileys connections.
*/ */
async function sendWithIpcFallback( async function sendWithIpcFallback(
@@ -52,7 +52,7 @@ async function sendWithIpcFallback(
const ipcResult = await sendViaIpc(to, message, opts.mediaUrl); const ipcResult = await sendViaIpc(to, message, opts.mediaUrl);
if (ipcResult?.success && ipcResult.messageId) { if (ipcResult?.success && ipcResult.messageId) {
if (opts.verbose) { if (opts.verbose) {
console.log(info(`Sent via relay IPC (avoiding session corruption)`)); console.log(info(`Sent via gateway IPC (avoiding session corruption)`));
} }
return { messageId: ipcResult.messageId, toJid: `${to}@s.whatsapp.net` }; return { messageId: ipcResult.messageId, toJid: `${to}@s.whatsapp.net` };
} }
@@ -720,7 +720,7 @@ export async function monitorWebProvider(
); );
// Avoid noisy MaxListenersExceeded warnings in test environments where // Avoid noisy MaxListenersExceeded warnings in test environments where
// multiple relay instances may be constructed. // multiple gateway instances may be constructed.
const currentMaxListeners = process.getMaxListeners?.() ?? 10; const currentMaxListeners = process.getMaxListeners?.() ?? 10;
if (process.setMaxListeners && currentMaxListeners < 50) { if (process.setMaxListeners && currentMaxListeners < 50) {
process.setMaxListeners(50); process.setMaxListeners(50);
@@ -1021,7 +1021,7 @@ export async function monitorWebProvider(
// Surface a concise connection event for the next main-session turn/heartbeat. // Surface a concise connection event for the next main-session turn/heartbeat.
const { e164: selfE164 } = readWebSelfId(); const { e164: selfE164 } = readWebSelfId();
enqueueSystemEvent( enqueueSystemEvent(
`WhatsApp relay connected${selfE164 ? ` as ${selfE164}` : ""}.`, `WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`,
); );
// Start IPC server so `clawdis send` can use this connection // Start IPC server so `clawdis send` can use this connection
@@ -1099,10 +1099,10 @@ export async function monitorWebProvider(
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) { if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
heartbeatLogger.warn( heartbeatLogger.warn(
logData, logData,
"⚠️ web relay heartbeat - no messages in 30+ minutes", "⚠️ web gateway heartbeat - no messages in 30+ minutes",
); );
} else { } else {
heartbeatLogger.info(logData, "web relay heartbeat"); heartbeatLogger.info(logData, "web gateway heartbeat");
} }
}, heartbeatSeconds * 1000); }, heartbeatSeconds * 1000);
@@ -1398,7 +1398,7 @@ export async function monitorWebProvider(
); );
enqueueSystemEvent( enqueueSystemEvent(
`WhatsApp relay disconnected (status ${status ?? "unknown"})`, `WhatsApp gateway disconnected (status ${status ?? "unknown"})`,
); );
if (loggedOut) { if (loggedOut) {

View File

@@ -64,7 +64,7 @@ export async function monitorWebInbox(options: {
onCloseResolve = resolve; onCloseResolve = resolve;
}); });
try { try {
// Advertise that the relay is online right after connecting. // Advertise that the gateway is online right after connecting.
await sock.sendPresenceUpdate("available"); await sock.sendPresenceUpdate("available");
if (isVerbose()) logVerbose("Sent global 'available' presence on connect"); if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
} catch (err) { } catch (err) {

View File

@@ -1,7 +1,7 @@
/** /**
* IPC server for clawdis relay. * IPC server for clawdis gateway.
* *
* When the relay is running, it starts a Unix socket server that allows * When the gateway is running, it starts a Unix socket server that allows
* `clawdis send` and `clawdis heartbeat` to send messages through the * `clawdis send` and `clawdis heartbeat` to send messages through the
* existing WhatsApp connection instead of creating new ones. * existing WhatsApp connection instead of creating new ones.
* *
@@ -40,7 +40,7 @@ type SendHandler = (
let server: net.Server | null = null; let server: net.Server | null = null;
/** /**
* Start the IPC server. Called by the relay when it starts. * Start the IPC server. Called by the gateway when it starts.
*/ */
export function startIpcServer(sendHandler: SendHandler): void { export function startIpcServer(sendHandler: SendHandler): void {
const logger = getChildLogger({ module: "ipc-server" }); const logger = getChildLogger({ module: "ipc-server" });
@@ -126,7 +126,7 @@ export function startIpcServer(sendHandler: SendHandler): void {
} }
/** /**
* Stop the IPC server. Called when relay shuts down. * Stop the IPC server. Called when gateway shuts down.
*/ */
export function stopIpcServer(): void { export function stopIpcServer(): void {
if (server) { if (server) {
@@ -141,7 +141,7 @@ export function stopIpcServer(): void {
} }
/** /**
* Check if the relay IPC server is running. * Check if the gateway IPC server is running.
*/ */
export function isRelayRunning(): boolean { export function isRelayRunning(): boolean {
try { try {
@@ -154,8 +154,8 @@ export function isRelayRunning(): boolean {
} }
/** /**
* Send a message through the running relay's IPC. * Send a message through the running gateway's IPC.
* Returns null if relay is not running. * Returns null if gateway is not running.
*/ */
export async function sendViaIpc( export async function sendViaIpc(
to: string, to: string,
@@ -214,7 +214,7 @@ export async function sendViaIpc(
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
clearTimeout(timeout); clearTimeout(timeout);
// Socket exists but can't connect - relay might have crashed // Socket exists but can't connect - gateway might have crashed
resolve(null); resolve(null);
} }
}); });

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WarelayConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { import {
computeBackoff, computeBackoff,
DEFAULT_HEARTBEAT_SECONDS, DEFAULT_HEARTBEAT_SECONDS,
@@ -11,7 +11,7 @@ import {
} from "./reconnect.js"; } from "./reconnect.js";
describe("web reconnect helpers", () => { describe("web reconnect helpers", () => {
const cfg: WarelayConfig = {}; const cfg: ClawdisConfig = {};
it("resolves sane reconnect defaults with clamps", () => { it("resolves sane reconnect defaults with clamps", () => {
const policy = resolveReconnectPolicy(cfg, { const policy = resolveReconnectPolicy(cfg, {

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { WarelayConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
export type ReconnectPolicy = { export type ReconnectPolicy = {
initialMs: number; initialMs: number;
@@ -23,7 +23,7 @@ const clamp = (val: number, min: number, max: number) =>
Math.max(min, Math.min(max, val)); Math.max(min, Math.min(max, val));
export function resolveHeartbeatSeconds( export function resolveHeartbeatSeconds(
cfg: WarelayConfig, cfg: ClawdisConfig,
overrideSeconds?: number, overrideSeconds?: number,
): number { ): number {
const candidate = overrideSeconds ?? cfg.web?.heartbeatSeconds; const candidate = overrideSeconds ?? cfg.web?.heartbeatSeconds;
@@ -32,7 +32,7 @@ export function resolveHeartbeatSeconds(
} }
export function resolveReconnectPolicy( export function resolveReconnectPolicy(
cfg: WarelayConfig, cfg: ClawdisConfig,
overrides?: Partial<ReconnectPolicy>, overrides?: Partial<ReconnectPolicy>,
): ReconnectPolicy { ): ReconnectPolicy {
const merged = { const merged = {