@bahulam/code 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +39 -0
  3. package/package.json +8 -9
  4. package/pulse/lib/tool-categories.ts +13 -0
  5. package/src/commands/device.mjs +121 -0
  6. package/src/commands/pair.mjs +190 -0
  7. package/src/commands/remote.mjs +110 -0
  8. package/src/config/env.mjs +2 -2
  9. package/src/core/event-log.mjs +393 -0
  10. package/src/core/headless.mjs +198 -0
  11. package/src/core/loop.mjs +276 -0
  12. package/src/core/memory-disk.mjs +210 -0
  13. package/src/core/paths.mjs +36 -0
  14. package/src/core/stream-client.mjs +28 -9
  15. package/src/core/tool-executor.mjs +64 -16
  16. package/src/daemon/approval-store.mjs +253 -0
  17. package/src/daemon/attach-client.mjs +361 -0
  18. package/src/daemon/daemonize.mjs +151 -0
  19. package/src/daemon/event-tap.mjs +197 -0
  20. package/src/daemon/input-lock.mjs +191 -0
  21. package/src/daemon/relay-client.mjs +258 -0
  22. package/src/daemon/session-core.mjs +179 -0
  23. package/src/daemon/session-list.mjs +26 -0
  24. package/src/daemon/session-publisher.mjs +78 -0
  25. package/src/daemon/socket-server.mjs +329 -0
  26. package/src/daemon/stop-daemon.mjs +18 -0
  27. package/src/permissions/checker.mjs +6 -6
  28. package/src/permissions/prompt.mjs +8 -7
  29. package/src/skills/installer.mjs +8 -0
  30. package/src/terminal/ansi.mjs +85 -9
  31. package/src/terminal/main.mjs +97 -3
  32. package/src/terminal/repl.mjs +389 -6
  33. package/src/terminal/skills-picker.mjs +121 -0
  34. package/src/terminal/skills.mjs +3 -3
  35. package/src/tools/analyze-code.mjs +39 -0
  36. package/src/tools/bash.mjs +1 -1
  37. package/src/tools/edit.mjs +18 -18
  38. package/src/tools/git-diff.mjs +34 -0
  39. package/src/tools/git-status.mjs +30 -0
  40. package/src/tools/glob.mjs +5 -2
  41. package/src/tools/grep.mjs +1 -1
  42. package/src/tools/meta-tools.mjs +85 -0
  43. package/src/tools/read-files.mjs +37 -0
  44. package/src/tools/read.mjs +20 -10
  45. package/src/tools/registry.mjs +20 -0
  46. package/src/tools/remember.mjs +147 -0
  47. package/src/tools/search-files.mjs +41 -0
  48. package/src/tools/write-project.mjs +62 -0
  49. package/src/tools/write.mjs +1 -1
  50. package/src/ui/banner.mjs +1 -1
  51. package/src/ui/slash-commands.mjs +16 -0
  52. package/src/ui/sub-agent.mjs +8 -2
  53. package/src/ui/transcript-block.mjs +4 -1
@@ -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';
@@ -49,6 +60,9 @@ import { resolveBackendUrl } from '../core/backend-url.mjs';
49
60
  import { formatMessageWindow, lowWindowStatus, messagesRemaining } from '../core/rate-limit-display.mjs';
50
61
  import { formatAgentErrorGuidance } from '../core/error-guidance.mjs';
51
62
  import { BUILTIN_AGENTS, findBuiltinAgent, localAgentMatches, runAgent, runAgentDefinition } from './agents.mjs';
63
+ import { SkillInstaller } from '../skills/installer.mjs';
64
+ import { SkillsLoader } from '../skills/loader.mjs';
65
+ import { openSkillsPicker, formatSkillsList } from './skills-picker.mjs';
52
66
  import { createAgentFile, isVsCodeTerminal, listLocalAgents, openAgentFile, syncAgentsToBackend } from '../agents/scaffold.mjs';
53
67
  import { SessionManager } from '../core/session-manager.mjs';
54
68
  import { parseArgs } from '../config/cli-args.mjs';
@@ -939,6 +953,163 @@ async function handleAgentsCommand(rest = '', ctx) {
939
953
  printAgentsUsage();
940
954
  }
941
955
 
956
+ function printSkillsUsage() {
957
+ process.stderr.write(` ${c.dim('Usage:')}\n`);
958
+ process.stderr.write(` ${c.dim(' /skills open interactive picker')}\n`);
959
+ process.stderr.write(` ${c.dim(' /skills list [--project] list installed skills')}\n`);
960
+ process.stderr.write(` ${c.dim(' /skills install <url|path> [--project] [--force]')}\n`);
961
+ process.stderr.write(` ${c.dim(' /skills view <name> [resource]')}\n`);
962
+ process.stderr.write(` ${c.dim(' /skills remove <name> [--project]')}\n`);
963
+ process.stderr.write(` ${c.dim(' /skills update <name> [--project]')}\n`);
964
+ }
965
+
966
+ async function handleSkillsCommand(rest = '', ctx) {
967
+ const parts = String(rest || '').trim().split(/\s+/).filter(Boolean);
968
+ const hasFlag = (flag) => parts.includes(flag);
969
+ const positional = parts.filter(p => !p.startsWith('--'));
970
+ const action = (positional[0] || 'picker').toLowerCase();
971
+ const scope = hasFlag('--project') ? 'project' : 'global';
972
+ const cwd = safeCwd();
973
+ const installer = new SkillInstaller({ cwd });
974
+ const loader = new SkillsLoader().load(cwd);
975
+
976
+ if (action === 'picker' || action === '') {
977
+ const skills = loader.list({ scope: hasFlag('--all') ? '' : '' });
978
+ if (!skills.length) {
979
+ process.stderr.write(` ${c.dim('No skills installed yet.')}\n`);
980
+ process.stderr.write(` ${c.dim('Install one:')} ${c.brand('/skills install https://github.com/<user>/<repo>')}\n`);
981
+ return;
982
+ }
983
+ const rl = ctx?.rl || null;
984
+ const choice = await openSkillsPicker({ rl, skills });
985
+ if (!choice) {
986
+ process.stderr.write(formatSkillsList(skills));
987
+ return;
988
+ }
989
+ if (choice.action === 'view') {
990
+ try {
991
+ const view = loader.view(choice.name, null);
992
+ process.stderr.write(`\n ${c.bold(view.name)} ${c.dim(`· ${view.source || ''}`)}\n`);
993
+ if (view.description) process.stderr.write(` ${c.dim(view.description)}\n`);
994
+ process.stderr.write(` ${c.gray('─'.repeat(60))}\n`);
995
+ process.stderr.write(renderMarkdown(String(view.instructions || '')) + '\n');
996
+ if (view.resources?.length) {
997
+ process.stderr.write(` ${c.dim('Bundled resources:')}\n`);
998
+ for (const r of view.resources.slice(0, 20)) process.stderr.write(` ${c.dim('·')} ${r}\n`);
999
+ if (view.resources.length > 20) process.stderr.write(` ${c.dim(`(+${view.resources.length - 20} more)`)}\n`);
1000
+ process.stderr.write(` ${c.dim('View one:')} /skills view ${view.name} <path>\n`);
1001
+ }
1002
+ } catch (err) {
1003
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1004
+ }
1005
+ return;
1006
+ }
1007
+ if (choice.action === 'remove') {
1008
+ try {
1009
+ installer.remove(choice.name, { scope: 'global' });
1010
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Removed skill:')} ${choice.name}\n`);
1011
+ } catch (err) {
1012
+ // Try project scope if global failed.
1013
+ try {
1014
+ installer.remove(choice.name, { scope: 'project' });
1015
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Removed skill:')} ${choice.name} ${c.dim('(project)')}\n`);
1016
+ } catch (err2) {
1017
+ process.stderr.write(` ${c.red(err2.message || String(err2))}\n`);
1018
+ }
1019
+ }
1020
+ return;
1021
+ }
1022
+ return;
1023
+ }
1024
+
1025
+ if (action === 'list' || action === 'ls') {
1026
+ const filter = hasFlag('--all') ? '' : scope;
1027
+ const skills = loader.list({ scope: filter });
1028
+ process.stderr.write(formatSkillsList(skills));
1029
+ return;
1030
+ }
1031
+
1032
+ if (action === 'install') {
1033
+ const source = positional[1];
1034
+ if (!source) {
1035
+ process.stderr.write(` ${c.yellow('Usage:')} /skills install <git-url|path> [--project] [--force]\n`);
1036
+ return;
1037
+ }
1038
+ try {
1039
+ process.stderr.write(` ${c.dim('Installing')} ${source} ${c.dim(`→ ${scope}`)}...\n`);
1040
+ const result = installer.install(source, { scope, force: hasFlag('--force') });
1041
+ const unique = [...new Set(result.installed || [])];
1042
+ const dupes = (result.installed?.length || 0) - unique.length;
1043
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Installed:')} ${unique.join(', ') || '(none)'}\n`);
1044
+ if (dupes > 0) {
1045
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Source shipped ${dupes} duplicate SKILL.md file(s) with the same name — last one won.`)}\n`);
1046
+ }
1047
+ process.stderr.write(` ${c.dim('Lock file:')} ${result.lock_file}\n`);
1048
+ } catch (err) {
1049
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1050
+ }
1051
+ return;
1052
+ }
1053
+
1054
+ if (action === 'view') {
1055
+ const name = positional[1];
1056
+ if (!name) {
1057
+ process.stderr.write(` ${c.yellow('Usage:')} /skills view <name> [resource-path]\n`);
1058
+ return;
1059
+ }
1060
+ const resource = positional[2] || null;
1061
+ try {
1062
+ const view = loader.view(name, resource);
1063
+ process.stderr.write(`\n ${c.bold(view.name)}${resource ? c.dim(` · ${resource}`) : ''}\n`);
1064
+ if (!resource && view.description) process.stderr.write(` ${c.dim(view.description)}\n`);
1065
+ process.stderr.write(` ${c.gray('─'.repeat(60))}\n`);
1066
+ const body = resource ? view.content : view.instructions;
1067
+ process.stderr.write(renderMarkdown(String(body || '')) + '\n');
1068
+ if (!resource && view.resources?.length) {
1069
+ process.stderr.write(` ${c.dim('Bundled resources:')}\n`);
1070
+ for (const r of view.resources.slice(0, 20)) process.stderr.write(` ${c.dim('·')} ${r}\n`);
1071
+ if (view.resources.length > 20) process.stderr.write(` ${c.dim(`(+${view.resources.length - 20} more)`)}\n`);
1072
+ }
1073
+ } catch (err) {
1074
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1075
+ }
1076
+ return;
1077
+ }
1078
+
1079
+ if (action === 'remove' || action === 'rm' || action === 'uninstall') {
1080
+ const name = positional[1];
1081
+ if (!name) {
1082
+ process.stderr.write(` ${c.yellow('Usage:')} /skills remove <name> [--project]\n`);
1083
+ return;
1084
+ }
1085
+ try {
1086
+ installer.remove(name, { scope });
1087
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Removed:')} ${name} ${c.dim(`(${scope})`)}\n`);
1088
+ } catch (err) {
1089
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1090
+ }
1091
+ return;
1092
+ }
1093
+
1094
+ if (action === 'update' || action === 'upgrade') {
1095
+ const name = positional[1];
1096
+ if (!name) {
1097
+ process.stderr.write(` ${c.yellow('Usage:')} /skills update <name> [--project]\n`);
1098
+ return;
1099
+ }
1100
+ try {
1101
+ const result = installer.update(name, { scope });
1102
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Updated:')} ${name} ${c.dim(`(${scope})`)}\n`);
1103
+ if (result?.commit) process.stderr.write(` ${c.dim('Commit:')} ${result.commit.slice(0, 12)}\n`);
1104
+ } catch (err) {
1105
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1106
+ }
1107
+ return;
1108
+ }
1109
+
1110
+ printSkillsUsage();
1111
+ }
1112
+
942
1113
  function commandCompletions(line) {
943
1114
  if (line.startsWith('/help ')) {
944
1115
  const topic = line.slice('/help '.length).toLowerCase();
@@ -1241,6 +1412,16 @@ function foldedSubAgentName(data = {}) {
1241
1412
  return data?.sub_agent || data?.agent || data?.type || 'sub-agent';
1242
1413
  }
1243
1414
 
1415
+ function subAgentStartingLine(agentType = 'sub-agent') {
1416
+ const normalized = String(agentType || 'sub-agent').toLowerCase();
1417
+ if (normalized === 'plan') return '→ preparing plan handoff';
1418
+ if (normalized === 'explore') return '→ starting exploration';
1419
+ if (normalized === 'verify') return '→ preparing verification';
1420
+ if (normalized === 'debug') return '→ isolating failure context';
1421
+ if (normalized === 'refactor') return '→ preparing refactor pass';
1422
+ return `→ starting ${normalized}`;
1423
+ }
1424
+
1244
1425
  function ensureFoldedSubAgentTools(agentType) {
1245
1426
  const current = runtime.foldedSubAgentTools;
1246
1427
  if (current && current.agentType === agentType) return current;
@@ -1533,11 +1714,19 @@ function renderEvent(event) {
1533
1714
  const phaseTag = phase === 'mid_turn' ? 'mid-turn' : 'pre-turn';
1534
1715
  const collapsed = Number(data?.collapsed_messages || 0);
1535
1716
  const kept = Number(data?.kept_recent || 0);
1536
- const before = Number(data?.before_est_tokens || 0);
1537
- const beforeK = before ? ` · ~${Math.round(before / 1000)}k est tokens` : '';
1717
+ // PRD-213: backend now sends `before_tokens` (real when source is
1718
+ // `ground_truth`, chars/4 estimate when `estimator_fallback`) plus
1719
+ // the resolved threshold. Keep the legacy `before_est_tokens` fall-
1720
+ // back so an older backend that hasn't rolled out yet still renders.
1721
+ const before = Number(data?.before_tokens || data?.before_est_tokens || 0);
1722
+ const threshold = Number(data?.threshold || 160_000);
1723
+ const source = String(data?.source || 'estimator');
1724
+ const tokenLabel = source === 'ground_truth' ? 'real' : 'est';
1725
+ const beforeK = before ? ` · ${Math.round(before / 1000)}k/${Math.round(threshold / 1000)}k ${tokenLabel} tokens` : '';
1726
+ const sourceTag = source === 'ground_truth' ? '' : ` [${source}]`;
1538
1727
  const keptPart = kept ? ` · kept last ${kept}` : '';
1539
1728
  const preview = String(data?.summary_preview || '').trim();
1540
- process.stderr.write(` ${c.brand('✎')} ${c.dim(`context summarized (${phaseTag}) · ${collapsed} older message${collapsed === 1 ? '' : 's'} collapsed${keptPart}${beforeK}`)}\n`);
1729
+ process.stderr.write(` ${c.brand('✎')} ${c.dim(`context summarized (${phaseTag}) · ${collapsed} older message${collapsed === 1 ? '' : 's'} collapsed${keptPart}${beforeK}${sourceTag}`)}\n`);
1541
1730
  if (preview) {
1542
1731
  process.stderr.write(` ${c.dim('› ' + preview)}\n`);
1543
1732
  }
@@ -1780,6 +1969,7 @@ function renderEvent(event) {
1780
1969
  // Phase per sub-agent run: the status line counts elapsed time and
1781
1970
  // tool calls live ("plan agent · 4 calls · 32s") for the whole run.
1782
1971
  startSpinner(`${agentType} agent`, { phase: `sub:${agentType}:${Date.now()}` });
1972
+ pushSubAgentWindowLine(subAgentStartingLine(agentType));
1783
1973
  break;
1784
1974
  }
1785
1975
 
@@ -1829,7 +2019,12 @@ function renderEvent(event) {
1829
2019
  flushFoldedSubAgentTools();
1830
2020
  const agentType = data?.type || 'sub-agent';
1831
2021
  const usage = data?.usage || {};
1832
- 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;
1833
2028
  const costUsd = usage.cost_usd ?? usage.total_cost_usd ?? data?.cost_usd ?? null;
1834
2029
  if (typeof costUsd === 'number') session.savedUsd += costUsd;
1835
2030
  const summary = data?.result_summary
@@ -1869,6 +2064,150 @@ function renderEvent(event) {
1869
2064
  session.id = data.session_id;
1870
2065
  // Track in session manager so conversations save to the right file
1871
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
+ }
1872
2211
  }
1873
2212
  if (data?.model) session.model = data.model;
1874
2213
  if (data?.models?.coder) session.model = data.models.coder;
@@ -3240,9 +3579,14 @@ async function handleCommand(input, ctx) {
3240
3579
  }
3241
3580
 
3242
3581
  case '/agents':
3582
+ case '/subagents':
3243
3583
  await handleAgentsCommand(rest, ctx);
3244
3584
  return;
3245
3585
 
3586
+ case '/skills':
3587
+ await handleSkillsCommand(rest, ctx);
3588
+ return;
3589
+
3246
3590
  case '/explore':
3247
3591
  case '/review':
3248
3592
  case '/architect': {
@@ -3398,7 +3742,7 @@ export async function startTerminalRepl() {
3398
3742
  stopSpinner();
3399
3743
  flushContent();
3400
3744
  flushPendingHead();
3401
- flushCompactReadRun();
3745
+ flushExploreRun();
3402
3746
  runtime.foldedSubAgentTools = null;
3403
3747
  clearCards();
3404
3748
 
@@ -4932,8 +5276,47 @@ export async function startTerminalRepl() {
4932
5276
  }
4933
5277
  }
4934
5278
 
4935
- 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) {
4936
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 });
4937
5320
  if (event.type === 'plan_created' || event.type === 'goal_created') {
4938
5321
  persistProjectArtifacts(
4939
5322
  event.data,
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Interactive skills picker — reuses the raw-stdin overlay pattern from
3
+ * repl-ask-form.mjs. Arrow keys move, Enter views the selected SKILL.md,
4
+ * Esc/q closes. Emits nothing when the terminal is not a TTY (falls
5
+ * back to plain-text listing at the call site).
6
+ */
7
+
8
+ import { c, renderMarkdown, stripAnsi } from './ansi.mjs';
9
+ import { fitAnsiLine, writeOverlayFrame, eraseOverlayFrame } from './repl-format.mjs';
10
+
11
+ function truncate(str, max) {
12
+ const s = String(str || '');
13
+ if (s.length <= max) return s;
14
+ return s.slice(0, Math.max(1, max - 1)) + '…';
15
+ }
16
+
17
+ /**
18
+ * @param {object} opts
19
+ * @param {object|null} opts.rl readline instance to pause/resume
20
+ * @param {Array<{name,description,scope,source}>} opts.skills
21
+ * @returns {Promise<{action:'view',name:string}|{action:'remove',name:string}|null>}
22
+ */
23
+ export async function openSkillsPicker({ rl, skills }) {
24
+ if (!process.stdin.isTTY) return null;
25
+ if (!skills?.length) return null;
26
+ if (rl) rl.pause();
27
+
28
+ const rows = skills.map(s => ({
29
+ name: s.name,
30
+ description: s.description || '',
31
+ scope: s.scope,
32
+ source: s.source,
33
+ }));
34
+
35
+ return await new Promise((resolve) => {
36
+ const wasRaw = process.stdin.isRaw;
37
+ let cursor = 0;
38
+ let renderedLines = 0;
39
+
40
+ const render = () => {
41
+ const cols = Math.max(60, process.stderr.columns || 120);
42
+ const nameWidth = Math.min(24, Math.max(...rows.map(r => r.name.length)));
43
+ const scopeWidth = 8;
44
+ const lines = [];
45
+ lines.push(` ${c.bold('Installed skills')} ${c.dim(`· ${rows.length} bundle${rows.length === 1 ? '' : 's'}`)}`);
46
+ lines.push('');
47
+ rows.forEach((row, i) => {
48
+ const active = i === cursor;
49
+ const marker = active ? c.brand('▸') : ' ';
50
+ const name = row.name.padEnd(nameWidth).slice(0, nameWidth);
51
+ const scope = row.scope.padEnd(scopeWidth).slice(0, scopeWidth);
52
+ const descBudget = Math.max(20, cols - 6 - nameWidth - scopeWidth - 4);
53
+ const desc = truncate(row.description, descBudget);
54
+ const painted = active
55
+ ? `${c.brand(name)} ${c.dim(scope)} ${c.brand(desc)}`
56
+ : `${c.bold(name)} ${c.dim(scope)} ${desc}`;
57
+ lines.push(fitAnsiLine(` ${marker} ${painted}`, cols - 1));
58
+ });
59
+ lines.push('');
60
+ lines.push(fitAnsiLine(
61
+ ` ${c.dim('↑↓ move · Enter view SKILL.md · r remove · Esc close')}`,
62
+ cols - 1,
63
+ ));
64
+ writeOverlayFrame(renderedLines, lines);
65
+ renderedLines = lines.length;
66
+ };
67
+
68
+ const cleanup = (value) => {
69
+ process.stdin.removeListener('data', onData);
70
+ process.stdin.setRawMode(wasRaw || false);
71
+ eraseOverlayFrame(renderedLines);
72
+ if (rl) rl.resume();
73
+ resolve(value);
74
+ };
75
+
76
+ const onData = (data) => {
77
+ const key = data.toString('utf8');
78
+ if (key === '\x1b' || key === '\x03' || key === 'q') { cleanup(null); return; }
79
+ if (key === '\r' || key === '\n') {
80
+ cleanup({ action: 'view', name: rows[cursor].name });
81
+ return;
82
+ }
83
+ if (key === 'r' || key === 'R') {
84
+ cleanup({ action: 'remove', name: rows[cursor].name });
85
+ return;
86
+ }
87
+ if (key === '\x1b[A' || key === 'k') { cursor = Math.max(0, cursor - 1); render(); return; }
88
+ if (key === '\x1b[B' || key === 'j') { cursor = Math.min(rows.length - 1, cursor + 1); render(); return; }
89
+ if (key === '\x1b[H' || key === 'g') { cursor = 0; render(); return; }
90
+ if (key === '\x1b[F' || key === 'G') { cursor = rows.length - 1; render(); return; }
91
+ };
92
+
93
+ process.stdin.setRawMode(true);
94
+ process.stdin.resume();
95
+ process.stdin.on('data', onData);
96
+ render();
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Render a nicely-aligned static list (fallback when no TTY, or for `/skills list`).
102
+ * Returns the printable string; caller writes it to stderr.
103
+ */
104
+ export function formatSkillsList(skills) {
105
+ if (!skills?.length) return ` ${c.dim('No skills installed. Try /skills install <git-url>')}\n`;
106
+ const nameWidth = Math.min(28, Math.max(...skills.map(s => s.name.length)));
107
+ const scopeWidth = 8;
108
+ const cols = Math.max(60, process.stderr.columns || 120);
109
+ const descBudget = Math.max(20, cols - 6 - nameWidth - scopeWidth - 4);
110
+ const lines = [` ${c.bold('Installed skills')} ${c.dim(`· ${skills.length} bundle${skills.length === 1 ? '' : 's'}`)}\n`];
111
+ for (const s of skills) {
112
+ const name = s.name.padEnd(nameWidth).slice(0, nameWidth);
113
+ const scope = String(s.scope || '').padEnd(scopeWidth).slice(0, scopeWidth);
114
+ const desc = truncate(s.description || '', descBudget);
115
+ lines.push(` ${c.bold(name)} ${c.dim(scope)} ${desc}\n`);
116
+ }
117
+ lines.push(`\n ${c.dim('Open interactively:')} /skills\n`);
118
+ return lines.join('');
119
+ }
120
+
121
+ export { renderMarkdown, stripAnsi };
@@ -1,5 +1,6 @@
1
1
  import { SkillInstaller } from '../skills/installer.mjs';
2
2
  import { SkillsLoader } from '../skills/loader.mjs';
3
+ import { formatSkillsList } from './skills-picker.mjs';
3
4
 
4
5
  function has(args, flag) {
5
6
  return args.includes(flag);
@@ -22,9 +23,8 @@ export async function runSkillsCommand(args, { cwd = process.cwd() } = {}) {
22
23
 
23
24
  if (action === 'list') {
24
25
  const rows = loader.list({ scope: has(rest, '--all') ? '' : scope });
25
- if (has(rest, '--json')) print(rows);
26
- else if (!rows.length) print('No skills found.');
27
- else for (const row of rows) print(`${row.name}\t${row.scope}\t${row.source}\t${row.description}`);
26
+ if (has(rest, '--json')) { print(rows); return; }
27
+ process.stdout.write(formatSkillsList(rows));
28
28
  return;
29
29
  }
30
30
  if (action === 'view') {
@@ -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',