@bahulam/code 0.1.1 → 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.
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/package.json +8 -9
- 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/config/env.mjs +2 -2
- 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 +64 -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/skills/installer.mjs +8 -0
- package/src/terminal/ansi.mjs +85 -9
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl.mjs +389 -6
- package/src/terminal/skills-picker.mjs +121 -0
- package/src/terminal/skills.mjs +3 -3
- 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/banner.mjs +1 -1
- package/src/ui/slash-commands.mjs +16 -0
- package/src/ui/sub-agent.mjs +8 -2
- package/src/ui/transcript-block.mjs +4 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §6.1 / §6.8 — SessionCore: the daemon-owned view of a session.
|
|
3
|
+
*
|
|
4
|
+
* The daemon (bahulamd, .3+) owns:
|
|
5
|
+
* 1. The agent loop (SSE consumer → tool executor → event dispatch).
|
|
6
|
+
* 2. The event log ( §6.4).
|
|
7
|
+
* 3. The message history + cumulative usage — everything an attach
|
|
8
|
+
* client needs to reconstruct the transcript on reconnect.
|
|
9
|
+
*
|
|
10
|
+
* The attach client () owns everything rendering-related:
|
|
11
|
+
* spinner, per-line dedup, block-boundary tracking, sub-agent tool
|
|
12
|
+
* window, input-history for arrow keys, one-shot toasts, etc.
|
|
13
|
+
*
|
|
14
|
+
* repl-state.mjs already holds both categories mixed together (see the
|
|
15
|
+
* `session` and `runtime` exports). This module is the *classification*
|
|
16
|
+
* of which fields the daemon persists across attach/detach and which
|
|
17
|
+
* belong to the attached client — plus a small snapshot/restore API
|
|
18
|
+
* bahulamd will call to write/read the periodic snapshot-<seq>.json
|
|
19
|
+
* files that seed a re-attaching client.
|
|
20
|
+
*
|
|
21
|
+
* IMPORTANT — cache invariance ( §7 note we keep repeating):
|
|
22
|
+
* NOTHING in this module gets sent to the LLM. The snapshot is written
|
|
23
|
+
* to local disk only. The daemon's outbound HTTP body to /api/execute
|
|
24
|
+
* is constructed exactly the same way it is today — via existing code
|
|
25
|
+
* paths that read session.agentHistory / session.messages. The
|
|
26
|
+
* BAHULAM_CAPTURE_REQUEST hook (see stream-client.mjs) exists so a
|
|
27
|
+
* later slice can hard-assert that byte identity.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { session as sharedSession } from '../terminal/repl-state.mjs';
|
|
31
|
+
|
|
32
|
+
// ── field classification ─────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Daemon-owned session fields. These persist across detach/attach and
|
|
36
|
+
* appear in snapshot-<seq>.json. Order and shape match repl-state.mjs
|
|
37
|
+
* so callers don't have to translate.
|
|
38
|
+
*
|
|
39
|
+
* A field belongs here if:
|
|
40
|
+
* • Its value is the source of truth for what happened in the session
|
|
41
|
+
* (message history, cumulative usage, cost, files touched, …).
|
|
42
|
+
* • A re-attaching client needs it to render an accurate transcript.
|
|
43
|
+
* • It doesn't change based on which attach is watching.
|
|
44
|
+
*/
|
|
45
|
+
export const DAEMON_OWNED_FIELDS = Object.freeze([
|
|
46
|
+
'id',
|
|
47
|
+
'startTime',
|
|
48
|
+
'inputTokens',
|
|
49
|
+
'outputTokens',
|
|
50
|
+
'toolCalls',
|
|
51
|
+
'subAgentToolCalls',
|
|
52
|
+
'totalToolCalls',
|
|
53
|
+
'totalPrimaryToolCalls',
|
|
54
|
+
'totalSubAgentToolCalls',
|
|
55
|
+
'turns',
|
|
56
|
+
'history',
|
|
57
|
+
'agentHistory',
|
|
58
|
+
'user',
|
|
59
|
+
'model',
|
|
60
|
+
'modelLimits',
|
|
61
|
+
'blockedOps',
|
|
62
|
+
'delegations',
|
|
63
|
+
'phases',
|
|
64
|
+
'filesChanged',
|
|
65
|
+
'filesRead',
|
|
66
|
+
'lastTurnDuration',
|
|
67
|
+
'toolCounts',
|
|
68
|
+
'subAgentCounts',
|
|
69
|
+
'savedUsd',
|
|
70
|
+
'lastTask',
|
|
71
|
+
'lastReasoning',
|
|
72
|
+
'budgetUsd',
|
|
73
|
+
'budgetExceeded',
|
|
74
|
+
'costBreakdown',
|
|
75
|
+
'totalCost',
|
|
76
|
+
'costAccurate',
|
|
77
|
+
'modelOverrides',
|
|
78
|
+
'modelMode',
|
|
79
|
+
'routePreference',
|
|
80
|
+
'isByok',
|
|
81
|
+
'subscriptionTier',
|
|
82
|
+
'creditsTotal',
|
|
83
|
+
'creditsIncluded',
|
|
84
|
+
'creditsPurchased',
|
|
85
|
+
'creditsLimit',
|
|
86
|
+
'creditsCharged',
|
|
87
|
+
'rateLimit',
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Client-owned session fields. These belong to whichever attach is
|
|
92
|
+
* currently connected and MUST NOT be included in snapshots — replaying
|
|
93
|
+
* them on re-attach would produce wrong UX (e.g. a "low credits" toast
|
|
94
|
+
* shown twice, arrow-key history from a stranger's terminal).
|
|
95
|
+
*
|
|
96
|
+
* A field belongs here if:
|
|
97
|
+
* • It exists to dedup a per-attach UI effect (`*_LowWarned` flags).
|
|
98
|
+
* • It's input state tied to a keyboard (`inputHistory`).
|
|
99
|
+
* • It's a rendering flag whose value depends on the current visible
|
|
100
|
+
* transcript, not the session's actual state (`inSubAgent`).
|
|
101
|
+
*/
|
|
102
|
+
export const CLIENT_OWNED_SESSION_FIELDS = Object.freeze([
|
|
103
|
+
'inputHistory',
|
|
104
|
+
'inSubAgent',
|
|
105
|
+
'creditsLowWarned',
|
|
106
|
+
'msgsLowWarned',
|
|
107
|
+
'_lastEmittedThinking',
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `runtime` object in repl-state.mjs is ENTIRELY client-owned.
|
|
112
|
+
* It's stream buffers, spinner frames, explore-run counters, sub-agent
|
|
113
|
+
* live windows — all rendering. Listed here for completeness; the
|
|
114
|
+
* daemon never reads or writes runtime.*.
|
|
115
|
+
*/
|
|
116
|
+
export const CLIENT_OWNED_RUNTIME_FIELDS = Object.freeze([
|
|
117
|
+
'streamBuffer', 'streamedPartialText', 'streamTimer',
|
|
118
|
+
'renderedContentThisTurn', 'contentHeaderPrinted', 'afterContentFlush',
|
|
119
|
+
'pendingHead', 'lastRenderedBlock', 'renderedToolResults', 'renderedFileDiffPreviews',
|
|
120
|
+
'exploreRun', 'foldedSubAgentTools',
|
|
121
|
+
'spinInterval', 'spinText', 'spinFrame', 'spinPhase', 'spinStartedAt', 'spinToolCalls',
|
|
122
|
+
'subAgentWindow',
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
// ── snapshot / restore ───────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract the daemon-owned fields from a session-like object into a
|
|
129
|
+
* plain, JSON-serializable snapshot. Use `sharedSession` by default so
|
|
130
|
+
* bahulamd can call `snapshotSession()` with no arg and get the right
|
|
131
|
+
* thing; tests can pass an alternate source.
|
|
132
|
+
*
|
|
133
|
+
* Missing fields are omitted (not written as `undefined`) so the
|
|
134
|
+
* snapshot file stays clean. Nested objects/arrays are shallow-copied
|
|
135
|
+
* — callers that mutate history/agentHistory after snapshotting will
|
|
136
|
+
* see the mutation reflected. That's fine for the daemon (single
|
|
137
|
+
* writer, snapshot-then-continue pattern), but tests that reuse
|
|
138
|
+
* snapshots across mutations should structuredClone() the return.
|
|
139
|
+
*/
|
|
140
|
+
export function snapshotSession(source = sharedSession) {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const key of DAEMON_OWNED_FIELDS) {
|
|
143
|
+
if (source[key] !== undefined) out[key] = source[key];
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Apply a snapshot back onto a session-like object. Only DAEMON_OWNED
|
|
150
|
+
* fields are copied — a maliciously crafted snapshot that includes
|
|
151
|
+
* runtime.* or CLIENT_OWNED_SESSION_FIELDS is silently ignored to
|
|
152
|
+
* prevent snapshot injection from replaying per-attach UI state.
|
|
153
|
+
*
|
|
154
|
+
* Fields the snapshot doesn't mention are left unchanged on `target`
|
|
155
|
+
* (not zeroed out). That way a partial snapshot from an older schema
|
|
156
|
+
* version still restores what it knows without wiping newer fields.
|
|
157
|
+
*/
|
|
158
|
+
export function restoreSession(snapshot, target = sharedSession) {
|
|
159
|
+
if (!snapshot || typeof snapshot !== 'object') return target;
|
|
160
|
+
for (const key of DAEMON_OWNED_FIELDS) {
|
|
161
|
+
if (Object.prototype.hasOwnProperty.call(snapshot, key)) {
|
|
162
|
+
target[key] = snapshot[key];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return target;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── introspection ────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Classify a single field name. Useful in tests to guarantee every
|
|
172
|
+
* field in repl-state.mjs is accounted for (nothing accidentally lives
|
|
173
|
+
* in "neither" — new fields must be classified when added).
|
|
174
|
+
*/
|
|
175
|
+
export function classifyField(name) {
|
|
176
|
+
if (DAEMON_OWNED_FIELDS.includes(name)) return 'daemon';
|
|
177
|
+
if (CLIENT_OWNED_SESSION_FIELDS.includes(name)) return 'client';
|
|
178
|
+
return 'unclassified';
|
|
179
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
* Session list — reads local session metadata from ~/.bahulam/sessions/
|
|
3
|
+
* and presents them for attach/stop operations. try {
|
|
4
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
5
|
+
const sessions = [];
|
|
6
|
+
for (const entry of entries) {
|
|
7
|
+
if (!entry.isDirectory() || !entry.name.startsWith('sess_')) continue;
|
|
8
|
+
try {
|
|
9
|
+
const meta = JSON.parse(await readFile(join(dir, entry.name, 'meta.json'), 'utf-8'));
|
|
10
|
+
sessions.push({ id: entry.name, ...meta });
|
|
11
|
+
} catch { /* no meta.json, skip */ }
|
|
12
|
+
}
|
|
13
|
+
if (sessions.length === 0) {
|
|
14
|
+
process.stderr.write('No daemon sessions.\n');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const s of sessions) {
|
|
18
|
+
const pid = s.pid ? ` (pid ${s.pid})` : '';
|
|
19
|
+
const cwd = s.cwd || '?';
|
|
20
|
+
const model = s.model || '?';
|
|
21
|
+
process.stderr.write(` ${s.id} ${cwd} ${model}${pid}\n`);
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {
|
|
24
|
+
process.stderr.write(`No daemon sessions: ${err.message}\n`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* . — session_directory publisher (daemon side).
|
|
3
|
+
*
|
|
4
|
+
* POSTs to the gateway's /v1/session-directory endpoint on session
|
|
5
|
+
* lifecycle transitions so mobile clients + the web dashboard can see
|
|
6
|
+
* daemons that aren't currently connected to the relay.
|
|
7
|
+
*
|
|
8
|
+
* The gateway's /relay/sessions endpoint shows LIVE relay connections
|
|
9
|
+
* (in-memory hub state). This publisher covers the gap: daemons that
|
|
10
|
+
* started, ran, and now sit idle waiting for input still appear.
|
|
11
|
+
*
|
|
12
|
+
* Fire-and-forget: a publish failure MUST NOT interrupt the turn or
|
|
13
|
+
* crash the daemon. Errors log to stderr and the next transition retries.
|
|
14
|
+
*
|
|
15
|
+
* Called by:
|
|
16
|
+
* - src/core/headless.mjs on session_info (status=running)
|
|
17
|
+
* - src/core/headless.mjs on agent_complete (status=idle if held,
|
|
18
|
+
* closed if exiting)
|
|
19
|
+
* - src/terminal/repl.mjs on session_info (running) and REPL exit (closed)
|
|
20
|
+
*
|
|
21
|
+
* We hit the GATEWAY (BAHULAM_GATEWAY_URL) rather than Supabase directly
|
|
22
|
+
* because (a) the daemon has a Bahulam bearer token, not a Supabase
|
|
23
|
+
* JWT, and (b) the gateway already knows how to resolve that bearer to
|
|
24
|
+
* a user_id via /internal/whoami. Same trust chain as pair/device/remote.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as os from 'node:os';
|
|
28
|
+
|
|
29
|
+
const GATEWAY = (process.env.BAHULAM_GATEWAY_URL || 'https://gateway.bahulam.ai').replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
30
|
+
|
|
31
|
+
/** One publish. Returns nothing; errors log to stderr. */
|
|
32
|
+
export async function publishSessionDirectory({
|
|
33
|
+
sessionId,
|
|
34
|
+
token,
|
|
35
|
+
cwd = null,
|
|
36
|
+
model = null,
|
|
37
|
+
hostHint = null,
|
|
38
|
+
status = 'running',
|
|
39
|
+
origin = 'local',
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (!sessionId || !token) return;
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`${GATEWAY}/v1/session-directory`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${token}`,
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
session_id: sessionId,
|
|
51
|
+
origin,
|
|
52
|
+
host_hint: hostHint || _defaultHostHint(),
|
|
53
|
+
cwd_display: cwd,
|
|
54
|
+
model,
|
|
55
|
+
status,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text().catch(() => '');
|
|
60
|
+
try { process.stderr.write(`[session-directory] publish ${status} failed (${res.status}): ${body.slice(0, 200)}\n`); } catch {}
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
try { process.stderr.write(`[session-directory] network error: ${err.message}\n`); } catch {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Convenience: mark a session closed. Best-effort; call from exit paths. */
|
|
68
|
+
export async function markSessionClosed({ sessionId, token }) {
|
|
69
|
+
return publishSessionDirectory({ sessionId, token, status: 'closed' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _defaultHostHint() {
|
|
73
|
+
try {
|
|
74
|
+
const user = process.env.USER || process.env.USERNAME || 'user';
|
|
75
|
+
const host = (os.hostname() || 'host').split('.')[0];
|
|
76
|
+
return `${user}@${host}`;
|
|
77
|
+
} catch { return 'unknown'; }
|
|
78
|
+
}
|