@bahulam/code 0.1.2 → 0.1.4
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.
- package/package.json +5 -8
- package/pulse/lib/tool-categories.ts +13 -0
- package/src/commands/device.mjs +121 -0
- package/src/commands/pair.mjs +190 -0
- package/src/commands/remote.mjs +110 -0
- package/src/core/event-log.mjs +393 -0
- package/src/core/headless.mjs +198 -0
- package/src/core/loop.mjs +276 -0
- package/src/core/memory-disk.mjs +210 -0
- package/src/core/paths.mjs +36 -0
- package/src/core/stream-client.mjs +28 -9
- package/src/core/tool-executor.mjs +56 -16
- package/src/daemon/approval-store.mjs +253 -0
- package/src/daemon/attach-client.mjs +361 -0
- package/src/daemon/daemonize.mjs +151 -0
- package/src/daemon/event-tap.mjs +197 -0
- package/src/daemon/input-lock.mjs +191 -0
- package/src/daemon/relay-client.mjs +258 -0
- package/src/daemon/session-core.mjs +179 -0
- package/src/daemon/session-list.mjs +26 -0
- package/src/daemon/session-publisher.mjs +78 -0
- package/src/daemon/socket-server.mjs +329 -0
- package/src/daemon/stop-daemon.mjs +18 -0
- package/src/permissions/checker.mjs +6 -6
- package/src/permissions/prompt.mjs +8 -7
- package/src/terminal/ansi.mjs +20 -3
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl-render.mjs +21 -8
- package/src/terminal/repl.mjs +201 -2
- package/src/tools/analyze-code.mjs +39 -0
- package/src/tools/bash.mjs +1 -1
- package/src/tools/edit.mjs +18 -18
- package/src/tools/git-diff.mjs +34 -0
- package/src/tools/git-status.mjs +30 -0
- package/src/tools/glob.mjs +5 -2
- package/src/tools/grep.mjs +1 -1
- package/src/tools/meta-tools.mjs +85 -0
- package/src/tools/read-files.mjs +37 -0
- package/src/tools/read.mjs +20 -10
- package/src/tools/registry.mjs +20 -0
- package/src/tools/remember.mjs +147 -0
- package/src/tools/search-files.mjs +41 -0
- package/src/tools/write-project.mjs +62 -0
- package/src/tools/write.mjs +1 -1
- 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
|
+
}
|