@hncode/hncode 0.4.2 → 0.5.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 (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
@@ -1,236 +1,264 @@
1
- // Tool-result size control.
2
- //
3
- // Two separate problems, both of which used to be silent:
4
- //
5
- // 1. A SINGLE huge result. Per-tool caps exist (`MAX_OUTPUT_BYTES` in
6
- // tools/utils.js) but `Read` deliberately has none, and nothing bounded the
7
- // AGGREGATE of a step's results — a turn with four Reads could add half a
8
- // megabyte to a history that is then resent on every later request. Real
9
- // sessions in ~/.hncode/sessions reached 5 MB this way.
10
- // 2. OLD results that no longer matter. A file read forty turns ago is dead
11
- // weight, but the whole history is resent every request, so it is paid for
12
- // again and again until something compacts.
13
- //
14
- // The cap is applied where the result is BUILT (see `shapeToolResult`), and the
15
- // overflow is kept, not lost: it goes to a file under the session's own
16
- // directory and the model gets a bounded preview plus the path, so it can Read
17
- // the rest if it turns out to need it. This mirrors Claude Code's
18
- // `<persisted-output>` handling (src/utils/toolResultStorage.ts).
19
- //
20
- // The ages pass (stale-pass) is the cheap half of compaction: it clears the
21
- // BODY of old tool results while leaving the message — and therefore the
22
- // assistant/tool pairing and the exact prefix length — untouched, so it does not
23
- // disturb the message list shape the provider has already cached. Claude Code
24
- // does the same thing in microCompact.ts ("[Old tool result content cleared]").
25
-
26
- import fs from 'node:fs';
27
- import path from 'node:path';
28
-
29
- // Bytes of a single tool result that are kept inline. Above this the overflow is
30
- // written to disk. 50 KB is Claude Code's DEFAULT_MAX_RESULT_SIZE_CHARS and is
31
- // comfortably more than a whole source file's worth of lines.
32
- export const MAX_RESULT_BYTES = 50 * 1024;
33
-
34
- // Bytes of a spilled result that stay in the transcript as a preview.
35
- export const PREVIEW_BYTES = 2 * 1024;
36
-
37
- // Trigger ratio: trim once the request reaches this fraction of the model window.
38
- // 50% by default (`trim_threshold` / HNCODE_TRIM_THRESHOLD). Well below the
39
- // compaction trigger on purpose — this is the cheap pass that keeps the history
40
- // from ever getting near compaction in the first place.
41
- export const DEFAULT_TRIM_THRESHOLD = 0.5;
42
-
43
- // Fraction of the tool-result TEXT that survives a trim. 30% by default
44
- // (`trim_keep_ratio` / HNCODE_TRIM_KEEP_RATIO), i.e. the rest is elided. Measured
45
- // against the tool-result text only, not the whole request, so a trim never eats
46
- // the conversation itself.
47
- export const DEFAULT_TRIM_KEEP_RATIO = 0.3;
48
-
49
- // A tool result shorter than this is never worth eliding: the marker plus the
50
- // pointer would cost more than the text it replaces.
51
- export const MIN_TRIM_BYTES = 512;
52
-
53
- export const CLEARED_MARKER = '[Old tool result content cleared]';
54
- export const PERSISTED_TAG = 'persisted-output';
55
-
56
- // Directory for spilled results, beside the session file so it is cleaned up
57
- // with the session rather than accumulating in a temp dir.
58
- export function spillDir(sessionId) {
59
- const root = process.env.HNCODE_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.hncode');
60
- return path.join(root, 'tool-results', String(sessionId || 'unscoped'));
61
- }
62
-
63
- // Write `text` to disk and return the preview + path, or null when the write
64
- // fails (a read-only disk must not fail the tool call).
65
- export function spillResult(sessionId, toolCallId, text) {
66
- try {
67
- const dir = spillDir(sessionId);
68
- fs.mkdirSync(dir, { recursive: true });
69
- // The tool-call id is already a unique, filesystem-safe token from the
70
- // provider, so it needs no sanitizing beyond dropping path separators.
71
- const safe = String(toolCallId || 'result').replace(/[^A-Za-z0-9_.-]/g, '_');
72
- const file = path.join(dir, `${safe}.txt`);
73
- fs.writeFileSync(file, text, 'utf8');
74
- return { file, preview: previewOf(text, PREVIEW_BYTES), originalBytes: Buffer.byteLength(text, 'utf8') };
75
- } catch {
76
- return null;
77
- }
78
- }
79
-
80
- // A preview cut on a line boundary when one is close enough, so the model does
81
- // not receive half a line of code as its last visible detail.
82
- export function previewOf(text, maxBytes) {
83
- const s = String(text == null ? '' : text);
84
- if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
85
- let cut = Buffer.from(s, 'utf8').subarray(0, maxBytes).toString('utf8');
86
- if (cut.endsWith('\uFFFD')) cut = cut.slice(0, -1);
87
- const nl = cut.lastIndexOf('\n');
88
- if (nl > maxBytes * 0.5) cut = cut.slice(0, nl);
89
- return cut;
90
- }
91
-
92
- // The message the model sees in place of an oversized result. It says what
93
- // happened, how big it was, where the rest is, and how to get it — an agent that
94
- // cannot tell why its output is missing retries the same call forever.
95
- export function spillNotice(spilled) {
96
- return [
97
- `Output too large (${fmtBytes(spilled.originalBytes)}). Full output saved to: ${spilled.file}`,
98
- '',
99
- `Preview (first ${fmtBytes(PREVIEW_BYTES)}):`,
100
- spilled.preview,
101
- '',
102
- `Read the file above to see the rest — use line_offset / n_lines to page through it.`,
103
- ].join('\n');
104
- }
105
-
106
- export function fmtBytes(n) {
107
- const b = Number(n) || 0;
108
- if (b < 1024) return `${b} B`;
109
- if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
110
- return `${(b / (1024 * 1024)).toFixed(1)} MB`;
111
- }
112
-
113
- // Shape ONE tool result for the model: pass small results through, spill big
114
- // ones. Returns { content, spilled } where `content` is what goes into the
115
- // message. `sessionId`/`toolCallId` identify the spill file.
116
- export function shapeToolResult(content, { sessionId, toolCallId, maxBytes = MAX_RESULT_BYTES } = {}) {
117
- if (typeof content !== 'string') return { content, spilled: false };
118
- if (Buffer.byteLength(content, 'utf8') <= maxBytes) return { content, spilled: false };
119
- const spilled = spillResult(sessionId, toolCallId, content);
120
- // The write failed: a normal-sized preview is still better than megabytes, and
121
- // the notice must not claim a path that does not exist.
122
- if (!spilled) {
123
- return {
124
- content: `${previewOf(content, PREVIEW_BYTES)}\n\n[Output truncated: ${fmtBytes(Buffer.byteLength(content, 'utf8'))} total; the full output could not be saved.]`,
125
- spilled: false,
126
- };
127
- }
128
- return { content: spillNotice(spilled), spilled: true };
129
- }
130
-
131
- // Which tool results to elide, and by how much.
132
- //
133
- // RULE (the user's): keep a FRACTION of the tool-result text, the newest part.
134
- // Walk the results from newest to oldest accumulating their size; once the total
135
- // kept exceeds `keepRatio` of ALL tool-result text, everything older is elided.
136
- //
137
- // Walking newest-first is what makes this behave the way a person expects: the
138
- // results you were just working from are the ones that survive, and the material
139
- // from twenty turns ago goes first.
140
- //
141
- // Returns { keep: [indices], elide: [indices], totalBytes, keepBytes, elideBytes }.
142
- // `mustKeep` (an index) is never elided — the tool result belonging to the step
143
- // currently in flight, if the caller knows it.
144
- export function planTrim(messages, keepRatio, mustKeep) {
145
- const empty = { keep: [], elide: [], totalBytes: 0, keepBytes: 0, elideBytes: 0 };
146
- if (!Array.isArray(messages)) return empty;
147
- const ratio = Number.isFinite(keepRatio) ? Math.min(Math.max(keepRatio, 0), 1) : DEFAULT_TRIM_KEEP_RATIO;
148
-
149
- // Only string-bodied results can be elided; a media result carries content
150
- // blocks and clearing it would delete the image itself.
151
- const candidates = [];
152
- let totalBytes = 0;
153
- for (let i = 0; i < messages.length; i++) {
154
- const m = messages[i];
155
- if (!m || m.role !== 'tool') continue;
156
- if (typeof m.content !== 'string') continue;
157
- totalBytes += m.content.length;
158
- if (m.content === CLEARED_MARKER) continue;
159
- if (m.content.length < MIN_TRIM_BYTES) continue;
160
- candidates.push(i);
161
- }
162
- if (!candidates.length) return { ...empty, totalBytes };
163
-
164
- // A budget of 0 means "elide everything eligible"; a budget of 1 means "keep it
165
- // all", in which case we still elide the already-marked ones so a repeated call
166
- // is a no-op rather than a growing patch.
167
- const budget = totalBytes * ratio;
168
- const keep = new Set();
169
- let keepBytes = 0;
170
- for (let k = candidates.length - 1; k >= 0; k--) {
171
- const i = candidates[k];
172
- if (i === mustKeep) { keep.add(i); keepBytes += messages[i].content.length; continue; }
173
- const size = messages[i].content.length;
174
- // Keep this chunk if we are still under budget, but ALWAYS keep the single
175
- // newest result: a trim that elides the output the model is currently reading
176
- // is how a working turn turns into a confused one.
177
- if (keepBytes + size <= budget || keep.size === 0) {
178
- keep.add(i);
179
- keepBytes += size;
180
- }
181
- }
182
-
183
- const elide = [];
184
- let elideBytes = 0;
185
- for (const i of candidates) {
186
- if (keep.has(i)) continue;
187
- elide.push(i);
188
- elideBytes += messages[i].content.length;
189
- }
190
- return { keep: [...keep].sort((a, b) => a - b), elide, totalBytes, keepBytes, elideBytes };
191
- }
192
-
193
- // Apply a trim to `messages` IN PLACE, replacing each elided result with a short
194
- // pointer to the text on disk.
195
- //
196
- // Two properties matter here and both are deliberate:
197
- //
198
- // * The message COUNT and every message's role stay identical. The provider's
199
- // prompt cache is keyed on the prefix, and a message list that changes SHAPE
200
- // invalidates it — replacing bodies keeps the shape.
201
- //
202
- // * The full text is written to disk first (spillDir), so an elided result is
203
- // RECOVERABLE: the model reads the pointer and pages through the file. That is
204
- // what makes "elided" different from "deleted" — the point the user raised
205
- // about trimming eating their work.
206
- //
207
- // `sessionId`/`messages` identify where the copies go. Returns
208
- // { elided, elidedBytes, kept, keptBytes }.
209
- export function applyTrim(messages, plan, sessionId) {
210
- const out = { elided: 0, elidedBytes: 0, kept: plan.keep.length, keptBytes: plan.keepBytes };
211
- if (!Array.isArray(messages) || !plan.elide.length) return out;
212
- for (const i of plan.elide) {
213
- const m = messages[i];
214
- const original = m.content;
215
- // Copy it out BEFORE overwriting: this is the whole difference between a trim
216
- // and a loss. If the write fails we still elide, but the pointer says so.
217
- const spilled = spillResult(sessionId, `trim-${m.toolCallId || i}`, original);
218
- const tool = m.name || 'tool';
219
- m.content = spilled
220
- ? `[Output elided to save context — ${fmtBytes(original.length)} from ${tool}.\n`
221
- + `Full text saved to: ${spilled.file}\n`
222
- + `Read that file (with line_offset/n_lines) if you need this output again.]`
223
- : `[Output elided to save context — ${fmtBytes(original.length)} from ${tool}; the full text could not be saved.]`;
224
- out.elided++;
225
- out.elidedBytes += original.length;
226
- }
227
- return out;
228
- }
229
-
230
- // Convenience: plan + apply in one go. Returns null when nothing needed trimming,
231
- // so a caller can skip its UI event entirely.
232
- export function trimToolResults(messages, opts = {}) {
233
- const plan = planTrim(messages, opts.keepRatio, opts.mustKeep);
234
- if (!plan.elide.length) return null;
235
- return { ...applyTrim(messages, plan, opts.sessionId), plan };
236
- }
1
+ // Tool-result size control.
2
+ //
3
+ // Two separate problems, both of which used to be silent:
4
+ //
5
+ // 1. A SINGLE huge result. Per-tool caps exist (`MAX_OUTPUT_BYTES` in
6
+ // tools/utils.js) but `Read` deliberately has none, and nothing bounded the
7
+ // AGGREGATE of a step's results — a turn with four Reads could add half a
8
+ // megabyte to a history that is then resent on every later request. Real
9
+ // sessions in ~/.hncode/sessions reached 5 MB this way.
10
+ // 2. OLD results that no longer matter. A file read forty turns ago is dead
11
+ // weight, but the whole history is resent every request, so it is paid for
12
+ // again and again until something compacts.
13
+ //
14
+ // The cap is applied where the result is BUILT (see `shapeToolResult`), and the
15
+ // overflow is kept, not lost: it goes to a file under the session's own
16
+ // directory and the model gets a bounded preview plus the path, so it can Read
17
+ // the rest if it turns out to need it. This mirrors Claude Code's
18
+ // `<persisted-output>` handling (src/utils/toolResultStorage.ts).
19
+ //
20
+ // The ages pass (stale-pass) is the cheap half of compaction: it clears the
21
+ // BODY of old tool results while leaving the message — and therefore the
22
+ // assistant/tool pairing and the exact prefix length — untouched, so it does not
23
+ // disturb the message list shape the provider has already cached. Claude Code
24
+ // does the same thing in microCompact.ts ("[Old tool result content cleared]").
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+
29
+ // Bytes of a single tool result that are kept inline. Above this the overflow is
30
+ // written to disk. 50 KB is Claude Code's DEFAULT_MAX_RESULT_SIZE_CHARS and is
31
+ // comfortably more than a whole source file's worth of lines.
32
+ export const MAX_RESULT_BYTES = 50 * 1024;
33
+
34
+ // Bytes of a spilled result that stay in the transcript as a preview.
35
+ export const PREVIEW_BYTES = 2 * 1024;
36
+
37
+ // Trigger ratio: trim once the request reaches this fraction of the model window.
38
+ // 50% by default (`trim_threshold` / HNCODE_TRIM_THRESHOLD). Well below the
39
+ // compaction trigger on purpose — this is the cheap pass that keeps the history
40
+ // from ever getting near compaction in the first place.
41
+ export const DEFAULT_TRIM_THRESHOLD = 0.5;
42
+
43
+ // Fraction of the tool-result TEXT that survives a trim. 30% by default
44
+ // (`trim_keep_ratio` / HNCODE_TRIM_KEEP_RATIO); the rest of the eligible tool text is
45
+ // elided.
46
+ //
47
+ // A DIFFERENT denominator from `trim_threshold` above, on purpose. The threshold asks
48
+ // whether the request is too big for the window (a share of the WINDOW); this asks how much
49
+ // of the removable output stays (a share of the TOOL TEXT). Measuring this against the
50
+ // request was tried and reverted — at a 1M window the budget then exceeded all the elidable
51
+ // text, so `/trim 0.6` could not trim anything at all.
52
+ //
53
+ // The conversation is protected by the candidate filter in `planTrim`, not by a ratio:
54
+ // only messages with `role === 'tool'` are ever elided.
55
+ export const DEFAULT_TRIM_KEEP_RATIO = 0.3;
56
+
57
+ // A tool result shorter than this is never worth eliding: the marker plus the
58
+ // pointer would cost more than the text it replaces.
59
+ export const MIN_TRIM_BYTES = 512;
60
+
61
+ export const CLEARED_MARKER = '[Old tool result content cleared]';
62
+ export const PERSISTED_TAG = 'persisted-output';
63
+
64
+ // Directory for spilled results, beside the session file so it is cleaned up
65
+ // with the session rather than accumulating in a temp dir.
66
+ export function spillDir(sessionId) {
67
+ const root = process.env.HNCODE_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.hncode');
68
+ return path.join(root, 'tool-results', String(sessionId || 'unscoped'));
69
+ }
70
+
71
+ // Write `text` to disk and return the preview + path, or null when the write
72
+ // fails (a read-only disk must not fail the tool call).
73
+ export function spillResult(sessionId, toolCallId, text) {
74
+ try {
75
+ const dir = spillDir(sessionId);
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ // The tool-call id is already a unique, filesystem-safe token from the
78
+ // provider, so it needs no sanitizing beyond dropping path separators.
79
+ const safe = String(toolCallId || 'result').replace(/[^A-Za-z0-9_.-]/g, '_');
80
+ const file = path.join(dir, `${safe}.txt`);
81
+ fs.writeFileSync(file, text, 'utf8');
82
+ return { file, preview: previewOf(text, PREVIEW_BYTES), originalBytes: Buffer.byteLength(text, 'utf8') };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ // A preview cut on a line boundary when one is close enough, so the model does
89
+ // not receive half a line of code as its last visible detail.
90
+ export function previewOf(text, maxBytes) {
91
+ const s = String(text == null ? '' : text);
92
+ if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
93
+ let cut = Buffer.from(s, 'utf8').subarray(0, maxBytes).toString('utf8');
94
+ if (cut.endsWith('\uFFFD')) cut = cut.slice(0, -1);
95
+ const nl = cut.lastIndexOf('\n');
96
+ if (nl > maxBytes * 0.5) cut = cut.slice(0, nl);
97
+ return cut;
98
+ }
99
+
100
+ // The message the model sees in place of an oversized result. It says what
101
+ // happened, how big it was, where the rest is, and how to get it — an agent that
102
+ // cannot tell why its output is missing retries the same call forever.
103
+ export function spillNotice(spilled) {
104
+ return [
105
+ `Output too large (${fmtBytes(spilled.originalBytes)}). Full output saved to: ${spilled.file}`,
106
+ '',
107
+ `Preview (first ${fmtBytes(PREVIEW_BYTES)}):`,
108
+ spilled.preview,
109
+ '',
110
+ `Read the file above to see the rest — use line_offset / n_lines to page through it.`,
111
+ ].join('\n');
112
+ }
113
+
114
+ export function fmtBytes(n) {
115
+ const b = Number(n) || 0;
116
+ if (b < 1024) return `${b} B`;
117
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
118
+ return `${(b / (1024 * 1024)).toFixed(1)} MB`;
119
+ }
120
+
121
+ // Shape ONE tool result for the model: pass small results through, spill big
122
+ // ones. Returns { content, spilled } where `content` is what goes into the
123
+ // message. `sessionId`/`toolCallId` identify the spill file.
124
+ export function shapeToolResult(content, { sessionId, toolCallId, maxBytes = MAX_RESULT_BYTES } = {}) {
125
+ if (typeof content !== 'string') return { content, spilled: false };
126
+ if (Buffer.byteLength(content, 'utf8') <= maxBytes) return { content, spilled: false };
127
+ const spilled = spillResult(sessionId, toolCallId, content);
128
+ // The write failed: a normal-sized preview is still better than megabytes, and
129
+ // the notice must not claim a path that does not exist.
130
+ if (!spilled) {
131
+ return {
132
+ content: `${previewOf(content, PREVIEW_BYTES)}\n\n[Output truncated: ${fmtBytes(Buffer.byteLength(content, 'utf8'))} total; the full output could not be saved.]`,
133
+ spilled: false,
134
+ };
135
+ }
136
+ return { content: spillNotice(spilled), spilled: true };
137
+ }
138
+
139
+ // Which tool results to elide, and by how much.
140
+ //
141
+ // RULE (the user's): keep a FRACTION of the TOOL-RESULT TEXT, the newest part.
142
+ // Walk the results from newest to oldest accumulating their size; once the total kept
143
+ // exceeds `keepRatio` of ALL tool-result text, everything older is elided.
144
+ //
145
+ // THE DENOMINATOR IS THE TOOL TEXT, NOT THE REQUEST, and the two knobs deliberately use
146
+ // different scales because they answer different questions:
147
+ //
148
+ // * `trimThreshold` asks "is the request too big for the window yet?" — a share of the
149
+ // WINDOW.
150
+ // * `keep` asks "of the output we are allowed to remove, how much stays?" — a share of
151
+ // the TOOL TEXT.
152
+ //
153
+ // Measuring `keep` against the request was tried and reverted: at a 1M window with 270k of
154
+ // tool text, `threshold` fires at 500k while `keep=0.6` asks to retain 300k — MORE than the
155
+ // 270k that could ever be elided. So `/trim 0.6` reported "nothing to trim", and it did so
156
+ // for every request above 450k. A knob whose behaviour depends on a total the user cannot
157
+ // see is a knob nobody can predict; a share of the tool text behaves the same in a short
158
+ // session and a long one.
159
+ //
160
+ // ONLY TOOL RESULTS ARE TOUCHED, whatever the ratio. The `role !== 'tool'` filter below is
161
+ // the real protection for the conversation: user messages, assistant prose and the system
162
+ // prompt are never candidates, so no ratio can reach them. (An earlier comment here claimed
163
+ // the denominator was what protected the conversation. It never was — the filter is.)
164
+ //
165
+ // Walking newest-first is what makes this behave the way a person expects: the
166
+ // results you were just working from are the ones that survive, and the material
167
+ // from twenty turns ago goes first.
168
+ //
169
+ // Returns { keep: [indices], elide: [indices], totalBytes, keepBytes, elideBytes }.
170
+ // `mustKeep` (an index) is never elided — the tool result belonging to the step
171
+ // currently in flight, if the caller knows it.
172
+ export function planTrim(messages, keepRatio, mustKeep) {
173
+ const empty = { keep: [], elide: [], totalBytes: 0, keepBytes: 0, elideBytes: 0 };
174
+ if (!Array.isArray(messages)) return empty;
175
+ const ratio = Number.isFinite(keepRatio) ? Math.min(Math.max(keepRatio, 0), 1) : DEFAULT_TRIM_KEEP_RATIO;
176
+
177
+ // Only string-bodied results can be elided; a media result carries content
178
+ // blocks and clearing it would delete the image itself.
179
+ const candidates = [];
180
+ let totalBytes = 0;
181
+ for (let i = 0; i < messages.length; i++) {
182
+ const m = messages[i];
183
+ if (!m || m.role !== 'tool') continue;
184
+ if (typeof m.content !== 'string') continue;
185
+ totalBytes += m.content.length;
186
+ if (m.content === CLEARED_MARKER) continue;
187
+ if (m.content.length < MIN_TRIM_BYTES) continue;
188
+ candidates.push(i);
189
+ }
190
+ if (!candidates.length) return { ...empty, totalBytes };
191
+
192
+ // A budget of 0 means "elide everything eligible"; a budget of 1 means "keep it
193
+ // all", in which case we still elide the already-marked ones so a repeated call
194
+ // is a no-op rather than a growing patch.
195
+ const budget = totalBytes * ratio;
196
+ const keep = new Set();
197
+ let keepBytes = 0;
198
+ for (let k = candidates.length - 1; k >= 0; k--) {
199
+ const i = candidates[k];
200
+ if (i === mustKeep) { keep.add(i); keepBytes += messages[i].content.length; continue; }
201
+ const size = messages[i].content.length;
202
+ // Keep this chunk if we are still under budget, but ALWAYS keep the single
203
+ // newest result: a trim that elides the output the model is currently reading
204
+ // is how a working turn turns into a confused one.
205
+ if (keepBytes + size <= budget || keep.size === 0) {
206
+ keep.add(i);
207
+ keepBytes += size;
208
+ }
209
+ }
210
+
211
+ const elide = [];
212
+ let elideBytes = 0;
213
+ for (const i of candidates) {
214
+ if (keep.has(i)) continue;
215
+ elide.push(i);
216
+ elideBytes += messages[i].content.length;
217
+ }
218
+ return { keep: [...keep].sort((a, b) => a - b), elide, totalBytes, keepBytes, elideBytes };
219
+ }
220
+
221
+ // Apply a trim to `messages` IN PLACE, replacing each elided result with a short
222
+ // pointer to the text on disk.
223
+ //
224
+ // Two properties matter here and both are deliberate:
225
+ //
226
+ // * The message COUNT and every message's role stay identical. The provider's
227
+ // prompt cache is keyed on the prefix, and a message list that changes SHAPE
228
+ // invalidates it — replacing bodies keeps the shape.
229
+ //
230
+ // * The full text is written to disk first (spillDir), so an elided result is
231
+ // RECOVERABLE: the model reads the pointer and pages through the file. That is
232
+ // what makes "elided" different from "deleted" — the point the user raised
233
+ // about trimming eating their work.
234
+ //
235
+ // `sessionId`/`messages` identify where the copies go. Returns
236
+ // { elided, elidedBytes, kept, keptBytes }.
237
+ export function applyTrim(messages, plan, sessionId) {
238
+ const out = { elided: 0, elidedBytes: 0, kept: plan.keep.length, keptBytes: plan.keepBytes };
239
+ if (!Array.isArray(messages) || !plan.elide.length) return out;
240
+ for (const i of plan.elide) {
241
+ const m = messages[i];
242
+ const original = m.content;
243
+ // Copy it out BEFORE overwriting: this is the whole difference between a trim
244
+ // and a loss. If the write fails we still elide, but the pointer says so.
245
+ const spilled = spillResult(sessionId, `trim-${m.toolCallId || i}`, original);
246
+ const tool = m.name || 'tool';
247
+ m.content = spilled
248
+ ? `[Output elided to save context — ${fmtBytes(original.length)} from ${tool}.\n`
249
+ + `Full text saved to: ${spilled.file}\n`
250
+ + `Read that file (with line_offset/n_lines) if you need this output again.]`
251
+ : `[Output elided to save context — ${fmtBytes(original.length)} from ${tool}; the full text could not be saved.]`;
252
+ out.elided++;
253
+ out.elidedBytes += original.length;
254
+ }
255
+ return out;
256
+ }
257
+
258
+ // Convenience: plan + apply in one go. Returns null when nothing needed trimming,
259
+ // so a caller can skip its UI event entirely.
260
+ export function trimToolResults(messages, opts = {}) {
261
+ const plan = planTrim(messages, opts.keepRatio, opts.mustKeep);
262
+ if (!plan.elide.length) return null;
263
+ return { ...applyTrim(messages, plan, opts.sessionId), plan };
264
+ }
@@ -0,0 +1,98 @@
1
+ // Tools for controlling a RUNNING subagent: list, message, interrupt, close.
2
+ //
3
+ // WHY THESE ARE SEPARATE FROM TaskList / TaskStop
4
+ // ----------------------------------------------
5
+ // `TaskStop` cancels a background task by task_id, and for an agent that means aborting
6
+ // its controller — the whole run dies. There was no way to do the thing a person actually
7
+ // asks for while watching a subagent work: tell it something, and let it continue. And
8
+ // there was no way to stop ONE subagent of a swarm without stopping the turn that started
9
+ // them, because `TaskStop` needs a task id that only the background path creates.
10
+ //
11
+ // These tools address agents by AGENT id (or run id, or task id — whichever the caller
12
+ // has), which is the identifier `Agent` prints and `AgentSwarm` reports, so the model can
13
+ // act on what it just started without a lookup table.
14
+ //
15
+ // `AgentMessage` uses the same `steer` path as the TUI's Ctrl+S: the message lands when
16
+ // the subagent's in-flight tool calls finish. That is the only safe moment to change a
17
+ // run's instructions, and it is what makes "keep going, but also do X" meaningful.
18
+
19
+ import { listRuns, sendToRun, interruptRun, interruptAll, closeRun, describeRuns } from '../subagent-control.js';
20
+
21
+ export const AgentListSpec = {
22
+ name: 'AgentList',
23
+ description: `List the subagents that are running RIGHT NOW, with the id to address each one.
24
+
25
+ Use this before AgentMessage / AgentInterrupt / AgentClose when you do not already hold the id. A subagent that has finished is not listed — it is no longer controllable, and its result is already in this conversation (or in TaskOutput for a background run).`,
26
+ parameters: { type: 'object', properties: {} },
27
+ async execute() {
28
+ const runs = listRuns();
29
+ if (!runs.length) return 'No subagent is running.';
30
+ return describeRuns().join('\n');
31
+ },
32
+ };
33
+
34
+ export const AgentMessageSpec = {
35
+ name: 'AgentMessage',
36
+ description: `Send a message to a RUNNING subagent without stopping it.
37
+
38
+ The text is injected when the subagent's in-flight tool calls finish — the same point the user's own steering messages land — so it changes the run's instructions rather than interrupting them. Use it to redirect work ("skip the tests for now"), to add a constraint, or to correct a wrong assumption you can see in its stream.
39
+
40
+ Identify the subagent by its agent_id (what \`Agent\` returned, or what AgentSwarm reported), its run id, or its task id. Use AgentList if you have none of them.`,
41
+ parameters: {
42
+ type: 'object',
43
+ properties: {
44
+ agent_id: { type: 'string', description: 'The subagent to message: agent_id, run id, or task_id.' },
45
+ message: { type: 'string', description: 'What to tell it. Delivered as a steering message.' },
46
+ },
47
+ required: ['agent_id', 'message'],
48
+ },
49
+ async execute(args) {
50
+ const r = sendToRun(args.agent_id, args.message);
51
+ return r.ok ? r.message : `Error: ${r.message}`;
52
+ },
53
+ };
54
+
55
+ export const AgentInterruptSpec = {
56
+ name: 'AgentInterrupt',
57
+ description: `Stop ONE running subagent, leaving everything else alone.
58
+
59
+ Prefer this over TaskStop for a subagent: TaskStop works on a background task id and cancels the whole run, while this reaches the subagent directly and needs only the agent id. Omit \`agent_id\` to stop EVERY running subagent, which is the way out when a swarm has gone wrong.
60
+
61
+ Brief the model on what the agent had established before stopping it: a subagent's partial work is not written anywhere you can read afterwards.`,
62
+ parameters: {
63
+ type: 'object',
64
+ properties: {
65
+ agent_id: { type: 'string', description: 'The subagent to stop. Omit to stop all of them.' },
66
+ reason: { type: 'string', description: 'Short reason, for the transcript.' },
67
+ },
68
+ },
69
+ async execute(args) {
70
+ const ref = args && args.agent_id ? String(args.agent_id).trim() : '';
71
+ if (!ref) {
72
+ const all = interruptAll();
73
+ return all.ok
74
+ ? `Interrupted ${all.count} subagent(s): ${all.agents.join(', ')}`
75
+ : 'No subagent is running.';
76
+ }
77
+ const r = interruptRun(ref);
78
+ return r.ok ? r.message : `Error: ${r.message}`;
79
+ },
80
+ };
81
+
82
+ export const AgentCloseSpec = {
83
+ name: 'AgentClose',
84
+ description: `Interrupt a subagent AND remove it from the running list immediately.
85
+
86
+ Use this when you stopped a subagent and no longer want it in AgentList. An interrupted run is removed when its await unwinds, which is asynchronous, so a listing right afterwards can still show it; close removes it at once. The subagent's CONVERSATION is kept, so a later \`Agent\` call can still \`resume\` it.`,
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ agent_id: { type: 'string', description: 'The subagent to close.' },
91
+ },
92
+ required: ['agent_id'],
93
+ },
94
+ async execute(args) {
95
+ const r = closeRun(args.agent_id);
96
+ return r.ok ? r.message : `Error: ${r.message}`;
97
+ },
98
+ };