@hncode/hncode 0.3.6 → 0.4.1

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 (148) hide show
  1. package/package.json +5 -3
  2. package/src/agent-task.js +154 -84
  3. package/src/agent.js +533 -145
  4. package/src/clipboard.js +5 -2
  5. package/src/colors.js +242 -238
  6. package/src/config.js +217 -17
  7. package/src/file-history.js +177 -0
  8. package/src/hooks.js +11 -1
  9. package/src/i18n.js +389 -0
  10. package/src/index.js +46 -3
  11. package/src/llm.js +139 -16
  12. package/src/permissions.js +120 -0
  13. package/src/session-hub.js +407 -0
  14. package/src/session.js +31 -0
  15. package/src/term.js +19 -3
  16. package/src/tool-result.js +236 -0
  17. package/src/tools/agent.js +11 -0
  18. package/src/tools/bash.js +84 -17
  19. package/src/tools/edit.js +192 -32
  20. package/src/tools/file-lines.js +5 -0
  21. package/src/tools/index.js +4 -0
  22. package/src/tools/memory.js +88 -0
  23. package/src/tools/read-media-file.js +85 -68
  24. package/src/tools/read.js +15 -1
  25. package/src/tools/utils.js +14 -0
  26. package/src/tools/write.js +35 -5
  27. package/src/tui.js +11665 -9179
  28. package/src/web-auth.js +98 -0
  29. package/src/web-daemon-cli.js +63 -0
  30. package/src/web-daemon-client.js +203 -0
  31. package/src/web-daemon.js +871 -0
  32. package/src/web-public/app.css +1509 -0
  33. package/src/web-public/app.js +2913 -0
  34. package/src/web-public/complete.js +154 -0
  35. package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
  36. package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
  38. package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
  39. package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
  40. package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
  41. package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
  42. package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
  43. package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
  44. package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
  45. package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
  46. package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
  47. package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
  48. package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
  49. package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
  50. package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
  51. package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
  52. package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
  53. package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
  54. package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
  55. package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
  56. package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
  57. package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
  58. package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
  59. package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
  60. package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
  61. package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
  62. package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
  63. package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
  64. package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
  65. package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
  66. package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
  67. package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
  68. package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
  69. package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
  70. package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
  71. package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
  72. package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
  73. package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
  74. package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
  75. package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
  76. package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
  77. package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
  78. package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
  79. package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
  80. package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
  81. package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
  82. package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
  83. package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
  84. package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
  85. package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
  86. package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
  87. package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
  88. package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
  89. package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
  90. package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
  91. package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
  92. package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
  93. package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
  94. package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
  95. package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
  96. package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
  97. package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
  98. package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
  99. package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
  100. package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
  101. package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
  102. package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
  103. package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
  104. package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
  105. package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
  106. package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
  107. package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
  108. package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
  109. package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
  110. package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
  111. package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
  112. package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
  113. package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
  114. package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
  115. package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
  116. package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
  117. package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
  118. package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
  119. package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
  120. package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
  121. package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
  122. package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
  123. package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
  124. package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
  125. package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
  126. package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
  127. package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
  128. package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
  129. package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
  130. package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
  131. package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
  132. package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
  133. package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
  134. package/src/web-public/fonts/noto-sans-sc.css +775 -0
  135. package/src/web-public/fonts.css +31 -0
  136. package/src/web-public/home.css +222 -0
  137. package/src/web-public/home.html +33 -0
  138. package/src/web-public/home.js +160 -0
  139. package/src/web-public/i18n.js +983 -0
  140. package/src/web-public/index.html +196 -0
  141. package/src/web-public/login.css +123 -0
  142. package/src/web-public/login.html +25 -0
  143. package/src/web-public/login.js +46 -0
  144. package/src/web-public/settings.js +290 -0
  145. package/src/web-ws.js +226 -0
  146. package/src/web.js +380 -0
  147. /package/bin/{install.js → install.cjs} +0 -0
  148. /package/bin/{preinstall.js → preinstall.cjs} +0 -0
package/src/agent.js CHANGED
@@ -5,9 +5,106 @@
5
5
  import { LLM, setToolsList } from './llm.js';
6
6
  import { tools, getTool, llmTools } from './tools/index.js';
7
7
  import { DELEGATION_TOOLS } from './subagent-types.js';
8
- import { estimateMessagesTokens } from './term.js';
8
+ import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens } from './term.js';
9
9
  import { runHooks } from './plugin.js';
10
10
  import { loadHooks, runShellHooks } from './hooks.js';
11
+ import {
12
+ shapeToolResult, trimToolResults, DEFAULT_TRIM_THRESHOLD, DEFAULT_TRIM_KEEP_RATIO,
13
+ } from './tool-result.js';
14
+ // ---- compaction text (kimi's shapes) ----------------------------------------
15
+ // These mirror kimi-code's contextMemory/compactionHandoff.ts so a compacted
16
+ // history reads the same way there.
17
+
18
+ // Wraps harness-generated text so it reads as a system note rather than something
19
+ // the user actually typed.
20
+ function wrapSystemReminder(content) {
21
+ return `<system-reminder>\n${String(content).trim()}\n</system-reminder>`;
22
+ }
23
+
24
+ const COMPACTION_SUMMARY_PREFIX = [
25
+ 'The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over.',
26
+ 'Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it.',
27
+ 'User messages earlier in this context are preserved verbatim from the compacted conversation. The summary records which earlier requests were already addressed.',
28
+ ].join(' ');
29
+
30
+ // The instruction given to the model that WRITES the summary. Adapted from
31
+ // kimi-code's fullCompaction/compaction-instruction.md: it is a handoff for the
32
+ // model that resumes, not a recap for a human. The language rule matters — without
33
+ // it a Chinese conversation produced an English summary and the resumed context
34
+ // lost the vocabulary the task was being discussed in.
35
+ const COMPACTION_INSTRUCTION = [
36
+ 'You are about to run out of context. Create a handoff summary for the model that will resume this task after the earlier conversation is cleared.',
37
+ '',
38
+ 'This message is a direct task, not part of the above conversation.',
39
+ '',
40
+ 'Write it in the same language the conversation has been using — do not switch to English just because these instructions are in English.',
41
+ '',
42
+ 'Make the summary self-sufficient: the next turn will see only the preserved messages and this summary. In your own words, preserve what is genuinely needed to continue:',
43
+ '',
44
+ '- What the latest request actually asks for, and any ambiguity you have already resolved. If the latest request is large (a big paste or file) and may be truncated in the kept messages, preserve the actual ask.',
45
+ '- The instructions and constraints currently in force (user preferences, project rules, environment limits), with settled decisions kept separate from open questions, so neither is reopened nor mistaken for decided.',
46
+ '- What has actually been done, at high fidelity: exact commands run, exact file paths touched, and their results — the concrete values, key lines and error text — not just the commands. Keep only the final working version of any code; drop intermediate attempts and already-resolved errors.',
47
+ '- What you still do not know: files referenced but not read, APIs assumed but unseen, questions the user has not answered. Name these gaps so the next turn checks them instead of assuming.',
48
+ '- The forward plan: the exact next step, the sequence to finish, decisions already made for those steps, foreseeable obstacles and how you mean to handle them.',
49
+ '',
50
+ 'Be honest about uncertainty. If an earlier step claimed something was done but was never verified, say so plainly and treat it as unverified rather than fact.',
51
+ '',
52
+ 'Be concise and proportional: a long multi-step task warrants detail, a nearly finished exchange needs only a sentence or two.',
53
+ '',
54
+ 'Respond with text only. Do not call any tools.',
55
+ ].join('\n');
56
+
57
+ // Trailing note: the model should RESUME, not answer the summary.
58
+ const COMPACTION_CONTINUATION_TEXT = 'Context compaction is complete — continue the work that was in progress when it began.';
59
+
60
+ // Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
61
+ // Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
62
+ // rule estimateTokens uses). Slicing by characters is wrong for CJK text: 4000
63
+ // Chinese characters are ~4000 tokens, not ~1000.
64
+ function clipToTokens(text, maxTokens) {
65
+ if (maxTokens <= 0) return '';
66
+ const s = String(text ?? '');
67
+ let ascii = 0, nonAscii = 0, end = 0;
68
+ for (const ch of s) {
69
+ if (ch.codePointAt(0) <= 127) ascii++; else nonAscii++;
70
+ if (Math.ceil(ascii / 4) + nonAscii > maxTokens) break;
71
+ end += ch.length;
72
+ }
73
+ return end >= s.length ? s : s.slice(0, end) + '\n…[clipped]';
74
+ }
75
+
76
+ // May the history be split between `index` and `index + 1`? Mirrors kimi's
77
+ // canSplitAfter — a split is illegal when it would orphan a tool exchange:
78
+ // * the message before the cut is a user message (would start the kept part
79
+ // mid-turn), or
80
+ // * it is an assistant message that still has tool calls, or
81
+ // * the message after the cut is a tool result, or
82
+ // * the prefix ends inside an open tool exchange (more results to come).
83
+ function canSplitAfter(messages, index) {
84
+ if (index < 0 || index >= messages.length) return false;
85
+ const m = messages[index];
86
+ if (!m) return false;
87
+ if (m.role === 'user') return false;
88
+ if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) return false;
89
+ const next = messages[index + 1];
90
+ if (next && next.role === 'tool') return false;
91
+ return !prefixEndsWithOpenToolExchange(messages, index);
92
+ }
93
+
94
+ // True when the prefix [0..index] ends with an assistant that issued MORE tool
95
+ // calls than have results so far — cutting there would leave a call unanswered.
96
+ function prefixEndsWithOpenToolExchange(messages, index) {
97
+ if (!messages[index] || messages[index].role !== 'tool') return false;
98
+ let results = 0;
99
+ for (let i = index; i >= 0; i--) {
100
+ const m = messages[i];
101
+ if (!m) return false;
102
+ if (m.role === 'tool') { results++; continue; }
103
+ return m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > results;
104
+ }
105
+ return false;
106
+ }
107
+
11
108
 
12
109
  // A short, stable key for the prompt cache, derived from the conversation's
13
110
  // first user message. Stability is the whole point: the same conversation must
@@ -22,15 +119,71 @@ function hashKey(s) {
22
119
  h = (h * 0x01000193) >>> 0;
23
120
  }
24
121
  return h.toString(36) + '-' + str.length.toString(36);
122
+ return h.toString(36) + '-' + str.length.toString(36);
25
123
  }
26
124
 
125
+ // Render what an Edit actually changed, for the MODEL's tool result.
126
+ //
127
+ // The UI has always received the edit through `editDiff` (see the tool_result
128
+ // event), but the message pushed into `this.messages` carried only the one-line
129
+ // receipt ("Edited a.js: replaced 1 occurrence(s)."). The model was therefore
130
+ // asked to verify its own edit from a sentence that describes nothing: it could
131
+ // not see WHAT changed, so a wrong-but-successful edit looked identical to a
132
+ // correct one. The diff is appended here — after the receipt — so the model
133
+ // reads what it did.
134
+ //
135
+ // The format is the usual unified one (`- old`, `+ new`) with the line number
136
+ // from the file, and it is deliberately indented two spaces: a tool result is
137
+ // rendered as plain text, and the indent keeps the +/- marks from reading as
138
+ // part of the JSON envelope some providers wrap tool output in.
139
+ export function formatEditDiffForModel(diff, maxLines = 400) {
140
+ if (!diff || typeof diff.old !== 'string' || typeof diff.new !== 'string') return '';
141
+ const split = (s) => String(s).replace(/\r\n/g, '\n').split('\n');
142
+ const oldLines = split(diff.old);
143
+ const newLines = split(diff.new);
144
+ const startLine = Number(diff.startLine) > 0 ? Number(diff.startLine) : 1;
145
+ const out = [];
146
+ out.push('');
147
+ out.push('--- diff ---');
148
+ let n = startLine;
149
+ for (const l of oldLines) out.push(' ' + String(n++).padStart(4) + ' - ' + l);
150
+ n = startLine;
151
+ for (const l of newLines) out.push(' ' + String(n++).padStart(4) + ' + ' + l);
152
+ // A whole-file edit (a Write-sized `new`) must not be echoed back in full.
153
+ if (out.length > maxLines + 3) {
154
+ const hidden = out.length - (maxLines + 3);
155
+ out.length = maxLines + 3;
156
+ out.push(' … ' + hidden + ' more diff line(s) omitted');
157
+ }
158
+ return out.join('\n');
159
+ }
27
160
 
28
161
  // Final instruction appended to the system prompt (see SYSTEM_PROMPT).
29
162
  // (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
30
163
 
31
164
 
32
- // Final instruction appended to the system prompt (see SYSTEM_PROMPT).
33
- // (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
165
+ // Tools whose execution cannot affect what any OTHER tool call in the same step
166
+ // observes, and cannot be affected by one. Two read-only tools therefore have no
167
+ // reason to run one after the other, and the agent runs a consecutive run of them
168
+ // in parallel (see the tool loop in run()). Anything that writes, spawns, shells
169
+ // out or mutates the working tree is excluded — that rules out `Git` (it can
170
+ // stage/commit/switch), `ReadMediaFile` (read-only, but it decodes megabytes of
171
+ // media and is never the bottleneck) and every delegation tool. Claude Code
172
+ // expresses the same idea as `isConcurrencySafe()` on each tool; this is the flat
173
+ // version of it, matching hncode's tool registry.
174
+ const READ_ONLY_TOOLS = new Set([
175
+ 'Read', 'Grep', 'Glob', 'FileLines', 'FetchURL', 'WebSearch',
176
+ 'TaskList', 'TaskOutput',
177
+ ]);
178
+
179
+ // Debug switch for the size/age control in tool-result.js, mirroring Claude
180
+ // Code's env-gated knobs. Set HNCODE_TOOL_RESULT_LIMITS=off to send every tool
181
+ // result verbatim (useful when a spill is suspected of hiding something the
182
+ // model needed).
183
+ function toolResultLimitsEnabled() {
184
+ return !/^(0|off|false|no)$/i.test(String(process.env.HNCODE_TOOL_RESULT_LIMITS || ''));
185
+ }
186
+
34
187
 
35
188
  export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
36
189
  You work inside a workspace directory using the tools provided.
@@ -84,6 +237,12 @@ Finishing:
84
237
  - If context is running low, call /compact to shrink history.
85
238
  `;
86
239
 
240
+ /** Plain text of a structured tool result, for the model and the transcript. */
241
+ function mediaResultText(r) {
242
+ if (!r || typeof r !== 'object') return String(r == null ? '' : r);
243
+ return String(r.text == null ? '' : r.text);
244
+ }
245
+
87
246
  export class Agent {
88
247
  constructor({ cfg, messages, onEvent, maxSteps = 0, onToolStart, canInterrupt = () => false, onApproval }) {
89
248
  this.cfg = cfg;
@@ -110,7 +269,11 @@ export class Agent {
110
269
  // when the user presses Esc / Ctrl-C — otherwise the command keeps running
111
270
  // after the turn was interrupted.
112
271
  this.toolAbort = new AbortController();
113
- this.ctx = { ...cfg, tasks: {}, todoState: Array.isArray(cfg.todoState) ? cfg.todoState.slice() : [], allowExternal: !!cfg.allowExternal, signal: this.toolAbort.signal };
272
+ // Reuse an incoming task store when the caller provides one. Background
273
+ // tasks OUTLIVE the turn that started them (their process is detached), so a
274
+ // fresh `{}` per Agent made them vanish from the UI on the next turn while
275
+ // still running. The TUI passes its persistent `state.tasks` through cfg.
276
+ this.ctx = { ...cfg, tasks: cfg.tasks || {}, todoState: Array.isArray(cfg.todoState) ? cfg.todoState.slice() : [], allowExternal: !!cfg.allowExternal, signal: this.toolAbort.signal };
114
277
  // Live output stream for a RUNNING tool (Bash): the tool calls this with raw
115
278
  // chunks and the agent tags them with the id of the tool call in flight, so
116
279
  // the TUI can append them to the matching "Using …" row while it runs.
@@ -118,12 +281,24 @@ export class Agent {
118
281
  this.ctx.onOutput = (chunk) => {
119
282
  if (chunk && this._currentToolId) this.onEvent({ type: 'tool_output', id: this._currentToolId, chunk });
120
283
  };
284
+ // The Read snapshot pool lives ON THE AGENT, not on a per-turn ctx. It records
285
+ // what the model has read (and the hashes at that moment) so a later Edit can
286
+ // refuse to act on a stale snippet. /undo rewinds files on disk, i.e. it makes
287
+ // those hashes describe content that is no longer there — the TUI clears this
288
+ // pool right after a rewind so the next Edit is not rejected as stale.
289
+ this.readPool = new Map();
290
+ this.ctx.readPool = this.readPool;
121
291
  // `fork` (the Agent tool) snapshots THIS conversation into the subagent. The
122
- // tool reads `ctx._parentMessages`, which nothing ever set — so `fork: true`
123
- // was accepted and then seeded the subagent with an empty history. Hand the
124
- // tool a live getter over this agent's own messages instead of a copy, so the
125
- // snapshot is taken at spawn time and reflects everything up to that point.
126
- this.ctx._parentMessages = this.messages;
292
+ // tool reads `ctx._parentMessages`. This must be a GETTER, not the array
293
+ // itself: auto-compaction REPLACES `this.messages` with a trimmed history, so
294
+ // a retained reference kept pointing at the pre-compaction array — a fork
295
+ // after a compaction would hand the subagent exactly the oversized history
296
+ // that was just cut, and could push it straight past its own window.
297
+ Object.defineProperty(this.ctx, '_parentMessages', {
298
+ get: () => this.messages,
299
+ enumerable: true,
300
+ configurable: true,
301
+ });
127
302
  // Prompt-cache key: identify the SESSION, not the request, so the cached
128
303
  // prefix is reused across turns instead of thrashing. The caller may pass one
129
304
  // (the TUI knows the session id); otherwise derive a stable value from the
@@ -136,6 +311,10 @@ export class Agent {
136
311
  }
137
312
  this.llm = new LLM(cfg);
138
313
  this.stopRequested = false;
314
+ // Auto-compaction is allowed at most once per TURN (not per step). Reset here so
315
+ // a caller that reuses one Agent across turns still gets a fresh allowance.
316
+ this._compactedThisTurn = false;
317
+ this.onApproval = onApproval; // async (toolName, args) => boolean
139
318
  this.onApproval = onApproval; // async (toolName, args) => boolean
140
319
  // Shell hooks (Claude Code-style): user-configured commands run at lifecycle
141
320
  // events. ALWAYS stored as a `{ hooks: {Event: [...]} }` config, which is the
@@ -165,6 +344,55 @@ export class Agent {
165
344
  this.stopRequested = true;
166
345
  try { this.toolAbort.abort(); } catch {}
167
346
  if (this.llm) this.llm.abort();
347
+ // The compaction summarizer is a SEPARATE LLM instance; abort it too, or an
348
+ // auto-compaction in flight cannot be interrupted (Esc did nothing until the
349
+ // summary request finished on its own).
350
+ if (this._summaryLlm) { try { this._summaryLlm.abort(); } catch {} }
351
+ }
352
+
353
+ // What the NEXT request will carry: the messages (which already begin with the
354
+ // assembled system prompt — see the TUI's runAgent) plus the tool-definition
355
+ // overhead. It must NOT add `cfg.systemPrompt` on top: that value is the RAW
356
+ // config string, not the assembled prompt (calm/plan/swarm/AGENTS.md are appended
357
+ // later), so counting it both double-counted the system text and measured the
358
+ // wrong figure — while being the only input that decides whether to compact.
359
+ usedTokens() {
360
+ let total = 0;
361
+ for (const m of this.messages || []) total += estimateMessagesTokens([m], null);
362
+ if (Array.isArray(this.cfg.toolFilter)) total += this.cfg.toolFilter.length * 8;
363
+ return total;
364
+ }
365
+
366
+ // Where to cut the history for compaction. Walks back from the end accumulating a
367
+ // token budget, but only accepts indices the boundary rule allows (so the kept
368
+ // slice can never begin on an orphan tool result). Returns how many messages to
369
+ // DROP. Mirrors kimi's computeCompactCount + canSplitAfter.
370
+ computeCompactSplit(maxCtx) {
371
+ // Keep a fraction of the CURRENT usage, not of the whole window: a big window
372
+ // (1M) should not force a huge tail to be kept when only a little was used.
373
+ // Ratio is configurable (/auto-compact keep, config.toml compact_keep_ratio),
374
+ // default 0.2.
375
+ const keepRatio = (typeof this.cfg.compactKeepRatio === 'number'
376
+ && this.cfg.compactKeepRatio > 0 && this.cfg.compactKeepRatio < 1)
377
+ ? this.cfg.compactKeepRatio : 0.2;
378
+ const used = this.usedTokens();
379
+ const maxSize = Math.max(1, Math.floor((used || (maxCtx || 512000) * 0.85) * keepRatio));
380
+ const msgs = this.messages || [];
381
+ // Never cut inside the LEADING system block. `msgs[0]` is the assembled system
382
+ // prompt, and canSplitAfter() would happily allow a split right after it — which
383
+ // would drop the prompt itself and leave the request with no instructions.
384
+ let floor = 0;
385
+ while (floor < msgs.length && msgs[floor] && msgs[floor].role === 'system') floor++;
386
+ let recent = 1;
387
+ let size = 0;
388
+ let bestSplit;
389
+ for (; recent < msgs.length; recent++) {
390
+ const split = msgs.length - recent;
391
+ size += estimateMessagesTokens([msgs[split]], null);
392
+ if (split > floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
393
+ if (size >= maxSize && bestSplit !== undefined) break;
394
+ }
395
+ return bestSplit ?? 0;
168
396
  }
169
397
 
170
398
  async run() {
@@ -191,6 +419,10 @@ export class Agent {
191
419
  let thought = '';
192
420
  // decide whether running out of steps actually left the turn unfinished.
193
421
  let hadToolCallsOnFinalStep = false;
422
+ // How many times this turn has continued an answer the output cap cut in half.
423
+ // Bounded so a model that cannot fit its answer in the budget cannot loop.
424
+ let truncationRecoveries = 0;
425
+ const MAX_TRUNCATION_RECOVERIES = 3;
194
426
 
195
427
  const handle = async (e) => {
196
428
  switch (e.type) {
@@ -204,6 +436,12 @@ export class Agent {
204
436
  thought += e.text;
205
437
  this.onEvent({ type: 'think', text: e.text });
206
438
  break;
439
+ case 'truncated':
440
+ // The reply hit max_tokens and was cut off mid-answer. Remember it: the
441
+ // loop below continues the turn instead of ending it (see the `truncated`
442
+ // branch), so a half-written answer is never presented as a finished one.
443
+ this._truncated = true;
444
+ break;
207
445
  case 'tool_start':
208
446
  toolCalls.push({ id: e.id, name: e.name, args: {}, argsJson: '' });
209
447
  this.onEvent({ type: 'tool_start', id: e.id, name: e.name });
@@ -220,7 +458,15 @@ export class Agent {
220
458
  const tc = toolCalls.find((t) => t.id === e.id);
221
459
  if (tc) {
222
460
  try { tc.args = tc.argsJson ? JSON.parse(tc.argsJson) : {}; }
223
- catch { tc.args = { raw: tc.argsJson }; }
461
+ catch {
462
+ // The streamed arguments did not form complete JSON. The usual cause
463
+ // is truncation (a huge `content`, a long command) or an interrupted
464
+ // stream. The `raw` shape is kept so a tool can SEE that its
465
+ // arguments failed to parse and say so, instead of reporting a
466
+ // missing field; `_rawLen` lets that message state how big the
467
+ // partial payload was, which is what tells the model to split it.
468
+ tc.args = { raw: tc.argsJson, _rawLen: tc.argsJson ? tc.argsJson.length : 0 };
469
+ }
224
470
  }
225
471
  break;
226
472
  }
@@ -253,20 +499,37 @@ export class Agent {
253
499
  }
254
500
  if (this.stopRequested) { this.onEvent({ type: 'stopped' }); break; }
255
501
  text = ''; toolCalls = []; emittedEnd = false; thought = '';
502
+ this._truncated = false;
256
503
  // Report the start of each model round so the TUI can count steps/rounds.
257
504
  this.onEvent({ type: 'step_start', step, round: step + 1 });
258
505
  // ---- auto-compaction (kimi's runtime strategy) ----
259
506
  // Before each model call, if the estimated context usage is at or above
260
- // 85% of the model's max context, summarize the older portion of the
261
- // conversation and keep only the most recent 20% of messages (plus the
262
- // summary). This mirrors kimi's triggerRatio=0.85 & maxRecentSizeRatio=0.2
263
- // policy, using the same ASCII/4 + non-ASCII token estimator. The dropped
264
- // messages are replaced by a single AI-generated summary so context is
265
- // preserved rather than simply discarded.
507
+ // 85% of the model's max context, summarize the older portion and keep a
508
+ // small recent tail. Mirrors kimi's triggerRatio=0.85 /
509
+ // maxRecentSizeRatio=0.2 policy, plus its `reservedContextSize` rule: a
510
+ // request that leaves less than the reserved headroom is compacted even
511
+ // below 85%, so the reply has room to be generated.
266
512
  const maxCtx = this.cfg.maxContextTokens || 512000;
267
- let usedNow = estimateMessagesTokens(this.messages, this.cfg);
268
- if (this.cfg.autoCompact !== false && maxCtx > 0 && usedNow >= maxCtx * 0.85) {
513
+ let usedNow = this.usedTokens();
514
+ // `reservedContextSize` (kimi): a request leaving less than this much headroom
515
+ // is compacted even below the trigger ratio, so the REPLY has room to be
516
+ // generated. A request just under the trigger could otherwise still fail.
517
+ const reserved = this.cfg.reservedContextTokens || 50_000;
518
+ const needsRoom = reserved > 0 && reserved < maxCtx && usedNow + reserved >= maxCtx;
519
+ // Trigger ratio is configurable (/auto-compact threshold, config.toml
520
+ // compact_threshold); default 0.85.
521
+ const trigger = (typeof this.cfg.compactThreshold === 'number' && this.cfg.compactThreshold > 0
522
+ && this.cfg.compactThreshold < 1) ? this.cfg.compactThreshold : 0.85;
523
+ // At most ONCE per turn. This used to run inside the step loop with no counter,
524
+ // so a trim that failed to get under the threshold triggered again on the very
525
+ // next step — paying for another summary round-trip each step while the history
526
+ // kept shrinking.
527
+ const shouldCompact = this.cfg.autoCompact !== false && maxCtx > 0
528
+ && !this._compactedThisTurn
529
+ && (usedNow >= maxCtx * trigger || needsRoom);
530
+ if (shouldCompact) {
269
531
  const before = usedNow;
532
+ this._compactedThisTurn = true;
270
533
  // SHELL HOOK: PreCompact — a chance to back up the transcript before the
271
534
  // older half is summarized away. Never blocks (the trim must go ahead).
272
535
  await runShellHooks(this.hookConfig, 'PreCompact',
@@ -276,79 +539,124 @@ export class Agent {
276
539
  // with no sign anything was happening.
277
540
  this.onEvent({ type: 'compacting', before: usedNow, keep: 0, dropped: 0 });
278
541
  //
279
- // ORDER: SUMMARIZE FIRST, THEN TRIM. The summary is generated from the
280
- // FULL history so the summarizing model can see what just happened — the
281
- // most recent exchange is what tells it what the session is doing now.
282
- // Trimming first and summarizing the leftovers summarized only the OLD
283
- // part and lost the information the summary needed most.
542
+ // SUMMARIZE BEFORE TRIMMING. The summary is generated from the FULL history,
543
+ // so the summarizing model can see what just happened — the most recent
544
+ // exchange is what tells it what the session is doing now. Trimming first
545
+ // summarized only the OLD part and lost what the summary needed most.
284
546
  //
285
- // The transcript fed to the summarizer is BOUNDED: the full history can
286
- // itself exceed the window, in which case the call fails and the trim
287
- // would drop everything with nothing to show for it. Keep the MOST RECENT
288
- // messages within the budget — they carry the live state — and clip any
289
- // single oversized message.
290
- const SUMMARY_INPUT_CHARS = 120_000;
291
- const perMsgCap = 8_000;
547
+ // The transcript fed to the summarizer is bounded by TOKENS, not characters.
548
+ // A character cap is not a size cap: 120k Chinese characters is roughly 120k
549
+ // tokens, which alone can exceed the window and make the call FAIL — after
550
+ // which the history was discarded with no summary at all. That is the normal
551
+ // case for a Chinese conversation, not an edge case.
552
+ const summaryBudget = Math.max(1, Math.floor(maxCtx * 0.3));
553
+ const perMsgTokens = 4_000;
292
554
  const summaryParts = [];
293
- let summaryChars = 0;
555
+ let summaryTokens = 0;
294
556
  for (let i = this.messages.length - 1; i >= 0; i--) {
295
557
  const m = this.messages[i];
296
- const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
297
- const clipped = raw.length > perMsgCap ? raw.slice(0, perMsgCap) + '\n…[clipped]' : raw;
298
- const line = `${m.role}: ${clipped}`;
299
- if (summaryChars + line.length > SUMMARY_INPUT_CHARS) break;
300
- summaryChars += line.length;
558
+ let raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
559
+ if (estimateTokens(raw) > perMsgTokens) raw = clipToTokens(raw, perMsgTokens);
560
+ const line = `${m.role}: ${raw}`;
561
+ const t = estimateTokens(line);
562
+ if (summaryTokens + t > summaryBudget) break;
563
+ summaryTokens += t;
301
564
  summaryParts.unshift(line);
302
565
  }
303
566
  let summary = '';
304
567
  if (this.messages.length > 0) {
305
568
  const summaryPrompt = [
306
- { role: 'system', content: 'Summarize the conversation history that follows. Capture the user\'s goals, the key decisions made, files created/modified, and the current state of any ongoing work. Be concise but thorough.' },
569
+ { role: 'system', content: COMPACTION_INSTRUCTION },
307
570
  { role: 'user', content: summaryParts.join('\n\n') },
308
571
  ];
309
- const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 2048) };
572
+ const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 4096) };
310
573
  const llm = new LLM(cfgCopy);
311
- try { summary = await llm.requestText(summaryPrompt); } catch { summary = ''; }
574
+ // Remember this throwaway summarizer so Esc / Ctrl-C can abort IT too.
575
+ // It is NOT `this.llm`, so `interrupt()` could not reach it — a running
576
+ // auto-compaction was uninterruptible, which felt like a hang.
577
+ this._summaryLlm = llm;
578
+ // Thinking OFF and no tools, matching Claude Code's summarizer
579
+ // (`thinkingConfig: { type: 'disabled' }`). Summarizing is a straight
580
+ // transcription task: reasoning tokens buy nothing here, and on a
581
+ // reasoning model they can consume the whole output budget — which
582
+ // returns an EMPTY summary, and an empty summary is what turns a
583
+ // compaction into a silent loss of history.
584
+ try { summary = await llm.requestText(summaryPrompt, { noReasoning: true, noTools: true }); } catch { summary = ''; }
585
+ this._summaryLlm = null;
312
586
  }
313
- // NOW trim. Keep under a TOKEN budget, not a message count: sizes vary by
314
- // orders of magnitude (one Read result can dwarf fifty short turns), so a
315
- // fixed 20%-of-messages slice could still leave the request above the
316
- // threshold or throw away far more than needed. Walk backwards until the
317
- // retained tail reaches ~30% of the window; always keep at least the last
318
- // two so the newest exchange survives.
319
- const keepBudget = Math.max(1, Math.floor(maxCtx * 0.3));
320
- let keepFrom = this.messages.length;
321
- let keptTokens = 0;
322
- while (keepFrom > 0) {
323
- const n = this.messages.length - keepFrom; // already-kept count
324
- if (n >= 2 && keptTokens >= keepBudget) break;
325
- const cand = this.messages[keepFrom - 1];
326
- const t = estimateMessagesTokens([cand], this.cfg) - estimateMessagesTokens([], this.cfg);
327
- // Always take at least the last two messages, however big they are.
328
- if (n >= 2 && keptTokens + t > keepBudget) break;
329
- keptTokens += t;
330
- keepFrom--;
587
+ // Interrupted mid-summary: do NOT trim. Aborting yields an empty summary, and
588
+ // proceeding would drop history with nothing to replace it — data loss. Mark
589
+ // the compaction block cancelled and bail out of the turn.
590
+ if (this.stopRequested) {
591
+ this.onEvent({ type: 'compaction_cancelled' });
592
+ this.onEvent({ type: 'stopped' });
593
+ break;
331
594
  }
332
- // BOUNDARY FIX: never let the kept slice START on a `tool` message whose
333
- // assistant(toolCalls) was dropped — most APIs reject a tool result with
334
- // no preceding call. Pull the boundary back over any leading tool rows.
335
- while (keepFrom > 0 && keepFrom < this.messages.length && this.messages[keepFrom].role === 'tool') {
336
- keepFrom--;
595
+ // NOW trim, at a split point the boundary rule allows.
596
+ const split = this.computeCompactSplit(maxCtx);
597
+ const keep = this.messages.slice(split);
598
+ const droppedCount = this.messages.length - keep.length;
599
+ // Shape: [ ...kept, summary, continuation ].
600
+ //
601
+ // The summary is a USER message wrapped in a <system-reminder> (kimi's
602
+ // shape), not a `system` message. Two reasons:
603
+ // * the TUI rebuilds the system prompt from scratch each turn and SKIPS
604
+ // every `system` entry in the stored history, so a system summary was
605
+ // dropped before the model ever saw it;
606
+ // * the turn-end save filters `system` out of the session, so it was not
607
+ // persisted either — history trimmed AND summary lost at both ends,
608
+ // which made "compaction" pure data loss.
609
+ // The continuation note goes LAST so the model's next move is "keep going"
610
+ // rather than "answer the summary".
611
+ const summaryText = summary
612
+ ? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
613
+ : `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
614
+ this.messages = [
615
+ ...keep,
616
+ { role: 'user', content: wrapSystemReminder(summaryText) },
617
+ { role: 'user', content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
618
+ ];
619
+ usedNow = this.usedTokens();
620
+ this.onEvent({
621
+ type: 'compacted', before, after: usedNow, kept: keep.length,
622
+ dropped: droppedCount, summary,
623
+ });
624
+ // SHELL HOOK: PostCompact. Fires after the trim, so a hook can archive the
625
+ // summary or record the shrink. Never blocks.
626
+ await runShellHooks(this.hookConfig, 'PostCompact',
627
+ { before, after: usedNow, kept: keep.length, dropped: droppedCount, summary }, this.cfg.workspace);
628
+ }
629
+ // ---- tool-result trimming ----
630
+ // The cheap pass, and the one that actually runs in practice: a history is
631
+ // usually LARGE because of old tool output nobody is reading, not because the
632
+ // conversation is long. Bodies are replaced by a short pointer — the message
633
+ // list keeps its exact length and roles, so the assistant/tool pairing and the
634
+ // cached prefix shape are untouched — and the removed text is copied to disk
635
+ // first, so it stays recoverable.
636
+ //
637
+ // Trigger: the request reached `trimThreshold` (50% by default) of the window.
638
+ // Target: keep `trimKeepRatio` (30% by default) of the tool-result TEXT,
639
+ // newest first. Skipped while a compaction just ran (that already shrank
640
+ // things) and above the compaction trigger (compaction is the right tool by
641
+ // then, and the two must not fight over the same history).
642
+ const trimThreshold = Number.isFinite(this.cfg.trimThreshold) ? this.cfg.trimThreshold : DEFAULT_TRIM_THRESHOLD;
643
+ if (this.cfg.autoTrim !== false && toolResultLimitsEnabled()
644
+ && !this._compactedThisTurn && maxCtx > 0
645
+ && usedNow >= maxCtx * trimThreshold && usedNow < maxCtx * (this.cfg.compactThreshold || 0.85)) {
646
+ const res = trimToolResults(this.messages, {
647
+ sessionId: this.cfg.sessionId,
648
+ keepRatio: Number.isFinite(this.cfg.trimKeepRatio) ? this.cfg.trimKeepRatio : DEFAULT_TRIM_KEEP_RATIO,
649
+ // The model may still be reading the result it was handed last step.
650
+ mustKeep: this._lastToolResultIndex,
651
+ });
652
+ if (res && res.elided > 0) {
653
+ const after = this.usedTokens();
654
+ this.onEvent({
655
+ type: 'results_trimmed', elided: res.elided, elidedBytes: res.elidedBytes,
656
+ kept: res.kept, keptBytes: res.keptBytes, before: usedNow, after,
657
+ });
658
+ usedNow = after;
337
659
  }
338
- const keep = this.messages.slice(keepFrom);
339
- const droppedCount = keepFrom;
340
- // Final shape: SUMMARY + the most recent messages.
341
- // ALWAYS prepend a marker, even when summarization failed. Silently
342
- // dropping history left the model (and the user) with no idea that context
343
- // was lost; a one-line note makes the gap explicit.
344
- this.messages = [{
345
- role: 'system',
346
- content: summary
347
- ? `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of the conversation so far:\n\n${summary}`
348
- : `[hncode] ${droppedCount} earlier message(s) were dropped to stay within the context window, and no summary could be generated. Their detail is no longer available.`,
349
- }, ...keep];
350
- usedNow = estimateMessagesTokens(this.messages, this.cfg);
351
- this.onEvent({ type: 'compacted', before, after: usedNow, kept: keep.length });
352
660
  }
353
661
  // Live context gauge: report the estimated size of the request about to be
354
662
  // sent, so the TUI's context readout is refreshed at every step rather than
@@ -378,8 +686,9 @@ export class Agent {
378
686
  break;
379
687
  }
380
688
 
381
- // append assistant message —— 绝不 push 既无文本、又无 tool_calls 的空消息,
382
- // 否则上游报 "content or tool_calls must be set"。
689
+ // Append the assistant message. NEVER push one that carries neither text
690
+ // nor tool_calls: upstream rejects it with "content or tool_calls must be
691
+ // set".
383
692
  const hasText = !!(text && text.trim());
384
693
  const hasCalls = toolCalls.length > 0;
385
694
  if (hasText || hasCalls) {
@@ -403,6 +712,32 @@ export class Agent {
403
712
  const steers = this.steerQueue.splice(0);
404
713
  for (const msg of steers) this.messages.push({ role: 'user', content: msg });
405
714
  continue;
715
+ } else if (this._truncated && truncationRecoveries < MAX_TRUNCATION_RECOVERIES) {
716
+ if (!hasText) {
717
+ // Truncated with NO text at all: every output token went into reasoning
718
+ // (a reasoning model on a small budget). There is nothing to continue
719
+ // from, and ending the turn here would look like the model simply had
720
+ // nothing to say. Say what actually happened instead.
721
+ this.onEvent({
722
+ type: 'error',
723
+ error: new Error(
724
+ `The model reached its ${this.cfg.maxOutputTokens || 'configured'} output-token limit `
725
+ + 'before producing any text — raise the output limit (HNCODE_MAX_OUTPUT) or lower the thinking effort.',
726
+ ),
727
+ });
728
+ break;
729
+ }
730
+ // The answer was cut off by max_tokens: it is HALF an answer, and it is
731
+ // already committed above with no tool calls. Ask the model to pick up
732
+ // where it stopped instead of ending the turn — otherwise the user reads
733
+ // a sentence that stops mid-word and assumes that was all of it.
734
+ truncationRecoveries++;
735
+ this.messages.push({
736
+ role: 'user',
737
+ content: 'Your previous message was cut off because it reached the output token limit. Continue from exactly where it stopped — do not repeat what you already wrote, and do not start over.',
738
+ });
739
+ this.onEvent({ type: 'truncation_recovery', attempt: truncationRecoveries });
740
+ continue;
406
741
  } else if (!hasText) {
407
742
  // No tool calls and no answer text: the model stopped without saying
408
743
  // anything. End the turn.
@@ -428,26 +763,29 @@ export class Agent {
428
763
  const modeName = Array.isArray(this.cfg.toolFilter)
429
764
  ? (this.cfg.toolFilter.includes('Bash') ? 'Focus' : 'Plan')
430
765
  : undefined;
431
- for (const tc of toolCalls) {
432
- if (this.stopRequested) break;
766
+
767
+ // Gate + execute + hooks for ONE call, with no message bookkeeping: the
768
+ // caller posts the result separately, in call order. Splitting it this way
769
+ // is what makes a parallel batch possible at all — concurrent calls finish
770
+ // out of order, and tool results MUST be appended in the order the model
771
+ // asked for them or the protocol pairing breaks.
772
+ const prepareToolCall = async (tc) => {
433
773
  // Tag output produced by THIS tool call so onOutput can route it to the
434
774
  // right "Using …" row (a turn can run several tools in sequence).
435
775
  this._currentToolId = tc.id;
436
-
776
+
437
777
  // Permission check before executing each tool
438
- let result;
778
+ let result = null;
779
+ let ok = true;
439
780
  if (this.onApproval) {
440
- const ok = await this.onApproval(tc.name, tc.args);
441
- if (!ok) {
781
+ const approved = await this.onApproval(tc.name, tc.args);
782
+ if (!approved) {
442
783
  result = `Blocked by user: tool "${tc.name}" was rejected.`;
443
784
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
444
- this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
445
- this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
446
- allOk = false;
447
- continue;
785
+ return { ok: false, result, editDiff: null, media: false };
448
786
  }
449
787
  }
450
-
788
+
451
789
  const tool = getTool(tc.name);
452
790
  // A subagent may not delegate. The tool is not exposed to it, but the model
453
791
  // can still emit a call for a tool it was told about earlier, so refuse it
@@ -455,73 +793,123 @@ export class Agent {
455
793
  if (this.noDelegation && DELEGATION_TOOLS.includes(tc.name)) {
456
794
  result = `You CAN'T use ${tc.name}: subagents cannot delegate. Do the work yourself and report the result to the agent that started you.`;
457
795
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
458
- this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
459
- this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
460
- allOk = false;
461
- continue;
796
+ return { ok: false, result, editDiff: null, media: false };
462
797
  }
463
798
  if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
464
799
  result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
465
800
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
466
- this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
467
- this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
468
- allOk = false;
469
- continue;
801
+ return { ok: false, result, editDiff: null, media: false };
470
802
  }
471
803
  if (!tool) {
472
804
  result = `Error: unknown tool "${tc.name}".`;
473
- allOk = false;
474
- } else {
475
- // SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
476
- // message goes back to the model in place of the tool result — that is
477
- // the whole point of a pre-hook (e.g. refuse a write that fails a lint
478
- // gate). Checked BEFORE approving/executing so a blocked call never
479
- // touches the filesystem.
480
- const pre = await runShellHooks(this.hookConfig, 'PreToolUse',
481
- { toolName: tc.name, toolArgs: tc.args }, this.ctx.cwd || this.cfg.workspace);
482
- if (pre.blocked) {
483
- result = `Blocked by a PreToolUse hook: ${pre.reason}`;
484
- this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
485
- this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
486
- this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
487
- allOk = false;
488
- continue;
489
- }
490
- if (this.onToolStart) this.onToolStart(tc.name, tc.args);
805
+ return { ok: false, result, editDiff: null, media: false };
806
+ }
807
+ // SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
808
+ // message goes back to the model in place of the tool result — that is
809
+ // the whole point of a pre-hook (e.g. refuse a write that fails a lint
810
+ // gate). Checked BEFORE approving/executing so a blocked call never
811
+ // touches the filesystem.
812
+ const pre = await runShellHooks(this.hookConfig, 'PreToolUse',
813
+ { toolName: tc.name, toolArgs: tc.args }, this.ctx.cwd || this.cfg.workspace);
814
+ if (pre.blocked) {
815
+ result = `Blocked by a PreToolUse hook: ${pre.reason}`;
491
816
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
492
- // Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
493
- // mutable args object, so a plugin can inspect or adjust a call. Errors
494
- // are swallowed by runHooks — a broken plugin must not break a turn.
495
- await runHooks('onToolExecute', tc.name, tc.args, this.ctx);
496
- try {
497
- result = await tool.execute(tc.args, this.ctx);
498
- } catch (err) {
499
- result = `Error running ${tc.name}: ${err.message}`;
500
- allOk = false;
501
- }
502
- this.ctx.lastResult = result;
503
- // Plugin hook: onToolResult fires with the tool's output.
504
- await runHooks('onToolResult', tc.name, result, this.ctx);
505
- // SHELL HOOK: PostToolUse. Never blocks (the tool already ran); a
506
- // failure is logged. This is where "format after every edit" lives.
507
- await runShellHooks(this.hookConfig, 'PostToolUse',
817
+ return { ok: false, result, editDiff: null, media: false };
818
+ }
819
+ if (this.onToolStart) this.onToolStart(tc.name, tc.args);
820
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
821
+ // Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
822
+ // mutable args object, so a plugin can inspect or adjust a call. Errors
823
+ // are swallowed by runHooks — a broken plugin must not break a turn.
824
+ await runHooks('onToolExecute', tc.name, tc.args, this.ctx);
825
+ try {
826
+ result = await tool.execute(tc.args, this.ctx);
827
+ } catch (err) {
828
+ result = `Error running ${tc.name}: ${err.message}`;
829
+ ok = false;
830
+ }
831
+ this.ctx.lastResult = result;
832
+ // Plugin hook: onToolResult fires with the tool's output.
833
+ await runHooks('onToolResult', tc.name, result, this.ctx);
834
+ // SHELL HOOK: PostToolUse. Never blocks (the tool already ran); a
835
+ // failure is logged. This is where "format after every edit" lives.
836
+ await runShellHooks(this.hookConfig, 'PostToolUse',
837
+ { toolName: tc.name, toolArgs: tc.args, toolResult: result }, this.ctx.cwd || this.cfg.workspace);
838
+ // SHELL HOOK: PostToolUseFailure. Fires only when the tool failed (an
839
+ // `Error`/`[error:` result, or a thrown run), so a hook can alert or
840
+ // retry without matching on every successful call.
841
+ if (typeof result === 'string' && /^(Error\b|\[error:)/.test(result.trim())) {
842
+ ok = false;
843
+ await runShellHooks(this.hookConfig, 'PostToolUseFailure',
508
844
  { toolName: tc.name, toolArgs: tc.args, toolResult: result }, this.ctx.cwd || this.cfg.workspace);
509
- // Surface the live TODO list so the TUI can render its panel.
510
- if (tc.name === 'TodoList') {
511
- this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
512
- }
513
845
  }
514
- this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
515
- // An Edit reports the text it replaced on ctx (both the substring and the
516
- // line-range path do). Forward it so the TUI can draw the +/- diff: the
517
- // line-range mode has no `old_string` in its args, so without this the
518
- // edit rendered with no diff at all. Cleared every time so a later tool
519
- // cannot inherit a stale diff.
846
+ // Surface the live TODO list so the TUI can render its panel.
847
+ if (tc.name === 'TodoList') {
848
+ this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
849
+ }
850
+ // A tool may return a STRUCTURED result instead of a string: ReadMediaFile
851
+ // returns `{ text, media }` so an image can travel as an image.
852
+ const editDiff = this.ctx.lastEditDiff || null;
853
+ const media = !!result && typeof result === 'object' && Array.isArray(result.media);
854
+ this.ctx.lastEditDiff = null;
855
+ return { ok, result, editDiff, media };
856
+ };
857
+
858
+ // Post one call's outcome: the model's `tool` message, then the TUI event.
859
+ const postToolResult = (tc, out) => {
860
+ if (!out.ok) allOk = false;
861
+ if (out.media) {
862
+ const forModel = out.editDiff
863
+ ? { ...out.result, text: mediaResultText(out.result) + formatEditDiffForModel(out.editDiff) }
864
+ : out.result;
865
+ this.messages.push({ role: 'tool', toolCallId: tc.id, content: forModel });
866
+ this.onEvent({
867
+ type: 'tool_result', id: tc.id, name: tc.name,
868
+ content: out.result._receipt || out.result.text || '[media]',
869
+ editDiff: out.editDiff,
870
+ });
871
+ return;
872
+ }
873
+ // The transcript keeps the full text; the MODEL may get a bounded version
874
+ // (a spill or a preview) so one huge read cannot inflate every subsequent
875
+ // request. See tool-result.js.
876
+ const rawForModel = out.editDiff ? out.result + formatEditDiffForModel(out.editDiff) : out.result;
877
+ const shaped = toolResultLimitsEnabled()
878
+ ? shapeToolResult(rawForModel, { sessionId: this.cfg.sessionId, toolCallId: tc.id })
879
+ : { content: rawForModel, spilled: false };
880
+ this.messages.push({ role: 'tool', toolCallId: tc.id, content: shaped.content });
881
+ // Remember where the newest tool result landed: a trim must never elide the
882
+ // output the model was handed in the step it is about to reason about.
883
+ this._lastToolResultIndex = this.messages.length - 1;
520
884
  this.onEvent({
521
- type: 'tool_result', id: tc.id, name: tc.name, content: result,
522
- editDiff: this.ctx.lastEditDiff || null,
885
+ type: 'tool_result', id: tc.id, name: tc.name, content: out.result,
886
+ editDiff: out.editDiff, spilled: shaped.spilled,
523
887
  });
524
- this.ctx.lastEditDiff = null;
888
+ };
889
+
890
+
891
+ for (let i = 0; i < toolCalls.length; i++) {
892
+ if (this.stopRequested) break;
893
+ const tc = toolCalls[i];
894
+ // A RUN of consecutive read-only calls goes out in parallel. Non-
895
+ // destructive by construction (see READ_ONLY_TOOLS), so none of them can
896
+ // observe another's effects. The system prompt has always promised this
897
+ // ("Run independent tool calls in parallel; never parallelize dependent
898
+ // ones") but the loop executed every call one at a time, so a model that
899
+ // issued four Reads paid for four round-trips in sequence.
900
+ if (!READ_ONLY_TOOLS.has(tc.name)) {
901
+ postToolResult(tc, await prepareToolCall(tc));
902
+ continue;
903
+ }
904
+ const batch = [tc];
905
+ while (i + 1 < toolCalls.length && READ_ONLY_TOOLS.has(toolCalls[i + 1].name)) batch.push(toolCalls[++i]);
906
+ if (batch.length === 1) {
907
+ postToolResult(tc, await prepareToolCall(tc));
908
+ continue;
909
+ }
910
+ // Results are posted in CALL order, never completion order.
911
+ const outs = await Promise.all(batch.map((b) => prepareToolCall(b)));
912
+ for (let k = 0; k < batch.length; k++) postToolResult(batch[k], outs[k]);
525
913
  }
526
914
 
527
915
 
@@ -531,7 +919,7 @@ export class Agent {
531
919
 
532
920
  // Report the context after this step's tool results were appended: that is
533
921
  // exactly what the next request will carry, so the gauge grows with history.
534
- this.onEvent({ type: 'context', tokens: estimateMessagesTokens(this.messages, this.cfg), max: maxCtx });
922
+ this.onEvent({ type: 'context', tokens: this.usedTokens(), max: maxCtx });
535
923
 
536
924
  }
537
925