@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.
- package/.claude/settings.local.json +6 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1584 -26
- package/README.md +147 -3
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +711 -104
- package/lib/api.js +213 -49
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +333 -11
- package/lib/constants.js +372 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +84 -5
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2558 -0
- package/lib/tool_specs.js +222 -2
- package/lib/tools.js +272 -1020
- package/lib/ui/format.js +22 -1
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/background.test.js +414 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +203 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/max-iterations.test.js +216 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +163 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/result-cap.test.js +233 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- 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 };
|