@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,957 @@
1
+ /**
2
+ * TarangStreamClient — SSE consumer for Tarang backend.
3
+ *
4
+ * Replaces OCC's agent-loop.mjs. Instead of calling the LLM API directly,
5
+ * this client POSTs to the Tarang backend, parses the SSE stream, intercepts
6
+ * tool_request/tool_call events (executes locally, POSTs callback), and
7
+ * yields all other events to the caller for rendering.
8
+ *
9
+ * Phase 2: handles all 22 event types. Approval flow integrated.
10
+ */
11
+
12
+ import { llmToolResultContent, sendCallback, sendSkippedCallback, sendApprovalDecision } from './callback-client.mjs';
13
+ import { ApprovalManager } from './approval.mjs';
14
+ import { normalizeBillingBrandCopy, quotaErrorDetail, rateLimitErrorMessage } from './rate-limit-display.mjs';
15
+ import * as telemetry from '../telemetry/index.mjs';
16
+
17
+ export const EVENT_TYPES = Object.freeze({
18
+ // Phase 1 — handled
19
+ STATUS: 'status',
20
+ TOOL_REQUEST: 'tool_request',
21
+ TOOL_CALL: 'tool_call',
22
+ PLAN: 'plan',
23
+ ERROR: 'error',
24
+ COMPLETE: 'complete',
25
+ // Phase 2 — stubbed
26
+ SESSION_INFO: 'session_info',
27
+ TOOL_DONE: 'tool_done',
28
+ THINKING: 'thinking',
29
+ PHASE_UPDATE: 'phase_update',
30
+ PHASE_SUMMARY: 'phase_summary',
31
+ PHASE_START: 'phase_start',
32
+ WORKER_UPDATE: 'worker_update',
33
+ WORKER_START: 'worker_start',
34
+ WORKER_DONE: 'worker_done',
35
+ DELEGATION: 'delegation',
36
+ CHANGE: 'change',
37
+ CONTENT: 'content',
38
+ CONTENT_PARTIAL: 'content_partial',
39
+ TOOL_RESULT: 'tool_result',
40
+ FILE_DIFF: 'file_diff',
41
+ SUB_AGENT_START: 'sub_agent_start',
42
+ SUB_AGENT_TOOL: 'sub_agent_tool',
43
+ SUB_AGENT_COMPLETE: 'sub_agent_complete',
44
+ STAGNATION: 'stagnation',
45
+ CANCELLED: 'cancelled',
46
+ PAUSED: 'paused',
47
+ RESUMED: 'resumed',
48
+ PAUSE_INSTRUCTION: 'pause_instruction',
49
+ RECONNECTING: 'reconnecting',
50
+ RECONNECTED: 'reconnected',
51
+ RECONNECT_FAILED: 'reconnect_failed',
52
+ // HITL approval events (from framework)
53
+ APPROVAL_REQUIRED: 'approval_required',
54
+ APPROVAL_GRANTED: 'approval_granted',
55
+ APPROVAL_DENIED: 'approval_denied',
56
+ // Live steering (PRD-081 §5.2)
57
+ USER_INTERVENTION_ACCEPTED: 'user_intervention_accepted',
58
+ USER_INTERVENTION_DELIVERED: 'user_intervention_delivered',
59
+ USER_INTERVENTION_QUEUED: 'user_intervention_queued',
60
+ });
61
+
62
+ // Lightweight UUID-ish generator for intervention ids. Node ≥18 has
63
+ // crypto.randomUUID but the fallback avoids importing node:crypto here.
64
+ function _uuidLike() {
65
+ try {
66
+ // eslint-disable-next-line no-undef
67
+ if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
68
+ return globalThis.crypto.randomUUID();
69
+ }
70
+ } catch {}
71
+ const rand = () => Math.random().toString(16).slice(2, 10);
72
+ return `iv-${Date.now().toString(36)}-${rand()}${rand()}`;
73
+ }
74
+
75
+ function sleep(ms) {
76
+ return new Promise(resolve => setTimeout(resolve, ms));
77
+ }
78
+
79
+ // Full jitter around the scheduled delay: pick a value in [delay*0.5, delay*1.5].
80
+ // Spreads out reconnect storms so N clients dropping simultaneously don't
81
+ // synchronize their retries. Clamped to the same 30s ceiling as the base delay.
82
+ function jitteredDelay(delayMs) {
83
+ const min = Math.floor(delayMs * 0.5);
84
+ const max = Math.floor(delayMs * 1.5);
85
+ const jittered = min + Math.floor(Math.random() * (max - min + 1));
86
+ return Math.max(200, Math.min(30_000, jittered));
87
+ }
88
+
89
+ // Categorize a fetch error. "offline-class" means DNS/routing failures that
90
+ // almost never recover within a few seconds — we bail after 2 in a row rather
91
+ // than burn the full 5-min budget when the user's network is truly down.
92
+ function isOfflineLikelyError(err) {
93
+ const code = err?.cause?.code || err?.code || '';
94
+ return (
95
+ code === 'ENOTFOUND' || // DNS lookup failed
96
+ code === 'EAI_AGAIN' || // DNS temporary failure
97
+ code === 'ENETUNREACH' || // no route to host
98
+ code === 'EHOSTUNREACH' || // host unreachable
99
+ code === 'UND_ERR_CONNECT_TIMEOUT' // undici connect timeout
100
+ );
101
+ }
102
+
103
+ export class TarangStreamClient {
104
+ /**
105
+ * @param {Object} opts
106
+ * @param {string} opts.baseUrl - Tarang backend URL (ignored when mode='bundled')
107
+ * @param {string} opts.token - CLI auth token
108
+ * @param {Object} opts.toolExecutor - { execute(name, args) }
109
+ * @param {boolean} [opts.verbose=false]
110
+ * @param {'remote'|'bundled'} [opts.mode='remote'] - 'bundled' routes all
111
+ * requests through the local bahulam-agent runtime subprocess (PRD-091 §6).
112
+ * 'remote' uses baseUrl and standard fetch (default; matches cloud/dev/prod backend).
113
+ */
114
+ constructor({
115
+ baseUrl,
116
+ token,
117
+ toolExecutor,
118
+ verbose = false,
119
+ approvalManager = null,
120
+ reconnectMaxElapsedMs = null,
121
+ mode = null,
122
+ }) {
123
+ this.baseUrl = (baseUrl || '').replace(/\/$/, '');
124
+ this.token = token;
125
+ this.toolExecutor = toolExecutor;
126
+ this.verbose = verbose;
127
+ this.approval = approvalManager || new ApprovalManager();
128
+ this.product = 'bahulam';
129
+ this.currentTaskId = null;
130
+ this.lastEventId = null;
131
+ this.retryDelayMs = null;
132
+ this.pendingToolCallbacks = new Map();
133
+ this.reconnectMaxElapsedMs = reconnectMaxElapsedMs
134
+ ?? Number(process.env.KEPLER_RECONNECT_MAX_ELAPSED_MS || 300_000);
135
+ // Set by backend on first turn, reused on subsequent turns. Headless mode
136
+ // (which starts fresh per invocation) can pre-seed via TARANG_SESSION_ID
137
+ // so multi-turn benchmarks share one backend session across `node` runs.
138
+ this.sessionId = process.env.TARANG_SESSION_ID || null;
139
+ this._cancelled = false;
140
+ this._paused = false;
141
+ this._pauseWaiters = new Set();
142
+ this._abort = null;
143
+ this._toolAbort = null;
144
+
145
+ // Transport mode:
146
+ // 'bundled' → local Python runtime (PRD-091 §6). Framework calls
147
+ // the Bahulam Gateway directly. Metering runs. THIS IS THE
148
+ // PUBLIC CLI DEFAULT.
149
+ // 'remote' → cloud backend runs the agent loop server-side.
150
+ // Backend calls Bahulam Gateway with service-token attribution;
151
+ // metering still runs at the gateway boundary.
152
+ //
153
+ // Explicit opt precedence: constructor arg > env vars > sniff runtime
154
+ // package availability > default 'bundled'.
155
+ this.mode = mode
156
+ || (process.env.BAHULAM_RUNTIME_MODE === 'remote' ? 'remote' : null)
157
+ || (process.env.BAHULAM_RUNTIME_MODE === 'bundled' ? 'bundled' : null)
158
+ || (process.env.TARANG_ENV === 'remote' ? 'remote' : null)
159
+ || (process.env.TARANG_ENV === 'bundled' ? 'bundled' : null)
160
+ || 'bundled';
161
+ // Bundled runtime binds to a random localhost port on first use. Cached
162
+ // here so every method sees the same baseUrl without re-spawning.
163
+ this._bundledReady = false;
164
+ }
165
+
166
+ /**
167
+ * Ensure the bundled runtime is spawned and this.baseUrl points at it.
168
+ * No-op in remote mode. Callers that hit the backend should invoke this
169
+ * at the top of their method (idempotent, cheap after the first call).
170
+ */
171
+ async _ensureBundledRuntime() {
172
+ if (this.mode !== 'bundled' || this._bundledReady) return;
173
+ const { ensureRuntimeReady } = await import('./bundled-runtime.mjs');
174
+ const info = await ensureRuntimeReady();
175
+ // Runtime speaks the same HTTP shape as the cloud backend, just on a
176
+ // random localhost port. Overriding baseUrl means every existing
177
+ // ${this.baseUrl}/api/* fetch call keeps working unchanged.
178
+ this.baseUrl = info.baseUrl;
179
+ this._bundledReady = true;
180
+ }
181
+
182
+ _headers(extra = {}) {
183
+ const headers = { ...extra };
184
+ if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
185
+ if (this.product) headers['X-Product'] = this.product;
186
+ return headers;
187
+ }
188
+
189
+ async analyzeVision({ instruction, attachments }) {
190
+ const url = `${this.baseUrl}/api/vision/analyze`;
191
+ const body = { instruction, attachments: attachments || [] };
192
+ const response = await fetch(url, {
193
+ method: 'POST',
194
+ headers: this._headers({ 'Content-Type': 'application/json' }),
195
+ body: JSON.stringify(body),
196
+ });
197
+ const text = await response.text().catch(() => '');
198
+ let payload = null;
199
+ try {
200
+ payload = text ? JSON.parse(text) : null;
201
+ } catch {
202
+ payload = { detail: text };
203
+ }
204
+ if (!response.ok) {
205
+ const detail = payload?.detail || payload || {};
206
+ const message = typeof detail === 'string'
207
+ ? detail
208
+ : detail.message || detail.error || `Vision analysis failed (${response.status})`;
209
+ const err = new Error(message);
210
+ err.status = response.status;
211
+ err.detail = detail;
212
+ throw err;
213
+ }
214
+ return payload || {};
215
+ }
216
+
217
+ /**
218
+ * Execute an instruction via SSE stream.
219
+ * Yields parsed events. Client-side tool requests are shown, executed
220
+ * locally, callback-posted, then followed by a local tool_result event.
221
+ *
222
+ * @param {string} instruction
223
+ * @param {Object} [context={}]
224
+ * @param {string} [model]
225
+ * @yields {{ type: string, data: Object }}
226
+ */
227
+ async *execute(instruction, context = {}, messages = null) {
228
+ this._cancelled = false;
229
+ this.currentTaskId = null;
230
+
231
+ // Bundled mode: spawn the local Python runtime on first turn.
232
+ // After this, this.baseUrl points at http://127.0.0.1:<random-port>
233
+ // and every existing fetch call in this class works unchanged.
234
+ await this._ensureBundledRuntime();
235
+
236
+ const url = `${this.baseUrl}/api/execute`;
237
+ const body = { instruction, context };
238
+ if (messages && messages.length > 0) body.messages = messages;
239
+ if (this.sessionId) body.session_id = this.sessionId;
240
+
241
+ const headers = this._headers({
242
+ 'Accept': 'text/event-stream',
243
+ 'Content-Type': 'application/json',
244
+ });
245
+
246
+ // Abort controller so cancel() can break out of a stalled reader
247
+ // instead of waiting for the next SSE event to notice _cancelled.
248
+ this._abort = new AbortController();
249
+ this._toolAbort = new AbortController();
250
+
251
+ let response;
252
+ try {
253
+ response = await fetch(url, {
254
+ method: 'POST',
255
+ headers,
256
+ body: JSON.stringify(body),
257
+ signal: this._abort.signal,
258
+ });
259
+ } catch (err) {
260
+ if (err.name === 'AbortError') {
261
+ return;
262
+ }
263
+ yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
264
+ return;
265
+ }
266
+
267
+ if (response.status === 401) {
268
+ yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam login` to re-authenticate.', fatal: true } };
269
+ return;
270
+ }
271
+ if (response.status === 429) {
272
+ const text = await response.text().catch(() => '');
273
+ let payload = null;
274
+ try {
275
+ payload = text ? JSON.parse(text) : null;
276
+ } catch {
277
+ payload = { detail: { message: text } };
278
+ }
279
+ const detail = quotaErrorDetail(payload);
280
+ yield {
281
+ type: EVENT_TYPES.ERROR,
282
+ data: {
283
+ message: rateLimitErrorMessage(payload),
284
+ code: detail?.code || 'rate_limited',
285
+ retry_after: detail?.retry_after ?? detail?.rate_limit?.retry_after,
286
+ rate_limit: detail?.rate_limit || null,
287
+ action: detail?.action || null,
288
+ pricing_url: normalizeBillingBrandCopy(detail?.pricing_url || null),
289
+ fatal: true,
290
+ },
291
+ };
292
+ return;
293
+ }
294
+ if (!response.ok) {
295
+ const text = await response.text().catch(() => 'Unknown error');
296
+ yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
297
+ return;
298
+ }
299
+
300
+ try {
301
+ yield* this._consumeResponse(response);
302
+ } catch (err) {
303
+ if (this._cancelled) {
304
+ return;
305
+ }
306
+ yield* this._reconnectAfterDrop(err);
307
+ }
308
+ }
309
+
310
+ async *_consumeResponse(response) {
311
+ const taskId = response.headers.get('X-Task-ID');
312
+ if (taskId) this.currentTaskId = taskId;
313
+
314
+ for await (const parsed of this._parseSSE(response)) {
315
+ if (this._cancelled) return;
316
+ await this._waitIfPaused();
317
+ if (this._cancelled) return;
318
+
319
+ if (parsed.id != null) this.lastEventId = parsed.id;
320
+ if (parsed.retry != null) this.retryDelayMs = parsed.retry;
321
+ if (parsed.data?.task_id) this.currentTaskId = parsed.data.task_id;
322
+
323
+ for await (const event of this._handleStreamEvent(parsed)) {
324
+ yield event;
325
+ }
326
+ }
327
+ }
328
+
329
+ async *consumeEventStream(response) {
330
+ yield* this._consumeResponse(response);
331
+ }
332
+
333
+ async *_handleStreamEvent({ event, data, id = null, retry = null }) {
334
+ const rendered = { type: event, data, event_id: id, retry };
335
+
336
+ // Capture session_id from backend (first turn creates it, subsequent turns reuse)
337
+ if (event === EVENT_TYPES.SESSION_INFO && data?.session_id) {
338
+ this.sessionId = data.session_id;
339
+ }
340
+
341
+ // Framework HITL: approval_required — show menu, POST decision
342
+ if (event === EVENT_TYPES.APPROVAL_REQUIRED) {
343
+ yield rendered;
344
+ const approvalEvent = await this._handleApprovalRequired(data);
345
+ if (approvalEvent) yield approvalEvent;
346
+ return;
347
+ }
348
+
349
+ if (event === EVENT_TYPES.APPROVAL_GRANTED || event === EVENT_TYPES.APPROVAL_DENIED) {
350
+ yield rendered;
351
+ return;
352
+ }
353
+
354
+ // Tool requests — show to user, then execute locally and POST callback.
355
+ if (event === EVENT_TYPES.TOOL_REQUEST || event === EVENT_TYPES.TOOL_CALL) {
356
+ yield rendered;
357
+ await this._waitIfPaused();
358
+ if (this._cancelled || data?.server_side) return;
359
+ const toolEvent = await this._handleToolRequest(data);
360
+ if (toolEvent) {
361
+ await this._waitIfPaused();
362
+ if (this._cancelled) return;
363
+ yield toolEvent;
364
+ for (const diffEvent of fileDiffEventsForToolResult(toolEvent)) {
365
+ yield diffEvent;
366
+ }
367
+ }
368
+ return;
369
+ }
370
+
371
+ yield rendered;
372
+ if (event === EVENT_TYPES.TOOL_RESULT || event === EVENT_TYPES.TOOL_DONE) {
373
+ for (const diffEvent of fileDiffEventsForToolResult(rendered)) {
374
+ yield diffEvent;
375
+ }
376
+ }
377
+ }
378
+
379
+ async *_reconnectAfterDrop(err) {
380
+ const taskId = this.currentTaskId;
381
+ if (!taskId || this.lastEventId == null) {
382
+ yield {
383
+ type: EVENT_TYPES.ERROR,
384
+ data: {
385
+ message: `Stream interrupted: ${err?.message || 'connection lost'}. Use /resume to continue from saved history.`,
386
+ fatal: true,
387
+ },
388
+ };
389
+ return;
390
+ }
391
+
392
+ const started = Date.now();
393
+ let attempt = 0;
394
+ // Slow-start baseline: 500ms for the first retry catches most
395
+ // transient blips before the user notices; the ramp doubles from
396
+ // there. Server-hinted `retry:` from the last SSE frame overrides
397
+ // if present. Capped at 30s.
398
+ let delayMs = Math.max(200, Math.min(this.retryDelayMs || 500, 30_000));
399
+ let offlineStreak = 0;
400
+
401
+ while (!this._cancelled && Date.now() - started < this.reconnectMaxElapsedMs) {
402
+ attempt++;
403
+ const after = this.lastEventId;
404
+ // Apply jitter to the scheduled delay so N clients dropping at
405
+ // the same time don't lockstep-retry against the server. The
406
+ // event surfaces the ACTUAL wait so the CLI can show it.
407
+ const waitMs = jitteredDelay(delayMs);
408
+ telemetry.track('stream.reconnect.attempt', {
409
+ task_id: taskId,
410
+ after,
411
+ attempt,
412
+ base_delay_ms: delayMs,
413
+ delay_ms: waitMs,
414
+ });
415
+ yield {
416
+ type: EVENT_TYPES.RECONNECTING,
417
+ data: { task_id: taskId, after, attempt, delay_ms: waitMs },
418
+ };
419
+ await sleep(waitMs);
420
+ try {
421
+ const url = `${this.baseUrl}/api/execute/${encodeURIComponent(taskId)}/events?after=${encodeURIComponent(after)}`;
422
+ const response = await fetch(url, {
423
+ method: 'GET',
424
+ headers: this._headers({ Accept: 'text/event-stream' }),
425
+ signal: AbortSignal.timeout(30_000),
426
+ });
427
+ if (response.status === 404 || response.status === 410) {
428
+ telemetry.track('stream.reconnect.failed', {
429
+ task_id: taskId,
430
+ after,
431
+ attempt,
432
+ status: response.status,
433
+ code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
434
+ retryable: false,
435
+ });
436
+ yield {
437
+ type: EVENT_TYPES.RECONNECT_FAILED,
438
+ data: {
439
+ task_id: taskId,
440
+ code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
441
+ retryable: false,
442
+ },
443
+ };
444
+ return;
445
+ }
446
+ if (!response.ok) throw new Error(`reconnect failed (${response.status})`);
447
+ let replayed = 0;
448
+ for await (const event of this._consumeResponse(response)) {
449
+ if (event.type !== EVENT_TYPES.RECONNECTED) replayed++;
450
+ if (event.type === EVENT_TYPES.RECONNECTED) {
451
+ replayed = Number(event.data?.replayed ?? replayed);
452
+ }
453
+ yield event;
454
+ }
455
+ telemetry.track('stream.reconnect.succeeded', {
456
+ task_id: taskId,
457
+ after,
458
+ attempt,
459
+ replayed,
460
+ elapsed_ms: Date.now() - started,
461
+ });
462
+ return;
463
+ } catch (nextErr) {
464
+ if (this._cancelled) return;
465
+ // Offline-class errors (DNS failure, no route) rarely recover
466
+ // in seconds. Bail after 2 in a row so we don't burn the full
467
+ // 5-min budget spinning against a dead network — the user
468
+ // gets an actionable "network unreachable" message instead.
469
+ if (isOfflineLikelyError(nextErr)) {
470
+ offlineStreak++;
471
+ if (offlineStreak >= 2) {
472
+ telemetry.track('stream.reconnect.failed', {
473
+ task_id: taskId,
474
+ after,
475
+ attempt,
476
+ code: 'network_unreachable',
477
+ error_code: nextErr?.cause?.code || nextErr?.code || '',
478
+ retryable: true,
479
+ });
480
+ yield {
481
+ type: EVENT_TYPES.RECONNECT_FAILED,
482
+ data: {
483
+ task_id: taskId,
484
+ after,
485
+ code: 'network_unreachable',
486
+ message: 'Network appears unreachable — DNS lookup failed twice. Check your connection, then /resume to continue.',
487
+ retryable: true,
488
+ },
489
+ };
490
+ return;
491
+ }
492
+ } else {
493
+ offlineStreak = 0;
494
+ }
495
+ telemetry.track('stream.reconnect.retry', {
496
+ task_id: taskId,
497
+ after,
498
+ attempt,
499
+ message: nextErr?.message || 'reconnect failed',
500
+ error_code: nextErr?.cause?.code || nextErr?.code || '',
501
+ });
502
+ err = nextErr;
503
+ delayMs = Math.min(delayMs * 2, 30_000);
504
+ }
505
+ }
506
+
507
+ telemetry.track('stream.reconnect.failed', {
508
+ task_id: taskId,
509
+ after: this.lastEventId,
510
+ code: 'reconnect_timeout',
511
+ retryable: false,
512
+ elapsed_ms: Date.now() - started,
513
+ });
514
+ yield {
515
+ type: EVENT_TYPES.RECONNECT_FAILED,
516
+ data: {
517
+ task_id: taskId,
518
+ after: this.lastEventId,
519
+ code: 'reconnect_timeout',
520
+ message: err?.message || 'connection lost',
521
+ retryable: false,
522
+ },
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Parse SSE from a fetch Response using ReadableStream.
528
+ * @param {Response} response
529
+ * @yields {{ event: string, data: Object, id: string|null, retry: number|null }}
530
+ */
531
+ async *_parseSSE(response) {
532
+ const reader = response.body.getReader();
533
+ const decoder = new TextDecoder();
534
+ let buffer = '';
535
+ let currentEvent = 'message';
536
+ let currentData = [];
537
+ let currentId = null;
538
+ let currentRetry = null;
539
+
540
+ try {
541
+ while (true) {
542
+ let read;
543
+ try {
544
+ read = await reader.read();
545
+ } catch (err) {
546
+ // Aborted via cancel() — treat as a clean end-of-stream.
547
+ if (err && (err.name === 'AbortError' || this._cancelled)) break;
548
+ throw err;
549
+ }
550
+ const { done, value } = read;
551
+ if (done) break;
552
+
553
+ buffer += decoder.decode(value, { stream: true });
554
+ const lines = buffer.split('\n');
555
+ buffer = lines.pop(); // keep incomplete last line
556
+
557
+ for (const line of lines) {
558
+ const rawLine = line.endsWith('\r') ? line.slice(0, -1) : line;
559
+ const trimmed = rawLine.trim();
560
+
561
+ if (!trimmed) {
562
+ // Empty line = event boundary
563
+ if (currentData.length > 0) {
564
+ const rawData = currentData.join('\n');
565
+ let parsed;
566
+ try {
567
+ parsed = JSON.parse(rawData);
568
+ } catch {
569
+ parsed = { message: rawData };
570
+ }
571
+ yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
572
+ }
573
+ currentEvent = 'message';
574
+ currentData = [];
575
+ currentId = null;
576
+ currentRetry = null;
577
+ } else if (trimmed.startsWith('event:')) {
578
+ currentEvent = trimmed.slice(6).trim();
579
+ } else if (trimmed.startsWith('data:')) {
580
+ currentData.push(trimmed.slice(5).trim());
581
+ } else if (trimmed.startsWith('id:')) {
582
+ currentId = trimmed.slice(3).trim();
583
+ } else if (trimmed.startsWith('retry:')) {
584
+ const parsedRetry = Number(trimmed.slice(6).trim());
585
+ if (Number.isFinite(parsedRetry) && parsedRetry >= 0) currentRetry = parsedRetry;
586
+ }
587
+ // comments and unknown SSE fields are ignored.
588
+ }
589
+ }
590
+
591
+ // Flush remaining
592
+ if (currentData.length > 0) {
593
+ const rawData = currentData.join('\n');
594
+ let parsed;
595
+ try {
596
+ parsed = JSON.parse(rawData);
597
+ } catch {
598
+ parsed = { message: rawData };
599
+ }
600
+ yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
601
+ }
602
+ } finally {
603
+ reader.releaseLock();
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Handle a tool_call event: execute tool locally, POST result via callback.
609
+ *
610
+ * Approval is handled by the framework (HITL) BEFORE this is called.
611
+ * By the time a tool_call arrives here, it's already approved.
612
+ *
613
+ * @param {Object} data - { call_id, tool, args }
614
+ * @returns {Object} local tool result event to yield
615
+ */
616
+ async _handleToolRequest(data) {
617
+ const { call_id, request_id, tool, args } = data;
618
+ const callId = call_id || request_id;
619
+ const toolName = tool;
620
+ const isInternal = Boolean(data?.internal || data?.sub_agent);
621
+
622
+ if (this.verbose) {
623
+ process.stderr.write(`\x1b[2m[tool] ${toolName}(${JSON.stringify(args).slice(0, 80)}...)\x1b[0m\n`);
624
+ }
625
+
626
+ if (this._cancelled) {
627
+ return {
628
+ type: EVENT_TYPES.TOOL_RESULT,
629
+ data: {
630
+ success: false,
631
+ output: 'Cancelled by user',
632
+ call_id: callId,
633
+ tool: toolName,
634
+ args: args || {},
635
+ _cancelled: true,
636
+ internal: isInternal,
637
+ sub_agent: data?.sub_agent || null,
638
+ local_callback: false,
639
+ },
640
+ };
641
+ }
642
+
643
+ // Execute tool locally — framework already approved this
644
+ const startTime = Date.now();
645
+ let result;
646
+ try {
647
+ result = await this.toolExecutor.execute(toolName, args || {}, {
648
+ signal: this._toolAbort?.signal,
649
+ });
650
+ } catch (err) {
651
+ if (err?.name === 'AbortError' || this._cancelled) {
652
+ result = { success: false, output: 'Cancelled by user', _cancelled: true };
653
+ } else {
654
+ result = { success: false, output: `Tool execution error: ${err.message}` };
655
+ }
656
+ }
657
+ const durationMs = Date.now() - startTime;
658
+
659
+ if (this.verbose) {
660
+ const status = result.success ? 'OK' : 'FAIL';
661
+ process.stderr.write(`\x1b[2m[tool] ${toolName} → ${status} (${durationMs}ms)\x1b[0m\n`);
662
+ }
663
+
664
+ // POST callback to backend
665
+ let callbackPosted = false;
666
+ if (!this._cancelled && !result?._cancelled && this.currentTaskId && callId) {
667
+ callbackPosted = await sendCallback(this.baseUrl, this.token, this.currentTaskId, callId, result);
668
+ telemetry.track(callbackPosted ? 'tool.callback.posted' : 'tool.callback.failed', {
669
+ task_id: this.currentTaskId,
670
+ call_id: callId,
671
+ tool: toolName,
672
+ success: Boolean(result?.success !== false),
673
+ duration_ms: durationMs,
674
+ });
675
+ }
676
+
677
+ return {
678
+ type: EVENT_TYPES.TOOL_RESULT,
679
+ data: {
680
+ ...result,
681
+ call_id: callId,
682
+ tool: toolName,
683
+ args: args || {},
684
+ llm_content: llmToolResultContent(toolName, result),
685
+ duration_ms: durationMs,
686
+ internal: isInternal,
687
+ sub_agent: data?.sub_agent || null,
688
+ local_callback: true,
689
+ },
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Handle framework HITL approval_required event.
695
+ * Shows the same approval menu as tool_call, but POSTs the decision
696
+ * to /api/approval_callback instead of skipping the tool.
697
+ *
698
+ * @param {Object} data - { tool_id, tool, args, risk, reason }
699
+ * @returns {Object|null} optional status event to yield
700
+ */
701
+ async _handleApprovalRequired(data) {
702
+ const { tool_id, tool, args, risk, reason } = data;
703
+
704
+ if (this.verbose) {
705
+ process.stderr.write(`\x1b[2m[hitl] Approval needed: ${tool} (${risk})\x1b[0m\n`);
706
+ }
707
+
708
+ // Use the same ApprovalManager for consistent UX
709
+ const { approved, reason: denyReason, scope: approvedScope } = await this.approval.check(
710
+ tool,
711
+ args || {},
712
+ true,
713
+ { risk, reason },
714
+ );
715
+
716
+ // Map ApprovalManager decision to framework scope
717
+ let decision, scope;
718
+ if (approved) {
719
+ decision = 'grant';
720
+ // Determine scope from ApprovalManager state
721
+ if (this.approval.approveAll) {
722
+ scope = 'all';
723
+ } else if (this.approval.approvedToolTypes.has(tool)) {
724
+ scope = 'type';
725
+ } else if (approvedScope) {
726
+ scope = String(approvedScope).toLowerCase();
727
+ } else {
728
+ scope = 'once';
729
+ }
730
+ } else {
731
+ decision = 'deny';
732
+ scope = 'once';
733
+ }
734
+
735
+ // POST decision to backend
736
+ if (this.currentTaskId && tool_id) {
737
+ const posted = await sendApprovalDecision(
738
+ this.baseUrl, this.token, this.currentTaskId,
739
+ tool_id, decision, scope, denyReason || '',
740
+ );
741
+ telemetry.track(posted ? 'approval.callback.posted' : 'approval.callback.failed', {
742
+ task_id: this.currentTaskId,
743
+ tool_id,
744
+ tool,
745
+ decision,
746
+ });
747
+ if (!posted) {
748
+ return {
749
+ type: EVENT_TYPES.ERROR,
750
+ data: {
751
+ message: `Approval for ${tool} could not be applied. The request may have timed out or the stream may be stale; retry the command if it did not continue.`,
752
+ code: 'approval_callback_failed',
753
+ fatal: false,
754
+ },
755
+ };
756
+ }
757
+ }
758
+
759
+ if (!approved) {
760
+ return {
761
+ type: EVENT_TYPES.STATUS,
762
+ data: { message: `Denied ${tool}: ${denyReason || 'rejected'}` },
763
+ };
764
+ }
765
+
766
+ return null; // Approved — framework continues with tool execution
767
+ }
768
+
769
+ /** Cancel the current stream. */
770
+ async cancel() {
771
+ this._cancelled = true;
772
+ this._paused = false;
773
+ this._releasePauseWaiters();
774
+ // Stop local work first. The backend POST below is best-effort and
775
+ // must not delay returning control to the terminal.
776
+ if (this._toolAbort) {
777
+ try { this._toolAbort.abort(); } catch {}
778
+ }
779
+ if (this._abort) {
780
+ try { this._abort.abort(); } catch {}
781
+ }
782
+ // Best-effort backend POST — the stream may already be torn down.
783
+ if (this.currentTaskId) {
784
+ fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
785
+ method: 'POST',
786
+ headers: this._headers(),
787
+ }).catch(() => {});
788
+ }
789
+ }
790
+
791
+ /** Pause the current stream. */
792
+ async pause() {
793
+ this._paused = true;
794
+ if (this.currentTaskId) {
795
+ try {
796
+ await fetch(`${this.baseUrl}/api/pause/${this.currentTaskId}`, {
797
+ method: 'POST',
798
+ headers: this._headers(),
799
+ });
800
+ } catch { /* best effort */ }
801
+ }
802
+ }
803
+
804
+ /** Resume a paused stream. */
805
+ async resume(instruction = null) {
806
+ this._paused = false;
807
+ this._releasePauseWaiters();
808
+ if (this.currentTaskId) {
809
+ const body = instruction ? JSON.stringify({ instruction }) : undefined;
810
+ try {
811
+ await fetch(`${this.baseUrl}/api/resume/${this.currentTaskId}`, {
812
+ method: 'POST',
813
+ headers: this._headers({
814
+ 'Content-Type': 'application/json',
815
+ }),
816
+ body,
817
+ });
818
+ } catch { /* best effort */ }
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Submit a live-steering follow-up on the current running task (PRD-081 §5.2).
824
+ * Unlike resume(), this does NOT pause/unpause — the text is queued and
825
+ * delivered at the next tool boundary.
826
+ *
827
+ * Returns a status object; callers should NOT swallow errors:
828
+ * { status: 'accepted', interventionId } — queued on backend, SSE ack forthcoming
829
+ * { status: 'queued_next_turn', interventionId } — task already ended; hand back as next turn
830
+ * { status: 'duplicate', interventionId } — same intervention_id previously submitted
831
+ * { status: 'no_task' } — no currentTaskId (nothing to steer)
832
+ * { status: 'error', error, httpStatus? } — network or non-2xx response
833
+ *
834
+ * @param {string} instruction Follow-up text (non-empty, trimmed by caller).
835
+ * @param {object} [opts]
836
+ * @param {string} [opts.idempotencyKey] Optional client-generated id.
837
+ * Auto-generated when omitted; pass the same key
838
+ * for retries to stay idempotent.
839
+ */
840
+ async sendIntervention(instruction, opts = {}) {
841
+ if (!this.currentTaskId) {
842
+ return { status: 'no_task' };
843
+ }
844
+ const text = String(instruction || '').trim();
845
+ if (!text) {
846
+ return { status: 'error', error: 'instruction is empty' };
847
+ }
848
+ const interventionId = opts.idempotencyKey || _uuidLike();
849
+ try {
850
+ const response = await fetch(
851
+ `${this.baseUrl}/api/intervention/${this.currentTaskId}`,
852
+ {
853
+ method: 'POST',
854
+ headers: this._headers({
855
+ 'Content-Type': 'application/json',
856
+ }),
857
+ body: JSON.stringify({
858
+ instruction: text,
859
+ intervention_id: interventionId,
860
+ }),
861
+ },
862
+ );
863
+ if (!response.ok) {
864
+ let errText = '';
865
+ try { errText = (await response.text()).slice(0, 400); } catch {}
866
+ return {
867
+ status: 'error',
868
+ httpStatus: response.status,
869
+ error: errText || `HTTP ${response.status}`,
870
+ interventionId,
871
+ };
872
+ }
873
+ const body = await response.json().catch(() => ({}));
874
+ const backendStatus = body.status || 'accepted';
875
+ const status = body.duplicate ? 'duplicate' : backendStatus;
876
+ return {
877
+ status,
878
+ interventionId: body.intervention_id || interventionId,
879
+ };
880
+ } catch (e) {
881
+ return {
882
+ status: 'error',
883
+ error: (e && e.message) || String(e),
884
+ interventionId,
885
+ };
886
+ }
887
+ }
888
+
889
+ async _waitIfPaused() {
890
+ while (this._paused && !this._cancelled) {
891
+ await new Promise(resolve => this._pauseWaiters.add(resolve));
892
+ }
893
+ }
894
+
895
+ _releasePauseWaiters() {
896
+ const waiters = [...this._pauseWaiters];
897
+ this._pauseWaiters.clear();
898
+ for (const resolve of waiters) {
899
+ try { resolve(); } catch { /* ignore */ }
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Summarize a prior transcript for resume continuity.
905
+ *
906
+ * @param {Array<{role:string,content:string}>} messages
907
+ * @param {Object} [opts]
908
+ * @returns {Promise<{summary:string,source:string,model:string}>}
909
+ */
910
+ async summarizeSession(messages, opts = {}) {
911
+ const response = await fetch(`${this.baseUrl}/api/summarize/session`, {
912
+ method: 'POST',
913
+ headers: this._headers({
914
+ 'Content-Type': 'application/json',
915
+ 'Accept': 'application/json',
916
+ }),
917
+ body: JSON.stringify({
918
+ messages,
919
+ session_id: opts.sessionId || null,
920
+ project_path: opts.projectPath || null,
921
+ max_tokens: opts.maxTokens || 800,
922
+ }),
923
+ signal: AbortSignal.timeout(opts.timeoutMs || 15000),
924
+ });
925
+ if (!response.ok) {
926
+ const text = await response.text().catch(() => '');
927
+ throw new Error(`summary request failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ''}`);
928
+ }
929
+ return await response.json();
930
+ }
931
+ }
932
+
933
+ function fileDiffEventsForToolResult(toolEvent) {
934
+ const data = toolEvent?.data || {};
935
+ const diffs = Array.isArray(data.file_diffs)
936
+ ? data.file_diffs
937
+ : data.file_diff ? [data.file_diff] : [];
938
+ if (!diffs.length) return [];
939
+
940
+ return diffs.map((diff, index) => ({
941
+ type: EVENT_TYPES.FILE_DIFF,
942
+ data: {
943
+ call_id: data.call_id || data._callId || null,
944
+ tool: data.tool || data._tool || '',
945
+ index,
946
+ count: diffs.length,
947
+ path: diff.path || '',
948
+ relative_path: diff.relative_path || '',
949
+ lines_added: diff.lines_added || 0,
950
+ lines_removed: diff.lines_removed || 0,
951
+ truncated: !!diff.truncated,
952
+ truncated_line_count: diff.truncated_line_count || 0,
953
+ hunks: diff.hunks || [],
954
+ unified: diff.unified || '',
955
+ },
956
+ }));
957
+ }