@semalt-ai/code 1.8.5 → 1.19.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 (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,446 @@
1
+ 'use strict';
2
+
3
+ // The chat input/turn handler (cmdChat's inputField.onSubmit), extracted in
4
+ // Task 1.5. Handles picker text-fallback navigation, slash-command dispatch, and
5
+ // running a normal message through the agent loop with the full TUI callback
6
+ // wiring. Mutable session fields go through ctx; session/picker/sync helpers and
7
+ // stable collaborators are read from ctx (where cmdChat assigned them). Bodies
8
+ // are unchanged. NOTE: the onToolStart/onToolEnd callbacks take a local `ctx`
9
+ // parameter (the per-tool invocation context) that intentionally shadows the
10
+ // session ctx — those callbacks use only the per-tool fields, never session state.
11
+
12
+ const { resolveMaxIterations } = require('../config');
13
+ const { createWebActivityTracker } = require('../ui/web-activity');
14
+
15
+ function createTurnHandler(ctx, slashHandlers) {
16
+ // The session ctx — the per-tool callbacks below intentionally shadow `ctx`
17
+ // with the per-invocation context, so capture the session here for the few
18
+ // session-level reads they need (the live --debug flag).
19
+ const sessionCtx = ctx;
20
+ const {
21
+ inputField, statusBar, chatHistory, getConfig, approxTokens, resolveCommand,
22
+ runAgentLoop, opts, TAG_REGISTRY, formatToolLine, writerModule,
23
+ collapseListMsg, handlePendingSelection, showPendingStep, activateNavCapture, finalizeListMsg,
24
+ createChatIfNeeded, saveTurnToDashboard, saveSession,
25
+ } = ctx;
26
+
27
+ // Running session token totals for the cost indicator (Task 2.6). Each turn's
28
+ // Metrics is per-turn, so we accumulate here for a session cost in the bar.
29
+ const sessionUsage = { prompt_tokens: 0, completion_tokens: 0 };
30
+
31
+ return async (text) => {
32
+ // Handle pending selection (text fallback for non-TTY; TTY uses captureNavigation)
33
+ if (ctx.pendingAction) {
34
+ inputField.releaseNavigation();
35
+ const t = text.trim().toLowerCase();
36
+ const { items, displayItems: di, stepIdx, type } = ctx.pendingAction;
37
+ const activeItems = di || items;
38
+ if (t === 's' || t === 'select' || t === 'y' || t === 'yes') {
39
+ collapseListMsg(type, activeItems[stepIdx]);
40
+ statusBar.update('idle');
41
+ await handlePendingSelection(stepIdx);
42
+ inputField.setDisabled(false);
43
+ return;
44
+ } else if (t === 'n' || t === 'next') {
45
+ ctx.pendingAction = { ...ctx.pendingAction, stepIdx: (stepIdx + 1) % items.length };
46
+ showPendingStep();
47
+ activateNavCapture();
48
+ return;
49
+ } else if (t === 'p' || t === 'prev') {
50
+ ctx.pendingAction = { ...ctx.pendingAction, stepIdx: (stepIdx - 1 + items.length) % items.length };
51
+ showPendingStep();
52
+ activateNavCapture();
53
+ return;
54
+ } else if (t === 'c' || t === 'cancel') {
55
+ finalizeListMsg();
56
+ chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
57
+ ctx.pendingAction = null;
58
+ statusBar.update('idle');
59
+ inputField.setDisabled(false);
60
+ return;
61
+ } else {
62
+ // Not a nav key: close nav silently and let the message go to AI
63
+ finalizeListMsg();
64
+ ctx.pendingAction = null;
65
+ statusBar.update('idle');
66
+ // fall through to AI processing below
67
+ }
68
+ }
69
+
70
+ // Slash-command dispatch via the registry (replaces the former if-chain).
71
+ // resolveCommand maps the raw text to a canonical command + its argument;
72
+ // null means "not a command" → fall through to the agent below.
73
+ const resolved = resolveCommand(text);
74
+ if (resolved) {
75
+ if (resolved.spec && resolved.spec.custom) {
76
+ // Custom (Markdown) command: render its template and let it fall through
77
+ // to the agent path below as the user prompt. It is submitted as text,
78
+ // never executed as code.
79
+ const { renderTemplate } = require('./custom');
80
+ text = renderTemplate(resolved.spec.template, resolved.arg);
81
+ if (!text.trim()) {
82
+ chatHistory.addMessage({ role: 'system', content: `✗ Custom command ${resolved.name} produced an empty prompt.`, isError: true });
83
+ return;
84
+ }
85
+ } else if (resolved.spec && resolved.spec.skill) {
86
+ // Skill invocation (Task 3.5): the system prompt carried only the skill's
87
+ // metadata. Loading the body HERE — on invocation — is the progressive
88
+ // disclosure: the instructions enter context only now. The body is read
89
+ // from SKILL.md, rendered (so $ARGUMENTS/$1 work if the author used them),
90
+ // and submitted to the agent as a user prompt, never executed as code.
91
+ const { loadSkillBody } = require('../skills');
92
+ const { renderTemplate } = require('./custom');
93
+ let body;
94
+ try { body = loadSkillBody(resolved.spec); } catch { body = ''; }
95
+ if (!body || !body.trim()) {
96
+ chatHistory.addMessage({ role: 'system', content: `✗ Skill ${resolved.name} has no loadable body.`, isError: true });
97
+ return;
98
+ }
99
+ const rendered = renderTemplate(body, resolved.arg);
100
+ // Skills may carry assets/scripts alongside SKILL.md — tell the agent where.
101
+ text = rendered + (resolved.spec.dir ? `\n\n(Skill assets directory: ${resolved.spec.dir})` : '');
102
+ } else {
103
+ await slashHandlers[resolved.name](resolved.arg, text);
104
+ return;
105
+ }
106
+ }
107
+
108
+
109
+ // Block unauthenticated users from running the agent
110
+ if (!getConfig().auth_token) {
111
+ chatHistory.addMessage({ role: 'system', content: '✗ Not logged in. Run /login first.', isError: true });
112
+ return;
113
+ }
114
+
115
+ // Normal message → run agent
116
+ inputField.setDisabled(true);
117
+ chatHistory.addMessage({ role: 'user', content: text });
118
+ statusBar.update('thinking', 'Thinking...');
119
+ // Bump the context-size indicator with this user message's approximate
120
+ // token count. It'll be overwritten with the exact prompt_tokens from
121
+ // the API response when the first turn completes — this just keeps the
122
+ // indicator reactive in the gap before that.
123
+ statusBar.addPendingTokens(approxTokens(text));
124
+ await createChatIfNeeded(text);
125
+ // Multimodal image input (Task 5.4): consume any images staged by /image and
126
+ // attach them to this user turn, then clear the staging buffer.
127
+ const stagedImages = (ctx.pendingImages && ctx.pendingImages.length) ? ctx.pendingImages : null;
128
+ ctx.pendingImages = [];
129
+ const userMessage = { role: 'user', content: text };
130
+ if (stagedImages) userMessage.images = stagedImages;
131
+ ctx.messages.push(userMessage);
132
+
133
+ // Per-turn state: buffer tokens until we know if the model is in an implicit
134
+ // think block (Qwen3-style: plain text followed by </think>, no opening tag).
135
+ let implicitThinkPhase = !opts.showThink;
136
+ let implicitThinkBuffer = '';
137
+
138
+ // Web-activity collapse (Task W.3): in the default (non-debug) view, a run of
139
+ // consecutive web ops (web_search → http_get) renders as ONE process-summary
140
+ // line instead of a per-op line each. Fresh per turn. In --debug the tracker
141
+ // is bypassed and web ops render the normal per-op way (full detail).
142
+ const webTracker = createWebActivityTracker({ writerModule });
143
+
144
+ const callbacks = {
145
+ onThinking: () => statusBar.update('thinking', 'Thinking...'),
146
+ onRequestSent: () => {
147
+ statusBar.update('thinking', 'Thinking...');
148
+ // Reset think-phase detection for each new agent iteration.
149
+ implicitThinkPhase = !opts.showThink;
150
+ implicitThinkBuffer = '';
151
+ },
152
+ onStreamStart: () => {
153
+ // If showThink is on, switch to streaming immediately.
154
+ // Otherwise keep "Thinking…" until </think> is resolved.
155
+ if (opts.showThink) statusBar.update('streaming', 'Streaming response');
156
+ },
157
+ onTagOpen: (tag, attrs) => {
158
+ const entry = TAG_REGISTRY[tag];
159
+ if (entry?.type === 'tool') {
160
+ const actionLabel = entry.label || tag;
161
+ const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
162
+ const isDownload = tag === 'download' || tag === 'http_get';
163
+ const barState = isDownload ? 'waiting_download' : 'tool';
164
+ const label = isDownload
165
+ ? `Waiting for download${detail ? ': ' + detail : ''}`
166
+ : `${actionLabel}${detail ? ': ' + detail : ''}`;
167
+ statusBar.update(barState, label);
168
+ if (!opts.showThink) chatHistory.clearStreamingContent();
169
+ }
170
+ if (entry?.display === 'think_bubble') {
171
+ statusBar.update('thinking', 'Reasoning...');
172
+ }
173
+ },
174
+ onThinkEnd: (content) => {
175
+ chatHistory.addMessage({ role: 'think', content });
176
+ statusBar.update('streaming', 'Streaming response');
177
+ },
178
+ onPermissionAsk: (tag, input) => {
179
+ // Status-bar update fires while the permission picker is open so
180
+ // the user can see what's pending in the side label, not just
181
+ // inside the modal. Mirrors the labels onToolStart uses post-grant
182
+ // — the next streaming/idle state will overwrite this when the
183
+ // picker closes (whether granted or denied).
184
+ const actionLabel = TAG_REGISTRY[tag]?.label || tag;
185
+ const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
186
+ const isDownload = tag === 'download' || tag === 'http_get';
187
+ if (isDownload) {
188
+ statusBar.update('waiting_download', `Waiting for download: ${short}`);
189
+ } else {
190
+ statusBar.update('tool', `${actionLabel}: ${short}`);
191
+ }
192
+ },
193
+ onToolStart: (tag, input, ctx) => {
194
+ const actionLabel = TAG_REGISTRY[tag]?.label || tag;
195
+ const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
196
+ const isDownload = tag === 'download' || tag === 'http_get';
197
+ if (isDownload) {
198
+ statusBar.update('waiting_download', `Waiting for download: ${short}`);
199
+ } else {
200
+ statusBar.update('tool', `${actionLabel}: ${short}`);
201
+ }
202
+ // Web-activity collapse (Task W.3): in the default view, fold this web op
203
+ // into the running process-summary line instead of its own activity row.
204
+ // --debug keeps the per-op line (fall through to the normal path below).
205
+ if (!sessionCtx.debugMode && webTracker.isWeb(tag)) {
206
+ webTracker.start(tag, input);
207
+ return;
208
+ }
209
+ // A non-web tool (or debug mode) closes any open web group first, so its
210
+ // committed summary lands ABOVE this tool's line in scrollback.
211
+ if (webTracker.isOpen()) webTracker.flush();
212
+ // Register the invocation with the writer's activity region.
213
+ // The render function is re-invoked by the writer on every
214
+ // redraw so the pending line's elapsed time stays current with
215
+ // the ticker cadence without an explicit refresh timer.
216
+ //
217
+ // ask_user is the only currently-blocking tool — it pauses the
218
+ // agent until the user responds via the modal. A ticking
219
+ // elapsed-time meter on a paused tool is misleading ("13s"
220
+ // suggests work is happening), and the per-tick redraw
221
+ // interacts badly with the open modal (see TECHNICAL_DEBT.md).
222
+ // Render once with no duration meta and freeze. Replace this
223
+ // name check with a category flag (e.g. blocking: true on the
224
+ // tool spec) if more blocking tools appear.
225
+ if (ctx && ctx.id) {
226
+ if (tag === 'ask_user') {
227
+ const staticLine = formatToolLine({
228
+ status: 'pending',
229
+ tag,
230
+ arg: input,
231
+ attrs: ctx.attrs,
232
+ noDuration: true,
233
+ });
234
+ writerModule.startActivity(ctx.id, () => staticLine);
235
+ } else {
236
+ writerModule.startActivity(ctx.id, (elapsedMs) => formatToolLine({
237
+ status: 'pending',
238
+ tag,
239
+ arg: input,
240
+ attrs: ctx.attrs,
241
+ durationMs: elapsedMs,
242
+ }));
243
+ }
244
+ }
245
+ },
246
+ onToolEnd: (tag, result, durationMs, ctx) => {
247
+ const hasError = !!(ctx && ctx.error);
248
+ // Web-activity collapse (Task W.3): record this web op into the running
249
+ // summary instead of committing a per-op line. The summary reflects the
250
+ // failure (a 403/406 or timeout shows as "blocked"); the detailed error
251
+ // body stays hidden in the collapsed view (visible under --debug).
252
+ if (!sessionCtx.debugMode && webTracker.isWeb(tag)) {
253
+ webTracker.end(tag, result, durationMs, ctx);
254
+ if (hasError) statusBar.update('streaming', 'Streaming response');
255
+ return;
256
+ }
257
+ const isBlocking = tag === 'ask_user';
258
+ const finalLine = formatToolLine({
259
+ status: hasError ? 'failure' : 'success',
260
+ tag,
261
+ arg: ctx && ctx.attrs ? (ctx.attrs.command || ctx.attrs.path || ctx.attrs.url || ctx.attrs.src || ctx.attrs.key || ctx.attrs.name || ctx.attrs.pattern) : '',
262
+ attrs: ctx ? ctx.attrs : null,
263
+ durationMs,
264
+ meta: ctx ? ctx.meta : null,
265
+ error: ctx ? ctx.error : null,
266
+ noDuration: isBlocking,
267
+ });
268
+ if (ctx && ctx.id) {
269
+ writerModule.endActivity(ctx.id, finalLine);
270
+ } else {
271
+ // No invocation id means the agent-loop wasn't upgraded to pass
272
+ // structured context (shouldn't happen in practice). Fall back
273
+ // to a direct scrollback line so the tool still leaves a trace.
274
+ writerModule.scrollback(finalLine);
275
+ }
276
+ if (hasError) {
277
+ // Preserve the expandable error body as a follow-up tool
278
+ // bubble. Empty content suppresses its header so the scrollback
279
+ // line above (written by endActivity) isn't duplicated.
280
+ const body = typeof result === 'string' && result.trim() ? result : null;
281
+ if (body) {
282
+ chatHistory.addMessage({ role: 'tool', tag, content: '', output: body, isError: true });
283
+ }
284
+ statusBar.update('streaming', 'Streaming response');
285
+ }
286
+ },
287
+ onToken: (token) => {
288
+ if (!opts.showThink && implicitThinkPhase) {
289
+ // Check if this token is the closing think tag (Qwen3-style implicit think).
290
+ if (/^<\/(think|reasoning|reflection)>$/i.test(token.trim())) {
291
+ // Thinking phase is over — discard buffered reasoning, start streaming.
292
+ implicitThinkPhase = false;
293
+ implicitThinkBuffer = '';
294
+ statusBar.update('streaming', 'Streaming response');
295
+ return;
296
+ }
297
+ // Buffer the token; keep the thinking animation visible.
298
+ implicitThinkBuffer += token;
299
+ return;
300
+ }
301
+ chatHistory.streamToken(token);
302
+ statusBar.onToken();
303
+ },
304
+ onAssistantMessage: (cleanContent) => {
305
+ // If </think> was never seen, the model had no implicit think block —
306
+ // flush whatever was buffered as normal streaming content.
307
+ if (implicitThinkPhase && implicitThinkBuffer) {
308
+ implicitThinkPhase = false;
309
+ implicitThinkBuffer = '';
310
+ }
311
+ // Web-activity ordering (W.3 regression fix): commit any still-open web
312
+ // group BEFORE the answer is finalized, so the collapsed "✓ web · …"
313
+ // summary lands ABOVE the answer in scrollback (pre-W.3 ordering).
314
+ //
315
+ // Guard on non-empty content: that is exactly the "terminal response"
316
+ // signal. Intermediate web-tool iterations pass cleanContent === ''
317
+ // (suppressed because they carried tool calls — agent.js), so they do
318
+ // NOT flush — the group stays open and the multi-step search→fetch
319
+ // activity stays collapsed into a single line (the W.3 guarantee).
320
+ // The final-answer iteration passes non-empty content → flush once.
321
+ // Empty/interrupted turns (no non-empty message ever arrives) fall back
322
+ // to the turn-end `finally` flush, which is now the safety net.
323
+ if (cleanContent && cleanContent.trim() && webTracker.isOpen()) {
324
+ webTracker.flush();
325
+ }
326
+ chatHistory.finalizeLastMessage(cleanContent);
327
+ },
328
+ onMetricsUpdate: (data) => statusBar.updateMetrics(data),
329
+ onRetry: (attempt, max) => {
330
+ statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
331
+ },
332
+ onDebug: (block) => {
333
+ // Render in-history as a tool-style bubble so ctrl+O expand works and
334
+ // the RAW RESPONSE text survives TUI redraws (stderr would be clobbered).
335
+ chatHistory.addMessage({ role: 'tool', tag: 'debug', content: 'DEBUG', output: block });
336
+ },
337
+ onError: (err) => {
338
+ if (err && err.isWarning) {
339
+ chatHistory.addMessage({ role: 'system', content: err.message || String(err) });
340
+ } else {
341
+ const msg = (err && err.message) || String(err);
342
+ statusBar.update('error', msg);
343
+ chatHistory.addMessage({ role: 'system', content: `✗ ${msg}`, isError: true });
344
+ }
345
+ },
346
+ onPlanWithhold: (tag, arg) => {
347
+ chatHistory.addMessage({ role: 'system', content: `⏸ Planned (withheld): ${tag}${arg ? ' ' + arg : ''}` });
348
+ },
349
+ };
350
+
351
+ let _agentAborted = false;
352
+ const _onAbort = () => {
353
+ if (!_agentAborted) {
354
+ _agentAborted = true;
355
+ chatHistory.addMessage({ role: 'system', content: '⏹ Interrupted.' });
356
+ }
357
+ };
358
+ inputField.on('abort', _onAbort);
359
+
360
+ // Refresh in case a prior turn's 400 overflow persisted a learned
361
+ // context_length to config after this chat started.
362
+ if (ctx.resolvedTokenLimit == null) {
363
+ const cfg = getConfig();
364
+ if (Number.isInteger(cfg.context_length) && cfg.context_length > 0) {
365
+ ctx.resolvedTokenLimit = cfg.context_length;
366
+ }
367
+ }
368
+
369
+ // Auto-compaction near the context limit (Task 2.7): summarize older turns
370
+ // before the request so they survive as a summary rather than being dropped
371
+ // by api.js trimToTokenBudget. Best-effort; never blocks the turn.
372
+ try {
373
+ const { shouldAutoCompact, selectForCompaction, summarizationRequest, buildCompactedMessages, approxTokens: approxTok } = require('../compact');
374
+ const lim = ctx.resolvedTokenLimit;
375
+ const used = approxTok(ctx.messages, approxTokens);
376
+ if (shouldAutoCompact(used, lim, ctx.messages.length)) {
377
+ const sel = selectForCompaction(ctx.messages, { keepRecent: 6 });
378
+ if (sel.head.length) {
379
+ // PreCompact hook (Task 3.4): fire before summarizing. Best-effort.
380
+ try {
381
+ await require('../hooks').createHookRunner({ getConfig })
382
+ .run('PreCompact', { reason: 'auto', messageCount: ctx.messages.length });
383
+ } catch { /* hook failures never block compaction */ }
384
+ const summary = await ctx.chatSync(summarizationRequest(sel.head), { model: ctx.currentModel });
385
+ if (summary && summary.trim()) {
386
+ ctx.messages = buildCompactedMessages(sel, summary);
387
+ const after = approxTok(ctx.messages, approxTokens);
388
+ chatHistory.addMessage({ role: 'system', content: `✓ Auto-compacted near context limit: ~${used} → ~${after} tokens.` });
389
+ }
390
+ }
391
+ }
392
+ } catch { /* auto-compaction is best-effort */ }
393
+
394
+ try {
395
+ const agentResult = await runAgentLoop(ctx.messages, ctx.currentModel, resolveMaxIterations(getConfig().max_iterations), ctx.resolvedTokenLimit, {
396
+ showThink: opts.showThink || false,
397
+ debug: ctx.debugMode,
398
+ callbacks,
399
+ systemPrompt: ctx.resolvedSystemPrompt,
400
+ systemPromptMode: getConfig().system_prompt_mode || 'system_role',
401
+ getAbortFlag: () => _agentAborted,
402
+ getPlanMode: () => ctx.planMode,
403
+ noVerify: !!opts.noVerify,
404
+ });
405
+ ctx.messages = agentResult.messages;
406
+ ctx.sessionMetrics = agentResult.metrics;
407
+
408
+ // Cost indicator (Task 2.6): accumulate this turn's usage and render the
409
+ // session cost. Unknown model price → "unknown", never a fake $0.
410
+ try {
411
+ const cfg = getConfig();
412
+ if (cfg.show_cost && agentResult.metrics && Array.isArray(agentResult.metrics.turns)) {
413
+ for (const t of agentResult.metrics.turns) {
414
+ sessionUsage.prompt_tokens += t.promptTokens || 0;
415
+ sessionUsage.completion_tokens += t.completionTokens || 0;
416
+ }
417
+ const { priceForModel, computeCost, formatCost } = require('../pricing');
418
+ const cost = computeCost(sessionUsage, priceForModel(ctx.currentModel, cfg.pricing));
419
+ if (typeof statusBar.setCost === 'function') statusBar.setCost(formatCost(cost));
420
+ }
421
+ } catch { /* cost display is best-effort */ }
422
+
423
+ if (ctx.planMode && agentResult.withheldActions && agentResult.withheldActions.length) {
424
+ chatHistory.addMessage({
425
+ role: 'system',
426
+ content: `Plan ready — ${agentResult.withheldActions.length} action(s) withheld. Run /plan to approve and execute, or /clear to discard.`,
427
+ });
428
+ }
429
+ } catch (err) {
430
+ statusBar.update('error', err.message || 'Agent error');
431
+ chatHistory.addMessage({ role: 'system', content: err.message || 'Agent error', isError: true });
432
+ } finally {
433
+ // Commit any still-open web-activity summary (the turn may have ended right
434
+ // after a web op, or been interrupted mid-group) before the turn unwinds.
435
+ try { webTracker.flush(); } catch { /* never block turn teardown */ }
436
+ inputField.removeListener('abort', _onAbort);
437
+ }
438
+
439
+ statusBar.update('idle');
440
+ inputField.setDisabled(false);
441
+ await saveTurnToDashboard();
442
+ saveSession();
443
+ };
444
+ }
445
+
446
+ module.exports = { createTurnHandler };