@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,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
@@ -34,12 +34,12 @@ export async function promptPermission(toolName, input, rl) {
34
34
  */
35
35
  export function formatToolSummary(toolName, input) {
36
36
  switch (toolName) {
37
- case 'Bash':
38
- return `Bash: ${truncate(input.command || '', 60)}`;
39
- case 'Edit':
40
- return `Edit: ${input.file_path || 'unknown file'}`;
41
- case 'Write':
42
- return `Write: ${input.file_path || 'unknown file'} (${(input.content || '').length} chars)`;
37
+ case 'shell':
38
+ return `shell: ${truncate(input.command || '', 60)}`;
39
+ case 'edit_file':
40
+ return `edit_file: ${input.file_path || 'unknown file'}`;
41
+ case 'write_file':
42
+ return `write_file: ${input.file_path || 'unknown file'} (${(input.content || '').length} chars)`;
43
43
  case 'MultiEdit':
44
44
  return `MultiEdit: ${input.file_path || 'unknown file'} (${(input.edits || []).length} edits)`;
45
45
  case 'Agent':
@@ -61,7 +61,8 @@ export function formatToolSummary(toolName, input) {
61
61
  */
62
62
  export function requiresPermission(toolName) {
63
63
  const SAFE_TOOLS = new Set([
64
- 'Read', 'Glob', 'Grep', 'LS', 'ToolSearch',
64
+ 'read_file', 'list_files', 'search_code',
65
+ 'grep', 'search_files', 'LS', 'ToolSearch',
65
66
  'AskUser', 'CronList', 'TodoWrite',
66
67
  ]);
67
68
  return !SAFE_TOOLS.has(toolName);
@@ -119,12 +119,20 @@ export class SkillInstaller {
119
119
  fs.mkdirSync(skillsDir, { recursive: true });
120
120
 
121
121
  const plans = [];
122
+ const seenNames = new Set();
122
123
  for (const skillDir of skillDirs) {
123
124
  rejectSymlinks(skillDir);
124
125
  const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
125
126
  const name = parseSkill(content, path.basename(skillDir)).name;
126
127
  if (onlyNames && !onlyNames.includes(name)) continue;
127
128
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new Error(`Unsafe skill name: ${name}`);
129
+ // Source repos sometimes ship multiple SKILL.md bundles that all
130
+ // parse to the same name (e.g. one variant per agent tool).
131
+ // Silently keep only the FIRST occurrence — otherwise we'd copy
132
+ // N bundles into the same destination and the last write would
133
+ // win invisibly.
134
+ if (seenNames.has(name)) continue;
135
+ seenNames.add(name);
128
136
  const destination = path.join(skillsDir, name);
129
137
  if (fs.existsSync(destination)) {
130
138
  if (!force) throw new Error(`Skill already installed: ${name} (use --force to replace)`);
@@ -600,21 +600,80 @@ function inlineMarkdown(text) {
600
600
  /**
601
601
  * Render a unified diff with +/- color highlighting.
602
602
  */
603
- export function renderDiff(diffText) {
603
+ // Parses `@@ -old_start,old_count +new_start,new_count @@` from a unified
604
+ // diff hunk header. Missing counts default to 1 (per unified-diff spec).
605
+ const _HUNK_RE = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
606
+
607
+ function _padLineNo(n, width) {
608
+ return n === null ? ' '.repeat(width) : String(n).padStart(width);
609
+ }
610
+
611
+ /**
612
+ * Render a unified diff with GitHub-PR-style single-column line numbers.
613
+ *
614
+ * 12 context line
615
+ * 13 - removed line
616
+ * 13 + added line
617
+ * 14 context line
618
+ *
619
+ * Deletions show the OLD-side line number; additions and context show the
620
+ * NEW-side number. Hunk headers (`@@ ... @@`) are kept as visual section
621
+ * markers and seed the counters. Pass `{ numbers: false }` to fall back to
622
+ * the plain colored-only form.
623
+ */
624
+ export function renderDiff(diffText, { numbers = true } = {}) {
604
625
  if (!diffText) return '';
605
626
  const lines = diffText.split('\n');
606
627
  const out = [];
628
+ let oldLn = null;
629
+ let newLn = null;
630
+ // Width the gutter to the largest number that will appear in this diff,
631
+ // clamped to 4 so short diffs still line up cleanly. Prevents a single
632
+ // huge line number from pushing the whole gutter wide.
633
+ let gutterWidth = 4;
634
+ if (numbers) {
635
+ for (const line of lines) {
636
+ const m = line.match(_HUNK_RE);
637
+ if (m) {
638
+ const maxNum = Math.max(parseInt(m[1], 10), parseInt(m[2], 10));
639
+ gutterWidth = Math.max(gutterWidth, String(maxNum + 200).length);
640
+ }
641
+ }
642
+ }
643
+
607
644
  for (const line of lines) {
608
645
  if (line.startsWith('+++') || line.startsWith('---')) {
609
646
  out.push(c.bold(line));
610
- } else if (line.startsWith('@@')) {
647
+ continue;
648
+ }
649
+ const m = line.match(_HUNK_RE);
650
+ if (m) {
651
+ oldLn = parseInt(m[1], 10);
652
+ newLn = parseInt(m[2], 10);
611
653
  out.push(c.brand(line));
612
- } else if (line.startsWith('+')) {
613
- out.push(c.green(line));
654
+ continue;
655
+ }
656
+ if (!numbers || oldLn === null) {
657
+ if (line.startsWith('+')) out.push(c.green(line));
658
+ else if (line.startsWith('-')) out.push(c.red(line));
659
+ else out.push(c.gray(line));
660
+ continue;
661
+ }
662
+ if (line.startsWith('+')) {
663
+ out.push(`${c.gray(_padLineNo(newLn, gutterWidth))} ${c.green(line)}`);
664
+ newLn += 1;
614
665
  } else if (line.startsWith('-')) {
615
- out.push(c.red(line));
666
+ out.push(`${c.gray(_padLineNo(oldLn, gutterWidth))} ${c.red(line)}`);
667
+ oldLn += 1;
668
+ } else if (line.startsWith('\\')) {
669
+ // "" — meta, no line-number applies.
670
+ out.push(`${' '.repeat(gutterWidth)} ${c.dim(line)}`);
616
671
  } else {
617
- out.push(c.gray(line));
672
+ // Context (line starts with a single space per unified-diff spec,
673
+ // or is truly empty for a blank context line).
674
+ out.push(`${c.gray(_padLineNo(newLn, gutterWidth))} ${c.gray(line)}`);
675
+ oldLn += 1;
676
+ newLn += 1;
618
677
  }
619
678
  }
620
679
  return out.join('\n');
@@ -668,9 +727,26 @@ export function table(headers, rows) {
668
727
  // ── Elapsed Timer ──
669
728
 
670
729
  export function formatElapsed(startMs) {
671
- const s = Math.floor((Date.now() - startMs) / 1000);
672
- if (s < 60) return `${s}s`;
673
- return `${Math.floor(s / 60)}m${s % 60}s`;
730
+ return formatSeconds((Date.now() - startMs) / 1000);
731
+ }
732
+
733
+ /**
734
+ * Human-readable duration from a numeric seconds value. Same shape as
735
+ * formatElapsed (`1h:35m:19s` / `35m:19s` / `19s`) so every duration in
736
+ * the UI reads the same. Sub-second values fall back to one decimal so
737
+ * fast tool calls don't collapse to `0s`.
738
+ */
739
+ export function formatSeconds(seconds) {
740
+ const s = Number(seconds);
741
+ if (!Number.isFinite(s) || s < 0) return '0s';
742
+ if (s < 1) return `${s.toFixed(1)}s`;
743
+ const whole = Math.floor(s);
744
+ const h = Math.floor(whole / 3600);
745
+ const m = Math.floor((whole % 3600) / 60);
746
+ const sec = whole % 60;
747
+ if (h > 0) return `${h}h:${m}m:${sec}s`;
748
+ if (m > 0) return `${m}m:${sec}s`;
749
+ return `${sec}s`;
674
750
  }
675
751
 
676
752
  // ── Format Cost ──
@@ -232,6 +232,14 @@ async function main() {
232
232
  bahulam init Scaffold .bahulam config, memory, hooks, tasks
233
233
  bahulam version Show version
234
234
 
235
+ \x1b[1mDaemon:\x1b[0m
236
+ bahulam list List detached daemon sessions
237
+ bahulam attach <id> Attach to a running daemon
238
+ bahulam stop <id> Stop a running daemon
239
+ bahulam pair Pair a device for remote access
240
+ bahulam remote enable Enable relay connection
241
+ bahulam remote disable Disable relay connection (kill switch)
242
+
235
243
  \x1b[1mAnalytics:\x1b[0m
236
244
  bahulam sessions List recent local sessions
237
245
  bahulam stats Show aggregate local session stats
@@ -285,12 +293,98 @@ async function main() {
285
293
  return;
286
294
  }
287
295
 
288
- // ── Headless mode (benchmarks, automation) ──
296
+ // ── Daemon subcommands (daemon) ──
297
+
298
+ if (subcommand === 'list') {
299
+ const { listDaemonSessions } = await import('../daemon/session-list.mjs');
300
+ await listDaemonSessions();
301
+ return;
302
+ }
303
+
304
+ if (subcommand === 'attach') {
305
+ const { attachToSession } = await import('../daemon/attach-client.mjs');
306
+ await attachToSession(subcommandArgs[0]);
307
+ return;
308
+ }
309
+
310
+ if (subcommand === 'stop') {
311
+ const { stopDaemonSession } = await import('../daemon/stop-daemon.mjs');
312
+ await stopDaemonSession(subcommandArgs[0]);
313
+ return;
314
+ }
315
+
316
+ if (subcommand === 'pair') {
317
+ const { runPairCommand } = await import('../commands/pair.mjs');
318
+ await runPairCommand(subcommandArgs);
319
+ return;
320
+ }
321
+
322
+ if (subcommand === 'device') {
323
+ const { runDeviceCommand } = await import('../commands/device.mjs');
324
+ const code = await runDeviceCommand(subcommandArgs);
325
+ process.exit(code || 0);
326
+ }
327
+
328
+ if (subcommand === 'remote') {
329
+ const { runRemoteCommand } = await import('../commands/remote.mjs');
330
+ await runRemoteCommand(subcommandArgs);
331
+ return;
332
+ }
333
+
334
+ // auto-daemon spawn.
335
+ // bahulam daemonize [prompt] → fork a detached bahulam child with
336
+ // the socket server up. Parent waits briefly for the child's session
337
+ // id to appear, prints it, and exits. Attach later with `bahulam
338
+ // attach <sess_id>` or from a paired device via the relay.
339
+ if (subcommand === 'daemonize') {
340
+ const { spawnDetachedDaemon } = await import('../daemon/daemonize.mjs');
341
+ const initialPrompt = subcommandArgs.join(' ').trim();
342
+ const { pid, waitForSession } = spawnDetachedDaemon({
343
+ cwd: process.cwd(),
344
+ prompt: initialPrompt || null,
345
+ });
346
+ process.stderr.write(`\x1b[2mdaemon spawned pid=${pid}, waiting for session id…\x1b[0m\n`);
347
+ const sid = await waitForSession();
348
+ if (sid) {
349
+ process.stderr.write(`\x1b[32m✓\x1b[0m ${sid}\n`);
350
+ process.stderr.write(` \x1b[2mattach:\x1b[0m bahulam attach ${sid}\n`);
351
+ process.stderr.write(` \x1b[2mstop: \x1b[0m bahulam stop ${sid}\n`);
352
+ process.exit(0);
353
+ } else {
354
+ process.stderr.write(`\x1b[33m! daemon spawned (pid ${pid}) but no session id visible after 15s. Check \`bahulam list\`.\x1b[0m\n`);
355
+ process.exit(2);
356
+ }
357
+ }
358
+
359
+ // auto-attach when a live daemon is bound to this cwd.
360
+ // Opt-in via BAHULAM_AUTO_ATTACH=1 so existing muscle memory (bahulam →
361
+ // fresh REPL) isn't disrupted for users who haven't opted into the daemon
362
+ // model. `bahulam --no-attach` bypasses even with the env var set.
363
+ const wantsAutoAttach = process.env.BAHULAM_AUTO_ATTACH === '1' && !process.argv.includes('--no-attach');
364
+ if (wantsAutoAttach && !subcommand) {
365
+ const { findSessionForCwd } = await import('../daemon/daemonize.mjs');
366
+ const existing = await findSessionForCwd(process.cwd());
367
+ if (existing) {
368
+ process.stderr.write(`\x1b[2mattaching to existing daemon ${existing} (BAHULAM_AUTO_ATTACH)…\x1b[0m\n`);
369
+ const { attachToSession } = await import('../daemon/attach-client.mjs');
370
+ const code = await attachToSession(existing);
371
+ process.exit(code || 0);
372
+ }
373
+ }
374
+
375
+ // ── Headless mode (benchmarks, automation, daemonize) ──
289
376
  const args = parseArgs(process.argv.slice(2));
290
- if (args.prompt && (process.argv.includes('--headless') || !process.stdin.isTTY)) {
377
+ // Slice D: when this process is a spawned daemon (via
378
+ // `bahulam daemonize`), pull the initial prompt from the env var
379
+ // rather than argv — child was spawned with stdio: 'ignore' and no
380
+ // shell args. BAHULAM_DAEMON_SPAWNED=1 is set by daemonize.mjs.
381
+ const daemonSpawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
382
+ const daemonPrompt = daemonSpawned ? (process.env.BAHULAM_DAEMON_INITIAL_PROMPT || '').trim() : '';
383
+ const effectivePrompt = args.prompt || (daemonSpawned && daemonPrompt) || '';
384
+ if (effectivePrompt && (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY)) {
291
385
  const { runHeadless } = await import('../core/headless.mjs');
292
386
  await runHeadless({
293
- instruction: args.prompt,
387
+ instruction: effectivePrompt,
294
388
  model: args.model,
295
389
  timeout: args.timeout || (args.maxTurns ? args.maxTurns * 60 : 600),
296
390
  verbose: args.verbose,