@bahulam/code 0.1.1

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 (278) hide show
  1. package/README.md +93 -0
  2. package/package.json +56 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +223 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +312 -0
  136. package/src/commands/agent.mjs +221 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +202 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/model-catalog.mjs +57 -0
  143. package/src/config/settings-loader.mjs +45 -0
  144. package/src/config/settings.mjs +132 -0
  145. package/src/context/ast-parser.mjs +298 -0
  146. package/src/context/bm25.mjs +85 -0
  147. package/src/context/prose-chunker.mjs +255 -0
  148. package/src/context/retriever.mjs +425 -0
  149. package/src/context/skeleton.mjs +134 -0
  150. package/src/context/symbol-indexer.mjs +375 -0
  151. package/src/core/agent-history.mjs +111 -0
  152. package/src/core/agent-loop.mjs +486 -0
  153. package/src/core/approval-log.mjs +145 -0
  154. package/src/core/approval.mjs +700 -0
  155. package/src/core/attachments.mjs +666 -0
  156. package/src/core/backend-url.mjs +68 -0
  157. package/src/core/bundled-runtime.mjs +418 -0
  158. package/src/core/cache-control.mjs +92 -0
  159. package/src/core/cache.mjs +105 -0
  160. package/src/core/callback-client.mjs +180 -0
  161. package/src/core/checkpoints.mjs +142 -0
  162. package/src/core/compact-history.mjs +127 -0
  163. package/src/core/context-envelope.mjs +54 -0
  164. package/src/core/context-manager.mjs +198 -0
  165. package/src/core/error-guidance.mjs +331 -0
  166. package/src/core/file-diff.mjs +217 -0
  167. package/src/core/headless.mjs +460 -0
  168. package/src/core/hooks-manager.mjs +87 -0
  169. package/src/core/jsonl-writer.mjs +449 -0
  170. package/src/core/local-agent.mjs +538 -0
  171. package/src/core/local-store.mjs +836 -0
  172. package/src/core/mode-selector.mjs +51 -0
  173. package/src/core/output-filter.mjs +177 -0
  174. package/src/core/paths.mjs +190 -0
  175. package/src/core/policy-resolver.mjs +156 -0
  176. package/src/core/pricing.mjs +336 -0
  177. package/src/core/project-artifacts.mjs +39 -0
  178. package/src/core/project-context-loader.mjs +139 -0
  179. package/src/core/providers.mjs +219 -0
  180. package/src/core/rate-limit-display.mjs +121 -0
  181. package/src/core/rate-limiter.mjs +119 -0
  182. package/src/core/resume-mode.mjs +192 -0
  183. package/src/core/risk-tier.mjs +388 -0
  184. package/src/core/safety.mjs +260 -0
  185. package/src/core/scheduler.mjs +173 -0
  186. package/src/core/session-manager.mjs +360 -0
  187. package/src/core/session.mjs +143 -0
  188. package/src/core/settings-sync.mjs +85 -0
  189. package/src/core/stagnation.mjs +57 -0
  190. package/src/core/stream-client.mjs +957 -0
  191. package/src/core/streaming.mjs +182 -0
  192. package/src/core/system-prompt.mjs +140 -0
  193. package/src/core/tasks.mjs +196 -0
  194. package/src/core/tool-executor.mjs +2231 -0
  195. package/src/core/trust.mjs +160 -0
  196. package/src/core/work-scope.mjs +248 -0
  197. package/src/hooks/engine.mjs +162 -0
  198. package/src/mcp/client.mjs +253 -0
  199. package/src/mcp/transport-shttp.mjs +130 -0
  200. package/src/mcp/transport-sse.mjs +131 -0
  201. package/src/mcp/transport-ws.mjs +134 -0
  202. package/src/onboarding/preflight.mjs +374 -0
  203. package/src/permissions/checker.mjs +57 -0
  204. package/src/permissions/command-classifier.mjs +700 -0
  205. package/src/permissions/injection-check.mjs +60 -0
  206. package/src/permissions/path-check.mjs +102 -0
  207. package/src/permissions/prompt.mjs +73 -0
  208. package/src/permissions/sandbox.mjs +112 -0
  209. package/src/plugins/loader.mjs +138 -0
  210. package/src/skills/installer.mjs +188 -0
  211. package/src/skills/loader.mjs +252 -0
  212. package/src/skills/runner.mjs +55 -0
  213. package/src/state/orbit.mjs +263 -0
  214. package/src/state/verbosity.mjs +99 -0
  215. package/src/telemetry/index.mjs +122 -0
  216. package/src/terminal/agents.mjs +353 -0
  217. package/src/terminal/analytics.mjs +292 -0
  218. package/src/terminal/ansi.mjs +695 -0
  219. package/src/terminal/init.mjs +145 -0
  220. package/src/terminal/main.mjs +310 -0
  221. package/src/terminal/repl-ask-form.mjs +120 -0
  222. package/src/terminal/repl-explore.mjs +44 -0
  223. package/src/terminal/repl-format.mjs +317 -0
  224. package/src/terminal/repl-model-form.mjs +132 -0
  225. package/src/terminal/repl-render.mjs +833 -0
  226. package/src/terminal/repl-resume.mjs +640 -0
  227. package/src/terminal/repl-state.mjs +120 -0
  228. package/src/terminal/repl-utils.mjs +34 -0
  229. package/src/terminal/repl.mjs +5032 -0
  230. package/src/terminal/skills.mjs +54 -0
  231. package/src/terminal/tool-display.mjs +392 -0
  232. package/src/tools/agent.mjs +137 -0
  233. package/src/tools/ask-user.mjs +61 -0
  234. package/src/tools/bash.mjs +231 -0
  235. package/src/tools/cron-create.mjs +120 -0
  236. package/src/tools/cron-delete.mjs +49 -0
  237. package/src/tools/cron-list.mjs +37 -0
  238. package/src/tools/edit.mjs +82 -0
  239. package/src/tools/enter-worktree.mjs +69 -0
  240. package/src/tools/exit-worktree.mjs +57 -0
  241. package/src/tools/glob.mjs +117 -0
  242. package/src/tools/grep.mjs +129 -0
  243. package/src/tools/lint.mjs +71 -0
  244. package/src/tools/ls.mjs +58 -0
  245. package/src/tools/lsp.mjs +115 -0
  246. package/src/tools/multi-edit.mjs +94 -0
  247. package/src/tools/notebook-edit.mjs +96 -0
  248. package/src/tools/project-overview.mjs +703 -0
  249. package/src/tools/read-mcp-resource.mjs +57 -0
  250. package/src/tools/read.mjs +138 -0
  251. package/src/tools/registry.mjs +116 -0
  252. package/src/tools/remote-trigger.mjs +84 -0
  253. package/src/tools/send-message.mjs +64 -0
  254. package/src/tools/skill.mjs +52 -0
  255. package/src/tools/test-runner.mjs +49 -0
  256. package/src/tools/todo-write.mjs +68 -0
  257. package/src/tools/tool-search.mjs +77 -0
  258. package/src/tools/web-fetch.mjs +65 -0
  259. package/src/tools/web-search.mjs +89 -0
  260. package/src/tools/write.mjs +55 -0
  261. package/src/ui/approval.mjs +510 -0
  262. package/src/ui/banner.mjs +232 -0
  263. package/src/ui/commands.mjs +537 -0
  264. package/src/ui/formatter.mjs +409 -0
  265. package/src/ui/icons.mjs +170 -0
  266. package/src/ui/input-dock.mjs +772 -0
  267. package/src/ui/markdown.mjs +278 -0
  268. package/src/ui/mission-report.mjs +296 -0
  269. package/src/ui/palette.mjs +189 -0
  270. package/src/ui/render-queue.mjs +500 -0
  271. package/src/ui/slash-commands.mjs +257 -0
  272. package/src/ui/spinner.mjs +116 -0
  273. package/src/ui/sub-agent.mjs +167 -0
  274. package/src/ui/term.mjs +174 -0
  275. package/src/ui/text-layout.mjs +127 -0
  276. package/src/ui/tool-card.mjs +740 -0
  277. package/src/ui/tool-details.mjs +504 -0
  278. package/src/ui/transcript-block.mjs +20 -0
@@ -0,0 +1,2231 @@
1
+ /**
2
+ * Tool Executor Bridge — maps Tarang backend tool names to OCC tool calls.
3
+ *
4
+ * The Tarang backend sends tool_request events with its own tool names and arg shapes.
5
+ * This bridge translates those into OCC tool calls and wraps the results.
6
+ *
7
+ * Safety guardrails integrated — prevents destructive operations on source code.
8
+ * Tools are mapped across file, search, shell, validation, and Git operations.
9
+ */
10
+
11
+ import { createToolRegistry } from '../tools/registry.mjs';
12
+ import { detectCommandType, filterOutput } from './output-filter.mjs';
13
+ import { isSensitiveConfigPath, validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
14
+ import { classifyCommand, isExitCodeError } from '../permissions/command-classifier.mjs';
15
+ import { analyzeCode } from '../context/ast-parser.mjs';
16
+ import { ProjectRegistry } from '../tools/project-overview.mjs';
17
+ import { SkillInstaller } from '../skills/installer.mjs';
18
+ import { SkillsLoader } from '../skills/loader.mjs';
19
+ import { createAgentFile, listLocalAgents, syncAgentsToBackend } from '../agents/scaffold.mjs';
20
+ import { createWorkflowFile, listLocalWorkflows, WORKFLOW_SYNC_ENDPOINT, slugifyWorkflowName } from '../agents/workflow_scaffold.mjs';
21
+ import { TarangAuth } from '../auth/tarang-auth.mjs';
22
+ import { streamResponse } from './streaming.mjs';
23
+ import { sendApprovalDecision, sendCallback } from './callback-client.mjs';
24
+ import { HookRunner } from '../config/hook-runner.mjs';
25
+ import { buildFileDiff } from './file-diff.mjs';
26
+ import { buildWorkScope } from './work-scope.mjs';
27
+ import * as fs from 'node:fs';
28
+ import * as os from 'node:os';
29
+ import * as path from 'node:path';
30
+ import { execSync } from 'node:child_process';
31
+
32
+ /**
33
+ * Create a tool executor that bridges Tarang tool names to OCC tools.
34
+ * @param {Object} [options]
35
+ * @param {ProjectRegistry} [options.projectRegistry] - session-owned project registry
36
+ * @returns {{ execute(name, args): Promise<Object>, listTools(): string[] }}
37
+ */
38
+ export function createToolExecutor({
39
+ projectRegistry = new ProjectRegistry(),
40
+ skillsLoader = new SkillsLoader().load(process.cwd()),
41
+ skillInstaller = null,
42
+ checkpoints = null,
43
+ hookRunner = null,
44
+ interactionHandler = null,
45
+ } = {}) {
46
+ const occRegistry = createToolRegistry();
47
+ const skillTool = occRegistry.get('Skill');
48
+ if (skillTool) skillTool._skillsLoader = skillsLoader;
49
+ const installer = skillInstaller || new SkillInstaller({
50
+ cwd: process.cwd(),
51
+ homeDir: skillsLoader.homeDir || os.homedir(),
52
+ });
53
+
54
+ // ── Auto-register the current working directory as a project ──
55
+ // Without this, shell / list_files / read_attachment fail with
56
+ // "No projects registered. Call get_project_overview first." on
57
+ // any fresh folder — including a legitimate user CWD they just
58
+ // cd'd into to start work. The model then has to spend a turn
59
+ // registering before it can do anything, which is a poor first-run
60
+ // UX. Fire-and-forget: if registration fails (permissions, weird
61
+ // FS), the model can still call get_project_overview explicitly.
62
+ // bypassProjectMarkers=true because we don't require a .git etc.
63
+ // for the current directory to be usable — the user chose to be here.
64
+ // Opt out via BAHULAM_SKIP_AUTO_REGISTER=true for tests or headless
65
+ // scripts that want a truly empty registry.
66
+ if (process.env.BAHULAM_SKIP_AUTO_REGISTER !== 'true') {
67
+ projectRegistry.register(process.cwd(), { bypassProjectMarkers: true })
68
+ .catch(() => { /* silent — model can register explicitly */ });
69
+ }
70
+ let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
71
+ let _readOnlyCacheGeneration = 0;
72
+ const readOnlyResultCache = new Map();
73
+
74
+ function resolvePath(p, args = {}, options = {}) {
75
+ return projectRegistry.resolvePath(p, args.project_id, options);
76
+ }
77
+
78
+ function projectRootFor(filePath) {
79
+ const project = projectRegistry.projectForPath(filePath);
80
+ if (!project) throw new Error(`No registered project contains path: ${filePath}`);
81
+ return project.resource.root;
82
+ }
83
+
84
+ async function commandCwd(args = {}) {
85
+ return await resolvePath(args.cwd || null, args);
86
+ }
87
+
88
+ function shellTargetPath(cwd, target) {
89
+ const value = String(target || '');
90
+ if (value === '~') return os.homedir();
91
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
92
+ return path.resolve(cwd, value);
93
+ }
94
+
95
+ function blockedShellOutput(reason) {
96
+ const text = String(reason || 'Blocked by shell safety policy').trim();
97
+ const hint = /command substitution|backticks|\$\(\)/i.test(text)
98
+ ? 'Retry with separate simple shell commands instead of backticks or $().'
99
+ : 'Work only inside a registered project root.';
100
+ return `BLOCKED: ${text}. ${hint}`;
101
+ }
102
+
103
+ function longRunningObservationTimeoutMs() {
104
+ const configured = Number(process.env.KEPLER_LONG_RUNNING_TIMEOUT_MS);
105
+ return Number.isFinite(configured) && configured > 0 ? configured : 15_000;
106
+ }
107
+
108
+ function isLikelyLongRunningCommand(command) {
109
+ const cmd = String(command || '').trim();
110
+ if (!cmd) return false;
111
+ if (/^(?:timeout|gtimeout)\s+\S+\s+/i.test(cmd)) return false;
112
+ if (/(?:^|[;&|]\s*)(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b/i.test(cmd)) return true;
113
+ if (/(?:^|[;&|]\s*)(?:vite|next|nuxt|astro|webpack-dev-server)\b/i.test(cmd)) return true;
114
+ if (/\b(?:uvicorn|gunicorn|flask\s+run|rails\s+server|bin\/rails\s+server|django-admin\s+runserver|manage\.py\s+runserver)\b/i.test(cmd)) return true;
115
+ if (/\b(?:python|python3)\s+-m\s+http\.server\b/i.test(cmd)) return true;
116
+ if (/\bnode\b[\s\S]*(?:setInterval|\.listen\s*\(|createServer\s*\()/i.test(cmd)) return true;
117
+ if (/\b(?:docker\s+compose|docker-compose)\s+up\b(?![\s\S]*\s-d\b)/i.test(cmd)) return true;
118
+ if (/\btail\s+-f\b/i.test(cmd)) return true;
119
+ if (/\b(?:--watch|watch)\b/i.test(cmd)) return true;
120
+ return false;
121
+ }
122
+
123
+ function limitTail(text, maxChars = 8000) {
124
+ const value = String(text || '');
125
+ if (value.length <= maxChars) return value;
126
+ return `... (tail truncated)\n${value.slice(value.length - maxChars)}`;
127
+ }
128
+
129
+ function isAbortError(err) {
130
+ return err?.name === 'AbortError' || err?.code === 'ABORT_ERR';
131
+ }
132
+
133
+ function throwIfAborted(signal) {
134
+ if (!signal?.aborted) return;
135
+ const err = new Error('Cancelled by user');
136
+ err.name = 'AbortError';
137
+ throw err;
138
+ }
139
+
140
+ function cancelledToolResult(name) {
141
+ return {
142
+ success: false,
143
+ output: 'Cancelled by user',
144
+ _tool: name,
145
+ _cancelled: true,
146
+ };
147
+ }
148
+
149
+ function skillScope(args = {}) {
150
+ const scope = String(args.scope || '').trim();
151
+ if (scope !== 'project' && scope !== 'global') {
152
+ throw new Error('scope must be "project" or "global"');
153
+ }
154
+ return scope;
155
+ }
156
+
157
+ function reloadSkillCatalog() {
158
+ skillsLoader.load(installer.cwd || process.cwd());
159
+ return skillsLoader.list();
160
+ }
161
+
162
+ function normalizeAgentTools(tools) {
163
+ if (Array.isArray(tools) && tools.length > 0) {
164
+ return tools.map(tool => String(tool).trim()).filter(Boolean);
165
+ }
166
+ if (typeof tools === 'string' && tools.trim()) {
167
+ return tools.split(',').map(tool => tool.trim()).filter(Boolean);
168
+ }
169
+ return ['read_file', 'search_code', 'list_files'];
170
+ }
171
+
172
+ function agentMatches(agent, query) {
173
+ const needle = String(query || '').trim().toLowerCase();
174
+ if (!needle) return true;
175
+ return [
176
+ agent.slug,
177
+ agent.name,
178
+ agent.description,
179
+ agent.role,
180
+ agent.model,
181
+ ...(Array.isArray(agent.tools) ? agent.tools : []),
182
+ ...(Array.isArray(agent.capabilities) ? agent.capabilities : []),
183
+ ...(Array.isArray(agent.domains) ? agent.domains : []),
184
+ ].some(value => String(value || '').toLowerCase().includes(needle));
185
+ }
186
+
187
+ function compactAgentMetadata(agent) {
188
+ return {
189
+ slug: agent.slug,
190
+ name: agent.name,
191
+ description: agent.description || '',
192
+ role: agent.role || 'specialist',
193
+ model: agent.model || null,
194
+ models: agent.models && Object.keys(agent.models).length ? agent.models : undefined,
195
+ tools: Array.isArray(agent.tools) ? agent.tools : [],
196
+ capabilities: Array.isArray(agent.capabilities) ? agent.capabilities : [],
197
+ domains: Array.isArray(agent.domains) ? agent.domains : [],
198
+ source_scope: agent.source_scope || 'unknown',
199
+ source: agent.source || '',
200
+ content_hash: agent.content_hash || '',
201
+ };
202
+ }
203
+
204
+ function filterLocalAgents(args = {}) {
205
+ const scope = String(args.scope || '').trim();
206
+ if (scope && scope !== 'project' && scope !== 'global') {
207
+ throw new Error('scope must be "project" or "global"');
208
+ }
209
+ return listLocalAgents(process.cwd())
210
+ .filter(agent => !scope || agent.source_scope === scope)
211
+ .filter(agent => agentMatches(agent, args.query || args.name || ''));
212
+ }
213
+
214
+ function selectAgentsForSync(args = {}) {
215
+ const target = String(args.name || args.slug || '').trim();
216
+ const agents = listLocalAgents(process.cwd());
217
+ if (!target) return agents;
218
+ return agents.filter(agent => (
219
+ agent.slug === target ||
220
+ String(agent.name || '').toLowerCase() === target.toLowerCase()
221
+ ));
222
+ }
223
+
224
+ function compactWorkflowMetadata(workflow) {
225
+ return {
226
+ slug: workflow.slug,
227
+ name: workflow.name,
228
+ description: workflow.description || '',
229
+ pattern: workflow.pattern || workflow.orchestration_pattern || 'sequential',
230
+ agent_count: workflow.agent_count || (workflow.graph?.nodes || []).filter(node => node.type === 'agent').length,
231
+ edge_count: workflow.edge_count || (workflow.graph?.edges || []).length,
232
+ source: workflow.filePath || workflow.source || '',
233
+ source_scope: workflow.source_scope || 'project',
234
+ };
235
+ }
236
+
237
+ function filterLocalWorkflows(args = {}) {
238
+ const query = String(args.query || args.name || args.slug || '').trim().toLowerCase();
239
+ return listLocalWorkflows(process.cwd())
240
+ .filter(workflow => {
241
+ if (!query) return true;
242
+ return [
243
+ workflow.slug,
244
+ workflow.name,
245
+ workflow.description,
246
+ workflow.pattern,
247
+ ...(Array.isArray(workflow.agents) ? workflow.agents.map(a => a.slug || a.label || a.name) : []),
248
+ ].some(value => String(value || '').toLowerCase().includes(query));
249
+ });
250
+ }
251
+
252
+ function selectWorkflowsForSync(args = {}) {
253
+ const target = String(args.name || args.slug || '').trim();
254
+ const workflows = listLocalWorkflows(process.cwd());
255
+ if (!target) return workflows;
256
+ return workflows.filter(workflow => (
257
+ workflow.slug === target ||
258
+ String(workflow.name || '').toLowerCase() === target.toLowerCase()
259
+ ));
260
+ }
261
+
262
+ function workflowTargetMatches(workflow, target) {
263
+ const needle = String(target || '').trim().toLowerCase();
264
+ if (!needle) return false;
265
+ return [
266
+ workflow.id,
267
+ workflow.slug,
268
+ workflow.name,
269
+ ].some(value => String(value || '').toLowerCase() === needle);
270
+ }
271
+
272
+ async function resolveWorkflowId(creds, target) {
273
+ const trimmed = String(target || '').trim();
274
+ if (!trimmed) return null;
275
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed)) {
276
+ return trimmed;
277
+ }
278
+ if (!creds.backendUrl || !creds.token) return null;
279
+ const resp = await fetch(`${creds.backendUrl}${WORKFLOW_SYNC_ENDPOINT}`, {
280
+ headers: {
281
+ Authorization: `Bearer ${creds.token}`,
282
+ Accept: 'application/json',
283
+ },
284
+ });
285
+ if (!resp.ok) return null;
286
+ const payload = await resp.json().catch(() => ({}));
287
+ const workflows = Array.isArray(payload.workflows) ? payload.workflows : [];
288
+ const match = workflows.find(workflow => workflowTargetMatches(workflow, trimmed));
289
+ return match?.id || null;
290
+ }
291
+
292
+ function formatObservationTimeoutOutput(rawOutput, timeoutMs) {
293
+ const tail = String(rawOutput || '')
294
+ .replace(/^Error:\s*Command timed out after \d+ms\s*/i, '')
295
+ .trim();
296
+ const body = tail || '(no output captured before timeout)';
297
+ return limitTail(
298
+ `Observation timeout after ${timeoutMs}ms for a likely long-running command. ` +
299
+ `The process was stopped after collecting the output tail.\n${body}`
300
+ );
301
+ }
302
+
303
+ function updateProjectIndex(filePath) {
304
+ try {
305
+ projectRegistry.projectForPath(filePath)?.retriever.updateFile(filePath);
306
+ } catch { /* best effort */ }
307
+ _readOnlyCacheGeneration++;
308
+ }
309
+
310
+ function readTextIfExists(filePath) {
311
+ try {
312
+ if (!fs.existsSync(filePath)) return '';
313
+ return fs.readFileSync(filePath, 'utf-8');
314
+ } catch {
315
+ return '';
316
+ }
317
+ }
318
+
319
+ function stable(value) {
320
+ if (Array.isArray(value)) return value.map(stable);
321
+ if (value && typeof value === 'object') {
322
+ return Object.fromEntries(
323
+ Object.entries(value)
324
+ .filter(([, v]) => v !== undefined)
325
+ .sort(([a], [b]) => a.localeCompare(b))
326
+ .map(([k, v]) => [k, stable(v)]),
327
+ );
328
+ }
329
+ return value;
330
+ }
331
+
332
+ function clonePlain(value) {
333
+ return JSON.parse(JSON.stringify(value));
334
+ }
335
+
336
+ function fileFingerprint(filePath) {
337
+ try {
338
+ const stat = fs.statSync(filePath);
339
+ return {
340
+ filePath,
341
+ size: stat.size,
342
+ mtimeMs: Math.round(stat.mtimeMs),
343
+ };
344
+ } catch {
345
+ return { filePath, missing: true };
346
+ }
347
+ }
348
+
349
+ function cacheKey(kind, args, fingerprint) {
350
+ return JSON.stringify(stable({
351
+ kind,
352
+ args,
353
+ fingerprint,
354
+ generation: _readOnlyCacheGeneration,
355
+ }));
356
+ }
357
+
358
+ function compactCachedResult(kind, cached) {
359
+ const result = clonePlain(cached.result);
360
+ const output = String(result.output || result.content || result.message || '').trim();
361
+ const excerpt = output.length > 1200 ? `${output.slice(0, 1200)}\n[... cached output truncated ...]` : output;
362
+ result.output = `[Bahulam Code reused prior ${kind} result; source unchanged.]${excerpt ? `\n\n${excerpt}` : ''}`;
363
+ if (typeof result.content === 'string') {
364
+ result.content = result.content.length > 1200
365
+ ? `${result.content.slice(0, 1200)}\n[... cached content truncated ...]`
366
+ : result.content;
367
+ }
368
+ result._cache_reused = true;
369
+ result._cache_source_call = cached.callId;
370
+ return result;
371
+ }
372
+
373
+ async function withReadOnlyCache(kind, args, fingerprint, compute) {
374
+ const key = cacheKey(kind, args, fingerprint);
375
+ const cached = readOnlyResultCache.get(key);
376
+ if (cached) return compactCachedResult(kind, cached);
377
+ const result = await compute();
378
+ if (result?.success !== false) {
379
+ readOnlyResultCache.set(key, {
380
+ result: clonePlain(result),
381
+ callId: `${kind}-${readOnlyResultCache.size + 1}`,
382
+ });
383
+ }
384
+ return result;
385
+ }
386
+
387
+ function buildResultFileDiff(filePath, before, after) {
388
+ const diff = buildFileDiff({
389
+ filePath,
390
+ before,
391
+ after,
392
+ cwd: projectRootFor(filePath),
393
+ });
394
+ if (!isSensitiveConfigPath(filePath)) return diff;
395
+ return {
396
+ ...diff,
397
+ hunks: [],
398
+ unified: '',
399
+ redacted: true,
400
+ sensitive: true,
401
+ redaction_reason: 'Sensitive config diff redacted',
402
+ };
403
+ }
404
+
405
+ function attachFileDiff(result, filePath, before, after) {
406
+ try {
407
+ const diff = buildResultFileDiff(filePath, before, after);
408
+ result.file_diff = diff;
409
+ result.diff = diff.unified;
410
+ result.lines_added = diff.lines_added;
411
+ result.lines_removed = diff.lines_removed;
412
+ if (diff.redacted) {
413
+ result.output = `File updated: ${diff.relative_path || filePath}\nDiff redacted for sensitive config file.`;
414
+ result.redacted = true;
415
+ }
416
+ } catch { /* best effort */ }
417
+ return result;
418
+ }
419
+
420
+ /**
421
+ * Detect if an OCC tool result string indicates an error.
422
+ */
423
+ function isError(result) {
424
+ if (typeof result !== 'string') return false;
425
+ return result.startsWith('Error:') || result.startsWith('Error -') ||
426
+ result.includes('Exit code:') && !result.includes('Exit code: 0');
427
+ }
428
+
429
+ /**
430
+ * Wrap an OCC string result into Tarang's { success, output } format.
431
+ */
432
+ function wrapResult(result, toolName) {
433
+ if (typeof result === 'object' && result !== null && 'success' in result) {
434
+ result._tool = toolName;
435
+ return result;
436
+ }
437
+ const output = typeof result === 'string' ? result : JSON.stringify(result);
438
+ return {
439
+ success: !isError(output),
440
+ output,
441
+ _tool: toolName,
442
+ };
443
+ }
444
+
445
+ // ── Auto-lint after file writes ────────────────────────────
446
+
447
+ const LINT_COMMANDS = {
448
+ '.py': (file) => `python3 -m py_compile "${file}" 2>&1`,
449
+ '.js': (file) => `npx eslint --no-eslintrc --rule '{}' "${file}" 2>&1 || true`,
450
+ '.ts': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
451
+ '.tsx': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
452
+ '.go': (file) => `go vet "${file}" 2>&1`,
453
+ '.rs': (file) => `rustfmt --check "${file}" 2>&1`,
454
+ };
455
+
456
+ // tsc --pretty and eslint emit ANSI codes (including background-red
457
+ // highlights) which bleed when our renderer slices the first 80 chars.
458
+ // Strip color codes so the stored lint string is always plain text.
459
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
460
+ function stripAnsi(s) { return String(s || '').replace(ANSI_RE, ''); }
461
+
462
+ function autoLint(filePath) {
463
+ const ext = path.extname(filePath);
464
+ const cmdFn = LINT_COMMANDS[ext];
465
+ if (!cmdFn) return null;
466
+
467
+ try {
468
+ const output = execSync(cmdFn(filePath), {
469
+ encoding: 'utf-8',
470
+ timeout: 15_000,
471
+ cwd: process.cwd(),
472
+ stdio: ['pipe', 'pipe', 'pipe'],
473
+ env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb' },
474
+ });
475
+ const trimmed = stripAnsi(output).trim();
476
+ if (!trimmed) return null;
477
+ return trimmed;
478
+ } catch (err) {
479
+ // Non-zero exit means lint errors found
480
+ const output = stripAnsi(err.stderr || err.stdout || '').trim();
481
+ if (!output) return null;
482
+ return output;
483
+ }
484
+ }
485
+
486
+ // ── Post-edit verification hint ──────────────────────────────
487
+ // Appended to edit_file/write_file results so the model knows
488
+ // exactly how to verify. Uses detected project commands.
489
+
490
+ function verificationHint(filePath) {
491
+ const project = projectRegistry.projectForPath(filePath);
492
+ const commands = project?.resource?.commands || {};
493
+ const parts = [];
494
+ if (commands.test) {
495
+ parts.push(`Run tests: ${commands.test}`);
496
+ }
497
+ if (parts.length === 0) {
498
+ const ext = path.extname(filePath);
499
+ if (ext === '.py') parts.push('Run tests: python -m pytest');
500
+ else if (['.js', '.ts', '.tsx', '.mjs'].includes(ext)) parts.push('Run tests: npm test');
501
+ }
502
+ return parts.length ? `\n--- Verify ---\n${parts.join('\n')}` : '';
503
+ }
504
+
505
+ // ── Solution nudge after exploration ───────────────────────
506
+ // After the agent has read enough code, nudge it to formulate
507
+ // a solution based on the goal — not to blindly edit, but to
508
+ // synthesize what it learned into a fix approach.
509
+ let _codeReadsCount = 0;
510
+ let _hasEdited = false;
511
+
512
+ function solutionNudge(filePath) {
513
+ const ext = path.extname(filePath).toLowerCase();
514
+ const isCode = ['.py', '.js', '.ts', '.tsx', '.mjs', '.go', '.rs', '.java', '.rb'].includes(ext);
515
+ if (!isCode || _hasEdited) return '';
516
+
517
+ _codeReadsCount++;
518
+ if (_codeReadsCount < 4) return '';
519
+
520
+ // Only nudge once at threshold, not every read after
521
+ if (_codeReadsCount === 4) {
522
+ return '\n\n--- You have explored enough code to formulate a solution. ' +
523
+ 'Based on what you have read, determine the fix and apply it. ' +
524
+ 'If the approach is unclear, call plan() with your findings. ---';
525
+ }
526
+ return '';
527
+ }
528
+
529
+ function buildDirectoryTree(rootPath, { maxDepth = 2, maxEntries = 200 } = {}) {
530
+ const ignored = new Set(['.git', 'node_modules', '.next', '.turbo', 'dist', 'build', 'coverage']);
531
+ const rootName = path.basename(rootPath) || rootPath;
532
+ const lines = [`${rootName}/`];
533
+ const files = [];
534
+ const directories = [rootPath];
535
+ let entriesSeen = 0;
536
+ let truncated = false;
537
+
538
+ function walk(dir, depth, prefix) {
539
+ if (depth >= maxDepth || truncated) return;
540
+ let entries;
541
+ try {
542
+ entries = fs.readdirSync(dir, { withFileTypes: true })
543
+ .filter(entry => !ignored.has(entry.name))
544
+ .sort((a, b) => {
545
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
546
+ return a.name.localeCompare(b.name);
547
+ });
548
+ } catch (err) {
549
+ lines.push(`${prefix}[error: ${err.message}]`);
550
+ return;
551
+ }
552
+
553
+ for (let i = 0; i < entries.length; i++) {
554
+ if (entriesSeen >= maxEntries) {
555
+ truncated = true;
556
+ lines.push(`${prefix}... [truncated after ${maxEntries} entries]`);
557
+ return;
558
+ }
559
+ const entry = entries[i];
560
+ const fullPath = path.join(dir, entry.name);
561
+ const isLast = i === entries.length - 1;
562
+ const connector = isLast ? '`-- ' : '|-- ';
563
+ entriesSeen++;
564
+
565
+ if (entry.isDirectory()) {
566
+ directories.push(fullPath);
567
+ lines.push(`${prefix}${connector}${entry.name}/`);
568
+ walk(fullPath, depth + 1, `${prefix}${isLast ? ' ' : '| '}`);
569
+ } else {
570
+ files.push(fullPath);
571
+ lines.push(`${prefix}${connector}${entry.name}`);
572
+ }
573
+ }
574
+ }
575
+
576
+ walk(rootPath, 0, '');
577
+ return { output: lines.join('\n'), files, directories, truncated };
578
+ }
579
+
580
+ async function executeToolWithHooks(name, args, options = {}) {
581
+ const handler = toolMap[name];
582
+ if (!handler) {
583
+ return { success: false, output: `Unknown tool: ${name}`, _tool: name };
584
+ }
585
+ const hooks = hookRunner || new HookRunner({ cwd: process.cwd() });
586
+ try {
587
+ throwIfAborted(options.signal);
588
+ const pre = await hooks.run('PreToolUse', { toolName: name, input: args || {} });
589
+ throwIfAborted(options.signal);
590
+ if (pre.blocked) {
591
+ return { success: false, output: `BLOCKED by hook: ${pre.message}`, _tool: name, _blocked: true };
592
+ }
593
+ let result = await handler(args || {}, options);
594
+ if (result?._cancelled) return result;
595
+ throwIfAborted(options.signal);
596
+ const post = await hooks.run('PostToolUse', { toolName: name, input: args || {}, result });
597
+ throwIfAborted(options.signal);
598
+ for (const item of post.results || []) {
599
+ if (item.parsed?.modifiedResult !== undefined) result = item.parsed.modifiedResult;
600
+ if (item.parsed?.feedback && result && typeof result === 'object') {
601
+ result.output = `${result.output || ''}\n\n--- Hook Feedback ---\n${item.parsed.feedback}`.trim();
602
+ }
603
+ }
604
+ return result;
605
+ } catch (err) {
606
+ if (isAbortError(err) || options.signal?.aborted) {
607
+ return cancelledToolResult(name);
608
+ }
609
+ return { success: false, output: `Tool error (${name}): ${err.message}`, _tool: name };
610
+ }
611
+ }
612
+
613
+ // ── Tool mapping table ──────────────────────────────────────
614
+
615
+ const toolMap = {
616
+ // 0. ask_user → interactive direction question (client-executed).
617
+ // The UI form is injected by the REPL via `interactionHandler`;
618
+ // headless/piped sessions have none and get the best-judgment
619
+ // fallback so the agent is never blocked on a missing human.
620
+ ask_user: async (args, options = {}) => {
621
+ throwIfAborted(options.signal);
622
+ const question = String(args?.question || '').trim();
623
+ const choices = Array.isArray(args?.options)
624
+ ? args.options.map(o => String(o || '').trim()).filter(Boolean)
625
+ : [];
626
+ if (!question) {
627
+ return { success: false, output: 'ask_user requires a question.', _tool: 'ask_user' };
628
+ }
629
+ if (choices.length < 2 || choices.length > 4) {
630
+ return { success: false, output: 'ask_user requires 2-4 options.', _tool: 'ask_user' };
631
+ }
632
+ if (!interactionHandler || !process.stdin.isTTY) {
633
+ return {
634
+ success: true,
635
+ output: 'No interactive user is available in this session. Proceed with your best judgment and state the assumption you made.',
636
+ _tool: 'ask_user',
637
+ };
638
+ }
639
+ const res = await interactionHandler({ question, options: choices, context: args?.context });
640
+ throwIfAborted(options.signal);
641
+ if (!res || !res.answer) {
642
+ return {
643
+ success: true,
644
+ output: 'The user declined to answer. Proceed with your best judgment and state the assumption you made.',
645
+ _tool: 'ask_user',
646
+ };
647
+ }
648
+ return {
649
+ success: true,
650
+ output: `User answered: ${res.answer}${res.source === 'free_text' ? ' (typed answer, not one of the offered options)' : ''}`,
651
+ _tool: 'ask_user',
652
+ };
653
+ },
654
+
655
+ // 0b. read_attachment → chunked text extraction from a local document.
656
+ // Backend registers the schema; execution happens client-side because
657
+ // only the CLI has the user's filesystem. Supports the `path` mode
658
+ // (local file); `upload_id` is chat-only and returns a redirect
659
+ // error. Uses the shared prose-chunker so chunk boundaries and
660
+ // page/chunk numbering match the server-side path executor byte-
661
+ // for-byte (documents.py). Supports total_chunks metadata mode,
662
+ // chunk_range/chunk_no selection, page filtering (PDFs), and
663
+ // case-insensitive query substring filtering.
664
+ read_attachment: async (args, options = {}) => {
665
+ throwIfAborted(options.signal);
666
+ const uploadId = String(args?.upload_id || '').trim();
667
+ const rawPath = String(args?.path || '').trim();
668
+ if (uploadId && !rawPath) {
669
+ return {
670
+ success: false,
671
+ output: 'upload_id is a chat-only mode. In the CLI, pass path=<local path> to read a file the user has on disk.',
672
+ _tool: 'read_attachment',
673
+ };
674
+ }
675
+ if (!rawPath) {
676
+ return {
677
+ success: false,
678
+ output: 'read_attachment requires path=<local path> (upload_id is chat-only).',
679
+ _tool: 'read_attachment',
680
+ };
681
+ }
682
+ // Route through projectRegistry.resolvePath — the same helper
683
+ // read_file/edit_file use. This handles shell-escape unescaping,
684
+ // LLM-quoting normalization, and external-file registration for
685
+ // paths outside registered project roots (attachments in
686
+ // ~/Downloads, /tmp, etc. are legitimate).
687
+ let abs;
688
+ try {
689
+ abs = await resolvePath(rawPath, args, { allowExternalFileRead: true });
690
+ } catch (err) {
691
+ return { success: false, output: String(err?.message || err), _tool: 'read_attachment' };
692
+ }
693
+
694
+ const { extractFromPath } = await import('../context/prose-chunker.mjs');
695
+ let mime, chunks;
696
+ try {
697
+ ({ mime, chunks } = await extractFromPath(abs));
698
+ } catch (err) {
699
+ return { success: false, output: `Failed to read ${abs}: ${err?.message || err}`, _tool: 'read_attachment' };
700
+ }
701
+ if (!mime) {
702
+ return { success: false, output: `File not found or not a regular file: ${abs}`, _tool: 'read_attachment' };
703
+ }
704
+ if (!chunks.length) {
705
+ return {
706
+ success: false,
707
+ output: `Unsupported or empty file (mime=${mime}). Supported: pdf, txt, md/mdx, csv, tsv, json, yaml, toml, html, log, rst. For CSV/Excel analysis use read_table; for images use analyze_image.`,
708
+ _tool: 'read_attachment',
709
+ };
710
+ }
711
+
712
+ // Ingest into the project's BM25 index so subsequent search_code
713
+ // (and future search_document) calls surface this doc's chunks.
714
+ // Best-effort: skip if the file isn't inside a registered project
715
+ // (external attachment like ~/Downloads/foo.pdf), and never let
716
+ // an index write fail the tool.
717
+ try {
718
+ const owningProject = projectRegistry.projectForPath(abs);
719
+ if (owningProject?.retriever?.addProseChunks) {
720
+ const rel = path.relative(owningProject.resource.root, abs);
721
+ owningProject.retriever.addProseChunks(rel, chunks);
722
+ }
723
+ } catch { /* best-effort */ }
724
+
725
+ const totalChunks = chunks.length;
726
+ const totalPages = new Set(chunks.map(c => c.page).filter(p => p != null)).size;
727
+ const totalChars = chunks.reduce((s, c) => s + c.text.length, 0);
728
+
729
+ // total_chunks metadata mode — size-before-read for large docs.
730
+ if (args?.total_chunks) {
731
+ const previewLen = Math.min(400, chunks[0].text.length);
732
+ const preview = chunks[0].text.slice(0, previewLen);
733
+ const previewSuffix = previewLen < chunks[0].text.length ? '…' : '';
734
+ const pagesLine = totalPages ? ` · ${totalPages} pages` : '';
735
+ return {
736
+ success: true,
737
+ output: `📄 ${path.basename(abs)} · ${totalChars} chars · ${totalChunks} chunks (0-${totalChunks - 1})${pagesLine}\n\nFirst chunk preview:\n${preview}${previewSuffix}\n\nUse chunk_range='N-M' or chunk_no=N to read specific chunks.`,
738
+ _tool: 'read_attachment',
739
+ _path: abs,
740
+ _mime: mime,
741
+ _total_chunks: totalChunks,
742
+ _total_pages: totalPages,
743
+ _total_chars: totalChars,
744
+ };
745
+ }
746
+
747
+ // chunk_range / chunk_no selection.
748
+ let selected = chunks;
749
+ const rangeStr = String(args?.chunk_range ?? '').trim()
750
+ || (args?.chunk_no !== undefined && args?.chunk_no !== null
751
+ ? String(args.chunk_no).trim()
752
+ : '');
753
+ if (rangeStr) {
754
+ const m = rangeStr.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
755
+ if (!m) {
756
+ return {
757
+ success: false,
758
+ output: `Invalid chunk_range: '${rangeStr}'. Use 'N' for a single chunk or 'N-M' for an inclusive range.`,
759
+ _tool: 'read_attachment',
760
+ };
761
+ }
762
+ const start = parseInt(m[1], 10);
763
+ const end = m[2] != null ? parseInt(m[2], 10) : start;
764
+ if (end < start) {
765
+ return {
766
+ success: false,
767
+ output: `Invalid chunk_range '${rangeStr}': end (${end}) is before start (${start}).`,
768
+ _tool: 'read_attachment',
769
+ };
770
+ }
771
+ selected = chunks.filter(c => c.chunk_no >= start && c.chunk_no <= end);
772
+ if (!selected.length) {
773
+ return {
774
+ success: false,
775
+ output: `No chunks in range ${start}-${end}. Doc has ${totalChunks} chunks (0-${totalChunks - 1}).`,
776
+ _tool: 'read_attachment',
777
+ };
778
+ }
779
+ }
780
+
781
+ // page filter (PDF only — no-op on text docs where page is null).
782
+ if (args?.page !== undefined && args?.page !== null) {
783
+ const p = parseInt(args.page, 10);
784
+ if (Number.isFinite(p)) {
785
+ selected = selected.filter(c => c.page === p);
786
+ if (!selected.length) {
787
+ return {
788
+ success: false,
789
+ output: `No chunks on page ${p}. Doc has ${totalPages} pages.`,
790
+ _tool: 'read_attachment',
791
+ };
792
+ }
793
+ }
794
+ }
795
+
796
+ // query substring filter (case-insensitive, per-chunk).
797
+ const query = String(args?.query || '').trim();
798
+ if (query) {
799
+ const q = query.toLowerCase();
800
+ selected = selected.filter(c => c.text.toLowerCase().includes(q));
801
+ if (!selected.length) {
802
+ return {
803
+ success: true,
804
+ output: `(No chunks matched query="${query}".)`,
805
+ _tool: 'read_attachment',
806
+ _path: abs,
807
+ _total_chunks: totalChunks,
808
+ _returned_chunks: 0,
809
+ };
810
+ }
811
+ }
812
+
813
+ // Render chunks — matches server _render_chunks format:
814
+ // [page N, chunk M]\n<text>\n\n
815
+ const maxChars = Math.max(1000, Number(args?.max_chars) || 100_000);
816
+ const lines = [];
817
+ let total = 0;
818
+ let truncated = false;
819
+ let renderedCount = 0;
820
+ for (const c of selected) {
821
+ const headerBits = [];
822
+ if (c.page != null) headerBits.push(`page ${c.page}`);
823
+ headerBits.push(`chunk ${c.chunk_no}`);
824
+ const block = `[${headerBits.join(', ')}]\n${c.text}`;
825
+ if (total + block.length + 2 > maxChars) {
826
+ lines.push(`... [truncated at chunk ${c.chunk_no} to fit max_chars=${maxChars}. Use chunk_range='${c.chunk_no}-${selected[selected.length - 1].chunk_no}' to read the rest.]`);
827
+ truncated = true;
828
+ break;
829
+ }
830
+ lines.push(block);
831
+ total += block.length + 2;
832
+ renderedCount += 1;
833
+ }
834
+
835
+ const pagesLine = totalPages ? ` · ${totalPages} pages` : '';
836
+ const truncNote = truncated ? ' (truncated)' : '';
837
+ const header = `📄 ${path.basename(abs)} · ${totalChunks} chunks total${pagesLine} · showing ${renderedCount}${truncNote}\n\n`;
838
+ return {
839
+ success: true,
840
+ output: header + lines.join('\n\n'),
841
+ _tool: 'read_attachment',
842
+ _path: abs,
843
+ _mime: mime,
844
+ _total_chunks: totalChunks,
845
+ _returned_chunks: renderedCount,
846
+ _truncated: truncated,
847
+ };
848
+ },
849
+
850
+ // 1. shell → Bash + classification + smart output filtering
851
+ shell: async (args, options = {}) => {
852
+ throwIfAborted(options.signal);
853
+ // Phase 1: legacy safety check (kept for backward compat)
854
+ const shellCheck = validateShellCommand(args.command);
855
+ if (!shellCheck.safe) {
856
+ return {
857
+ success: false,
858
+ output: blockedShellOutput(shellCheck.reason),
859
+ _tool: 'shell', _blocked: true,
860
+ };
861
+ }
862
+
863
+ // Phase 2: command classifier (PRD-050)
864
+ const classification = classifyCommand(args.command);
865
+ if (classification.classification === 'blocked') {
866
+ return {
867
+ success: false,
868
+ output: blockedShellOutput(classification.reason),
869
+ _tool: 'shell', _blocked: true,
870
+ };
871
+ }
872
+
873
+ // Tag for approval/sandbox routing
874
+ if (classification.highRisk || shellCheck.highRisk) {
875
+ args._highRisk = true;
876
+ args._riskReason = classification.reason || shellCheck.reason;
877
+ }
878
+ args._classification = classification.classification; // 'safe' or 'contained'
879
+ const cwd = await commandCwd(args);
880
+
881
+ // Pre-check: if command is rm/unlink, verify targets exist first
882
+ const rmMatch = (args.command || '').match(/^rm\s+(?:-\w+\s+)*(.+)$/);
883
+ if (rmMatch) {
884
+ const targets = rmMatch[1].split(/\s+/).filter(t => !t.startsWith('-'));
885
+ const missing = targets.filter(t => {
886
+ try { return !fs.existsSync(shellTargetPath(cwd, t)); } catch { return true; }
887
+ });
888
+ if (missing.length > 0 && missing.length === targets.length) {
889
+ return {
890
+ success: true,
891
+ output: `No action needed: ${missing.join(', ')} — file(s) do not exist. Do not retry.`,
892
+ exit_code: 0,
893
+ _tool: 'shell',
894
+ _skipped: true,
895
+ };
896
+ }
897
+ }
898
+
899
+ const observationTimeout = args.timeout == null && isLikelyLongRunningCommand(args.command);
900
+ const effectiveTimeout = observationTimeout ? longRunningObservationTimeoutMs() : args.timeout;
901
+ const result = await occRegistry.call('Bash', {
902
+ command: args.command,
903
+ timeout: effectiveTimeout,
904
+ description: args.description || `Run: ${(args.command || '').slice(0, 50)}`,
905
+ cwd,
906
+ signal: options.signal,
907
+ });
908
+ const rawOutput = typeof result === 'string' ? result : String(result);
909
+ const cancelled = /^Error:\s*Command cancelled by user/i.test(rawOutput);
910
+ const timedOut = /^Error:\s*Command timed out after \d+ms/i.test(rawOutput);
911
+ const exitMatch = rawOutput.match(/Exit code: (\d+)/);
912
+ const exitCode = cancelled ? 130 : (timedOut ? 124 : (exitMatch ? parseInt(exitMatch[1]) : 0));
913
+ // Semantic exit code: grep returns 1 for "no matches" (not an error)
914
+ const success = cancelled ? false : (observationTimeout && timedOut ? true : (!timedOut && !isExitCodeError(args.command, exitCode)));
915
+
916
+ // Apply smart filtering based on command type
917
+ const filtered = observationTimeout && timedOut
918
+ ? {
919
+ output: formatObservationTimeoutOutput(rawOutput, effectiveTimeout),
920
+ commandType: detectCommandType(args.command),
921
+ truncated: false,
922
+ originalLines: rawOutput.split('\n').length,
923
+ filteredLines: rawOutput.split('\n').length,
924
+ }
925
+ : filterOutput(rawOutput, args.command, success);
926
+
927
+ return {
928
+ success,
929
+ output: filtered.output,
930
+ exit_code: exitCode,
931
+ _tool: 'shell',
932
+ _classification: args._classification,
933
+ _commandType: filtered.commandType,
934
+ _filtered: filtered.truncated || filtered.originalLines !== filtered.filteredLines,
935
+ _timed_out: timedOut,
936
+ _cancelled: cancelled,
937
+ _observation_timeout: observationTimeout && timedOut,
938
+ _observation_timeout_ms: observationTimeout && timedOut ? effectiveTimeout : undefined,
939
+ };
940
+ },
941
+
942
+ // 2. read_file → Read (with smart truncation for large files)
943
+ read_file: async (args) => {
944
+ const filePath = await resolvePath(args.file_path || args.path, args, { allowExternalFileRead: true });
945
+ const hasLineRange = args.start_line || args.end_line || args.offset || args.limit;
946
+ const offset = args.start_line ? args.start_line - 1 : args.offset;
947
+ const limit = (args.start_line && args.end_line)
948
+ ? (args.end_line - args.start_line + 1)
949
+ : args.limit;
950
+
951
+ return await withReadOnlyCache(
952
+ 'read_file',
953
+ { filePath, offset, limit },
954
+ fileFingerprint(filePath),
955
+ async () => {
956
+
957
+ // Nudge: if reading shallow overview files, remind agent to search deeper
958
+ const basename = path.basename(filePath).toLowerCase();
959
+ const isShallowFile = ['readme.md', 'package.json', 'pyproject.toml', 'cargo.toml', 'go.mod'].includes(basename);
960
+ const nudge = isShallowFile && !_searchCodeUsed
961
+ ? '\n\nNOTE: You read a top-level overview file. Use search_code(query) to find actual implementations before drawing conclusions. READMEs and package.json do NOT show what features exist in the codebase.'
962
+ : '';
963
+
964
+ // If no line range specified, auto-truncate and return AST summary
965
+ if (!hasLineRange) {
966
+ try {
967
+ const content = fs.readFileSync(filePath, 'utf-8');
968
+ const lines = content.split('\n').length;
969
+
970
+ if (lines > 50) {
971
+ // File >50 lines: return AST summary with line numbers
972
+ // Model must use start_line/end_line to read specific sections
973
+ const analysis = analyzeCode(filePath);
974
+ const firstLines = content.split('\n').slice(0, 20).join('\n');
975
+ return {
976
+ success: true,
977
+ output: `${analysis.summary}\n\n` +
978
+ `## First 20 lines\n${firstLines}${nudge}`,
979
+ _tool: 'read_file',
980
+ _truncated: true,
981
+ _total_lines: lines,
982
+ };
983
+ }
984
+ // Small file (<50 lines): return full content
985
+ } catch { /* let Read handle the error */ }
986
+ }
987
+
988
+ const result = await occRegistry.call('Read', {
989
+ file_path: filePath,
990
+ offset,
991
+ limit,
992
+ });
993
+ const output = typeof result === 'string' ? result : String(result);
994
+ const content = output.replace(/^\s*\d+[→\t]/gm, '');
995
+ const actNudge = solutionNudge(filePath);
996
+ return {
997
+ success: !isError(output),
998
+ content,
999
+ output: output + nudge + actNudge,
1000
+ _tool: 'read_file',
1001
+ _output_type: 'file_content',
1002
+ };
1003
+ },
1004
+ );
1005
+ },
1006
+
1007
+ // 3. write_file → Write + auto-lint + safety check
1008
+ write_file: async (args) => {
1009
+ const rawPath = args.file_path || args.path;
1010
+ if (!rawPath || rawPath === 'file' || rawPath.length < 3) {
1011
+ return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Register the project, then use an absolute path.`, _tool: 'write_file' };
1012
+ }
1013
+ const filePath = await resolvePath(rawPath, args, { allowMissing: true });
1014
+ const before = readTextIfExists(filePath);
1015
+ const writeCheck = validateWrite(filePath, args.content, projectRootFor(filePath));
1016
+ if (!writeCheck.safe) {
1017
+ return { success: false, output: `🛡️ BLOCKED: ${writeCheck.reason}`, _tool: 'write_file', _blocked: true };
1018
+ }
1019
+ // OCC Write requires Read first for existing files — handle gracefully
1020
+ try {
1021
+ if (fs.existsSync(filePath)) {
1022
+ await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1023
+ }
1024
+ } catch { /* file may not exist yet */ }
1025
+ // Checkpoint before overwrite so /undo can restore the previous content.
1026
+ if (checkpoints && fs.existsSync(filePath)) {
1027
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
1028
+ }
1029
+ const result = await occRegistry.call('Write', {
1030
+ file_path: filePath,
1031
+ content: args.content,
1032
+ });
1033
+ const wrapped = wrapResult(result, 'write_file');
1034
+ const after = readTextIfExists(filePath);
1035
+ attachFileDiff(wrapped, filePath, before, after);
1036
+ updateProjectIndex(filePath);
1037
+
1038
+ // Auto-lint the written file
1039
+ const lintOutput = autoLint(filePath);
1040
+ if (lintOutput) {
1041
+ wrapped.output += `\n\n--- Lint ---\n${lintOutput}`;
1042
+ wrapped.lint = lintOutput;
1043
+ }
1044
+
1045
+ // Nudge: tell the model how to verify
1046
+ const hint = verificationHint(filePath);
1047
+ if (hint) wrapped.output += hint;
1048
+
1049
+ return wrapped;
1050
+ },
1051
+
1052
+ // 3b. write_project → Batch write multiple files at once
1053
+ write_project: async (args) => {
1054
+ const files = args.files || [];
1055
+ if (!files.length) {
1056
+ return { success: false, output: 'Error: No files provided', _tool: 'write_project' };
1057
+ }
1058
+
1059
+ const results = [];
1060
+ const errors = [];
1061
+ const diffs = [];
1062
+
1063
+ for (const file of files) {
1064
+ const rawPath = file.path || file.file_path;
1065
+ if (!rawPath) {
1066
+ errors.push('Missing path in file entry');
1067
+ continue;
1068
+ }
1069
+ const filePath = await resolvePath(rawPath, file, { allowMissing: true });
1070
+ const content = file.content || '';
1071
+
1072
+ const writeCheck = validateWrite(filePath, content, projectRootFor(filePath));
1073
+ if (!writeCheck.safe) {
1074
+ errors.push(`${rawPath}: BLOCKED — ${writeCheck.reason}`);
1075
+ continue;
1076
+ }
1077
+
1078
+ try {
1079
+ // Ensure parent directory exists
1080
+ const dir = path.dirname(filePath);
1081
+ fs.mkdirSync(dir, { recursive: true });
1082
+ const before = readTextIfExists(filePath);
1083
+
1084
+ // Read first if exists (OCC Write requirement)
1085
+ try {
1086
+ if (fs.existsSync(filePath)) {
1087
+ await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1088
+ }
1089
+ } catch { /* file may not exist yet */ }
1090
+
1091
+ await occRegistry.call('Write', { file_path: filePath, content });
1092
+ const after = readTextIfExists(filePath);
1093
+ diffs.push(buildResultFileDiff(filePath, before, after));
1094
+ updateProjectIndex(filePath);
1095
+ results.push(rawPath);
1096
+ } catch (err) {
1097
+ errors.push(`${rawPath}: ${err.message}`);
1098
+ }
1099
+ }
1100
+
1101
+ const output = results.length > 0
1102
+ ? `Created ${results.length} file(s):\n${results.map(f => ` ✓ ${f}`).join('\n')}`
1103
+ : 'No files written';
1104
+
1105
+ if (errors.length > 0) {
1106
+ return {
1107
+ success: results.length > 0,
1108
+ output: `${output}\n\nErrors:\n${errors.map(e => ` ✗ ${e}`).join('\n')}`,
1109
+ files_written: results,
1110
+ files_failed: errors,
1111
+ file_diffs: diffs,
1112
+ lines_added: diffs.reduce((sum, diff) => sum + (diff.lines_added || 0), 0),
1113
+ lines_removed: diffs.reduce((sum, diff) => sum + (diff.lines_removed || 0), 0),
1114
+ _tool: 'write_project',
1115
+ };
1116
+ }
1117
+
1118
+ return {
1119
+ success: true,
1120
+ output,
1121
+ files_written: results,
1122
+ file_diffs: diffs,
1123
+ lines_added: diffs.reduce((sum, diff) => sum + (diff.lines_added || 0), 0),
1124
+ lines_removed: diffs.reduce((sum, diff) => sum + (diff.lines_removed || 0), 0),
1125
+ _tool: 'write_project',
1126
+ };
1127
+ },
1128
+
1129
+ // 4. edit_file → Edit + auto-lint + auto-fallback to sed
1130
+ edit_file: async (args) => {
1131
+ const rawPath = args.file_path || args.path;
1132
+ const filePath = await resolvePath(rawPath, args);
1133
+ const before = readTextIfExists(filePath);
1134
+ const writeCheck = validateWrite(filePath, args.replace, projectRootFor(filePath));
1135
+ if (!writeCheck.safe) {
1136
+ return { success: false, output: `BLOCKED: ${writeCheck.reason}`, _tool: 'edit_file', _blocked: true };
1137
+ }
1138
+ // OCC Edit requires Read first
1139
+ try {
1140
+ await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1141
+ } catch { /* best effort */ }
1142
+
1143
+ // Checkpoint before edit so /undo can restore the previous content.
1144
+ if (checkpoints) {
1145
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
1146
+ }
1147
+
1148
+ let result;
1149
+ try {
1150
+ result = await occRegistry.call('Edit', {
1151
+ file_path: filePath,
1152
+ old_string: args.search,
1153
+ new_string: args.replace,
1154
+ replace_all: args.replace_all || false,
1155
+ });
1156
+ } catch (editErr) {
1157
+ // OCC Edit failed (string not found) — fallback to Python replacement
1158
+ try {
1159
+ const search = args.search.replace(/'/g, "\\'").replace(/\n/g, "\\n");
1160
+ const replace = args.replace.replace(/'/g, "\\'").replace(/\n/g, "\\n");
1161
+ const pyCmd = `python3 -c "
1162
+ import sys
1163
+ with open('${filePath}', 'r') as f: content = f.read()
1164
+ old = '''${args.search}'''
1165
+ new = '''${args.replace}'''
1166
+ if old not in content:
1167
+ print('ERROR: search string not found in file', file=sys.stderr)
1168
+ sys.exit(1)
1169
+ content = content.replace(old, new, 1)
1170
+ with open('${filePath}', 'w') as f: f.write(content)
1171
+ print('OK: replaced')
1172
+ "`;
1173
+ const fallbackResult = execSync(pyCmd, {
1174
+ encoding: 'utf-8',
1175
+ timeout: 5000,
1176
+ cwd: projectRootFor(filePath),
1177
+ });
1178
+ result = `Edited ${filePath} (via fallback): ${fallbackResult.trim()}`;
1179
+ } catch (sedErr) {
1180
+ return { success: false, output: `edit_file failed: ${editErr?.message || 'unknown'}. Fallback also failed: ${sedErr?.message || 'unknown'}. Try shell(sed) manually.`, _tool: 'edit_file' };
1181
+ }
1182
+ }
1183
+
1184
+ const wrapped = wrapResult(result, 'edit_file');
1185
+ const after = readTextIfExists(filePath);
1186
+ attachFileDiff(wrapped, filePath, before, after);
1187
+ updateProjectIndex(filePath);
1188
+ _hasEdited = true;
1189
+
1190
+ // Auto-lint the edited file
1191
+ const lintOutput = autoLint(filePath);
1192
+ if (lintOutput) {
1193
+ wrapped.output += `\n\n--- Lint ---\n${lintOutput}`;
1194
+ wrapped.lint = lintOutput;
1195
+ }
1196
+
1197
+ // Nudge: tell the model how to verify
1198
+ const hint = verificationHint(filePath);
1199
+ if (hint) wrapped.output += hint;
1200
+
1201
+ return wrapped;
1202
+ },
1203
+
1204
+ // 5. list_files → Glob
1205
+ list_files: async (args) => {
1206
+ const searchPath = await resolvePath(args.path || null, args);
1207
+ return await withReadOnlyCache(
1208
+ 'list_files',
1209
+ {
1210
+ pattern: args.pattern || '**/*',
1211
+ path: searchPath,
1212
+ format: args.format || (args.tree === true ? 'tree' : 'glob'),
1213
+ max_depth: args.max_depth ?? args.maxDepth ?? null,
1214
+ },
1215
+ { generation: _readOnlyCacheGeneration },
1216
+ async () => {
1217
+ if (args.format === 'tree' || args.tree === true) {
1218
+ const requestedDepth = Number(args.max_depth ?? args.maxDepth ?? 2);
1219
+ const maxDepth = Number.isFinite(requestedDepth)
1220
+ ? Math.max(1, Math.min(6, Math.trunc(requestedDepth)))
1221
+ : 2;
1222
+ const tree = buildDirectoryTree(searchPath, { maxDepth });
1223
+ return {
1224
+ success: true,
1225
+ output: tree.output,
1226
+ tree: tree.output,
1227
+ files: tree.files,
1228
+ directories: tree.directories,
1229
+ truncated: tree.truncated,
1230
+ _tool: 'list_files',
1231
+ _format: 'tree',
1232
+ };
1233
+ }
1234
+ const result = await occRegistry.call('Glob', {
1235
+ pattern: args.pattern || '**/*',
1236
+ path: searchPath,
1237
+ });
1238
+ const output = typeof result === 'string' ? result : String(result);
1239
+ const files = output.split('\n').filter(Boolean);
1240
+ return {
1241
+ success: true,
1242
+ files,
1243
+ output,
1244
+ _tool: 'list_files',
1245
+ };
1246
+ },
1247
+ );
1248
+ },
1249
+
1250
+ // 6. search_code → combined rg + BM25 for best results
1251
+ search_code: async (args) => {
1252
+ _searchCodeUsed = true;
1253
+ const query = args.query || args.pattern;
1254
+ if (!query) return { success: false, output: 'query required', _tool: 'search_code' };
1255
+
1256
+ let project;
1257
+ if (args.project_id) {
1258
+ project = projectRegistry.get(args.project_id);
1259
+ if (!project) {
1260
+ return { success: false, output: `Unknown project_id: ${args.project_id}`, _tool: 'search_code' };
1261
+ }
1262
+ } else if (args.path) {
1263
+ project = projectRegistry.projectForPath(await resolvePath(args.path, args));
1264
+ } else if (projectRegistry.resources().length === 1) {
1265
+ project = projectRegistry.get(projectRegistry.resources()[0].project_id);
1266
+ } else {
1267
+ return {
1268
+ success: false,
1269
+ output: 'search_code requires project_id when multiple or no projects are registered',
1270
+ _tool: 'search_code',
1271
+ };
1272
+ }
1273
+ const searchPath = args.path ? await resolvePath(args.path, args) : project.resource.root;
1274
+ const parts = [];
1275
+
1276
+ // Layer 1: ripgrep — exact text matches with context
1277
+ try {
1278
+ const cmd = `rg -n -C 1 --max-count 5 --max-filesize 500K -e ${JSON.stringify(query)} ${JSON.stringify(searchPath)} 2>/dev/null | head -60`;
1279
+ const rgOutput = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: searchPath }).trim();
1280
+ if (rgOutput) {
1281
+ parts.push(`## Exact matches (rg)\n${rgOutput}`);
1282
+ }
1283
+ } catch { /* rg not found or no results */ }
1284
+
1285
+ // Layer 2: Symbol search — AST-extracted functions/classes with signatures
1286
+ if (project?.retriever) {
1287
+ if (!project.retriever.index) project.retriever.loadIndex();
1288
+ const symbols = project.retriever.searchSymbols(query, 5);
1289
+ if (symbols.length > 0) {
1290
+ const symOutput = project.retriever.formatSymbolResults(symbols);
1291
+ parts.push(`## Symbols (functions/classes)\n${symOutput}`);
1292
+ }
1293
+
1294
+ // Layer 3: BM25 chunks — broader context when symbols aren't enough
1295
+ const chunks = project.retriever.retrieve(query, 5);
1296
+ if (chunks.length > 0) {
1297
+ const bm25Output = chunks.map(c => {
1298
+ const score = c.score?.toFixed(2) || '?';
1299
+ return `── ${c.id} (score: ${score}) ──\n${c.text}`;
1300
+ }).join('\n\n');
1301
+ parts.push(`## Related code (BM25)\n${bm25Output}`);
1302
+ }
1303
+ }
1304
+
1305
+ // Return combined results
1306
+ if (parts.length > 0) {
1307
+ return {
1308
+ success: true,
1309
+ output: parts.join('\n\n'),
1310
+ _tool: 'search_code',
1311
+ _method: parts.length > 1 ? 'rg+bm25' : (parts[0].startsWith('## Exact') ? 'rg' : 'bm25'),
1312
+ };
1313
+ }
1314
+
1315
+ // Nothing found — actionable hint
1316
+ const firstWord = query.split(/\s+/)[0];
1317
+ return {
1318
+ success: true,
1319
+ output: `No results for "${query}" in ${searchPath}.\n` +
1320
+ `Try: shell(grep -rn "${firstWord}" . --include="*.py" | head -20)`,
1321
+ _tool: 'search_code',
1322
+ _method: 'none',
1323
+ };
1324
+ },
1325
+
1326
+ // 7. search_files → Grep with line numbers + context (like grep -n -C 3)
1327
+ search_files: async (args) => {
1328
+ const query = args.query || args.pattern || '*';
1329
+ const searchPath = await resolvePath(args.path || null, args);
1330
+
1331
+ // If it looks like a glob pattern, use Glob
1332
+ if (query.includes('*') || query.includes('?')) {
1333
+ return await withReadOnlyCache(
1334
+ 'search_files',
1335
+ { query, path: searchPath, mode: 'glob' },
1336
+ { generation: _readOnlyCacheGeneration },
1337
+ async () => {
1338
+ const result = await occRegistry.call('Glob', {
1339
+ pattern: query,
1340
+ path: searchPath,
1341
+ });
1342
+ const output = typeof result === 'string' ? result : String(result);
1343
+ return {
1344
+ success: true,
1345
+ files: output.split('\n').filter(Boolean),
1346
+ output,
1347
+ _tool: 'search_files',
1348
+ };
1349
+ },
1350
+ );
1351
+ }
1352
+
1353
+ // For text patterns: grep with context lines (like grep -n -C 3)
1354
+ return await withReadOnlyCache(
1355
+ 'search_files',
1356
+ { query, path: searchPath, mode: 'grep' },
1357
+ { generation: _readOnlyCacheGeneration },
1358
+ async () => {
1359
+ const result = await occRegistry.call('Grep', {
1360
+ pattern: query,
1361
+ path: searchPath,
1362
+ output_mode: 'content',
1363
+ '-n': true,
1364
+ '-C': 3,
1365
+ head_limit: 50,
1366
+ });
1367
+ const output = typeof result === 'string' ? result : String(result);
1368
+ return {
1369
+ success: true,
1370
+ files: output.split('\n').filter(Boolean),
1371
+ output,
1372
+ _tool: 'search_files',
1373
+ };
1374
+ },
1375
+ );
1376
+ },
1377
+
1378
+ // 7b. grep → dedicated ripgrep tool (fast text/regex search)
1379
+ grep: async (args) => {
1380
+ const pattern = args.pattern;
1381
+ if (!pattern) return { success: false, output: 'pattern required', _tool: 'grep' };
1382
+
1383
+ const searchPath = await resolvePath(args.path || null, args);
1384
+ const includeFlag = args.include ? `--glob "${args.include}"` : '';
1385
+
1386
+ try {
1387
+ const cmd = `rg -n -C 2 --max-count 10 --max-filesize 500K ${includeFlag} -e ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)} 2>/dev/null | head -80`;
1388
+ const output = execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: searchPath }).trim();
1389
+ if (output) {
1390
+ return { success: true, output, _tool: 'grep' };
1391
+ }
1392
+ } catch { /* no results or rg not found */ }
1393
+
1394
+ return {
1395
+ success: true,
1396
+ output: `No matches for "${pattern}" in ${searchPath}`,
1397
+ _tool: 'grep',
1398
+ };
1399
+ },
1400
+
1401
+ // ── Tarang-specific tools (no OCC bridge) ──────────────
1402
+
1403
+ // 8. read_files → batch Read (with AST truncation for large files)
1404
+ read_files: async (args) => {
1405
+ const rawItems = args.items || args.files || args.file_paths || args.paths || [];
1406
+ const items = (Array.isArray(rawItems) ? rawItems : [])
1407
+ .map(item => typeof item === 'string' ? { file_path: item } : item)
1408
+ .filter(Boolean);
1409
+ const results = [];
1410
+ for (const item of items) {
1411
+ const p = item.file_path || item.path;
1412
+ try {
1413
+ const result = await toolMap.read_file({
1414
+ ...args,
1415
+ ...item,
1416
+ file_path: p,
1417
+ });
1418
+ results.push({
1419
+ path: p,
1420
+ success: result.success !== false,
1421
+ content: result.content,
1422
+ output: result.output,
1423
+ lines: result._total_lines,
1424
+ cached: Boolean(result._cache_reused),
1425
+ truncated: Boolean(result._truncated),
1426
+ });
1427
+ } catch (err) {
1428
+ results.push({ path: p, error: err.message, success: false });
1429
+ }
1430
+ }
1431
+ return {
1432
+ success: results.every(item => item.success !== false),
1433
+ files: results,
1434
+ output: results.map(item => {
1435
+ const status = item.success === false ? 'ERROR' : item.cached ? 'CACHED' : 'OK';
1436
+ return `## ${item.path} [${status}]\n${item.output || item.content || item.error || ''}`;
1437
+ }).join('\n\n'),
1438
+ _tool: 'read_files',
1439
+ };
1440
+ },
1441
+
1442
+ read_batch: async (args) => {
1443
+ const result = await toolMap.read_files({
1444
+ ...args,
1445
+ items: args.items || args.files || args.file_paths || args.paths || [],
1446
+ });
1447
+ return { ...result, _tool: 'read_batch' };
1448
+ },
1449
+
1450
+ // 9. delete_file + safety check + checkpoint for undo
1451
+ delete_file: async (args) => {
1452
+ try {
1453
+ const filePath = await resolvePath(args.file_path || args.path, args);
1454
+ const delCheck = validateDelete(filePath, projectRootFor(filePath));
1455
+ if (!delCheck.safe) {
1456
+ return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
1457
+ }
1458
+ if (checkpoints) {
1459
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
1460
+ }
1461
+ fs.unlinkSync(filePath);
1462
+ updateProjectIndex(filePath);
1463
+ return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
1464
+ } catch (err) {
1465
+ return { success: false, output: `Error: ${err.message}`, _tool: 'delete_file' };
1466
+ }
1467
+ },
1468
+
1469
+ // 10. get_file_info
1470
+ get_file_info: async (args) => {
1471
+ try {
1472
+ const filePath = await resolvePath(args.file_path || args.path, args);
1473
+ const stat = fs.statSync(filePath);
1474
+ return {
1475
+ success: true,
1476
+ size: stat.size,
1477
+ mtime: stat.mtime.toISOString(),
1478
+ type: stat.isDirectory() ? 'directory' : 'file',
1479
+ mode: stat.mode.toString(8),
1480
+ _tool: 'get_file_info',
1481
+ };
1482
+ } catch (err) {
1483
+ return { success: false, output: `Error: ${err.message}`, _tool: 'get_file_info' };
1484
+ }
1485
+ },
1486
+
1487
+ // 11. validate_file (syntax check)
1488
+ validate_file: async (args) => {
1489
+ try {
1490
+ const filePath = await resolvePath(args.path, args);
1491
+ const ext = path.extname(filePath);
1492
+ let cmd;
1493
+ if (ext === '.py') cmd = `python3 -m py_compile "${filePath}"`;
1494
+ else if (ext === '.js' || ext === '.mjs') cmd = `node --check "${filePath}"`;
1495
+ else return { success: true, valid: true, message: 'No validator for this file type', _tool: 'validate_file' };
1496
+
1497
+ execSync(cmd, { stdio: 'pipe', cwd: projectRootFor(filePath) });
1498
+ return { success: true, valid: true, _tool: 'validate_file' };
1499
+ } catch (err) {
1500
+ return { success: true, valid: false, errors: err.stderr?.toString() || err.message, _tool: 'validate_file' };
1501
+ }
1502
+ },
1503
+
1504
+ // 12. validate_build
1505
+ validate_build: async (args, options = {}) => {
1506
+ try {
1507
+ throwIfAborted(options.signal);
1508
+ let cmd = args.command;
1509
+ const cwd = await commandCwd(args);
1510
+ if (!cmd) {
1511
+ if (fs.existsSync(path.join(cwd, 'package.json'))) cmd = 'npm run build';
1512
+ else if (fs.existsSync(path.join(cwd, 'Makefile'))) cmd = 'make';
1513
+ else if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) cmd = 'cargo build';
1514
+ else return { success: false, output: 'No build system detected', _tool: 'validate_build' };
1515
+ }
1516
+ const output = await occRegistry.call('Bash', {
1517
+ command: cmd,
1518
+ timeout: Math.min(args.timeout || 120_000, 600_000),
1519
+ description: `Validate build: ${cmd.slice(0, 80)}`,
1520
+ cwd,
1521
+ signal: options.signal,
1522
+ });
1523
+ const rawOutput = typeof output === 'string' ? output : String(output);
1524
+ if (/^Error:\s*Command cancelled by user/i.test(rawOutput)) {
1525
+ return { success: false, output: 'Cancelled by user', exit_code: 130, _cancelled: true, _tool: 'validate_build' };
1526
+ }
1527
+ const exitMatch = rawOutput.match(/Exit code: (\d+)/);
1528
+ if (exitMatch || /^Error:\s*Command timed out/i.test(rawOutput)) {
1529
+ return { success: false, output: rawOutput, exit_code: exitMatch ? Number(exitMatch[1]) : 124, _tool: 'validate_build' };
1530
+ }
1531
+ return { success: true, output: rawOutput, _tool: 'validate_build' };
1532
+ } catch (err) {
1533
+ if (isAbortError(err) || options.signal?.aborted) return cancelledToolResult('validate_build');
1534
+ return { success: false, output: err.stderr?.toString() || err.message, _tool: 'validate_build' };
1535
+ }
1536
+ },
1537
+
1538
+ // 13. validate_structure
1539
+ validate_structure: async (args) => {
1540
+ const expected = args.expected || [];
1541
+ const missing = [];
1542
+ for (const f of expected) {
1543
+ if (!fs.existsSync(await resolvePath(f, args, { allowMissing: true }))) {
1544
+ missing.push(f);
1545
+ }
1546
+ }
1547
+ return {
1548
+ success: missing.length === 0,
1549
+ missing,
1550
+ checked: expected.length,
1551
+ _tool: 'validate_structure',
1552
+ };
1553
+ },
1554
+
1555
+ // 14. lint_check
1556
+ lint_check: async (args, options = {}) => {
1557
+ try {
1558
+ throwIfAborted(options.signal);
1559
+ const filePath = await resolvePath(args.file_path || args.path, args);
1560
+ const ext = path.extname(filePath);
1561
+ let cmd;
1562
+ if (ext === '.py') cmd = `python3 -m ruff check "${filePath}" 2>&1 || true`;
1563
+ else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
1564
+ else return { success: true, issues: [], message: 'No linter for this file type', _tool: 'lint_check' };
1565
+
1566
+ const output = await occRegistry.call('Bash', {
1567
+ command: cmd,
1568
+ timeout: 30_000,
1569
+ description: `Lint: ${path.basename(filePath)}`,
1570
+ cwd: projectRootFor(filePath),
1571
+ signal: options.signal,
1572
+ });
1573
+ const rawOutput = typeof output === 'string' ? output : String(output);
1574
+ if (/^Error:\s*Command cancelled by user/i.test(rawOutput)) {
1575
+ return { success: false, output: 'Cancelled by user', _cancelled: true, _tool: 'lint_check' };
1576
+ }
1577
+ return { success: true, output: rawOutput, issues: rawOutput.split('\n').filter(Boolean), _tool: 'lint_check' };
1578
+ } catch (err) {
1579
+ if (isAbortError(err) || options.signal?.aborted) return cancelledToolResult('lint_check');
1580
+ return { success: false, output: err.message, _tool: 'lint_check' };
1581
+ }
1582
+ },
1583
+
1584
+ // 15. run_tests
1585
+ run_tests: async (args, options = {}) => {
1586
+ try {
1587
+ throwIfAborted(options.signal);
1588
+ const cmd = args.command || 'npm test';
1589
+ const cwd = await commandCwd(args);
1590
+ const output = await occRegistry.call('Bash', {
1591
+ command: cmd,
1592
+ timeout: Math.min(args.timeout || 120_000, 600_000),
1593
+ description: `Run tests: ${cmd.slice(0, 80)}`,
1594
+ cwd,
1595
+ signal: options.signal,
1596
+ });
1597
+ const rawOutput = typeof output === 'string' ? output : String(output);
1598
+ if (/^Error:\s*Command cancelled by user/i.test(rawOutput)) {
1599
+ return { success: false, output: 'Cancelled by user', exit_code: 130, _cancelled: true, _tool: 'run_tests' };
1600
+ }
1601
+ const exitMatch = rawOutput.match(/Exit code: (\d+)/);
1602
+ const timedOut = /^Error:\s*Command timed out/i.test(rawOutput);
1603
+ return {
1604
+ success: !exitMatch && !timedOut,
1605
+ output: rawOutput.slice(-3000),
1606
+ exit_code: exitMatch ? Number(exitMatch[1]) : (timedOut ? 124 : 0),
1607
+ _tool: 'run_tests',
1608
+ };
1609
+ } catch (err) {
1610
+ if (isAbortError(err) || options.signal?.aborted) return cancelledToolResult('run_tests');
1611
+ const output = (err.stdout || '') + (err.stderr || '');
1612
+ return { success: false, output: output.slice(-3000), exit_code: err.status, _tool: 'run_tests' };
1613
+ }
1614
+ },
1615
+
1616
+ // 16. git_diff
1617
+ git_diff: async (args) => {
1618
+ try {
1619
+ const filePath = args.file_path ? `-- "${args.file_path}"` : '';
1620
+ const cwd = await commandCwd(args);
1621
+ const output = execSync(`git diff ${filePath}`, {
1622
+ stdio: 'pipe', timeout: 10_000, cwd, encoding: 'utf-8',
1623
+ }).toString();
1624
+ return { success: true, output: output.slice(-5000) || '(no changes)', _tool: 'git_diff' };
1625
+ } catch (err) {
1626
+ return { success: false, output: err.message, _tool: 'git_diff' };
1627
+ }
1628
+ },
1629
+
1630
+ // 17. git_status
1631
+ git_status: async (args) => {
1632
+ try {
1633
+ const cwd = await commandCwd(args);
1634
+ const output = execSync('git status --short', {
1635
+ stdio: 'pipe', timeout: 10_000, cwd, encoding: 'utf-8',
1636
+ }).toString();
1637
+ return { success: true, output: output || '(clean)', _tool: 'git_status' };
1638
+ } catch (err) {
1639
+ return { success: false, output: err.message, _tool: 'git_status' };
1640
+ }
1641
+ },
1642
+
1643
+ // 18. analyze_code — AST-based structured code analysis
1644
+ // Returns function signatures, classes, imports instead of raw file contents
1645
+ // 10x more token-efficient than read_file
1646
+ analyze_code: async (args) => {
1647
+ const filePath = await resolvePath(args.file_path || args.path, args);
1648
+ let stat;
1649
+ try {
1650
+ stat = fs.statSync(filePath);
1651
+ } catch (err) {
1652
+ return { success: false, output: `Error: ${err.message}`, structure: {}, _tool: 'analyze_code' };
1653
+ }
1654
+ if (stat.isDirectory()) {
1655
+ return {
1656
+ success: false,
1657
+ output: `Error: analyze_code expects a file, but got directory: ${filePath}. Use list_files/search_code first, then pass a specific source file.`,
1658
+ structure: {},
1659
+ _tool: 'analyze_code',
1660
+ };
1661
+ }
1662
+ const result = analyzeCode(filePath, {
1663
+ startLine: args.start_line,
1664
+ endLine: args.end_line,
1665
+ });
1666
+ return {
1667
+ success: result.success,
1668
+ output: result.summary,
1669
+ structure: result.structure,
1670
+ _tool: 'analyze_code',
1671
+ };
1672
+ },
1673
+
1674
+ // Project overview — on-demand index + skeleton
1675
+ get_project_overview: async (args) => {
1676
+ const projectPath = args.path || args.project_path;
1677
+ const result = await projectRegistry.register(projectPath, {
1678
+ forceRefresh: Boolean(args.force_refresh || args.forceRefresh),
1679
+ });
1680
+ return {
1681
+ success: true,
1682
+ output: result.output,
1683
+ project_resource: result.resource,
1684
+ already_registered: result.already_registered,
1685
+ refreshed: result.refreshed,
1686
+ _tool: 'get_project_overview',
1687
+ };
1688
+ },
1689
+
1690
+ // Portable skills — metadata first, full content only on demand.
1691
+ skills_list: async (args) => ({
1692
+ success: true,
1693
+ output: JSON.stringify(skillsLoader.list({
1694
+ query: args.query || '',
1695
+ source: args.source || '',
1696
+ scope: args.scope || '',
1697
+ }), null, 2),
1698
+ skills: skillsLoader.list({
1699
+ query: args.query || '',
1700
+ source: args.source || '',
1701
+ scope: args.scope || '',
1702
+ }),
1703
+ _tool: 'skills_list',
1704
+ }),
1705
+
1706
+ skill_view: async (args) => {
1707
+ const skill = skillsLoader.view(
1708
+ args.name,
1709
+ args.path || null,
1710
+ { sourceId: args.source_id || null },
1711
+ );
1712
+ return {
1713
+ success: true,
1714
+ output: JSON.stringify(skill, null, 2),
1715
+ skill,
1716
+ _tool: 'skill_view',
1717
+ };
1718
+ },
1719
+
1720
+ skill_install: async (args) => {
1721
+ const result = installer.install(args.source, {
1722
+ scope: skillScope(args),
1723
+ force: Boolean(args.force),
1724
+ });
1725
+ const skills = reloadSkillCatalog();
1726
+ const payload = { ...result, skills };
1727
+ return {
1728
+ success: true,
1729
+ output: JSON.stringify(payload, null, 2),
1730
+ ...payload,
1731
+ _tool: 'skill_install',
1732
+ };
1733
+ },
1734
+
1735
+ skill_update: async (args) => {
1736
+ const result = installer.update(args.name, { scope: skillScope(args) });
1737
+ const skills = reloadSkillCatalog();
1738
+ const payload = { ...result, skills };
1739
+ return {
1740
+ success: true,
1741
+ output: JSON.stringify(payload, null, 2),
1742
+ ...payload,
1743
+ _tool: 'skill_update',
1744
+ };
1745
+ },
1746
+
1747
+ skill_remove: async (args) => {
1748
+ const result = installer.remove(args.name, { scope: skillScope(args) });
1749
+ const skills = reloadSkillCatalog();
1750
+ const payload = { ...result, skills };
1751
+ return {
1752
+ success: true,
1753
+ output: JSON.stringify(payload, null, 2),
1754
+ ...payload,
1755
+ _tool: 'skill_remove',
1756
+ };
1757
+ },
1758
+
1759
+ // User-defined agents — metadata first, project YAML + backend sync on demand.
1760
+ agents_list: async (args = {}) => {
1761
+ const agents = filterLocalAgents(args).map(compactAgentMetadata);
1762
+ const payload = { agents, count: agents.length };
1763
+ return {
1764
+ success: true,
1765
+ output: JSON.stringify(payload, null, 2),
1766
+ ...payload,
1767
+ _tool: 'agents_list',
1768
+ };
1769
+ },
1770
+
1771
+ agent_create: async (args = {}) => {
1772
+ if (!args.name || !String(args.name).trim()) {
1773
+ throw new Error('name is required');
1774
+ }
1775
+ const result = createAgentFile({
1776
+ cwd: process.cwd(),
1777
+ name: args.name,
1778
+ description: args.description || '',
1779
+ role: args.role || 'specialist',
1780
+ model: args.model || '',
1781
+ tools: normalizeAgentTools(args.tools),
1782
+ prompt: args.system_prompt || args.prompt || '',
1783
+ force: Boolean(args.force),
1784
+ });
1785
+ const created = listLocalAgents(process.cwd()).find(agent => agent.slug === result.slug);
1786
+ const payload = {
1787
+ ...result,
1788
+ agent: created
1789
+ ? { ...compactAgentMetadata(created), spec: created.spec }
1790
+ : null,
1791
+ next_actions: [
1792
+ `Edit ${result.filePath}`,
1793
+ `Run /agents sync ${result.slug} when ready`,
1794
+ ],
1795
+ };
1796
+ return {
1797
+ success: true,
1798
+ output: JSON.stringify(payload, null, 2),
1799
+ ...payload,
1800
+ _tool: 'agent_create',
1801
+ };
1802
+ },
1803
+
1804
+ agent_sync: async (args = {}) => {
1805
+ const selected = selectAgentsForSync(args);
1806
+ if (!selected.length) {
1807
+ const target = args.name || args.slug || '';
1808
+ throw new Error(target ? `No local agent found: ${target}` : 'No local agents found in .bahulam/agents');
1809
+ }
1810
+ const creds = new TarangAuth().loadCredentials();
1811
+ const result = await syncAgentsToBackend({
1812
+ backendUrl: creds.backendUrl,
1813
+ token: creds.token,
1814
+ agents: selected,
1815
+ });
1816
+ const payload = {
1817
+ ...result,
1818
+ agents: selected.map(compactAgentMetadata),
1819
+ };
1820
+ return {
1821
+ success: true,
1822
+ output: JSON.stringify(payload, null, 2),
1823
+ ...payload,
1824
+ _tool: 'agent_sync',
1825
+ };
1826
+ },
1827
+
1828
+ workflow_list: async (args = {}) => {
1829
+ const local = filterLocalWorkflows(args).map(compactWorkflowMetadata);
1830
+ let backend = [];
1831
+ try {
1832
+ const creds = new TarangAuth().loadCredentials();
1833
+ if (creds.backendUrl && creds.token) {
1834
+ const resp = await fetch(`${creds.backendUrl}${WORKFLOW_SYNC_ENDPOINT}`, {
1835
+ headers: {
1836
+ Authorization: `Bearer ${creds.token}`,
1837
+ Accept: 'application/json',
1838
+ },
1839
+ });
1840
+ if (resp.ok) {
1841
+ const payload = await resp.json().catch(() => ({}));
1842
+ backend = Array.isArray(payload.workflows) ? payload.workflows : [];
1843
+ }
1844
+ }
1845
+ } catch {
1846
+ // best effort
1847
+ }
1848
+ const payload = {
1849
+ local_workflows: local,
1850
+ backend_workflows: backend,
1851
+ count: local.length + backend.length,
1852
+ };
1853
+ return {
1854
+ success: true,
1855
+ output: JSON.stringify(payload, null, 2),
1856
+ ...payload,
1857
+ _tool: 'workflow_list',
1858
+ };
1859
+ },
1860
+
1861
+ workflow_create_multi: async (args = {}) => {
1862
+ if (!args.name || !String(args.name).trim()) {
1863
+ throw new Error('name is required');
1864
+ }
1865
+ const result = createWorkflowFile({
1866
+ cwd: process.cwd(),
1867
+ name: args.name,
1868
+ description: args.description || '',
1869
+ pattern: args.pattern || args.orchestration_pattern || 'sequential',
1870
+ agents: args.agents || [],
1871
+ edges: args.edges || [],
1872
+ globalParams: args.global_params || args.globalParams || {},
1873
+ force: Boolean(args.force),
1874
+ });
1875
+ const workflow = listLocalWorkflows(process.cwd()).find(item => item.slug === result.slug);
1876
+ const payload = {
1877
+ ...result,
1878
+ workflow: workflow ? compactWorkflowMetadata(workflow) : null,
1879
+ next_actions: [
1880
+ `Edit ${result.filePath}`,
1881
+ `Run workflow sync for ${result.slug} when ready`,
1882
+ ],
1883
+ };
1884
+ return {
1885
+ success: true,
1886
+ output: JSON.stringify(payload, null, 2),
1887
+ ...payload,
1888
+ _tool: 'workflow_create_multi',
1889
+ };
1890
+ },
1891
+
1892
+ workflow_sync_multi: async (args = {}) => {
1893
+ const selected = selectWorkflowsForSync(args);
1894
+ if (!selected.length) {
1895
+ const target = args.name || args.slug || '';
1896
+ throw new Error(target ? `No local workflow found: ${target}` : 'No local workflows found in .bahulam/workflows');
1897
+ }
1898
+ const creds = new TarangAuth().loadCredentials();
1899
+ if (!creds.backendUrl || !creds.token) {
1900
+ throw new Error('Not logged in. Run bahulam login first.');
1901
+ }
1902
+
1903
+ const headers = {
1904
+ Authorization: `Bearer ${creds.token}`,
1905
+ 'Content-Type': 'application/json',
1906
+ };
1907
+ const listResp = await fetch(`${creds.backendUrl}${WORKFLOW_SYNC_ENDPOINT}`, {
1908
+ headers: { Authorization: `Bearer ${creds.token}`, Accept: 'application/json' },
1909
+ });
1910
+ const backendPayload = listResp.ok ? await listResp.json().catch(() => ({})) : {};
1911
+ const existing = Array.isArray(backendPayload.workflows) ? backendPayload.workflows : [];
1912
+ const existingByName = new Map(existing.map(item => [String(item.name || '').toLowerCase(), item]));
1913
+
1914
+ const results = [];
1915
+ for (const workflow of selected) {
1916
+ const payload = {
1917
+ name: workflow.name,
1918
+ description: workflow.description || '',
1919
+ graph: workflow.graph,
1920
+ global_params: workflow.global_params || {},
1921
+ orchestration_pattern: workflow.pattern || workflow.orchestration_pattern || 'sequential',
1922
+ pattern: workflow.pattern || workflow.orchestration_pattern || 'sequential',
1923
+ };
1924
+ const existingWorkflow = existingByName.get(String(workflow.name || '').toLowerCase());
1925
+ const endpoint = existingWorkflow
1926
+ ? `${creds.backendUrl}${WORKFLOW_SYNC_ENDPOINT}/${encodeURIComponent(existingWorkflow.id)}`
1927
+ : `${creds.backendUrl}${WORKFLOW_SYNC_ENDPOINT}`;
1928
+ const method = existingWorkflow ? 'PATCH' : 'POST';
1929
+ const resp = await fetch(endpoint, {
1930
+ method,
1931
+ headers,
1932
+ body: JSON.stringify(payload),
1933
+ });
1934
+ if (!resp.ok) {
1935
+ let detail = '';
1936
+ try {
1937
+ const data = await resp.json();
1938
+ detail = data.detail || data.error || JSON.stringify(data);
1939
+ } catch {
1940
+ detail = await resp.text().catch(() => '');
1941
+ }
1942
+ throw new Error(`Workflow sync failed (${resp.status})${detail ? `: ${detail}` : ''}`);
1943
+ }
1944
+ const data = await resp.json().catch(() => ({}));
1945
+ results.push({
1946
+ workflow: compactWorkflowMetadata(workflow),
1947
+ action: existingWorkflow ? 'updated' : 'created',
1948
+ id: data?.workflow?.id || data?.id || existingWorkflow?.id || null,
1949
+ });
1950
+ }
1951
+
1952
+ const payload = {
1953
+ workflows: results,
1954
+ created: results.filter(item => item.action === 'created').length,
1955
+ updated: results.filter(item => item.action === 'updated').length,
1956
+ };
1957
+ return {
1958
+ success: true,
1959
+ output: JSON.stringify(payload, null, 2),
1960
+ ...payload,
1961
+ _tool: 'workflow_sync_multi',
1962
+ };
1963
+ },
1964
+
1965
+ workflow_run_multi: async (args = {}, options = {}) => {
1966
+ const target = String(args.workflow_id || args.workflowId || args.id || args.name || args.slug || '').trim();
1967
+ if (!target) {
1968
+ throw new Error('workflow_id is required');
1969
+ }
1970
+ const creds = new TarangAuth().loadCredentials();
1971
+ if (!creds.backendUrl || !creds.token) {
1972
+ throw new Error('Not logged in. Run bahulam login first.');
1973
+ }
1974
+
1975
+ const workflowId = await resolveWorkflowId(creds, target);
1976
+ if (!workflowId) {
1977
+ const localMatch = listLocalWorkflows(process.cwd()).find(workflow => workflowTargetMatches(workflow, target));
1978
+ if (localMatch) {
1979
+ throw new Error(`Workflow '${target}' exists locally but is not synced yet. Run workflow_sync_multi before workflow_run_multi.`);
1980
+ }
1981
+ throw new Error(`Workflow not found: ${target}`);
1982
+ }
1983
+
1984
+ const url = `${creds.backendUrl}/api/workflows/${encodeURIComponent(workflowId)}/run-multi`;
1985
+ const approvalAllowedTools = [
1986
+ 'get_project_overview',
1987
+ 'write_file',
1988
+ 'write_project',
1989
+ 'edit_file',
1990
+ 'shell',
1991
+ 'lint_check',
1992
+ 'validate_build',
1993
+ 'run_tests',
1994
+ 'agents_list',
1995
+ 'agent_create',
1996
+ 'agent_sync',
1997
+ 'workflow_list',
1998
+ 'workflow_create_multi',
1999
+ 'workflow_sync_multi',
2000
+ 'workflow_run_multi',
2001
+ ];
2002
+ const instruction = args.instruction || '';
2003
+ const projectResources = projectRegistry.resources();
2004
+ const workflowScope = buildWorkScope({
2005
+ instruction,
2006
+ cwd: process.cwd(),
2007
+ projectResources,
2008
+ });
2009
+ const suppliedGlobalParams = args.global_params || args.globalParams || {};
2010
+ const globalParams = {
2011
+ instruction,
2012
+ cwd: process.cwd(),
2013
+ project_root: process.cwd(),
2014
+ project_resources: projectResources,
2015
+ work_scope: workflowScope,
2016
+ ...suppliedGlobalParams,
2017
+ };
2018
+ const body = {
2019
+ trigger_input: {
2020
+ instruction,
2021
+ cwd: process.cwd(),
2022
+ project_root: process.cwd(),
2023
+ work_scope: globalParams.work_scope,
2024
+ },
2025
+ global_params: globalParams,
2026
+ orchestration_pattern: args.pattern || args.orchestration_pattern || 'sequential',
2027
+ pattern: args.pattern || args.orchestration_pattern || 'sequential',
2028
+ // Multi-agent workflow runs are SSE-only today. Do not forward
2029
+ // a model-supplied "sync" mode into the backend 400 path.
2030
+ mode: 'stream',
2031
+ approval_scope: {
2032
+ approved: true,
2033
+ source: 'cli_hitl',
2034
+ scope: 'workflow_run',
2035
+ workflow_id: workflowId,
2036
+ target,
2037
+ allowed_tools: approvalAllowedTools,
2038
+ allow_destructive: false,
2039
+ reason: 'User approved workflow execution',
2040
+ },
2041
+ };
2042
+
2043
+ const resp = await fetch(url, {
2044
+ method: 'POST',
2045
+ headers: {
2046
+ Authorization: `Bearer ${creds.token}`,
2047
+ Accept: 'text/event-stream',
2048
+ 'Content-Type': 'application/json',
2049
+ },
2050
+ body: JSON.stringify(body),
2051
+ signal: options.signal,
2052
+ });
2053
+
2054
+ if (!resp.ok) {
2055
+ let detail = '';
2056
+ try {
2057
+ const data = await resp.json();
2058
+ detail = data.detail || data.error || JSON.stringify(data);
2059
+ } catch {
2060
+ detail = await resp.text().catch(() => '');
2061
+ }
2062
+ throw new Error(`Workflow run failed (${resp.status})${detail ? `: ${detail}` : ''}`);
2063
+ }
2064
+
2065
+ const events = [];
2066
+ let final = null;
2067
+ let nestedToolCalls = 0;
2068
+ let callbacksPosted = 0;
2069
+ const workflowTaskId = resp.headers.get('X-Task-ID') || resp.headers.get('X-Workflow-Run-ID');
2070
+ for await (const event of streamResponse(resp)) {
2071
+ events.push(event.type);
2072
+ if (event.type === 'tool_call' || event.type === 'tool_request') {
2073
+ if (event.server_side || event.data?.server_side) continue;
2074
+ const toolName = event.tool || event.name || event.data?.tool || event.data?.name;
2075
+ const callId = event.call_id || event.id || event.data?.call_id || event.data?.id;
2076
+ const toolArgs = event.args || event.input || event.data?.args || event.data?.input || {};
2077
+ if (!workflowTaskId || !callId || !toolName) {
2078
+ throw new Error(`Workflow tool call missing callback metadata: ${JSON.stringify(event).slice(0, 300)}`);
2079
+ }
2080
+ if (toolName === 'workflow_run_multi') {
2081
+ const nestedResult = {
2082
+ success: false,
2083
+ output: 'Nested workflow_run_multi is not allowed inside a workflow run.',
2084
+ };
2085
+ await sendCallback(creds.backendUrl, creds.token, workflowTaskId, callId, nestedResult);
2086
+ throw new Error(nestedResult.output);
2087
+ }
2088
+ nestedToolCalls++;
2089
+ const result = await executeToolWithHooks(toolName, toolArgs, {
2090
+ ...options,
2091
+ workflowRun: true,
2092
+ workflowId,
2093
+ workflowTaskId,
2094
+ });
2095
+ const posted = await sendCallback(creds.backendUrl, creds.token, workflowTaskId, callId, result);
2096
+ if (!posted) {
2097
+ throw new Error(`Workflow tool callback failed for ${toolName}`);
2098
+ }
2099
+ callbacksPosted++;
2100
+ } else if (event.type === 'approval_required') {
2101
+ const toolId = event.tool_id || event.id || event.data?.tool_id || event.data?.id;
2102
+ const toolName = event.tool || event.data?.tool || 'tool';
2103
+ if (workflowTaskId && toolId) {
2104
+ await sendApprovalDecision(
2105
+ creds.backendUrl,
2106
+ creds.token,
2107
+ workflowTaskId,
2108
+ toolId,
2109
+ 'deny',
2110
+ 'once',
2111
+ 'Workflow run approval scope did not include this operation',
2112
+ );
2113
+ }
2114
+ throw new Error(`Workflow requested additional approval for ${toolName}; the upfront workflow-run approval scope did not cover it.`);
2115
+ } else if (event.type === 'orchestration_complete') {
2116
+ final = event;
2117
+ } else if (event.type === 'run_error') {
2118
+ const detail = event.error || event.data?.error || event.message || 'Workflow run failed';
2119
+ throw new Error(detail);
2120
+ }
2121
+ }
2122
+
2123
+ const payload = {
2124
+ workflow_id: workflowId,
2125
+ target,
2126
+ pattern: body.pattern,
2127
+ run_id: final?.run_id || null,
2128
+ result: final?.result || final?.data?.result || '',
2129
+ total_tokens: final?.total_tokens || 0,
2130
+ total_cost: final?.total_cost || 0,
2131
+ duration_s: final?.duration_s || 0,
2132
+ agent_count: final?.agent_count || 0,
2133
+ events_seen: events.length,
2134
+ nested_tool_calls: nestedToolCalls,
2135
+ callbacks_posted: callbacksPosted,
2136
+ };
2137
+
2138
+ return {
2139
+ success: true,
2140
+ output: JSON.stringify(payload, null, 2),
2141
+ ...payload,
2142
+ _tool: 'workflow_run_multi',
2143
+ };
2144
+ },
2145
+ };
2146
+
2147
+ return {
2148
+ /**
2149
+ * Execute a Tarang tool by name.
2150
+ * @param {string} name - Tarang tool name
2151
+ * @param {Object} args - Tool arguments
2152
+ * @returns {Promise<Object>} - { success, output, ... }
2153
+ */
2154
+ async execute(name, args, options = {}) {
2155
+ return executeToolWithHooks(name, args, options);
2156
+ },
2157
+
2158
+ /** List all available tool names. */
2159
+ listTools() {
2160
+ return Object.keys(toolMap);
2161
+ },
2162
+
2163
+ getProjectResources() {
2164
+ return projectRegistry.resources();
2165
+ },
2166
+
2167
+ async registerProjectRoots(roots, { forceRefresh = false } = {}) {
2168
+ const results = [];
2169
+ const seen = new Set();
2170
+ for (const root of Array.isArray(roots) ? roots : []) {
2171
+ if (!root || seen.has(root)) continue;
2172
+ seen.add(root);
2173
+ try {
2174
+ // CLI-startup roots are declared by the user (via cwd, flag,
2175
+ // or preflight) and should not be second-guessed by the
2176
+ // project-marker guard. That guard exists to stop the AGENT
2177
+ // from calling get_project_overview on non-project paths.
2178
+ const result = await projectRegistry.register(root, {
2179
+ forceRefresh,
2180
+ bypassProjectMarkers: true,
2181
+ });
2182
+ results.push({ success: true, root: result.resource.root, ...result });
2183
+ } catch (err) {
2184
+ results.push({ success: false, root, error: err.message });
2185
+ }
2186
+ }
2187
+ return results;
2188
+ },
2189
+
2190
+ getAgentContext() {
2191
+ const global = projectRegistry.getGlobalContext();
2192
+ return {
2193
+ identity: global.identity,
2194
+ preferences: global.preferences,
2195
+ global_skills: skillsLoader.list(),
2196
+ available_agents: listLocalAgents(process.cwd()).map(agent => ({
2197
+ slug: agent.slug,
2198
+ name: agent.name,
2199
+ description: agent.description,
2200
+ role: agent.role,
2201
+ model: agent.model,
2202
+ models: agent.models,
2203
+ tools: agent.tools,
2204
+ capabilities: agent.capabilities,
2205
+ domains: agent.domains,
2206
+ source_scope: agent.source_scope,
2207
+ spec: agent.spec,
2208
+ })),
2209
+ available_workflows: listLocalWorkflows(process.cwd()).map(workflow => ({
2210
+ slug: workflow.slug,
2211
+ name: workflow.name,
2212
+ description: workflow.description || '',
2213
+ pattern: workflow.pattern || workflow.orchestration_pattern || 'sequential',
2214
+ agent_count: workflow.agent_count || 0,
2215
+ edge_count: workflow.edge_count || 0,
2216
+ source_scope: 'project',
2217
+ })),
2218
+ source: 'cli',
2219
+ };
2220
+ },
2221
+
2222
+ reloadSkills(cwd = process.cwd()) {
2223
+ skillsLoader.load(cwd);
2224
+ return skillsLoader.list();
2225
+ },
2226
+
2227
+ resetProjects() {
2228
+ projectRegistry.reset();
2229
+ },
2230
+ };
2231
+ }