@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.
Files changed (45) hide show
  1. package/package.json +5 -8
  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-render.mjs +21 -8
  29. package/src/terminal/repl.mjs +201 -2
  30. package/src/tools/analyze-code.mjs +39 -0
  31. package/src/tools/bash.mjs +1 -1
  32. package/src/tools/edit.mjs +18 -18
  33. package/src/tools/git-diff.mjs +34 -0
  34. package/src/tools/git-status.mjs +30 -0
  35. package/src/tools/glob.mjs +5 -2
  36. package/src/tools/grep.mjs +1 -1
  37. package/src/tools/meta-tools.mjs +85 -0
  38. package/src/tools/read-files.mjs +37 -0
  39. package/src/tools/read.mjs +20 -10
  40. package/src/tools/registry.mjs +20 -0
  41. package/src/tools/remember.mjs +147 -0
  42. package/src/tools/search-files.mjs +41 -0
  43. package/src/tools/write-project.mjs +62 -0
  44. package/src/tools/write.mjs +1 -1
  45. package/src/ui/sub-agent.mjs +8 -2
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Append-only event log for daemon-owned sessions.
3
+ *
4
+ * This module is the DURABLE side of the daemon. Every event the agent loop
5
+ * emits (tool calls, approvals, diffs, usage updates, …) is written here so
6
+ * that a detached-then-reattached client can reconstruct exactly what
7
+ * happened while nobody was watching.
8
+ *
9
+ * • Append-only, monotonic seq per session — never rewrite an earlier line.
10
+ * • Line-delimited JSON — one event per line, `JSON.parse` per line.
11
+ * • Rotate at ~100MB → events-1.jsonl, events-2.jsonl, … `events.jsonl`
12
+ * is always the live tail. Readers concatenate rolled files in order
13
+ * when resolving `sinceSeq` older than the current tail's first seq.
14
+ * • Snapshot every N events → `snapshot-<seq>.json` — a compacted view
15
+ * of session state so an attach client can seed from the snapshot and
16
+ * only stream events with seq > snapshot.seq.
17
+ * • Buffered writes (batch every FLUSH_INTERVAL_MS or on close) — writes
18
+ * are best-effort in Phase 1; loss of the last few events on an OS
19
+ * crash is acceptable, but ORDERING never breaks (append + monotonic
20
+ * counter guarantees it).
21
+ * • Perm 0600 on files, 0700 on the session directory — this is user
22
+ * data and may include tool arguments, code diffs, etc.
23
+ *
24
+ * NOT in scope for Slice A:
25
+ * • Wiring into the REPL / stream-client — Slice A is the writer +
26
+ * reader + snapshot API only. Slice B adds the daemon that calls it.
27
+ * • Encryption at rest — the local file is `0600` in the user's home;
28
+ * the wire-encrypted variant lands with the Phase 2 relay.
29
+ * • Session id minting — `mintSessionId()` here is the ONE approved
30
+ * source, so the daemon and the CLI agree on format, but ids are
31
+ * assigned wherever a session is created (Slice B).
32
+ */
33
+
34
+ import * as fs from 'node:fs';
35
+ import * as path from 'node:path';
36
+ import { randomBytes } from 'node:crypto';
37
+
38
+ import { daemonSessionDir, daemonSessionsRoot } from './paths.mjs';
39
+
40
+ // ── constants ────────────────────────────────────────────────────────
41
+
42
+ const EVENTS_FILE = 'events.jsonl'; // live tail
43
+ const EVENTS_ROLLED_PREFIX = 'events-'; // events-1.jsonl, events-2.jsonl, ...
44
+ const SNAPSHOT_PREFIX = 'snapshot-'; // snapshot-<seq>.json
45
+ const META_FILE = 'meta.json';
46
+ const SEQ_FILE = '.seq'; // last seq written (source of truth on restart)
47
+
48
+ /** Rotate the live tail when it grows past this many bytes. */
49
+ const DEFAULT_ROTATE_AT_BYTES = 100 * 1024 * 1024;
50
+
51
+ /** Flush the write buffer at most this often. */
52
+ const FLUSH_INTERVAL_MS = 250;
53
+
54
+ /** Schema version stamped on every event; readers reject unknown majors. */
55
+ export const EVENT_SCHEMA_V = 1;
56
+
57
+ // ── session id ───────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Mint a new session id in the `sess_<time36>_<rand>` format.
61
+ * Lexicographically sortable by wall time (good enough for filesystem
62
+ * listings and grouping), collision-safe with 48 bits of randomness.
63
+ * No external ULID dependency — Node's crypto is enough.
64
+ */
65
+ export function mintSessionId() {
66
+ const t = Date.now().toString(36).padStart(9, '0');
67
+ const r = randomBytes(6).toString('hex');
68
+ return `sess_${t}_${r}`;
69
+ }
70
+
71
+ // ── writer ────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Create an EventLog bound to one session directory. Multiple daemons
75
+ * MUST NOT open the same session — locking is enforced at the daemon
76
+ * level (via daemon.pid), not here.
77
+ *
78
+ * @param {object} opts
79
+ * @param {string} opts.sessionId e.g. "sess_..."
80
+ * @param {string} [opts.sessionDir] override the default path
81
+ * (~/.bahulam/sessions/<id>/)
82
+ * @param {number} [opts.rotateAtBytes] default 100MB
83
+ * @param {number} [opts.flushIntervalMs] default 250ms
84
+ * @returns {{
85
+ * sessionId: string,
86
+ * dir: string,
87
+ * writeEvent(type: string, data: object, opts?: {turnId?: string, ts?: string}): number,
88
+ * flush(): Promise<void>,
89
+ * close(): Promise<void>,
90
+ * currentSeq(): number,
91
+ * liveTailPath(): string,
92
+ * }}
93
+ */
94
+ export function createEventLog({
95
+ sessionId,
96
+ sessionDir,
97
+ rotateAtBytes = DEFAULT_ROTATE_AT_BYTES,
98
+ flushIntervalMs = FLUSH_INTERVAL_MS,
99
+ } = {}) {
100
+ if (!sessionId) throw new Error('createEventLog: sessionId is required');
101
+ const dir = sessionDir || daemonSessionDir(sessionId);
102
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
103
+
104
+ // Recover the last seq from disk. If .seq is missing (fresh session or
105
+ // interrupted crash before any write), scan the live tail to find it.
106
+ let seq = _recoverSeq(dir);
107
+ let livePath = path.join(dir, EVENTS_FILE);
108
+ let liveBytes = _fileSize(livePath);
109
+
110
+ const buffer = [];
111
+ let flushTimer = null;
112
+ let flushChain = Promise.resolve();
113
+ let closed = false;
114
+
115
+ function writeEvent(type, data, extra = {}) {
116
+ if (closed) throw new Error('event log is closed');
117
+ if (typeof type !== 'string' || !type) {
118
+ throw new Error('writeEvent: type must be a non-empty string');
119
+ }
120
+ seq += 1;
121
+ const evt = {
122
+ seq,
123
+ ts: extra.ts || new Date().toISOString(),
124
+ type,
125
+ session_id: sessionId,
126
+ v: EVENT_SCHEMA_V,
127
+ ...(extra.turnId ? { turn_id: extra.turnId } : {}),
128
+ data: data == null ? {} : data,
129
+ };
130
+ const line = JSON.stringify(evt) + '\n';
131
+ buffer.push(line);
132
+ _scheduleFlush();
133
+ return seq;
134
+ }
135
+
136
+ function _scheduleFlush() {
137
+ if (flushTimer || closed) return;
138
+ flushTimer = setTimeout(() => { _flushNow().catch(() => {}); }, flushIntervalMs);
139
+ }
140
+
141
+ async function _flushNow() {
142
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
143
+ if (buffer.length === 0) return;
144
+ const pending = buffer.splice(0, buffer.length).join('');
145
+ const target = livePath;
146
+ flushChain = flushChain.then(async () => {
147
+ try {
148
+ await fs.promises.appendFile(target, pending, { mode: 0o600 });
149
+ liveBytes += Buffer.byteLength(pending, 'utf-8');
150
+ // Persist seq AFTER the append lands so recovery never overreads.
151
+ await fs.promises.writeFile(path.join(dir, SEQ_FILE), String(seq), { mode: 0o600 });
152
+ if (liveBytes >= rotateAtBytes) await _rotate();
153
+ } catch (err) {
154
+ // Local logging is best-effort; do not throw into the daemon loop.
155
+ // A follow-up flush will retry the same buffer content — no, wait,
156
+ // we already consumed it. Log to stderr so an operator can spot
157
+ // repeated failures (disk full, perm error, etc).
158
+ try { process.stderr.write(`[event-log] flush failed: ${err.message}\n`); } catch {}
159
+ }
160
+ });
161
+ await flushChain;
162
+ }
163
+
164
+ async function _rotate() {
165
+ // Find the next rolled index. Simple linear scan; sessions rarely
166
+ // roll more than a handful of times.
167
+ let idx = 1;
168
+ while (fs.existsSync(path.join(dir, `${EVENTS_ROLLED_PREFIX}${idx}.jsonl`))) idx += 1;
169
+ const rolled = path.join(dir, `${EVENTS_ROLLED_PREFIX}${idx}.jsonl`);
170
+ try {
171
+ await fs.promises.rename(livePath, rolled);
172
+ liveBytes = 0;
173
+ } catch (err) {
174
+ // Rotation failed — keep writing to the current tail; it will just be
175
+ // larger than the target. Not catastrophic.
176
+ try { process.stderr.write(`[event-log] rotate failed: ${err.message}\n`); } catch {}
177
+ }
178
+ }
179
+
180
+ async function flush() { await _flushNow(); await flushChain; }
181
+
182
+ async function close() {
183
+ closed = true;
184
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
185
+ await _flushNow();
186
+ await flushChain;
187
+ }
188
+
189
+ return {
190
+ sessionId,
191
+ dir,
192
+ writeEvent,
193
+ flush,
194
+ close,
195
+ currentSeq: () => seq,
196
+ liveTailPath: () => livePath,
197
+ };
198
+ }
199
+
200
+ // ── reader ────────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Async iterator over events with `seq > sinceSeq`. Walks all rolled
204
+ * files first, then the live tail, in seq order. Malformed lines are
205
+ * skipped with a stderr warning rather than throwing — a torn last
206
+ * line (crash mid-flush) shouldn't prevent replay of the good events
207
+ * before it.
208
+ *
209
+ * Not a live tail — this only reads what is on disk at the moment the
210
+ * iterator advances. A separate "watch" API can be added later if
211
+ * needed, but the daemon's socket server can just piggyback on
212
+ * writeEvent() to broadcast live to attached clients.
213
+ *
214
+ * @param {object} opts
215
+ * @param {string} opts.sessionId e.g. "sess_..."
216
+ * @param {string} [opts.sessionDir] override
217
+ * @param {number} [opts.sinceSeq=0] only yield events with seq > sinceSeq
218
+ * @param {number} [opts.maxEvents] stop after this many
219
+ * @returns {AsyncGenerator<object>}
220
+ */
221
+ export async function* readEvents({ sessionId, sessionDir, sinceSeq = 0, maxEvents } = {}) {
222
+ if (!sessionId) throw new Error('readEvents: sessionId is required');
223
+ const dir = sessionDir || daemonSessionDir(sessionId);
224
+ const files = _listEventFilesInOrder(dir);
225
+ let yielded = 0;
226
+ for (const filePath of files) {
227
+ for await (const line of _readLines(filePath)) {
228
+ if (!line) continue;
229
+ let evt;
230
+ try { evt = JSON.parse(line); } catch {
231
+ try { process.stderr.write(`[event-log] skipping malformed line in ${filePath}\n`); } catch {}
232
+ continue;
233
+ }
234
+ if (typeof evt.seq !== 'number' || evt.seq <= sinceSeq) continue;
235
+ yield evt;
236
+ yielded += 1;
237
+ if (maxEvents && yielded >= maxEvents) return;
238
+ }
239
+ }
240
+ }
241
+
242
+ /** Convenience: collect readEvents() into an array. */
243
+ export async function readAllEvents(opts) {
244
+ const out = [];
245
+ for await (const e of readEvents(opts)) out.push(e);
246
+ return out;
247
+ }
248
+
249
+ // ── snapshots ─────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * Write a snapshot of session state at a given seq. Callers decide
253
+ * what "state" means — usually the message history + turn state +
254
+ * cumulative usage. Files are named `snapshot-<seq>.json` so the
255
+ * latest is easy to find with a directory listing.
256
+ *
257
+ * @param {object} opts
258
+ * @param {string} opts.sessionId
259
+ * @param {string} [opts.sessionDir]
260
+ * @param {number} opts.seq the seq this snapshot summarizes UP TO
261
+ * @param {object} opts.state arbitrary JSON-serializable snapshot
262
+ * @returns {Promise<string>} path written
263
+ */
264
+ export async function writeSnapshot({ sessionId, sessionDir, seq, state }) {
265
+ if (!sessionId) throw new Error('writeSnapshot: sessionId is required');
266
+ if (typeof seq !== 'number') throw new Error('writeSnapshot: seq must be a number');
267
+ const dir = sessionDir || daemonSessionDir(sessionId);
268
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
269
+ const p = path.join(dir, `${SNAPSHOT_PREFIX}${String(seq).padStart(12, '0')}.json`);
270
+ const body = { seq, ts: new Date().toISOString(), v: EVENT_SCHEMA_V, state };
271
+ await fs.promises.writeFile(p, JSON.stringify(body), { mode: 0o600 });
272
+ return p;
273
+ }
274
+
275
+ /**
276
+ * Read the highest-seq snapshot for a session, or null if none exists.
277
+ * Attach clients call this first to seed their renderer, then readEvents()
278
+ * with `sinceSeq = snapshot.seq` to catch up.
279
+ */
280
+ export async function readLatestSnapshot({ sessionId, sessionDir } = {}) {
281
+ const dir = sessionDir || daemonSessionDir(sessionId);
282
+ let entries;
283
+ try { entries = await fs.promises.readdir(dir); } catch { return null; }
284
+ const snaps = entries
285
+ .filter(n => n.startsWith(SNAPSHOT_PREFIX) && n.endsWith('.json'))
286
+ .sort();
287
+ if (snaps.length === 0) return null;
288
+ const latest = snaps[snaps.length - 1];
289
+ try {
290
+ const raw = await fs.promises.readFile(path.join(dir, latest), 'utf-8');
291
+ return JSON.parse(raw);
292
+ } catch { return null; }
293
+ }
294
+
295
+ // ── session meta ──────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * Write or update session meta. Meta is the "what and where" — cwd,
299
+ * model, product, opened_at, closed_at, and anything else the daemon
300
+ * needs on next attach to explain itself to a client.
301
+ */
302
+ export async function writeSessionMeta({ sessionId, sessionDir, meta }) {
303
+ const dir = sessionDir || daemonSessionDir(sessionId);
304
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
305
+ const p = path.join(dir, META_FILE);
306
+ let existing = {};
307
+ try { existing = JSON.parse(await fs.promises.readFile(p, 'utf-8')); } catch {}
308
+ const merged = { ...existing, ...meta, session_id: sessionId };
309
+ await fs.promises.writeFile(p, JSON.stringify(merged, null, 2), { mode: 0o600 });
310
+ return merged;
311
+ }
312
+
313
+ export async function readSessionMeta({ sessionId, sessionDir } = {}) {
314
+ const dir = sessionDir || daemonSessionDir(sessionId);
315
+ try {
316
+ const raw = await fs.promises.readFile(path.join(dir, META_FILE), 'utf-8');
317
+ return JSON.parse(raw);
318
+ } catch { return null; }
319
+ }
320
+
321
+ // ── session discovery ────────────────────────────────────────────────
322
+
323
+ /**
324
+ * List all session ids visible under ~/.bahulam/sessions/. Used by
325
+ * `bahulam list`. Cheap — one readdir, no per-session parsing.
326
+ */
327
+ export async function listSessionIds() {
328
+ const root = daemonSessionsRoot();
329
+ let entries;
330
+ try { entries = await fs.promises.readdir(root, { withFileTypes: true }); }
331
+ catch { return []; }
332
+ return entries
333
+ .filter(e => e.isDirectory() && e.name.startsWith('sess_'))
334
+ .map(e => e.name)
335
+ .sort();
336
+ }
337
+
338
+ // ── internals ─────────────────────────────────────────────────────────
339
+
340
+ function _fileSize(p) {
341
+ try { return fs.statSync(p).size; } catch { return 0; }
342
+ }
343
+
344
+ function _listEventFilesInOrder(dir) {
345
+ let entries;
346
+ try { entries = fs.readdirSync(dir); } catch { return []; }
347
+ const rolled = entries
348
+ .filter(n => n.startsWith(EVENTS_ROLLED_PREFIX) && n.endsWith('.jsonl'))
349
+ .sort((a, b) => {
350
+ const na = parseInt(a.slice(EVENTS_ROLLED_PREFIX.length), 10);
351
+ const nb = parseInt(b.slice(EVENTS_ROLLED_PREFIX.length), 10);
352
+ return na - nb;
353
+ })
354
+ .map(n => path.join(dir, n));
355
+ const live = path.join(dir, EVENTS_FILE);
356
+ return fs.existsSync(live) ? [...rolled, live] : rolled;
357
+ }
358
+
359
+ function _recoverSeq(dir) {
360
+ // Fast path: read .seq if present.
361
+ const seqFile = path.join(dir, SEQ_FILE);
362
+ try {
363
+ const raw = fs.readFileSync(seqFile, 'utf-8').trim();
364
+ const n = Number(raw);
365
+ if (Number.isFinite(n) && n >= 0) return n;
366
+ } catch {}
367
+ // Slow path: scan the tail of the live file for the last valid seq.
368
+ // Only runs when .seq is missing — first-write or crash mid-write.
369
+ const live = path.join(dir, EVENTS_FILE);
370
+ try {
371
+ const raw = fs.readFileSync(live, 'utf-8');
372
+ let lastSeq = 0;
373
+ for (const line of raw.split('\n')) {
374
+ if (!line) continue;
375
+ try {
376
+ const evt = JSON.parse(line);
377
+ if (typeof evt.seq === 'number' && evt.seq > lastSeq) lastSeq = evt.seq;
378
+ } catch { /* torn last line */ }
379
+ }
380
+ return lastSeq;
381
+ } catch { return 0; }
382
+ }
383
+
384
+ // Async line reader — small enough that pulling in `readline` is overkill.
385
+ // Buffers whole file into memory; fine for logs up to the rotation limit.
386
+ async function* _readLines(filePath) {
387
+ let raw;
388
+ try { raw = await fs.promises.readFile(filePath, 'utf-8'); }
389
+ catch { return; }
390
+ for (const line of raw.split('\n')) {
391
+ yield line;
392
+ }
393
+ }
@@ -17,6 +17,19 @@ import { buildWorkScope, promptProjectRoots } from './work-scope.mjs';
17
17
  import { persistProjectArtifacts } from './project-artifacts.mjs';
18
18
  import { TarangAuth } from '../auth/tarang-auth.mjs';
19
19
  import { ApprovalManager } from './approval.mjs';
20
+ // daemon wiring — headless (and `bahulam daemonize`) also starts the socket
21
+ // server + relay bridge when eventlog is enabled. Without this the daemon
22
+ // is invisible to attach clients and to paired mobile devices.
23
+ import { tapSseEvent, registerBroadcaster } from '../daemon/event-tap.mjs';
24
+ import { startSocketServer } from '../daemon/socket-server.mjs';
25
+ import { resolvePending } from '../daemon/approval-store.mjs';
26
+ import { startRelayBridge } from '../daemon/relay-client.mjs';
27
+ import { loadRemoteConfig } from '../commands/remote.mjs';
28
+ import { writeSessionMeta } from './event-log.mjs';
29
+ import { daemonSessionDir } from './paths.mjs';
30
+ import { publishSessionDirectory, markSessionClosed } from '../daemon/session-publisher.mjs';
31
+ import * as fsSync from 'node:fs';
32
+ import * as pathSync from 'node:path';
20
33
  import {
21
34
  appendVisionAnalysisToInstruction,
22
35
  prepareImageAttachments,
@@ -174,6 +187,9 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
174
187
  const subAgents = []; // { type, model, duration_s, tool_calls, success }
175
188
  let stagnationCount = 0;
176
189
  let usage = {}; // { input_tokens, output_tokens, cache_read, cache_write }
190
+ // daemon wiring — one-shot per headless invocation.
191
+ let prd092Started = false;
192
+ let currentSessionId = null;
177
193
 
178
194
  try {
179
195
  for await (const event of client.execute(instruction, execContext)) {
@@ -305,7 +321,110 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
305
321
  // Surface session_id in the JSONL so multi-turn harnesses can
306
322
  // capture it from turn N and forward on turn N+1 (via TARANG_SESSION_ID).
307
323
  if (data?.session_id) emit({ type: 'session_info', session_id: data.session_id });
324
+
325
+ // — same daemon wiring the interactive REPL does on
326
+ // session_info: start the socket server so attach clients can
327
+ // connect, tap events, and (if remote is enabled) dial the
328
+ // relay so mobile can see the session. Gated by env var, one-
329
+ // shot per process, fire-and-forget so a wire failure never
330
+ // interrupts the turn.
331
+ const _sid = data?.session_id;
332
+ if (
333
+ _sid &&
334
+ process.env.BAHULAM_DAEMON_EVENTLOG === '1' &&
335
+ !prd092Started
336
+ ) {
337
+ prd092Started = true;
338
+ (async () => {
339
+ try {
340
+ const server = await startSocketServer({
341
+ sessionId: _sid,
342
+ onCommand: {
343
+ approve: async (payload, attachId) => resolvePending('approve', payload?.apr_id, attachId, payload?.note),
344
+ deny: async (payload, attachId) => resolvePending('deny', payload?.apr_id, attachId, payload?.note),
345
+ interrupt: async () => { try { if (typeof client?.cancel === 'function') client.cancel(); } catch {} },
346
+ send_message: async () => { /* Slice C follow-up */ },
347
+ },
348
+ });
349
+ registerBroadcaster(evt => server.broadcastEvent(evt));
350
+
351
+ // Write meta.json + daemon.pid so `bahulam list` /
352
+ // `bahulam stop` and the mobile session directory
353
+ // can find this session.
354
+ try {
355
+ await writeSessionMeta({
356
+ sessionId: _sid,
357
+ meta: {
358
+ cwd: process.cwd(),
359
+ model: options.model || null,
360
+ pid: process.pid,
361
+ sock_path: server.sockPath,
362
+ opened_at: new Date().toISOString(),
363
+ headless: true,
364
+ },
365
+ });
366
+ fsSync.writeFileSync(
367
+ pathSync.join(daemonSessionDir(_sid), 'daemon.pid'),
368
+ String(process.pid),
369
+ { mode: 0o600 },
370
+ );
371
+ } catch (err) {
372
+ try { process.stderr.write(`[prd-092] meta/pid write: ${err.message}\n`); } catch {}
373
+ }
374
+
375
+ // Slice M — publish to session_directory so mobile
376
+ // can list this session even before/after the
377
+ // relay handshake. Fire-and-forget; a failure
378
+ // here doesn't affect anything else.
379
+ try {
380
+ publishSessionDirectory({
381
+ sessionId: _sid,
382
+ token: creds.token,
383
+ cwd: process.cwd(),
384
+ model: options.model || null,
385
+ status: 'running',
386
+ }).catch(() => {});
387
+ } catch { /* silent */ }
388
+
389
+ // Optional relay dial (Slice H) — only when the
390
+ // user opted in via `bahulam remote enable`.
391
+ try {
392
+ const remoteCfg = loadRemoteConfig();
393
+ if (remoteCfg?.enabled) {
394
+ startRelayBridge({
395
+ sessionId: _sid,
396
+ remoteConfig: remoteCfg,
397
+ registerBroadcaster,
398
+ onCommand: {
399
+ approve: async (payload, attachId) => resolvePending('approve', payload?.apr_id, attachId, payload?.note),
400
+ deny: async (payload, attachId) => resolvePending('deny', payload?.apr_id, attachId, payload?.note),
401
+ interrupt: async () => { try { if (typeof client?.cancel === 'function') client.cancel(); } catch {} },
402
+ send_message: async () => { /* Slice C follow-up */ },
403
+ },
404
+ });
405
+ }
406
+ } catch (err) {
407
+ try { process.stderr.write(`[prd-092] relay bridge: ${err.message}\n`); } catch {}
408
+ }
409
+ } catch (err) {
410
+ try { process.stderr.write(`[prd-092] socket server: ${err.message}\n`); } catch {}
411
+ }
412
+ })();
413
+ }
414
+ // Tap this event too, so the very first frame lands in the
415
+ // event log (before broadcasters are registered).
416
+ if (_sid && process.env.BAHULAM_DAEMON_EVENTLOG === '1') {
417
+ tapSseEvent({ type: 'session_info', data }, { sessionId: _sid });
418
+ }
419
+ } else if (process.env.BAHULAM_DAEMON_EVENTLOG === '1') {
420
+ // Tap every other event too, matching the REPL's for-await tap.
421
+ // sessionId is populated once session_info has landed.
422
+ if (currentSessionId) {
423
+ tapSseEvent(event, { sessionId: currentSessionId });
424
+ }
308
425
  }
426
+ // Track current session id for the tap.
427
+ if (type === 'session_info' && data?.session_id) currentSessionId = data.session_id;
309
428
 
310
429
  if (type === 'complete') {
311
430
  if (data?.rate_limit) rateLimit = data.rate_limit;
@@ -456,5 +575,84 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
456
575
  process.stderr.write(`\n--- Response ---\n${finalContent.slice(0, 2000)}\n`);
457
576
  }
458
577
 
578
+ // — DAEMON_HOLD mode.
579
+ // BAHULAM_DAEMON_HOLD=1 keep alive forever
580
+ // BAHULAM_DAEMON_HOLD=<seconds> keep alive for N seconds
581
+ // BAHULAM_DAEMON_SPAWNED=1 (implicit) hold with a default TTL if
582
+ // HOLD not explicitly set
583
+ //
584
+ // When held, we DO NOT exit after agent_complete. The socket server and
585
+ // relay bridge (started earlier by the session_info wiring) stay up so
586
+ // attach clients and paired mobile devices can still see the session,
587
+ // reconnect, replay events from disk, and — once send_message is wired
588
+ // — kick off follow-up turns without spinning up a fresh daemon.
589
+ //
590
+ // Exit signals honored: SIGTERM (from `bahulam stop <id>`), SIGINT
591
+ // (Ctrl-C from a foreground shell), the TTL timer, and the idle-exit
592
+ // policy from §6.6 (30-min default with no attach).
593
+ if (prd092Started) {
594
+ const hold = process.env.BAHULAM_DAEMON_HOLD;
595
+ const spawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
596
+ const holdMode = hold != null || spawned;
597
+ if (holdMode) {
598
+ // Slice M — mark idle in the session directory so the mobile
599
+ // list shows the session as available-but-not-active.
600
+ try {
601
+ publishSessionDirectory({
602
+ sessionId: currentSessionId,
603
+ token: creds.token,
604
+ cwd: process.cwd(),
605
+ model: options.model || null,
606
+ status: 'idle',
607
+ }).catch(() => {});
608
+ } catch { /* silent */ }
609
+
610
+ const ttlSec = _resolveHoldTtl(hold, spawned);
611
+ const banner = ttlSec === Infinity ? 'until stopped' : `for ${ttlSec}s`;
612
+ log(`[prd-092] daemon holding ${banner}. Attach: bahulam attach ${currentSessionId || '<id>'}`);
613
+ await _blockUntilStop(ttlSec);
614
+ log('[prd-092] daemon exiting (hold expired or signal received)');
615
+ }
616
+ // Whether held or not, mark the session closed in the directory
617
+ // so mobile doesn't list a gone-forever daemon as "idle" forever.
618
+ try {
619
+ await markSessionClosed({ sessionId: currentSessionId, token: creds.token });
620
+ } catch { /* silent */ }
621
+ }
622
+
459
623
  process.exit(0);
460
624
  }
625
+
626
+ // ── daemon-hold helpers ─────────────────────────────────────
627
+
628
+ /**
629
+ * How long to hold. Explicit HOLD wins; else spawned-default is 30min
630
+ * (§6.6 idle_ttl). Infinity for "1" or "true".
631
+ */
632
+ function _resolveHoldTtl(holdEnv, spawned) {
633
+ if (holdEnv === '1' || holdEnv === 'true') return Infinity;
634
+ if (holdEnv != null && holdEnv !== '') {
635
+ const n = Number(holdEnv);
636
+ if (Number.isFinite(n) && n > 0) return n;
637
+ }
638
+ if (spawned) return 30 * 60; // §6.6 default idle_ttl
639
+ return 60; // defensive fallback
640
+ }
641
+
642
+ /**
643
+ * Await SIGTERM/SIGINT or the TTL. Resolves without an error either way —
644
+ * the caller then falls through to process.exit(0) so bahulam stop looks
645
+ * like a clean shutdown, not a crash.
646
+ */
647
+ function _blockUntilStop(ttlSec) {
648
+ return new Promise(resolve => {
649
+ let done = false;
650
+ const _done = () => { if (done) return; done = true; resolve(); };
651
+ process.once('SIGTERM', _done);
652
+ process.once('SIGINT', _done);
653
+ if (ttlSec !== Infinity) {
654
+ const t = setTimeout(_done, ttlSec * 1000);
655
+ if (typeof t.unref === 'function') t.unref();
656
+ }
657
+ });
658
+ }