@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,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 === '
|
|
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 (['
|
|
29
|
-
const pathResult = validatePath(input.file_path, { write: toolName !== '
|
|
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
|
|
39
|
-
if (toolName === '
|
|
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 === '
|
|
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 '
|
|
38
|
-
return `
|
|
39
|
-
case '
|
|
40
|
-
return `
|
|
41
|
-
case '
|
|
42
|
-
return `
|
|
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
|
-
'
|
|
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);
|
package/src/skills/installer.mjs
CHANGED
|
@@ -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)`);
|
package/src/terminal/ansi.mjs
CHANGED
|
@@ -600,21 +600,80 @@ function inlineMarkdown(text) {
|
|
|
600
600
|
/**
|
|
601
601
|
* Render a unified diff with +/- color highlighting.
|
|
602
602
|
*/
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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 ──
|
package/src/terminal/main.mjs
CHANGED
|
@@ -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
|
-
// ──
|
|
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
|
-
|
|
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:
|
|
387
|
+
instruction: effectivePrompt,
|
|
294
388
|
model: args.model,
|
|
295
389
|
timeout: args.timeout || (args.maxTurns ? args.maxTurns * 60 : 600),
|
|
296
390
|
verbose: args.verbose,
|