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,1436 @@
1
+ 'use strict';
2
+
3
+ // Chat + message REST handlers, including the SSE streaming loop
4
+ // (handleChatStream). Extracted from the original single-file
5
+ // http-server.js so no file stays above ~2 000 lines. Shared state
6
+ // (runningChats, runningChatCancels) and helpers live in
7
+ // src/server-shared.js.
8
+
9
+ const {
10
+ sendJSON,
11
+ qs,
12
+ readJsonOr400,
13
+ runningKey,
14
+ runningChats,
15
+ runningChatCancels,
16
+ resolveModel,
17
+ settings,
18
+ chats,
19
+ messages,
20
+ trace,
21
+ push,
22
+ usage,
23
+ promptProfiles,
24
+ prompts,
25
+ agentFiles,
26
+ agentSkills,
27
+ agentFeatures,
28
+ tags,
29
+ mcp,
30
+ shellTool,
31
+ ai,
32
+ liveChat,
33
+ safeDecode
34
+ } = require('./server-shared.js');
35
+ const { resolveNotificationPrefs } = require('./notifications.js');
36
+
37
+
38
+ // resolveNotificationPrefs(saved) now lives in src/notifications.js so the
39
+ // access sign-in push and the chat streaming push resolve the same
40
+ // preferences (status, authorization, quickActions, and the login alert).
41
+ // See that module for the legacy-key fallbacks.
42
+
43
+
44
+ async function handleChats(req, res, parsed, sessionToken, lifecycle = {}) {
45
+ const urlPath = parsed.pathname;
46
+
47
+ const method = req.method;
48
+ const q = parsed.query || {};
49
+
50
+ function chatError(e) {
51
+ if (e && e.code === 'MOUAIF_PROJECT_PARSE_ERROR') return 422;
52
+ if (e && e.code === 'EBADINPUT') return 400;
53
+ return 500;
54
+ }
55
+
56
+ function readProjectDir(body) {
57
+ const fromQuery = qs(q, 'projectDir');
58
+ const fromBody = body && typeof body.projectDir === 'string' ? body.projectDir : '';
59
+ const dir = fromQuery || fromBody;
60
+ if (!dir) return null;
61
+ return dir;
62
+ }
63
+
64
+ // GET /api/chats?projectDir=<abs>[&offset=0&limit=20]
65
+ if (urlPath === '/api/chats' && method === 'GET') {
66
+ const dir = qs(q, 'projectDir');
67
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
68
+ const offset = Math.max(0, parseInt(typeof q.offset === 'string' ? q.offset : '0', 10) || 0);
69
+ const limitRaw = parseInt(typeof q.limit === 'string' ? q.limit : '0', 10) || 0;
70
+ const limit = limitRaw > 0 ? Math.min(limitRaw, 100) : 0;
71
+ try {
72
+ const page = chats.listChats(dir, { offset, limit });
73
+ const total = limit > 0 ? chats.countChats(dir) : page.length;
74
+ // Chat cost totals are persisted on chat metadata when cost-bearing
75
+ // messages are written. Listing chats never scans message_store.
76
+ // `messageCount` is a per-page bulk COUNT (one indexed GROUP BY), so the
77
+ // project card can flag a draft-only chat — persisted messages === 0 —
78
+ // without any N+1 query and without sending transcript text down.
79
+ //
80
+ // List rows are summaries (src/chatdb.js#LIST_COLUMNS): a chat's
81
+ // `draftSnippet` + `hasDraftImage` stand in for the `draft` and
82
+ // `draftAttachments` bodies, which can run to megabytes when a picture sits
83
+ // in the composer. The card only previews the draft, so the bodies stay in
84
+ // SQLite until a single-chat read (GET /api/chats/:id) asks for them.
85
+ const pageCounts = messages.projectMessageCounts(dir, page.map((c) => c.id));
86
+ for (const c of page) {
87
+ if (runningChats.has(runningKey(dir, c.id))) c.running = true;
88
+ c.messageCount = pageCounts[c.id] || 0;
89
+ }
90
+ return sendJSON(res, 200, {
91
+ chats: page,
92
+ total,
93
+ offset,
94
+ limit: limit || page.length
95
+ });
96
+ } catch (e) {
97
+ return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
98
+ }
99
+ }
100
+
101
+ // GET /api/chats/:id?projectDir=<abs>
102
+ let m = urlPath.match(/^\/api\/chats\/([^/]+)$/);
103
+ if (m && method === 'GET') {
104
+ const id = safeDecode(m[1]);
105
+ const dir = qs(q, 'projectDir');
106
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
107
+ try {
108
+ const chat = chats.getChat(dir, id);
109
+ if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
110
+ // Response-only liveness marker (never persisted on the record).
111
+ if (runningChats.has(runningKey(dir, id))) chat.running = true;
112
+ return sendJSON(res, 200, { chat });
113
+ } catch (e) {
114
+ return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
115
+ }
116
+ }
117
+
118
+ // POST /api/chats body: { projectDir, title?, trace?, promptSize?, promptId? }
119
+ if (urlPath === '/api/chats' && method === 'POST') {
120
+ const body = await readJsonOr400(req, res);
121
+ if (!body) return;
122
+ const dir = readProjectDir(body);
123
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
124
+ try {
125
+ if (body.promptId != null && body.promptId !== '') {
126
+ if (typeof body.promptId !== 'string' || !prompts.getPrompt(dir, body.promptId)) {
127
+ return sendJSON(res, 400, { error: 'Unknown promptId' });
128
+ }
129
+ }
130
+ const chat = chats.createChat(dir, body || {});
131
+ return sendJSON(res, 201, { chat });
132
+ } catch (e) {
133
+ return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
134
+ }
135
+ }
136
+
137
+ // PATCH /api/chats/:id body: { projectDir, title?, trace?, promptSize?, draft?, draftAttachments? }
138
+ m = urlPath.match(/^\/api\/chats\/([^/]+)$/);
139
+ if (m && method === 'PATCH') {
140
+ const id = safeDecode(m[1]);
141
+ const body = await readJsonOr400(req, res);
142
+ if (!body) return;
143
+ const dir = readProjectDir(body);
144
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
145
+ // Strip server-owned fields from the client patch. The generic
146
+ // merge in updateChat absorbs every key, so without this a PATCH
147
+ // could rewrite the chat's id, createdAt, or lastOpenedAt.
148
+ // Internal callers (touchChat, titleChatFromPrompt) set those
149
+ // fields intentionally and don't come through here.
150
+ const safeBody = Object.assign({}, body || {});
151
+ delete safeBody.id;
152
+ delete safeBody.createdAt;
153
+ delete safeBody.lastOpenedAt;
154
+ try {
155
+ if (safeBody.promptId != null && safeBody.promptId !== '') {
156
+ if (typeof safeBody.promptId !== 'string' || !prompts.getPrompt(dir, safeBody.promptId)) {
157
+ return sendJSON(res, 400, { error: 'Unknown promptId' });
158
+ }
159
+ }
160
+ const chat = chats.updateChat(dir, id, safeBody);
161
+ if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
162
+ return sendJSON(res, 200, { chat });
163
+ } catch (e) {
164
+ return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
165
+ }
166
+ }
167
+
168
+ // POST /api/chats/:id/touch body: { projectDir }
169
+ m = urlPath.match(/^\/api\/chats\/([^/]+)\/touch$/);
170
+ if (m && method === 'POST') {
171
+ const id = safeDecode(m[1]);
172
+ const body = await readJsonOr400(req, res);
173
+ if (!body) return;
174
+ const dir = readProjectDir(body);
175
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
176
+ try {
177
+ require('./tools/authorization.js').clearGrants(dir, id);
178
+ const chat = chats.touchChat(dir, id);
179
+ if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
180
+ return sendJSON(res, 200, { chat });
181
+ } catch (e) {
182
+ return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
183
+ }
184
+ }
185
+
186
+ // DELETE /api/chats/:id?projectDir=<abs>
187
+ m = urlPath.match(/^\/api\/chats\/([^/]+)$/);
188
+ if (m && method === 'DELETE') {
189
+ const id = safeDecode(m[1]);
190
+ const dir = qs(q, 'projectDir');
191
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
192
+ try {
193
+ const removed = chats.deleteChat(dir, id);
194
+ if (!removed) return sendJSON(res, 404, { error: 'Chat not found', id });
195
+ // Chat storage and trace export are independent. Deleting a chat
196
+ // removes its transcript, but deliberately keeps the user-owned trace
197
+ // file so it can remain committed with the project (decision §5).
198
+ try { require('fs').rmSync(messages.messagesFilePath(dir, id), { force: true }); }
199
+ catch { /* best-effort cleanup after the chat record is gone */ }
200
+ // Clean up in-memory task state.
201
+ try { require('./tools/task.js').clearChat(id); } catch { /* non-fatal */ }
202
+ return sendJSON(res, 200, { ok: true, removed: id });
203
+ } catch (e) {
204
+ return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
205
+ }
206
+ }
207
+
208
+ // ---- Per-chat messages -------------------------------------------
209
+ // GET /api/chats/:id/messages?projectDir=[&fromSeq=<seq>] -> { messages, nextSeq, base }
210
+ // GET /api/chats/:id/messages?projectDir=[&limit=<n>[&beforeSeq=<seq>]] -> { messages, total, hasMore, nextSeq, base, beforeSeq }
211
+ //
212
+ // Two modes share one URL:
213
+ //
214
+ // - Tail mode (the stream/recovery hot path): `fromSeq` is the next
215
+ // persisted row the caller has not merged. Append-only — fetch rows
216
+ // with seq >= fromSeq and avoid a full transcript transfer unless the
217
+ // server cursor is behind the local cursor. `since` is a compatibility
218
+ // alias.
219
+ //
220
+ // - Window mode (chat backward pagination): `limit` returns only the
221
+ // newest `limit` rows (the first page), and `beforeSeq` returns the
222
+ // `limit` rows strictly below that seq (the previous page). This lets
223
+ // a long transcript open fast with just the tail; older pages load on
224
+ // demand as the user scrolls up. `total` and `hasMore` let the client
225
+ // know when every older row has been reached.
226
+ const getMsgsMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/messages$/);
227
+ if (getMsgsMatch && method === 'GET') {
228
+ const id = safeDecode(getMsgsMatch[1]);
229
+ const dir = qs(q, 'projectDir');
230
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
231
+ try {
232
+ const chat = chats.getChat(dir, id);
233
+ if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
234
+ // Snapshot the aggregate alongside this response's cursor. All storage
235
+ // reads below are synchronous, so appends cannot interleave the snapshot.
236
+ const totalCost = chat.totalCost;
237
+ const rawFrom = typeof q.fromSeq === 'string' ? q.fromSeq : q.since;
238
+ const fromSeq = typeof rawFrom === 'string' ? parseInt(rawFrom, 10) : NaN;
239
+ const rawLimit = typeof q.limit === 'string' ? parseInt(q.limit, 10) : 0;
240
+ // Window mode is selected by an explicit `limit` (used by the chat
241
+ // pagination loader). It takes precedence over fromSeq so the two
242
+ // paths never conflict.
243
+ if (rawLimit > 0) {
244
+ const limit = Math.min(rawLimit, 200);
245
+ const beforeSeqRaw = typeof q.beforeSeq === 'string' ? parseInt(q.beforeSeq, 10) : NaN;
246
+ const beforeSeq = (isFinite(beforeSeqRaw) && beforeSeqRaw >= 0) ? beforeSeqRaw : Infinity;
247
+ const window = messages.listMessagesWindow(dir, id, { limit, beforeSeq });
248
+ const total = messages.getMessageCount(dir, id);
249
+ // hasMore: a full page AND the oldest returned row is not the first
250
+ // message. Because seq is contiguous from 0, an oldest seq of 0 means
251
+ // we already reached the very top.
252
+ const hasMore = window.length >= limit && window.length > 0 && window[0].seq > 0;
253
+ const nextBeforeSeq = hasMore && window.length ? window[0].seq : null;
254
+ return sendJSON(res, 200, {
255
+ messages: window,
256
+ total,
257
+ totalCost,
258
+ hasMore,
259
+ beforeSeq: nextBeforeSeq,
260
+ nextSeq: total,
261
+ base: total
262
+ });
263
+ }
264
+ const all = messages.listMessages(dir, id);
265
+ if (isFinite(fromSeq) && fromSeq >= 0) {
266
+ const tail = fromSeq <= all.length ? all.slice(fromSeq) : [];
267
+ return sendJSON(res, 200, { messages: tail, nextSeq: all.length, base: all.length, totalCost });
268
+ }
269
+ return sendJSON(res, 200, { messages: all, nextSeq: all.length, base: all.length, totalCost });
270
+ } catch (e) {
271
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
272
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
273
+ }
274
+ }
275
+
276
+ // GET /api/chats/:id/revision?projectDir= -> { nextSeq, running }
277
+ // Lightweight run state for the reconcile/recovery poll. `nextSeq` is
278
+ // the append-only transcript cursor; if it is ahead of the client cursor,
279
+ // the client fetches just `/messages?fromSeq=<localNextSeq>`.
280
+ const revMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/revision$/);
281
+ if (revMatch && method === 'GET') {
282
+ const id = safeDecode(revMatch[1]);
283
+ const dir = qs(q, 'projectDir');
284
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
285
+ try {
286
+ if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
287
+ const rev = messages.messageCursor(dir, id);
288
+ rev.running = runningChats.has(runningKey(dir, id));
289
+ return sendJSON(res, 200, rev);
290
+ } catch (e) {
291
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
292
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
293
+ }
294
+ }
295
+
296
+ // GET /api/chats/:id/system-prompt?projectDir= -> { profile, agentFiles, skills, prompt, text }
297
+ // Returns the effective system context for a chat as it will be sent
298
+ // upstream: the resolved prompt-size profile system message, the
299
+ // agent files (when enabled), and, if the chat references a custom
300
+ // prompt, that prompt's content. The chat UI renders this as the
301
+ // first (collapsible) message in the transcript so the user can see
302
+ // what the model is being told, without the prompt-size picker having
303
+ // to be a permanent fixture.
304
+ const sysPromptMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/system-prompt$/);
305
+ if (sysPromptMatch && method === 'GET') {
306
+ const id = safeDecode(sysPromptMatch[1]);
307
+ const dir = qs(q, 'projectDir');
308
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
309
+ try {
310
+ const chat = chats.getChat(dir, id);
311
+ if (!chat) return sendJSON(res, 404, { error: 'chat not found' });
312
+ let profile = null;
313
+ try {
314
+ const p = promptProfiles.resolveProfile({ chat, projectDir: dir });
315
+ if (p) profile = { id: p.id, label: p.label, description: p.description, systemMessage: p.systemMessage };
316
+ } catch { /* profile stays null; the stream would fall through too */ }
317
+ let prompt = null;
318
+ // The prompt's optional `preset` (tools + agent-files) is part of
319
+ // the chat's effective config for this request — the same shape the
320
+ // stream uses (see resolveChatEffective). It only ADDS to the
321
+ // per-chat toggle (see prompts.effectivePresetConfig), never
322
+ // overrides the project's authorization gate.
323
+ let effectiveChat = chat;
324
+ if (chat.promptId) {
325
+ try {
326
+ const cp = prompts.getPrompt(dir, chat.promptId);
327
+ if (cp) {
328
+ prompt = { id: cp.id, title: cp.title, role: cp.role, content: cp.content, preset: cp.preset || null };
329
+ if (cp.preset) effectiveChat = Object.assign({}, chat, prompts.effectivePresetConfig(chat, cp.preset));
330
+ }
331
+ } catch { /* custom prompt stays null */ }
332
+ }
333
+ // The combined text mirrors the order handleChatStream uses:
334
+ // profile system message first, then agent files, selected agent,
335
+ // skills, then the custom prompt.
336
+ const parts = [];
337
+ if (profile && profile.systemMessage) parts.push(profile.systemMessage);
338
+ let agentFilesList = null;
339
+ let agentFilesEnabled = false;
340
+ let agentFilesAvailable = [];
341
+ let agentFileNames = [];
342
+ try {
343
+ agentFilesEnabled = agentFiles.resolveEnabled({ chat: effectiveChat, projectDir: dir });
344
+ agentFileNames = agentFiles.resolveFileNames({ chat: effectiveChat, projectDir: dir });
345
+ agentFilesAvailable = agentFiles.discover(dir, agentFileNames).map((f) => ({ name: f.name, size: f.size }));
346
+ if (agentFilesEnabled) {
347
+ agentFilesList = agentFiles.load(dir, agentFileNames);
348
+ for (const af of agentFilesList) parts.push(af.content);
349
+ }
350
+ } catch { /* agent files stay null */ }
351
+ // Agents are subagent delegation targets only — never part of
352
+ // the chat's system prompt. Skills are project instruction files.
353
+ // A prompt preset with `skills: true` rides on the chat for this
354
+ // turn (see prompts.effectivePresetConfig), so the catalog
355
+ // resolution reads `effectiveChat` — not the persisted record —
356
+ // to mirror the live stream's behavior.
357
+ const skillState = agentSkills.resolve({ chat: effectiveChat, projectDir: dir });
358
+ const skillCatalog = agentSkills.catalogMessage(dir, effectiveChat);
359
+ if (skillCatalog) parts.push(skillCatalog);
360
+ if (prompt && prompt.content) parts.push(prompt.content);
361
+ // Also expose the project-level gate so the UI can render the
362
+ // per-chat toggle as locked off when the project has it disabled.
363
+ let projectAgentFiles = null;
364
+ try {
365
+ const project = require('./settings.js').getProject(dir);
366
+ if (project && typeof project.agentFiles === 'boolean') projectAgentFiles = project.agentFiles;
367
+ } catch { /* null */ }
368
+ return sendJSON(res, 200, {
369
+ profile,
370
+ agentFiles: agentFilesList ? agentFilesList.map(m => ({ name: m.name })) : null,
371
+ agentFilesEnabled,
372
+ agentFilesAvailable,
373
+ agentFileNames,
374
+ projectAgentFiles,
375
+ // `disabled` is the project lock (rendered locked with a reason);
376
+ // `chatDisabled` is this chat's own per-skill opt-out, which the
377
+ // transcript row can turn back on without touching the project.
378
+ skills: skillState.skills.map((s) => ({
379
+ id: s.id,
380
+ name: s.name,
381
+ description: s.description,
382
+ enabled: skillState.enabled && !skillState.disabled.has(s.id),
383
+ disabled: skillState.projectDisabled.has(s.id),
384
+ chatDisabled: skillState.chatDisabled.has(s.id)
385
+ })),
386
+ projectSkills: skillState.projectEnabled,
387
+ // The family flag as the STREAM resolves it, which is not always the
388
+ // persisted `chat.skills`: a prompt preset with `skills: true` rides
389
+ // on the chat for the turn (see prompts.effectivePresetConfig). The
390
+ // transcript card must show what the model will actually get.
391
+ skillsEnabled: skillState.enabled,
392
+ prompt,
393
+ text: parts.join('\n\n')
394
+ });
395
+ } catch (e) {
396
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
397
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
398
+ }
399
+ }
400
+
401
+ // GET /api/chats/:id/tool-preview?projectDir= -> { profile, tools }
402
+ // Returns the tool-declaration state for the chat's resolved
403
+ // prompt-size profile: which tools are advertised to the model and
404
+ // in what shape (full spec vs. the very-small discover_tool flow).
405
+ // The chat UI shows this as a temporary preview while
406
+ // the chat is still empty, so the user sees the concrete effect of
407
+ // the S/M/L switch on the tool budget before the first message.
408
+ // The collection logic mirrors ai.streamChat (native shell + MCP),
409
+ // then promptProfiles.reduceToolSpecs applies the same reduction the
410
+ // stream will apply — so the preview is always what the model gets.
411
+ const toolPreviewMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/tool-preview$/);
412
+ if (toolPreviewMatch && method === 'GET') {
413
+ const id = safeDecode(toolPreviewMatch[1]);
414
+ const dir = qs(q, 'projectDir');
415
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
416
+ try {
417
+ const chat = chats.getChat(dir, id);
418
+ if (!chat) return sendJSON(res, 404, { error: 'chat not found' });
419
+ // Resolve the profile id (chat -> project -> app -> 'average').
420
+ let profileId = promptProfiles.DEFAULT_PROFILE;
421
+ try {
422
+ const p = promptProfiles.resolveProfile({ chat, projectDir: dir });
423
+ if (p && p.id) profileId = p.id;
424
+ } catch { /* fall through to default */ }
425
+ // Collect the tool specs exactly as streamChat does: base shell,
426
+ // progress, subagent, ask_user, and file tools are always
427
+ // advertised, plus ready MCP servers.
428
+ const shellEnabled = true;
429
+ const fileToolsEnabled = true;
430
+ const toolSpecs = [];
431
+ if (shellEnabled) {
432
+ try { toolSpecs.push(shellTool.SPEC); } catch { /* skip */ }
433
+ }
434
+ try { toolSpecs.push(require('./tools/progress.js').SPEC); } catch { /* skip */ }
435
+ try { toolSpecs.push(require('./tools/subagent.js').SPEC); } catch { /* skip */ }
436
+ try { toolSpecs.push(require('./tools/ask.js').SPEC); } catch { /* skip */ }
437
+ try { toolSpecs.push(require('./agentFeatures.js').LIST_FEATURES_SPEC); } catch { /* skip */ }
438
+ try { toolSpecs.push(require('./tools/webpreview.js').SPEC); } catch { /* skip */ }
439
+ try { toolSpecs.push(require('./tools/restart.js').SPEC); } catch { /* skip */ }
440
+ if (fileToolsEnabled) {
441
+
442
+ try {
443
+ const fileTools = require('./tools/files.js');
444
+ for (const n of fileTools.FILE_TOOL_NAMES) toolSpecs.push(fileTools.SPECS[n]);
445
+ } catch { /* skip */ }
446
+ }
447
+ try {
448
+ const specs = mcp.listComposedToolSpecs(dir);
449
+ if (specs && specs.length) {
450
+ for (const s of specs) {
451
+ toolSpecs.push({
452
+ type: 'function',
453
+ function: { name: s.name, description: s.description, parameters: s.parameters }
454
+ });
455
+ }
456
+ }
457
+ } catch { /* no MCP tools */ }
458
+ try {
459
+ const authz = require('./tools/authorization.js');
460
+ const authState = authz.getAuthorization(dir, id);
461
+ for (const family of ['shell', 'subagent', 'file', 'ask_user', 'report_progress', 'task', 'webpreview', 'restart_app']) {
462
+ const cfg = authState.tools[family];
463
+ if (cfg && cfg.mode === 'off') {
464
+ const hidden = family === 'file' ? authz.FILE_FAMILY_TOOLS : new Set([family]);
465
+ for (let i = toolSpecs.length - 1; i >= 0; i--) {
466
+ const spec = toolSpecs[i];
467
+ if (spec && spec.function && hidden.has(spec.function.name)) toolSpecs.splice(i, 1);
468
+ }
469
+ }
470
+ }
471
+ // Per-leaf file overrides (e.g. tools.read_file.mode = "off"
472
+ // with the `file` family enabled) must drop just that operation,
473
+ // matching what streamChat advertises to the model.
474
+ for (let i = toolSpecs.length - 1; i >= 0; i--) {
475
+ const spec = toolSpecs[i];
476
+ if (!spec || !spec.function || !authz.FILE_FAMILY_TOOLS.has(spec.function.name)) continue;
477
+ const cfg = authz.effectiveConfig(dir, spec.function.name, id);
478
+ if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
479
+ }
480
+ for (let i = toolSpecs.length - 1; i >= 0; i--) {
481
+ const spec = toolSpecs[i];
482
+ if (!spec || !spec.function || !String(spec.function.name).startsWith('mcp__')) continue;
483
+ const cfg = authz.effectiveConfig(dir, spec.function.name, id);
484
+ if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
485
+ }
486
+ } catch { /* authorization state unreadable; keep every tool advertised */ }
487
+ // Apply the same per-profile reduction the stream applies. For
488
+ // very-small this is discover_tool plus one compact (name + description,
489
+ // schema-less) entry per tool — a FIXED list, identical on every
490
+ // tool-loop request, so the Anthropic cached prefix stays byte-stable.
491
+ let effective = toolSpecs;
492
+ try { effective = promptProfiles.reduceToolSpecs(toolSpecs, profileId); } catch { /* full specs */ }
493
+ const reduced = profileId === 'very-small';
494
+ const tools = (effective || []).map((s) => {
495
+ const fn = (s && s.function) || {};
496
+ const params = fn.parameters && fn.parameters.properties ? Object.keys(fn.parameters.properties) : [];
497
+ return {
498
+ name: fn.name || '',
499
+ description: typeof fn.description === 'string' ? fn.description : '',
500
+ // hasSchema reflects whether this advertised tool exposes
501
+ // parameter names. For very-small only discover_tool's own
502
+ // schema is present; the other entries are schema-less.
503
+ hasSchema: params.length > 0,
504
+ params
505
+ };
506
+ });
507
+ return sendJSON(res, 200, {
508
+ profile: profileId,
509
+ reduced,
510
+ shellEnabled,
511
+ fileToolsEnabled,
512
+ count: tools.length,
513
+ tools
514
+ });
515
+ } catch (e) {
516
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
517
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
518
+ }
519
+ }
520
+
521
+ // POST /api/chats/:id/messages body: { projectDir, role, content }
522
+ // Append a message directly. The /messages/stream endpoint below
523
+ // does the same internally for user / assistant messages; this
524
+ // route is for manual edits and tests.
525
+ if (getMsgsMatch && method === 'POST') {
526
+ const id = safeDecode(getMsgsMatch[1]);
527
+ const body = await readJsonOr400(req, res);
528
+ if (!body) return;
529
+ const dir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
530
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
531
+ try {
532
+ if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
533
+ const msg = messages.appendMessage(dir, id, { role: body.role, content: body.content, ts: body.ts });
534
+ return sendJSON(res, 201, { message: msg });
535
+ } catch (e) {
536
+ return sendJSON(res, 400, { error: e.message });
537
+ }
538
+ }
539
+
540
+ // DELETE /api/chats/:id/messages?projectDir= -> { ok, removed }
541
+ if (getMsgsMatch && method === 'DELETE') {
542
+ const id = safeDecode(getMsgsMatch[1]);
543
+ const dir = qs(q, 'projectDir');
544
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
545
+ try {
546
+ if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
547
+ const removed = messages.clearMessages(dir, id);
548
+ return sendJSON(res, 200, { ok: true, removed });
549
+ } catch (e) {
550
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
551
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
552
+ }
553
+ }
554
+
555
+ const exportTraceMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/trace\/export$/);
556
+ if (exportTraceMatch && method === 'POST') {
557
+ const id = safeDecode(exportTraceMatch[1]);
558
+ const body = await readJsonOr400(req, res);
559
+ if (!body) return;
560
+ const dir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
561
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
562
+ try {
563
+ if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
564
+ const file = trace.exportMessages(dir, id, messages.listMessages(dir, id));
565
+ return sendJSON(res, 200, { ok: true, path: file });
566
+ } catch (e) {
567
+ return sendJSON(res, e.code === 'EBADINPUT' ? 400 : 500, { error: e.message, code: e.code || 'INTERNAL' });
568
+ }
569
+ }
570
+
571
+ // POST /api/chats/import body: { projectDir, skipExisting?: bool }
572
+ // Re-import chat metadata and messages from JSON files into the DB.
573
+ if (urlPath === '/api/chats/import' && method === 'POST') {
574
+ const body = await readJsonOr400(req, res);
575
+ if (!body) return;
576
+ const dir = readProjectDir(body);
577
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
578
+ try {
579
+ const chatdb = require('./chatdb.js');
580
+ const result = chatdb.importFromJson(dir, { skipExisting: !!body.skipExisting });
581
+ chats.recomputeProjectTotalCost(dir);
582
+ return sendJSON(res, 200, { ok: true, imported: result });
583
+ } catch (e) {
584
+ return sendJSON(res, 500, { error: e.message, code: e.code || 'INTERNAL' });
585
+ }
586
+ }
587
+
588
+ // GET /api/chats/:id/live?projectDir= (SSE)
589
+ // Per-chat live-replay stream. While a chat is running, a follower
590
+ // client (another tab/device, or this UI returning to a running chat)
591
+ // subscribes here and immediately receives the buffered transient tool
592
+ // events (shell_output / subagent_event / progress_update) for the
593
+ // in-flight run, then continues to receive them as they occur. The
594
+ // stream closes with a `run_end` event when the run finishes. Requires
595
+ // the chat to actually be running — a 404 prevents a client from
596
+ // holding a dead connection waiting for content that will never come.
597
+ const liveMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/live$/);
598
+ if (liveMatch && method === 'GET') {
599
+ const id = safeDecode(liveMatch[1]);
600
+ const dir = qs(q, 'projectDir');
601
+ if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
602
+ try {
603
+ if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
604
+ const rk = runningKey(dir, id);
605
+ if (!runningChats.has(rk)) return sendJSON(res, 404, { error: 'No live run for this chat', id });
606
+ const fromLiveSeq = typeof q.fromLiveSeq === 'string' ? parseInt(q.fromLiveSeq, 10) : 0;
607
+ return liveChat.addSubscriber(rk, req, res, { fromLiveSeq: isFinite(fromLiveSeq) && fromLiveSeq >= 0 ? fromLiveSeq : 0 });
608
+ } catch (e) {
609
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
610
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
611
+ }
612
+ }
613
+
614
+ // POST /api/chats/:id/messages/stream body: { projectDir, modelId, content }
615
+ // Appends the user message, calls ai.streamChat, streams the
616
+ // response back as SSE, appends the assistant message on done, and
617
+ // writes both events to the trace file (if the chat's trace flag
618
+ // is on). One round-trip per user turn.
619
+ const streamMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/messages\/stream$/);
620
+ if (streamMatch && method === 'POST') {
621
+ return handleChatStream(req, res, streamMatch[1], sessionToken, lifecycle);
622
+ }
623
+ return sendJSON(res, 404, { error: 'Not found', scope: 'chats' });
624
+ }
625
+ // Handles POST /api/chats/:id/messages/stream. Splits out for clarity;
626
+ // the route table above stays compact.
627
+ async function handleChatStream(req, res, chatId, sessionToken, lifecycle = {}) {
628
+ const _pushSessionId = push.sessionIdFromToken(sessionToken);
629
+ const body = await readJsonOr400(req, res);
630
+ if (!body) return;
631
+ const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
632
+ const modelId = body && typeof body.modelId === 'string' ? body.modelId : '';
633
+ const providerId = body && typeof body.providerId === 'string' ? body.providerId : '';
634
+ const content = body && typeof body.content === 'string' ? body.content : '';
635
+ const attachments = messages.normalizeAttachments(body && body.attachments);
636
+ const thinkingLevel = body && typeof body.thinkingLevel === 'string' ? body.thinkingLevel : '';
637
+ const maxOutputTokens = body && typeof body.maxOutputTokens === 'string' ? body.maxOutputTokens : '';
638
+ if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
639
+ if (!modelId) return sendJSON(res, 400, { error: 'modelId is required' });
640
+ if (!content && !attachments.length) return sendJSON(res, 400, { error: 'content or image is required' });
641
+
642
+ let chat;
643
+ try { chat = chats.getChat(projectDir, chatId); }
644
+ catch (e) {
645
+ const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
646
+ return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
647
+ }
648
+ if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id: chatId });
649
+
650
+ // The chat's effective per-chat config. A custom prompt with a `preset`
651
+ // (tools + agent-files) rides on the chat for THIS turn: it is merged
652
+ // into the per-chat tool filter and agent-files toggle (see
653
+ // prompts.effectivePresetConfig) without touching the persisted chat
654
+ // record or the project's authorization gate. Falls back to `chat`
655
+ // when the prompt has no preset.
656
+ let effectiveChat = chat;
657
+ try {
658
+ if (chat.promptId) {
659
+ const preset = prompts.getPromptPreset(projectDir, chat.promptId);
660
+ if (preset) effectiveChat = Object.assign({}, chat, prompts.effectivePresetConfig(chat, preset));
661
+ }
662
+ } catch { /* preset best-effort; fall back to plain chat */ }
663
+
664
+ // Reject a second concurrent stream on the same chat. Two in-flight
665
+ // runs interleave appendMessage read-modify-writes and both append
666
+ // assistant messages, corrupting transcript order.
667
+ const runKey = runningKey(projectDir, chatId);
668
+ if (runningChats.has(runKey)) {
669
+ return sendJSON(res, 409, { error: 'A response is already streaming for this chat', code: 'EALREADY_RUNNING', id: chatId });
670
+ }
671
+
672
+ // Resolve the project model and hydrate it with its app-level provider
673
+ // connection (credentials, base URL, and auth account).
674
+ let model;
675
+ try { model = resolveModel(modelId, projectDir, providerId); }
676
+ catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code, modelId, providerId: providerId || undefined }); }
677
+
678
+ // Append the user message and bump lastOpenedAt BEFORE streaming.
679
+ // If this is the first prompt in a new/default-named chat, also
680
+ // derive a human title from that prompt and persist it immediately.
681
+ let userMsg;
682
+ try { userMsg = messages.appendMessage(projectDir, chatId, { role: 'user', content, attachments }); }
683
+ catch (e) { return sendJSON(res, 400, { error: e.message }); }
684
+ // Read the full transcript once and reuse across the title-derivation
685
+ // check and the upstream message assembly below. Two separate
686
+ // listMessages() calls would read SQLite or the JSON file twice
687
+ // for identical data (the user message was already appended above).
688
+ const history = messages.listMessages(projectDir, chatId);
689
+ try {
690
+ if (history.filter(m => m && m.role === 'user').length === 1 && content) {
691
+ const renamed = chats.titleChatFromPrompt(projectDir, chatId, content);
692
+ if (renamed) chat = renamed;
693
+ }
694
+ } catch { /* non-fatal */ }
695
+ try { chats.touchChat(projectDir, chatId); } catch { /* non-fatal */ }
696
+
697
+ // Mark the chat as running for the lifetime of the SSE response so a
698
+ // reloaded client re-enters its busy state and a second stream is
699
+ // rejected (above). Registered only after every failable setup step
700
+ // (model resolution, message append) so an early 4xx cannot leak the
701
+ // marker; cleared at every exit below (normal, error, and throw).
702
+ const runController = new AbortController();
703
+ runningChats.add(runKey);
704
+ runningChatCancels.set(runKey, runController);
705
+ liveChat.ensureLiveChat(runKey);
706
+
707
+ // Open SSE.
708
+ res.writeHead(200, {
709
+ 'Content-Type': 'text/event-stream',
710
+ 'Cache-Control': 'no-cache',
711
+ Connection: 'keep-alive'
712
+ });
713
+ res.write(': connected\n\n');
714
+
715
+ // Open the trace file. No-op writer if trace is off or the file
716
+ // system is read-only.
717
+ const traceStream = chat.trace ? trace.open(projectDir, chatId) : null;
718
+ function emit(name, data) {
719
+ try {
720
+ res.write('event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n');
721
+ } catch { /* socket closed */ }
722
+ if (traceStream) trace.write(traceStream, name, data);
723
+ // Fan transient tool streams out to followers (live-chat.js) so a
724
+ // returning page or a second tab renders them in real time. These
725
+ // are exactly the events that never reach the persisted transcript
726
+ // on their own: buffered until the matching tool_result lands (or
727
+ // the run ends), replayed to late subscribers, and pushed live to
728
+ // connected ones. Conveniently, the run entry also gives us the
729
+ // toolResultId for a later prune on `tool_result`.
730
+ if (name === 'shell_output' || name === 'subagent_event' || name === 'progress_update'
731
+ || name === 'authorization_required' || name === 'ask_user_required') {
732
+ liveChat.pushLive(runKey, name, data);
733
+ } else if (name === 'tool_result') {
734
+ liveChat.pruneLive(runKey, data && data.id);
735
+ }
736
+ }
737
+ if (traceStream) {
738
+ const event = trace.eventForMessage(userMsg);
739
+ trace.write(traceStream, event.type, event.payload);
740
+ }
741
+
742
+ // Build the message list to send upstream: existing transcript + the
743
+ // user message we just appended. The list is composed in this order
744
+ // (each block is optional, but the profile block is always present):
745
+ // 1. Prompt-size profile system message (decisions §4 prompt-size
746
+ // profiles). Resolved from chat.promptSize -> resolved project
747
+ // settings.promptSize -> 'average'. The profile carries the
748
+ // model identity + the default guidance. A missing or unknown
749
+ // value falls through to the default; this code never throws.
750
+ // 2. Agent files (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md),
751
+ // when enabled for this chat. Each file rides as its own system
752
+ // message so the model sees the file boundary.
753
+ // 3. Custom prompt (chat.promptId), if the chat references a
754
+ // project prompt. The custom prompt refines the profile — the
755
+ // instructions on each prompt say "where they do not conflict
756
+ // with the active profile".
757
+ // 4. The transcript (user + assistant turns), with the brand-new
758
+ // user turn already appended by the appendMessage call above.
759
+ const upstreamMessages = [];
760
+ // Resolve the prompt-size profile once. Its id drives BOTH the system
761
+ // message (below) and the tool-declaration reduction passed to
762
+ // streamChat (decisions §4: very-small trims tool schemas).
763
+ let resolvedProfileId = promptProfiles.DEFAULT_PROFILE;
764
+ try {
765
+ const profile = promptProfiles.resolveProfile({ chat, projectDir });
766
+ if (profile) {
767
+ if (profile.id) resolvedProfileId = profile.id;
768
+ if (profile.systemMessage) {
769
+ upstreamMessages.push({ role: 'system', content: profile.systemMessage });
770
+ }
771
+ }
772
+ } catch { /* non-fatal; stream proceeds without a profile system message */ }
773
+ // Resolve the per-project tool output profile (size + structure). The
774
+ // resolved settings are defaults → app → project, so an unset project
775
+ // gets the built-in `{ size: 'average', structure: 'full' }`. This
776
+ // drives how much of each tool result the model sees on BOTH the live
777
+ // tool loop (streamChat) and the reconstructed history (below).
778
+ let resolvedToolOutput = null;
779
+ try {
780
+ resolvedToolOutput = settings.getResolved(projectDir).toolOutput;
781
+ } catch { /* non-fatal; fall back to the defaults in toolFeedback */ }
782
+ // Agent files (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md).
783
+ // Injected after the profile but before tagged files and the custom
784
+ // prompt, so they sit close to the identity block. Each file rides
785
+ // as its own system message. A trace line records what was injected.
786
+ try {
787
+ if (agentFiles.resolveEnabled({ chat: effectiveChat, projectDir })) {
788
+ const names = agentFiles.resolveFileNames({ chat: effectiveChat, projectDir });
789
+ const injected = agentFiles.load(projectDir, names);
790
+ for (const m of injected) upstreamMessages.push({ role: m.role, content: m.content });
791
+ if (traceStream && injected.length) {
792
+ trace.write(traceStream, 'agent-files', {
793
+ files: injected.map(m => m.name)
794
+ });
795
+ }
796
+ }
797
+ } catch { /* non-fatal; stream proceeds without agent files */ }
798
+ // Agents are delegation targets for the `subagent` tool only — they
799
+ // are never injected into the main chat stream (docs/features/agents.md).
800
+ // A prompt preset with `skills: true` rides on the chat for this
801
+ // turn (see prompts.effectivePresetConfig), so the skills catalog
802
+ // resolves against `effectiveChat` — not the persisted record —
803
+ // to match the agent-files / agentFeatures paths above.
804
+ try {
805
+ const catalog = agentSkills.catalogMessage(projectDir, effectiveChat);
806
+ if (catalog) upstreamMessages.push({ role: 'system', content: catalog });
807
+ } catch { /* non-fatal; stream proceeds without skills */ }
808
+
809
+ // Agent features summary — a terse list of enabled features and their
810
+ // authorization state in the current project. Tells the model what it
811
+ // can do without the user having to guess or ask. The `list_features`
812
+ // tool gives the full structured state.
813
+ try {
814
+ // Collect what we need for the feature summary.
815
+ const project = require('./settings.js').getProject(projectDir);
816
+ let authz = null;
817
+ try { authz = require('./tools/authorization.js').getAuthorization(projectDir, effectiveChat && effectiveChat.id); } catch { /* safe default */ }
818
+ let mcpServers = null;
819
+ try { mcpServers = require('./mcp.js').listServers(projectDir); } catch { /* safe default */ }
820
+ const featureMsg = agentFeatures.buildFeatureSummary({ chat: effectiveChat, projectDir, project, authz, mcpServers });
821
+ if (featureMsg) {
822
+ upstreamMessages.push({ role: 'system', content: featureMsg });
823
+ }
824
+ } catch { /* non-fatal; stream proceeds without feature summary */ }
825
+ // Tagged files (decisions §15). Injected after the profile but before
826
+ // the custom prompt and the transcript, so they are the deepest
827
+ // context. includeInChat entries ride as `system`; any file the user
828
+ // @-referenced in this turn's message is promoted to `user`. A trace
829
+ // line records what was injected without re-reading disk on replay.
830
+ try {
831
+ const referencedPaths = tags.parseReferences(projectDir, content);
832
+ const injected = tags.resolveForInjection(projectDir, { referencedPaths });
833
+ if (injected.length) {
834
+ for (const m of injected) upstreamMessages.push({ role: m.role, content: m.content });
835
+ if (traceStream) {
836
+ trace.write(traceStream, 'tags', {
837
+ files: injected.map(m => ({ path: m.relPath, role: m.role }))
838
+ });
839
+ }
840
+ }
841
+ } catch { /* non-fatal; stream proceeds without tagged files */ }
842
+ const effectivePromptId = chat.promptId || null;
843
+ if (effectivePromptId) {
844
+ try {
845
+ const prompt = prompts.getPrompt(projectDir, effectivePromptId);
846
+ if (prompt && prompt.content) {
847
+ upstreamMessages.push({ role: prompt.role, content: prompt.content });
848
+ }
849
+ } catch { /* non-fatal; stream proceeds without the prompt */ }
850
+ }
851
+ function upstreamContentForMessage(m) {
852
+ if (!m || m.role !== 'user' || !Array.isArray(m.attachments) || !m.attachments.length) return m && m.content;
853
+ const parts = [];
854
+ if (m.content) parts.push({ type: 'text', text: m.content });
855
+ for (const a of m.attachments) parts.push({ type: 'image_url', image_url: { url: a.dataUrl } });
856
+ return parts;
857
+ }
858
+
859
+ // Reconstruct only complete historical tool call/result pairs. An aborted
860
+ // run can leave a persisted call with no result; strict OpenAI-compatible
861
+ // providers reject that orphan on the next send with HTTP 400. The helper
862
+ // also canonicalizes provider-specific call ids for cross-model resumes.
863
+ const supportsOpenAIToolHistory = model.provider === 'openai-compatible'
864
+ || model.provider === 'openrouter'
865
+ || model.provider === 'github-copilot'
866
+ || model.provider === 'anthropic'; // converted to tool_use/tool_result by buildAnthropicRequest
867
+ let toolFeedbackMaxBytes;
868
+ try { toolFeedbackMaxBytes = settings.getApp().toolFeedbackMaxBytes; } catch { /* default applies */ }
869
+ upstreamMessages.push(...messages.reconstructUpstreamHistory(history, upstreamContentForMessage, {
870
+ includeTools: supportsOpenAIToolHistory,
871
+ toolFeedbackMaxBytes,
872
+ toolOutput: resolvedToolOutput
873
+ }));
874
+
875
+ let assistantContent = '';
876
+ let assistantReasoning = '';
877
+ let assistantMsg = null;
878
+ // Track the streaming window so the cost line (which is computed
879
+ // server-side from the upstream's authoritative usage block) also
880
+ // carries the streamingMs the chat UI needs for its tok/s counter.
881
+ // (The chat UI independently tracks its own counter for live
882
+ // updates; the server-side number is the fallback when the client
883
+ // missed frames — e.g. when the tab was backgrounded.)
884
+ //
885
+ // streamingMs accumulates ONLY the assistant-streaming windows, not
886
+ // the tool-execution gaps between them. The multi-round tool loop
887
+ // would otherwise stretch the window and under-report tok/s.
888
+ let streamStartedAt = 0; // set on first message/reasoning delta
889
+ let streamingMs = 0; // accumulated across streaming windows
890
+ // Per-round usage snapshots from ai.js. Each tool round's upstream
891
+ // call reports its own prompt/completion tokens. When a round ends
892
+ // with tool calls, the pending snapshot is attached to the segment
893
+ // persisted at `assistant_turn_end`, giving it a cost. When the
894
+ // turn ends without tool calls, the snapshot is redundant — the
895
+ // `done` handler computes the final cost from aggregated usage.
896
+ let pendingRoundUsage = null;
897
+ // Running token/cost totals across all upstream rounds in this turn,
898
+ // used by the task progress push notification title ("12.4K tok · $0.0312").
899
+ let turnTokens = 0;
900
+ let turnCost = 0;
901
+ let turnCostKnown = false;
902
+ // Wall-clock start of this turn, for the status block's elapsed-time row.
903
+ // Armed on the first streamed delta and cleared on turn end, so it measures
904
+ // the model's work rather than the user's think time.
905
+ let turnStartedAt = 0;
906
+ // The most recent tool this turn ran, for the status block's activity row.
907
+ // Reset each turn so it never leaks a tool name from the previous turn.
908
+ let lastToolName = '';
909
+ // Cost already persisted on intermediate segments (assistant_turn_end).
910
+ // The final message must carry only the REMAINING cost so the chat
911
+ // total (segment costs + final cost) equals the true per-round sum —
912
+ // otherwise segment completion tokens are billed twice (once on the
913
+ // segment, once inside the final aggregate).
914
+ let persistedSegmentCost = 0;
915
+ // Nested subagents are separate billed model calls. ai-stream reports their
916
+ // fully resolved total independently so it can be added without pretending
917
+ // their tokens used the parent model's price.
918
+ let delegatedCost = null;
919
+ function accumulateRoundUsage(roundUsage) {
920
+ if (!roundUsage) return;
921
+ turnTokens += (Number(roundUsage.promptTokens) || 0) + (Number(roundUsage.completionTokens) || 0);
922
+ const segCost = computeSegmentCost(roundUsage);
923
+ if (segCost && segCost.known) { turnCost += segCost.total; turnCostKnown = true; }
924
+ }
925
+ // The turn usage as the status block's usage row: token count plus price
926
+ // when pricing is known ('12.4K tok · $0.0312'), or '' before any usage has
927
+ // been reported. It rides the notification BODY — never the title, which the
928
+ // OS shows in a fixed slot and clips first.
929
+ function pushUsageLabel() {
930
+ if (!turnTokens) return '';
931
+ return usage.formatTokens(turnTokens) + ' tok' + (turnCostKnown ? ' · ' + usage.formatCost(turnCost) : '');
932
+ }
933
+ // Per-turn enrichment (cost + usage) is computed once on `done`
934
+ // and reused for both the SSE emit and the persisted assistant
935
+ // message. The chat UI's own live counter and the cost line
936
+ // diverge slightly while the stream is in flight (the live counter
937
+ // is per-delta; the cost line is final); that's intentional.
938
+ let lastEnrichment = null;
939
+
940
+ // Compute the cost for an intermediate segment from its round's
941
+ // usage snapshot. Returns null when pricing is unavailable.
942
+ function computeSegmentCost(roundUsage) {
943
+ if (!roundUsage) return null;
944
+ try {
945
+ const app = settings.getApp();
946
+ if (typeof roundUsage.providerCost === 'number' && isFinite(roundUsage.providerCost) && roundUsage.providerCost >= 0) {
947
+ // OpenRouter reports a real input/output split under
948
+ // cost_details; fall back to 0 when the round didn't carry it.
949
+ const split = (v) => (typeof v === 'number' && isFinite(v) && v > 0) ? v : 0;
950
+ return {
951
+ known: true,
952
+ input: split(roundUsage.providerCostInput),
953
+ output: split(roundUsage.providerCostOutput),
954
+ total: roundUsage.providerCost,
955
+ currency: 'USD'
956
+ };
957
+ }
958
+ const result = usage.computeCost({ model, usage: roundUsage, app });
959
+ return { known: result.known, input: result.input, output: result.output, total: result.total, currency: result.currency };
960
+ } catch { return null; }
961
+ }
962
+
963
+ // Built-in shell and file tools are always advertised. Their authorization
964
+ // modes decide whether calls prompt, run automatically, or are disabled.
965
+ const shellEnabled = true;
966
+ const fileToolsEnabled = true;
967
+
968
+ // App-level knobs (size caps etc.) are read once and passed through
969
+ // to the file tool dispatcher. The dispatcher itself uses the
970
+ // DEFAULT_* constants when these are missing, so passing the whole
971
+ // app object is fine — only the file-tool keys are consulted.
972
+ let appSettings = {};
973
+ try { appSettings = settings.getApp() || {}; } catch { /* defaults apply */ }
974
+
975
+ // Resolve the two-slot notification preferences (status, authorization)
976
+ // plus quickActions. Current settings store the two-key shape directly;
977
+ // older stores only had five booleans (progress/completion/errors and
978
+ // askUser/toolAuthorization). Prefer the new keys when present, else
979
+ // derive from the legacy keys. Mirrors normalizePreferences() in
980
+ // frontend/src/components/SettingsNotifications.jsx.
981
+ const notificationPrefs = resolveNotificationPrefs(appSettings.notifications);
982
+ const chatUrl = `/#/chat/${chatId}?projectDir=${encodeURIComponent(projectDir)}`;
983
+ // Exactly two notification channels exist per chat: one replaceable status
984
+ // slot rendered with an ASCII bar, and one authorization/attention slot.
985
+ const statusPushTag = 'chat-' + chatId + '-status';
986
+
987
+ // statusBody(sub, percent, info) -> notification body for one device
988
+ //
989
+ // The whole status lives in the body, under the bar: the running message,
990
+ // the position in the work, the turn usage, the elapsed time, the tool, and
991
+ // the model. The notification TITLE stays the chat name — the OS shows it
992
+ // in a fixed, narrow slot and clips it first, so the facts that used to ride
993
+ // there are far more useful in the body, where a wider device simply shows
994
+ // more of them (src/statusBar.js detailLines()).
995
+ //
996
+ // `info` is the fact object from report_progress / task, enriched with the
997
+ // per-turn usage, start time, tool, and model; see statusInfo().
998
+ function statusBody(sub, percent, info) {
999
+ const plan = push.statusBar.planForSubscription(sub);
1000
+ return push.statusBar.composeStatusBody(plan, percent, info);
1001
+ }
1002
+
1003
+ // statusInfo(data, extra) -> fact object for statusBody()
1004
+ //
1005
+ // Merges the stream event with this turn's context. Every field is
1006
+ // optional: a fact that is not known yet simply does not appear, so the
1007
+ // first update of a turn is just the bar and the message.
1008
+ function statusInfo(data, extra) {
1009
+ const o = extra || {};
1010
+ const modelName = model.label || model.id
1011
+ ? ((model.provider ? model.provider + '/' : '') + (model.id || model.label || ''))
1012
+ : '';
1013
+ return {
1014
+ title: data && data.title ? String(data.title) : '',
1015
+ message: data && data.message ? String(data.message) : '',
1016
+ kind: o.kind || (data && data.kind) || '',
1017
+ current: data && data.current != null ? data.current : undefined,
1018
+ total: data && data.total != null ? data.total : undefined,
1019
+ // The activity row's parts. A tool the stream named wins over the last
1020
+ // tool this turn ran. The elapsed time stands on its own (it is useful
1021
+ // without a tool), so it does not depend on one.
1022
+ tool: o.tool || lastToolName || '',
1023
+ model: modelName,
1024
+ time: turnStartedAt ? Math.max(1, Math.round((Date.now() - turnStartedAt) / 1000)) + 's' : '',
1025
+ // The title is the chat's own name for a task, which reads as the
1026
+ // notification title it already is; the running usage is the fact
1027
+ // worth a row here.
1028
+ usage: pushUsageLabel()
1029
+ };
1030
+ }
1031
+
1032
+ function sendChatPush(kind, options = {}) {
1033
+ if (!_pushSessionId) return;
1034
+ const preferenceKey = kind === 'ask_user' || kind === 'tool_authorization'
1035
+ ? 'authorization'
1036
+ : kind === 'completion' || kind === 'error' || kind === 'progress'
1037
+ ? 'status'
1038
+ : '';
1039
+ if (preferenceKey && notificationPrefs[preferenceKey] === false) return;
1040
+ const data = Object.assign({ kind, chatId, projectDir, url: chatUrl }, options.data || {});
1041
+ push.sendPushToSession(_pushSessionId, {
1042
+ title: options.title || ((chat && chat.title) || 'mouaif'),
1043
+ body: options.body || '',
1044
+ bodyFor: options.bodyFor,
1045
+ chatId,
1046
+ projectDir,
1047
+ tag: options.tag || `chat-${chatId}-${kind}`,
1048
+ data,
1049
+ actions: options.actions,
1050
+ requireInteraction: options.requireInteraction === true
1051
+ });
1052
+ }
1053
+
1054
+ function attentionActions(kind, data) {
1055
+ const actions = [];
1056
+ if (notificationPrefs.quickActions !== false) {
1057
+ if (kind === 'tool_authorization') {
1058
+ actions.push({ action: 'allow-once', title: 'Allow once' });
1059
+ actions.push({ action: 'deny', title: 'Deny' });
1060
+ } else if (kind === 'ask_user' && data && data.multiSelect !== true && Array.isArray(data.options) && data.options.length === 2) {
1061
+ for (let i = 0; i < data.options.length; i++) {
1062
+ const option = data.options[i] || {};
1063
+ if (option.label && option.value) actions.push({ action: 'answer-' + i, title: String(option.label).slice(0, 40) });
1064
+ }
1065
+ }
1066
+ }
1067
+ if (!actions.length) actions.push({ action: 'open', title: 'Open chat' });
1068
+ return actions;
1069
+ }
1070
+
1071
+ // formatStreamError(err) — one-line, user-facing summary of a
1072
+ // failed turn. Persisted as a system message and shown as the
1073
+ // chat's error bubble, so keep it short: code + message + the
1074
+ // first line of any upstream detail (provider error bodies can
1075
+ // run to a full HTML page — useless in a chat bubble).
1076
+ function formatStreamError(err) {
1077
+ if (!err || typeof err !== 'object') return 'Request failed';
1078
+ const code = err.code ? err.code + ': ' : '';
1079
+ const msg = err.message || 'Request failed';
1080
+ let detail = '';
1081
+ if (typeof err.detail === 'string' && err.detail) {
1082
+ detail = ' — ' + err.detail.split('\n').map(l => l.trim()).filter(Boolean).slice(0, 1).join(' ').slice(0, 300);
1083
+ }
1084
+ return '⚠ ' + code + msg + detail;
1085
+ }
1086
+
1087
+ // persistStreamError(err) — write the failure into the transcript
1088
+ // as a system message so it survives a reload and lands in the
1089
+ // chat history (errors belong in the chat, not just in a transient
1090
+ // status line). Kept best-effort: a read-only transcript must not
1091
+ // mask the original error.
1092
+ function persistStreamError(err) {
1093
+ try {
1094
+ messages.appendMessage(projectDir, chatId, { role: 'system', content: formatStreamError(err) });
1095
+ } catch { /* non-fatal */ }
1096
+ }
1097
+
1098
+ // Keep the server-side chat run alive even if the browser tab or SSE
1099
+ // connection disappears. All stream writes are best-effort and the
1100
+ // transcript remains authoritative, so a reloaded client can catch up by
1101
+ // polling persisted messages instead of causing an upstream abort with
1102
+ // "client disconnected".
1103
+
1104
+ let result;
1105
+ try {
1106
+ result = await ai.streamChat({
1107
+ model,
1108
+ messages: upstreamMessages,
1109
+ projectDir,
1110
+ chatId, // Pass chatId for authorization gate
1111
+ shellEnabled,
1112
+ fileToolsEnabled,
1113
+ appSettings,
1114
+ lifecycle,
1115
+ promptSize: resolvedProfileId,
1116
+
1117
+ toolOutput: resolvedToolOutput,
1118
+ thinkingLevel: thinkingLevel || chat.thinkingLevel || '',
1119
+ maxOutputTokens: maxOutputTokens || chat.maxOutputTokens || '',
1120
+ signal: runController.signal,
1121
+ // Per-chat tool filter (decisions: chat.tools). null/undefined
1122
+ // means "all tools available to the project"; an array (even an
1123
+ // empty one) means "restrict to exactly these tool names". The
1124
+ // legacy fields above stay so existing API clients keep working.
1125
+ // Chat tool filter wins; otherwise all project tools are offered.
1126
+ // `effectiveChat` folds in the prompt's preset tools (if any) the
1127
+ // same way it feeds agent-files above.
1128
+ enabledTools: Array.isArray(effectiveChat.tools) ? effectiveChat.tools : null,
1129
+ chat,
1130
+ // Per-round usage snapshot (one per upstream API call, including
1131
+ // tool rounds). Stashed so `assistant_turn_end` can attach cost
1132
+ // to the intermediate segment it persists.
1133
+ onRoundUsage: (roundUsage) => { pendingRoundUsage = roundUsage; accumulateRoundUsage(roundUsage); },
1134
+ onEvent: (name, data) => {
1135
+ if (name === 'message' && typeof data.delta === 'string') {
1136
+ if (!streamStartedAt) streamStartedAt = Date.now();
1137
+ if (!turnStartedAt) turnStartedAt = streamStartedAt;
1138
+ assistantContent += data.delta;
1139
+ try { res.write('event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n'); } catch { /* socket closed */ }
1140
+ return;
1141
+ } else if (name === 'reasoning' && typeof data.delta === 'string') {
1142
+ if (!streamStartedAt) streamStartedAt = Date.now();
1143
+ if (!turnStartedAt) turnStartedAt = streamStartedAt;
1144
+ assistantReasoning += data.delta;
1145
+ try { res.write('event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n'); } catch { /* socket closed */ }
1146
+ return;
1147
+ } else if (name === 'assistant_turn_end') {
1148
+ // A tool round is starting: fold the window that just ended into
1149
+ // the accumulator and clear the start marker. The next assistant
1150
+ // delta re-arms streamStartedAt.
1151
+ if (streamStartedAt) { streamingMs += Date.now() - streamStartedAt; streamStartedAt = 0; }
1152
+ // Persist text produced before a tool call at its real transcript
1153
+ // position, then start a fresh segment for the post-tool response.
1154
+ // Attach the round's usage/cost so this segment shows its own
1155
+ // cost line in the chat UI.
1156
+ // The same numbers ride the SSE event so the live bubble can
1157
+ // render the round's real cost without waiting for reconciliation.
1158
+ let segmentCost = null;
1159
+ let segmentUsage;
1160
+ if (assistantContent.trim() || assistantReasoning.trim()) {
1161
+ try {
1162
+ segmentCost = computeSegmentCost(pendingRoundUsage);
1163
+ if (segmentCost && segmentCost.known && typeof segmentCost.total === 'number') {
1164
+ persistedSegmentCost += segmentCost.total;
1165
+ }
1166
+ segmentUsage = pendingRoundUsage
1167
+ ? {
1168
+ promptTokens: pendingRoundUsage.promptTokens,
1169
+ completionTokens: pendingRoundUsage.completionTokens,
1170
+ cacheReadTokens: pendingRoundUsage.cacheReadTokens || 0,
1171
+ cacheCreationTokens: pendingRoundUsage.cacheCreationTokens || 0
1172
+ }
1173
+ : undefined;
1174
+ assistantMsg = messages.appendMessage(projectDir, chatId, {
1175
+ role: 'assistant', content: assistantContent, reasoning: assistantReasoning, modelId: model.id,
1176
+ usage: segmentUsage,
1177
+ cost: segmentCost || undefined
1178
+ });
1179
+ if (traceStream && assistantMsg) {
1180
+ const event = trace.eventForMessage(assistantMsg);
1181
+ trace.write(traceStream, event.type, event.payload);
1182
+ }
1183
+ } catch { /* non-fatal */ }
1184
+ }
1185
+ // The snapshot is consumed whether or not this segment had text.
1186
+ // Leaving it set on a no-text round would leak round N's tokens
1187
+ // into round N+1's segment (double-counted cost in the totals).
1188
+ pendingRoundUsage = null;
1189
+ assistantContent = '';
1190
+ assistantReasoning = '';
1191
+ // Emit the enriched frame (cost + usage attached) and skip the
1192
+ // generic emit below so the client never sees a cost-less copy.
1193
+ emit(name, Object.assign({}, data, {
1194
+ usage: segmentUsage,
1195
+ cost: segmentCost || undefined,
1196
+ modelId: model.id
1197
+ }));
1198
+ return;
1199
+ } else if (name === 'tool_call') {
1200
+ // Remember the tool for the status block's activity row: it names
1201
+ // what the model is doing, which is more useful in a progress
1202
+ // notification than a bare percentage.
1203
+ if (data && data.name) lastToolName = String(data.name);
1204
+ if (!turnStartedAt) turnStartedAt = Date.now();
1205
+ try {
1206
+ messages.appendMessage(projectDir, chatId, {
1207
+ role: 'tool', phase: 'call', toolCallId: data.id || '', name: data.name || '',
1208
+ args: data.args || {}, content: JSON.stringify(data.args || {})
1209
+ });
1210
+ } catch { /* non-fatal */ }
1211
+ } else if (name === 'tool_result') {
1212
+ try {
1213
+ messages.appendMessage(projectDir, chatId, {
1214
+ role: 'tool', phase: 'result', toolCallId: data.id || '', name: data.name || '',
1215
+ ok: !!data.ok, content: JSON.stringify(data.result || {})
1216
+ });
1217
+ } catch { /* non-fatal */ }
1218
+ } else if (name === 'authorization_required') {
1219
+ const notificationData = {
1220
+ callId: data && data.callId,
1221
+ tool: data && data.tool
1222
+ };
1223
+ sendChatPush('tool_authorization', {
1224
+ title: 'Authorization needed',
1225
+ body: (data && data.tool ? data.tool : 'A tool') + ' is waiting for approval.',
1226
+ tag: 'chat-' + chatId + '-attention',
1227
+ data: notificationData,
1228
+ actions: attentionActions('tool_authorization', data),
1229
+ requireInteraction: true
1230
+ });
1231
+ } else if (name === 'ask_user_required') {
1232
+ const quickOptions = data && data.multiSelect !== true && Array.isArray(data.options) && data.options.length === 2
1233
+ ? data.options.slice(0, 2).map((option) => ({ label: String(option.label || '').slice(0, 40), value: String(option.value || '').slice(0, 120) }))
1234
+ : [];
1235
+ const notificationData = {
1236
+ callId: data && data.callId,
1237
+ tool: 'ask_user',
1238
+ options: quickOptions
1239
+ };
1240
+ sendChatPush('ask_user', {
1241
+ title: 'The chat needs your answer',
1242
+ body: data && data.question ? String(data.question).slice(0, 240) : 'Open the chat to answer.',
1243
+ tag: 'chat-' + chatId + '-attention',
1244
+ data: notificationData,
1245
+ actions: attentionActions('ask_user', data),
1246
+ requireInteraction: true
1247
+ });
1248
+ } else if (name === 'progress_update') {
1249
+ // Updatable per-chat push notification for real-time progress.
1250
+ // Uses a stable tag so each new progress_update replaces the
1251
+ // previous OS notification for this chat (no notification spam).
1252
+ const pctNum = data.current != null && data.total != null
1253
+ ? Math.round((Number(data.current) / Math.max(1, Number(data.total))) * 100)
1254
+ : null;
1255
+ // Everything the status shows rides in the BODY, under the bar, as a
1256
+ // set of optional facts (src/statusBar.js detailLines()): the message,
1257
+ // the position in the work, the turn usage, the elapsed time, the
1258
+ // tool, and the model. The title stays just the chat name — it is the
1259
+ // slot the OS clips first, and a wider device shows more detail rows
1260
+ // without the title changing. A task update is one row of facts, not a
1261
+ // title row plus a separate task row.
1262
+ sendChatPush('progress', {
1263
+ title: (chat && chat.title) || 'mouaif',
1264
+ bodyFor: (sub) => statusBody(sub, pctNum, statusInfo(data)),
1265
+ tag: statusPushTag
1266
+ });
1267
+ } else if (name === 'done') {
1268
+ sendChatPush('completion', {
1269
+ title: (chat && chat.title) || 'mouaif',
1270
+ bodyFor: (sub) => statusBody(sub, 100, statusInfo({
1271
+ kind: 'complete',
1272
+ message: 'Response complete'
1273
+ })),
1274
+ tag: statusPushTag
1275
+ });
1276
+ // Compute the enrichment once. `cost.known` is true when at
1277
+ // least one of the four pricing layers (model, app, builtin)
1278
+ // had a non-empty entry for this model id. We always emit
1279
+ // the enriched event so the UI can render `--` cleanly; the
1280
+ // `known: false` flag tells it not to show a dollar sign.
1281
+ let enriched = data;
1282
+ delegatedCost = data && typeof data.delegatedCost === 'number' && isFinite(data.delegatedCost) && data.delegatedCost >= 0
1283
+ ? data.delegatedCost
1284
+ : null;
1285
+ try {
1286
+ const app = settings.getApp();
1287
+ const providerCost = data && typeof data.providerCost === 'number' && isFinite(data.providerCost) && data.providerCost >= 0
1288
+ ? data.providerCost
1289
+ : null;
1290
+ const cost = providerCost == null
1291
+ ? usage.computeCost({ model, usage: data && data.usage, app })
1292
+ : { known: true, input: 0, output: 0, total: providerCost, currency: 'USD' };
1293
+ // Fold the still-open window (first delta → done) into the
1294
+ // accumulated tool-round windows. Falls back to the full
1295
+ // elapsed time when no message delta ever armed the start.
1296
+ const finalStreamingMs = streamingMs + (streamStartedAt ? Date.now() - streamStartedAt : 0);
1297
+ enriched = Object.assign({}, data, {
1298
+ cost: {
1299
+ known: cost.known,
1300
+ input: cost.input,
1301
+ output: cost.output,
1302
+ total: cost.total,
1303
+ currency: cost.currency
1304
+ },
1305
+ streamingMs: finalStreamingMs,
1306
+ modelId: model.id
1307
+ });
1308
+ } catch { /* keep data as-is on any pricing resolution error */ }
1309
+ // Cost and usage on the final row are REMAINDER values, not the
1310
+ // turn aggregate:
1311
+ // - Intermediate segments (assistant_turn_end) already carry
1312
+ // their own round's usage + cost.
1313
+ // - `turnCost` accumulated EVERY round's real cost — including
1314
+ // tool rounds that produced no text and would otherwise
1315
+ // vanish from the chat total.
1316
+ // - Charging the full aggregate here would double-bill the
1317
+ // segment completion tokens; charging only this round's
1318
+ // snapshot would drop the no-text rounds entirely.
1319
+ // So: final cost = parent turnCost + delegatedCost − persistedSegmentCost,
1320
+ // and the usage block shows this round's own footprint (the aggregate
1321
+ // stays on the SSE event's usage block for the live "Context"
1322
+ // display). The SAME remainder rides the SSE `done` cost so the
1323
+ // in-flight chat total (segments + live final) matches the
1324
+ // persisted total exactly — no jump on reload.
1325
+ const finalRoundUsage = pendingRoundUsage;
1326
+ pendingRoundUsage = null;
1327
+ let remainderCost = enriched.cost;
1328
+ if (turnCostKnown && delegatedCost != null && enriched.cost && enriched.cost.known) {
1329
+ const remaining = Math.max(0, turnCost + delegatedCost - persistedSegmentCost);
1330
+ remainderCost = {
1331
+ known: true,
1332
+ input: 0,
1333
+ output: 0,
1334
+ total: remaining,
1335
+ currency: (enriched.cost && enriched.cost.currency) || 'USD'
1336
+ };
1337
+ enriched = Object.assign({}, enriched, { cost: remainderCost });
1338
+ }
1339
+ lastEnrichment = enriched;
1340
+ // Persist the assistant message so a chat that is later
1341
+ // reopened renders the same numbers (decision §14 — the usage
1342
+ // block rides the message).
1343
+ if (assistantContent.trim() || assistantReasoning.trim()) {
1344
+ try {
1345
+ const persistUsage = finalRoundUsage
1346
+ ? {
1347
+ promptTokens: finalRoundUsage.promptTokens,
1348
+ completionTokens: finalRoundUsage.completionTokens,
1349
+ cacheReadTokens: finalRoundUsage.cacheReadTokens || 0,
1350
+ cacheCreationTokens: finalRoundUsage.cacheCreationTokens || 0
1351
+ }
1352
+ : (data && data.usage);
1353
+ assistantMsg = messages.appendMessage(projectDir, chatId, {
1354
+ role: 'assistant',
1355
+ content: assistantContent,
1356
+ reasoning: assistantReasoning,
1357
+ usage: persistUsage,
1358
+ cost: remainderCost,
1359
+ streamingMs: enriched.streamingMs,
1360
+ modelId: enriched.modelId
1361
+ });
1362
+ } catch { /* non-fatal */ }
1363
+ }
1364
+ if (traceStream && assistantMsg) {
1365
+ const event = trace.eventForMessage(assistantMsg);
1366
+ trace.write(traceStream, event.type, event.payload);
1367
+ }
1368
+ emit('done', enriched);
1369
+ return;
1370
+ }
1371
+ emit(name, data);
1372
+ }
1373
+ });
1374
+ } catch (streamErr) {
1375
+ // A throw out of the streaming layer must still clear the running
1376
+ // marker or the chat would look busy forever after a reload.
1377
+ runningChats.delete(runKey);
1378
+ runningChatCancels.delete(runKey);
1379
+ // The turn is over (either way), so its status facts stop applying.
1380
+ lastToolName = '';
1381
+ turnStartedAt = 0;
1382
+ if (traceStream) trace.close(traceStream);
1383
+ const errPayload = { code: 'EINTERNAL', message: streamErr && streamErr.message ? streamErr.message : 'stream failed' };
1384
+ persistStreamError(errPayload);
1385
+ try { emit('error', errPayload); } catch { /* socket closed */ }
1386
+ liveChat.finishLiveChat(runKey);
1387
+ sendChatPush('error', {
1388
+ title: (chat && chat.title) || 'mouaif',
1389
+ // An error is a fact set too: the message, then whatever usage and
1390
+ // context the turn had reached before it failed.
1391
+ bodyFor: (sub) => statusBody(sub, null, statusInfo({ kind: 'error', message: 'Error: ' + (errPayload.message || 'stream failed') })),
1392
+ tag: statusPushTag
1393
+ });
1394
+ res.end();
1395
+ return;
1396
+ }
1397
+
1398
+ if (!result.ok) {
1399
+ // Always surface the failure — even when the stream produced
1400
+ // partial content before dying. The old guard
1401
+ // (`!assistantContent && !assistantReasoning`) silently dropped
1402
+ // mid-turn failures: the client saw the socket close with no
1403
+ // `done` and no `error`, leaving the chat stuck on "streaming…"
1404
+ // with zero explanation. Persist any partial output first, then
1405
+ // the error itself, so the transcript shows exactly what the
1406
+ // model produced before the failure.
1407
+ const errPayload = Object.assign({ code: result.error.code || 'EUPSTREAM' }, result.error);
1408
+ if (assistantContent.trim() || assistantReasoning.trim()) {
1409
+ try {
1410
+ messages.appendMessage(projectDir, chatId, {
1411
+ role: 'assistant',
1412
+ content: assistantContent,
1413
+ reasoning: assistantReasoning,
1414
+ modelId: model.id
1415
+ });
1416
+ } catch { /* non-fatal */ }
1417
+ }
1418
+ persistStreamError(errPayload);
1419
+ emit('error', errPayload);
1420
+ sendChatPush('error', {
1421
+ title: (chat && chat.title) || 'mouaif',
1422
+ bodyFor: (sub) => statusBody(sub, null, statusInfo({ kind: 'error', message: 'Error: ' + (errPayload.message || 'upstream error') })),
1423
+ tag: statusPushTag
1424
+ });
1425
+ }
1426
+ if (traceStream) trace.close(traceStream);
1427
+ runningChats.delete(runKey);
1428
+ runningChatCancels.delete(runKey);
1429
+ // The turn is over, so its status facts stop applying to the next one.
1430
+ lastToolName = '';
1431
+ turnStartedAt = 0;
1432
+ liveChat.finishLiveChat(runKey);
1433
+ res.end();
1434
+ }
1435
+
1436
+ module.exports = { handleChats, handleChatStream };