@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,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
|
+
}
|
package/src/core/headless.mjs
CHANGED
|
@@ -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
|
+
}
|