@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,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
|
+
}
|