@semalt-ai/code 1.8.4 → 1.8.5

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.
@@ -15,7 +15,9 @@
15
15
  "Bash(python3 *)",
16
16
  "Read(//tmp/**)",
17
17
  "Bash(sed -i \"s/addMessage\\('>>> AI MSG 2'.*$/addMessage\\('>>> AI MSG 2', ['response body 2a', 'response body 2b']\\);\\\\nfor \\(let k = 3; k <= 8; k++\\) { addMessage\\('>>> USER MSG ' + k, ['line body ' + k]\\); addMessage\\('>>> AI MSG ' + k, ['response body ' + k + 'a', 'response body ' + k + 'b']\\); }/\" scroll-capture.js)",
18
- "Bash(echo \"exit=$?\")"
18
+ "Bash(echo \"exit=$?\")",
19
+ "Bash(echo \"---grep done, exit=$?---\")",
20
+ "Bash(grep *)"
19
21
  ]
20
22
  }
21
23
  }
package/CLAUDE.md CHANGED
@@ -87,7 +87,10 @@ semalt-code config [set <key> <val>] # show or update config keys
87
87
  --dashboard-url <url> dashboard base URL (overrides config)
88
88
  --default-model <name> set default model in config
89
89
  --show-think display model reasoning (thinking) content
90
- --debug print raw AI response (stderr) each iteration
90
+ --debug inline debug: per-iteration debug block in chat history (TUI-safe)
91
+ --debug-file <path> extended debug: per-iteration block + raw SSE chunks
92
+ + request body dumps written to <path>, nothing to stdout.
93
+ Mutually exclusive with --debug.
91
94
  --allow-fs auto-approve all filesystem operations
92
95
  --allow-exec auto-approve shell command execution
93
96
  --allow-net auto-approve network operations
@@ -0,0 +1,66 @@
1
+ ## Activity region in-place update breaks when a modal is open
2
+
3
+ When a modal occupies screen lines below an active activity bubble, the
4
+ activity region's redraw mechanism appears to fall back to scrollback
5
+ append per tick instead of in-place rewrite. Surfaced via the `ask_user`
6
+ ticking-timer bug; mitigated by making `ask_user` a static bubble.
7
+
8
+ Latent: any future long-running tool that opens a modal concurrently
9
+ (none today) will reproduce the fragmentation. Fix likely involves
10
+ making the activity region modal-aware in `lib/ui/writer.js` — when a
11
+ modal region is active, route activity updates through a path that
12
+ clears modal, redraws activity, redraws modal — or reserves activity
13
+ above the modal in a way that survives modal lifecycle.
14
+
15
+ Not blocking. Revisit if a second use-case appears.
16
+
17
+ ## `cmdShell` and `chatStream` write to stdout bypassing the writer
18
+
19
+ Several call sites currently emit directly to `process.stdout.write`
20
+ without going through `lib/ui/writer.js`:
21
+
22
+ - `lib/commands.js` (`cmdShell`)
23
+ - `lib/api.js` (streaming output path)
24
+
25
+ These were flagged during the Phase 2 writer audit and annotated as
26
+ `// audit: allowed` because they need to interleave with synchronous
27
+ writes from `StreamRenderer`. Routing them through the writer today
28
+ would require buffering or sequencing changes that don't compose with
29
+ how `StreamRenderer` flushes content per chunk.
30
+
31
+ Resolves when: `StreamRenderer` itself is migrated to write through
32
+ the writer. After that, the bypass annotations can be removed and
33
+ these call sites become normal `writer.scrollback(...)` calls.
34
+
35
+ Not blocking. The audit annotation makes the bypass intentional and
36
+ greppable. Revisit when `StreamRenderer` migration is on the table.
37
+
38
+ ## Tool result storage: single `content` field used for both model and UI
39
+
40
+ Storage (PHP backend, MySQL `messages` table) holds one `content` field
41
+ per tool result. The full payload is required for the model on
42
+ subsequent turns, but the UI needs a compact summary (e.g. `net · GET
43
+ https://... · 200 · 256 KB`).
44
+
45
+ Today this is handled UI-side: `summarizeToolResult` in
46
+ `lib/ui/format.js` runs read-side heuristics on the raw `content` every
47
+ time `/history` renders. Heuristics cover HTTP, exec, file ops, with
48
+ a fallback for unknown shapes. They work in practice but are a
49
+ compromise — any tool whose output format drifts will fall through to
50
+ the generic fallback until the heuristic is updated.
51
+
52
+ Full fix: storage holds both `content` (full, model-bound) and
53
+ `display` (pre-rendered summary, UI-bound). Summary is generated
54
+ write-side at tool execution time, when the live activity bubble
55
+ already produces the right string — that string just needs to be
56
+ captured and persisted alongside the full content.
57
+
58
+ Resolves when: backend schema migration for native function calling
59
+ lands (Phase 2.2 of the native-tools plan, which already touches the
60
+ `messages` table). Adding a `display` column in the same migration is
61
+ cheap; doing it as a separate migration later is not. When this lands,
62
+ `summarizeToolResult` becomes unnecessary for new tool results; it
63
+ stays only as a fallback for legacy rows lacking `display`.
64
+
65
+ Not blocking — current heuristics cover all 33 tools' output shapes.
66
+ Track until Phase 2.2 lands.
package/index.js CHANGED
@@ -49,7 +49,7 @@ if (_argv.includes('--allow-all')) {
49
49
  const _readonly = _argv.includes('--readonly');
50
50
 
51
51
  const permissionManager = createPermissionManager(ui, { allowedTiers: _allowedTiers, readonly: _readonly });
52
- const { agentExecShell, agentExecFile } = createToolExecutor(permissionManager, ui, getConfig);
52
+ const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(permissionManager, ui, getConfig);
53
53
  const apiClient = createApiClient({
54
54
  getConfig,
55
55
  saveConfig: (nextConfig) => {
@@ -66,6 +66,8 @@ const { runAgentLoop } = createAgentRunner({
66
66
  }),
67
67
  agentExecShell,
68
68
  agentExecFile,
69
+ describePermission,
70
+ permissionManager,
69
71
  ui,
70
72
  getConfig,
71
73
  });
@@ -119,7 +121,12 @@ Options:
119
121
  --dashboard-url <url> Dashboard URL (init)
120
122
  --default-model <name> Default model (init)
121
123
  --show-think Display model reasoning (thinking) content
122
- --debug Print messages sent to agent + raw AI response (stderr) each iteration
124
+ --debug Inline debug output: per-iteration debug block in the
125
+ chat history. TUI-safe; no per-chunk noise.
126
+ --debug-file <path> Extended debug to file: per-iteration block PLUS raw
127
+ SSE chunks, request body dumps, accumulator state,
128
+ and other high-volume traces. Nothing prints to stdout
129
+ — the TUI stays clean. Mutually exclusive with --debug.
123
130
  --allow-fs Auto-approve all filesystem operations
124
131
  --allow-exec Auto-approve shell command execution
125
132
  --allow-net Auto-approve network operations
package/lib/agent.js CHANGED
@@ -3,13 +3,16 @@
3
3
  const { logToolCall } = require('./audit');
4
4
  const { Metrics } = require('./metrics');
5
5
  const { getSystemPrompt } = require('./prompts');
6
+ const { isNativeToolsActive } = require('./config');
6
7
  const { TAG_REGISTRY } = require('./constants');
7
8
  const { mapInvokeToCall } = require('./tools');
9
+ const { TOOL_SPECS } = require('./tool_specs');
8
10
  const { UI_THEME } = require('./ui/theme');
9
11
  const { RST } = require('./ui/ansi');
10
12
  const { getCols: _getCols, repeatToWidth } = require('./ui/utils');
11
13
  const writer = require('./ui/writer');
12
14
  const messages = require('./ui/messages');
15
+ const dbg = require('./debug');
13
16
 
14
17
  class StreamParser {
15
18
  constructor(onToken, onTagOpen, onTagContent, onTagClose) {
@@ -180,7 +183,8 @@ function abortableSleep(ms, signal) {
180
183
  });
181
184
  }
182
185
 
183
- function detectFormat(reply, toolCalls) {
186
+ function detectFormat(reply, toolCalls, nativeToolCalls) {
187
+ if (nativeToolCalls && nativeToolCalls.length > 0) return 'native_tool_calls';
184
188
  if (!reply || !reply.trim()) return 'empty';
185
189
  if (/<(minimax:tool_call|qwen:tool_call|tool_call|function_call)\b/i.test(reply)) return 'tool_call';
186
190
  if (toolCalls && toolCalls.length > 0) return 'command';
@@ -222,6 +226,26 @@ function previewCommand(call) {
222
226
  return trimmed ? `<${tag}> ${trimmed}` : `<${tag}>`;
223
227
  }
224
228
 
229
+ // Classify why mapInvokeToCall returned null for a native tool_call so the
230
+ // debug block (and the corrective retry hint) can surface the specific cause
231
+ // instead of a generic "unknown name or invalid args". Source of truth is
232
+ // TOOL_SPECS — its `required` array tells us which positional args the
233
+ // native API advertised, and `wrapper:true` flags parser envelopes that
234
+ // must never appear as a model-emitted tool name.
235
+ function describeNativeRejection(toolName, params) {
236
+ const lowerName = (toolName || '').toLowerCase();
237
+ const spec = TOOL_SPECS[lowerName];
238
+ if (!spec || spec.wrapper) {
239
+ return 'unknown name (not in TOOL_SPECS / not supported by mapInvokeToCall)';
240
+ }
241
+ const required = (spec.parameters && spec.parameters.required) || [];
242
+ const missing = required.filter((r) => params[r] === undefined || params[r] === null);
243
+ if (missing.length > 0) {
244
+ return `missing required arg: ${missing.join(', ')}`;
245
+ }
246
+ return 'mapInvokeToCall returned null without specific reason';
247
+ }
248
+
225
249
  function formatDebugBlock(sections) {
226
250
  // The debug block is rendered as a tool-output message in the TUI. Chat
227
251
  // history indents output by 5 cols; account for that so the frame still
@@ -418,7 +442,7 @@ function _attrsFromCall(call) {
418
442
  }
419
443
  }
420
444
 
421
- function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agentExecFile, ui, getConfig }) {
445
+ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agentExecFile, describePermission, permissionManager, ui, getConfig }) {
422
446
  const { BOLD, FG_DARK, FG_GRAY, FG_TEAL, FG_YELLOW, RST, THEME, getCols } = ui;
423
447
 
424
448
  function formatFileResult(call, result) {
@@ -542,8 +566,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
542
566
  }
543
567
  case 'http_get': {
544
568
  const url = attrs.url || content;
545
- const raw = attrs.raw || '';
546
- return formatFileResult(['http_get', url, raw], await agentExecFile('http_get', url, raw));
569
+ return formatFileResult(['http_get', url], await agentExecFile('http_get', url));
547
570
  }
548
571
  case 'ask_user': {
549
572
  const q = attrs.question || content;
@@ -598,22 +621,23 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
598
621
  const metrics = new Metrics(tokenLimit);
599
622
  const mode = overrideMode || 'system_role';
600
623
 
601
- // Route debug blocks to the UI callback when present (interactive TUI mode
602
- // overwrites stderr with redraws, losing the output). Fall back to stderr
603
- // for one-shot/non-TTY flows where there's no UI to host the block.
624
+ // Route debug blocks based on debug mode.
625
+ // file mode — write to the debug file. Never touch the TUI.
626
+ // simple mode UI callback when present (chat-bubble in interactive
627
+ // TUI), fall back to stderr for one-shot/non-TTY flows.
628
+ // off mode — discard. (debug=true can also come from in-chat /debug
629
+ // toggle with no global mode active.)
604
630
  const emitDebug = (block) => {
631
+ if (dbg.isFile()) {
632
+ dbg.log(block);
633
+ return;
634
+ }
605
635
  if (typeof cb.onDebug === 'function') cb.onDebug(block);
606
636
  // audit: allowed — stderr debug under --debug flag (no UI hosting available).
607
637
  else process.stderr.write('\n' + block + '\n');
608
638
  };
609
639
 
610
- // Resolve native_tools from the active profile (matched by api_base+model).
611
- // Fallback to true if no matching profile — mirrors config-normalization default.
612
- const _cfg = typeof getConfig === 'function' ? getConfig() : {};
613
- const _profile = Array.isArray(_cfg.models)
614
- ? _cfg.models.find((p) => p && p.api_base === _cfg.api_base && p.model === model)
615
- : null;
616
- const nativeTools = _profile && _profile.native_tools === false ? false : true;
640
+ const nativeTools = isNativeToolsActive(model);
617
641
 
618
642
  const activeSystemPrompt = overrideSystemPrompt !== null ? overrideSystemPrompt : getSystemPrompt(nativeTools);
619
643
 
@@ -854,20 +878,35 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
854
878
  const nativeToolCalls = Array.isArray(result?.toolCalls) ? result.toolCalls : [];
855
879
  let toolCalls;
856
880
  let nativeToolCallIds = [];
881
+ // Per-call rejection records for native tool_calls that could not be
882
+ // converted to executable form (parse error or unknown name / missing
883
+ // required arg). Used downstream to (a) keep the assistant's tool_calls
884
+ // ↔ tool-result map consistent, and (b) feed a corrective hint back to
885
+ // the model so it retries instead of stalling.
886
+ const nativeRejections = [];
857
887
  if (nativeToolCalls.length > 0) {
858
888
  toolCalls = [];
859
889
  for (const tc of nativeToolCalls) {
890
+ const fnName = tc.function?.name || '(unknown)';
891
+ const argsRaw = tc.function?.arguments || '';
892
+ const argsPreview = argsRaw.length > 200 ? argsRaw.slice(0, 200) + '…' : argsRaw;
860
893
  let args;
861
894
  try {
862
- args = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
895
+ args = argsRaw ? JSON.parse(argsRaw) : {};
863
896
  } catch (err) {
864
- if (cb.onError) cb.onError({ message: `Failed to parse tool_call arguments for ${tc.function?.name || '(unknown)'}: ${err.message}`, isWarning: true });
897
+ const reason = `JSON parse failed: ${err.message}`;
898
+ if (cb.onError) cb.onError({ message: `${fnName}: ${reason} Args: ${argsPreview}`, isWarning: true });
899
+ nativeRejections.push({ id: tc.id, name: fnName, argsPreview, reason });
865
900
  continue;
866
901
  }
867
- const call = mapInvokeToCall(tc.function?.name, args);
902
+ const call = mapInvokeToCall(fnName, args);
868
903
  if (call) {
869
904
  toolCalls.push(call);
870
905
  nativeToolCallIds.push(tc.id);
906
+ } else {
907
+ const reason = describeNativeRejection(fnName, args);
908
+ if (cb.onError) cb.onError({ message: `${fnName}: ${reason} Args: ${argsPreview}`, isWarning: true });
909
+ nativeRejections.push({ id: tc.id, name: fnName, argsPreview, reason });
871
910
  }
872
911
  }
873
912
  } else {
@@ -895,17 +934,27 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
895
934
  const visibleTokens = Math.max(completionTokens - thinkingTokens, 0);
896
935
  const contextLimit = tokenLimit || null;
897
936
  const ctxPct = contextLimit ? Math.round((promptTokens / contextLimit) * 100) : null;
898
- const detected = detectFormat(reply, toolCalls);
937
+ const detected = detectFormat(reply, toolCalls, nativeToolCalls);
899
938
  const firstCmd = toolCalls.length > 0 ? previewCommand(toolCalls[0]) : previewCommand(null);
900
939
  const toolTags = Object.entries(TAG_REGISTRY)
901
940
  .filter(([, e]) => e.type === 'tool')
902
941
  .map(([t]) => t);
942
+ const callableSpecCount = Object.values(TOOL_SPECS).filter((s) => !s.wrapper).length;
903
943
 
904
944
  const warnings = [];
905
945
  if (result.finish_reason === 'length') warnings.push('finish_reason=length → response truncated, increase max_tokens');
906
946
  if (detected === 'tool_call' && toolCalls.length === 0) {
907
947
  warnings.push('commands_found=0 → agent emitted no command, client will stall');
908
948
  }
949
+ if (detected === 'native_tool_calls' && toolCalls.length === 0) {
950
+ const lines = [`commands_found=0 → all ${nativeToolCalls.length} native tool_call(s) rejected:`];
951
+ for (const r of nativeRejections) {
952
+ lines.push(` • name="${r.name}"`);
953
+ lines.push(` args=${r.argsPreview || '(empty)'}`);
954
+ lines.push(` reason=${r.reason}`);
955
+ }
956
+ warnings.push(lines.join('\n'));
957
+ }
909
958
  if (ctxPct !== null && ctxPct > 80) warnings.push(`context_used=${ctxPct}% → approaching context limit`);
910
959
 
911
960
  const block = formatDebugBlock({
@@ -931,7 +980,9 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
931
980
  ['temperature:', result.request?.temperature ?? '(default)'],
932
981
  ['stop_sequences:', JSON.stringify(result.request?.stop || [])],
933
982
  ['reasoning_effort:', '(n/a)'],
934
- ['tools_enabled:', `${toolTags.length} XML tags (via system prompt)`],
983
+ ['tools_enabled:', nativeTools
984
+ ? `${callableSpecCount} functions (via tools API)`
985
+ : `${toolTags.length} XML tags (via system prompt)`],
935
986
  ]],
936
987
  ['RESPONSE', [
937
988
  ['finish_reason:', result.finish_reason || '(unknown)'],
@@ -981,7 +1032,13 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
981
1032
  }
982
1033
 
983
1034
  const assistantMsg = { role: 'assistant', content: cleanedReply };
984
- if (isNativeCall) assistantMsg.tool_calls = nativeToolCalls;
1035
+ // Only attach tool_calls for the calls we actually accepted. Attaching
1036
+ // rejected calls here would leave them without matching `tool` results
1037
+ // on the next turn — strict providers reject the resulting history.
1038
+ if (isNativeCall && nativeToolCallIds.length > 0) {
1039
+ const acceptedSet = new Set(nativeToolCallIds);
1040
+ assistantMsg.tool_calls = nativeToolCalls.filter((tc) => acceptedSet.has(tc.id));
1041
+ }
985
1042
  messages.push(assistantMsg);
986
1043
  // When showThink is off and the turn has tool calls, suppress the text bubble —
987
1044
  // pre-tool reasoning is noise, tool result bubbles already convey what happened.
@@ -989,6 +1046,29 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
989
1046
  if (cb.onAssistantMessage) cb.onAssistantMessage(displayReply);
990
1047
 
991
1048
  if (toolCalls.length === 0) {
1049
+ // Native mode: tool_calls came in but none could be converted (parse
1050
+ // error or unknown name / missing required arg). Push a corrective
1051
+ // user hint so the model retries instead of stalling. Without this
1052
+ // the loop would break silently — that's the bug the migration set
1053
+ // out to fix.
1054
+ if (isNativeCall && nativeRejections.length > 0) {
1055
+ const summary = nativeRejections
1056
+ .map((r) => `- ${r.name}: ${r.reason}`)
1057
+ .join('\n');
1058
+ if (cb.onError) {
1059
+ const names = nativeRejections.map((r) => r.name).join(', ');
1060
+ cb.onError({
1061
+ message: `Native tool_call(s) rejected: ${names}. Asking the model to retry with a valid call.`,
1062
+ isWarning: true,
1063
+ });
1064
+ }
1065
+ messages.push({
1066
+ role: 'user',
1067
+ content: `Your last response contained tool_calls that could not be executed:\n\n${summary}\n\nRetry with a valid tool name and complete required arguments per the tools schema.`,
1068
+ });
1069
+ continue;
1070
+ }
1071
+
992
1072
  // Detect malformed known-tag syntax (e.g. <create_file> with no path
993
1073
  // attribute, usually paired with nonsense like <attrs: path=...> inside
994
1074
  // the body). Push a corrective feedback message and keep looping so
@@ -1027,36 +1107,125 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
1027
1107
  // never reused even if the agent runs the same tag twice.
1028
1108
  let invocationCounter = 0;
1029
1109
 
1030
- for (const call of toolCalls) {
1031
- if (isAborted()) { aborted = true; break; }
1110
+ // Re-arm the abort watcher for the tool-execution phase. The API-call
1111
+ // finally cleared the previous one, so without this a Ctrl+C while a
1112
+ // long shell command is running would never reach the AbortSignal we
1113
+ // now thread into agentExecShell — the child would keep running and
1114
+ // the UI would show "Interrupted" without actually killing anything.
1115
+ const toolAbortWatcher = setInterval(() => {
1116
+ if (isAborted() && !controller.signal.aborted) controller.abort();
1117
+ }, 50);
1032
1118
 
1033
- const tag = call[0] || 'unknown';
1034
- const arg = call[1] || '';
1035
- const toolStart = Date.now();
1036
- const invocationId = `tool-${iteration}-${invocationCounter++}-${tag}`;
1037
- const attrs = _attrsFromCall(call);
1038
- const startCtx = { id: invocationId, call, attrs, startedAt: toolStart };
1119
+ try {
1120
+ for (const call of toolCalls) {
1121
+ if (isAborted()) { aborted = true; break; }
1122
+
1123
+ const tag = call[0] || 'unknown';
1124
+ const arg = call[1] || '';
1125
+ const attrs = _attrsFromCall(call);
1126
+
1127
+ // Permission gate, lifted out of the executors. Asking before
1128
+ // onToolStart fires means the activity bubble (and its 1Hz
1129
+ // ticker) doesn't pre-date grant — and on denial no bubble
1130
+ // appears at all. The picker's own onCloseModal scrollback
1131
+ // line ("✗ <description>") is the visual record of the denial.
1132
+ let permDesc = null;
1133
+ try {
1134
+ permDesc = describePermission ? await describePermission(call) : null;
1135
+ } catch (err) {
1136
+ if (cb.onError) cb.onError({ message: `describePermission(${tag}): ${err.message}`, isWarning: true });
1137
+ }
1138
+ if (permDesc) {
1139
+ if (cb.onPermissionAsk) cb.onPermissionAsk(tag, arg);
1140
+ let approved = true;
1141
+ try {
1142
+ approved = await permissionManager.askPermission(permDesc.actionType, permDesc.description, permDesc.tag);
1143
+ } catch (err) {
1144
+ if (cb.onError) cb.onError({ message: `askPermission(${tag}): ${err.message}`, isWarning: true });
1145
+ approved = false;
1146
+ }
1147
+ if (!approved) {
1148
+ const resultStr = (tag === 'shell' || tag === 'exec')
1149
+ ? `Command \`${arg}\`: Permission denied by user.`
1150
+ : `${tag} ${arg}: Permission denied by user.`;
1151
+ logToolCall(permDesc.tag, { args: call.slice(1) }, false, 'denied');
1152
+ results.push(resultStr);
1153
+ if (debugEntries) debugEntries.push({ tag, call, ms: 0, status: 'denied', exitCode: null, result: resultStr });
1154
+ aborted = true;
1155
+ break;
1156
+ }
1157
+ }
1039
1158
 
1040
- if (cb.onToolStart) cb.onToolStart(tag, arg, startCtx);
1159
+ const toolStart = Date.now();
1160
+ const invocationId = `tool-${iteration}-${invocationCounter++}-${tag}`;
1161
+ const startCtx = { id: invocationId, call, attrs, startedAt: toolStart };
1041
1162
 
1042
- try {
1043
- if (tag === 'shell') {
1044
- const shellResult = await agentExecShell(arg);
1163
+ if (cb.onToolStart) cb.onToolStart(tag, arg, startCtx);
1164
+
1165
+ try {
1166
+ if (tag === 'shell') {
1167
+ const shellResult = await agentExecShell(arg, { signal: controller.signal });
1168
+ const ms = Date.now() - toolStart;
1169
+ if (shellResult.aborted) {
1170
+ // User pressed Ctrl+C mid-command. The child process tree
1171
+ // has already been terminated by killTreeEscalating in
1172
+ // tools.js. Surface a clear message to the model so it can
1173
+ // plan around the interruption instead of blindly retrying
1174
+ // the same long-running command on the next turn.
1175
+ const elapsedS = shellResult.elapsed_s || 0;
1176
+ const oneLine = String(arg).replace(/\s+/g, ' ').trim();
1177
+ const truncatedCmd = oneLine.length > 80 ? oneLine.slice(0, 77) + '...' : oneLine;
1178
+ const resultStr = `User interrupted execution after ${elapsedS}s. Tool was running: ${truncatedCmd}. Plan around this — do not retry the same long-running command.`;
1179
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'aborted' } });
1180
+ results.push(resultStr);
1181
+ if (debugEntries) debugEntries.push({ tag, call, ms, status: 'aborted', exitCode: null, result: resultStr });
1182
+ aborted = true;
1183
+ break;
1184
+ } else {
1185
+ let out = shellResult.stdout;
1186
+ if (shellResult.stderr) out += `\nSTDERR: ${shellResult.stderr}`;
1187
+ const resultStr = `Command \`${arg}\`:\nExit code: ${shellResult.exit_code}\n${out}`;
1188
+ const meta = _metaForTool(tag, shellResult);
1189
+ const error = shellResult.exit_code !== 0
1190
+ ? { message: `exit ${shellResult.exit_code}`, code: shellResult.exit_code }
1191
+ : null;
1192
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta, error });
1193
+ results.push(resultStr);
1194
+ if (debugEntries) debugEntries.push({
1195
+ tag,
1196
+ call,
1197
+ ms,
1198
+ status: shellResult.exit_code === 0 ? 'ok' : 'nonzero_exit',
1199
+ exitCode: shellResult.exit_code,
1200
+ result: resultStr,
1201
+ });
1202
+ }
1203
+ continue;
1204
+ }
1205
+
1206
+ const fileResult = await agentExecFile(...call, { signal: controller.signal });
1045
1207
  const ms = Date.now() - toolStart;
1046
- if (shellResult.stderr === 'Permission denied by user') {
1047
- const resultStr = `Command \`${arg}\`: Permission denied by user.`;
1048
- if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'denied' }, denied: true });
1208
+
1209
+ if (fileResult.aborted) {
1210
+ // User pressed Ctrl+C while a file/network tool was running.
1211
+ // The per-tool abort listener has already torn down the in-flight
1212
+ // op (closed the FS read, destroyed the HTTP request, stopped the
1213
+ // recursive walk). Surface a clear note to the model so the next
1214
+ // turn doesn't replay the same long-running operation.
1215
+ const elapsedS = fileResult.elapsed_s || 0;
1216
+ const oneLine = String(arg).replace(/\s+/g, ' ').trim();
1217
+ const truncatedArg = oneLine.length > 80 ? oneLine.slice(0, 77) + '...' : oneLine;
1218
+ const resultStr = `User interrupted execution after ${elapsedS}s. Tool was running: ${tag} ${truncatedArg}. Plan around this — do not retry the same long-running operation.`;
1219
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'aborted' } });
1049
1220
  results.push(resultStr);
1050
- if (debugEntries) debugEntries.push({ tag, call, ms, status: 'denied', exitCode: null, result: resultStr });
1221
+ if (debugEntries) debugEntries.push({ tag, call, ms, status: 'aborted', exitCode: null, result: resultStr });
1051
1222
  aborted = true;
1052
1223
  break;
1053
1224
  } else {
1054
- let out = shellResult.stdout;
1055
- if (shellResult.stderr) out += `\nSTDERR: ${shellResult.stderr}`;
1056
- const resultStr = `Command \`${arg}\`:\nExit code: ${shellResult.exit_code}\n${out}`;
1057
- const meta = _metaForTool(tag, shellResult);
1058
- const error = shellResult.exit_code !== 0
1059
- ? { message: `exit ${shellResult.exit_code}`, code: shellResult.exit_code }
1225
+ const resultStr = formatFileResult(call, fileResult);
1226
+ const meta = _metaForTool(tag, fileResult);
1227
+ const error = fileResult.error
1228
+ ? { message: fileResult.error, code: fileResult.error_code || null }
1060
1229
  : null;
1061
1230
  if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta, error });
1062
1231
  results.push(resultStr);
@@ -1064,53 +1233,26 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
1064
1233
  tag,
1065
1234
  call,
1066
1235
  ms,
1067
- status: shellResult.exit_code === 0 ? 'ok' : 'nonzero_exit',
1068
- exitCode: shellResult.exit_code,
1236
+ status: fileResult.error ? 'error' : 'ok',
1237
+ exitCode: null,
1069
1238
  result: resultStr,
1070
1239
  });
1071
1240
  }
1072
- continue;
1073
- }
1074
-
1075
- const fileResult = await agentExecFile(...call);
1076
- const ms = Date.now() - toolStart;
1077
-
1078
- if (fileResult.error === 'Permission denied') {
1079
- const resultStr = `${tag} ${call[1] || ''}: Permission denied by user.`;
1080
- if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'denied' }, denied: true });
1081
- results.push(resultStr);
1082
- if (debugEntries) debugEntries.push({ tag, call, ms, status: 'denied', exitCode: null, result: resultStr });
1083
- aborted = true;
1084
- break;
1085
- } else {
1086
- const resultStr = formatFileResult(call, fileResult);
1087
- const meta = _metaForTool(tag, fileResult);
1088
- const error = fileResult.error
1089
- ? { message: fileResult.error, code: fileResult.error_code || null }
1090
- : null;
1091
- if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta, error });
1092
- results.push(resultStr);
1093
- if (debugEntries) debugEntries.push({
1094
- tag,
1095
- call,
1096
- ms,
1097
- status: fileResult.error ? 'error' : 'ok',
1098
- exitCode: null,
1099
- result: resultStr,
1100
- });
1101
- }
1102
- } catch (err) {
1103
- const ms = Date.now() - toolStart;
1104
- if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms, { id: invocationId, call, attrs, meta: null, error: err });
1105
- if (cb.onError) {
1106
- cb.onError({ message: `Tool error (${tag}): ${err.message}`, isWarning: true });
1107
- } else {
1108
- messages.toolError(tag, err.message);
1241
+ } catch (err) {
1242
+ const ms = Date.now() - toolStart;
1243
+ if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms, { id: invocationId, call, attrs, meta: null, error: err });
1244
+ if (cb.onError) {
1245
+ cb.onError({ message: `Tool error (${tag}): ${err.message}`, isWarning: true });
1246
+ } else {
1247
+ messages.toolError(tag, err.message);
1248
+ }
1249
+ logToolCall(tag, { args: call.slice(1) }, false, 'error');
1250
+ results.push(`${tag}: Error — ${err.message}`);
1251
+ if (debugEntries) debugEntries.push({ tag, call, ms, status: 'exception', exitCode: null, result: `Error — ${err.message}` });
1109
1252
  }
1110
- logToolCall(tag, { args: call.slice(1) }, false, 'error');
1111
- results.push(`${tag}: Error — ${err.message}`);
1112
- if (debugEntries) debugEntries.push({ tag, call, ms, status: 'exception', exitCode: null, result: `Error — ${err.message}` });
1113
1253
  }
1254
+ } finally {
1255
+ clearInterval(toolAbortWatcher);
1114
1256
  }
1115
1257
 
1116
1258
  if (debug && debugEntries && debugEntries.length > 0) {
@@ -1167,9 +1309,14 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
1167
1309
  } else {
1168
1310
  messages.sysWarn(warnMsg);
1169
1311
  }
1170
- // Push whatever results accumulated before the denial so the LLM has
1171
- // context if the user asks to continue.
1312
+ // Push whatever results accumulated before the stop so the LLM has
1313
+ // context if the user asks to continue. The reason matters: an abort
1314
+ // (Ctrl+C) and a denial are both surfaced through the same `aborted`
1315
+ // flag, but the model should know which happened so it doesn't
1316
+ // immediately retry a runaway command after the user explicitly
1317
+ // killed it.
1172
1318
  if (results.length > 0) {
1319
+ const reason = isAborted() ? 'user interrupted' : 'after user denied an action';
1173
1320
  if (isNativeCall) {
1174
1321
  for (let i = 0; i < results.length; i++) {
1175
1322
  messages.push({ role: 'tool', tool_call_id: nativeToolCallIds[i], content: results[i] });
@@ -1177,7 +1324,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
1177
1324
  } else {
1178
1325
  messages.push({
1179
1326
  role: 'user',
1180
- content: `Tool execution results (partial — stopped after user denied an action):\n\n${results.join('\n\n')}`,
1327
+ content: `Tool execution results (partial — stopped: ${reason}):\n\n${results.join('\n\n')}`,
1181
1328
  });
1182
1329
  }
1183
1330
  }