@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,253 @@
1
+ /**
2
+ * — Pending-approval registry + timeout policy.
3
+ *
4
+ * Bridges the two paths that can answer an approval request:
5
+ *
6
+ * 1. Local TTY — the existing approval.mjs prompt (arrow keys, dock).
7
+ * 2. Remote — a socket attach client (or later, mobile via relay)
8
+ * sending an `approve` / `deny` command with an apr_id.
9
+ *
10
+ * Both race the same promise. Whichever resolves first wins; the loser's
11
+ * resolver becomes a no-op. Cleanup on either resolution.
12
+ *
13
+ * Also owns the timeout policy from PRD §6.7:
14
+ * - `hold` : never times out (default; safe for attended sessions)
15
+ * - `deny <sec>` : auto-deny after N seconds if nobody has answered
16
+ * - `allow <sec>` : auto-approve — gated behind the existing
17
+ * `--dangerously-skip-permissions` opt-in, checked
18
+ * by the caller (this module doesn't enforce it).
19
+ *
20
+ * NOT in this module:
21
+ * - The dispatch that turns a `check()` call into an `approval_required`
22
+ * event. That's an intercept wrapper wired at the ApprovalManager
23
+ * call sites (see attach-approval-bridge below).
24
+ * - The socket-server side — `socket-server.mjs` already parses
25
+ * `approve`/`deny` commands; the daemon wiring passes them here.
26
+ */
27
+
28
+ import { randomBytes } from 'node:crypto';
29
+
30
+ /** Approvals we're waiting on. Key: apr_id → { resolve, sourceType, timer } */
31
+ const _pending = new Map();
32
+
33
+ /** Default timeout policy: never expires. Change via setTimeoutPolicy(). */
34
+ let _policy = { mode: 'hold', durationMs: 0 };
35
+
36
+ // ── policy ────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Set the session-level timeout policy for pending approvals.
40
+ * @param {{mode: 'hold'|'deny'|'allow', durationSec?: number}} p
41
+ */
42
+ export function setTimeoutPolicy(p) {
43
+ const mode = p?.mode || 'hold';
44
+ const durationMs = Math.max(0, Number(p?.durationSec || 0)) * 1000;
45
+ _policy = { mode, durationMs };
46
+ return { mode, durationMs };
47
+ }
48
+
49
+ export function getTimeoutPolicy() {
50
+ return { ..._policy };
51
+ }
52
+
53
+ // ── registry ──────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Register a new pending approval. Returns { apr_id, race } where
57
+ *
58
+ * race — a Promise<{decision, decided_by, note}> that resolves
59
+ * when ANY source (local TTY, socket, timeout) answers.
60
+ * cancel(id) — called after another source has already resolved to
61
+ * mark this pending entry as consumed and clean up the
62
+ * timer. Idempotent.
63
+ *
64
+ * The caller (typically the approval manager intercept) also races this
65
+ * against the local TTY prompt. When the TTY prompt resolves, the caller
66
+ * should call `cancel(apr_id)` so a late socket `approve` becomes a
67
+ * no-op instead of getting an "unknown apr_id" error.
68
+ *
69
+ * @param {object} meta
70
+ * @param {string} meta.kind tool name / classifier tag
71
+ * @param {string} meta.subject human-readable one-liner
72
+ * @param {number} [meta.expiresAtMs] caller-provided override; otherwise
73
+ * we compute from the active policy.
74
+ */
75
+ export function registerPending(meta = {}) {
76
+ const apr_id = `apr_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`;
77
+ let resolve;
78
+ const race = new Promise(r => { resolve = r; });
79
+
80
+ const entry = {
81
+ apr_id,
82
+ kind: meta.kind || 'unknown',
83
+ subject: meta.subject || '',
84
+ resolve,
85
+ consumed: false,
86
+ timer: null,
87
+ };
88
+ _pending.set(apr_id, entry);
89
+
90
+ // Wire policy timeout — only if a duration was set AND a mode that
91
+ // implies an automatic decision. `hold` never schedules a timer.
92
+ const policy = getTimeoutPolicy();
93
+ if (policy.mode !== 'hold' && policy.durationMs > 0) {
94
+ entry.timer = setTimeout(() => {
95
+ if (entry.consumed) return;
96
+ entry.consumed = true;
97
+ _pending.delete(apr_id);
98
+ resolve({
99
+ decision: policy.mode === 'allow' ? 'approve' : 'deny',
100
+ decided_by: 'timeout',
101
+ note: `timeout:${policy.mode}:${policy.durationMs}ms`,
102
+ });
103
+ }, policy.durationMs);
104
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
105
+ }
106
+
107
+ return {
108
+ apr_id,
109
+ race,
110
+ cancel() {
111
+ if (entry.consumed) return;
112
+ entry.consumed = true;
113
+ _pending.delete(apr_id);
114
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
115
+ },
116
+ expiresAt: policy.mode !== 'hold' && policy.durationMs > 0
117
+ ? new Date(Date.now() + policy.durationMs).toISOString()
118
+ : null,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Called by the socket server's approve/deny handlers. Resolves the
124
+ * pending approval with the given decision. Returns true if the
125
+ * apr_id existed and was resolved; false if unknown or already consumed.
126
+ *
127
+ * @param {'approve'|'deny'} decision
128
+ * @param {string} apr_id
129
+ * @param {string} decided_by attach_id of the answering client
130
+ * @param {string} [note]
131
+ */
132
+ export function resolvePending(decision, apr_id, decided_by, note = '') {
133
+ const entry = _pending.get(apr_id);
134
+ if (!entry || entry.consumed) return false;
135
+ entry.consumed = true;
136
+ _pending.delete(apr_id);
137
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
138
+ entry.resolve({ decision, decided_by, note });
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Introspection — used by the socket server's `attach_joined` replay
144
+ * ( follow-up) and by `bahulam status` to show pending approvals.
145
+ */
146
+ export function listPending() {
147
+ return Array.from(_pending.values(), e => ({
148
+ apr_id: e.apr_id,
149
+ kind: e.kind,
150
+ subject: e.subject,
151
+ }));
152
+ }
153
+
154
+ /**
155
+ * Clean up all pending approvals (session end, daemon shutdown). Every
156
+ * pending entry gets a synthetic `deny` decision so no `check()` call
157
+ * hangs forever.
158
+ */
159
+ export function shutdownAllPending(reason = 'shutdown') {
160
+ for (const entry of Array.from(_pending.values())) {
161
+ if (entry.consumed) continue;
162
+ entry.consumed = true;
163
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
164
+ entry.resolve({ decision: 'deny', decided_by: 'shutdown', note: reason });
165
+ }
166
+ _pending.clear();
167
+ }
168
+
169
+ // ── intercept helper for ApprovalManager ─────────────────────────────
170
+
171
+ /**
172
+ * Wrap an ApprovalManager.check() call so that:
173
+ * (a) an approval_required event is emitted (broadcast to attaches),
174
+ * (b) a socket approve/deny can resolve the check before the TTY does.
175
+ *
176
+ * Usage in repl.mjs ( wiring):
177
+ *
178
+ * const orig = approval.check.bind(approval);
179
+ * approval.check = (tool, args, req, ctx) =>
180
+ * interceptApproval(orig, { tool, args, req, ctx, sessionId, emit });
181
+ *
182
+ * `emit(event)` is the caller's hook that writes the approval_required
183
+ * event to the daemon event log (tap) so it also fans out to sockets.
184
+ * We do the emission here rather than inside registerPending so the
185
+ * store stays transport-agnostic.
186
+ */
187
+ export async function interceptApproval(origCheck, { tool, args, req, ctx, sessionId, emit } = {}) {
188
+ const pending = registerPending({ kind: tool, subject: _subjectFromArgs(tool, args) });
189
+ const eventData = {
190
+ apr_id: pending.apr_id,
191
+ kind: tool,
192
+ subject: _subjectFromArgs(tool, args),
193
+ expires_at: pending.expiresAt,
194
+ };
195
+ if (typeof emit === 'function') {
196
+ try { emit('approval_required', eventData); } catch { /* never blocks approval */ }
197
+ }
198
+
199
+ // Race the local TTY prompt against the remote resolution promise.
200
+ // Whichever resolves first wins; cancel the other.
201
+ const local = origCheck(tool, args, req, ctx).then(v => ({ __src: 'local', v }));
202
+ const remote = pending.race.then(v => ({ __src: 'remote', v }));
203
+
204
+ const first = await Promise.race([local, remote]);
205
+ pending.cancel();
206
+
207
+ if (first.__src === 'local') {
208
+ // TTY already answered; nothing else to do. Emit approval_decided so
209
+ // remote watchers see the outcome.
210
+ if (typeof emit === 'function') {
211
+ try {
212
+ emit('approval_decided', {
213
+ apr_id: pending.apr_id,
214
+ decision: first.v?.approved ? 'approve' : 'deny',
215
+ decided_by: 'local_tty',
216
+ });
217
+ } catch { /* ignore */ }
218
+ }
219
+ return first.v;
220
+ }
221
+
222
+ // Remote answered. Build the same shape ApprovalManager.check() returns
223
+ // so the caller (stream-client's tool_request handler) doesn't need to
224
+ // care where the decision came from.
225
+ if (typeof emit === 'function') {
226
+ try {
227
+ emit('approval_decided', {
228
+ apr_id: pending.apr_id,
229
+ decision: first.v.decision,
230
+ decided_by: first.v.decided_by,
231
+ note: first.v.note,
232
+ });
233
+ } catch { /* ignore */ }
234
+ }
235
+ return {
236
+ approved: first.v.decision === 'approve',
237
+ tier: 'destructive', // remote answers always treated as an explicit tier decision
238
+ reason: first.v.note || `Decided remotely by ${first.v.decided_by}`,
239
+ remoteDecision: true,
240
+ };
241
+ }
242
+
243
+ // ── helpers ──────────────────────────────────────────────────────────
244
+
245
+ function _subjectFromArgs(tool, args) {
246
+ if (!args || typeof args !== 'object') return tool;
247
+ // Best-effort short summary of the most common tool args.
248
+ if (typeof args.command === 'string') return `${tool}: ${args.command.slice(0, 120)}`;
249
+ if (typeof args.cmd === 'string') return `${tool}: ${args.cmd.slice(0, 120)}`;
250
+ if (typeof args.path === 'string') return `${tool}: ${args.path}`;
251
+ if (typeof args.file_path === 'string') return `${tool}: ${args.file_path}`;
252
+ return tool;
253
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * — Attach client.
3
+ *
4
+ * `bahulam attach <session-id>` connects to the daemon's Unix socket at
5
+ * ~/.bahulam/sockets/<sess_id>.sock and mirrors the event stream in the
6
+ * current terminal. It's the "observer + approver" surface — the local
7
+ * counterpart to the mobile PWA. Every wire type is the same
8
+ * event/command schema the relay uses; only the transport differs.
9
+ *
10
+ * What this slice ships:
11
+ * • Connect + hello handshake (with `last_seq` resume support).
12
+ * • Renders replayed events (bracketed by replay_batch_{start,end})
13
+ * compactly so a long history doesn't spam the terminal.
14
+ * • Renders live events as they arrive.
15
+ * • Approve/deny keyboard shortcut on pending approvals.
16
+ * • Ctrl-D or `.bye` → clean bye + exit (daemon keeps running).
17
+ * • Ctrl-C → sends `interrupt` command (cancels current turn).
18
+ *
19
+ * What's deferred:
20
+ * • Full renderer parity (spinner, block boundaries, sub-agent window)
21
+ * — refactors repl-render.mjs to be attach-mode-aware.
22
+ * • Input-lock steal-with-grace ().
23
+ * • Sending `send_message` / `switch_model` from the attach client
24
+ * ( ships read+approve; interactive prompt input lands in D).
25
+ *
26
+ * NOT wired here (/H concerns):
27
+ * • The daemon's approve/deny handlers don't yet resolve pending
28
+ * approvals (they stub as TODO in repl.mjs). Approvals we send from
29
+ * here will be dispatched to the daemon but the daemon-side pending
30
+ * approval registry is work. This client sends the wire
31
+ * command correctly — that's the piece is responsible for.
32
+ */
33
+
34
+ import * as fs from 'node:fs';
35
+ import * as net from 'node:net';
36
+ import * as readline from 'node:readline';
37
+
38
+ import { daemonSocketPath, daemonSessionDir } from '../core/paths.mjs';
39
+
40
+ const NL = '\n';
41
+ const RESET = '\x1b[0m';
42
+ const DIM = '\x1b[2m';
43
+ const BOLD = '\x1b[1m';
44
+ const RED = '\x1b[31m';
45
+ const GREEN = '\x1b[32m';
46
+ const YELLOW = '\x1b[33m';
47
+ const BLUE = '\x1b[34m';
48
+ const CYAN = '\x1b[36m';
49
+
50
+ export async function attachToSession(sessionId, { lastSeq = 0, humanHint = null } = {}) {
51
+ if (!sessionId) {
52
+ process.stderr.write('Usage: bahulam attach <session-id>\n');
53
+ process.stderr.write(`Run ${BOLD}bahulam list${RESET} to see available sessions.\n`);
54
+ return 1;
55
+ }
56
+
57
+ const sockPath = daemonSocketPath(sessionId);
58
+ if (!fs.existsSync(sockPath)) {
59
+ process.stderr.write(`No socket at ${sockPath}\n`);
60
+ process.stderr.write(`Session may not be running. Try ${BOLD}bahulam list${RESET}.\n`);
61
+ return 1;
62
+ }
63
+
64
+ // Read session meta for a nicer banner. Best-effort — attach still works if
65
+ // meta.json is missing (e.g. daemon crashed before writing it).
66
+ let meta = null;
67
+ try { meta = JSON.parse(fs.readFileSync(`${daemonSessionDir(sessionId)}/meta.json`, 'utf-8')); }
68
+ catch { /* ignore */ }
69
+
70
+ return new Promise((resolve) => {
71
+ const sock = net.createConnection(sockPath);
72
+ let buf = '';
73
+ let bye = false;
74
+
75
+ // Track approvals we've seen but not yet answered — one-liner prompt shows
76
+ // the most recent unanswered one. Keyed by apr_id.
77
+ const pending = new Map();
78
+
79
+ // Readline for keyboard commands (a/d/i/q). raw mode so single-key input
80
+ // works without hitting Enter.
81
+ let rl = null;
82
+ let stdinRaw = false;
83
+
84
+ function _teardownStdin() {
85
+ if (stdinRaw && process.stdin.isTTY) {
86
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
87
+ stdinRaw = false;
88
+ }
89
+ if (rl) { try { rl.close(); } catch { /* ignore */ } rl = null; }
90
+ }
91
+
92
+ function _shutdown(exitCode = 0) {
93
+ _teardownStdin();
94
+ try { sock.end(); } catch { /* ignore */ }
95
+ resolve(exitCode);
96
+ }
97
+
98
+ sock.on('connect', () => {
99
+ _printBanner(sessionId, meta, humanHint, lastSeq);
100
+ // Send hello.
101
+ const hello = {
102
+ type: 'hello',
103
+ attach_id: `att_local_${process.pid}`,
104
+ last_seq: lastSeq,
105
+ want_pty: false,
106
+ kind: 'local',
107
+ human_hint: humanHint || `${process.env.USER || 'user'}@${_hostShort()}`,
108
+ protocol_versions: [1],
109
+ };
110
+ sock.write(JSON.stringify(hello) + NL);
111
+
112
+ // Wire keyboard input. Prefer raw mode on a real TTY (single-key
113
+ // response, no Enter needed). When stdin is piped (scripts, tests),
114
+ // fall back to plain data events — each character still triggers
115
+ // _handleKey, just without the raw-mode terminal setup.
116
+ try {
117
+ process.stdin.setEncoding('utf-8');
118
+ if (process.stdin.isTTY) {
119
+ process.stdin.setRawMode(true);
120
+ stdinRaw = true;
121
+ }
122
+ process.stdin.on('data', ch => {
123
+ // Piped input may deliver multiple chars per data event
124
+ // (buffered). Feed one at a time so a batched "aq" still
125
+ // resolves as approve+quit in order.
126
+ for (const c of String(ch)) _handleKey(c);
127
+ });
128
+ process.stdin.on('end', () => {
129
+ if (!bye) {
130
+ bye = true;
131
+ _send({ type: 'bye', attach_id: `att_local_${process.pid}` });
132
+ }
133
+ });
134
+ } catch (err) {
135
+ process.stderr.write(`${DIM}(stdin unavailable: ${err.message})${RESET}\n`);
136
+ }
137
+ });
138
+
139
+ sock.setEncoding('utf-8');
140
+ sock.on('data', chunk => {
141
+ buf += chunk;
142
+ let nl;
143
+ while ((nl = buf.indexOf(NL)) !== -1) {
144
+ const line = buf.slice(0, nl);
145
+ buf = buf.slice(nl + 1);
146
+ if (line.trim().length === 0) continue;
147
+ let frame;
148
+ try { frame = JSON.parse(line); }
149
+ catch { continue; }
150
+ _renderFrame(frame, pending);
151
+ }
152
+ });
153
+
154
+ sock.on('error', err => {
155
+ process.stderr.write(`${RED}[attach] socket error: ${err.message}${RESET}\n`);
156
+ _shutdown(1);
157
+ });
158
+
159
+ sock.on('end', () => {
160
+ if (!bye) process.stderr.write(`${DIM}[attach] peer half-closed${RESET}\n`);
161
+ _shutdown(bye ? 0 : 2);
162
+ });
163
+
164
+ // Ctrl-C → interrupt. Ctrl-D → bye. Otherwise pass to _handleKey below.
165
+ function _handleKey(ch) {
166
+ // Raw mode: ETX=0x03 (Ctrl-C), EOT=0x04 (Ctrl-D).
167
+ if (ch === '\x03') {
168
+ _send({ type: 'interrupt', attach_id: `att_local_${process.pid}` });
169
+ process.stderr.write(`${YELLOW}[attach] interrupt sent${RESET}\n`);
170
+ return;
171
+ }
172
+ if (ch === '\x04') {
173
+ bye = true;
174
+ _send({ type: 'bye', attach_id: `att_local_${process.pid}` });
175
+ return;
176
+ }
177
+ // Answer the latest pending approval with a/d.
178
+ const k = ch.toLowerCase();
179
+ if (k === 'a' || k === 'd') {
180
+ const latest = _latestPending(pending);
181
+ if (!latest) return;
182
+ _send({
183
+ type: k === 'a' ? 'approve' : 'deny',
184
+ attach_id: `att_local_${process.pid}`,
185
+ data: { apr_id: latest.apr_id },
186
+ });
187
+ pending.delete(latest.apr_id);
188
+ process.stderr.write(
189
+ `${k === 'a' ? GREEN + '✓ approved' : RED + '✗ denied'}${RESET}${DIM} ${latest.apr_id}${RESET}\n`
190
+ );
191
+ _reprintPendingHint(pending);
192
+ return;
193
+ }
194
+ if (k === 'q') {
195
+ bye = true;
196
+ _send({ type: 'bye', attach_id: `att_local_${process.pid}` });
197
+ return;
198
+ }
199
+ }
200
+
201
+ function _send(obj) {
202
+ try { sock.write(JSON.stringify(obj) + NL); } catch { /* ignore */ }
203
+ }
204
+ });
205
+ }
206
+
207
+ // ── frame rendering ──────────────────────────────────────────────────
208
+
209
+ // Called on every wire frame. Renders human-readable output; batches long
210
+ // replay sequences into a one-line "replayed N events" summary so an attach
211
+ // to a long-running session doesn't spam the terminal.
212
+ let _replayCount = 0;
213
+ let _inReplay = false;
214
+
215
+ function _renderFrame(frame, pending) {
216
+ switch (frame.type) {
217
+ case 'hello_ok':
218
+ return; // banner already printed
219
+ case 'hello_error':
220
+ process.stderr.write(`${RED}hello rejected: ${frame.data?.reason}${RESET}\n`);
221
+ return;
222
+ case 'replay_batch_start':
223
+ _inReplay = true;
224
+ _replayCount = 0;
225
+ return;
226
+ case 'replay_batch_end':
227
+ _inReplay = false;
228
+ if (_replayCount > 0) {
229
+ process.stdout.write(`${DIM} … replayed ${_replayCount} event(s) from before you attached${RESET}\n`);
230
+ }
231
+ _replayCount = 0;
232
+ return;
233
+ case 'snapshot':
234
+ process.stdout.write(`${DIM} (snapshot @ seq ${frame.data?.seq})${RESET}\n`);
235
+ return;
236
+ case 'command_error':
237
+ process.stderr.write(`${RED}[cmd err ${frame.data?.code}] ${frame.data?.message}${RESET}\n`);
238
+ return;
239
+ case 'attach_joined':
240
+ if (!_inReplay) {
241
+ process.stderr.write(`${DIM} + ${frame.data?.attach_id || 'attach'} joined${RESET}\n`);
242
+ }
243
+ return;
244
+ case 'attach_left':
245
+ if (!_inReplay) {
246
+ process.stderr.write(`${DIM} - ${frame.data?.attach_id || 'attach'} left (${frame.data?.reason})${RESET}\n`);
247
+ }
248
+ return;
249
+ }
250
+
251
+ if (_inReplay) { _replayCount += 1; return; }
252
+
253
+ const seq = typeof frame.seq === 'number' ? frame.seq : '?';
254
+ const ts = frame.ts ? frame.ts.slice(11, 19) : ' ';
255
+ switch (frame.type) {
256
+ case 'session_started':
257
+ process.stdout.write(`${DIM}[${ts}]${RESET} ${BOLD}session${RESET} model=${frame.data?.model} cwd=${frame.data?.cwd}\n`);
258
+ break;
259
+ case 'turn_started':
260
+ process.stdout.write(`${DIM}[${ts}]${RESET} ${BLUE}▶ turn${RESET} ${frame.turn_id} iter=${frame.data?.iteration || 0}\n`);
261
+ break;
262
+ case 'turn_ended':
263
+ process.stdout.write(`${DIM}[${ts}]${RESET} ${BLUE}◀ turn${RESET} ${frame.turn_id} ${frame.data?.ok ? GREEN + 'ok' : RED + 'err'}${RESET}\n`);
264
+ break;
265
+ case 'thinking_delta': {
266
+ const chunk = String(frame.data?.chunk || '').slice(0, 120);
267
+ if (chunk.trim()) process.stdout.write(`${DIM} ⋯ ${chunk}${RESET}\n`);
268
+ break;
269
+ }
270
+ case 'tool_call': {
271
+ const name = frame.data?.name || '?';
272
+ const args = _briefArgs(frame.data?.args);
273
+ process.stdout.write(`${DIM}[${ts}]${RESET} ${CYAN}⚙ ${name}${RESET}${DIM}(${args})${RESET}\n`);
274
+ break;
275
+ }
276
+ case 'tool_result': {
277
+ const ok = frame.data?.ok !== false;
278
+ const dur = frame.data?.duration_ms ? ` ${frame.data.duration_ms}ms` : '';
279
+ const summary = String(frame.data?.summary || '').split(NL)[0].slice(0, 80);
280
+ process.stdout.write(`${DIM} ${ok ? GREEN + '↳' : RED + '↳'}${RESET} ${summary}${DIM}${dur}${RESET}\n`);
281
+ break;
282
+ }
283
+ case 'approval_required': {
284
+ const apr_id = frame.data?.apr_id;
285
+ pending.set(apr_id, frame.data);
286
+ process.stdout.write(
287
+ `${YELLOW}⚠ approval${RESET} ${BOLD}${frame.data?.kind || ''}${RESET}: ${frame.data?.subject || ''}\n`
288
+ );
289
+ _reprintPendingHint(pending);
290
+ break;
291
+ }
292
+ case 'approval_decided':
293
+ pending.delete(frame.data?.apr_id);
294
+ process.stdout.write(
295
+ `${DIM}[${ts}] ${frame.data?.decision === 'approve' ? GREEN + '✓' : RED + '✗'}${RESET}${DIM} ${frame.data?.apr_id} by ${frame.data?.decided_by}${RESET}\n`
296
+ );
297
+ _reprintPendingHint(pending);
298
+ break;
299
+ case 'diff':
300
+ process.stdout.write(`${DIM}[${ts}]${RESET} 📝 ${frame.data?.path} (${frame.data?.hunks?.length || 0} hunk(s))\n`);
301
+ break;
302
+ case 'test_result':
303
+ process.stdout.write(`${DIM}[${ts}]${RESET} 🧪 ${frame.data?.suite}: ${GREEN}${frame.data?.passed || 0} passed${RESET} ${RED}${frame.data?.failed || 0} failed${RESET}\n`);
304
+ break;
305
+ case 'tokens_used':
306
+ process.stdout.write(`${DIM}[${ts}] tok in=${frame.data?.prompt || 0} out=${frame.data?.completion || 0} cache=${frame.data?.cached || 0}${RESET}\n`);
307
+ break;
308
+ case 'agent_complete':
309
+ process.stdout.write(`${GREEN}[${ts}] ✓ complete${RESET} ${DIM}${frame.data?.summary || ''}${RESET}\n`);
310
+ break;
311
+ case 'daemon_shutdown':
312
+ process.stdout.write(`${DIM}[${ts}] daemon shutdown: ${frame.data?.reason}${RESET}\n`);
313
+ break;
314
+ default:
315
+ // Unknown types are forward-compat: dim one-liner so we can see them
316
+ // if the daemon starts emitting a new type before we know about it.
317
+ process.stdout.write(`${DIM}[${ts}] ${frame.type} #${seq}${RESET}\n`);
318
+ }
319
+ }
320
+
321
+ function _briefArgs(args) {
322
+ if (!args || typeof args !== 'object') return '';
323
+ const parts = [];
324
+ for (const [k, v] of Object.entries(args)) {
325
+ let repr;
326
+ if (typeof v === 'string') repr = v.length > 40 ? v.slice(0, 40) + '…' : v;
327
+ else if (Array.isArray(v)) repr = `[${v.length}]`;
328
+ else if (v && typeof v === 'object') repr = '{…}';
329
+ else repr = String(v);
330
+ parts.push(`${k}=${repr}`);
331
+ if (parts.join(' ').length > 60) { parts.push('…'); break; }
332
+ }
333
+ return parts.join(' ');
334
+ }
335
+
336
+ function _printBanner(sessionId, meta, humanHint, lastSeq) {
337
+ process.stderr.write(`${BOLD}bahulam attach${RESET} ${DIM}${sessionId}${RESET}`);
338
+ if (meta?.cwd) process.stderr.write(` ${DIM}${meta.cwd}${RESET}`);
339
+ if (meta?.model) process.stderr.write(` ${DIM}${meta.model}${RESET}`);
340
+ process.stderr.write(`\n${DIM} since seq ${lastSeq} · a=approve · d=deny · Ctrl-C=interrupt · Ctrl-D=detach${RESET}\n`);
341
+ }
342
+
343
+ function _latestPending(pending) {
344
+ const it = pending.values();
345
+ let last = null;
346
+ for (const v of it) last = v;
347
+ return last;
348
+ }
349
+
350
+ function _reprintPendingHint(pending) {
351
+ if (pending.size === 0) return;
352
+ const latest = _latestPending(pending);
353
+ process.stdout.write(
354
+ `${YELLOW} → press ${BOLD}a${RESET}${YELLOW} to approve, ${BOLD}d${RESET}${YELLOW} to deny${RESET}${DIM} (${latest.subject || ''})${RESET}\n`
355
+ );
356
+ }
357
+
358
+ function _hostShort() {
359
+ try { return (process.env.HOSTNAME || process.env.HOST || 'host').split('.')[0]; }
360
+ catch { return 'host'; }
361
+ }