@bahulam/code 0.1.2 → 0.1.4
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 +5 -8
- 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-render.mjs +21 -8
- 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
|
@@ -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/terminal/ansi.mjs
CHANGED
|
@@ -727,9 +727,26 @@ export function table(headers, rows) {
|
|
|
727
727
|
// ── Elapsed Timer ──
|
|
728
728
|
|
|
729
729
|
export function formatElapsed(startMs) {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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`;
|
|
733
750
|
}
|
|
734
751
|
|
|
735
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,
|
|
@@ -814,12 +814,23 @@ export function renderStagnation(data = {}) {
|
|
|
814
814
|
const rawMessage = data?.message || '';
|
|
815
815
|
const reason = data?.reason || rawMessage.replace(/^Stagnation:\s*/i, '').trim();
|
|
816
816
|
const tool = data?.tool || data?.tool_name || '';
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
817
|
+
const count = data?.repeat_count || data?.count || null;
|
|
818
|
+
// Try to extract a target/path from the reason so we can show a
|
|
819
|
+
// compact one-liner. Reason shapes we know about from the framework:
|
|
820
|
+
// "Repeated overlapping <tool> inspections of '<target>' N times without mutation"
|
|
821
|
+
// "..." (fallback: use reason as-is, trimmed to ~80 chars)
|
|
822
|
+
const targetMatch = reason.match(/of\s+['"]([^'"]+)['"]/);
|
|
823
|
+
const target = targetMatch ? targetMatch[1] : '';
|
|
824
|
+
|
|
825
|
+
// Compose a compact single-line message:
|
|
826
|
+
// ! stagnation · read_file × 3 · v3_sse.py
|
|
827
|
+
// Falls back to a short slice of the reason when we can't parse it.
|
|
828
|
+
const parts = ['stagnation'];
|
|
829
|
+
if (tool) parts.push(`${tool}${count ? ` × ${count}` : ''}`);
|
|
830
|
+
if (target) parts.push(target);
|
|
831
|
+
const compact = parts.length > 1 ? parts.join(' · ') : reason.slice(0, 80);
|
|
832
|
+
|
|
833
|
+
const key = `${tool}:${target}:${count}`;
|
|
823
834
|
if (session._lastStagnationWarning === key) return;
|
|
824
835
|
session._lastStagnationWarning = key;
|
|
825
836
|
|
|
@@ -827,7 +838,9 @@ export function renderStagnation(data = {}) {
|
|
|
827
838
|
flushContent();
|
|
828
839
|
flushPendingHead();
|
|
829
840
|
renderBlockBoundary('status', { compactSame: true });
|
|
830
|
-
|
|
831
|
-
|
|
841
|
+
// One line, dim yellow, no follow-up paragraph. The full guidance is
|
|
842
|
+
// still injected into the LLM context on the backend side — no need
|
|
843
|
+
// to also spam the operator's terminal with it.
|
|
844
|
+
process.stderr.write(` ${c.yellow('!')} ${c.dim(c.yellow(compact))}\n`);
|
|
832
845
|
runtime.lastRenderedBlock = 'status';
|
|
833
846
|
}
|
package/src/terminal/repl.mjs
CHANGED
|
@@ -25,7 +25,18 @@ import { calculateCost, formatCostValue, formatTokens, costToCredits, formatCred
|
|
|
25
25
|
import { TarangStreamClient, EVENT_TYPES } from '../core/stream-client.mjs';
|
|
26
26
|
import { AgentHistoryTurnBuilder } from '../core/agent-history.mjs';
|
|
27
27
|
import { JsonlWriter } from '../core/jsonl-writer.mjs';
|
|
28
|
+
import { tapSseEvent, registerBroadcaster } from '../daemon/event-tap.mjs';
|
|
29
|
+
import { startSocketServer } from '../daemon/socket-server.mjs';
|
|
30
|
+
import { resolvePending, interceptApproval, shutdownAllPending, setTimeoutPolicy } from '../daemon/approval-store.mjs';
|
|
31
|
+
import { wireEmit as wireInputLockEmit, resetInputLock } from '../daemon/input-lock.mjs';
|
|
32
|
+
import { startRelayBridge } from '../daemon/relay-client.mjs';
|
|
33
|
+
import { loadRemoteConfig } from '../commands/remote.mjs';
|
|
28
34
|
import { createToolExecutor } from '../core/tool-executor.mjs';
|
|
35
|
+
// PRD-091 Phase 3 preview: opt-in gateway loop path. Set
|
|
36
|
+
// BAHULAM_USE_GATEWAY_LOOP=1 to route the REPL's turn through
|
|
37
|
+
// /v1/agent/turn instead of the local bundled runtime. Falls back to
|
|
38
|
+
// the existing local-agent path if the flag is unset.
|
|
39
|
+
import { createAgentLoop, createGatewaySession } from '../core/loop.mjs';
|
|
29
40
|
import { buildWorkScope, promptProjectRoots } from '../core/work-scope.mjs';
|
|
30
41
|
import { CheckpointManager } from '../core/checkpoints.mjs';
|
|
31
42
|
import { HookRunner } from '../config/hook-runner.mjs';
|
|
@@ -2008,7 +2019,12 @@ function renderEvent(event) {
|
|
|
2008
2019
|
flushFoldedSubAgentTools();
|
|
2009
2020
|
const agentType = data?.type || 'sub-agent';
|
|
2010
2021
|
const usage = data?.usage || {};
|
|
2011
|
-
|
|
2022
|
+
// Output tokens = generation size. Summing input+output across a
|
|
2023
|
+
// multi-iteration sub-agent double-counts the context re-shipped each
|
|
2024
|
+
// iteration (a 16-iter run can inflate to 600k+ "tokens" of which
|
|
2025
|
+
// ~95% is repeated context) — the resulting number reads as usage but
|
|
2026
|
+
// it's really billing accumulation, not useful signal in the close line.
|
|
2027
|
+
const tokens = usage.output_tokens || 0;
|
|
2012
2028
|
const costUsd = usage.cost_usd ?? usage.total_cost_usd ?? data?.cost_usd ?? null;
|
|
2013
2029
|
if (typeof costUsd === 'number') session.savedUsd += costUsd;
|
|
2014
2030
|
const summary = data?.result_summary
|
|
@@ -2048,6 +2064,150 @@ function renderEvent(event) {
|
|
|
2048
2064
|
session.id = data.session_id;
|
|
2049
2065
|
// Track in session manager so conversations save to the right file
|
|
2050
2066
|
if (sessionMgrRef.current) sessionMgrRef.current.setSessionInfo({ session_id: data.session_id });
|
|
2067
|
+
// Wire socket server for attach clients.
|
|
2068
|
+
// renderEvent() is NOT async, so we can't `await startSocketServer(...)`
|
|
2069
|
+
// directly (that fails at import time — "Unexpected reserved word").
|
|
2070
|
+
// Fire-and-forget IIFE, and guard against re-entry on session_info
|
|
2071
|
+
// repeats (backend can re-emit on reconnect; two listen()s on the
|
|
2072
|
+
// same sock path → EADDRINUSE and both server + tap explode).
|
|
2073
|
+
if (process.env.BAHULAM_DAEMON_EVENTLOG === '1' && !session._prd092SocketStarting && !session._prd092SocketServer) {
|
|
2074
|
+
session._prd092SocketStarting = true;
|
|
2075
|
+
const _sid = session.id;
|
|
2076
|
+
(async () => {
|
|
2077
|
+
try {
|
|
2078
|
+
const server = await startSocketServer({
|
|
2079
|
+
sessionId: _sid,
|
|
2080
|
+
onCommand: {
|
|
2081
|
+
// Slice E — approve/deny now resolve any pending approval
|
|
2082
|
+
// registered via interceptApproval(). Returns false if the
|
|
2083
|
+
// apr_id is unknown or already answered (log-only, not an
|
|
2084
|
+
// error surfaced to the client — the racy nature of two
|
|
2085
|
+
// attaches answering is expected).
|
|
2086
|
+
approve: async (payload, attachId) => {
|
|
2087
|
+
const ok = resolvePending('approve', payload?.apr_id, attachId, payload?.note);
|
|
2088
|
+
if (!ok) try { process.stderr.write(`[prd-092] approve for unknown apr_id ${payload?.apr_id}\n`); } catch {}
|
|
2089
|
+
},
|
|
2090
|
+
deny: async (payload, attachId) => {
|
|
2091
|
+
const ok = resolvePending('deny', payload?.apr_id, attachId, payload?.note);
|
|
2092
|
+
if (!ok) try { process.stderr.write(`[prd-092] deny for unknown apr_id ${payload?.apr_id}\n`); } catch {}
|
|
2093
|
+
},
|
|
2094
|
+
// Slice C/E — interrupt from an attach holder cancels the
|
|
2095
|
+
// current turn. Uses the stream client on the closure
|
|
2096
|
+
// above (streamClient variable is in scope in this file
|
|
2097
|
+
// at the outer REPL loop; if unavailable, log and skip).
|
|
2098
|
+
interrupt: async (_payload, _attachId) => {
|
|
2099
|
+
try { if (typeof streamClient?.cancel === 'function') streamClient.cancel(); } catch {}
|
|
2100
|
+
},
|
|
2101
|
+
send_message: async (_payload, _attachId) => {
|
|
2102
|
+
// Slice C follow-up. Requires plumbing into the turn
|
|
2103
|
+
// handler; enqueued as a TODO for the daemon slice.
|
|
2104
|
+
},
|
|
2105
|
+
}
|
|
2106
|
+
});
|
|
2107
|
+
session._prd092SocketServer = server;
|
|
2108
|
+
session._prd092Unregister = registerBroadcaster(evt => server.broadcastEvent(evt));
|
|
2109
|
+
// Slice E — input-lock changes broadcast via the tap so the
|
|
2110
|
+
// input_lock_changed events flow to attached clients + land
|
|
2111
|
+
// in the event log. Emit under the sessionId in scope.
|
|
2112
|
+
wireInputLockEmit((type, data) => {
|
|
2113
|
+
try { tapSseEvent({ type, data }, { sessionId: _sid }); }
|
|
2114
|
+
catch { /* never blocks a lock transition */ }
|
|
2115
|
+
});
|
|
2116
|
+
// Slice E — apply the timeout policy from env (safe default:
|
|
2117
|
+
// 'hold' = never times out). Explicit opt-in via env var so
|
|
2118
|
+
// running unattended is a conscious choice, not a surprise.
|
|
2119
|
+
// BAHULAM_APPROVAL_TIMEOUT=hold (default)
|
|
2120
|
+
// BAHULAM_APPROVAL_TIMEOUT=deny:300 (auto-deny after 5min)
|
|
2121
|
+
// BAHULAM_APPROVAL_TIMEOUT=allow:300 (auto-approve; gated behind --dangerously-skip-permissions elsewhere)
|
|
2122
|
+
try {
|
|
2123
|
+
const p = String(process.env.BAHULAM_APPROVAL_TIMEOUT || 'hold').trim();
|
|
2124
|
+
const [mode, secStr] = p.split(':');
|
|
2125
|
+
setTimeoutPolicy({
|
|
2126
|
+
mode: (mode === 'deny' || mode === 'allow') ? mode : 'hold',
|
|
2127
|
+
durationSec: Number(secStr) || 0,
|
|
2128
|
+
});
|
|
2129
|
+
} catch { /* stay on hold */ }
|
|
2130
|
+
// Slice E — intercept ApprovalManager.check so every prompt
|
|
2131
|
+
// also emits approval_required to attached clients AND can be
|
|
2132
|
+
// resolved by a remote approve/deny command (racing the local
|
|
2133
|
+
// TTY prompt; first answer wins). Only patch once per session.
|
|
2134
|
+
try {
|
|
2135
|
+
if (approval && !approval._prd092Intercepted) {
|
|
2136
|
+
const orig = approval.check.bind(approval);
|
|
2137
|
+
approval.check = (tool, args, req, ctx) => interceptApproval(orig, {
|
|
2138
|
+
tool, args, req, ctx,
|
|
2139
|
+
sessionId: _sid,
|
|
2140
|
+
emit: (type, data) => {
|
|
2141
|
+
try { tapSseEvent({ type, data }, { sessionId: _sid }); }
|
|
2142
|
+
catch { /* never blocks approval */ }
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
approval._prd092Intercepted = true;
|
|
2146
|
+
}
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
try { process.stderr.write(`[prd-092] approval intercept: ${err.message}\n`); } catch {}
|
|
2149
|
+
}
|
|
2150
|
+
// Slice H — dial the relay if `bahulam remote enable` set the
|
|
2151
|
+
// flag. Bridge is bidirectional: local events go out as
|
|
2152
|
+
// control-frame envelopes, incoming envelopes dispatch to the
|
|
2153
|
+
// same handlers the local socket-server uses (so approve/deny
|
|
2154
|
+
// from a phone runs the same resolvePending path).
|
|
2155
|
+
try {
|
|
2156
|
+
const remoteCfg = loadRemoteConfig();
|
|
2157
|
+
if (remoteCfg?.enabled) {
|
|
2158
|
+
const bridge = startRelayBridge({
|
|
2159
|
+
sessionId: _sid,
|
|
2160
|
+
remoteConfig: remoteCfg,
|
|
2161
|
+
registerBroadcaster,
|
|
2162
|
+
onCommand: {
|
|
2163
|
+
approve: async (payload, attachId) => resolvePending('approve', payload?.apr_id, attachId, payload?.note),
|
|
2164
|
+
deny: async (payload, attachId) => resolvePending('deny', payload?.apr_id, attachId, payload?.note),
|
|
2165
|
+
interrupt: async () => { try { if (typeof streamClient?.cancel === 'function') streamClient.cancel(); } catch {} },
|
|
2166
|
+
send_message: async (_p, _a) => { /* Slice C follow-up */ },
|
|
2167
|
+
},
|
|
2168
|
+
});
|
|
2169
|
+
session._prd092RelayBridge = bridge;
|
|
2170
|
+
}
|
|
2171
|
+
} catch (err) {
|
|
2172
|
+
try { process.stderr.write(`[prd-092] relay bridge: ${err.message}\n`); } catch {}
|
|
2173
|
+
}
|
|
2174
|
+
// Populate ~/.bahulam/sessions/<id>/meta.json + daemon.pid so
|
|
2175
|
+
// `bahulam list` and `bahulam stop <id>` see this session.
|
|
2176
|
+
// These files are what session-list.mjs / stop-daemon.mjs
|
|
2177
|
+
// read; without them those commands are cosmetic. Fire-and-
|
|
2178
|
+
// forget imports so a write failure never affects the turn.
|
|
2179
|
+
try {
|
|
2180
|
+
const [{ writeSessionMeta }, fs, path, { daemonSessionDir }] = await Promise.all([
|
|
2181
|
+
import('../core/event-log.mjs'),
|
|
2182
|
+
import('node:fs'),
|
|
2183
|
+
import('node:path'),
|
|
2184
|
+
import('../core/paths.mjs'),
|
|
2185
|
+
]);
|
|
2186
|
+
await writeSessionMeta({
|
|
2187
|
+
sessionId: _sid,
|
|
2188
|
+
meta: {
|
|
2189
|
+
cwd: process.cwd(),
|
|
2190
|
+
model: session.model || null,
|
|
2191
|
+
pid: process.pid,
|
|
2192
|
+
sock_path: server.sockPath,
|
|
2193
|
+
opened_at: new Date().toISOString(),
|
|
2194
|
+
},
|
|
2195
|
+
});
|
|
2196
|
+
fs.writeFileSync(
|
|
2197
|
+
path.join(daemonSessionDir(_sid), 'daemon.pid'),
|
|
2198
|
+
String(process.pid),
|
|
2199
|
+
{ mode: 0o600 },
|
|
2200
|
+
);
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
try { process.stderr.write(`[prd-092] meta/pid write: ${err.message}\n`); } catch {}
|
|
2203
|
+
}
|
|
2204
|
+
} catch (err) {
|
|
2205
|
+
try { process.stderr.write(`[prd-092] socket server: ${err.message}\n`); } catch {}
|
|
2206
|
+
} finally {
|
|
2207
|
+
session._prd092SocketStarting = false;
|
|
2208
|
+
}
|
|
2209
|
+
})();
|
|
2210
|
+
}
|
|
2051
2211
|
}
|
|
2052
2212
|
if (data?.model) session.model = data.model;
|
|
2053
2213
|
if (data?.models?.coder) session.model = data.models.coder;
|
|
@@ -5116,8 +5276,47 @@ export async function startTerminalRepl() {
|
|
|
5116
5276
|
}
|
|
5117
5277
|
}
|
|
5118
5278
|
|
|
5119
|
-
|
|
5279
|
+
// PRD-091 Phase 3 preview: BAHULAM_USE_GATEWAY_LOOP=1 routes the
|
|
5280
|
+
// turn through the gateway's /v1/agent/turn (thin CLI loop) instead
|
|
5281
|
+
// of the local bundled runtime. Session is bootstrapped lazily on
|
|
5282
|
+
// first turn and reused across the REPL. Falls through to the
|
|
5283
|
+
// existing local-agent path when the flag is unset (default today).
|
|
5284
|
+
const _useGatewayLoop = process.env.BAHULAM_USE_GATEWAY_LOOP === '1';
|
|
5285
|
+
let _turnIterable;
|
|
5286
|
+
if (_useGatewayLoop) {
|
|
5287
|
+
if (!session.gatewaySession) {
|
|
5288
|
+
try {
|
|
5289
|
+
session.gatewaySession = await createGatewaySession({
|
|
5290
|
+
workspace: process.env.BAHULAM_WORKSPACE || 'kepler-code',
|
|
5291
|
+
model: process.env.BAHULAM_MODEL || undefined,
|
|
5292
|
+
});
|
|
5293
|
+
process.stderr.write(
|
|
5294
|
+
` ${c.dim(`[gateway] session ${session.gatewaySession.session_id.slice(0, 20)}… ${session.gatewaySession.tool_schemas.length} tools, model=${session.gatewaySession.model}`)}\n`,
|
|
5295
|
+
);
|
|
5296
|
+
} catch (err) {
|
|
5297
|
+
process.stderr.write(` ${c.warn(`[gateway] session create failed: ${err.message}`)}\n`);
|
|
5298
|
+
process.stderr.write(` ${c.dim('falling back to local-agent path')}\n`);
|
|
5299
|
+
session.gatewaySession = null; // don't retry every turn
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
if (session.gatewaySession) {
|
|
5303
|
+
_turnIterable = createAgentLoop({
|
|
5304
|
+
session: session.gatewaySession,
|
|
5305
|
+
messages: session.agentHistory,
|
|
5306
|
+
toolExecutor,
|
|
5307
|
+
});
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5310
|
+
if (!_turnIterable) {
|
|
5311
|
+
_turnIterable = client.execute(input, execContext, session.agentHistory);
|
|
5312
|
+
}
|
|
5313
|
+
for await (const event of _turnIterable) {
|
|
5120
5314
|
jsonlWriter.writeKeplerEvent(event);
|
|
5315
|
+
// . daemon event log. Env-var gated (off by default) —
|
|
5316
|
+
// when BAHULAM_DAEMON_EVENTLOG=1, mirror each SSE frame that maps
|
|
5317
|
+
// to a first-class type into ~/.bahulam/sessions/<id>/events.jsonl.
|
|
5318
|
+
// No-op otherwise; zero effect on the render path either way.
|
|
5319
|
+
tapSseEvent(event, { sessionId: session.id });
|
|
5121
5320
|
if (event.type === 'plan_created' || event.type === 'goal_created') {
|
|
5122
5321
|
persistProjectArtifacts(
|
|
5123
5322
|
event.data,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analyze Code Tool — AST-based structured code analysis (matches Python schema).
|
|
3
|
+
*/
|
|
4
|
+
import { analyzeCode } from '../context/ast-parser.mjs';
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const AnalyzeCodeTool = {
|
|
9
|
+
name: 'analyze_code',
|
|
10
|
+
description:
|
|
11
|
+
'Get structured analysis of one specific file: function/class names with LINE NUMBERS, imports, exports. Never pass a directory or project root.',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
file_path: { type: 'string', description: 'Path to a specific file to analyze' },
|
|
16
|
+
},
|
|
17
|
+
required: ['file_path'],
|
|
18
|
+
},
|
|
19
|
+
validateInput(input) {
|
|
20
|
+
return input.file_path ? [] : ['file_path required'];
|
|
21
|
+
},
|
|
22
|
+
async call(input) {
|
|
23
|
+
const filePath = path.resolve(input.file_path);
|
|
24
|
+
let stat;
|
|
25
|
+
try {
|
|
26
|
+
stat = fs.statSync(filePath);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return `Error: ${err.message}`;
|
|
29
|
+
}
|
|
30
|
+
if (stat.isDirectory()) {
|
|
31
|
+
return `Error: analyze_code expects a file, but got directory: ${filePath}. Use list_files/search_code first, then pass a specific source file.`;
|
|
32
|
+
}
|
|
33
|
+
const result = analyzeCode(filePath, {
|
|
34
|
+
startLine: input.start_line,
|
|
35
|
+
endLine: input.end_line,
|
|
36
|
+
});
|
|
37
|
+
return result.summary;
|
|
38
|
+
},
|
|
39
|
+
};
|
package/src/tools/bash.mjs
CHANGED
package/src/tools/edit.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
5
|
* - replace_all parameter for global replacement
|
|
6
|
-
* - Verify
|
|
6
|
+
* - Verify search string is unique (error if not)
|
|
7
7
|
* - Require file was Read first (track read files)
|
|
8
8
|
* - Preserve exact indentation
|
|
9
9
|
*/
|
|
@@ -12,23 +12,23 @@ import path from 'path';
|
|
|
12
12
|
import { hasBeenRead, markRead } from './read.mjs';
|
|
13
13
|
|
|
14
14
|
export const EditTool = {
|
|
15
|
-
name: '
|
|
15
|
+
name: 'edit_file',
|
|
16
16
|
description: 'Performs exact string replacements in files.',
|
|
17
17
|
inputSchema: {
|
|
18
18
|
type: 'object',
|
|
19
19
|
properties: {
|
|
20
20
|
file_path: { type: 'string', description: 'Absolute path to the file' },
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
search: { type: 'string', description: 'The text to replace' },
|
|
22
|
+
replace: { type: 'string', description: 'The replacement text' },
|
|
23
23
|
replace_all: { type: 'boolean', description: 'Replace all occurrences', default: false },
|
|
24
24
|
},
|
|
25
|
-
required: ['file_path', '
|
|
25
|
+
required: ['file_path', 'search', 'replace'],
|
|
26
26
|
},
|
|
27
27
|
validateInput(input) {
|
|
28
28
|
const errors = [];
|
|
29
29
|
if (!input.file_path) errors.push('file_path required');
|
|
30
|
-
if (!input.
|
|
31
|
-
if (input.
|
|
30
|
+
if (!input.search && input.search !== '') errors.push('search is required');
|
|
31
|
+
if (input.search === input.replace) errors.push('search must differ from replace');
|
|
32
32
|
return errors;
|
|
33
33
|
},
|
|
34
34
|
async call(input) {
|
|
@@ -51,23 +51,23 @@ export const EditTool = {
|
|
|
51
51
|
return `Error: ${e.message}`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
if (!content.includes(input.
|
|
55
|
-
return 'Error:
|
|
54
|
+
if (!content.includes(input.search)) {
|
|
55
|
+
return 'Error: search string not found in file. Make sure the string matches exactly, including whitespace and indentation.';
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
if (input.replace_all) {
|
|
59
59
|
// Replace all occurrences
|
|
60
|
-
const escaped = input.
|
|
61
|
-
content = content.replace(new RegExp(escaped, 'g'), input.
|
|
60
|
+
const escaped = input.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
61
|
+
content = content.replace(new RegExp(escaped, 'g'), input.replace);
|
|
62
62
|
} else {
|
|
63
|
-
// Check uniqueness:
|
|
64
|
-
const firstIdx = content.indexOf(input.
|
|
65
|
-
const secondIdx = content.indexOf(input.
|
|
63
|
+
// Check uniqueness: search string must appear exactly once
|
|
64
|
+
const firstIdx = content.indexOf(input.search);
|
|
65
|
+
const secondIdx = content.indexOf(input.search, firstIdx + 1);
|
|
66
66
|
if (secondIdx !== -1) {
|
|
67
|
-
const count = content.split(input.
|
|
68
|
-
return `Error:
|
|
67
|
+
const count = content.split(input.search).length - 1;
|
|
68
|
+
return `Error: search string is not unique in the file (found ${count} occurrences). Provide more context to make it unique, or use replace_all to replace all occurrences.`;
|
|
69
69
|
}
|
|
70
|
-
content = content.replace(input.
|
|
70
|
+
content = content.replace(input.search, input.replace);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
try {
|
|
@@ -79,4 +79,4 @@ export const EditTool = {
|
|
|
79
79
|
return `Error writing file: ${e.message}`;
|
|
80
80
|
}
|
|
81
81
|
},
|
|
82
|
-
};
|
|
82
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Diff Tool — shows unstaged changes (matches Python schema).
|
|
3
|
+
*/
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const GitDiffTool = {
|
|
8
|
+
name: 'git_diff',
|
|
9
|
+
description: 'Show git diff of current changes — verify what was modified',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
file_path: { type: 'string', description: 'Specific file to diff (optional, defaults to all changes)' },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
validateInput(input) {
|
|
17
|
+
return [];
|
|
18
|
+
},
|
|
19
|
+
async call(input) {
|
|
20
|
+
try {
|
|
21
|
+
const cwd = input._cwd || process.cwd();
|
|
22
|
+
const fileArg = input.file_path ? ` -- "${input.file_path}"` : '';
|
|
23
|
+
const output = execSync(`git diff${fileArg}`, {
|
|
24
|
+
encoding: 'utf-8',
|
|
25
|
+
timeout: 10_000,
|
|
26
|
+
cwd,
|
|
27
|
+
stdio: 'pipe',
|
|
28
|
+
}).toString().trim();
|
|
29
|
+
return output || '(no changes)';
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return `Error: ${err.message}`;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Status Tool — shows working tree status (matches Python schema).
|
|
3
|
+
*/
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export const GitStatusTool = {
|
|
7
|
+
name: 'git_status',
|
|
8
|
+
description: 'Show git status — list modified, added, deleted files',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {},
|
|
12
|
+
},
|
|
13
|
+
validateInput(input) {
|
|
14
|
+
return [];
|
|
15
|
+
},
|
|
16
|
+
async call(input) {
|
|
17
|
+
try {
|
|
18
|
+
const cwd = input._cwd || process.cwd();
|
|
19
|
+
const output = execSync('git status --short', {
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
timeout: 10_000,
|
|
22
|
+
cwd,
|
|
23
|
+
stdio: 'pipe',
|
|
24
|
+
}).toString().trim();
|
|
25
|
+
return output || '(clean)';
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return `Error: ${err.message}`;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|