@bahulam/code 2.6.0

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 (273) hide show
  1. package/README.md +80 -0
  2. package/package.json +49 -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 +222 -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 +306 -0
  136. package/src/commands/agent.mjs +220 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +200 -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/settings-loader.mjs +45 -0
  143. package/src/config/settings.mjs +132 -0
  144. package/src/context/ast-parser.mjs +298 -0
  145. package/src/context/bm25.mjs +85 -0
  146. package/src/context/retriever.mjs +308 -0
  147. package/src/context/skeleton.mjs +134 -0
  148. package/src/context/symbol-indexer.mjs +375 -0
  149. package/src/core/agent-history.mjs +111 -0
  150. package/src/core/agent-loop.mjs +486 -0
  151. package/src/core/approval-log.mjs +104 -0
  152. package/src/core/approval.mjs +476 -0
  153. package/src/core/attachments.mjs +380 -0
  154. package/src/core/backend-url.mjs +55 -0
  155. package/src/core/cache-control.mjs +92 -0
  156. package/src/core/cache.mjs +105 -0
  157. package/src/core/callback-client.mjs +180 -0
  158. package/src/core/checkpoints.mjs +142 -0
  159. package/src/core/compact-history.mjs +127 -0
  160. package/src/core/context-envelope.mjs +54 -0
  161. package/src/core/context-manager.mjs +198 -0
  162. package/src/core/error-guidance.mjs +311 -0
  163. package/src/core/file-diff.mjs +217 -0
  164. package/src/core/headless.mjs +448 -0
  165. package/src/core/hooks-manager.mjs +87 -0
  166. package/src/core/jsonl-writer.mjs +449 -0
  167. package/src/core/local-agent.mjs +537 -0
  168. package/src/core/local-store.mjs +836 -0
  169. package/src/core/mode-selector.mjs +51 -0
  170. package/src/core/output-filter.mjs +177 -0
  171. package/src/core/paths.mjs +190 -0
  172. package/src/core/policy-resolver.mjs +156 -0
  173. package/src/core/pricing.mjs +336 -0
  174. package/src/core/project-artifacts.mjs +39 -0
  175. package/src/core/project-context-loader.mjs +139 -0
  176. package/src/core/providers.mjs +219 -0
  177. package/src/core/rate-limit-display.mjs +121 -0
  178. package/src/core/rate-limiter.mjs +119 -0
  179. package/src/core/resume-mode.mjs +192 -0
  180. package/src/core/risk-tier.mjs +337 -0
  181. package/src/core/safety.mjs +203 -0
  182. package/src/core/scheduler.mjs +173 -0
  183. package/src/core/session-manager.mjs +360 -0
  184. package/src/core/session.mjs +143 -0
  185. package/src/core/settings-sync.mjs +85 -0
  186. package/src/core/stagnation.mjs +57 -0
  187. package/src/core/stream-client.mjs +829 -0
  188. package/src/core/streaming.mjs +182 -0
  189. package/src/core/system-prompt.mjs +140 -0
  190. package/src/core/tasks.mjs +196 -0
  191. package/src/core/tool-executor.mjs +1950 -0
  192. package/src/core/trust.mjs +158 -0
  193. package/src/core/work-scope.mjs +248 -0
  194. package/src/hooks/engine.mjs +162 -0
  195. package/src/index.mjs +426 -0
  196. package/src/mcp/client.mjs +253 -0
  197. package/src/mcp/transport-shttp.mjs +130 -0
  198. package/src/mcp/transport-sse.mjs +131 -0
  199. package/src/mcp/transport-ws.mjs +134 -0
  200. package/src/onboarding/preflight.mjs +360 -0
  201. package/src/permissions/checker.mjs +57 -0
  202. package/src/permissions/command-classifier.mjs +652 -0
  203. package/src/permissions/injection-check.mjs +60 -0
  204. package/src/permissions/path-check.mjs +102 -0
  205. package/src/permissions/prompt.mjs +73 -0
  206. package/src/permissions/sandbox.mjs +112 -0
  207. package/src/plugins/loader.mjs +138 -0
  208. package/src/skills/installer.mjs +188 -0
  209. package/src/skills/loader.mjs +252 -0
  210. package/src/skills/runner.mjs +55 -0
  211. package/src/state/orbit.mjs +263 -0
  212. package/src/state/verbosity.mjs +99 -0
  213. package/src/telemetry/index.mjs +96 -0
  214. package/src/terminal/agents.mjs +177 -0
  215. package/src/terminal/analytics.mjs +292 -0
  216. package/src/terminal/ansi.mjs +695 -0
  217. package/src/terminal/init.mjs +145 -0
  218. package/src/terminal/main.mjs +269 -0
  219. package/src/terminal/repl-explore.mjs +35 -0
  220. package/src/terminal/repl-format.mjs +257 -0
  221. package/src/terminal/repl-render.mjs +561 -0
  222. package/src/terminal/repl-resume.mjs +625 -0
  223. package/src/terminal/repl-state.mjs +103 -0
  224. package/src/terminal/repl-utils.mjs +34 -0
  225. package/src/terminal/repl.mjs +3832 -0
  226. package/src/terminal/skills.mjs +54 -0
  227. package/src/terminal/tool-display.mjs +240 -0
  228. package/src/tools/agent.mjs +137 -0
  229. package/src/tools/ask-user.mjs +61 -0
  230. package/src/tools/bash.mjs +231 -0
  231. package/src/tools/cron-create.mjs +120 -0
  232. package/src/tools/cron-delete.mjs +49 -0
  233. package/src/tools/cron-list.mjs +37 -0
  234. package/src/tools/edit.mjs +82 -0
  235. package/src/tools/enter-worktree.mjs +69 -0
  236. package/src/tools/exit-worktree.mjs +57 -0
  237. package/src/tools/glob.mjs +117 -0
  238. package/src/tools/grep.mjs +129 -0
  239. package/src/tools/lint.mjs +71 -0
  240. package/src/tools/ls.mjs +58 -0
  241. package/src/tools/lsp.mjs +115 -0
  242. package/src/tools/multi-edit.mjs +94 -0
  243. package/src/tools/notebook-edit.mjs +96 -0
  244. package/src/tools/project-overview.mjs +641 -0
  245. package/src/tools/read-mcp-resource.mjs +57 -0
  246. package/src/tools/read.mjs +138 -0
  247. package/src/tools/registry.mjs +116 -0
  248. package/src/tools/remote-trigger.mjs +84 -0
  249. package/src/tools/send-message.mjs +64 -0
  250. package/src/tools/skill.mjs +52 -0
  251. package/src/tools/test-runner.mjs +49 -0
  252. package/src/tools/todo-write.mjs +68 -0
  253. package/src/tools/tool-search.mjs +77 -0
  254. package/src/tools/web-fetch.mjs +65 -0
  255. package/src/tools/web-search.mjs +89 -0
  256. package/src/tools/write.mjs +55 -0
  257. package/src/ui/approval.mjs +263 -0
  258. package/src/ui/banner.mjs +235 -0
  259. package/src/ui/commands.mjs +537 -0
  260. package/src/ui/formatter.mjs +409 -0
  261. package/src/ui/icons.mjs +164 -0
  262. package/src/ui/input-dock.mjs +444 -0
  263. package/src/ui/markdown.mjs +278 -0
  264. package/src/ui/mission-report.mjs +296 -0
  265. package/src/ui/palette.mjs +189 -0
  266. package/src/ui/slash-commands.mjs +245 -0
  267. package/src/ui/spinner.mjs +116 -0
  268. package/src/ui/sub-agent.mjs +152 -0
  269. package/src/ui/term.mjs +159 -0
  270. package/src/ui/text-layout.mjs +127 -0
  271. package/src/ui/tool-card.mjs +463 -0
  272. package/src/ui/tool-details.mjs +312 -0
  273. package/src/ui/transcript-block.mjs +21 -0
@@ -0,0 +1,829 @@
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
+ });
57
+
58
+ function sleep(ms) {
59
+ return new Promise(resolve => setTimeout(resolve, ms));
60
+ }
61
+
62
+ // Full jitter around the scheduled delay: pick a value in [delay*0.5, delay*1.5].
63
+ // Spreads out reconnect storms so N clients dropping simultaneously don't
64
+ // synchronize their retries. Clamped to the same 30s ceiling as the base delay.
65
+ function jitteredDelay(delayMs) {
66
+ const min = Math.floor(delayMs * 0.5);
67
+ const max = Math.floor(delayMs * 1.5);
68
+ const jittered = min + Math.floor(Math.random() * (max - min + 1));
69
+ return Math.max(200, Math.min(30_000, jittered));
70
+ }
71
+
72
+ // Categorize a fetch error. "offline-class" means DNS/routing failures that
73
+ // almost never recover within a few seconds — we bail after 2 in a row rather
74
+ // than burn the full 5-min budget when the user's network is truly down.
75
+ function isOfflineLikelyError(err) {
76
+ const code = err?.cause?.code || err?.code || '';
77
+ return (
78
+ code === 'ENOTFOUND' || // DNS lookup failed
79
+ code === 'EAI_AGAIN' || // DNS temporary failure
80
+ code === 'ENETUNREACH' || // no route to host
81
+ code === 'EHOSTUNREACH' || // host unreachable
82
+ code === 'UND_ERR_CONNECT_TIMEOUT' // undici connect timeout
83
+ );
84
+ }
85
+
86
+ export class TarangStreamClient {
87
+ /**
88
+ * @param {Object} opts
89
+ * @param {string} opts.baseUrl - Tarang backend URL
90
+ * @param {string} opts.token - CLI auth token
91
+ * @param {Object} opts.toolExecutor - { execute(name, args) }
92
+ * @param {boolean} [opts.verbose=false]
93
+ */
94
+ constructor({
95
+ baseUrl,
96
+ token,
97
+ toolExecutor,
98
+ verbose = false,
99
+ approvalManager = null,
100
+ product = null,
101
+ reconnectMaxElapsedMs = null,
102
+ }) {
103
+ this.baseUrl = (baseUrl || '').replace(/\/$/, '');
104
+ this.token = token;
105
+ this.toolExecutor = toolExecutor;
106
+ this.verbose = verbose;
107
+ this.approval = approvalManager || new ApprovalManager();
108
+ this.product = product || process.env.TARANG_PRODUCT || process.env.KEPLER_PRODUCT || 'kepler';
109
+ this.currentTaskId = null;
110
+ this.lastEventId = null;
111
+ this.retryDelayMs = null;
112
+ this.pendingToolCallbacks = new Map();
113
+ this.reconnectMaxElapsedMs = reconnectMaxElapsedMs
114
+ ?? Number(process.env.KEPLER_RECONNECT_MAX_ELAPSED_MS || 300_000);
115
+ // Set by backend on first turn, reused on subsequent turns. Headless mode
116
+ // (which starts fresh per invocation) can pre-seed via TARANG_SESSION_ID
117
+ // so multi-turn benchmarks share one backend session across `node` runs.
118
+ this.sessionId = process.env.TARANG_SESSION_ID || null;
119
+ this._cancelled = false;
120
+ this._paused = false;
121
+ this._pauseWaiters = new Set();
122
+ this._abort = null;
123
+ this._toolAbort = null;
124
+ }
125
+
126
+ _headers(extra = {}) {
127
+ const headers = { ...extra };
128
+ if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
129
+ if (this.product) headers['X-Product'] = this.product;
130
+ return headers;
131
+ }
132
+
133
+ async analyzeVision({ instruction, attachments }) {
134
+ const url = `${this.baseUrl}/api/vision/analyze`;
135
+ const body = { instruction, attachments: attachments || [] };
136
+ const response = await fetch(url, {
137
+ method: 'POST',
138
+ headers: this._headers({ 'Content-Type': 'application/json' }),
139
+ body: JSON.stringify(body),
140
+ });
141
+ const text = await response.text().catch(() => '');
142
+ let payload = null;
143
+ try {
144
+ payload = text ? JSON.parse(text) : null;
145
+ } catch {
146
+ payload = { detail: text };
147
+ }
148
+ if (!response.ok) {
149
+ const detail = payload?.detail || payload || {};
150
+ const message = typeof detail === 'string'
151
+ ? detail
152
+ : detail.message || detail.error || `Vision analysis failed (${response.status})`;
153
+ const err = new Error(message);
154
+ err.status = response.status;
155
+ err.detail = detail;
156
+ throw err;
157
+ }
158
+ return payload || {};
159
+ }
160
+
161
+ /**
162
+ * Execute an instruction via SSE stream.
163
+ * Yields parsed events. Client-side tool requests are shown, executed
164
+ * locally, callback-posted, then followed by a local tool_result event.
165
+ *
166
+ * @param {string} instruction
167
+ * @param {Object} [context={}]
168
+ * @param {string} [model]
169
+ * @yields {{ type: string, data: Object }}
170
+ */
171
+ async *execute(instruction, context = {}, messages = null) {
172
+ this._cancelled = false;
173
+ this.currentTaskId = null;
174
+
175
+ const url = `${this.baseUrl}/api/execute`;
176
+ const body = { instruction, context };
177
+ if (messages && messages.length > 0) body.messages = messages;
178
+ if (this.sessionId) body.session_id = this.sessionId;
179
+
180
+ const headers = this._headers({
181
+ 'Accept': 'text/event-stream',
182
+ 'Content-Type': 'application/json',
183
+ });
184
+
185
+ // Abort controller so cancel() can break out of a stalled reader
186
+ // instead of waiting for the next SSE event to notice _cancelled.
187
+ this._abort = new AbortController();
188
+ this._toolAbort = new AbortController();
189
+
190
+ let response;
191
+ try {
192
+ response = await fetch(url, {
193
+ method: 'POST',
194
+ headers,
195
+ body: JSON.stringify(body),
196
+ signal: this._abort.signal,
197
+ });
198
+ } catch (err) {
199
+ if (err.name === 'AbortError') {
200
+ return;
201
+ }
202
+ yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
203
+ return;
204
+ }
205
+
206
+ if (response.status === 401) {
207
+ yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam-code login` to re-authenticate.', fatal: true } };
208
+ return;
209
+ }
210
+ if (response.status === 429) {
211
+ const text = await response.text().catch(() => '');
212
+ let payload = null;
213
+ try {
214
+ payload = text ? JSON.parse(text) : null;
215
+ } catch {
216
+ payload = { detail: { message: text } };
217
+ }
218
+ const detail = quotaErrorDetail(payload);
219
+ yield {
220
+ type: EVENT_TYPES.ERROR,
221
+ data: {
222
+ message: rateLimitErrorMessage(payload),
223
+ code: detail?.code || 'rate_limited',
224
+ retry_after: detail?.retry_after ?? detail?.rate_limit?.retry_after,
225
+ rate_limit: detail?.rate_limit || null,
226
+ action: detail?.action || null,
227
+ pricing_url: normalizeBillingBrandCopy(detail?.pricing_url || null),
228
+ fatal: true,
229
+ },
230
+ };
231
+ return;
232
+ }
233
+ if (!response.ok) {
234
+ const text = await response.text().catch(() => 'Unknown error');
235
+ yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
236
+ return;
237
+ }
238
+
239
+ try {
240
+ yield* this._consumeResponse(response);
241
+ } catch (err) {
242
+ if (this._cancelled) {
243
+ return;
244
+ }
245
+ yield* this._reconnectAfterDrop(err);
246
+ }
247
+ }
248
+
249
+ async *_consumeResponse(response) {
250
+ const taskId = response.headers.get('X-Task-ID');
251
+ if (taskId) this.currentTaskId = taskId;
252
+
253
+ for await (const parsed of this._parseSSE(response)) {
254
+ if (this._cancelled) return;
255
+ await this._waitIfPaused();
256
+ if (this._cancelled) return;
257
+
258
+ if (parsed.id != null) this.lastEventId = parsed.id;
259
+ if (parsed.retry != null) this.retryDelayMs = parsed.retry;
260
+ if (parsed.data?.task_id) this.currentTaskId = parsed.data.task_id;
261
+
262
+ for await (const event of this._handleStreamEvent(parsed)) {
263
+ yield event;
264
+ }
265
+ }
266
+ }
267
+
268
+ async *consumeEventStream(response) {
269
+ yield* this._consumeResponse(response);
270
+ }
271
+
272
+ async *_handleStreamEvent({ event, data, id = null, retry = null }) {
273
+ const rendered = { type: event, data, event_id: id, retry };
274
+
275
+ // Capture session_id from backend (first turn creates it, subsequent turns reuse)
276
+ if (event === EVENT_TYPES.SESSION_INFO && data?.session_id) {
277
+ this.sessionId = data.session_id;
278
+ }
279
+
280
+ // Framework HITL: approval_required — show menu, POST decision
281
+ if (event === EVENT_TYPES.APPROVAL_REQUIRED) {
282
+ yield rendered;
283
+ const approvalEvent = await this._handleApprovalRequired(data);
284
+ if (approvalEvent) yield approvalEvent;
285
+ return;
286
+ }
287
+
288
+ if (event === EVENT_TYPES.APPROVAL_GRANTED || event === EVENT_TYPES.APPROVAL_DENIED) {
289
+ yield rendered;
290
+ return;
291
+ }
292
+
293
+ // Tool requests — show to user, then execute locally and POST callback.
294
+ if (event === EVENT_TYPES.TOOL_REQUEST || event === EVENT_TYPES.TOOL_CALL) {
295
+ yield rendered;
296
+ await this._waitIfPaused();
297
+ if (this._cancelled || data?.server_side) return;
298
+ const toolEvent = await this._handleToolRequest(data);
299
+ if (toolEvent) {
300
+ await this._waitIfPaused();
301
+ if (this._cancelled) return;
302
+ yield toolEvent;
303
+ for (const diffEvent of fileDiffEventsForToolResult(toolEvent)) {
304
+ yield diffEvent;
305
+ }
306
+ }
307
+ return;
308
+ }
309
+
310
+ yield rendered;
311
+ if (event === EVENT_TYPES.TOOL_RESULT || event === EVENT_TYPES.TOOL_DONE) {
312
+ for (const diffEvent of fileDiffEventsForToolResult(rendered)) {
313
+ yield diffEvent;
314
+ }
315
+ }
316
+ }
317
+
318
+ async *_reconnectAfterDrop(err) {
319
+ const taskId = this.currentTaskId;
320
+ if (!taskId || this.lastEventId == null) {
321
+ yield {
322
+ type: EVENT_TYPES.ERROR,
323
+ data: {
324
+ message: `Stream interrupted: ${err?.message || 'connection lost'}. Use /resume to continue from saved history.`,
325
+ fatal: true,
326
+ },
327
+ };
328
+ return;
329
+ }
330
+
331
+ const started = Date.now();
332
+ let attempt = 0;
333
+ // Slow-start baseline: 500ms for the first retry catches most
334
+ // transient blips before the user notices; the ramp doubles from
335
+ // there. Server-hinted `retry:` from the last SSE frame overrides
336
+ // if present. Capped at 30s.
337
+ let delayMs = Math.max(200, Math.min(this.retryDelayMs || 500, 30_000));
338
+ let offlineStreak = 0;
339
+
340
+ while (!this._cancelled && Date.now() - started < this.reconnectMaxElapsedMs) {
341
+ attempt++;
342
+ const after = this.lastEventId;
343
+ // Apply jitter to the scheduled delay so N clients dropping at
344
+ // the same time don't lockstep-retry against the server. The
345
+ // event surfaces the ACTUAL wait so the CLI can show it.
346
+ const waitMs = jitteredDelay(delayMs);
347
+ telemetry.track('stream.reconnect.attempt', {
348
+ task_id: taskId,
349
+ after,
350
+ attempt,
351
+ base_delay_ms: delayMs,
352
+ delay_ms: waitMs,
353
+ });
354
+ yield {
355
+ type: EVENT_TYPES.RECONNECTING,
356
+ data: { task_id: taskId, after, attempt, delay_ms: waitMs },
357
+ };
358
+ await sleep(waitMs);
359
+ try {
360
+ const url = `${this.baseUrl}/api/execute/${encodeURIComponent(taskId)}/events?after=${encodeURIComponent(after)}`;
361
+ const response = await fetch(url, {
362
+ method: 'GET',
363
+ headers: this._headers({ Accept: 'text/event-stream' }),
364
+ signal: AbortSignal.timeout(30_000),
365
+ });
366
+ if (response.status === 404 || response.status === 410) {
367
+ telemetry.track('stream.reconnect.failed', {
368
+ task_id: taskId,
369
+ after,
370
+ attempt,
371
+ status: response.status,
372
+ code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
373
+ retryable: false,
374
+ });
375
+ yield {
376
+ type: EVENT_TYPES.RECONNECT_FAILED,
377
+ data: {
378
+ task_id: taskId,
379
+ code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
380
+ retryable: false,
381
+ },
382
+ };
383
+ return;
384
+ }
385
+ if (!response.ok) throw new Error(`reconnect failed (${response.status})`);
386
+ let replayed = 0;
387
+ for await (const event of this._consumeResponse(response)) {
388
+ if (event.type !== EVENT_TYPES.RECONNECTED) replayed++;
389
+ if (event.type === EVENT_TYPES.RECONNECTED) {
390
+ replayed = Number(event.data?.replayed ?? replayed);
391
+ }
392
+ yield event;
393
+ }
394
+ telemetry.track('stream.reconnect.succeeded', {
395
+ task_id: taskId,
396
+ after,
397
+ attempt,
398
+ replayed,
399
+ elapsed_ms: Date.now() - started,
400
+ });
401
+ return;
402
+ } catch (nextErr) {
403
+ if (this._cancelled) return;
404
+ // Offline-class errors (DNS failure, no route) rarely recover
405
+ // in seconds. Bail after 2 in a row so we don't burn the full
406
+ // 5-min budget spinning against a dead network — the user
407
+ // gets an actionable "network unreachable" message instead.
408
+ if (isOfflineLikelyError(nextErr)) {
409
+ offlineStreak++;
410
+ if (offlineStreak >= 2) {
411
+ telemetry.track('stream.reconnect.failed', {
412
+ task_id: taskId,
413
+ after,
414
+ attempt,
415
+ code: 'network_unreachable',
416
+ error_code: nextErr?.cause?.code || nextErr?.code || '',
417
+ retryable: true,
418
+ });
419
+ yield {
420
+ type: EVENT_TYPES.RECONNECT_FAILED,
421
+ data: {
422
+ task_id: taskId,
423
+ after,
424
+ code: 'network_unreachable',
425
+ message: 'Network appears unreachable — DNS lookup failed twice. Check your connection, then /resume to continue.',
426
+ retryable: true,
427
+ },
428
+ };
429
+ return;
430
+ }
431
+ } else {
432
+ offlineStreak = 0;
433
+ }
434
+ telemetry.track('stream.reconnect.retry', {
435
+ task_id: taskId,
436
+ after,
437
+ attempt,
438
+ message: nextErr?.message || 'reconnect failed',
439
+ error_code: nextErr?.cause?.code || nextErr?.code || '',
440
+ });
441
+ err = nextErr;
442
+ delayMs = Math.min(delayMs * 2, 30_000);
443
+ }
444
+ }
445
+
446
+ telemetry.track('stream.reconnect.failed', {
447
+ task_id: taskId,
448
+ after: this.lastEventId,
449
+ code: 'reconnect_timeout',
450
+ retryable: false,
451
+ elapsed_ms: Date.now() - started,
452
+ });
453
+ yield {
454
+ type: EVENT_TYPES.RECONNECT_FAILED,
455
+ data: {
456
+ task_id: taskId,
457
+ after: this.lastEventId,
458
+ code: 'reconnect_timeout',
459
+ message: err?.message || 'connection lost',
460
+ retryable: false,
461
+ },
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Parse SSE from a fetch Response using ReadableStream.
467
+ * @param {Response} response
468
+ * @yields {{ event: string, data: Object, id: string|null, retry: number|null }}
469
+ */
470
+ async *_parseSSE(response) {
471
+ const reader = response.body.getReader();
472
+ const decoder = new TextDecoder();
473
+ let buffer = '';
474
+ let currentEvent = 'message';
475
+ let currentData = [];
476
+ let currentId = null;
477
+ let currentRetry = null;
478
+
479
+ try {
480
+ while (true) {
481
+ let read;
482
+ try {
483
+ read = await reader.read();
484
+ } catch (err) {
485
+ // Aborted via cancel() — treat as a clean end-of-stream.
486
+ if (err && (err.name === 'AbortError' || this._cancelled)) break;
487
+ throw err;
488
+ }
489
+ const { done, value } = read;
490
+ if (done) break;
491
+
492
+ buffer += decoder.decode(value, { stream: true });
493
+ const lines = buffer.split('\n');
494
+ buffer = lines.pop(); // keep incomplete last line
495
+
496
+ for (const line of lines) {
497
+ const rawLine = line.endsWith('\r') ? line.slice(0, -1) : line;
498
+ const trimmed = rawLine.trim();
499
+
500
+ if (!trimmed) {
501
+ // Empty line = event boundary
502
+ if (currentData.length > 0) {
503
+ const rawData = currentData.join('\n');
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(rawData);
507
+ } catch {
508
+ parsed = { message: rawData };
509
+ }
510
+ yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
511
+ }
512
+ currentEvent = 'message';
513
+ currentData = [];
514
+ currentId = null;
515
+ currentRetry = null;
516
+ } else if (trimmed.startsWith('event:')) {
517
+ currentEvent = trimmed.slice(6).trim();
518
+ } else if (trimmed.startsWith('data:')) {
519
+ currentData.push(trimmed.slice(5).trim());
520
+ } else if (trimmed.startsWith('id:')) {
521
+ currentId = trimmed.slice(3).trim();
522
+ } else if (trimmed.startsWith('retry:')) {
523
+ const parsedRetry = Number(trimmed.slice(6).trim());
524
+ if (Number.isFinite(parsedRetry) && parsedRetry >= 0) currentRetry = parsedRetry;
525
+ }
526
+ // comments and unknown SSE fields are ignored.
527
+ }
528
+ }
529
+
530
+ // Flush remaining
531
+ if (currentData.length > 0) {
532
+ const rawData = currentData.join('\n');
533
+ let parsed;
534
+ try {
535
+ parsed = JSON.parse(rawData);
536
+ } catch {
537
+ parsed = { message: rawData };
538
+ }
539
+ yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
540
+ }
541
+ } finally {
542
+ reader.releaseLock();
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Handle a tool_call event: execute tool locally, POST result via callback.
548
+ *
549
+ * Approval is handled by the framework (HITL) BEFORE this is called.
550
+ * By the time a tool_call arrives here, it's already approved.
551
+ *
552
+ * @param {Object} data - { call_id, tool, args }
553
+ * @returns {Object} local tool result event to yield
554
+ */
555
+ async _handleToolRequest(data) {
556
+ const { call_id, request_id, tool, args } = data;
557
+ const callId = call_id || request_id;
558
+ const toolName = tool;
559
+ const isInternal = Boolean(data?.internal || data?.sub_agent);
560
+
561
+ if (this.verbose) {
562
+ process.stderr.write(`\x1b[2m[tool] ${toolName}(${JSON.stringify(args).slice(0, 80)}...)\x1b[0m\n`);
563
+ }
564
+
565
+ if (this._cancelled) {
566
+ return {
567
+ type: EVENT_TYPES.TOOL_RESULT,
568
+ data: {
569
+ success: false,
570
+ output: 'Cancelled by user',
571
+ call_id: callId,
572
+ tool: toolName,
573
+ args: args || {},
574
+ _cancelled: true,
575
+ internal: isInternal,
576
+ sub_agent: data?.sub_agent || null,
577
+ local_callback: false,
578
+ },
579
+ };
580
+ }
581
+
582
+ // Execute tool locally — framework already approved this
583
+ const startTime = Date.now();
584
+ let result;
585
+ try {
586
+ result = await this.toolExecutor.execute(toolName, args || {}, {
587
+ signal: this._toolAbort?.signal,
588
+ });
589
+ } catch (err) {
590
+ if (err?.name === 'AbortError' || this._cancelled) {
591
+ result = { success: false, output: 'Cancelled by user', _cancelled: true };
592
+ } else {
593
+ result = { success: false, output: `Tool execution error: ${err.message}` };
594
+ }
595
+ }
596
+ const durationMs = Date.now() - startTime;
597
+
598
+ if (this.verbose) {
599
+ const status = result.success ? 'OK' : 'FAIL';
600
+ process.stderr.write(`\x1b[2m[tool] ${toolName} → ${status} (${durationMs}ms)\x1b[0m\n`);
601
+ }
602
+
603
+ // POST callback to backend
604
+ let callbackPosted = false;
605
+ if (!this._cancelled && !result?._cancelled && this.currentTaskId && callId) {
606
+ callbackPosted = await sendCallback(this.baseUrl, this.token, this.currentTaskId, callId, result);
607
+ telemetry.track(callbackPosted ? 'tool.callback.posted' : 'tool.callback.failed', {
608
+ task_id: this.currentTaskId,
609
+ call_id: callId,
610
+ tool: toolName,
611
+ success: Boolean(result?.success !== false),
612
+ duration_ms: durationMs,
613
+ });
614
+ }
615
+
616
+ return {
617
+ type: EVENT_TYPES.TOOL_RESULT,
618
+ data: {
619
+ ...result,
620
+ call_id: callId,
621
+ tool: toolName,
622
+ args: args || {},
623
+ llm_content: llmToolResultContent(toolName, result),
624
+ duration_ms: durationMs,
625
+ internal: isInternal,
626
+ sub_agent: data?.sub_agent || null,
627
+ local_callback: true,
628
+ },
629
+ };
630
+ }
631
+
632
+ /**
633
+ * Handle framework HITL approval_required event.
634
+ * Shows the same approval menu as tool_call, but POSTs the decision
635
+ * to /api/approval_callback instead of skipping the tool.
636
+ *
637
+ * @param {Object} data - { tool_id, tool, args, risk, reason }
638
+ * @returns {Object|null} optional status event to yield
639
+ */
640
+ async _handleApprovalRequired(data) {
641
+ const { tool_id, tool, args, risk, reason } = data;
642
+
643
+ if (this.verbose) {
644
+ process.stderr.write(`\x1b[2m[hitl] Approval needed: ${tool} (${risk})\x1b[0m\n`);
645
+ }
646
+
647
+ // Use the same ApprovalManager for consistent UX
648
+ const { approved, reason: denyReason, scope: approvedScope } = await this.approval.check(
649
+ tool,
650
+ args || {},
651
+ true,
652
+ { risk, reason },
653
+ );
654
+
655
+ // Map ApprovalManager decision to framework scope
656
+ let decision, scope;
657
+ if (approved) {
658
+ decision = 'grant';
659
+ // Determine scope from ApprovalManager state
660
+ if (this.approval.approveAll) {
661
+ scope = 'all';
662
+ } else if (this.approval.approvedToolTypes.has(tool)) {
663
+ scope = 'type';
664
+ } else if (approvedScope) {
665
+ scope = String(approvedScope).toLowerCase();
666
+ } else {
667
+ scope = 'once';
668
+ }
669
+ } else {
670
+ decision = 'deny';
671
+ scope = 'once';
672
+ }
673
+
674
+ // POST decision to backend
675
+ if (this.currentTaskId && tool_id) {
676
+ const posted = await sendApprovalDecision(
677
+ this.baseUrl, this.token, this.currentTaskId,
678
+ tool_id, decision, scope, denyReason || '',
679
+ );
680
+ telemetry.track(posted ? 'approval.callback.posted' : 'approval.callback.failed', {
681
+ task_id: this.currentTaskId,
682
+ tool_id,
683
+ tool,
684
+ decision,
685
+ });
686
+ if (!posted) {
687
+ return {
688
+ type: EVENT_TYPES.ERROR,
689
+ data: {
690
+ 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.`,
691
+ code: 'approval_callback_failed',
692
+ fatal: false,
693
+ },
694
+ };
695
+ }
696
+ }
697
+
698
+ if (!approved) {
699
+ return {
700
+ type: EVENT_TYPES.STATUS,
701
+ data: { message: `Denied ${tool}: ${denyReason || 'rejected'}` },
702
+ };
703
+ }
704
+
705
+ return null; // Approved — framework continues with tool execution
706
+ }
707
+
708
+ /** Cancel the current stream. */
709
+ async cancel() {
710
+ this._cancelled = true;
711
+ this._paused = false;
712
+ this._releasePauseWaiters();
713
+ // Stop local work first. The backend POST below is best-effort and
714
+ // must not delay returning control to the terminal.
715
+ if (this._toolAbort) {
716
+ try { this._toolAbort.abort(); } catch {}
717
+ }
718
+ if (this._abort) {
719
+ try { this._abort.abort(); } catch {}
720
+ }
721
+ // Best-effort backend POST — the stream may already be torn down.
722
+ if (this.currentTaskId) {
723
+ fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
724
+ method: 'POST',
725
+ headers: this._headers(),
726
+ }).catch(() => {});
727
+ }
728
+ }
729
+
730
+ /** Pause the current stream. */
731
+ async pause() {
732
+ this._paused = true;
733
+ if (this.currentTaskId) {
734
+ try {
735
+ await fetch(`${this.baseUrl}/api/pause/${this.currentTaskId}`, {
736
+ method: 'POST',
737
+ headers: this._headers(),
738
+ });
739
+ } catch { /* best effort */ }
740
+ }
741
+ }
742
+
743
+ /** Resume a paused stream. */
744
+ async resume(instruction = null) {
745
+ this._paused = false;
746
+ this._releasePauseWaiters();
747
+ if (this.currentTaskId) {
748
+ const body = instruction ? JSON.stringify({ instruction }) : undefined;
749
+ try {
750
+ await fetch(`${this.baseUrl}/api/resume/${this.currentTaskId}`, {
751
+ method: 'POST',
752
+ headers: this._headers({
753
+ 'Content-Type': 'application/json',
754
+ }),
755
+ body,
756
+ });
757
+ } catch { /* best effort */ }
758
+ }
759
+ }
760
+
761
+ async _waitIfPaused() {
762
+ while (this._paused && !this._cancelled) {
763
+ await new Promise(resolve => this._pauseWaiters.add(resolve));
764
+ }
765
+ }
766
+
767
+ _releasePauseWaiters() {
768
+ const waiters = [...this._pauseWaiters];
769
+ this._pauseWaiters.clear();
770
+ for (const resolve of waiters) {
771
+ try { resolve(); } catch { /* ignore */ }
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Summarize a prior transcript for resume continuity.
777
+ *
778
+ * @param {Array<{role:string,content:string}>} messages
779
+ * @param {Object} [opts]
780
+ * @returns {Promise<{summary:string,source:string,model:string}>}
781
+ */
782
+ async summarizeSession(messages, opts = {}) {
783
+ const response = await fetch(`${this.baseUrl}/api/summarize/session`, {
784
+ method: 'POST',
785
+ headers: this._headers({
786
+ 'Content-Type': 'application/json',
787
+ 'Accept': 'application/json',
788
+ }),
789
+ body: JSON.stringify({
790
+ messages,
791
+ session_id: opts.sessionId || null,
792
+ project_path: opts.projectPath || null,
793
+ max_tokens: opts.maxTokens || 800,
794
+ }),
795
+ signal: AbortSignal.timeout(opts.timeoutMs || 15000),
796
+ });
797
+ if (!response.ok) {
798
+ const text = await response.text().catch(() => '');
799
+ throw new Error(`summary request failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ''}`);
800
+ }
801
+ return await response.json();
802
+ }
803
+ }
804
+
805
+ function fileDiffEventsForToolResult(toolEvent) {
806
+ const data = toolEvent?.data || {};
807
+ const diffs = Array.isArray(data.file_diffs)
808
+ ? data.file_diffs
809
+ : data.file_diff ? [data.file_diff] : [];
810
+ if (!diffs.length) return [];
811
+
812
+ return diffs.map((diff, index) => ({
813
+ type: EVENT_TYPES.FILE_DIFF,
814
+ data: {
815
+ call_id: data.call_id || data._callId || null,
816
+ tool: data.tool || data._tool || '',
817
+ index,
818
+ count: diffs.length,
819
+ path: diff.path || '',
820
+ relative_path: diff.relative_path || '',
821
+ lines_added: diff.lines_added || 0,
822
+ lines_removed: diff.lines_removed || 0,
823
+ truncated: !!diff.truncated,
824
+ truncated_line_count: diff.truncated_line_count || 0,
825
+ hunks: diff.hunks || [],
826
+ unified: diff.unified || '',
827
+ },
828
+ }));
829
+ }