@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,110 @@
1
+ /**
2
+ * Remote relay control.
3
+ *
4
+ * bahulam remote enable Turn on the relay connection.
5
+ * Flips config to enable outbound relay dial.
6
+ *
7
+ * bahulam remote disable Turn off the relay connection (kill switch).
8
+ * Disabling drops the relay connection within
9
+ * one heartbeat interval.
10
+ *
11
+ * IMPORTANT: this command only flips a flag. The actual relay dial and
12
+ * disconnect lives in the daemon. Enabling does not guarantee a
13
+ * connection — the daemon must be running. * daemon needs a device_id + keypair to authenticate to the relay.
14
+ */
15
+
16
+ import { TarangAuth } from '../auth/tarang-auth.mjs';
17
+
18
+ const RESET = '\x1b[0m';
19
+ const BOLD = '\x1b[1m';
20
+ const DIM = '\x1b[2m';
21
+ const RED = '\x1b[31m';
22
+ const GREEN = '\x1b[32m';
23
+ const YELLOW = '\x1b[33m';
24
+
25
+ const DEFAULT_RELAY = (process.env.BAHULAM_RELAY_URL || 'wss://relay.bahulam.ai').replace(/\/+$/, '');
26
+
27
+ export async function runRemoteCommand(args = []) {
28
+ const sub = (args[0] || '').toLowerCase();
29
+ const auth = new TarangAuth();
30
+ auth.loadCredentials();
31
+
32
+ if (sub === 'enable') return _enable(auth);
33
+ if (sub === 'disable') return _disable(auth);
34
+ if (sub === 'status') return _status(auth);
35
+
36
+ process.stderr.write(`${BOLD}bahulam remote${RESET} ${DIM}— control the remote relay connection${RESET}\n\n`);
37
+ process.stderr.write(` bahulam remote enable Connect the daemon to the relay for remote monitoring/control\n`);
38
+ process.stderr.write(` bahulam remote disable Kill switch — drops relay connection within one heartbeat\n`);
39
+ process.stderr.write(` bahulam remote status Show current remote-connection state\n`);
40
+ return 1;
41
+ }
42
+
43
+ function _enable(auth) {
44
+ const config = auth.getRawConfig();
45
+ if (!config.pairing?.device_id) {
46
+ process.stderr.write(`${YELLOW}⚠ this device is not paired yet.${RESET}\n`);
47
+ process.stderr.write(` ${DIM}Run ${BOLD}bahulam pair get-code${RESET}${DIM} (this device) or ${BOLD}bahulam pair <code>${RESET}${DIM} (with a code).${RESET}\n`);
48
+ process.stderr.write(` ${DIM}Enable will still write the flag, but the daemon can't connect without a device.${RESET}\n\n`);
49
+ }
50
+ auth.saveCredentials({
51
+ remote: {
52
+ enabled: true,
53
+ relay_url: config.remote?.relay_url || DEFAULT_RELAY,
54
+ enabled_at: new Date().toISOString(),
55
+ },
56
+ });
57
+ process.stderr.write(`${GREEN}✓ remote enabled${RESET}\n`);
58
+ process.stderr.write(` ${DIM}relay:${RESET} ${config.remote?.relay_url || DEFAULT_RELAY}\n`);
59
+ process.stderr.write(` ${DIM}A running daemon will pick this up on next session start.${RESET}\n\n`);
60
+ return 0;
61
+ }
62
+
63
+ function _disable(auth) {
64
+ const config = auth.getRawConfig();
65
+ auth.saveCredentials({
66
+ remote: {
67
+ ...(config.remote || {}),
68
+ enabled: false,
69
+ disabled_at: new Date().toISOString(),
70
+ },
71
+ });
72
+ process.stderr.write(`${GREEN}✓ remote disabled${RESET} ${DIM}(kill switch)${RESET}\n`);
73
+ process.stderr.write(` ${DIM}Any running daemon drops its relay connection within one heartbeat.${RESET}\n\n`);
74
+ return 0;
75
+ }
76
+
77
+ function _status(auth) {
78
+ const config = auth.getRawConfig();
79
+ const r = config.remote || {};
80
+ const p = config.pairing || {};
81
+ process.stderr.write(`\n${BOLD}remote${RESET}\n`);
82
+ process.stderr.write(` ${DIM}enabled:${RESET} ${r.enabled ? GREEN + 'yes' + RESET : DIM + 'no' + RESET}\n`);
83
+ process.stderr.write(` ${DIM}relay:${RESET} ${r.relay_url || DEFAULT_RELAY}\n`);
84
+ if (r.enabled_at) process.stderr.write(` ${DIM}since:${RESET} ${r.enabled_at}\n`);
85
+ if (r.disabled_at && !r.enabled) process.stderr.write(` ${DIM}stopped:${RESET} ${r.disabled_at}\n`);
86
+ process.stderr.write(` ${DIM}device:${RESET} ${p.device_id ? `${p.device_id} (${p.device_name})` : DIM + 'not paired' + RESET}\n\n`);
87
+ return 0;
88
+ }
89
+
90
+ /**
91
+ * Read-only helper for the daemon. Returns the effective
92
+ * remote config: { enabled, relay_url, device_id, private_key,
93
+ * public_key, peer_pubkeys } — or null if we shouldn't dial.
94
+ */
95
+ export function loadRemoteConfig() {
96
+ const auth = new TarangAuth();
97
+ const config = auth.getRawConfig() || auth.loadCredentials() && auth.getRawConfig();
98
+ if (!config?.remote?.enabled) return null;
99
+ if (!config?.pairing?.device_id) return null;
100
+ return {
101
+ enabled: true,
102
+ relay_url: config.remote.relay_url || DEFAULT_RELAY,
103
+ device_id: config.pairing.device_id,
104
+ private_key: config.pairing.private_key,
105
+ public_key: config.pairing.public_key,
106
+ pubkey_base64: config.pairing.pubkey_base64,
107
+ peer_pubkeys: config.pairing.peer_pubkeys || {},
108
+ token: auth.loadCredentials().token,
109
+ };
110
+ }
@@ -35,7 +35,7 @@ export const ENV_SCHEMA = {
35
35
 
36
36
  // Permission and Security
37
37
  CLAUDE_CODE_PERMISSION_MODE: { type: 'string', default: 'default', description: 'Permission mode' },
38
- CLAUDE_CODE_SANDBOX: { type: 'boolean', default: true, description: 'Enable sandbox' },
38
+ BAHULAM_SANDBOX: { type: 'boolean', default: true, description: 'Wrap contained/high-risk shell commands in the OS sandbox (bwrap/seatbelt)' },
39
39
 
40
40
  // Context and Memory
41
41
  CLAUDE_CODE_MAX_CONTEXT_TOKENS: { type: 'number', default: 180000, description: 'Max context window tokens' },
@@ -81,7 +81,7 @@ export const ENV_SCHEMA = {
81
81
  ANTHROPIC_AUTH_TOKEN: { type: 'string', description: 'Anthropic auth token (OAuth)' },
82
82
 
83
83
  // Extended: Sandbox & Security
84
- CLAUDE_CODE_SANDBOX_PLATFORM: { type: 'string', description: 'Override sandbox platform (linux/darwin)' },
84
+ BAHULAM_SANDBOX_PLATFORM: { type: 'string', description: 'Override sandbox platform (linux/darwin)' },
85
85
  CLAUDE_CODE_INJECTION_CHECK: { type: 'boolean', default: true, description: 'Enable command injection checks' },
86
86
  CLAUDE_CODE_PATH_CHECK: { type: 'boolean', default: true, description: 'Enable file path validation' },
87
87
 
@@ -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
+ }