@bahulam/code 0.1.2 → 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/package.json +4 -7
- 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/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 +56 -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/terminal/ansi.mjs +20 -3
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl.mjs +201 -2
- 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/sub-agent.mjs +8 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* . — auto-daemon spawn + start-or-attach.
|
|
3
|
+
*
|
|
4
|
+
* Two entrypoints:
|
|
5
|
+
*
|
|
6
|
+
* spawnDetachedDaemon(cwd, {prompt, extraEnv})
|
|
7
|
+
* Forks a background bahulam child, detached from the current
|
|
8
|
+
* terminal, with BAHULAM_DAEMON_EVENTLOG=1 forced so it starts a
|
|
9
|
+
* socket server on session_info. Returns { pid, waitForSession() }
|
|
10
|
+
* — the parent can await a session_id becoming visible in
|
|
11
|
+
* ~/.bahulam/sessions/, or exit immediately (typical case: user
|
|
12
|
+
* types `bahulam daemonize "fix this bug"`, we spawn + print the
|
|
13
|
+
* session id + exit; they attach later with `bahulam attach <id>`).
|
|
14
|
+
*
|
|
15
|
+
* findSessionForCwd(cwd)
|
|
16
|
+
* Scans ~/.bahulam/sessions/<id>/meta.json for entries where meta.cwd
|
|
17
|
+
* matches (after realpath), and where the pid is still alive and
|
|
18
|
+
* the socket file exists. Returns the newest such session id or null.
|
|
19
|
+
* Used by `bahulam` (no args) to decide start-vs-attach.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as fsp from 'node:fs/promises';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { daemonSessionsRoot, daemonSocketPath } from '../core/paths.mjs';
|
|
27
|
+
|
|
28
|
+
const POLL_INTERVAL_MS = 100;
|
|
29
|
+
const DEFAULT_WAIT_MS = 15_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Look for a live daemon session bound to `cwd`. A session is "live" if:
|
|
33
|
+
* 1. `~/.bahulam/sessions/<id>/meta.json` has `cwd` matching (realpath).
|
|
34
|
+
* 2. `~/.bahulam/sockets/<id>.sock` exists.
|
|
35
|
+
* 3. `meta.pid` is alive (`kill -0` succeeds — we don't send SIGTERM,
|
|
36
|
+
* just probe existence with signal 0).
|
|
37
|
+
*
|
|
38
|
+
* Returns the newest matching session id or null. Newest = highest
|
|
39
|
+
* `opened_at` in meta.json (falls back to directory mtime).
|
|
40
|
+
*/
|
|
41
|
+
export async function findSessionForCwd(cwd) {
|
|
42
|
+
const root = daemonSessionsRoot();
|
|
43
|
+
let target;
|
|
44
|
+
try { target = fs.realpathSync(cwd); } catch { target = cwd; }
|
|
45
|
+
|
|
46
|
+
let entries;
|
|
47
|
+
try { entries = await fsp.readdir(root, { withFileTypes: true }); }
|
|
48
|
+
catch { return null; }
|
|
49
|
+
|
|
50
|
+
const candidates = [];
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!entry.isDirectory() || !entry.name.startsWith('sess_')) continue;
|
|
53
|
+
const sid = entry.name;
|
|
54
|
+
const dir = path.join(root, sid);
|
|
55
|
+
let meta;
|
|
56
|
+
try { meta = JSON.parse(await fsp.readFile(path.join(dir, 'meta.json'), 'utf-8')); }
|
|
57
|
+
catch { continue; }
|
|
58
|
+
if (!meta.cwd) continue;
|
|
59
|
+
let metaCwd;
|
|
60
|
+
try { metaCwd = fs.realpathSync(meta.cwd); } catch { metaCwd = meta.cwd; }
|
|
61
|
+
if (metaCwd !== target) continue;
|
|
62
|
+
if (!fs.existsSync(daemonSocketPath(sid))) continue;
|
|
63
|
+
if (!_pidAlive(meta.pid)) continue;
|
|
64
|
+
candidates.push({ sid, openedAt: meta.opened_at || '', pid: meta.pid });
|
|
65
|
+
}
|
|
66
|
+
if (candidates.length === 0) return null;
|
|
67
|
+
candidates.sort((a, b) => (b.openedAt || '').localeCompare(a.openedAt || ''));
|
|
68
|
+
return candidates[0].sid;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Spawn a detached bahulam child. The child inherits nothing on stdio
|
|
73
|
+
* (piped to /dev/null via 'ignore') — it lives in the background,
|
|
74
|
+
* writes its transcript to the event log, and any attach client renders
|
|
75
|
+
* from there.
|
|
76
|
+
*
|
|
77
|
+
* Returns { pid, waitForSession(ms?) → sess_id | null }. Caller can
|
|
78
|
+
* await the session_id becoming visible before exiting the parent so
|
|
79
|
+
* the printed "sess_..." line isn't stale.
|
|
80
|
+
*/
|
|
81
|
+
export function spawnDetachedDaemon({
|
|
82
|
+
cwd = process.cwd(),
|
|
83
|
+
prompt = null,
|
|
84
|
+
binPath = process.argv[1], // the bahulam entrypoint that spawned US
|
|
85
|
+
extraEnv = {},
|
|
86
|
+
} = {}) {
|
|
87
|
+
const beforeSet = _listCurrentSessionsSync();
|
|
88
|
+
|
|
89
|
+
const env = {
|
|
90
|
+
...process.env,
|
|
91
|
+
BAHULAM_DAEMON_EVENTLOG: '1',
|
|
92
|
+
// Spawned children auto-quit after the first turn's agent_complete
|
|
93
|
+
// unless the operator opts into idle-hold. Cheap default that
|
|
94
|
+
// matches the "bahulam daemonize <prompt>; check back later"
|
|
95
|
+
// mental model. Override with BAHULAM_DAEMON_HOLD=1 to keep the
|
|
96
|
+
// socket up for follow-up send_message commands.
|
|
97
|
+
BAHULAM_DAEMON_SPAWNED: '1',
|
|
98
|
+
...extraEnv,
|
|
99
|
+
};
|
|
100
|
+
if (prompt) env.BAHULAM_DAEMON_INITIAL_PROMPT = String(prompt);
|
|
101
|
+
|
|
102
|
+
const child = spawn(process.execPath, [binPath], {
|
|
103
|
+
cwd,
|
|
104
|
+
env,
|
|
105
|
+
detached: true,
|
|
106
|
+
stdio: 'ignore',
|
|
107
|
+
});
|
|
108
|
+
child.unref(); // parent can exit without waiting on child
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
pid: child.pid,
|
|
112
|
+
async waitForSession(ms = DEFAULT_WAIT_MS) {
|
|
113
|
+
const deadline = Date.now() + ms;
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
// Find a session_id that's NEW since we spawned + belongs to our cwd.
|
|
116
|
+
const now = _listCurrentSessionsSync();
|
|
117
|
+
for (const sid of now) {
|
|
118
|
+
if (beforeSet.has(sid)) continue;
|
|
119
|
+
const dir = path.join(daemonSessionsRoot(), sid);
|
|
120
|
+
try {
|
|
121
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf-8'));
|
|
122
|
+
if (meta.pid === child.pid) return sid;
|
|
123
|
+
// pid mismatch is fine early — the daemon may not have written
|
|
124
|
+
// meta.json yet; keep polling.
|
|
125
|
+
} catch { /* not written yet */ }
|
|
126
|
+
}
|
|
127
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── internals ────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function _pidAlive(pid) {
|
|
137
|
+
if (typeof pid !== 'number' || pid <= 0) return false;
|
|
138
|
+
try { process.kill(pid, 0); return true; }
|
|
139
|
+
catch (err) {
|
|
140
|
+
// EPERM = process exists but we can't signal it → still alive.
|
|
141
|
+
return err && err.code === 'EPERM';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _listCurrentSessionsSync() {
|
|
146
|
+
try {
|
|
147
|
+
const root = daemonSessionsRoot();
|
|
148
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
149
|
+
return new Set(entries.filter(e => e.isDirectory() && e.name.startsWith('sess_')).map(e => e.name));
|
|
150
|
+
} catch { return new Set(); }
|
|
151
|
+
}
|