@bahulam/code 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/package.json +8 -9
- package/pulse/lib/tool-categories.ts +13 -0
- package/src/commands/device.mjs +121 -0
- package/src/commands/pair.mjs +190 -0
- package/src/commands/remote.mjs +110 -0
- package/src/config/env.mjs +2 -2
- package/src/core/event-log.mjs +393 -0
- package/src/core/headless.mjs +198 -0
- package/src/core/loop.mjs +276 -0
- package/src/core/memory-disk.mjs +210 -0
- package/src/core/paths.mjs +36 -0
- package/src/core/stream-client.mjs +28 -9
- package/src/core/tool-executor.mjs +64 -16
- package/src/daemon/approval-store.mjs +253 -0
- package/src/daemon/attach-client.mjs +361 -0
- package/src/daemon/daemonize.mjs +151 -0
- package/src/daemon/event-tap.mjs +197 -0
- package/src/daemon/input-lock.mjs +191 -0
- package/src/daemon/relay-client.mjs +258 -0
- package/src/daemon/session-core.mjs +179 -0
- package/src/daemon/session-list.mjs +26 -0
- package/src/daemon/session-publisher.mjs +78 -0
- package/src/daemon/socket-server.mjs +329 -0
- package/src/daemon/stop-daemon.mjs +18 -0
- package/src/permissions/checker.mjs +6 -6
- package/src/permissions/prompt.mjs +8 -7
- package/src/skills/installer.mjs +8 -0
- package/src/terminal/ansi.mjs +85 -9
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl.mjs +389 -6
- package/src/terminal/skills-picker.mjs +121 -0
- package/src/terminal/skills.mjs +3 -3
- package/src/tools/analyze-code.mjs +39 -0
- package/src/tools/bash.mjs +1 -1
- package/src/tools/edit.mjs +18 -18
- package/src/tools/git-diff.mjs +34 -0
- package/src/tools/git-status.mjs +30 -0
- package/src/tools/glob.mjs +5 -2
- package/src/tools/grep.mjs +1 -1
- package/src/tools/meta-tools.mjs +85 -0
- package/src/tools/read-files.mjs +37 -0
- package/src/tools/read.mjs +20 -10
- package/src/tools/registry.mjs +20 -0
- package/src/tools/remember.mjs +147 -0
- package/src/tools/search-files.mjs +41 -0
- package/src/tools/write-project.mjs +62 -0
- package/src/tools/write.mjs +1 -1
- package/src/ui/banner.mjs +1 -1
- package/src/ui/slash-commands.mjs +16 -0
- package/src/ui/sub-agent.mjs +8 -2
- package/src/ui/transcript-block.mjs +4 -1
|
@@ -0,0 +1,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
|
+
}
|
package/src/config/env.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|