@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,179 @@
1
+ /**
2
+ * §6.1 / §6.8 — SessionCore: the daemon-owned view of a session.
3
+ *
4
+ * The daemon (bahulamd, .3+) owns:
5
+ * 1. The agent loop (SSE consumer → tool executor → event dispatch).
6
+ * 2. The event log ( §6.4).
7
+ * 3. The message history + cumulative usage — everything an attach
8
+ * client needs to reconstruct the transcript on reconnect.
9
+ *
10
+ * The attach client () owns everything rendering-related:
11
+ * spinner, per-line dedup, block-boundary tracking, sub-agent tool
12
+ * window, input-history for arrow keys, one-shot toasts, etc.
13
+ *
14
+ * repl-state.mjs already holds both categories mixed together (see the
15
+ * `session` and `runtime` exports). This module is the *classification*
16
+ * of which fields the daemon persists across attach/detach and which
17
+ * belong to the attached client — plus a small snapshot/restore API
18
+ * bahulamd will call to write/read the periodic snapshot-<seq>.json
19
+ * files that seed a re-attaching client.
20
+ *
21
+ * IMPORTANT — cache invariance ( §7 note we keep repeating):
22
+ * NOTHING in this module gets sent to the LLM. The snapshot is written
23
+ * to local disk only. The daemon's outbound HTTP body to /api/execute
24
+ * is constructed exactly the same way it is today — via existing code
25
+ * paths that read session.agentHistory / session.messages. The
26
+ * BAHULAM_CAPTURE_REQUEST hook (see stream-client.mjs) exists so a
27
+ * later slice can hard-assert that byte identity.
28
+ */
29
+
30
+ import { session as sharedSession } from '../terminal/repl-state.mjs';
31
+
32
+ // ── field classification ─────────────────────────────────────────────
33
+
34
+ /**
35
+ * Daemon-owned session fields. These persist across detach/attach and
36
+ * appear in snapshot-<seq>.json. Order and shape match repl-state.mjs
37
+ * so callers don't have to translate.
38
+ *
39
+ * A field belongs here if:
40
+ * • Its value is the source of truth for what happened in the session
41
+ * (message history, cumulative usage, cost, files touched, …).
42
+ * • A re-attaching client needs it to render an accurate transcript.
43
+ * • It doesn't change based on which attach is watching.
44
+ */
45
+ export const DAEMON_OWNED_FIELDS = Object.freeze([
46
+ 'id',
47
+ 'startTime',
48
+ 'inputTokens',
49
+ 'outputTokens',
50
+ 'toolCalls',
51
+ 'subAgentToolCalls',
52
+ 'totalToolCalls',
53
+ 'totalPrimaryToolCalls',
54
+ 'totalSubAgentToolCalls',
55
+ 'turns',
56
+ 'history',
57
+ 'agentHistory',
58
+ 'user',
59
+ 'model',
60
+ 'modelLimits',
61
+ 'blockedOps',
62
+ 'delegations',
63
+ 'phases',
64
+ 'filesChanged',
65
+ 'filesRead',
66
+ 'lastTurnDuration',
67
+ 'toolCounts',
68
+ 'subAgentCounts',
69
+ 'savedUsd',
70
+ 'lastTask',
71
+ 'lastReasoning',
72
+ 'budgetUsd',
73
+ 'budgetExceeded',
74
+ 'costBreakdown',
75
+ 'totalCost',
76
+ 'costAccurate',
77
+ 'modelOverrides',
78
+ 'modelMode',
79
+ 'routePreference',
80
+ 'isByok',
81
+ 'subscriptionTier',
82
+ 'creditsTotal',
83
+ 'creditsIncluded',
84
+ 'creditsPurchased',
85
+ 'creditsLimit',
86
+ 'creditsCharged',
87
+ 'rateLimit',
88
+ ]);
89
+
90
+ /**
91
+ * Client-owned session fields. These belong to whichever attach is
92
+ * currently connected and MUST NOT be included in snapshots — replaying
93
+ * them on re-attach would produce wrong UX (e.g. a "low credits" toast
94
+ * shown twice, arrow-key history from a stranger's terminal).
95
+ *
96
+ * A field belongs here if:
97
+ * • It exists to dedup a per-attach UI effect (`*_LowWarned` flags).
98
+ * • It's input state tied to a keyboard (`inputHistory`).
99
+ * • It's a rendering flag whose value depends on the current visible
100
+ * transcript, not the session's actual state (`inSubAgent`).
101
+ */
102
+ export const CLIENT_OWNED_SESSION_FIELDS = Object.freeze([
103
+ 'inputHistory',
104
+ 'inSubAgent',
105
+ 'creditsLowWarned',
106
+ 'msgsLowWarned',
107
+ '_lastEmittedThinking',
108
+ ]);
109
+
110
+ /**
111
+ * The `runtime` object in repl-state.mjs is ENTIRELY client-owned.
112
+ * It's stream buffers, spinner frames, explore-run counters, sub-agent
113
+ * live windows — all rendering. Listed here for completeness; the
114
+ * daemon never reads or writes runtime.*.
115
+ */
116
+ export const CLIENT_OWNED_RUNTIME_FIELDS = Object.freeze([
117
+ 'streamBuffer', 'streamedPartialText', 'streamTimer',
118
+ 'renderedContentThisTurn', 'contentHeaderPrinted', 'afterContentFlush',
119
+ 'pendingHead', 'lastRenderedBlock', 'renderedToolResults', 'renderedFileDiffPreviews',
120
+ 'exploreRun', 'foldedSubAgentTools',
121
+ 'spinInterval', 'spinText', 'spinFrame', 'spinPhase', 'spinStartedAt', 'spinToolCalls',
122
+ 'subAgentWindow',
123
+ ]);
124
+
125
+ // ── snapshot / restore ───────────────────────────────────────────────
126
+
127
+ /**
128
+ * Extract the daemon-owned fields from a session-like object into a
129
+ * plain, JSON-serializable snapshot. Use `sharedSession` by default so
130
+ * bahulamd can call `snapshotSession()` with no arg and get the right
131
+ * thing; tests can pass an alternate source.
132
+ *
133
+ * Missing fields are omitted (not written as `undefined`) so the
134
+ * snapshot file stays clean. Nested objects/arrays are shallow-copied
135
+ * — callers that mutate history/agentHistory after snapshotting will
136
+ * see the mutation reflected. That's fine for the daemon (single
137
+ * writer, snapshot-then-continue pattern), but tests that reuse
138
+ * snapshots across mutations should structuredClone() the return.
139
+ */
140
+ export function snapshotSession(source = sharedSession) {
141
+ const out = {};
142
+ for (const key of DAEMON_OWNED_FIELDS) {
143
+ if (source[key] !== undefined) out[key] = source[key];
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Apply a snapshot back onto a session-like object. Only DAEMON_OWNED
150
+ * fields are copied — a maliciously crafted snapshot that includes
151
+ * runtime.* or CLIENT_OWNED_SESSION_FIELDS is silently ignored to
152
+ * prevent snapshot injection from replaying per-attach UI state.
153
+ *
154
+ * Fields the snapshot doesn't mention are left unchanged on `target`
155
+ * (not zeroed out). That way a partial snapshot from an older schema
156
+ * version still restores what it knows without wiping newer fields.
157
+ */
158
+ export function restoreSession(snapshot, target = sharedSession) {
159
+ if (!snapshot || typeof snapshot !== 'object') return target;
160
+ for (const key of DAEMON_OWNED_FIELDS) {
161
+ if (Object.prototype.hasOwnProperty.call(snapshot, key)) {
162
+ target[key] = snapshot[key];
163
+ }
164
+ }
165
+ return target;
166
+ }
167
+
168
+ // ── introspection ────────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Classify a single field name. Useful in tests to guarantee every
172
+ * field in repl-state.mjs is accounted for (nothing accidentally lives
173
+ * in "neither" — new fields must be classified when added).
174
+ */
175
+ export function classifyField(name) {
176
+ if (DAEMON_OWNED_FIELDS.includes(name)) return 'daemon';
177
+ if (CLIENT_OWNED_SESSION_FIELDS.includes(name)) return 'client';
178
+ return 'unclassified';
179
+ }
@@ -0,0 +1,26 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ * Session list — reads local session metadata from ~/.bahulam/sessions/
3
+ * and presents them for attach/stop operations. try {
4
+ const entries = await readdir(dir, { withFileTypes: true });
5
+ const sessions = [];
6
+ for (const entry of entries) {
7
+ if (!entry.isDirectory() || !entry.name.startsWith('sess_')) continue;
8
+ try {
9
+ const meta = JSON.parse(await readFile(join(dir, entry.name, 'meta.json'), 'utf-8'));
10
+ sessions.push({ id: entry.name, ...meta });
11
+ } catch { /* no meta.json, skip */ }
12
+ }
13
+ if (sessions.length === 0) {
14
+ process.stderr.write('No daemon sessions.\n');
15
+ return;
16
+ }
17
+ for (const s of sessions) {
18
+ const pid = s.pid ? ` (pid ${s.pid})` : '';
19
+ const cwd = s.cwd || '?';
20
+ const model = s.model || '?';
21
+ process.stderr.write(` ${s.id} ${cwd} ${model}${pid}\n`);
22
+ }
23
+ } catch (err) {
24
+ process.stderr.write(`No daemon sessions: ${err.message}\n`);
25
+ }
26
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * . — session_directory publisher (daemon side).
3
+ *
4
+ * POSTs to the gateway's /v1/session-directory endpoint on session
5
+ * lifecycle transitions so mobile clients + the web dashboard can see
6
+ * daemons that aren't currently connected to the relay.
7
+ *
8
+ * The gateway's /relay/sessions endpoint shows LIVE relay connections
9
+ * (in-memory hub state). This publisher covers the gap: daemons that
10
+ * started, ran, and now sit idle waiting for input still appear.
11
+ *
12
+ * Fire-and-forget: a publish failure MUST NOT interrupt the turn or
13
+ * crash the daemon. Errors log to stderr and the next transition retries.
14
+ *
15
+ * Called by:
16
+ * - src/core/headless.mjs on session_info (status=running)
17
+ * - src/core/headless.mjs on agent_complete (status=idle if held,
18
+ * closed if exiting)
19
+ * - src/terminal/repl.mjs on session_info (running) and REPL exit (closed)
20
+ *
21
+ * We hit the GATEWAY (BAHULAM_GATEWAY_URL) rather than Supabase directly
22
+ * because (a) the daemon has a Bahulam bearer token, not a Supabase
23
+ * JWT, and (b) the gateway already knows how to resolve that bearer to
24
+ * a user_id via /internal/whoami. Same trust chain as pair/device/remote.
25
+ */
26
+
27
+ import * as os from 'node:os';
28
+
29
+ const GATEWAY = (process.env.BAHULAM_GATEWAY_URL || 'https://gateway.bahulam.ai').replace(/\/+$/, '').replace(/\/v1$/, '');
30
+
31
+ /** One publish. Returns nothing; errors log to stderr. */
32
+ export async function publishSessionDirectory({
33
+ sessionId,
34
+ token,
35
+ cwd = null,
36
+ model = null,
37
+ hostHint = null,
38
+ status = 'running',
39
+ origin = 'local',
40
+ } = {}) {
41
+ if (!sessionId || !token) return;
42
+ try {
43
+ const res = await fetch(`${GATEWAY}/v1/session-directory`, {
44
+ method: 'POST',
45
+ headers: {
46
+ Authorization: `Bearer ${token}`,
47
+ 'Content-Type': 'application/json',
48
+ },
49
+ body: JSON.stringify({
50
+ session_id: sessionId,
51
+ origin,
52
+ host_hint: hostHint || _defaultHostHint(),
53
+ cwd_display: cwd,
54
+ model,
55
+ status,
56
+ }),
57
+ });
58
+ if (!res.ok) {
59
+ const body = await res.text().catch(() => '');
60
+ try { process.stderr.write(`[session-directory] publish ${status} failed (${res.status}): ${body.slice(0, 200)}\n`); } catch {}
61
+ }
62
+ } catch (err) {
63
+ try { process.stderr.write(`[session-directory] network error: ${err.message}\n`); } catch {}
64
+ }
65
+ }
66
+
67
+ /** Convenience: mark a session closed. Best-effort; call from exit paths. */
68
+ export async function markSessionClosed({ sessionId, token }) {
69
+ return publishSessionDirectory({ sessionId, token, status: 'closed' });
70
+ }
71
+
72
+ function _defaultHostHint() {
73
+ try {
74
+ const user = process.env.USER || process.env.USERNAME || 'user';
75
+ const host = (os.hostname() || 'host').split('.')[0];
76
+ return `${user}@${host}`;
77
+ } catch { return 'unknown'; }
78
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Unix socket server — accepts local CLI client connections and
3
+ * dispatches relay events to attached clients.
4
+ *
5
+ * Commands accepted from local clients:
6
+ * • `approve` / `deny` — dispatched to the approval handler
7
+ * • `interrupt`, `send_message`, `switch_model` — forwarded to relay
8
+ * • `take_input_lock` / `release_input_lock` — lock management * • Multi-attach input lock ().
9
+ * • Relay bridge ().
10
+ *
11
+ * Design invariants:
12
+ * • ONE writer per event log; the server never writes to the log
13
+ * directly. The tap does. The server only READS the log to replay.
14
+ * • Broadcast failures on ONE client MUST NOT affect other clients or
15
+ * the daemon's own event flow. Every socket write is try/catch'd.
16
+ * • The server is a passive fan-out: it does not mutate session
17
+ * state, it does not drive the SSE loop, it does not have opinions
18
+ * about which events matter.
19
+ */
20
+
21
+ import * as net from 'node:net';
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+
25
+ import { readEvents, readLatestSnapshot } from '../core/event-log.mjs';
26
+ import { daemonSocketPath, daemonSocketsDir } from '../core/paths.mjs';
27
+ import {
28
+ onAttachJoined, onAttachLeft, takeInputLock, releaseInputLock, isHolder,
29
+ } from './input-lock.mjs';
30
+
31
+ const NL = '\n';
32
+
33
+ /**
34
+ * Create + start a socket server for one session.
35
+ *
36
+ * @param {object} opts
37
+ * @param {string} opts.sessionId
38
+ * @param {object} [opts.onCommand] — { approve, deny, interrupt, sendMessage, ... }
39
+ * each handler is `async (payload, attachId) => void`.
40
+ * Missing keys → the server responds with a
41
+ * `command_error { code: "not_implemented" }` event.
42
+ * @returns {Promise<{
43
+ * sockPath: string,
44
+ * broadcastEvent(event: object): void,
45
+ * attachedCount(): number,
46
+ * close(): Promise<void>,
47
+ * }>}
48
+ */
49
+ export async function startSocketServer({ sessionId, onCommand = {} } = {}) {
50
+ if (!sessionId) throw new Error('startSocketServer: sessionId is required');
51
+
52
+ const sockPath = daemonSocketPath(sessionId);
53
+ fs.mkdirSync(daemonSocketsDir(), { recursive: true, mode: 0o700 });
54
+ // If a stale socket exists (previous daemon crashed), remove it before bind.
55
+ // The OS retains the inode across process death so `listen` will EADDRINUSE
56
+ // even though nothing owns it.
57
+ try { fs.unlinkSync(sockPath); } catch { /* file didn't exist, fine */ }
58
+
59
+ /** @type {Set<AttachedClient>} */
60
+ const clients = new Set();
61
+
62
+ const server = net.createServer(sock => {
63
+ // 0600 on the socket path itself. On most kernels this is enforced at
64
+ // bind() time (see below), but re-chmod defensively in case umask lied.
65
+ try { fs.chmodSync(sockPath, 0o600); } catch { /* best effort */ }
66
+
67
+ const client = _createAttachedClient(sock, sessionId, onCommand);
68
+ clients.add(client);
69
+ sock.on('close', () => { clients.delete(client); });
70
+ });
71
+
72
+ server.on('error', err => {
73
+ // Never crash on a listen error — log and let the caller notice via
74
+ // attachedCount() staying at 0. The daemon session itself continues.
75
+ try { process.stderr.write(`[socket-server] listen error: ${err.message}\n`); } catch {}
76
+ });
77
+
78
+ // Bind with umask temporarily narrowed so the socket file is created 0600
79
+ // even if the user's shell umask would grant group/other read.
80
+ const priorUmask = process.umask(0o077);
81
+ try {
82
+ await new Promise((resolve, reject) => {
83
+ server.once('error', reject);
84
+ server.listen(sockPath, () => {
85
+ server.off('error', reject);
86
+ resolve();
87
+ });
88
+ });
89
+ } finally {
90
+ process.umask(priorUmask);
91
+ }
92
+ // chmod again post-listen — belt and braces on platforms where the umask
93
+ // trick doesn't cover socket files (rare but seen on some Linux configs).
94
+ try { fs.chmodSync(sockPath, 0o600); } catch { /* ignore */ }
95
+
96
+ return {
97
+ sockPath,
98
+ broadcastEvent(event) {
99
+ // Fire-and-forget to every client. One slow reader must not throttle
100
+ // the daemon; we let the OS socket buffer absorb bursts and drop on
101
+ // the individual client if that client fills.
102
+ const line = _serializeFrame(event);
103
+ for (const client of clients) {
104
+ try { client.write(line); }
105
+ catch (err) { try { process.stderr.write(`[socket-server] write to ${client.id} failed: ${err.message}\n`); } catch {} }
106
+ }
107
+ },
108
+ attachedCount: () => clients.size,
109
+ async close() {
110
+ // Close all client sockets first so they drain, then stop listening.
111
+ for (const client of Array.from(clients)) {
112
+ try { client.end(); } catch { /* ignore */ }
113
+ }
114
+ await new Promise(res => server.close(() => res()));
115
+ try { fs.unlinkSync(sockPath); } catch { /* ignore */ }
116
+ },
117
+ };
118
+ }
119
+
120
+ // ── attached client (per-connection state) ───────────────────────────
121
+
122
+ let _nextAttachId = 1;
123
+
124
+ /**
125
+ * @typedef {{ id: string, write: (line: string) => void, end: () => void }} AttachedClient
126
+ */
127
+
128
+ function _createAttachedClient(sock, sessionId, onCommand) {
129
+ const attachId = `att_${Date.now().toString(36)}_${(_nextAttachId++).toString(36)}`;
130
+ let helloSeen = false;
131
+ let buf = '';
132
+
133
+ sock.setEncoding('utf-8');
134
+ sock.on('data', chunk => {
135
+ buf += chunk;
136
+ let nl;
137
+ while ((nl = buf.indexOf(NL)) !== -1) {
138
+ const line = buf.slice(0, nl);
139
+ buf = buf.slice(nl + 1);
140
+ if (line.trim().length === 0) continue;
141
+ _handleFrame(line).catch(err => {
142
+ try { process.stderr.write(`[socket-server] frame handler crashed: ${err.message}\n`); } catch {}
143
+ });
144
+ }
145
+ });
146
+ sock.on('error', err => {
147
+ try { process.stderr.write(`[socket-server] ${attachId} socket error: ${err.message}\n`); } catch {}
148
+ });
149
+
150
+ async function _handleFrame(line) {
151
+ let msg;
152
+ try { msg = JSON.parse(line); }
153
+ catch { _sendError('invalid_json', 'frame is not valid JSON'); return; }
154
+ if (!msg || typeof msg.type !== 'string') {
155
+ _sendError('invalid_frame', 'missing type'); return;
156
+ }
157
+
158
+ if (!helloSeen && msg.type !== 'hello') {
159
+ _sendError('hello_required', 'first frame must be hello'); return;
160
+ }
161
+
162
+ switch (msg.type) {
163
+ case 'hello': {
164
+ helloSeen = true;
165
+ const lastSeq = Number(msg.last_seq) || 0;
166
+ // — input lock: first attach implicitly becomes holder;
167
+ // later attaches join as watchers. The state is stored in
168
+ // input-lock.mjs; the changed event is emitted from THAT module
169
+ // (via wireEmit()) so it also fans out through the tap and hits
170
+ // the event log for later attaches to replay.
171
+ const lockInfo = onAttachJoined(attachId);
172
+ _send({
173
+ type: 'hello_ok', v: 1,
174
+ data: {
175
+ attach_id: attachId,
176
+ session_id: sessionId,
177
+ input_lock: { holder: lockInfo.holder, kind: lockInfo.kind },
178
+ },
179
+ });
180
+ await _replaySince(lastSeq);
181
+ _send({
182
+ seq: 0, ts: new Date().toISOString(), type: 'attach_joined',
183
+ session_id: sessionId, v: 1,
184
+ data: {
185
+ attach_id: attachId, kind: 'local',
186
+ human_hint: msg.human_hint || null,
187
+ input_role: lockInfo.kind, // 'holder' | 'watch'
188
+ },
189
+ });
190
+ return;
191
+ }
192
+ // — input lock commands. Both handled internally by the
193
+ // shared input-lock.mjs state; the resulting input_lock_changed
194
+ // event is emitted from that module (via wireEmit) so all attaches
195
+ // see the transition, including the daemon's local renderer.
196
+ case 'take_input_lock': {
197
+ const out = takeInputLock(attachId);
198
+ _send({
199
+ seq: 0, ts: new Date().toISOString(), type: 'input_lock_ack',
200
+ session_id: sessionId, v: 1,
201
+ data: { in_reply_to: msg.reply_to || null, ...out },
202
+ });
203
+ return;
204
+ }
205
+ case 'release_input_lock': {
206
+ const out = releaseInputLock(attachId);
207
+ _send({
208
+ seq: 0, ts: new Date().toISOString(), type: 'input_lock_ack',
209
+ session_id: sessionId, v: 1,
210
+ data: { in_reply_to: msg.reply_to || null, ...out },
211
+ });
212
+ return;
213
+ }
214
+
215
+ case 'bye': {
216
+ onAttachLeft(attachId);
217
+ // Serialize attach_left, then end() only after the write drains.
218
+ // Immediate sock.end() after sock.write() races the flush on some
219
+ // kernels — the FIN can go out before the frame's last byte lands
220
+ // in the client's read buffer, so the client sees close-without-
221
+ // attach_left. Use the write completion callback to sequence.
222
+ const frame = _serializeFrame({
223
+ seq: 0, ts: new Date().toISOString(), type: 'attach_left',
224
+ session_id: sessionId, v: 1, data: { attach_id: attachId, reason: 'bye' },
225
+ });
226
+ try {
227
+ sock.write(frame, () => { try { sock.end(); } catch {} });
228
+ } catch {
229
+ try { sock.end(); } catch {}
230
+ }
231
+ return;
232
+ }
233
+ case 'approve':
234
+ case 'deny': {
235
+ const handler = onCommand[msg.type];
236
+ if (typeof handler !== 'function') {
237
+ _sendError('not_implemented', `command ${msg.type} has no handler`, msg.reply_to);
238
+ return;
239
+ }
240
+ try { await handler(msg.data || {}, attachId); }
241
+ catch (err) { _sendError('handler_failed', err.message, msg.reply_to); }
242
+ return;
243
+ }
244
+ case 'interrupt':
245
+ case 'send_message':
246
+ case 'switch_model': {
247
+ // — typing-class commands require the input lock. Watch-
248
+ // mode attaches get a `not_input_holder` error and can request
249
+ // the lock via take_input_lock (steal-with-grace).
250
+ if (!isHolder(attachId)) {
251
+ _sendError('not_input_holder', `command ${msg.type} requires the input lock; send take_input_lock first`, msg.reply_to);
252
+ return;
253
+ }
254
+ const handler = onCommand[msg.type];
255
+ if (typeof handler !== 'function') {
256
+ _sendError('not_implemented', `command ${msg.type} is not wired yet (deferred slice)`, msg.reply_to);
257
+ return;
258
+ }
259
+ try { await handler(msg.data || {}, attachId); }
260
+ catch (err) { _sendError('handler_failed', err.message, msg.reply_to); }
261
+ return;
262
+ }
263
+ case 'wake': {
264
+ const handler = onCommand[msg.type];
265
+ if (typeof handler !== 'function') {
266
+ _sendError('not_implemented', `command ${msg.type} is not wired yet (deferred slice)`, msg.reply_to);
267
+ return;
268
+ }
269
+ try { await handler(msg.data || {}, attachId); }
270
+ catch (err) { _sendError('handler_failed', err.message, msg.reply_to); }
271
+ return;
272
+ }
273
+ default:
274
+ _sendError('unknown_type', `unknown command: ${msg.type}`, msg.reply_to);
275
+ }
276
+ }
277
+
278
+ async function _replaySince(lastSeq) {
279
+ // Seed from snapshot (if any) so long-running sessions don't stream 100k
280
+ // events at attach time. Then stream events with seq > snapshot.seq (or
281
+ // > lastSeq, whichever's higher).
282
+ let sinceSeq = lastSeq;
283
+ const snap = await readLatestSnapshot({ sessionId }).catch(() => null);
284
+ if (snap && typeof snap.seq === 'number' && snap.seq > sinceSeq) {
285
+ _send({
286
+ seq: 0, ts: new Date().toISOString(), type: 'snapshot',
287
+ session_id: sessionId, v: 1, data: { seq: snap.seq, state: snap.state },
288
+ });
289
+ sinceSeq = snap.seq;
290
+ }
291
+ let firstSeq = null, lastSeqSeen = sinceSeq;
292
+ const batch = [];
293
+ for await (const evt of readEvents({ sessionId, sinceSeq })) {
294
+ if (firstSeq == null) firstSeq = evt.seq;
295
+ batch.push(evt);
296
+ lastSeqSeen = evt.seq;
297
+ }
298
+ if (batch.length > 0) {
299
+ _send({ seq: 0, ts: new Date().toISOString(), type: 'replay_batch_start',
300
+ session_id: sessionId, v: 1, data: { from_seq: firstSeq, to_seq: lastSeqSeen } });
301
+ for (const evt of batch) _send(evt);
302
+ _send({ seq: 0, ts: new Date().toISOString(), type: 'replay_batch_end',
303
+ session_id: sessionId, v: 1, data: { from_seq: firstSeq, to_seq: lastSeqSeen } });
304
+ }
305
+ }
306
+
307
+ function _send(obj) {
308
+ try { sock.write(_serializeFrame(obj)); }
309
+ catch { /* silent — client will close and we'll clean up */ }
310
+ }
311
+
312
+ function _sendError(code, message, in_reply_to) {
313
+ _send({
314
+ seq: 0, ts: new Date().toISOString(), type: 'command_error',
315
+ session_id: sessionId, v: 1,
316
+ data: { code, message, ...(in_reply_to ? { in_reply_to } : {}) },
317
+ });
318
+ }
319
+
320
+ return {
321
+ id: attachId,
322
+ write: line => sock.write(line),
323
+ end: () => sock.end(),
324
+ };
325
+ }
326
+
327
+ function _serializeFrame(obj) {
328
+ return JSON.stringify(obj) + NL;
329
+ }
@@ -0,0 +1,18 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { daemonSessionDir } from '../core/paths.mjs';
4
+
5
+ export async function stopDaemonSession(sessionId) {
6
+ if (!sessionId) {
7
+ process.stderr.write('Usage: bahulam stop <session-id>\n');
8
+ return;
9
+ }
10
+ const pidFile = join(daemonSessionDir(sessionId), 'daemon.pid');
11
+ try {
12
+ const pid = parseInt((await readFile(pidFile, 'utf-8')).trim(), 10);
13
+ process.kill(pid, 'SIGTERM');
14
+ process.stderr.write(`Sent SIGTERM to daemon ${sessionId} (pid ${pid})\n`);
15
+ } catch (err) {
16
+ process.stderr.write(`Failed to stop daemon ${sessionId}: ${err.message}\n`);
17
+ }
18
+ }
@@ -17,7 +17,7 @@ export function createPermissionChecker(config = {}) {
17
17
  mode,
18
18
  async check(toolName, input) {
19
19
  // Always run injection check on Bash commands
20
- if (toolName === 'Bash' && input?.command) {
20
+ if (toolName === 'shell' && input?.command) {
21
21
  const injection = checkInjection(input.command);
22
22
  if (!injection.safe) {
23
23
  return false; // block dangerous commands
@@ -25,8 +25,8 @@ export function createPermissionChecker(config = {}) {
25
25
  }
26
26
 
27
27
  // Always validate file paths for file operations
28
- if (['Edit', 'Write', 'Read', 'MultiEdit'].includes(toolName) && input?.file_path) {
29
- const pathResult = validatePath(input.file_path, { write: toolName !== 'Read' });
28
+ if (['edit_file', 'write_file', 'read_file', 'MultiEdit'].includes(toolName) && input?.file_path) {
29
+ const pathResult = validatePath(input.file_path, { write: toolName !== 'read_file' });
30
30
  if (!pathResult.safe) {
31
31
  return false; // block unsafe paths
32
32
  }
@@ -35,14 +35,14 @@ export function createPermissionChecker(config = {}) {
35
35
  switch (mode) {
36
36
  case 'bypassPermissions': return true;
37
37
  case 'acceptEdits':
38
- // Allow file ops, block Bash/Agent unless rl available
39
- if (toolName === 'Bash' || toolName === 'Agent') {
38
+ // Allow file ops, block shell/Agent unless rl available
39
+ if (toolName === 'shell' || toolName === 'Agent') {
40
40
  return !requiresPermission(toolName) || !!config.bypassBash;
41
41
  }
42
42
  return true;
43
43
  case 'auto': return true; // AI decides
44
44
  case 'dontAsk': return false; // deny everything not pre-approved
45
- case 'plan': return toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep';
45
+ case 'plan': return toolName === 'read_file' || toolName === 'list_files' || toolName === 'search_code' || toolName === 'grep';
46
46
  case 'default':
47
47
  default:
48
48
  // In default mode, safe tools pass through