chore: rename relay to gateway
This commit is contained in:
@@ -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`.
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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 you’re @‑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 don’t 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
|
||||
|
||||
|
||||
14
README.md
14
README.md
@@ -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 |
|
||||
|
||||
|
||||
@@ -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)")!
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ struct ConfigSettings: View {
|
||||
}
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the relay’s loopback web chat on this port.
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ final class HealthStore: ObservableObject {
|
||||
return "The gateway control port (127.0.0.1:18789) isn’t 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 we’ll 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 409–515:** 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user