mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,2048 @@
1
+ 'use strict';
2
+
3
+ // Streaming core for the AI client.
4
+ //
5
+ // Owns the multi-turn tool loop (streamChat), the single tool-call
6
+ // runner (runSingleToolCall), and the byte-stream helpers shared with
7
+ // the provider parsers (readSSE / parseSSEFrame / readNDJSON).
8
+ // Provider definitions, request builders and event parsers live in
9
+ // src/ai-endpoints.js; the public facade is src/ai.js.
10
+
11
+ const { endpointFor, requireApiKey, BUILDERS, PARSERS, parseMiniMaxTextToolCalls } = require('./ai-endpoints.js');
12
+ const { projectModelRecord } = require('./util.js');
13
+ const toolFeedback = require('./toolFeedback.js');
14
+ const usageMetrics = require('./usage.js');
15
+
16
+ // ---- Streaming core ----------------------------------------------------
17
+
18
+ // Walks an SSE byte stream and yields {eventName, data} pairs.
19
+ // `stream` is a ReadableStream<Uint8Array> from fetch().
20
+ async function* readSSE(stream) {
21
+ const decoder = new TextDecoder('utf-8');
22
+ let buf = '';
23
+ // SSE frames are separated by a blank line. The spec allows LF, CRLF,
24
+ // and bare CR as line endings, and all three occur in the wild: Azure
25
+ // OpenAI, Ollama's OpenAI-compatible mode, and many gateways emit CRLF,
26
+ // and an old-style proxy can emit CR-only. Splitting on `\n\n` alone
27
+ // would glue such frames into one giant frame with literal `\r` inside
28
+ // the JSON, so every chunk would fail JSON.parse and stream as
29
+ // `passthrough` (no visible text). The separator pattern below matches
30
+ // all three (CRLF first, so it is never read as two CR/LF breaks), and
31
+ // each frame's line endings are normalized to LF so parseSSEFrame's
32
+ // `\n`-based field splitting sees a clean frame.
33
+ const FRAME_SEP = /(?:\r\n|\r|\n){2}/;
34
+ for await (const chunk of stream) {
35
+ buf += decoder.decode(chunk, { stream: true });
36
+ let idx;
37
+ while ((idx = buf.search(FRAME_SEP)) !== -1) {
38
+ const frame = buf.slice(0, idx).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
39
+ // Consume the separator (2 or 4 chars: "\n\n" / "\r\n\r\n" / "\r\r")
40
+ // — a plain slice by fixed length would leave a stray "\r" behind.
41
+ const sep = buf.slice(idx, idx + 4).match(FRAME_SEP)[0];
42
+ buf = buf.slice(idx + sep.length);
43
+ const ev = parseSSEFrame(frame);
44
+ if (ev) yield ev;
45
+ }
46
+ }
47
+ // Tail without trailing blank line.
48
+ if (buf.trim()) {
49
+ const ev = parseSSEFrame(buf);
50
+ if (ev) yield ev;
51
+ }
52
+ }
53
+
54
+ function parseSSEFrame(frame) {
55
+ let eventName = 'message';
56
+ const dataLines = [];
57
+ for (const line of frame.split('\n')) {
58
+ if (!line) continue;
59
+ if (line.startsWith(':')) continue; // comment / heartbeat
60
+ const colon = line.indexOf(':');
61
+ if (colon === -1) continue;
62
+ const field = line.slice(0, colon);
63
+ let value = line.slice(colon + 1);
64
+ if (value.startsWith(' ')) value = value.slice(1);
65
+ if (field === 'event') eventName = value;
66
+ else if (field === 'data') dataLines.push(value);
67
+ }
68
+ // No data lines -> comment-only / heartbeat / empty frame; skip.
69
+ if (!dataLines.length) return null;
70
+ return { eventName, data: dataLines.join('\n') };
71
+ }
72
+
73
+ // Walks an NDJSON byte stream and yields one parsed object per line.
74
+ async function* readNDJSON(stream) {
75
+ const decoder = new TextDecoder('utf-8');
76
+ let buf = '';
77
+ for await (const chunk of stream) {
78
+ buf += decoder.decode(chunk, { stream: true });
79
+ let idx;
80
+ while ((idx = buf.indexOf('\n')) !== -1) {
81
+ const line = buf.slice(0, idx).trim();
82
+ buf = buf.slice(idx + 1);
83
+ if (!line) continue;
84
+ try { yield JSON.parse(line); }
85
+ catch { /* ignore malformed line, upstream is responsible */ }
86
+ }
87
+ }
88
+ if (buf.trim()) {
89
+ try { yield JSON.parse(buf); } catch { /* ignore */ }
90
+ }
91
+ }
92
+
93
+ // ---- Single tool-call runner ---------------------------------------------
94
+ // Executes one tool call through the full native pipeline — circuit
95
+ // breaker, authorization gate, dispatch — emitting tool_call/tool_result
96
+ // events and appending the `tool` message to `cx.convo` (when provided).
97
+ // Shared by the streamChat tool loop and by POST /api/tools/subagent
98
+ // (direct @agent dispatch from the composer).
99
+ //
100
+ // cx = {
101
+ // opts, onEvent, convo, // streamChat closure (convo optional)
102
+ // toolSpecs, promptProfilesMod, discoveredToolNames,
103
+ // modelContentForTool, // (name, exec) -> string
104
+ // dispatchTool, firstStringArgument, toolResultImageParts, // helpers
105
+ // getLastToolCallKey/setLastToolCallKey,
106
+ // getRepeatedToolCallCount/setRepeatedToolCallCount, REPEATED_TOOL_CALL_LIMIT
107
+ // }
108
+ async function runSingleToolCall(c, cx) {
109
+ const { opts, onEvent, convo, toolSpecs, promptProfilesMod, discoveredToolNames, modelContentForTool } = cx;
110
+ const dispatchTool = cx.dispatchTool;
111
+ const firstStringArgument = cx.firstStringArgument;
112
+ const toolResultImageParts = cx.toolResultImageParts;
113
+ let args = {};
114
+ if (c.arguments) {
115
+ try { args = JSON.parse(c.arguments); }
116
+ catch { args = { __raw: c.arguments }; }
117
+ }
118
+ let exec;
119
+ let callEmitted = false;
120
+ // Captured when the authorization gate resolves a prompt for an
121
+ // `ask_user` call. The runner reads it to fold the user's
122
+ // structured answer into the `tool` message it returns.
123
+ let callOptsAnswerPayload = null;
124
+ // Captured when the authorization gate resolves a prompt for a
125
+ // `subagent` call whose card carried a user-picked model. The
126
+ // payload is { modelOverride: { providerId, modelId } }; the
127
+ // subagent dispatcher resolves it to a hydrated model so the
128
+ // delegated run executes on the chosen model for this call only.
129
+ let callOptsModelOverride = null;
130
+ // Captured from the same authorization payload: a per-run thinking
131
+ // level the user picked on the approval card (this call only).
132
+ let callOptsThinkingLevel = null;
133
+ const pushToolMessage = (name, content) => {
134
+ if (convo) convo.push({ role: 'tool', tool_call_id: c.id || undefined, name, content });
135
+ };
136
+ // Identical-call circuit breaker. A model retrying the exact same
137
+ // call with the exact same arguments (typically after a tool
138
+ // error) never converges — enforce that the returned result is
139
+ // identical, so nothing was learned from the retry. Refuse it
140
+ // with an explanatory tool error so the model is forced to vary
141
+ // the command or answer in plain text. Counts per consecutive
142
+ // identical call; any different call resets the streak.
143
+ const callKey = c.name + '\n' + (c.arguments || '');
144
+ if (callKey === cx.getLastToolCallKey()) {
145
+ cx.setRepeatedToolCallCount(cx.getRepeatedToolCallCount() + 1);
146
+ } else {
147
+ cx.setLastToolCallKey(callKey);
148
+ cx.setRepeatedToolCallCount(0);
149
+ }
150
+ const loopLimit = typeof cx.REPEATED_TOOL_CALL_LIMIT === 'number' ? cx.REPEATED_TOOL_CALL_LIMIT : 3;
151
+ if (cx.getRepeatedToolCallCount() >= loopLimit) {
152
+ const r = {
153
+ error: {
154
+ code: 'ELOOP',
155
+ message: 'You have called ' + c.name + ' with identical arguments ' + (cx.getRepeatedToolCallCount() + 1) + ' times in a row with identical results. The call was refused. Do not retry it — change the command/arguments or answer the user in plain text instead.'
156
+ }
157
+ };
158
+ exec = { ok: false, content: JSON.stringify(r), result: r };
159
+ onEvent('tool_call', { id: c.id || null, name: c.name, args });
160
+ callEmitted = true;
161
+ onEvent('tool_result', { id: c.id || null, name: c.name, ok: false, result: exec.result });
162
+ pushToolMessage(c.name, modelContentForTool(c.name, exec));
163
+ return exec;
164
+ }
165
+ try {
166
+ // list_features is a read-only metadata tool that bypasses
167
+ // the authorization gate — it only returns feature state.
168
+ if (c.name === 'list_features') {
169
+ let af;
170
+ try { af = require('./agentFeatures.js'); }
171
+ catch (e) {
172
+ exec = { ok: false, content: JSON.stringify({ error: { code: 'EMODULE', message: 'agentFeatures module unavailable: ' + (e.message || e) } }), result: { error: { code: 'EMODULE' } } };
173
+ }
174
+ if (!exec) {
175
+ exec = await af.dispatchListFeatures(args, Object.assign({}, opts, { callId: c.id || null }));
176
+ }
177
+ onEvent('tool_call', { id: c.id || null, name: c.name, args });
178
+ callEmitted = true;
179
+ } else if (c.name === 'activate_skill') {
180
+ // Activation is a read-only lookup constrained to the enum of enabled,
181
+ // project-contained skills, so it does not require a separate approval.
182
+ onEvent('tool_call', { id: c.id || null, name: c.name, args });
183
+ callEmitted = true;
184
+ exec = await dispatchTool(c.name, args, Object.assign({}, opts, { callId: c.id || null }));
185
+ } else if (c.name === 'report_progress') {
186
+ // report_progress is a read-only UI/update tool. It honors
187
+ // the project `off` visibility gate, but does not show an
188
+ // interactive authorization prompt because progress updates
189
+ // do not read or modify project resources.
190
+ try {
191
+ const authGate = require('./tools/authorization.js');
192
+ const cfg = authGate.effectiveConfig(opts && opts.projectDir, c.name, opts && opts.chatId);
193
+ if (cfg && cfg.mode === 'off') {
194
+ exec = { ok: false, content: JSON.stringify({ ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' }), result: { ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' } };
195
+ }
196
+ } catch { /* unreadable authorization state: keep compatibility path */ }
197
+ // Emit the running card before dispatch so the subsequent
198
+ // progress_update can attach to the same call id.
199
+ onEvent('tool_call', { id: c.id || null, name: c.name, args });
200
+ callEmitted = true;
201
+ if (!exec) exec = await dispatchTool(c.name, args, Object.assign({}, opts, { callId: c.id || null }));
202
+ } else if (promptProfilesMod && c.name === promptProfilesMod.DISCOVER_TOOL_NAME) {
203
+ const requested = args && (args.toolName || args.name || args.tool);
204
+ const spec = (toolSpecs || []).find(s => s && s.function && s.function.name === requested);
205
+ if (!spec) {
206
+ exec = {
207
+ ok: false,
208
+ content: JSON.stringify({ error: { code: 'EUNKNOWN_TOOL', message: 'Unknown tool: ' + requested } }),
209
+ result: { error: { code: 'EUNKNOWN_TOOL', message: 'Unknown tool: ' + requested } }
210
+ };
211
+ } else {
212
+ if (discoveredToolNames) discoveredToolNames.add(requested);
213
+ const fn = spec.function || {};
214
+ exec = {
215
+ ok: true,
216
+ content: JSON.stringify({ name: fn.name, description: fn.description, parameters: fn.parameters }),
217
+ result: { name: fn.name, description: fn.description, parameters: fn.parameters }
218
+ };
219
+ }
220
+ onEvent('tool_call', { id: c.id || null, name: c.name, args });
221
+ callEmitted = true;
222
+ } else {
223
+ const authGate = require('./tools/authorization.js');
224
+ // The summary shown on the "Authorization required" card and
225
+ // matched against the file-tool allowlist needs the right
226
+ // argument per tool family. For shell it's the command; for
227
+ // the file tools it's the path (with the optional query /
228
+ // content as a hint, when relevant).
229
+ let summary;
230
+ if (c.name === 'shell') summary = (args && args.cmd) || '';
231
+ else if (c.name === 'subagent') summary = (args && args.task) || '';
232
+ else if (c.name === 'webpreview') {
233
+ // Show the URL the model wants to open so the user can tell
234
+ // at a glance which site it'll preview — beats the generic
235
+ // first-arg fallback because every webpreview call has a
236
+ // `url` argument anyway.
237
+ summary = (args && args.url) || '';
238
+ }
239
+ else if (c.name === 'read_file' || c.name === 'list_files' || c.name === 'search_files' || c.name === 'write_file' || c.name === 'edit_file') {
240
+ summary = (args && (args.path || args.file)) || (args && args.query) || '';
241
+ } else if (String(c.name).startsWith('mcp__')) {
242
+ // MCP allowlists (shared or per-server/per-tool) match
243
+ // against "<composedName> <firstStringArg>" so a pattern
244
+ // can pin either the tool itself (^mcp__fs__read_file$)
245
+ // or the resource it touches (^mcp__fs__read_file src/).
246
+ const first = firstStringArgument(args);
247
+ summary = first ? (c.name + ' ' + first) : c.name;
248
+ } else {
249
+ summary = firstStringArgument(args);
250
+ }
251
+ const authResult = await authGate.authorize({
252
+ projectDir: opts && opts.projectDir,
253
+ chatId: opts && opts.chatId,
254
+ tool: c.name,
255
+ callId: c.id,
256
+ cmd: args && args.cmd,
257
+ path: args && args.path,
258
+ query: args && args.query,
259
+ summary,
260
+ timeoutMs: args && args.timeoutMs,
261
+ args
262
+ });
263
+ // The gate returns the timeout it resolved against the project's
264
+ // `defaultTimeoutMs` / `maxTimeoutMs`. That value — never the raw
265
+ // model-supplied one — is what the runner must use and what the
266
+ // approval card must show: the model cannot raise its own ceiling
267
+ // by asking for a longer timeout.
268
+ const clampedTimeoutMs = Number.isFinite(authResult && authResult.timeoutMs)
269
+ ? authResult.timeoutMs
270
+ : (args && args.timeoutMs);
271
+
272
+ // `ask_user` rides a separate UI card (question + options +
273
+ // free-form "extra" textbox). The same authorization gate is
274
+ // reused so the audit log, session grants, and `off` /
275
+ // `allow-always` semantics work the same as for the other
276
+ // tools. The dedicated `ask_user_required` event carries the
277
+ // validated question payload so the chat UI can render the
278
+ // right component without parsing `args` itself.
279
+ let askUserPayload = null;
280
+ if (c.name === 'ask_user') {
281
+ try {
282
+ const askMod = require('./tools/ask.js');
283
+ askUserPayload = askMod.validateArgs(args);
284
+ } catch (e) {
285
+ // The model fed us a bad question (too many options,
286
+ // duplicate value, missing label, ...). Surface the
287
+ // validation error directly as a tool_result so the
288
+ // model can self-correct on the next turn; do NOT block
289
+ // the gate on a user prompt, because the bug is on the
290
+ // model side, not the user side.
291
+ const r = { error: { code: e.code || 'EBADINPUT', message: e.message } };
292
+ exec = { ok: false, content: JSON.stringify(r), result: r };
293
+ onEvent('tool_call', { id: c.id || null, name: c.name, args });
294
+ callEmitted = true;
295
+ onEvent('tool_result', { id: c.id || null, name: c.name, ok: false, result: exec.result });
296
+ pushToolMessage(c.name, modelContentForTool(c.name, exec));
297
+ return exec;
298
+ }
299
+ }
300
+
301
+ if (authResult.decision === 'prompt') {
302
+ if (c.name === 'ask_user' && askUserPayload) {
303
+ onEvent('ask_user_required', {
304
+ chatId: opts && opts.chatId,
305
+ callId: c.id,
306
+ tool: c.name,
307
+ question: askUserPayload.question,
308
+ options: askUserPayload.options,
309
+ multiSelect: askUserPayload.multiSelect,
310
+ presets: askUserPayload.presets,
311
+ projectDir: opts && opts.projectDir
312
+ });
313
+ } else {
314
+ onEvent('authorization_required', {
315
+ chatId: opts && opts.chatId,
316
+ callId: c.id,
317
+ tool: c.name,
318
+ cmd: args && args.cmd,
319
+ path: args && args.path,
320
+ query: args && args.query,
321
+ summary,
322
+ timeoutMs: clampedTimeoutMs,
323
+ projectDir: opts && opts.projectDir
324
+ });
325
+ }
326
+ // Capture the resolved value (allow, payload, ...) so the
327
+ // `ask_user` runner can read the user's structured answer.
328
+ // For every other tool the payload is undefined and the
329
+ // runner ignores it.
330
+ const authDecision = await authResult.wait;
331
+ if (authDecision && authDecision.payload) {
332
+ if (c.name === 'ask_user') {
333
+ callOptsAnswerPayload = authDecision.payload;
334
+ } else if (c.name === 'subagent') {
335
+ if (authDecision.payload.modelOverride) {
336
+ // The authorization card let the user pick a model for
337
+ // this delegated run. Hand it to the subagent dispatcher,
338
+ // which resolves it to a hydrated model and runs the
339
+ // nested call on it (per-call override, never persisted).
340
+ callOptsModelOverride = authDecision.payload.modelOverride;
341
+ }
342
+ if (typeof authDecision.payload.thinkingLevel === 'string') {
343
+ // Per-run thinking level chosen on the same card.
344
+ callOptsThinkingLevel = authDecision.payload.thinkingLevel;
345
+ }
346
+ }
347
+ }
348
+ }
349
+ // Only announce a running tool after authorization has completed.
350
+ // Previously the UI showed "tool call — running" while the server
351
+ // was actually blocked waiting for an authorization decision. If the
352
+ // authorization card was missed or the page reloaded, the transcript
353
+ // appeared permanently stuck on a tool call with no messages.
354
+ onEvent('tool_call', { id: c.id || null, name: c.name, args });
355
+ callEmitted = true;
356
+ exec = await dispatchTool(c.name, args, Object.assign({}, opts, { callId: c.id || null, toolTimeoutMs: clampedTimeoutMs, answerPayload: callOptsAnswerPayload, modelOverride: callOptsModelOverride, thinkingLevel: callOptsThinkingLevel }));
357
+ }
358
+ } catch (e) {
359
+ // Denied/disabled/error calls still need a call card immediately
360
+ // before their result so persisted history remains a valid pair.
361
+ if (!callEmitted) onEvent('tool_call', { id: c.id || null, name: c.name, args });
362
+ if (e.code === 'EDENIED') {
363
+ // For `ask_user` we want the runner to produce a
364
+ // `cancelled: true` result so the model can decide what to
365
+ // do next (fall back to a free-form chat, stop, ask a
366
+ // different question, ...). For every other tool a deny
367
+ // stays a plain EDENIED stub.
368
+ if (c.name === 'ask_user') {
369
+ exec = await dispatchTool('ask_user', args, Object.assign({}, opts, { callId: c.id || null, answerPayload: { cancelled: true } }));
370
+ } else {
371
+ exec = { ok: false, content: JSON.stringify({ ok: false, code: 'EDENIED', reason: 'user denied' }), result: { ok: false, code: 'EDENIED', reason: 'user denied' } };
372
+ }
373
+ } else if (e.code === 'ETOOL_DISABLED') {
374
+ exec = { ok: false, content: JSON.stringify({ ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' }), result: { ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' } };
375
+ } else {
376
+ exec = { ok: false, content: JSON.stringify({ ok: false, error: e.message }), result: { ok: false, error: e.message } };
377
+ }
378
+ }
379
+
380
+ onEvent('tool_result', { id: c.id || null, name: c.name, ok: exec.ok, result: exec.result });
381
+
382
+ pushToolMessage(c.name, modelContentForTool(c.name, exec));
383
+ // webpreview screenshots are rendered for the user in the preview dock. The
384
+ // model only receives the compact JSON result and can call the tool again to
385
+ // reload the page; it does not receive or inspect the image bytes.
386
+ const imageParts = c.name === 'webpreview' ? [] : toolResultImageParts(exec && exec.result);
387
+ return { exec, imageParts };
388
+ }
389
+
390
+ // ---- OpenAI prompt-cache routing key ----------------------------------
391
+ // OpenAI-family endpoints cache prompt prefixes automatically, but they only
392
+ // serve a warm cache when consecutive requests of one conversation reach the
393
+ // same machine. `prompt_cache_key` is the documented routing hint for that.
394
+ //
395
+ // The key must be identical for every request of one chat — every tool round
396
+ // and every follow-up turn — and different between chats, which makes the
397
+ // chat id exactly the right source. A request with no chat (a one-shot
398
+ // /api/chat call) returns null, and the builder then omits the field: there
399
+ // is no conversation worth keeping warm. The builder decides which providers
400
+ // accept the field (see ai-endpoints.js → PROMPT_CACHE_KEY_PROVIDERS).
401
+ function promptCacheKeyFor(opts) {
402
+ const chatId = opts && opts.chatId;
403
+ if (!chatId) return null;
404
+ const key = 'mouaif-' + String(chatId);
405
+ // Defensive cap so a hand-set or future id shape can never produce an
406
+ // oversized field. Truncation keeps the prefix, so distinct ids stay
407
+ // distinct.
408
+ return key.length > 64 ? key.slice(0, 64) : key;
409
+ }
410
+
411
+ async function streamChat(opts) {
412
+ const { model, messages, signal, onEvent, onRoundUsage, onRoundCommit, thinkingLevel, maxOutputTokens } = opts || {};
413
+ if (!model || !model.provider) {
414
+ return { ok: false, error: { code: 'EBADMODEL', message: 'Missing model.provider' } };
415
+ }
416
+ if (!Array.isArray(messages) || !messages.length) {
417
+ return { ok: false, error: { code: 'EBADINPUT', message: 'messages must be a non-empty array' } };
418
+ }
419
+ if (typeof onEvent !== 'function') {
420
+ return { ok: false, error: { code: 'EBADINPUT', message: 'onEvent must be a function' } };
421
+ }
422
+ // Pass thinking level and max output tokens down to the request builders
423
+ // so they can inject provider-specific fields (reasoning_effort, thinking
424
+ // budget, max_completion_tokens, max_tokens, etc.).
425
+ if (typeof thinkingLevel === 'string' && thinkingLevel) {
426
+ model.thinkingLevel = thinkingLevel;
427
+ }
428
+ if (typeof maxOutputTokens === 'string' && maxOutputTokens) {
429
+ model.maxOutputTokens = maxOutputTokens;
430
+ }
431
+
432
+ let def, build, parse;
433
+ try {
434
+ def = endpointFor(model);
435
+ // requireApiKey is async on the OAuth path (proactive refresh);
436
+ // sync-fast on the API-key path. We always `await` it here.
437
+ await requireApiKey(model, def);
438
+ build = BUILDERS[model.provider];
439
+ parse = PARSERS[model.provider];
440
+ } catch (e) {
441
+ return { ok: false, error: { code: e.code || 'EBADMODEL', message: e.message } };
442
+ }
443
+ if (!build || !parse) {
444
+ return { ok: false, error: { code: 'EUNKNOWN_PROVIDER', message: 'No builder/parser for ' + model.provider } };
445
+ }
446
+
447
+ // ---- Tool specs advertised to the model ----------------------------
448
+ // Three sources feed the `tools` field of the outgoing request:
449
+ // 1. The native `shell` tool (src/tools/shell.js), always present.
450
+ // 2. The native `report_progress` tool (src/tools/progress.js), always present.
451
+ // 3. The native `subagent` tool (src/tools/subagent.js), always present.
452
+ // 4. The native `ask_user` tool (src/tools/ask.js), always present.
453
+ // Lets the model pause and ask the user a structured question
454
+ // with a list of options (2+, no cap). The user always has a
455
+ // free-form "extra" textbox alongside their pick, so the answer
456
+ // is never constrained to the offered options. See
457
+ // docs/features/ask-user-tool.md.
458
+ // 5. The native file tools (read_file / list_files / search_files /
459
+ // write_file, src/tools/files.js), always present. Authorization
460
+ // decides whether a call prompts, runs, or is rejected. These cover
461
+ // "read this file / find where X is used / patch a small file"
462
+ // loop without requiring an MCP server.
463
+ // 6. MCP-discovered tools (decision §18), which use the
464
+ // mcp__<serverSlug>__<toolName> name convention.
465
+ // Tool calling and the multi-turn loop are wired for the OpenAI-
466
+ // compatible tool shape (openai-compatible + github-copilot + openrouter)
467
+ // and for Anthropic's native tool_use shape (buildAnthropicRequest
468
+ // converts the specs and the parser emits tool_call_delta for tool_use
469
+ // blocks). Other providers stream normally and never see a `tools`
470
+ // field, so their happy path is unchanged.
471
+ const toolSpecs = [];
472
+ try { toolSpecs.push(require('./tools/shell.js').SPEC); }
473
+ catch { /* shell tool module unavailable; skip */ }
474
+ try { toolSpecs.push(require('./tools/progress.js').SPEC); }
475
+ catch { /* progress tool module unavailable; skip */ }
476
+ try {
477
+ const sub = require('./tools/subagent.js');
478
+ // Enumerate the project's agent names in the `agent` parameter
479
+ // description so the model knows exactly what it can delegate to.
480
+ let agentNames = [];
481
+ try {
482
+ if (opts && opts.projectDir) agentNames = require('./agents.js').list(opts.projectDir).map((a) => a.name);
483
+ } catch { /* no agents */ }
484
+ toolSpecs.push(sub.buildSpec ? sub.buildSpec(agentNames) : sub.SPEC);
485
+ }
486
+ catch { /* subagent tool module unavailable; skip */ }
487
+ try { toolSpecs.push(require('./tools/ask.js').SPEC); }
488
+ catch { /* ask_user tool module unavailable; skip */ }
489
+ try { toolSpecs.push(require('./agentFeatures.js').LIST_FEATURES_SPEC); }
490
+ catch { /* list_features tool module unavailable; skip */ }
491
+ try { toolSpecs.push(require('./tools/task.js').SPEC); }
492
+ catch { /* task tool module unavailable; skip */ }
493
+ try { toolSpecs.push(require('./tools/restart.js').SPEC); }
494
+ catch { /* restart tool module unavailable; skip */ }
495
+ try {
496
+ const skillSpec = require('./agentSkills.js').buildSpec(opts && opts.projectDir, opts && opts.chat);
497
+
498
+ if (skillSpec) toolSpecs.push(skillSpec);
499
+ } catch { /* skills unavailable; skip */ }
500
+ try {
501
+ const ft = require('./tools/files.js');
502
+ for (const name of ft.FILE_TOOL_NAMES) toolSpecs.push(ft.SPECS[name]);
503
+ } catch { /* file tools module unavailable; skip */ }
504
+ // Native web-preview tool: opens a URL in the debug Chrome and
505
+ // returns a small JPEG thumbnail. Same CDP bridge as the Inspector
506
+ // tab, gated by the project-level `webpreview` authorization mode.
507
+ try { toolSpecs.push(require('./tools/webpreview.js').SPEC); }
508
+ catch { /* webpreview module unavailable; skip */ }
509
+ try {
510
+ if (opts && opts.projectDir) {
511
+ const mcpMod = require('./mcp.js');
512
+ const specs = mcpMod.listComposedToolSpecs(opts.projectDir);
513
+ if (specs && specs.length) {
514
+ for (const s of specs) {
515
+ toolSpecs.push({
516
+ type: 'function',
517
+ function: { name: s.name, description: s.description, parameters: s.parameters }
518
+ });
519
+ }
520
+ }
521
+ }
522
+ } catch { /* mcp module not loaded or project dir invalid; fall through without MCP tools */ }
523
+
524
+ // Tools in `off` authorization mode are dropped from the advertised
525
+ // set: a hidden tool costs zero prompt tokens and the model cannot
526
+ // waste turns calling something that would fail with ETOOL_DISABLED.
527
+ // authorize() still rejects `off` calls at execution time as
528
+ // defense-in-depth (e.g. a hand-crafted REST call or a stale spec
529
+ // name kept in a chat's tool filter). File tools resolve through
530
+ // their `file` family name; MCP tools resolve per tool / per server
531
+ // through the layered .mcp.json authorization block.
532
+ try {
533
+ if (opts && opts.projectDir) {
534
+ const authz = require('./tools/authorization.js');
535
+ const authState = authz.getAuthorization(opts.projectDir, opts && opts.chatId);
536
+ for (const family of ['shell', 'subagent', 'file', 'ask_user', 'report_progress', 'task', 'webpreview', 'restart_app']) {
537
+ const cfg = authState.tools[family];
538
+ if (cfg && cfg.mode === 'off') {
539
+ const hidden = family === 'file' ? authz.FILE_FAMILY_TOOLS : new Set([family]);
540
+ for (let i = toolSpecs.length - 1; i >= 0; i--) {
541
+ const spec = toolSpecs[i];
542
+ if (spec && spec.function && hidden.has(spec.function.name)) toolSpecs.splice(i, 1);
543
+ }
544
+ }
545
+ }
546
+ // Per-leaf file overrides: a single file operation can carry its own
547
+ // `off` (e.g. tools.read_file.mode = "off") while the `file` family
548
+ // stays enabled. The family loop above only fires when the family
549
+ // itself is `off`, so resolve each file-tool spec through
550
+ // effectiveConfig to honor the leaf. Without this the leaf was still
551
+ // advertised even though the execution gate rejects it with
552
+ // ETOOL_DISABLED — the model paid tokens for a tool it could never
553
+ // use. A family-level `off` still hides every leaf (the loop above
554
+ // runs first and drops them all).
555
+ for (let i = toolSpecs.length - 1; i >= 0; i--) {
556
+ const spec = toolSpecs[i];
557
+ if (!spec || !spec.function || !authz.FILE_FAMILY_TOOLS.has(spec.function.name)) continue;
558
+ const cfg = authz.effectiveConfig(opts.projectDir, spec.function.name, opts && opts.chatId);
559
+ if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
560
+ }
561
+ // MCP tools resolve through the layered gate (per-tool →
562
+ // per-server → shared, see authorization.mcpLayeredConfig): an
563
+ // `off` at any level hides exactly the mcp__<slug>__<tool> specs
564
+ // it covers — one server, or one tool — at zero prompt-token
565
+ // cost. Execution still rejects forged calls with ETOOL_DISABLED
566
+ // through authorize().
567
+ for (let i = toolSpecs.length - 1; i >= 0; i--) {
568
+ const spec = toolSpecs[i];
569
+ if (!spec || !spec.function || !String(spec.function.name).startsWith('mcp__')) continue;
570
+ const cfg = authz.effectiveConfig(opts.projectDir, spec.function.name, opts && opts.chatId);
571
+ if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
572
+ }
573
+ }
574
+ } catch { /* authorization state unreadable; keep every tool advertised */ }
575
+
576
+ // Per-chat tool filter. opts.enabledTools === null / undefined:
577
+ // legacy behavior — every collected spec is advertised. An array
578
+ // (even empty): restrict to those names exactly. Unknown names
579
+ // are dropped silently so a stale chat (a tool that was renamed
580
+ // or whose MCP server was stopped) does not fail the request.
581
+ // The array is captured here once — the chat UI persists the
582
+ // same set on the chat record, so we don't need to re-read it.
583
+ let visibleToolSpecs = toolSpecs;
584
+ if (opts && Array.isArray(opts.enabledTools)) {
585
+ const allow = new Set(opts.enabledTools.map((n) => String(n)));
586
+ visibleToolSpecs = toolSpecs.filter((s) => s && s.function && allow.has(s.function.name));
587
+ }
588
+
589
+ // Shrink the tool declaration according to the active prompt-size
590
+ // profile (decisions §4). For very-small, the list is compact (name +
591
+ // description, no schemas) but FIXED for the whole turn — it must not
592
+ // grow between tool-loop requests, because Anthropic's cached prefix
593
+ // (system + tools) would change and the warm cache would be
594
+ // invalidated on every round. average/extensive send the full specs
595
+ // from the start. `discoveredToolNames` is retained for the
596
+ // discover_tool dispatcher (it decides what the tool returns), but it
597
+ // no longer changes the advertised tool list.
598
+ const discoveredToolNames = new Set();
599
+ let promptProfilesMod = null;
600
+ try { promptProfilesMod = require('./promptProfiles.js'); } catch { /* optional */ }
601
+
602
+ // The multi-turn tool loop. `convo` is the working message array; it
603
+ // grows by one assistant (tool-call) message + N tool-result messages
604
+ // each iteration the model asks for tools. The model decides when its
605
+ // task is complete; tool use is not cut off after an arbitrary count.
606
+ const convo = messages.slice();
607
+ const usage = { promptTokens: 0, completionTokens: 0 };
608
+ // Anthropic prompt-cache totals across all tool rounds in this turn.
609
+ // Filled by commitRoundUsage() from the per-round trackers; folded into
610
+ // the final `done` usage block so the server and chat UI can price the
611
+ // cached tokens at the discounted rate.
612
+ const usageCache = { readTokens: 0, creationTokens: 0 };
613
+ const delegatedUsage = { promptTokens: 0, completionTokens: 0, count: 0, costCount: 0 };
614
+ let providerCost = null;
615
+ let delegatedProviderCost = null;
616
+ let completedToolRound = false;
617
+ let emptyPostToolRetries = 0;
618
+ const FINAL_ANSWER_RETRIES = 2;
619
+ // Identical-call circuit breaker state (enforced in runOneCall below).
620
+ // The loop has no fixed turn limit, so a model retrying the exact
621
+ // same failing call (e.g. an interactive command that exits
622
+ // immediately) would otherwise spin forever.
623
+ let lastToolCallKey = null;
624
+ let repeatedToolCallCount = 0;
625
+ const REPEATED_TOOL_CALL_LIMIT = 3;
626
+
627
+ function modelContentForTool(name, exec) {
628
+ return toolFeedback.compactToolFeedback({
629
+ name,
630
+ content: exec && exec.content,
631
+ result: exec && exec.result,
632
+ maxBytes: opts && opts.appSettings && opts.appSettings.toolFeedbackMaxBytes,
633
+ toolOutput: opts && opts.toolOutput
634
+ });
635
+ }
636
+
637
+ while (true) {
638
+ let effectiveToolSpecs = visibleToolSpecs;
639
+ try {
640
+ effectiveToolSpecs = promptProfilesMod
641
+ ? promptProfilesMod.reduceToolSpecs(visibleToolSpecs, opts && opts.promptSize, { discoveredToolNames })
642
+ : visibleToolSpecs;
643
+ } catch { /* non-fatal; fall back to the per-chat filtered set */ }
644
+ const result = await runUpstreamTurn(convo, effectiveToolSpecs);
645
+ if (!result.ok) return { ok: false, error: result.error, usage };
646
+
647
+ const calls = result.toolCalls;
648
+ if (!calls || !calls.length) {
649
+ // Some OpenAI-compatible models end the first follow-up request with
650
+ // `stop` but no content after receiving a tool result. Treat that as
651
+ // an incomplete exchange rather than a successful empty answer. A
652
+ // short system reminder reliably gets the model to summarize the tool
653
+ // output, while the retry cap prevents a silent model from looping.
654
+ if (completedToolRound && !String(result.assistantText || '').trim() && emptyPostToolRetries < FINAL_ANSWER_RETRIES) {
655
+ emptyPostToolRetries++;
656
+ // Anthropic merges every system-role message into the cached
657
+ // system block, so a mid-conversation system reminder would
658
+ // change the cache prefix and invalidate the warm cache for the
659
+ // rest of the chat. Ride it as a user message instead — the
660
+ // alternating user/assistant pattern stays valid and the system
661
+ // block (the cache breakpoint) stays byte-identical.
662
+ convo.push({
663
+ role: model.provider === 'anthropic' ? 'user' : 'system',
664
+ content: 'Your previous response was empty. Return the final user-facing answer now. Do not call a tool and do not return an empty response.'
665
+ });
666
+ continue;
667
+ }
668
+ if (completedToolRound && !String(result.assistantText || '').trim()) {
669
+ onEvent('message', {
670
+ delta: 'Tool execution finished, but the model did not provide a final response. Review the tool results above before retrying.'
671
+ });
672
+ }
673
+ // No tool calls this turn -> the assistant is done. Emit the
674
+ // final `done` with the accumulated usage and return. Parent
675
+ // prompt tokens stay last-round-wins, but delegated subagent
676
+ // requests are separate upstream calls and must be added so the
677
+ // chat's total usage/cost matches what providers billed.
678
+ const finalUsage = usageWithDelegated();
679
+ const finalProviderCost = totalProviderCost();
680
+ const finalDelegatedCost = delegatedCostTotal();
681
+ const finalTotalCost = totalRunCost();
682
+ onEvent('done', { usage: finalUsage, providerCost: finalProviderCost, delegatedCost: finalDelegatedCost });
683
+ return { ok: true, usage: finalUsage, providerCost: finalProviderCost, delegatedCost: finalDelegatedCost, totalCost: finalTotalCost };
684
+ }
685
+
686
+ for (const c of calls) {
687
+ if (!c.id) c.id = 'call_' + Math.random().toString(36).slice(2, 12);
688
+ }
689
+
690
+ // The model asked for tools. Append the assistant's tool-call
691
+ // message (OpenAI shape) so the follow-up request has the context.
692
+ convo.push({
693
+ role: 'assistant',
694
+ content: result.assistantText || null,
695
+ tool_calls: calls.map(c => ({
696
+ id: c.id || undefined,
697
+ type: 'function',
698
+ function: { name: c.name, arguments: c.arguments || '{}' }
699
+ }))
700
+ });
701
+
702
+ // Close the streamed assistant segment before tool cards are emitted.
703
+ // A model may send explanatory text and then request a tool; without an
704
+ // explicit boundary the browser keeps one live bubble above the tool
705
+ // cards and appends the post-tool answer back into that old bubble.
706
+ onEvent('assistant_turn_end', { content: result.assistantText || '', hasToolCalls: true });
707
+
708
+ // Execute each call, emit tool_call + tool_result, and append the
709
+ // `tool` result message the upstream needs on the next turn.
710
+ // Image blocks are also attached as native vision message parts after
711
+ // all required tool messages have been added.
712
+ //
713
+ // Parallelism: when every call in this turn is a `subagent`, run them
714
+ // concurrently — subagents are read-mostly nested chats, so the model
715
+ // can fan out independent research/analysis tasks in one turn. Mixed
716
+ // batches (subagent + file/shell/MCP) stay sequential so ordering
717
+ // guarantees hold for tools with side effects. The authorization
718
+ // session is keyed by callId and the UI routes nested events by
719
+ // parentCallId, so concurrent subagents prompt and render correctly.
720
+ const runParallel = calls.length > 1 && calls.every((c) => c.name === 'subagent');
721
+ const postToolImageMessages = [];
722
+ // Single-call runner shared with POST /api/tools/subagent (direct
723
+ // @agent dispatch from the composer). Closure state: convo (tool
724
+ // messages), call-key circuit breaker, delegated-usage counters,
725
+ // and the discoveredToolNames set for the very-small profile.
726
+ const runOneCall = async (c) => {
727
+ const out = await runSingleToolCall(c, {
728
+ opts,
729
+ onEvent,
730
+ convo,
731
+ toolSpecs,
732
+ visibleToolSpecs,
733
+ promptProfilesMod,
734
+ discoveredToolNames,
735
+ modelContentForTool,
736
+ dispatchTool,
737
+ firstStringArgument,
738
+ toolResultImageParts,
739
+ getLastToolCallKey: () => lastToolCallKey,
740
+ setLastToolCallKey: (k) => { lastToolCallKey = k; },
741
+ getRepeatedToolCallCount: () => repeatedToolCallCount,
742
+ setRepeatedToolCallCount: (n) => { repeatedToolCallCount = n; },
743
+ REPEATED_TOOL_CALL_LIMIT
744
+ });
745
+ const imageParts = out && out.imageParts;
746
+ if (imageParts && imageParts.length) {
747
+ postToolImageMessages.push({
748
+ role: 'user',
749
+ content: [
750
+ { type: 'text', text: 'Image result from tool `' + c.name + '`:' },
751
+ ...imageParts
752
+ ]
753
+ });
754
+ }
755
+ return out.exec;
756
+ };
757
+ if (runParallel) {
758
+ // Concurrent subagent fan-out. `convo` and `postToolImageMessages`
759
+ // are appended from each async worker; ordering of the tool
760
+ // messages in the follow-up request doesn't carry semantics (each
761
+ // is matched by tool_call_id), so completion order is fine.
762
+ await Promise.all(calls.map((c) => runOneCall(c)));
763
+ } else {
764
+ for (const c of calls) await runOneCall(c);
765
+ }
766
+ if (postToolImageMessages.length) convo.push(...postToolImageMessages);
767
+ completedToolRound = true;
768
+ emptyPostToolRetries = 0;
769
+ // Loop: request again with the tool results in context.
770
+ }
771
+
772
+ // ---- One upstream request (stream + accumulate) --------------------
773
+ // Performs a single request/response against the provider, streaming
774
+ // `message` deltas through onEvent as they arrive. Returns
775
+ // { ok: true, assistantText, toolCalls: [{ id, name, arguments }] }
776
+ // or { ok: false, error }. `done` is NOT emitted here — the caller
777
+ // decides when the whole exchange is finished.
778
+ async function runUpstreamTurn(convoMessages, specs) {
779
+ const req = build(model, convoMessages, true, specs, { promptCacheKey: promptCacheKeyFor(opts) });
780
+ const supportsOpenAITools = model.provider === 'openai-compatible'
781
+ || model.provider === 'openrouter'
782
+ || model.provider === 'github-copilot';
783
+ if (supportsOpenAITools && specs && specs.length) {
784
+ const builderBody = req.body;
785
+ if (builderBody && typeof builderBody === 'object') {
786
+ builderBody.tools = specs;
787
+ }
788
+ }
789
+ // Anthropic streams a tool_use block's arguments as input_json_delta
790
+ // frames across multiple SSE events. The parser generator is re-created
791
+ // for every frame, so the tool_use accumulator lives here — one map per
792
+ // upstream turn, shared by every parser call of that turn. Other
793
+ // providers pass the accumulator-less parser through untouched.
794
+ const anthropicToolAcc = model.provider === 'anthropic' ? new Map() : null;
795
+ const parseTurn = (eventName, data) => anthropicToolAcc
796
+ ? parse(eventName, data, anthropicToolAcc)
797
+ : parse(eventName, data);
798
+ // Idle watchdog on the upstream request. The provider can accept the
799
+ // socket and then go silent (dead gateway, stalled network, overloaded
800
+ // model): without a deadline the server waits forever, the chat shows
801
+ // "streaming…" permanently, and the running marker wedges the chat
802
+ // (every retry gets 409 EALREADY_RUNNING). The timer resets on every
803
+ // streamed byte, so a slow-but-chatty model never trips it — only a
804
+ // genuinely silent one does. UPSTREAM_IDLE_MS covers the quiet gap
805
+ // before the first token too (models can "think" for a long time
806
+ // before emitting anything).
807
+ const UPSTREAM_IDLE_MS = 180000; // 3 min of total silence = stuck
808
+ const upstreamCtl = new AbortController();
809
+ let idleTimer = null;
810
+ const resetIdle = () => {
811
+ if (idleTimer) clearTimeout(idleTimer);
812
+ idleTimer = setTimeout(() => {
813
+ try { upstreamCtl.abort(new Error('provider idle timeout')); } catch { /* already settled */ }
814
+ }, UPSTREAM_IDLE_MS);
815
+ };
816
+ resetIdle();
817
+ const stopIdle = () => { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } };
818
+ // An outer signal (client disconnect) aborts the same request.
819
+ let outerAbort = null;
820
+ if (signal) {
821
+ outerAbort = () => { try { upstreamCtl.abort(signal.reason || new Error('client disconnected')); } catch { /* already settled */ } };
822
+ if (signal.aborted) outerAbort();
823
+ else signal.addEventListener('abort', outerAbort, { once: true });
824
+ }
825
+ const upstream = await fetch(req.url, {
826
+ method: 'POST',
827
+ headers: req.headers,
828
+ body: JSON.stringify(req.body),
829
+ signal: upstreamCtl.signal
830
+ }).catch((e) => {
831
+ return { __networkError: e };
832
+ });
833
+
834
+ if (upstream && upstream.__networkError) {
835
+ stopIdle();
836
+ if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
837
+ const e = upstream.__networkError;
838
+ if (e && e.name === 'AbortError') {
839
+ const idle = upstreamCtl.signal.reason && upstreamCtl.signal.reason.message === 'provider idle timeout';
840
+ const clientGone = signal && signal.aborted;
841
+ if (clientGone) return { ok: false, error: { code: 'EABORTED', message: 'aborted' } };
842
+ if (idle) return { ok: false, error: { code: 'ETIMEOUT', message: 'Provider sent nothing for 3 minutes — the request was cancelled. Try again.' } };
843
+ return { ok: false, error: { code: 'EABORTED', message: 'aborted' } };
844
+ }
845
+ return { ok: false, error: { code: 'ENETWORK', message: e.message || 'network error' } };
846
+ }
847
+ if (!upstream.ok) {
848
+ stopIdle();
849
+ if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
850
+ let detail = '';
851
+ try { detail = await upstream.text(); } catch { /* ignore */ }
852
+ return {
853
+ ok: false,
854
+ error: {
855
+ code: 'EUPSTREAM',
856
+ message: 'Upstream ' + upstream.status + ' ' + upstream.statusText,
857
+ detail: detail.slice(0, 2000)
858
+ }
859
+ };
860
+ }
861
+
862
+ // Stream -> normalize -> onEvent. Assistant text and any tool-call
863
+ // deltas are accumulated locally; the caller (the tool loop) decides
864
+ // what to do with them. `done` is NOT emitted here.
865
+ let sawError = null;
866
+ let assistantText = '';
867
+ let reasoningText = '';
868
+ // OpenAI tool-call accumulator. Deltas arrive split across frames;
869
+ // we assemble by `index`. The accumulator lives only for the
870
+ // duration of one turn.
871
+ const toolAcc = new Map(); // index -> { id, name, arguments }
872
+ // Per-round usage trackers. OpenAI-shaped providers report usage once
873
+ // on the final chunk (with stream_options.include_usage), but some
874
+ // compatible gateways stamp a running total on every chunk. Summing
875
+ // those would double-count, so the round's LAST non-zero report is
876
+ // committed once by commitRoundUsage() when the stream ends. Across
877
+ // tool rounds, completion tokens and cost are genuinely new and do sum.
878
+ let roundPromptTokens = null;
879
+ let roundCompletionTokens = null;
880
+ // Anthropic prompt-cache metrics. `cache_read_input_tokens` are tokens
881
+ // served from the provider's prompt cache (billed at ~10% of the input
882
+ // rate); `cache_creation_input_tokens` are the tokens written into the
883
+ // cache on this request (billed at 1.25× the input rate). They flow from
884
+ // message_start (parseAnthropicSSE → usage_input) into the per-round
885
+ // trackers so per-segment cost and the final turn cost can price them
886
+ // correctly instead of charging everything at the full input rate.
887
+ let roundCacheReadTokens = null;
888
+ let roundCacheCreationTokens = null;
889
+ let roundProviderCost = null;
890
+ let roundProviderCostInput = null;
891
+ let roundProviderCostOutput = null;
892
+ let roundUsageCommitted = false;
893
+ const stream = upstream.body;
894
+ const isNDJSON = def.streamFormat === 'ndjson';
895
+ try {
896
+ if (isNDJSON) {
897
+ for await (const obj of readNDJSON(stream)) {
898
+ resetIdle(); // any upstream byte proves the provider is alive
899
+ for (const ev of parseTurn('', JSON.stringify(obj))) {
900
+ apply(ev);
901
+ }
902
+ }
903
+ } else {
904
+ for await (const ev of readSSE(stream)) {
905
+ resetIdle(); // any upstream byte proves the provider is alive
906
+ for (const out of parseTurn(ev.eventName, ev.data)) {
907
+ apply(out);
908
+ }
909
+ }
910
+ }
911
+ } catch (e) {
912
+ stopIdle();
913
+ if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
914
+ if (e && e.name === 'AbortError') {
915
+ const idle = upstreamCtl.signal.reason && upstreamCtl.signal.reason.message === 'provider idle timeout';
916
+ const clientGone = signal && signal.aborted;
917
+ if (!clientGone && idle) {
918
+ onEvent('error', { code: 'ETIMEOUT', message: 'Provider went silent mid-stream — the request was cancelled. Try again.' });
919
+ return { ok: false, error: { code: 'ETIMEOUT', message: 'Provider went silent mid-stream — the request was cancelled. Try again.' } };
920
+ }
921
+ if (!clientGone) onEvent('error', { code: 'EABORTED', message: 'aborted' });
922
+ return { ok: false, error: { code: 'EABORTED', message: 'aborted' } };
923
+ }
924
+ onEvent('error', { code: 'EUPSTREAM', message: e.message || 'stream error' });
925
+ return { ok: false, error: { code: 'EUPSTREAM', message: e.message || 'stream error' } };
926
+ }
927
+ stopIdle();
928
+ if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
929
+
930
+ // Commit the round's usage exactly once. Providers that stamp usage
931
+ // on every chunk (not just the final one) would otherwise have their
932
+ // running totals summed into the turn aggregate (double-count). The
933
+ // last non-zero report of the round wins — see apply()'s `done`.
934
+ commitRoundUsage();
935
+
936
+ if (sawError) return { ok: false, error: sawError };
937
+
938
+ // Collapse the accumulator into an ordered list of tool calls.
939
+ const toolCalls = [];
940
+ for (const tc of toolAcc.values()) {
941
+ if (tc.name) toolCalls.push({ id: tc.id, name: tc.name, arguments: tc.arguments });
942
+ }
943
+ // MiniMax/OpenRouter compatibility: if no native OpenAI tool call was
944
+ // emitted, recover calls serialized into assistant text. OpenRouter text is
945
+ // buffered for one turn so private sentinels never flash in the browser.
946
+ if (!toolCalls.length && model.provider === 'openrouter') {
947
+ const compat = parseMiniMaxTextToolCalls(assistantText);
948
+ if (compat.calls.length) {
949
+ assistantText = compat.text;
950
+ toolCalls.push(...compat.calls);
951
+ }
952
+ }
953
+ if (model.provider === 'openrouter') {
954
+ // Reasoning already streamed live as it arrived (see apply()).
955
+ // Only the assistant text is buffered for MiniMax tool-call
956
+ // compatibility; replaying it here would duplicate every thinking
957
+ // delta the client already rendered.
958
+ if (assistantText) onEvent('message', { delta: assistantText });
959
+ }
960
+ return { ok: true, assistantText, toolCalls };
961
+
962
+ function apply(ev) {
963
+ if (ev.name === 'message') {
964
+ if (ev.data && typeof ev.data.delta === 'string') assistantText += ev.data.delta;
965
+ // OpenRouter is buffered until the turn completes because MiniMax may
966
+ // serialize a tool call across several ordinary content deltas.
967
+ if (model.provider !== 'openrouter') onEvent('message', ev.data);
968
+ }
969
+ else if (ev.name === 'reasoning') {
970
+ if (ev.data && typeof ev.data.delta === 'string') reasoningText += ev.data.delta;
971
+ // Reasoning deltas stream live for every provider, OpenRouter
972
+ // included. Unlike `content`, reasoning text never carries a
973
+ // MiniMax-style serialized tool call, so there is no compat
974
+ // reason to buffer it — streaming keeps the client's
975
+ // "Thinking…" block filling in real time instead of popping
976
+ // in as one burst at turn end.
977
+ onEvent('reasoning', ev.data);
978
+ }
979
+ else if (ev.name === 'done') {
980
+ // Record the round's usage into per-round trackers; committed once
981
+ // by commitRoundUsage() when the stream ends. Do NOT emit `done`
982
+ // here — the outer tool loop owns the single final `done` after
983
+ // the whole exchange (all tool round-trips) has completed.
984
+ //
985
+ // promptTokens: the last report wins. Every round-trip re-sends the
986
+ // full conversation, so summing would double-count the context on
987
+ // tool-heavy turns (N rounds × full convo). The final round's
988
+ // prompt is the accurate footprint.
989
+ // completionTokens / providerCost: summed ACROSS rounds (each
990
+ // round's output is genuinely new) but last-wins WITHIN a round,
991
+ // so a provider that stamps running totals on intermediate chunks
992
+ // is not double-counted.
993
+ if (ev.data && ev.data.usage) {
994
+ const p = Number(ev.data.usage.promptTokens);
995
+ const c = Number(ev.data.usage.completionTokens);
996
+ // Only overwrite when the provider actually reported a count;
997
+ // a 0/absent value must not clobber a real number.
998
+ if (isFinite(p) && p > 0) roundPromptTokens = p;
999
+ if (isFinite(c) && c > 0) roundCompletionTokens = c;
1000
+ // Surface the round's prompt footprint so the chat UI can
1001
+ // refresh its context-usage line mid-exchange (tool rounds).
1002
+ // Anthropic already streams usage_input/usage_output; this
1003
+ // covers OpenAI-shaped providers that only report on `done`.
1004
+ if (isFinite(p) && p > 0) onEvent('usage_input', { promptTokens: p });
1005
+ }
1006
+ const cost = ev.data && ev.data.providerCost;
1007
+ if (typeof cost === 'number' && isFinite(cost) && cost >= 0) {
1008
+ roundProviderCost = cost;
1009
+ // firstFiniteNumberOrNull leaves absent fields null; keep "no
1010
+ // breakdown reported" distinct from a genuine $0 so the split
1011
+ // doesn't masquerade as known.
1012
+ const norm = (v) => (typeof v === 'number' && isFinite(v) && v > 0) ? v : null;
1013
+ roundProviderCostInput = norm(ev.data && ev.data.providerCostInput);
1014
+ roundProviderCostOutput = norm(ev.data && ev.data.providerCostOutput);
1015
+ }
1016
+ } else if (ev.name === 'usage_input') {
1017
+ const p = Number(ev.data && ev.data.promptTokens);
1018
+ // Anthropic reports this once at message_start; last wins so the
1019
+ // final round's prompt (the full conversation footprint) prevails.
1020
+ if (isFinite(p) && p > 0) roundPromptTokens = p;
1021
+ // Cache metrics ride the same message_start frame. The last non-zero
1022
+ // report wins (mirrors the prompt-token rule); a 0/absent value must
1023
+ // not clobber a real number already recorded this round.
1024
+ const cr = Number(ev.data && ev.data.cacheReadTokens);
1025
+ const cc = Number(ev.data && ev.data.cacheCreationTokens);
1026
+ if (isFinite(cr) && cr > 0) roundCacheReadTokens = cr;
1027
+ if (isFinite(cc) && cc > 0) roundCacheCreationTokens = cc;
1028
+ onEvent('usage_input', ev.data);
1029
+ } else if (ev.name === 'usage_output') {
1030
+ const c = Number(ev.data && ev.data.completionTokens);
1031
+ if (isFinite(c) && c > 0) roundCompletionTokens = c;
1032
+ onEvent('usage_output', ev.data);
1033
+ // Anthropic does not put usage on its `done` frame; the output
1034
+ // count arrives on `message_delta` and the input count on
1035
+ // `message_start`. Emit the round snapshot here so per-round cost
1036
+ // reaches intermediate segments for Anthropic too. Anthropic's
1037
+ // deltas are cumulative, so fire per delta — the server keeps the
1038
+ // last (richest) value. The trackers are intentionally NOT
1039
+ // committed here; the end-of-stream commitRoundUsage() folds the
1040
+ // final values into the turn aggregate exactly once.
1041
+ if (typeof onRoundUsage === 'function') {
1042
+ try {
1043
+ if ((roundPromptTokens || 0) > 0 || (roundCompletionTokens || 0) > 0) {
1044
+ onRoundUsage({
1045
+ promptTokens: roundPromptTokens || 0,
1046
+ completionTokens: roundCompletionTokens || 0,
1047
+ cacheReadTokens: roundCacheReadTokens || 0,
1048
+ cacheCreationTokens: roundCacheCreationTokens || 0,
1049
+ providerCost: null,
1050
+ providerCostInput: null,
1051
+ providerCostOutput: null
1052
+ });
1053
+ // The round has a snapshot; commitRoundUsage() must not
1054
+ // emit a duplicate when it folds the aggregates.
1055
+ roundUsageCommitted = true;
1056
+ }
1057
+ } catch { /* listener errors must not abort the stream */ }
1058
+ }
1059
+ } else if (ev.name === 'finish') {
1060
+ // The tool-call finish reason is handled by the outer loop
1061
+ // (it emits tool_call / tool_result). Pass through only the
1062
+ // non-tool finish reasons so the UI can show them.
1063
+ if (!(ev.data && ev.data.reason === 'tool_calls')) {
1064
+ onEvent('finish', ev.data);
1065
+ }
1066
+ } else if (ev.name === 'tool_call_delta') {
1067
+ // OpenAI streams tool calls as a list of deltas. Accumulate
1068
+ // by `index`. The first delta carries the `id`; subsequent
1069
+ // deltas fill in `function.name` (sometimes) and
1070
+ // `function.arguments` (a JSON string we concatenate).
1071
+ const d = ev.data;
1072
+ const idx = (typeof d.index === 'number') ? d.index : 0;
1073
+ let cur = toolAcc.get(idx);
1074
+ if (!cur) { cur = { id: null, name: '', arguments: '' }; toolAcc.set(idx, cur); }
1075
+ if (d.id) cur.id = d.id;
1076
+ if (d.function) {
1077
+ if (typeof d.function.name === 'string' && d.function.name) cur.name = d.function.name;
1078
+ if (typeof d.function.arguments === 'string') cur.arguments += d.function.arguments;
1079
+ }
1080
+ } else if (ev.name === 'error') {
1081
+ sawError = { code: ev.data.code || 'EUPSTREAM', message: ev.data.message || 'upstream error' };
1082
+ onEvent('error', ev.data);
1083
+ } else if (ev.name === 'passthrough') {
1084
+ onEvent('passthrough', ev.data);
1085
+ }
1086
+ }
1087
+
1088
+ // Fold the round's latest usage report into the turn aggregate. The
1089
+ // trackers are consumed, so each report is added exactly once: a
1090
+ // provider that stamps usage on every chunk overwrites the pending
1091
+ // value (last-wins) instead of accumulating it. Anthropic commits per
1092
+ // `usage_output` delta; the end-of-stream call is then a no-op and the
1093
+ // real commit for providers that report on `done` (OpenAI-shaped,
1094
+ // Ollama). The per-round snapshot for segment costing is emitted once
1095
+ // per round, carrying the most recent numbers.
1096
+ function commitRoundUsage() {
1097
+ const promptTokens = roundPromptTokens || 0;
1098
+ const completionTokens = roundCompletionTokens || 0;
1099
+ const hasUsage = promptTokens > 0 || completionTokens > 0;
1100
+ const hasCost = typeof roundProviderCost === 'number' && isFinite(roundProviderCost) && roundProviderCost >= 0;
1101
+ if (!hasUsage && !hasCost) return;
1102
+ if (promptTokens > 0) usage.promptTokens = promptTokens;
1103
+ if (completionTokens > 0) usage.completionTokens = (usage.completionTokens || 0) + completionTokens;
1104
+ if (roundCacheReadTokens) usageCache.readTokens += roundCacheReadTokens;
1105
+ if (roundCacheCreationTokens) usageCache.creationTokens += roundCacheCreationTokens;
1106
+ if (hasCost) providerCost = (providerCost || 0) + roundProviderCost;
1107
+ if (!roundUsageCommitted && hasUsage && typeof onRoundUsage === 'function') {
1108
+ try {
1109
+ onRoundUsage({
1110
+ promptTokens,
1111
+ completionTokens,
1112
+ cacheReadTokens: roundCacheReadTokens || 0,
1113
+ cacheCreationTokens: roundCacheCreationTokens || 0,
1114
+ providerCost: hasCost ? roundProviderCost : null,
1115
+ providerCostInput: roundProviderCostInput,
1116
+ providerCostOutput: roundProviderCostOutput
1117
+ });
1118
+ } catch { /* listener errors must not abort the stream */ }
1119
+ }
1120
+ if (typeof onRoundCommit === 'function') {
1121
+ // Fires exactly once per round — when the round's upstream stream has
1122
+ // ended — with the round's final token/cost snapshot. Unlike
1123
+ // onRoundUsage (a snapshot stream that providers reporting usage per
1124
+ // delta fire several times per round), this is a per-round boundary:
1125
+ // the subagent dispatcher bills each nested round the moment it
1126
+ // finishes so the parent chat's running total grows while the
1127
+ // delegated run is still working.
1128
+ try {
1129
+ onRoundCommit({
1130
+ promptTokens,
1131
+ completionTokens,
1132
+ cacheReadTokens: roundCacheReadTokens || 0,
1133
+ cacheCreationTokens: roundCacheCreationTokens || 0,
1134
+ providerCost: hasCost ? roundProviderCost : null,
1135
+ providerCostInput: roundProviderCostInput,
1136
+ providerCostOutput: roundProviderCostOutput
1137
+ });
1138
+ } catch { /* listener errors must not abort the stream */ }
1139
+ }
1140
+ roundUsageCommitted = true;
1141
+ roundPromptTokens = null;
1142
+ roundCompletionTokens = null;
1143
+ roundCacheReadTokens = null;
1144
+ roundCacheCreationTokens = null;
1145
+ roundProviderCost = null;
1146
+ roundProviderCostInput = null;
1147
+ roundProviderCostOutput = null;
1148
+ }
1149
+ } // end runUpstreamTurn
1150
+
1151
+ function usageWithDelegated(includeDelegated = true) {
1152
+ const u = {
1153
+ promptTokens: (usage.promptTokens || 0) + (includeDelegated ? (delegatedUsage.promptTokens || 0) : 0),
1154
+ completionTokens: (usage.completionTokens || 0) + (includeDelegated ? (delegatedUsage.completionTokens || 0) : 0)
1155
+ };
1156
+ // Cache totals are Anthropic-only and carry no delegated counterpart
1157
+ // (subagents may run on a different provider), so include them only
1158
+ // when the parent turn actually reported some.
1159
+ if (usageCache.readTokens || usageCache.creationTokens) {
1160
+ u.cacheReadTokens = usageCache.readTokens;
1161
+ u.cacheCreationTokens = usageCache.creationTokens;
1162
+ }
1163
+ return u;
1164
+ }
1165
+
1166
+ function parentProviderCost() {
1167
+ return (typeof providerCost === 'number' && isFinite(providerCost) && providerCost >= 0) ? providerCost : null;
1168
+ }
1169
+ function delegatedCostTotal() {
1170
+ if (!delegatedUsage.count) return 0;
1171
+ return delegatedUsage.costCount === delegatedUsage.count ? delegatedProviderCost : null;
1172
+ }
1173
+ function totalProviderCost() {
1174
+ const parent = parentProviderCost();
1175
+ const delegated = delegatedCostTotal();
1176
+ // Keep this legacy aggregate for callers that consume providerCost. New
1177
+ // callers can use delegatedCost/totalCost to avoid pricing nested tokens
1178
+ // with the parent model when only one side has provider-reported billing.
1179
+ if (parent == null || delegated == null) return null;
1180
+ return parent + delegated;
1181
+ }
1182
+ function parentCostTotal() {
1183
+ const exact = parentProviderCost();
1184
+ if (exact != null) return exact;
1185
+ try {
1186
+ const app = opts && opts.appSettings ? opts.appSettings : require('./settings.js').getApp();
1187
+ const estimate = usageMetrics.computeCost({ model, usage: usageWithDelegated(false), app });
1188
+ return estimate && estimate.known ? estimate.total : null;
1189
+ } catch { return null; }
1190
+ }
1191
+ function totalRunCost() {
1192
+ const parent = parentCostTotal();
1193
+ const delegated = delegatedCostTotal();
1194
+ return parent == null || delegated == null ? null : parent + delegated;
1195
+ }
1196
+ function delegatedCostForResult(result) {
1197
+ // An explicit null/undefined means "unknown", not $0: Number(null) is
1198
+ // 0, so the old form counted a subagent whose cost could not be
1199
+ // determined as an exact zero and silently dragged the run's known-cost
1200
+ // guard into "known", understating the total.
1201
+ const total = result && result.totalCost != null ? Number(result.totalCost) : NaN;
1202
+ if (isFinite(total) && total >= 0) return total;
1203
+ const exact = result && result.providerCost != null ? Number(result.providerCost) : NaN;
1204
+ if (isFinite(exact) && exact >= 0) return exact;
1205
+ if (!result || !result.model || !result.usage) return null;
1206
+ try {
1207
+ const app = opts && opts.appSettings ? opts.appSettings : require('./settings.js').getApp();
1208
+ const estimate = usageMetrics.computeCost({ model: result.model, usage: result.usage, app });
1209
+ return estimate && estimate.known ? estimate.total : null;
1210
+ } catch { return null; }
1211
+ }
1212
+ // reportDelegatedUsage(report)
1213
+ //
1214
+ // A nested subagent is one or more *separate* billed upstream calls, so
1215
+ // its tokens and cost ride on top of the parent turn. Reports arrive as
1216
+ // deltas and are folded in as they land:
1217
+ //
1218
+ // - every nested round that finishes reports its own increment (see the
1219
+ // `subagent` branch of dispatchTool), so the chat's live "Total" pill
1220
+ // grows while the delegated run is still working instead of jumping
1221
+ // when the whole run returns;
1222
+ // - the completion report (report.complete) commits the run's `count` —
1223
+ // and `costCount` when the run's cost is known — and adds only the
1224
+ // cost the round reports did not already cover. Only a run that
1225
+ // actually returned is counted; a delegated run that failed mid-way
1226
+ // keeps the cost its finished rounds already reported (those tokens
1227
+ // were really billed and are already on screen) without entering the
1228
+ // counts, mirroring the pre-existing rule that a failed subagent is
1229
+ // not a priced unit.
1230
+ //
1231
+ // `delegatedCostTotal()` requires one known-cost report per counted run,
1232
+ // so the counters are only touched on completion: a run in flight must
1233
+ // not make the aggregate look "known" while rounds are still unbilled.
1234
+ function reportDelegatedUsage(report) {
1235
+ if (!report) return;
1236
+ const promptTokens = Number(report.promptTokens);
1237
+ const completionTokens = Number(report.completionTokens);
1238
+ if (isFinite(promptTokens) && promptTokens > 0) delegatedUsage.promptTokens += promptTokens;
1239
+ if (isFinite(completionTokens) && completionTokens > 0) delegatedUsage.completionTokens += completionTokens;
1240
+ const cost = (typeof report.cost === 'number' && isFinite(report.cost) && report.cost > 0) ? report.cost : null;
1241
+ if (cost != null) {
1242
+ delegatedProviderCost = (delegatedProviderCost || 0) + cost;
1243
+ // Surface the increment to the SSE stream so the chat's "Total"
1244
+ // pill updates immediately. The wire shape matches the persisted
1245
+ // `cost` block on assistant messages
1246
+ // ({ known, total, input, output, currency }) so the client can
1247
+ // drop it into the live running total with no extra plumbing. The
1248
+ // final `done` event folds the same number into the parent
1249
+ // remainder; the client clears the running delta at that point so
1250
+ // nothing is double-counted.
1251
+ onEvent('usage_update', {
1252
+ cost: {
1253
+ known: true,
1254
+ total: cost,
1255
+ input: 0,
1256
+ output: 0,
1257
+ currency: 'USD'
1258
+ },
1259
+ source: 'subagent',
1260
+ modelId: report.modelId
1261
+ });
1262
+ }
1263
+ if (report.complete && report.ok) {
1264
+ delegatedUsage.count++;
1265
+ if (report.costKnown) delegatedUsage.costCount++;
1266
+ }
1267
+ }
1268
+
1269
+ function toolResultImageParts(result) {
1270
+ if (!result || !Array.isArray(result.content)) return [];
1271
+ const out = [];
1272
+ for (const block of result.content) {
1273
+ if (!block || typeof block !== 'object') continue;
1274
+ // MCP tools return images two ways: a top-level `image` content block
1275
+ // ({ type:'image', data, mimeType }) or an embedded `resource` block
1276
+ // ({ type:'resource', resource:{ blob, mimeType } }) — screenshot,
1277
+ // chart, and diagram servers commonly use the resource shape. Accept
1278
+ // both so those images actually reach the model instead of being
1279
+ // silently dropped.
1280
+ let data = null;
1281
+ let mimeType = null;
1282
+ if (block.type === 'image') {
1283
+ data = block.data || block.base64;
1284
+ mimeType = block.mimeType || block.mime_type || block.mediaType || block.media_type || 'image/png';
1285
+ } else if (block.type === 'resource' && block.resource && typeof block.resource === 'object') {
1286
+ const res = block.resource;
1287
+ const resMime = res.mimeType || res.mime_type || res.mediaType || res.media_type || '';
1288
+ // Only forward binary resources that are actually images; text
1289
+ // resources ride along in the stringified tool result instead.
1290
+ const blob = res.blob || res.data || res.base64;
1291
+ if (typeof blob === 'string' && blob && /^image\//i.test(resMime)) {
1292
+ data = blob;
1293
+ mimeType = resMime;
1294
+ }
1295
+ }
1296
+ if (typeof data === 'string' && data) {
1297
+ const url = data.startsWith('data:') ? data : ('data:' + mimeType + ';base64,' + data);
1298
+ out.push({ type: 'image_url', image_url: { url } });
1299
+ }
1300
+ }
1301
+ return out;
1302
+ }
1303
+
1304
+ // rawImageBlocks(result) — the raw `{ type:'image', data, mimeType }`
1305
+ // blocks on a tool result, in the same shape read_file's image path and
1306
+ // MCP image results emit.
1307
+ // Accepts the same `image` / `resource` shapes toolResultImageParts does,
1308
+ // but keeps the blocks as native image content (not `image_url`) so they
1309
+ // can be re-attached to a subagent's result `content` and picked up by the
1310
+ // parent's own toolResultImageParts pass. This is what carries a delegated
1311
+ // picture (e.g. a subagent's read_file or MCP image result) back to the
1312
+ // main agent.
1313
+ function rawImageBlocks(result) {
1314
+ if (!result || !Array.isArray(result.content)) return [];
1315
+ const out = [];
1316
+ for (const block of result.content) {
1317
+ if (!block || typeof block !== 'object') continue;
1318
+ let data = null;
1319
+ let mimeType = null;
1320
+ if (block.type === 'image') {
1321
+ data = block.data || block.base64;
1322
+ mimeType = block.mimeType || block.mime_type || block.mediaType || block.media_type || 'image/png';
1323
+ } else if (block.type === 'resource' && block.resource && typeof block.resource === 'object') {
1324
+ const res = block.resource;
1325
+ const resMime = res.mimeType || res.mime_type || res.mediaType || res.media_type || '';
1326
+ const blob = res.blob || res.data || res.base64;
1327
+ if (typeof blob === 'string' && blob && /^image\//i.test(resMime)) {
1328
+ data = blob;
1329
+ mimeType = resMime;
1330
+ }
1331
+ }
1332
+ if (typeof data === 'string' && data) out.push({ type: 'image', data, mimeType });
1333
+ }
1334
+ return out;
1335
+ }
1336
+
1337
+ function firstStringArgument(value) {
1338
+ if (!value || typeof value !== 'object') return '';
1339
+ for (const item of Object.values(value)) {
1340
+ if (typeof item === 'string') return item;
1341
+ }
1342
+ return '';
1343
+ }
1344
+
1345
+ // ---- Tool dispatcher -----------------------------------------------
1346
+ // Routes one tool call to its runner and returns
1347
+ // { ok, content, result } where `content` is the string fed back
1348
+ // to the model as the `tool` message, and `result` is the richer
1349
+ // object surfaced to the chat UI in the tool_result SSE event.
1350
+ async function dispatchTool(name, args, callOpts) {
1351
+ // Native shell tool.
1352
+ if (name === 'shell') {
1353
+ let out;
1354
+ try {
1355
+ const shell = require('./tools/shell.js');
1356
+ const parentOnEvent = callOpts && callOpts.onEvent;
1357
+ const callId = callOpts && callOpts.callId;
1358
+ out = await shell.runShell({
1359
+ projectDir: callOpts.projectDir,
1360
+ cmd: args && args.cmd,
1361
+ shell: args && args.shell,
1362
+ // Clamped by the authorization gate against the project's
1363
+ // maxTimeoutMs. Falls back to the model's request only when the
1364
+ // gate returned no value (it always does, so this is defensive).
1365
+ timeoutMs: (callOpts && callOpts.toolTimeoutMs != null) ? callOpts.toolTimeoutMs : (args && args.timeoutMs),
1366
+ // Stream decoded output chunks to the chat UI while the
1367
+ // command is still running so the tool card shows a live
1368
+ // preview instead of a silent spinner.
1369
+ onOutput: typeof parentOnEvent === 'function'
1370
+ ? (stream, delta) => {
1371
+ try {
1372
+ parentOnEvent('shell_output', { id: callId || null, stream, delta });
1373
+ // Inside a subagent run, re-emit under the nested event
1374
+ // name so the chunk lands in the parent subagent card
1375
+ // instead of a standalone transcript card.
1376
+ if (callOpts && callOpts.nestedSubagent) {
1377
+ parentOnEvent('subagent_event', {
1378
+ parentCallId: callOpts.callId || null,
1379
+ kind: 'shell_output',
1380
+ data: { id: callId || null, stream, delta }
1381
+ });
1382
+ }
1383
+ } catch { /* best-effort */ }
1384
+ }
1385
+ : null
1386
+ });
1387
+ } catch (e) {
1388
+ out = { ok: false, error: e.message || String(e), code: 'ESHELL' };
1389
+ }
1390
+ // The first tool message line tells the model which software and
1391
+ // which shell ran the command (the spec description says the same
1392
+ // thing up front; the per-call line survives prompt compaction).
1393
+ const identity = (out && out.identity) || 'mouaif shell';
1394
+ return { ok: !!out.ok, content: '# ' + identity + '\n' + JSON.stringify(out), result: out };
1395
+ }
1396
+
1397
+ if (name === 'activate_skill') {
1398
+ try {
1399
+ return require('./agentSkills.js').activate(callOpts && callOpts.projectDir, opts && opts.chat, args && args.name);
1400
+ } catch (e) {
1401
+ return { ok: false, content: JSON.stringify({ error: e.message, code: e.code || 'ENO_SKILL' }), result: { error: e.message, code: e.code || 'ENO_SKILL' } };
1402
+ }
1403
+ }
1404
+
1405
+ // Native task tool. Manages structured tasks with subtasks, progress
1406
+ // tracking, and completion. Tasks are in-memory per chat (do not
1407
+ // survive a server restart).
1408
+ if (name === 'task') {
1409
+ try {
1410
+ const taskMod = require('./tools/task.js');
1411
+ const validated = taskMod.validateArgs(args);
1412
+ const out = taskMod.dispatchTask(callOpts && callOpts.chatId, validated);
1413
+ // Surface task progress changes as a progress_update event so the
1414
+ // frontend progress card and the per-chat updatable status push
1415
+ // notification show the current task title and count — the same
1416
+ // notification slot the report_progress tool uses. Creation is
1417
+ // skipped: a fresh task
1418
+ // always starts at 0%, which would be a noise notification.
1419
+ if (out && out.ok && out.result && out.result.task
1420
+ && (out.result.action === 'progress_updated' || out.result.action === 'completed')
1421
+ && callOpts && typeof callOpts.onEvent === 'function') {
1422
+ const t = out.result.task;
1423
+ const completed = out.result.action === 'completed';
1424
+ callOpts.onEvent('progress_update', {
1425
+ callId: (callOpts && callOpts.callId) || null,
1426
+ kind: 'task',
1427
+ title: t.title || 'Task',
1428
+ current: typeof t.current === 'number' ? t.current : 0,
1429
+ total: typeof t.total === 'number' ? t.total : 100,
1430
+ status: completed ? 'completed' : 'running',
1431
+ message: completed
1432
+ ? 'Task complete'
1433
+ : ((typeof t.current === 'number' && typeof t.total === 'number')
1434
+ ? t.current + ' of ' + t.total
1435
+ : '')
1436
+ });
1437
+ }
1438
+ return out;
1439
+ } catch (e) {
1440
+ const r = { error: { code: e.code || 'ETASK', message: e.message || String(e) } };
1441
+ return { ok: false, content: JSON.stringify(r), result: r };
1442
+ }
1443
+ }
1444
+
1445
+ // Native app restart tool. Authorization is handled by the shared gate;
1446
+ // this dispatcher schedules the graceful relaunch after its result has had
1447
+ // time to flush through the current chat stream.
1448
+ if (name === 'restart_app') {
1449
+ try {
1450
+ return require('./tools/restart.js').runRestart(args, {
1451
+ lifecycle: callOpts && callOpts.lifecycle
1452
+ });
1453
+ } catch (e) {
1454
+ const r = { error: { code: e.code || 'ERESTART', message: e.message || String(e) } };
1455
+ return { ok: false, content: JSON.stringify(r), result: r };
1456
+ }
1457
+ }
1458
+ // Native subagent tool. It delegates to the same model with the same
1459
+ // project tool surface, including MCP. The nested call intentionally omits
1460
+
1461
+ // only `subagent` itself to avoid unbounded recursive delegation loops.
1462
+ // Authorization uses the parent chat id so the existing chat popup/card is
1463
+ // reused for any nested tool or MCP call that needs approval.
1464
+ if (name === 'subagent') {
1465
+ const task = args && typeof args.task === 'string' ? args.task.trim() : '';
1466
+ const context = args && typeof args.context === 'string' ? args.context.trim() : '';
1467
+ const agentName = args && typeof args.agent === 'string' ? args.agent.trim() : '';
1468
+ if (!task) {
1469
+ const r = { error: { code: 'EBADINPUT', message: 'task is required' } };
1470
+ return { ok: false, content: JSON.stringify(r), result: r };
1471
+ }
1472
+ // Resolve the requested agent persona (if any). Agents are
1473
+ // named subagent personas from .mouaif.json — an unknown name is
1474
+ // a hard, typed error so the model can retry with a valid one
1475
+ // instead of silently delegating to a generic subagent.
1476
+ let agentTools = null;
1477
+ let nestedModel = model; // default: inherit the chat's model
1478
+ // Default: inherit the chat's thinking level (null → inherit). An
1479
+ // explicit per-run override (authorization card) wins; otherwise an
1480
+ // agent's thinkingLevel applies. Empty string = "No thinking".
1481
+ let nestedThinkingLevel = null;
1482
+ const nestedMessages = [];
1483
+ // Per-call model override chosen on the authorization card.
1484
+ // { providerId, modelId } — the user explicitly picked a model
1485
+ // while approving this call, so it wins over the agent's pin
1486
+ // and the chat's model. Resolved to a hydrated model below.
1487
+ const chosenModel = (callOpts && callOpts.modelOverride && typeof callOpts.modelOverride === 'object')
1488
+ ? callOpts.modelOverride
1489
+ : null;
1490
+ if (chosenModel && (!chosenModel.providerId || !chosenModel.modelId)) {
1491
+ const r = { error: { code: 'EBADINPUT', message: 'Model override must set providerId and modelId' } };
1492
+ return { ok: false, content: JSON.stringify(r), result: r };
1493
+ }
1494
+ if (agentName) {
1495
+ if (!callOpts || !callOpts.projectDir) {
1496
+ const r = { error: { code: 'EUNKNOWN_AGENT', message: 'No project context to resolve agent "' + agentName + '"', available: [] } };
1497
+ return { ok: false, content: JSON.stringify(r), result: r };
1498
+ }
1499
+ let agent = null;
1500
+ let available = [];
1501
+ let agentMod = null;
1502
+ try {
1503
+ agentMod = require('./agents.js');
1504
+ const all = agentMod.list(callOpts.projectDir);
1505
+ available = all.map((a) => a.name);
1506
+ agent = all.find((a) => a.name === agentName) || null;
1507
+ } catch { /* fall through to typed error */ }
1508
+ if (!agent) {
1509
+ const r = { error: { code: 'EUNKNOWN_AGENT', message: 'Unknown agent "' + agentName + '"', available } };
1510
+ return { ok: false, content: JSON.stringify(r), result: r };
1511
+ }
1512
+ // Optional per-agent model pin. When set, the nested call runs
1513
+ // on that project model (hydrated with its provider connection)
1514
+ // instead of inheriting the chat's model. Unknown model ids
1515
+ // fail loudly — never a silent fallback.
1516
+ if (agent.modelId) {
1517
+ try {
1518
+ const rec = agentMod.resolveModel(callOpts.projectDir, agent);
1519
+ const settingsMod = require('./settings.js');
1520
+ const app = settingsMod.getApp();
1521
+ const providers = Array.isArray(app.providers) ? app.providers : [];
1522
+ const connection = providers.find((p) => p && p.id === rec.provider) || null;
1523
+ if (!connection) {
1524
+ const r = { error: { code: 'EPROVIDER_NOT_FOUND', message: 'No provider connection for "' + rec.provider + '"' } };
1525
+ return { ok: false, content: JSON.stringify(r), result: r };
1526
+ }
1527
+ nestedModel = Object.assign({}, connection, projectModelRecord(rec), {
1528
+ provider: rec.provider,
1529
+ auth: connection.auth || 'apikey'
1530
+ });
1531
+ } catch (e) {
1532
+ const r = { error: { code: e.code || 'EUNKNOWN_MODEL', message: e.message || String(e) } };
1533
+ return { ok: false, content: JSON.stringify(r), result: r };
1534
+ }
1535
+ }
1536
+ nestedMessages.push({ role: 'system', content: [{ type: 'text', text: agent.content, cache_control: { type: 'ephemeral' } }] });
1537
+ agentTools = Array.isArray(agent.tools) && agent.tools.length ? agent.tools : null;
1538
+ // Per-agent thinking level (optional). Applies only when no
1539
+ // explicit per-run override was chosen on the approval card.
1540
+ if (typeof agent.thinkingLevel === 'string' && agent.thinkingLevel.trim()) {
1541
+ nestedThinkingLevel = agent.thinkingLevel;
1542
+ }
1543
+ } else {
1544
+ nestedMessages.push({
1545
+ role: 'system',
1546
+ content: [{ type: 'text', text: 'You are a focused subagent. Answer only the delegated task. Be concise. You may use the available project tools and MCP tools when they help; authorization prompts are handled by the parent chat.', cache_control: { type: 'ephemeral' } }]
1547
+ });
1548
+ }
1549
+ // Authorization-time model override. The user picked a model on
1550
+ // the approval card for THIS delegated run, so it wins over both
1551
+ // the chat's model and an agent's pin. Resolve it the same way
1552
+ // the chat model is resolved: prefer the project model record,
1553
+ // then fall back to a live-catalog entry for the provider, and
1554
+ // hydrate either with the app-level provider connection. The
1555
+ // project record may only contribute identity/selection metadata
1556
+ // — committed project JSON must never redirect a credentialed
1557
+ // provider connection candidate (mirrors resolveModel in
1558
+ // server-shared.js).
1559
+ if (chosenModel) {
1560
+ try {
1561
+ const settingsMod = require('./settings.js');
1562
+ const resolved = settingsMod.getResolved(callOpts && callOpts.projectDir || null);
1563
+ const models = Array.isArray(resolved.models) ? resolved.models : [];
1564
+ let rec = models.find((x) => x && x.id === chosenModel.modelId && x.provider === chosenModel.providerId) || null;
1565
+ if (!rec) {
1566
+ // Live-catalog models (OpenRouter & co.) are resolved by
1567
+ // provider id; the URL/credential come from the connection.
1568
+ rec = { id: chosenModel.modelId, provider: chosenModel.providerId };
1569
+ }
1570
+ const safe = projectModelRecord(rec);
1571
+ const app = settingsMod.getApp();
1572
+ const providers = Array.isArray(app.providers) ? app.providers : [];
1573
+ const connection = providers.find((p) => p && p.id === rec.provider) || null;
1574
+ if (!connection) {
1575
+ const r = { error: { code: 'EPROVIDER_NOT_FOUND', message: 'No provider connection for "' + rec.provider + '"' } };
1576
+ return { ok: false, content: JSON.stringify(r), result: r };
1577
+ }
1578
+ nestedModel = Object.assign({}, connection, safe, {
1579
+ provider: rec.provider,
1580
+ auth: connection.auth || 'apikey'
1581
+ });
1582
+ } catch (e) {
1583
+ const r = { error: { code: e.code || 'EUNKNOWN_MODEL', message: e.message || String(e) } };
1584
+ return { ok: false, content: JSON.stringify(r), result: r };
1585
+ }
1586
+ }
1587
+ nestedMessages.push({
1588
+ role: 'user',
1589
+ content: context ? ('Task:\n' + task + '\n\nContext:\n' + context) : task
1590
+ });
1591
+ // Per-run thinking level from the authorization card wins over an
1592
+ // agent's pin. An empty string clears both so the nested call uses
1593
+ // the provider default (the user explicitly chose "No thinking").
1594
+ if (callOpts && typeof callOpts.thinkingLevel === 'string') {
1595
+ nestedThinkingLevel = callOpts.thinkingLevel;
1596
+ }
1597
+ // Apply the resolved level directly onto the nested model. `streamChat`
1598
+ // only assigns a truthy `thinkingLevel` opt, so an explicit "" (clear)
1599
+ // must delete the inherited value rather than ride through it.
1600
+ if (nestedThinkingLevel !== null) {
1601
+ if (nestedThinkingLevel === '') delete nestedModel.thinkingLevel;
1602
+ else nestedModel.thinkingLevel = nestedThinkingLevel;
1603
+ }
1604
+ // ---- live delegated billing ---------------------------------------
1605
+ // A delegated run is several separate billed upstream calls. Billing
1606
+ // it only when the whole run returns leaves the chat's live "Total"
1607
+ // frozen for the entire subagent — which, with tool-using subagents,
1608
+ // can be a long time. So every nested round reports its own increment
1609
+ // the moment it finishes, and the completion below only adds what the
1610
+ // round reports did not cover.
1611
+ //
1612
+ // `mirror` reproduces the nested run's own usage aggregation (see
1613
+ // commitRoundUsage: prompt tokens are last-round-wins because every
1614
+ // round re-sends the conversation, while completion, cache and
1615
+ // provider-reported cost accumulate), so `nestedCostSoFar()` is
1616
+ // exactly the cost the nested run reports when it returns.
1617
+ // `forwarded` tracks what has already been handed to the parent, so
1618
+ // the deltas can never bill a round twice.
1619
+ const nestedModelRef = nestedModel && nestedModel.id
1620
+ ? { id: nestedModel.id, pricing: nestedModel.pricing }
1621
+ : null;
1622
+ const mirror = {
1623
+ lastPromptTokens: 0,
1624
+ completionTokens: 0,
1625
+ cacheReadTokens: 0,
1626
+ cacheCreationTokens: 0,
1627
+ providerCost: null
1628
+ };
1629
+ const forwarded = { promptTokens: 0, completionTokens: 0, cost: 0 };
1630
+ let runCostKnown = false;
1631
+
1632
+ // Cost of the nested run as reported so far, resolved the same way
1633
+ // the completion path resolves it: provider-reported cost when a
1634
+ // round carried one, otherwise an estimate priced with the nested
1635
+ // model. Null while pricing is unknown.
1636
+ function nestedCostSoFar() {
1637
+ const usageSoFar = {
1638
+ promptTokens: mirror.lastPromptTokens,
1639
+ completionTokens: mirror.completionTokens
1640
+ };
1641
+ if (mirror.cacheReadTokens) usageSoFar.cacheReadTokens = mirror.cacheReadTokens;
1642
+ if (mirror.cacheCreationTokens) usageSoFar.cacheCreationTokens = mirror.cacheCreationTokens;
1643
+ return delegatedCostForResult({
1644
+ ok: true,
1645
+ model: nestedModelRef,
1646
+ providerCost: mirror.providerCost,
1647
+ usage: usageSoFar
1648
+ });
1649
+ }
1650
+
1651
+ // One nested round finished (streamChat's onRoundCommit): mirror its
1652
+ // usage and forward the increments to the parent's delegated totals,
1653
+ // which also updates the chat UI's running total.
1654
+ function forwardNestedRound(round) {
1655
+ if (!round) return;
1656
+ const promptTokens = Number(round.promptTokens);
1657
+ const completionTokens = Number(round.completionTokens);
1658
+ if (isFinite(promptTokens) && promptTokens > 0) mirror.lastPromptTokens = promptTokens;
1659
+ if (isFinite(completionTokens) && completionTokens > 0) mirror.completionTokens += completionTokens;
1660
+ mirror.cacheReadTokens += Number(round.cacheReadTokens) || 0;
1661
+ mirror.cacheCreationTokens += Number(round.cacheCreationTokens) || 0;
1662
+ // `providerCost: null` means "no provider-reported cost", NOT $0 —
1663
+ // Number(null) is 0, which would look like a real (free) price and
1664
+ // shadow the estimate for every round.
1665
+ const roundCost = round.providerCost == null ? NaN : Number(round.providerCost);
1666
+ if (isFinite(roundCost) && roundCost >= 0) mirror.providerCost = (mirror.providerCost || 0) + roundCost;
1667
+ const promptDelta = Math.max(0, mirror.lastPromptTokens - forwarded.promptTokens);
1668
+ const completionDelta = isFinite(completionTokens) && completionTokens > 0 ? completionTokens : 0;
1669
+ const costSoFar = nestedCostSoFar();
1670
+ if (costSoFar != null) runCostKnown = true;
1671
+ const costDelta = costSoFar == null ? 0 : Math.max(0, costSoFar - forwarded.cost);
1672
+ forwarded.promptTokens += promptDelta;
1673
+ forwarded.completionTokens += completionDelta;
1674
+ if (costSoFar != null && costSoFar > forwarded.cost) forwarded.cost = costSoFar;
1675
+ reportDelegatedUsage({
1676
+ promptTokens: promptDelta,
1677
+ completionTokens: completionDelta,
1678
+ cost: costDelta,
1679
+ modelId: nestedModelRef ? nestedModelRef.id : undefined
1680
+ });
1681
+ }
1682
+
1683
+ const nestedEvents = [];
1684
+ const parentEnabled = callOpts && Array.isArray(callOpts.enabledTools) ? callOpts.enabledTools : null;
1685
+ let nestedEnabled = parentEnabled
1686
+ ? parentEnabled.filter((toolName) => toolName !== 'subagent')
1687
+ : visibleToolSpecs
1688
+ .map((spec) => spec && spec.function && spec.function.name)
1689
+ .filter((toolName) => toolName && toolName !== 'subagent');
1690
+ // An agent's tool allowlist restricts the nested call's surface.
1691
+ // Agent tool entries can be exact tool names (e.g. "shell") or MCP
1692
+ // server slugs (e.g. "mcp__fs") which should allow every tool from
1693
+ // that server (mcp__fs__read_file, mcp__fs__write_file, ...).
1694
+ if (agentTools) {
1695
+ const allow = new Set(agentTools);
1696
+ nestedEnabled = nestedEnabled.filter((toolName) => {
1697
+ if (allow.has(toolName)) return true;
1698
+ // Prefix match for MCP server slugs: "mcp__fs" allows
1699
+ // "mcp__fs__read_file", "mcp__fs__write_file", etc.
1700
+ for (const prefix of allow) {
1701
+ if (prefix.startsWith('mcp__') && toolName.startsWith(prefix + '__')) return true;
1702
+ }
1703
+ return false;
1704
+ });
1705
+ }
1706
+ const nested = await streamChat({
1707
+ model: nestedModel,
1708
+ messages: nestedMessages,
1709
+ signal,
1710
+ projectDir: callOpts && callOpts.projectDir,
1711
+ chatId: callOpts && callOpts.chatId,
1712
+ appSettings: callOpts && callOpts.appSettings,
1713
+ lifecycle: callOpts && callOpts.lifecycle,
1714
+ promptSize: callOpts && callOpts.promptSize,
1715
+
1716
+ toolOutput: callOpts && callOpts.toolOutput,
1717
+ enabledTools: nestedEnabled,
1718
+ // Per-round billing for the live delegated cost (see above).
1719
+ onRoundCommit: forwardNestedRound,
1720
+ // Marker the shell dispatcher reads to re-emit live output
1721
+ // chunks as subagent_event so they render inside this card.
1722
+ nestedSubagent: true,
1723
+ onEvent: (eventName, data) => {
1724
+ nestedEvents.push({ name: eventName, data });
1725
+ if (typeof onEvent !== 'function') return;
1726
+ // Authorization (and ask_user) still ride the normal event
1727
+ // so the parent chat popup/card handles the nested
1728
+ // approval. The `parentTool` tag tells the chat UI to
1729
+ // route the card into the subagent's live container.
1730
+ if (eventName === 'authorization_required' || eventName === 'ask_user_required') {
1731
+ onEvent(eventName, Object.assign({}, data, { parentTool: 'subagent' }));
1732
+ return;
1733
+ }
1734
+ // Forward nested progress to the parent as-is. `report_progress`
1735
+ // and `task` progress updates from a subagent must still reach
1736
+ // the parent's push layer (sendChatPush 'progress' + the per-chat
1737
+ // updatable notification) and the transcript progress card.
1738
+ // `progress_update` never gets persisted by the parent, so it is
1739
+ // safe to reuse the event name directly (no transcript corruption,
1740
+ // unlike tool_call / tool_result / message below).
1741
+ if (eventName === 'progress_update') {
1742
+ onEvent('progress_update', data);
1743
+ return;
1744
+ }
1745
+ // Forward nested progress under a distinct event name. The
1746
+ // parent's SSE layer persists every `tool_call` / `tool_result`
1747
+ // / `message` it sees, so reusing those names would corrupt
1748
+ // the transcript with the subagent's internal turns.
1749
+ if (eventName === 'tool_call' || eventName === 'tool_result' || eventName === 'message') {
1750
+ onEvent('subagent_event', {
1751
+ parentCallId: (callOpts && callOpts.callId) || null,
1752
+ kind: eventName,
1753
+ data
1754
+ });
1755
+ }
1756
+ }
1757
+ });
1758
+ let text = '';
1759
+ const nestedToolEvents = [];
1760
+ for (const ev of nestedEvents) {
1761
+ if (ev.name === 'message' && ev.data && typeof ev.data.delta === 'string') text += ev.data.delta;
1762
+ else if (ev.name === 'tool_call' || ev.name === 'tool_result' || ev.name === 'authorization_required') nestedToolEvents.push(ev);
1763
+ }
1764
+ // Rebuild a faithful nested transcript for the UI. The plain
1765
+ // `chat` (system+user+final assistant) hides every tool turn,
1766
+ // which made the subagent preview look like no tools ran. We fold
1767
+ // streamed tool_call / tool_result events back into OpenAI-shaped
1768
+ // messages so the chat card can render them.
1769
+ const chat = nestedMessages.slice();
1770
+ // Image blocks produced by the subagent's own tool calls (a read_file
1771
+ // image, an MCP image result). Accumulated here so the delegated
1772
+ // result can carry the pixels back up to the parent — see below where
1773
+ // they are set on `r.content`, which the parent's line-387
1774
+ // toolResultImageParts(exec.result) then attaches as a vision message.
1775
+ const nestedImageParts = [];
1776
+ {
1777
+ let pendingCalls = [];
1778
+ const flushCalls = () => {
1779
+ if (!pendingCalls.length) return;
1780
+ chat.push({
1781
+ role: 'assistant',
1782
+ content: null,
1783
+ tool_calls: pendingCalls.map((c) => ({
1784
+ id: c.id || undefined,
1785
+ type: 'function',
1786
+ function: { name: c.name, arguments: typeof c.args === 'string' ? c.args : JSON.stringify(c.args || {}) }
1787
+ }))
1788
+ });
1789
+ pendingCalls = [];
1790
+ };
1791
+ for (const ev of nestedToolEvents) {
1792
+ const d = ev.data || {};
1793
+ if (ev.name === 'tool_call') {
1794
+ pendingCalls.push({ id: d.id, name: d.name, args: d.args });
1795
+ } else if (ev.name === 'tool_result') {
1796
+ flushCalls();
1797
+ chat.push({
1798
+ role: 'tool',
1799
+ tool_call_id: d.id || undefined,
1800
+ name: d.name,
1801
+ content: typeof d.result === 'string' ? d.result : JSON.stringify(d.result)
1802
+ });
1803
+ // webpreview's screenshot is a user-only preview (see line ~387);
1804
+ // never forward it as a model vision part.
1805
+ if (d.name !== 'webpreview') {
1806
+ for (const block of rawImageBlocks(d.result)) nestedImageParts.push(block);
1807
+ }
1808
+ }
1809
+ }
1810
+ flushCalls();
1811
+ }
1812
+ chat.push({ role: 'assistant', content: text });
1813
+ const r = nested && nested.ok
1814
+ ? { ok: true, text, chat, toolEvents: nestedToolEvents, usage: nested.usage || null, providerCost: nested.providerCost ?? null, totalCost: nested.totalCost ?? null, model: nestedModelRef }
1815
+ : { ok: false, text, chat, toolEvents: nestedToolEvents, error: nested && nested.error ? nested.error : { code: 'ESUBAGENT', message: 'subagent failed' } };
1816
+ // Record WHICH agent ran, when one was named. The name is not derivable
1817
+ // from the nested transcript (an agent's system message is its
1818
+ // instructions, not its name), and only the delegated call knows it, so
1819
+ // it has to ride the result payload or the chat card cannot say which
1820
+ // agent answered. Omitted entirely for a generic delegation.
1821
+ if (agentName) r.agent = agentName;
1822
+ // Carry the subagent's generated pictures on the result `content`, in
1823
+ // the same `{ type:'image', data, mimeType }` shape read_file's image
1824
+ // path and MCP image results emit, so
1825
+ // the parent's toolResultImageParts(exec.result) (line ~387) attaches
1826
+ // them as a vision message and postToolImageMessages paints the
1827
+ // delegated artwork for the main agent. Without this the pixels lived
1828
+ // only in the nested transcript and the main agent never saw them.
1829
+ if (nestedImageParts.length) r.content = nestedImageParts;
1830
+ // Commit the run. The round reports above already billed every nested
1831
+ // round, so this only adds what they missed — a run whose rounds
1832
+ // never reported usage, or a cost estimate that only became
1833
+ // resolvable from the full result — and marks the run as counted so
1834
+ // delegatedCostTotal() can be known again.
1835
+ const finalCost = delegatedCostForResult(r);
1836
+ if (finalCost != null) {
1837
+ runCostKnown = true;
1838
+ if (finalCost > forwarded.cost) {
1839
+ reportDelegatedUsage({
1840
+ cost: finalCost - forwarded.cost,
1841
+ modelId: nestedModelRef ? nestedModelRef.id : undefined
1842
+ });
1843
+ forwarded.cost = finalCost;
1844
+ }
1845
+ }
1846
+ reportDelegatedUsage({ complete: true, ok: !!(nested && nested.ok), costKnown: runCostKnown });
1847
+ return { ok: !!(nested && nested.ok), content: JSON.stringify(r), result: r };
1848
+ }
1849
+
1850
+ // Native ask_user tool. The runner is a thin shim: it folds the
1851
+ // user's structured answer (carried on callOpts.answerPayload, set
1852
+ // by the authorization gate above) into a { ok, content, result }
1853
+ // triple the AI client returns to the model. The actual user
1854
+ // interaction rides the `ask_user_required` SSE event; the chat
1855
+ // UI is the only thing that ever sees the question payload.
1856
+ if (name === 'ask_user') {
1857
+ let askMod;
1858
+ try { askMod = require('./tools/ask.js'); }
1859
+ catch (e) {
1860
+ const r = { error: { code: 'EMODULE', message: 'ask_user tool module unavailable: ' + (e.message || e) } };
1861
+ return { ok: false, content: JSON.stringify(r), result: r };
1862
+ }
1863
+ let validated;
1864
+ try { validated = askMod.validateArgs(args); }
1865
+ catch (e) {
1866
+ const r = { error: { code: e.code || 'EBADINPUT', message: e.message } };
1867
+ return { ok: false, content: JSON.stringify(r), result: r };
1868
+ }
1869
+ // The deny path passes { cancelled: true } explicitly. Any other
1870
+ // missing payload means the gate resolved without prompting — a
1871
+ // bug, not a user dismissal — so surface it as an internal error
1872
+ // the model can report instead of a silent "cancelled".
1873
+ const payload = (callOpts && callOpts.answerPayload);
1874
+ if (!payload) {
1875
+ const r = { error: { code: 'ENOANSWER', message: 'ask_user resolved without a user answer; the question was not shown or the session was stale. Ask the user again.' } };
1876
+ return { ok: false, content: JSON.stringify(r), result: r };
1877
+ }
1878
+ const choice = payload && Array.isArray(payload.choice) ? payload.choice.slice() : (payload && typeof payload.choice === 'string' ? payload.choice : '');
1879
+ const extra = askMod.clampExtra(payload && typeof payload.extra === 'string' ? payload.extra : '');
1880
+ const out = askMod.buildResult({
1881
+ choice,
1882
+ extra,
1883
+ options: validated.options,
1884
+ multiSelect: validated.multiSelect,
1885
+ cancelled: !!(payload && payload.cancelled)
1886
+ });
1887
+ return { ok: out.ok, content: out.content, result: out.result };
1888
+ }
1889
+
1890
+ // Native list_features tool — returns the full structured feature
1891
+ // state for the current project and chat. Not gated by authorization:
1892
+ // it is read-only metadata, does not execute commands or modify files.
1893
+ if (name === 'list_features') {
1894
+ let af;
1895
+ try { af = require('./agentFeatures.js'); }
1896
+ catch (e) {
1897
+ const r = { error: { code: 'EMODULE', message: 'agentFeatures module unavailable: ' + (e.message || e) } };
1898
+ return { ok: false, content: JSON.stringify(r), result: r };
1899
+ }
1900
+ return await af.dispatchListFeatures(args, {
1901
+ projectDir: callOpts && callOpts.projectDir,
1902
+ chatId: callOpts && callOpts.chatId,
1903
+ chat: callOpts && callOpts.chat
1904
+ });
1905
+ }
1906
+
1907
+ // Native report_progress tool — validates args, emits a
1908
+ // progress_update SSE event so the frontend can show a live
1909
+ // progress bar, and returns the structured data to the model.
1910
+ if (name === 'report_progress') {
1911
+ let progMod;
1912
+ try { progMod = require('./tools/progress.js'); }
1913
+ catch (e) {
1914
+ const r = { error: { code: 'EMODULE', message: 'report_progress tool module unavailable: ' + (e.message || e) } };
1915
+ return { ok: false, content: JSON.stringify(r), result: r };
1916
+ }
1917
+ let validated;
1918
+ try { validated = progMod.validateArgs(args); }
1919
+ catch (e) {
1920
+ const r = { error: { code: e.code || 'EBADINPUT', message: e.message } };
1921
+ return { ok: false, content: JSON.stringify(r), result: r };
1922
+ }
1923
+ // Emit progress_update SSE event for the frontend.
1924
+ if (callOpts && callOpts.onEvent && typeof callOpts.onEvent === 'function') {
1925
+ callOpts.onEvent('progress_update', {
1926
+ callId: (callOpts && callOpts.callId) || null,
1927
+ title: validated.title,
1928
+ current: validated.current,
1929
+ total: validated.total,
1930
+ status: validated.status,
1931
+ message: validated.message || ''
1932
+ });
1933
+ }
1934
+ return progMod.buildResult(validated);
1935
+ }
1936
+
1937
+ // Native file tools: read_file, list_files, search_files, write_file,
1938
+ // edit_file (compatibility alias for a full-file write).
1939
+ // Gated by callOpts.fileToolsEnabled (matches the spec-collection
1940
+ // branch above). Dispatched in one shot — all four share the same
1941
+ // path-safety, size-cap, and authorization story, so a single
1942
+ // dispatch helper keeps the call site readable.
1943
+ if (name === 'read_file' || name === 'list_files' || name === 'search_files' || name === 'write_file' || name === 'edit_file') {
1944
+ let ft;
1945
+ try { ft = require('./tools/files.js'); }
1946
+ catch (e) {
1947
+ const r = { error: { code: 'EMODULE', message: 'file tools module unavailable: ' + (e.message || e) } };
1948
+ return { ok: false, content: JSON.stringify(r), result: r };
1949
+ }
1950
+ return await ft.runFileTool(name, {
1951
+ projectDir: callOpts && callOpts.projectDir,
1952
+ args,
1953
+ settings: callOpts && callOpts.appSettings,
1954
+ toolOutput: callOpts && callOpts.toolOutput
1955
+ });
1956
+ }
1957
+
1958
+ // Native web-preview tool: opens a URL in the debug Chrome and
1959
+ // returns a small JPEG screenshot of what is on the page. The
1960
+ // chat UI renders it as a thumbnail card; tapping the card
1961
+ // opens a full-screen modal with a close button. Authorize is
1962
+ // handled by the dispatch gate above (it treats `webpreview` as
1963
+ // a native family like the others), so the runner does not see
1964
+ // a denial again — it just runs.
1965
+ if (name === 'webpreview') {
1966
+ let wp;
1967
+ try { wp = require('./tools/webpreview.js'); }
1968
+ catch (e) {
1969
+ const r = { error: { code: 'EMODULE', message: 'webpreview tool module unavailable: ' + (e.message || e) } };
1970
+ return { ok: false, content: JSON.stringify(r), result: r };
1971
+ }
1972
+ try {
1973
+ const out = await wp.runWebpreview({
1974
+ url: args && args.url,
1975
+ viewport: args && args.viewport,
1976
+ signal: callOpts && callOpts.signal
1977
+ });
1978
+ return out;
1979
+ } catch (e) {
1980
+ const r = { error: { code: 'EWEBPREVIEW', message: e.message || String(e) } };
1981
+ return { ok: false, content: JSON.stringify(r), result: r };
1982
+ }
1983
+ }
1984
+
1985
+ // MCP tools (mcp__<serverSlug>__<toolName>).
1986
+ if (callOpts && callOpts.projectDir) {
1987
+ let mcpMod;
1988
+ try { mcpMod = require('./mcp.js'); }
1989
+ catch (e) {
1990
+ const r = { error: { code: 'EMODULE', message: 'MCP module unavailable: ' + (e.message || e) } };
1991
+ return { ok: false, content: JSON.stringify(r), result: r };
1992
+ }
1993
+ const parsed = mcpMod.parseServerSlugAndToolName(name);
1994
+ if (parsed) {
1995
+ let error = null;
1996
+ let out;
1997
+ try {
1998
+ out = await mcpMod.callTool(callOpts.projectDir, parsed.serverSlug, parsed.toolName, args);
1999
+ } catch (e) {
2000
+ error = {
2001
+ code: (e && e.code) || 'EMCP_RPC',
2002
+ message: (e && e.message) || String(e)
2003
+ };
2004
+ if (e && e.serverSlug) error.serverSlug = e.serverSlug;
2005
+ if (e && e.toolName) error.toolName = e.toolName;
2006
+ out = { ok: false, content: [{ type: 'text', text: 'MCP error: ' + (error.message || error.code) }], isError: true };
2007
+ }
2008
+ // MCP servers may report an error as a normal result with isError=true.
2009
+ // Give that path the same typed envelope as a thrown transport/RPC error.
2010
+ if (!out.ok && !error) {
2011
+ const text = Array.isArray(out.content)
2012
+ ? out.content.find((block) => block && block.type === 'text' && block.text)
2013
+ : null;
2014
+ error = {
2015
+ code: 'EMCP_RPC',
2016
+ message: (text && text.text) || 'MCP tool failed',
2017
+ serverSlug: parsed.serverSlug,
2018
+ toolName: parsed.toolName
2019
+ };
2020
+ }
2021
+ const result = {
2022
+ content: out.content,
2023
+ isError: !!out.isError,
2024
+ serverSlug: parsed.serverSlug,
2025
+ toolName: parsed.toolName
2026
+ };
2027
+ if (error) result.error = error;
2028
+ return { ok: !!out.ok, content: JSON.stringify(result), result };
2029
+ }
2030
+ }
2031
+
2032
+ // Unknown tool.
2033
+ const r = { error: { code: 'EUNKNOWN_TOOL', message: 'Unknown tool: ' + name } };
2034
+ return { ok: false, content: JSON.stringify(r), result: r };
2035
+ }
2036
+ } // end streamChat
2037
+
2038
+ module.exports = {
2039
+ streamChat,
2040
+ runSingleToolCall,
2041
+ // byte-stream helpers, re-exported for tests through src/ai.js
2042
+ parseSSEFrame,
2043
+ readSSE,
2044
+ readNDJSON,
2045
+ // OpenAI prompt-cache routing key derivation, exported for tests
2046
+ promptCacheKeyFor
2047
+ };
2048
+