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.
## 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`.
- 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`.

View File

@@ -2,19 +2,19 @@
## 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
- 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.
- 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>`.
- 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
- **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.
- **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
- 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
- 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
@@ -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.
- 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/`.
- 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.
- 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.
@@ -84,7 +84,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
### Reliability & UX
- 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.
- 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.
- 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.
@@ -148,7 +148,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
### Changes
- 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.
- 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.
@@ -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`.
- Optional audio transcription via external CLI.
- 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.
- 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
- 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

View File

@@ -9,12 +9,12 @@
</p>
<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://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://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/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>
</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
# Install
npm install -g warelay # (still warelay on npm for now)
npm install -g clawdis
# Link your WhatsApp
clawdis login
@@ -125,11 +125,11 @@ CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setu
### WhatsApp Web
```bash
clawdis login # Scan QR code
clawdis relay # Start listening
clawdis gateway # Start listening
```
### 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
@@ -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 send` | Send a message (WhatsApp default; `--provider telegram` for bot mode, text + media) |
| `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 heartbeat` | Trigger a heartbeat |

View File

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

View File

@@ -111,7 +111,7 @@ struct ConfigSettings: View {
}
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.
""")
.font(.footnote)

View File

@@ -57,7 +57,7 @@ final class ControlChannel: ObservableObject {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private let gateway = GatewayChannel()
private var gatewayURL: URL {
let port = RelayEnvironment.gatewayPort()
let port = GatewayEnvironment.gatewayPort()
return URL(string: "ws://127.0.0.1:\(port)")!
}
@@ -130,16 +130,16 @@ final class ControlChannel: ObservableObject {
}
if let urlError = error as? URLError {
let port = RelayEnvironment.gatewayPort()
let port = GatewayEnvironment.gatewayPort()
switch urlError.code {
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:
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:
return "Gateway connection dropped; relay likely restarted—retry."
return "Gateway connection dropped; gateway likely restarted—retry."
case .timedOut:
return "Gateway request timed out; check relay on localhost:\(port)."
return "Gateway request timed out; check gateway on localhost:\(port)."
case .notConnectedToInternet:
return "No network connectivity; cannot reach gateway."
default:

View File

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

View File

@@ -85,9 +85,9 @@ enum DebugActions {
static func restartGateway() {
Task { @MainActor in
RelayProcessManager.shared.stop()
GatewayProcessManager.shared.stop()
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 modelsLoading = false
@State private var modelsError: String?
@ObservedObject private var relayManager = RelayProcessManager.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.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 sessionStoreSaveError: String?
@State private var debugSendInFlight = false
@@ -53,19 +53,19 @@ struct DebugSettings: View {
.textSelection(.enabled)
}
LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) }
LabeledContent("Relay status") {
LabeledContent("Gateway status") {
VStack(alignment: .leading, spacing: 2) {
Text(self.relayManager.status.label)
Text("Restarts: \(self.relayManager.restartCount)")
Text(self.gatewayManager.status.label)
Text("Restarts: \(self.gatewayManager.restartCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Relay stdout/stderr")
Text("Gateway stdout/stderr")
.font(.caption.weight(.semibold))
ScrollView {
Text(self.relayManager.log.isEmpty ? "" : self.relayManager.log)
Text(self.gatewayManager.log.isEmpty ? "" : self.gatewayManager.log)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
@@ -77,7 +77,7 @@ struct DebugSettings: View {
Text("Clawdis project root")
.font(.caption.weight(.semibold))
HStack(spacing: 8) {
TextField("Path to clawdis repo", text: self.$relayRootInput)
TextField("Path to clawdis repo", text: self.$gatewayRootInput)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.onSubmit { self.saveRelayRoot() }
@@ -86,12 +86,12 @@ struct DebugSettings: View {
Button("Reset") {
let def = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdis").path
self.relayRootInput = def
self.gatewayRootInput = def
self.saveRelayRoot()
}
.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)
.foregroundStyle(.secondary)
}
@@ -281,7 +281,7 @@ struct DebugSettings: View {
}
private func saveRelayRoot() {
RelayProcessManager.shared.setProjectRoot(path: self.relayRootInput)
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
}
private func loadSessionStorePath() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,9 +46,9 @@ struct OnboardingView: View {
@State private var monitoringPermissions = false
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var relayStatus: RelayEnvironmentStatus = .checking
@State private var relayInstalling = false
@State private var relayInstallMessage: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
@ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
@@ -70,7 +70,7 @@ struct OnboardingView: View {
HStack(spacing: 0) {
self.welcomePage().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.cliPage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth)
@@ -100,7 +100,7 @@ struct OnboardingView: View {
.task {
await self.refreshPerms()
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 {
Text("Install the relay")
Text("Install the gateway")
.font(.largeTitle.weight(.semibold))
Text(
"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) {
HStack(spacing: 10) {
Circle()
.fill(self.relayStatusColor)
.fill(self.gatewayStatusColor)
.frame(width: 10, height: 10)
Text(self.relayStatus.message)
Text(self.gatewayStatus.message)
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
}
if let relayVersion = self.relayStatus.relayVersion,
let required = self.relayStatus.requiredRelay,
relayVersion != required
if let gatewayVersion = self.gatewayStatus.gatewayVersion,
let required = self.gatewayStatus.requiredGateway,
gatewayVersion != required
{
Text("Installed: \(relayVersion) · Required: \(required)")
Text("Installed: \(gatewayVersion) · Required: \(required)")
.font(.caption)
.foregroundStyle(.secondary)
} else if let relayVersion = self.relayStatus.relayVersion {
Text("Relay \(relayVersion) detected")
} else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
Text("Gateway \(gatewayVersion) detected")
.font(.caption)
.foregroundStyle(.secondary)
}
if let node = self.relayStatus.nodeVersion {
if let node = self.gatewayStatus.nodeVersion {
Text("Node \(node)")
.font(.caption)
.foregroundStyle(.secondary)
@@ -221,24 +221,24 @@ struct OnboardingView: View {
HStack(spacing: 12) {
Button {
Task { await self.installRelay() }
Task { await self.installGateway() }
} label: {
if self.relayInstalling {
if self.gatewayInstalling {
ProgressView()
} else {
Text("Install / Update relay")
Text("Install / Update gateway")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.relayInstalling)
.disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshRelayStatus() }
Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered)
.disabled(self.relayInstalling)
.disabled(self.gatewayInstalling)
}
if let relayInstallMessage {
Text(relayInstallMessage)
if let gatewayInstallMessage {
Text(gatewayInstallMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -350,7 +350,7 @@ struct OnboardingView: View {
.font(.largeTitle.weight(.semibold))
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.
""")
.font(.body)
@@ -368,7 +368,7 @@ struct OnboardingView: View {
title: "Run `clawdis login --verbose`",
subtitle: """
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")
self.featureRow(
@@ -568,27 +568,27 @@ struct OnboardingView: View {
self.cliInstalled = installLocation != nil
}
private func refreshRelayStatus() {
self.relayStatus = RelayEnvironment.check()
private func refreshGatewayStatus() {
self.gatewayStatus = GatewayEnvironment.check()
}
private func installRelay() async {
guard !self.relayInstalling else { return }
self.relayInstalling = true
defer { self.relayInstalling = false }
self.relayInstallMessage = nil
let expected = RelayEnvironment.expectedRelayVersion()
await RelayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.relayInstallMessage = message }
private func installGateway() async {
guard !self.gatewayInstalling else { return }
self.gatewayInstalling = true
defer { self.gatewayInstalling = false }
self.gatewayInstallMessage = nil
let expected = GatewayEnvironment.expectedGatewayVersion()
await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.gatewayInstallMessage = message }
}
self.refreshRelayStatus()
self.refreshGatewayStatus()
}
private var relayStatusColor: Color {
switch self.relayStatus.kind {
private var gatewayStatusColor: Color {
switch self.gatewayStatus.kind {
case .ok: .green
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 {
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath"
private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
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
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
let binEntry = root.appendingPathComponent("bin/clawdis.js").path
@@ -326,7 +326,7 @@ enum CommandResolver {
return [clawdisPath, subcommand] + extraArgs
}
if let entry = self.relayEntrypoint(in: self.projectRoot()) {
if let entry = self.gatewayEntrypoint(in: self.projectRoot()) {
return self.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,

View File

@@ -26,8 +26,8 @@ import Testing
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
try self.makeExec(at: clawdisPath)
let cmd = CommandResolver.clawdisCommand(subcommand: "relay")
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"]))
let cmd = CommandResolver.clawdisCommand(subcommand: "gateway")
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "gateway"]))
}
@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.
## 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.
- `start(config)` called when “Clawdis Active” flips ON:
- 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
- `stop()` called when Active flips OFF or app terminates: cancel the execution and `waitUntilExit`.
- Wire SwiftUI toggle:
- ON: `RelayProcessManager.start(...)`
- OFF: `RelayProcessManager.stop()` (no launchctl calls in this mode)
- ON: `GatewayProcessManager.start(...)`
- 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.
## 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)
- 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.

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):
• voicewake - Voice wake detection/test harness
relay - Relay process manager
gateway - Gateway process manager
• xpc - XPC service calls
• notifications - Notification helper
• 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"
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
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
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"
if [[ "${SKIP_RELAY_PACKAGE:-0}" != "1" ]]; then
echo "🧰 Staging relay payload (dist + node_modules; expects system Node ≥22)"
if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
echo "🧰 Staging gateway payload (dist + node_modules; expects system Node ≥22)"
rsync -a --delete --exclude "Clawdis.app" "$ROOT_DIR/dist/" "$RELAY_DIR/dist/"
cp "$ROOT_DIR/package.json" "$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
rm -rf "$TMP_DEPLOY"
else
echo "🧰 Skipping relay payload packaging (SKIP_RELAY_PACKAGE=1)"
echo "🧰 Skipping gateway payload packaging (SKIP_GATEWAY_PACKAGE=1)"
fi
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 "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).
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=1 SKIP_RELAY_PACKAGE=1 '${ROOT_DIR}/scripts/package-mac-app.sh'"
# 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_GATEWAY_PACKAGE=1 '${ROOT_DIR}/scripts/package-mac-app.sh'"
choose_app_bundle() {
if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then

View File

@@ -8,7 +8,7 @@ import type {
} from "@mariozechner/pi-ai";
import { piSpec } from "../agents/pi.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 { emitAgentEvent } from "../infra/agent-events.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;
}
type CommandReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"] & {
type CommandReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"] & {
mode: "command";
};

View File

@@ -3,7 +3,7 @@ import crypto from "node:crypto";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.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 {
DEFAULT_IDLE_MINUTES,
DEFAULT_RESET_TRIGGER,
@@ -15,7 +15,7 @@ import {
} from "../config/sessions.js";
import { isVerbose, logVerbose } from "../globals.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 { defaultRuntime } from "../runtime.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 SYSTEM_MARK = "⚙️";
type ReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"];
type ReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"];
export function extractThinkDirective(body?: string): {
cleaned: string;
@@ -112,7 +112,7 @@ function stripStructuralPrefixes(text: string): string {
function stripMentions(
text: string,
ctx: MsgContext,
cfg: WarelayConfig | undefined,
cfg: ClawdisConfig | undefined,
): string {
let result = text;
const patterns = cfg?.inbound?.groupChat?.mentionPatterns ?? [];
@@ -161,7 +161,7 @@ function makeDefaultPiReply(): ReplyConfig {
export async function getReplyFromConfig(
ctx: MsgContext,
opts?: GetReplyOptions,
configOverride?: WarelayConfig,
configOverride?: ClawdisConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
// Choose reply from config: static text or external command stdout.
const cfg = configOverride ?? loadConfig();
@@ -503,7 +503,7 @@ export async function getReplyFromConfig(
rawBodyNormalized === "restart" ||
rawBodyNormalized.startsWith("/restart ")
) {
triggerWarelayRestart();
triggerClawdisRestart();
cleanupTyping();
return {
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 { 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 { ThinkLevel, VerboseLevel } from "./thinking.js";
type ReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"];
type ReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"];
type StatusArgs = {
reply: ReplyConfig;

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,12 +53,12 @@ export async function sendCommand(
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);
if (ipcResult) {
if (ipcResult.success) {
runtime.log(
success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`),
success(`✅ Sent via gateway IPC. Message ID: ${ipcResult.messageId}`),
);
if (opts.json) {
runtime.log(
@@ -77,7 +77,7 @@ export async function sendCommand(
}
return;
}
// IPC failed but relay is running - warn and fall back
// IPC failed but gateway is running - warn and fall back
runtime.log(
info(
`IPC send failed (${ipcResult.error}), falling back to direct connection`,

View File

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

View File

@@ -1,5 +1,5 @@
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 {
getWebAuthAgeMs,
@@ -10,7 +10,7 @@ import {
const DEFAULT_WEBCHAT_PORT = 18788;
export async function buildProviderSummary(
cfg?: WarelayConfig,
cfg?: ClawdisConfig,
): Promise<string[]> {
const effective = cfg ?? loadConfig();
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";
export function triggerWarelayRestart(): void {
export function triggerClawdisRestart(): void {
const label =
process.env.WARELAY_LAUNCHD_LABEL ||
process.env.CLAWDIS_LAUNCHD_LABEL ||
DEFAULT_LAUNCHD_LABEL;
const uid =

View File

@@ -56,7 +56,7 @@ function initSelfPresence() {
function ensureSelfPresence() {
// 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
// at least the current relay.
// at least the current gateway.
if (entries.size === 0) {
initSelfPresence();
}

View File

@@ -150,7 +150,7 @@ export async function ensureFunnel(
);
runtime.error(
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()) {

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import util from "node:util";
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";
// 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 {
const cfg: WarelayConfig["logging"] | undefined =
const cfg: ClawdisConfig["logging"] | undefined =
overrideSettings ?? loadConfig().logging;
const level = normalizeLevel(cfg?.level);
const file = cfg?.file ?? defaultRollingPathForToday();

View File

@@ -131,7 +131,7 @@ class TauRpcClient {
if (!ok) child.stdin.once("drain", () => resolve());
});
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 timer = setTimeout(() => {
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();
if (!token) {
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 { 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 * as commandQueue from "../process/command-queue.js";
import {
@@ -65,7 +65,7 @@ describe("heartbeat helpers", () => {
});
it("resolves heartbeat minutes with default and overrides", () => {
const cfgBase: WarelayConfig = {
const cfgBase: ClawdisConfig = {
inbound: {
reply: { mode: "command" as const },
},
@@ -94,7 +94,7 @@ describe("resolveHeartbeatRecipients", () => {
it("returns the sole session recipient", async () => {
const now = Date.now();
const store = await makeSessionStore({ "+1000": { updatedAt: now } });
const cfg: WarelayConfig = {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
@@ -112,7 +112,7 @@ describe("resolveHeartbeatRecipients", () => {
"+1000": { updatedAt: now },
"+2000": { updatedAt: now - 10 },
});
const cfg: WarelayConfig = {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
@@ -126,7 +126,7 @@ describe("resolveHeartbeatRecipients", () => {
it("filters wildcard allowFrom when no sessions exist", async () => {
const store = await makeSessionStore({});
const cfg: WarelayConfig = {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["*"],
reply: { mode: "command", session: { store: store.storePath } },
@@ -141,7 +141,7 @@ describe("resolveHeartbeatRecipients", () => {
it("merges sessions and allowFrom when --all is set", async () => {
const now = Date.now();
const store = await makeSessionStore({ "+1000": { updatedAt: now } });
const cfg: WarelayConfig = {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
@@ -162,7 +162,7 @@ describe("partial reply gating", () => {
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
const mockConfig: WarelayConfig = {
const mockConfig: ClawdisConfig = {
inbound: {
reply: { mode: "command" },
allowFrom: ["*"],
@@ -342,7 +342,7 @@ describe("runWebHeartbeatOnce", () => {
const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN });
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
const cfg: WarelayConfig = {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+4367"],
reply: {
@@ -385,7 +385,7 @@ describe("runWebHeartbeatOnce", () => {
}));
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
const cfg: WarelayConfig = {
const cfg: ClawdisConfig = {
inbound: {
allowFrom: ["+1999"],
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.
*/
async function sendWithIpcFallback(
@@ -52,7 +52,7 @@ async function sendWithIpcFallback(
const ipcResult = await sendViaIpc(to, message, opts.mediaUrl);
if (ipcResult?.success && ipcResult.messageId) {
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` };
}
@@ -720,7 +720,7 @@ export async function monitorWebProvider(
);
// 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;
if (process.setMaxListeners && currentMaxListeners < 50) {
process.setMaxListeners(50);
@@ -1021,7 +1021,7 @@ export async function monitorWebProvider(
// Surface a concise connection event for the next main-session turn/heartbeat.
const { e164: selfE164 } = readWebSelfId();
enqueueSystemEvent(
`WhatsApp relay connected${selfE164 ? ` as ${selfE164}` : ""}.`,
`WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`,
);
// Start IPC server so `clawdis send` can use this connection
@@ -1099,10 +1099,10 @@ export async function monitorWebProvider(
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
heartbeatLogger.warn(
logData,
"⚠️ web relay heartbeat - no messages in 30+ minutes",
"⚠️ web gateway heartbeat - no messages in 30+ minutes",
);
} else {
heartbeatLogger.info(logData, "web relay heartbeat");
heartbeatLogger.info(logData, "web gateway heartbeat");
}
}, heartbeatSeconds * 1000);
@@ -1398,7 +1398,7 @@ export async function monitorWebProvider(
);
enqueueSystemEvent(
`WhatsApp relay disconnected (status ${status ?? "unknown"})`,
`WhatsApp gateway disconnected (status ${status ?? "unknown"})`,
);
if (loggedOut) {

View File

@@ -64,7 +64,7 @@ export async function monitorWebInbox(options: {
onCloseResolve = resolve;
});
try {
// Advertise that the relay is online right after connecting.
// Advertise that the gateway is online right after connecting.
await sock.sendPresenceUpdate("available");
if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
} 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
* existing WhatsApp connection instead of creating new ones.
*
@@ -40,7 +40,7 @@ type SendHandler = (
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 {
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 {
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 {
try {
@@ -154,8 +154,8 @@ export function isRelayRunning(): boolean {
}
/**
* Send a message through the running relay's IPC.
* Returns null if relay is not running.
* Send a message through the running gateway's IPC.
* Returns null if gateway is not running.
*/
export async function sendViaIpc(
to: string,
@@ -214,7 +214,7 @@ export async function sendViaIpc(
if (!resolved) {
resolved = true;
clearTimeout(timeout);
// Socket exists but can't connect - relay might have crashed
// Socket exists but can't connect - gateway might have crashed
resolve(null);
}
});

View File

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

View File

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