@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.
Files changed (45) hide show
  1. package/package.json +5 -8
  2. package/pulse/lib/tool-categories.ts +13 -0
  3. package/src/commands/device.mjs +121 -0
  4. package/src/commands/pair.mjs +190 -0
  5. package/src/commands/remote.mjs +110 -0
  6. package/src/core/event-log.mjs +393 -0
  7. package/src/core/headless.mjs +198 -0
  8. package/src/core/loop.mjs +276 -0
  9. package/src/core/memory-disk.mjs +210 -0
  10. package/src/core/paths.mjs +36 -0
  11. package/src/core/stream-client.mjs +28 -9
  12. package/src/core/tool-executor.mjs +56 -16
  13. package/src/daemon/approval-store.mjs +253 -0
  14. package/src/daemon/attach-client.mjs +361 -0
  15. package/src/daemon/daemonize.mjs +151 -0
  16. package/src/daemon/event-tap.mjs +197 -0
  17. package/src/daemon/input-lock.mjs +191 -0
  18. package/src/daemon/relay-client.mjs +258 -0
  19. package/src/daemon/session-core.mjs +179 -0
  20. package/src/daemon/session-list.mjs +26 -0
  21. package/src/daemon/session-publisher.mjs +78 -0
  22. package/src/daemon/socket-server.mjs +329 -0
  23. package/src/daemon/stop-daemon.mjs +18 -0
  24. package/src/permissions/checker.mjs +6 -6
  25. package/src/permissions/prompt.mjs +8 -7
  26. package/src/terminal/ansi.mjs +20 -3
  27. package/src/terminal/main.mjs +97 -3
  28. package/src/terminal/repl-render.mjs +21 -8
  29. package/src/terminal/repl.mjs +201 -2
  30. package/src/tools/analyze-code.mjs +39 -0
  31. package/src/tools/bash.mjs +1 -1
  32. package/src/tools/edit.mjs +18 -18
  33. package/src/tools/git-diff.mjs +34 -0
  34. package/src/tools/git-status.mjs +30 -0
  35. package/src/tools/glob.mjs +5 -2
  36. package/src/tools/grep.mjs +1 -1
  37. package/src/tools/meta-tools.mjs +85 -0
  38. package/src/tools/read-files.mjs +37 -0
  39. package/src/tools/read.mjs +20 -10
  40. package/src/tools/registry.mjs +20 -0
  41. package/src/tools/remember.mjs +147 -0
  42. package/src/tools/search-files.mjs +41 -0
  43. package/src/tools/write-project.mjs +62 -0
  44. package/src/tools/write.mjs +1 -1
  45. 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 '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);
@@ -727,9 +727,26 @@ export function table(headers, rows) {
727
727
  // ── Elapsed Timer ──
728
728
 
729
729
  export function formatElapsed(startMs) {
730
- const s = Math.floor((Date.now() - startMs) / 1000);
731
- if (s < 60) return `${s}s`;
732
- 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`;
733
750
  }
734
751
 
735
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,
@@ -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 suggestion = data?.suggestion || data?.recovery_strategy || data?.strategy || '';
818
- const message = reason
819
- ? `Stagnation${tool ? ` (${tool})` : ''}: ${reason}`
820
- : `Stagnation${tool ? ` (${tool})` : ''} detected`;
821
- const key = `${message}\n${suggestion}`;
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
- process.stderr.write(` ${c.yellow('!')} ${c.yellow(message)}\n`);
831
- if (suggestion) process.stderr.write(` ${c.dim(suggestion)}\n`);
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
  }
@@ -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
- const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
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
- for await (const event of client.execute(input, execContext, session.agentHistory)) {
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
+ };
@@ -20,7 +20,7 @@ const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB
20
20
  const TIMEOUT_TAIL_BYTES = 64 * 1024;
21
21
 
22
22
  export const BashTool = {
23
- name: 'Bash',
23
+ name: 'shell',
24
24
  description: 'Execute a bash command and return its output.',
25
25
  inputSchema: {
26
26
  type: 'object',
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Features:
5
5
  * - replace_all parameter for global replacement
6
- * - Verify old_string is unique (error if not)
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: 'Edit',
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
- old_string: { type: 'string', description: 'The text to replace' },
22
- new_string: { type: 'string', description: 'The replacement text' },
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', 'old_string', 'new_string'],
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.old_string && input.old_string !== '') errors.push('old_string required');
31
- if (input.old_string === input.new_string) errors.push('old_string must differ from new_string');
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.old_string)) {
55
- return 'Error: old_string not found in file. Make sure the string matches exactly, including whitespace and indentation.';
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.old_string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
- content = content.replace(new RegExp(escaped, 'g'), input.new_string);
60
+ const escaped = input.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
+ content = content.replace(new RegExp(escaped, 'g'), input.replace);
62
62
  } else {
63
- // Check uniqueness: old_string must appear exactly once
64
- const firstIdx = content.indexOf(input.old_string);
65
- const secondIdx = content.indexOf(input.old_string, firstIdx + 1);
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.old_string).length - 1;
68
- return `Error: old_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.`;
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.old_string, input.new_string);
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
+ };