@hncode/hncode 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/package.json +4 -2
  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
@@ -0,0 +1,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), 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
+ }
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
20
20
  import { SUBAGENT_TYPES, DEFAULT_SUBAGENT_TYPE, availableTypes, toolsForSubagent } from '../subagent-types.js';
21
+ import { runShellHooks } from '../hooks.js';
21
22
 
22
23
  // Live subagent registry, keyed by agent id. A resumed agent keeps its own
23
24
  // message list here, which is what makes `resume` work.
@@ -105,6 +106,16 @@ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProg
105
106
  signal.addEventListener('abort', () => agent.interrupt(), { once: true });
106
107
  }
107
108
  await agent.run();
109
+ // SHELL HOOK: SubagentStop. The subagent finished (or was interrupted); the
110
+ // parent-side hook can log/archive it. Never blocks.
111
+ // `hookConfig` may be the raw `{event:[…]}` map or a `{hooks:{…}}` wrapper
112
+ // (agent.js normalises it to the wrapper) — accept either.
113
+ const hc = subCfg.hookConfig;
114
+ const hookCfg = hc ? (hc.hooks ? hc : { hooks: hc }) : null;
115
+ if (hookCfg) {
116
+ await runShellHooks(hookCfg, 'SubagentStop',
117
+ { agentId: agentId || '', subagentType: type, prompt }, cfg.workspace);
118
+ }
108
119
  // The parent must receive ONLY what the subagent concluded — never its tool
109
120
  // chatter, and never its reasoning. Two things could leak in:
110
121
  // * the aggregate `data` stream, which also contains intermediate narration;
package/src/tools/bash.js CHANGED
@@ -47,10 +47,55 @@ function decodeBuffer(buf) {
47
47
  try { return new TextDecoder('gbk').decode(buf); } catch { return buf.toString('utf8'); }
48
48
  }
49
49
  }
50
+
51
+ /**
52
+ * Byte budget for a RUNNING command's captured output.
53
+ *
54
+ * `truncateBuf` only runs when the command FINISHES, so the whole run's output
55
+ * sits in memory until then — and a build or a full test suite emits hundreds of
56
+ * megabytes. The ring below keeps only a bounded TAIL while the command runs,
57
+ * which is also the part a reader wants.
58
+ *
59
+ * The HEAD is dropped rather than the tail, and the dropped count is remembered
60
+ * so the finished result can still report it instead of silently looking like a
61
+ * short run.
62
+ */
63
+ const LIVE_RING_BYTES = 512 * 1024;
64
+
65
+ /** Keep only the LAST `limit` bytes of an array of Buffers, in place. Returns
66
+ how many bytes were dropped. */
67
+ function trimChunkRing(chunks, limit) {
68
+ let total = 0;
69
+ for (const c of chunks) total += c.length;
70
+ if (total <= limit) return 0;
71
+ const dropped = total - limit;
72
+ let drop = dropped;
73
+ while (drop > 0 && chunks.length) {
74
+ const head = chunks[0];
75
+ if (head.length <= drop) { chunks.shift(); drop -= head.length; }
76
+ else { chunks[0] = head.subarray(drop); drop = 0; }
77
+ }
78
+ return dropped;
79
+ }
80
+
50
81
  function decodeChunks(chunks) {
51
- return decodeBuffer(Buffer.concat(chunks));
82
+ // Tolerant of a restored task: after a session round-trip the chunks are
83
+ // `{type:'Buffer',data:[…]}` objects (JSON has no Buffer) or absent entirely.
84
+ // `Buffer.concat` would throw on either, and this getter runs while the output
85
+ // panel is being drawn — i.e. a throw here takes the TUI down too.
86
+ if (chunks == null) return '';
87
+ const list = Array.isArray(chunks) ? chunks : [chunks];
88
+ if (!list.length) return '';
89
+ const items = list.map((c) => {
90
+ if (Buffer.isBuffer(c)) return c;
91
+ if (c && c.type === 'Buffer' && Array.isArray(c.data)) return Buffer.from(c.data);
92
+ return Buffer.from(String(c));
93
+ });
94
+ try { return decodeBuffer(items.length === 1 ? items[0] : Buffer.concat(items)); }
95
+ catch { return items.map((c) => c.toString('utf8')).join(''); }
52
96
  }
53
97
 
98
+
54
99
  function killTree(child) {
55
100
  try {
56
101
  const exit = child.exitCode !== null || child.signalCode !== null;
@@ -99,33 +144,46 @@ export const spec = {
99
144
 
100
145
  const chunks = [];
101
146
  let settled = false;
147
+ // Bytes shed by the live ring, reported on the finished result so a trimmed
148
+ // run does not look like a short one.
149
+ let chunksDropped = 0;
102
150
  // Live stream sink: hand decoded output to the agent (ctx.onOutput) so the UI
103
151
  // can show a running command's output in real time, exactly like the
104
152
  // finished "Used Bash" row does. Foreground runs only — a background task's
105
153
  // output is read through TaskOutput.
106
154
  //
107
- // Decoding is STREAMING (a multi-byte char can be split across chunks) and
108
- // sanitizing runs over the whole accumulated text, then only the NEW tail is
109
- // emitted: stripping per-chunk would cut an escape sequence in half and leak
110
- // the remainder into the display.
155
+
156
+ //
157
+ // Decoding is STREAMING (a multi-byte char can be split across chunks), and
158
+ // sanitizing runs over complete LINES rather than the whole accumulated text.
159
+ // The old form re-ran the escape regexes over everything received so far on
160
+ // EVERY chunk — O(n²) over the run, plus a fresh full-length string per chunk
161
+ // — which is what made a chatty command slow the process down and double its
162
+ // peak memory.
163
+ //
164
+ // Line-by-line is also what keeps an escape sequence from being cut in half:
165
+ // a sequence never contains a newline, so a complete line is always safe to
166
+ // sanitize, and the trailing partial line waits for more input.
111
167
  const sink = typeof ctx.onOutput === 'function' ? ctx.onOutput : null;
112
168
  const liveDecoder = sink ? new TextDecoder('utf-8') : null;
113
- let liveAcc = '';
114
- let liveSent = 0;
115
- const push = (d) => {
169
+ let livePending = '';
170
+ const push = (d) => {
116
171
  chunks.push(d);
172
+ const dropped = trimChunkRing(chunks, LIVE_RING_BYTES);
173
+ if (dropped) chunksDropped += dropped;
174
+
117
175
  if (!sink || settled) return;
118
176
  try {
119
- liveAcc += liveDecoder.decode(d, { stream: true });
120
- const clean = sanitizeShellOutput(liveAcc);
121
- if (clean.length > liveSent) {
122
- const delta = clean.slice(liveSent);
123
- liveSent = clean.length;
124
- sink(delta);
125
- }
126
- } catch {}
177
+ livePending += liveDecoder.decode(d, { stream: true });
178
+ const nl = livePending.lastIndexOf('\n');
179
+ if (nl < 0) return;
180
+ const complete = livePending.slice(0, nl + 1);
181
+ livePending = livePending.slice(nl + 1);
182
+ const clean = sanitizeShellOutput(complete);
183
+ if (clean) sink(clean);
184
+ } catch { /* display is best-effort; the result below is authoritative */ }
127
185
  };
128
- if (child.stdout) child.stdout.on('data', push);
186
+ if (child.stdout) child.stdout.on('data', push);
129
187
  if (child.stderr) child.stderr.on('data', push);
130
188
 
131
189
  let timer = null;
@@ -172,12 +230,21 @@ export const spec = {
172
230
  if (ctx) ctx._foreground = null;
173
231
  if (settled) return;
174
232
  settled = true;
233
+ // Flush the last partial line the live sink was holding back.
234
+ if (sink && livePending) {
235
+ try { sink(sanitizeShellOutput(livePending)); } catch { /* best-effort */ }
236
+ livePending = '';
237
+ }
175
238
  if (signal && signal.aborted) { resolve('Interrupted by user'); return; }
176
239
  let out = truncateBuf(sanitizeShellOutput(decodeChunks(chunks)));
240
+ // A trimmed run says so; otherwise the result would look complete while
241
+ // quietly missing its head.
242
+ if (chunksDropped) out = `...[dropped ${chunksDropped} bytes of earlier output]\n` + out;
177
243
  out += `\n[exit code: ${code === null ? 'killed' : code}]`;
178
244
  resolve(out);
179
245
  });
180
246
  child.on('error', (e) => {
247
+
181
248
  cleanup();
182
249
  if (ctx) ctx._foreground = null;
183
250
  if (settled) return;