@bahulam/code 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +4 -7
  2. package/pulse/lib/tool-categories.ts +13 -0
  3. package/src/commands/device.mjs +121 -0
  4. package/src/commands/pair.mjs +190 -0
  5. package/src/commands/remote.mjs +110 -0
  6. package/src/core/event-log.mjs +393 -0
  7. package/src/core/headless.mjs +198 -0
  8. package/src/core/loop.mjs +276 -0
  9. package/src/core/memory-disk.mjs +210 -0
  10. package/src/core/paths.mjs +36 -0
  11. package/src/core/stream-client.mjs +28 -9
  12. package/src/core/tool-executor.mjs +56 -16
  13. package/src/daemon/approval-store.mjs +253 -0
  14. package/src/daemon/attach-client.mjs +361 -0
  15. package/src/daemon/daemonize.mjs +151 -0
  16. package/src/daemon/event-tap.mjs +197 -0
  17. package/src/daemon/input-lock.mjs +191 -0
  18. package/src/daemon/relay-client.mjs +258 -0
  19. package/src/daemon/session-core.mjs +179 -0
  20. package/src/daemon/session-list.mjs +26 -0
  21. package/src/daemon/session-publisher.mjs +78 -0
  22. package/src/daemon/socket-server.mjs +329 -0
  23. package/src/daemon/stop-daemon.mjs +18 -0
  24. package/src/permissions/checker.mjs +6 -6
  25. package/src/permissions/prompt.mjs +8 -7
  26. package/src/terminal/ansi.mjs +20 -3
  27. package/src/terminal/main.mjs +97 -3
  28. package/src/terminal/repl.mjs +201 -2
  29. package/src/tools/analyze-code.mjs +39 -0
  30. package/src/tools/bash.mjs +1 -1
  31. package/src/tools/edit.mjs +18 -18
  32. package/src/tools/git-diff.mjs +34 -0
  33. package/src/tools/git-status.mjs +30 -0
  34. package/src/tools/glob.mjs +5 -2
  35. package/src/tools/grep.mjs +1 -1
  36. package/src/tools/meta-tools.mjs +85 -0
  37. package/src/tools/read-files.mjs +37 -0
  38. package/src/tools/read.mjs +20 -10
  39. package/src/tools/registry.mjs +20 -0
  40. package/src/tools/remember.mjs +147 -0
  41. package/src/tools/search-files.mjs +41 -0
  42. package/src/tools/write-project.mjs +62 -0
  43. package/src/tools/write.mjs +1 -1
  44. package/src/ui/sub-agent.mjs +8 -2
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Event tap — bridges SSE events from the agent loop to the relay.
3
+ *
4
+ * Each SSE event type is mapped to a relay event type. The mapping
5
+ * is a fixed table. Keeping it centralized avoids scattering
6
+ * relay-specific concerns across the agent loop. * • Cache-invariance ( §7): this tap is READ-ONLY on the SSE
7
+ * event. It never mutates `event`, never changes what stream-client
8
+ * yields, and writes to a local file only. `/api/execute` body is
9
+ * untouched.
10
+ *
11
+ * NOT in scope for .3:
12
+ * • Broadcasting events to attached socket clients (.4).
13
+ * • Compact snapshot writing (.4 — the daemon supervisor
14
+ * decides when to snapshot; the tap doesn't).
15
+ * • Deriving events that don't have a direct SSE origin
16
+ * (e.g. `attach_joined`, `input_lock_changed` — those come from
17
+ * the socket server, not the SSE stream).
18
+ */
19
+
20
+ import { createEventLog } from '../core/event-log.mjs';
21
+
22
+ // ── env-var gate ─────────────────────────────────────────────────────
23
+
24
+ function _enabled() {
25
+ const v = process.env.BAHULAM_DAEMON_EVENTLOG;
26
+ return v === '1' || v === 'true';
27
+ }
28
+
29
+ // ── per-session lazy log holder ──────────────────────────────────────
30
+ //
31
+ // One active log at a time. Session id change (via new turn's session_info)
32
+ // closes the old log and opens a new one. `_current` is null when the tap
33
+ // hasn't seen a session id yet — session_info events don't always arrive
34
+ // before other events do, so we buffer nothing and simply drop events
35
+ // that arrive before the id is known. First real event a re-attach sees
36
+ // is session_started; anything before that is renderer-only anyway.
37
+
38
+ let _current = null; // { sessionId, log }
39
+
40
+ function _openLogFor(sessionId) {
41
+ if (_current && _current.sessionId === sessionId) return _current.log;
42
+ if (_current) {
43
+ // Fire-and-forget close on the previous log. The write chain resolves
44
+ // internally; we don't await here because tapSseEvent is called from
45
+ // a hot loop that can't afford to block.
46
+ try { _current.log.close().catch(() => {}); } catch { /* ignore */ }
47
+ }
48
+ const log = createEventLog({ sessionId });
49
+ _current = { sessionId, log };
50
+ return log;
51
+ }
52
+
53
+ /** Test / bahulam-stop hook — close the active log and drop the ref. */
54
+ export async function closeActiveEventLog() {
55
+ if (!_current) return;
56
+ const { log } = _current;
57
+ _current = null;
58
+ try { await log.close(); } catch { /* ignore */ }
59
+ _broadcasters.length = 0;
60
+ }
61
+
62
+ // ── broadcasters (.4) ─────────────────────────────────────────
63
+ //
64
+ // The socket server registers itself here so live events fan out to
65
+ // attached clients as they're written to the durable log. Broadcasters
66
+ // are fire-and-forget; a slow/broken one MUST NOT slow down or block
67
+ // the tap. Each broadcaster is invoked in its own try/catch.
68
+ //
69
+ // Registration is process-global (one bahulamd = one process = one tap
70
+ // registry). Multiple socket servers CAN register; the tap doesn't care.
71
+
72
+ const _broadcasters = [];
73
+
74
+ /**
75
+ * Register a broadcaster. Returns an unregister function.
76
+ * The broadcaster receives the fully-formed event AFTER it has
77
+ * been written to the log (so `seq` and `ts` are populated).
78
+ */
79
+ export function registerBroadcaster(fn) {
80
+ if (typeof fn !== 'function') throw new Error('registerBroadcaster: fn must be a function');
81
+ _broadcasters.push(fn);
82
+ return () => {
83
+ const i = _broadcasters.indexOf(fn);
84
+ if (i >= 0) _broadcasters.splice(i, 1);
85
+ };
86
+ }
87
+
88
+ // ── SSE → event mapping ──────────────────────────────────────
89
+ //
90
+ // Table (protocol spec §5.1). Only events that map to a first-class
91
+ // type are logged. Everything else (status, thinking with
92
+ // empty text, phase_update, worker_update, etc.) is renderer-transient
93
+ // and doesn't belong in a durable event log.
94
+
95
+ const SSE_TO_PRD092 = Object.freeze({
96
+ session_info: 'session_started',
97
+ turn_started: 'turn_started',
98
+ turn_ended: 'turn_ended',
99
+ thinking: 'thinking_delta',
100
+ tool_request: 'tool_call',
101
+ tool_call: 'tool_call',
102
+ tool_result: 'tool_result',
103
+ tool_done: 'tool_result',
104
+ approval_required: 'approval_required',
105
+ approval_decided: 'approval_decided',
106
+ file_diff: 'diff',
107
+ diff: 'diff',
108
+ test_result: 'test_result',
109
+ tokens_used: 'tokens_used',
110
+ usage_update: 'usage_update',
111
+ complete: 'agent_complete',
112
+ });
113
+
114
+ // Some SSE event bodies carry more than we want to log. Trim what makes
115
+ // sense per type so we don't bloat the log with, e.g., a 200KB rendered
116
+ // tool output when a summary would do. Everything else round-trips as-is.
117
+ function _projectData(prd092Type, data) {
118
+ const src = data || {};
119
+ switch (prd092Type) {
120
+ case 'tool_call':
121
+ return {
122
+ name: src.tool || src.name,
123
+ tool_id: src.tool_call_id || src.tool_id || src.id,
124
+ args: src.args || src.tool_input || src.input || {},
125
+ subagent_id: src.subagent_id || undefined,
126
+ cwd: src.cwd || undefined,
127
+ };
128
+ case 'tool_result':
129
+ return {
130
+ tool_id: src.tool_call_id || src.tool_id || src.id,
131
+ ok: src.error ? false : (src.ok !== false),
132
+ summary: typeof src.output === 'string'
133
+ ? src.output.slice(0, 4096)
134
+ : (src.summary || undefined),
135
+ duration_ms: src.duration_ms || src.durationMs || undefined,
136
+ output_truncated: typeof src.output === 'string' && src.output.length > 4096 ? true : undefined,
137
+ };
138
+ case 'thinking_delta':
139
+ return { chunk: src.message || src.text || '' };
140
+ case 'session_started':
141
+ return {
142
+ cwd: src.cwd,
143
+ model: src.model,
144
+ product: src.product,
145
+ session_id_from_backend: src.session_id,
146
+ };
147
+ case 'agent_complete':
148
+ return { ok: src.error == null, summary: src.summary, duration_ms: src.duration_ms };
149
+ default:
150
+ return src;
151
+ }
152
+ }
153
+
154
+ // ── the tap ──────────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Tap one SSE event to the event log.
158
+ *
159
+ * @param {{type: string, data?: object}} event the SSE frame from stream-client
160
+ * @param {object} opts
161
+ * @param {string} opts.sessionId from session.id — required for log routing
162
+ * @param {string} [opts.turnId] optional; stamped on turn-scoped events
163
+ *
164
+ * Failure modes are ALL silent — a broken tap must never affect the
165
+ * user-facing render path. If the env var isn't set or sessionId is
166
+ * missing we skip; if the file write throws internally, event-log.mjs
167
+ * logs to stderr and drops the event.
168
+ */
169
+ export function tapSseEvent(event, { sessionId, turnId } = {}) {
170
+ if (!_enabled()) return;
171
+ if (!event || !event.type || !sessionId) return;
172
+ const prd092Type = SSE_TO_PRD092[event.type];
173
+ if (!prd092Type) return;
174
+ const log = _openLogFor(sessionId);
175
+ const data = _projectData(prd092Type, event.data);
176
+ let seq;
177
+ try {
178
+ seq = log.writeEvent(prd092Type, data, turnId ? { turnId } : undefined);
179
+ } catch (err) {
180
+ try { process.stderr.write(`[event-tap] writeEvent failed: ${err.message}\n`); } catch {}
181
+ return;
182
+ }
183
+ if (_broadcasters.length === 0) return;
184
+ const wireEvent = {
185
+ seq,
186
+ ts: new Date().toISOString(),
187
+ type: prd092Type,
188
+ session_id: sessionId,
189
+ v: 1,
190
+ ...(turnId ? { turn_id: turnId } : {}),
191
+ data,
192
+ };
193
+ for (const fn of _broadcasters) {
194
+ try { fn(wireEvent); }
195
+ catch (err) { try { process.stderr.write(`[event-tap] broadcaster failed: ${err.message}\n`); } catch {} }
196
+ }
197
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * — Input lock state (multi-attach input arbitration).
3
+ *
4
+ * Per PRD §6.5:
5
+ * - The first attach implicitly holds the input lock.
6
+ * - Later attaches join in WATCH mode (may render events, may
7
+ * approve/deny, may NOT send_message / interrupt / switch_model).
8
+ * - `take_input_lock` triggers a STEAL WITH GRACE:
9
+ * 1. Emit input_lock_changed { holder: current, pending_transfer: att_new }
10
+ * 2. Current holder has 3s to release_input_lock gracefully
11
+ * 3. After 3s (or on release) → holder := att_new,
12
+ * emit input_lock_changed { holder: att_new }
13
+ * - Approvals bypass the lock. That's an authorization act, not
14
+ * a typing act; enforced by the socket-server, not this module.
15
+ *
16
+ * State model:
17
+ * holder : attach_id | null currently owns typing input
18
+ * since : ISO-8601 UTC when holder was assigned
19
+ * pending : attach_id | null the challenger waiting on a steal
20
+ * pendingTimer: NodeTimeout | null the 3s grace timer
21
+ *
22
+ * All mutations go through the exported functions so the emitters
23
+ * (repl.mjs wiring) always publish an `input_lock_changed` event.
24
+ */
25
+
26
+ const GRACE_MS = 3000;
27
+
28
+ const _state = {
29
+ holder: null, // attach_id | null
30
+ since: null, // ISO string
31
+ pending: null, // attach_id | null
32
+ pendingTimer: null, // NodeTimeout | null
33
+ };
34
+
35
+ let _emit = null; // (type, data) → void — set by wireEmit()
36
+
37
+ /**
38
+ * Wire the event emitter. The daemon (repl.mjs) sets this to a function
39
+ * that writes to the event tap so `input_lock_changed` fans out to all
40
+ * attached clients.
41
+ */
42
+ export function wireEmit(emitFn) {
43
+ _emit = typeof emitFn === 'function' ? emitFn : null;
44
+ }
45
+
46
+ function _now() { return new Date().toISOString(); }
47
+
48
+ function _publish(extra = {}) {
49
+ if (!_emit) return;
50
+ try {
51
+ _emit('input_lock_changed', {
52
+ holder: _state.holder,
53
+ since: _state.since,
54
+ pending_transfer: _state.pending,
55
+ ...extra,
56
+ });
57
+ } catch { /* never blocks a state change */ }
58
+ }
59
+
60
+ // ── attach lifecycle ─────────────────────────────────────────────────
61
+
62
+ /**
63
+ * First attach auto-takes the lock. Later attaches join in watch mode.
64
+ * Call from the socket-server hello handler.
65
+ *
66
+ * @param {string} attachId
67
+ * @returns {{holder: string, kind: 'holder'|'watch'}}
68
+ */
69
+ export function onAttachJoined(attachId) {
70
+ if (_state.holder == null) {
71
+ _state.holder = attachId;
72
+ _state.since = _now();
73
+ _publish();
74
+ return { holder: attachId, kind: 'holder' };
75
+ }
76
+ return { holder: _state.holder, kind: 'watch' };
77
+ }
78
+
79
+ /**
80
+ * If the leaving attach was the holder, transfer the lock — to the pending
81
+ * challenger if there is one, else to nobody (holder=null). If it was a
82
+ * watcher, no state change.
83
+ */
84
+ export function onAttachLeft(attachId) {
85
+ const wasHolder = _state.holder === attachId;
86
+ const wasPending = _state.pending === attachId;
87
+ if (wasPending) _clearPending();
88
+ if (!wasHolder) return;
89
+ if (_state.pending) {
90
+ _state.holder = _state.pending;
91
+ _state.since = _now();
92
+ _clearPending();
93
+ } else {
94
+ _state.holder = null;
95
+ _state.since = null;
96
+ }
97
+ _publish();
98
+ }
99
+
100
+ // ── commands ─────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * `take_input_lock` command — steal-with-grace protocol.
104
+ *
105
+ * If the requester is already the holder → no-op (return current state).
106
+ * If there's no holder → immediate takeover.
107
+ * Otherwise → set pending, schedule 3s timer to force-transfer.
108
+ *
109
+ * Returns the state after the request is processed (not after the
110
+ * grace timer fires).
111
+ */
112
+ export function takeInputLock(attachId) {
113
+ if (_state.holder === attachId) {
114
+ return { holder: attachId, pending: null, immediate: true };
115
+ }
116
+ if (_state.holder == null) {
117
+ _state.holder = attachId;
118
+ _state.since = _now();
119
+ _publish();
120
+ return { holder: attachId, pending: null, immediate: true };
121
+ }
122
+ // Someone else already requested — drop their pending in favor of the
123
+ // newer one (last-writer-wins). Restart the grace timer.
124
+ _clearPending();
125
+ _state.pending = attachId;
126
+ _publish();
127
+ _state.pendingTimer = setTimeout(() => {
128
+ // Grace elapsed → force transfer. Read pending BEFORE _clearPending
129
+ // (which nulls it) so the check + assign happen against the value at
130
+ // schedule time, not the intermediate cleared state.
131
+ if (_state.pending !== attachId) return; // preempted by another take
132
+ _state.holder = attachId;
133
+ _state.since = _now();
134
+ _state.pending = null;
135
+ _state.pendingTimer = null;
136
+ _publish();
137
+ }, GRACE_MS);
138
+ // No .unref(): we want the daemon event loop to be kept alive by a
139
+ // pending grace transfer. In tests, connected sockets already keep
140
+ // the loop alive, so no diff. In prod, this prevents a bahulamd whose
141
+ // ONLY outstanding work is a grace-steal from exiting mid-transfer.
142
+ return { holder: _state.holder, pending: attachId, immediate: false, graceMs: GRACE_MS };
143
+ }
144
+
145
+ /**
146
+ * `release_input_lock` command — the current holder yields.
147
+ * If there's a pending challenger, they take the lock immediately.
148
+ * If not, the lock becomes null (next `take_input_lock` from anyone wins).
149
+ */
150
+ export function releaseInputLock(attachId) {
151
+ if (_state.holder !== attachId) return { ignored: true, reason: 'not_holder' };
152
+ if (_state.pending) {
153
+ _state.holder = _state.pending;
154
+ _state.since = _now();
155
+ _clearPending();
156
+ } else {
157
+ _state.holder = null;
158
+ _state.since = null;
159
+ }
160
+ _publish();
161
+ return { holder: _state.holder };
162
+ }
163
+
164
+ // ── query ────────────────────────────────────────────────────────────
165
+
166
+ /** Read current lock state. Used by socket-server to enforce writes. */
167
+ export function currentHolder() { return _state.holder; }
168
+ export function isHolder(attachId) { return _state.holder === attachId; }
169
+
170
+ /** For tests and `bahulam status`. */
171
+ export function snapshot() {
172
+ return {
173
+ holder: _state.holder,
174
+ since: _state.since,
175
+ pending: _state.pending,
176
+ };
177
+ }
178
+
179
+ /** Reset (tests only, or on daemon shutdown). */
180
+ export function resetInputLock() {
181
+ _clearPending();
182
+ _state.holder = null;
183
+ _state.since = null;
184
+ }
185
+
186
+ // ── internal ─────────────────────────────────────────────────────────
187
+
188
+ function _clearPending() {
189
+ if (_state.pendingTimer) { clearTimeout(_state.pendingTimer); _state.pendingTimer = null; }
190
+ _state.pending = null;
191
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Relay WebSocket client — connects to the gateway relay and routes
3
+ * session events to attached CLI clients.
4
+ *
5
+ * On connect the client authenticates with a bearer token, then
6
+ * registers as a device on each active session. Events from the
7
+ * relay are dispatched to the appropriate session handler. * • Heartbeat / keepalive via WebSocket ping (20s).
8
+ * • Kill-switch check: BAHULAM_RELAY_KILL=1 env var, plus config
9
+ * `remote.enabled = false` polled every 10s from the file so
10
+ * `bahulam remote disable` in another terminal drops us fast.
11
+ *
12
+ * Deferred (Phase 2.5):
13
+ * • ChaCha20-Poly1305 AEAD payload encryption. My envelope shape is
14
+ * already compatible — swap the `control` field for `aead: {...}`
15
+ * without changing the transport.
16
+ * • X25519 wrap key derivation from paired peer_pubkeys.
17
+ * • Signed command verification (Ed25519 signatures INSIDE the aead
18
+ * payload per PRD §4).
19
+ * • Session-key rotation.
20
+ *
21
+ * Non-fatal design principle: any relay error MUST NOT affect the local
22
+ * session. If the relay is down / unreachable / kicks us, the daemon
23
+ * keeps running for local attaches. Reconnect quietly in the background.
24
+ */
25
+
26
+ const RESET = '\x1b[0m';
27
+ const DIM = '\x1b[2m';
28
+ const YELLOW = '\x1b[33m';
29
+ const GREEN = '\x1b[32m';
30
+
31
+ const HEARTBEAT_MS = 20_000;
32
+ const REVOKE_POLL_MS = 10_000;
33
+ const BACKOFF_BASE_MS = 250;
34
+ const BACKOFF_CAP_MS = 30_000;
35
+
36
+ /**
37
+ * Start the relay bridge for a session. Returns { stop } — call to
38
+ * cleanly disconnect. Safe to call even without a valid config; if
39
+ * `remote.enabled = false` we log a note and return a stop() no-op.
40
+ *
41
+ * @param {object} opts
42
+ * @param {string} opts.sessionId
43
+ * @param {object} opts.remoteConfig — from remote.mjs loadRemoteConfig()
44
+ * @param {Function} opts.registerBroadcaster — from event-tap.mjs
45
+ * @param {object} opts.onCommand — { approve, deny, interrupt, send_message, ... }
46
+ * same shape as socket-server's onCommand.
47
+ */
48
+ export function startRelayBridge({ sessionId, remoteConfig, registerBroadcaster, onCommand = {} } = {}) {
49
+ if (!remoteConfig?.enabled) {
50
+ return { stop: async () => {} };
51
+ }
52
+ if (!remoteConfig.device_id || !remoteConfig.token) {
53
+ process.stderr.write(`${YELLOW}[relay] remote enabled but device or token missing — skipping.${RESET}\n`);
54
+ return { stop: async () => {} };
55
+ }
56
+ if (typeof globalThis.WebSocket !== 'function') {
57
+ process.stderr.write(`${YELLOW}[relay] WebSocket not available in this Node runtime (need ≥22). Skipping.${RESET}\n`);
58
+ return { stop: async () => {} };
59
+ }
60
+
61
+ const url = _buildUrl(remoteConfig.relay_url, sessionId, remoteConfig.device_id, remoteConfig.token);
62
+ const state = {
63
+ stopped: false,
64
+ ws: null,
65
+ reconnectAttempt: 0,
66
+ heartbeatTimer: null,
67
+ revokeTimer: null,
68
+ unregisterBroadcaster: null,
69
+ };
70
+
71
+ function _log(msg) {
72
+ try { process.stderr.write(`${DIM}[relay] ${msg}${RESET}\n`); } catch {}
73
+ }
74
+
75
+ function _envelope(controlBody, to = 'session') {
76
+ return JSON.stringify({
77
+ v: 1,
78
+ sess: sessionId,
79
+ from: remoteConfig.device_id,
80
+ to,
81
+ // No AEAD until Phase 2.5 — send as a `control` field which
82
+ // gateway's relay.py forwards untouched (see routes/relay.py:_send_envelope).
83
+ // Payload is one event/command exactly as it would appear
84
+ // on the local socket, but plaintext for now.
85
+ control: controlBody,
86
+ });
87
+ }
88
+
89
+ function _send(controlBody) {
90
+ if (!state.ws || state.ws.readyState !== 1 /* OPEN */) return false;
91
+ try { state.ws.send(_envelope(controlBody)); return true; }
92
+ catch (err) { _log(`send failed: ${err.message}`); return false; }
93
+ }
94
+
95
+ function _connect() {
96
+ if (state.stopped) return;
97
+ if (process.env.BAHULAM_RELAY_KILL === '1') {
98
+ _log('BAHULAM_RELAY_KILL=1 — refusing to dial');
99
+ return;
100
+ }
101
+ _log(`dial ${url.replace(/token=[^&]+/, 'token=***')}`);
102
+ const ws = new WebSocket(url, ['bahulam.v1']);
103
+ state.ws = ws;
104
+
105
+ ws.addEventListener('open', () => {
106
+ state.reconnectAttempt = 0;
107
+ try { process.stderr.write(`${GREEN}[relay] connected${RESET}\n`); } catch {}
108
+ // Send a `daemon_hello` control frame so peers (mobile) know who joined.
109
+ _send({ type: 'daemon_hello', device_id: remoteConfig.device_id, session_id: sessionId });
110
+ _startHeartbeat();
111
+ _startRevokePoll();
112
+ });
113
+
114
+ ws.addEventListener('message', ev => {
115
+ let env;
116
+ try { env = JSON.parse(String(ev.data)); }
117
+ catch { return; }
118
+ // Ignore our own broadcasts echoed back.
119
+ if (env.from === remoteConfig.device_id) return;
120
+ const control = env.control;
121
+ if (!control || !control.type) return;
122
+ _dispatchIncoming(control, env.from);
123
+ });
124
+
125
+ ws.addEventListener('close', ev => {
126
+ _stopHeartbeat();
127
+ state.ws = null;
128
+ if (state.stopped) return;
129
+ // Codes: 1008 policy (bad auth / bad envelope) → don't reconnect.
130
+ if (ev && (ev.code === 1008 || ev.code === 4001 || ev.code === 4003)) {
131
+ _log(`closed with policy code ${ev.code} (${ev.reason || 'no reason'}) — not reconnecting`);
132
+ return;
133
+ }
134
+ _scheduleReconnect(ev?.code);
135
+ });
136
+
137
+ ws.addEventListener('error', err => {
138
+ _log(`ws error: ${err?.message || err}`);
139
+ // 'close' will fire after, which triggers reconnect.
140
+ });
141
+ }
142
+
143
+ function _scheduleReconnect(closeCode) {
144
+ if (state.stopped) return;
145
+ state.reconnectAttempt += 1;
146
+ const base = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * Math.pow(2, state.reconnectAttempt - 1));
147
+ const jitter = base * 0.5 * (Math.random() - 0.5); // ±25%
148
+ const wait = Math.max(BACKOFF_BASE_MS, Math.floor(base + jitter));
149
+ _log(`reconnect in ${wait}ms (attempt ${state.reconnectAttempt}${closeCode ? `, close ${closeCode}` : ''})`);
150
+ setTimeout(() => { if (!state.stopped) _connect(); }, wait);
151
+ }
152
+
153
+ function _startHeartbeat() {
154
+ _stopHeartbeat();
155
+ state.heartbeatTimer = setInterval(() => {
156
+ // WebSocket doesn't have a JS-level ping in the browser API. Send a
157
+ // small control-frame keepalive instead. Gateway routes as any other
158
+ // envelope; peer clients can ignore type=keepalive.
159
+ _send({ type: 'keepalive', ts: Date.now() });
160
+ }, HEARTBEAT_MS);
161
+ if (typeof state.heartbeatTimer.unref === 'function') state.heartbeatTimer.unref();
162
+ }
163
+
164
+ function _stopHeartbeat() {
165
+ if (state.heartbeatTimer) { clearInterval(state.heartbeatTimer); state.heartbeatTimer = null; }
166
+ }
167
+
168
+ function _startRevokePoll() {
169
+ _stopRevokePoll();
170
+ state.revokeTimer = setInterval(() => {
171
+ // Re-read the local config; if remote was disabled or the device
172
+ // is missing, drop the connection. Fast reaction to `bahulam remote
173
+ // disable` in another terminal, or to `bahulam device revoke <self>`
174
+ // which clears the pairing block.
175
+ try {
176
+ const fresh = _reloadConfig();
177
+ if (!fresh?.enabled || fresh?.device_id !== remoteConfig.device_id) {
178
+ _log('local config changed (disabled or device mismatch) — disconnecting');
179
+ stop();
180
+ }
181
+ } catch { /* ignore, next poll retries */ }
182
+ }, REVOKE_POLL_MS);
183
+ if (typeof state.revokeTimer.unref === 'function') state.revokeTimer.unref();
184
+ }
185
+
186
+ function _stopRevokePoll() {
187
+ if (state.revokeTimer) { clearInterval(state.revokeTimer); state.revokeTimer = null; }
188
+ }
189
+
190
+ function _dispatchIncoming(control, fromDevice) {
191
+ const handler = onCommand[control.type];
192
+ if (typeof handler !== 'function') {
193
+ _log(`unknown control type from ${fromDevice}: ${control.type}`);
194
+ return;
195
+ }
196
+ // Pass fromDevice as the attach id so decisions get attributed.
197
+ Promise.resolve()
198
+ .then(() => handler(control.data || {}, `relay:${fromDevice}`))
199
+ .catch(err => _log(`handler for ${control.type} failed: ${err.message}`));
200
+ }
201
+
202
+ async function stop() {
203
+ if (state.stopped) return;
204
+ state.stopped = true;
205
+ _stopHeartbeat();
206
+ _stopRevokePoll();
207
+ if (state.unregisterBroadcaster) { try { state.unregisterBroadcaster(); } catch {} }
208
+ if (state.ws) {
209
+ try { state.ws.close(1000, 'client_stop'); } catch {}
210
+ state.ws = null;
211
+ }
212
+ }
213
+
214
+ // Register a broadcaster that forwards every daemon event to the relay.
215
+ // Return the unregister so stop() can clean up.
216
+ if (typeof registerBroadcaster === 'function') {
217
+ state.unregisterBroadcaster = registerBroadcaster(evt => {
218
+ // Forward every event as a control-frame envelope so mobile sees
219
+ // the same §5.1 shape it does from the mock relay.
220
+ _send(evt);
221
+ });
222
+ }
223
+
224
+ _connect();
225
+ return { stop, _debug: state };
226
+ }
227
+
228
+ // ── helpers ──────────────────────────────────────────────────────────
229
+
230
+ function _buildUrl(relayBase, sessionId, deviceId, token) {
231
+ // relay_url from config is like "wss://relay.bahulam.ai" or
232
+ // "wss://gateway.bahulam.ai" (the router mounts /relay/*). We append
233
+ // /relay/session/<sess_id>?device=&token= regardless.
234
+ const base = String(relayBase || '').replace(/\/+$/, '');
235
+ const enc = encodeURIComponent;
236
+ return `${base}/relay/session/${enc(sessionId)}?device=${enc(deviceId)}&token=${enc(token)}`;
237
+ }
238
+
239
+ // Re-read the config from disk on each poll so the daemon reacts to
240
+ // out-of-band changes (bahulam remote disable, bahulam device revoke)
241
+ // without needing a signal. Lazy dynamic import: cache the resolved
242
+ // loader function so subsequent polls are one function call each.
243
+ let _loadRemoteConfigCached = null;
244
+ let _loadRemoteConfigPromise = null;
245
+
246
+ function _reloadConfig() {
247
+ if (_loadRemoteConfigCached) return _loadRemoteConfigCached();
248
+ // First call: kick off the import in the background. Subsequent polls
249
+ // pick up the cached function once it's resolved. This poll returns
250
+ // null in the meantime (no config → stay connected, which is safe;
251
+ // the next poll in 10s will have the loader ready).
252
+ if (!_loadRemoteConfigPromise) {
253
+ _loadRemoteConfigPromise = import('../commands/remote.mjs')
254
+ .then(mod => { _loadRemoteConfigCached = mod.loadRemoteConfig || (() => null); })
255
+ .catch(() => { _loadRemoteConfigCached = () => null; });
256
+ }
257
+ return null;
258
+ }