@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.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +39 -0
  3. package/package.json +8 -9
  4. package/pulse/lib/tool-categories.ts +13 -0
  5. package/src/commands/device.mjs +121 -0
  6. package/src/commands/pair.mjs +190 -0
  7. package/src/commands/remote.mjs +110 -0
  8. package/src/config/env.mjs +2 -2
  9. package/src/core/event-log.mjs +393 -0
  10. package/src/core/headless.mjs +198 -0
  11. package/src/core/loop.mjs +276 -0
  12. package/src/core/memory-disk.mjs +210 -0
  13. package/src/core/paths.mjs +36 -0
  14. package/src/core/stream-client.mjs +28 -9
  15. package/src/core/tool-executor.mjs +64 -16
  16. package/src/daemon/approval-store.mjs +253 -0
  17. package/src/daemon/attach-client.mjs +361 -0
  18. package/src/daemon/daemonize.mjs +151 -0
  19. package/src/daemon/event-tap.mjs +197 -0
  20. package/src/daemon/input-lock.mjs +191 -0
  21. package/src/daemon/relay-client.mjs +258 -0
  22. package/src/daemon/session-core.mjs +179 -0
  23. package/src/daemon/session-list.mjs +26 -0
  24. package/src/daemon/session-publisher.mjs +78 -0
  25. package/src/daemon/socket-server.mjs +329 -0
  26. package/src/daemon/stop-daemon.mjs +18 -0
  27. package/src/permissions/checker.mjs +6 -6
  28. package/src/permissions/prompt.mjs +8 -7
  29. package/src/skills/installer.mjs +8 -0
  30. package/src/terminal/ansi.mjs +85 -9
  31. package/src/terminal/main.mjs +97 -3
  32. package/src/terminal/repl.mjs +389 -6
  33. package/src/terminal/skills-picker.mjs +121 -0
  34. package/src/terminal/skills.mjs +3 -3
  35. package/src/tools/analyze-code.mjs +39 -0
  36. package/src/tools/bash.mjs +1 -1
  37. package/src/tools/edit.mjs +18 -18
  38. package/src/tools/git-diff.mjs +34 -0
  39. package/src/tools/git-status.mjs +30 -0
  40. package/src/tools/glob.mjs +5 -2
  41. package/src/tools/grep.mjs +1 -1
  42. package/src/tools/meta-tools.mjs +85 -0
  43. package/src/tools/read-files.mjs +37 -0
  44. package/src/tools/read.mjs +20 -10
  45. package/src/tools/registry.mjs +20 -0
  46. package/src/tools/remember.mjs +147 -0
  47. package/src/tools/search-files.mjs +41 -0
  48. package/src/tools/write-project.mjs +62 -0
  49. package/src/tools/write.mjs +1 -1
  50. package/src/ui/banner.mjs +1 -1
  51. package/src/ui/slash-commands.mjs +16 -0
  52. package/src/ui/sub-agent.mjs +8 -2
  53. 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
+ }