@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
@@ -0,0 +1,224 @@
1
+ // The /btw box — a bordered panel docked ABOVE the composer, the way kimi's
2
+ // BtwPanelComponent is (see its `mount`, which adds the panel to
3
+ // `btwPanelContainer` and sets `editor.connectedAbove = true`).
4
+ //
5
+ // Why a docked box rather than a full-screen panel: the side conversation is
6
+ // something you have WHILE working, and the composer stays the place you type. In
7
+ // kimi the composer input IS the way to ask a follow-up — `sendUserInput` feeds the
8
+ // same panel — so the box sits directly above it and the editor is where you keep
9
+ // typing. A full-screen takeover hides the conversation being asked about, which is
10
+ // precisely the context a side question is about.
11
+ //
12
+ // Shape, matching kimi's renderer:
13
+ //
14
+ // ╭ BTW ─ Esc close ─────────────────────────────╮
15
+ // │ Q: what does the --retries flag do? │
16
+ // │ The `--retries` flag controls how many… │
17
+ // │ │
18
+ // │ Q: and the timeout flag? │
19
+ // │ It sets how long a run may take… │
20
+ // ╰──────────────────────────────────────────────╯
21
+ //
22
+ // Differences from kimi, all deliberate:
23
+ // * turns are held in the SESSION side thread, not here, so /btw clear and the
24
+ // accumulated view have one source of truth;
25
+ // * the height cap is a fraction of the terminal (kimi: a third) but the panel
26
+ // GROWS to the largest size it has needed, so it does not jitter on every token.
27
+
28
+ import { visualWidth } from './term.js';
29
+ import { C } from './colors.js';
30
+
31
+ /** Rows kimi reserves as the floor for the box, so it is never a one-line sliver. */
32
+ export const MIN_PANEL_LINES = 3;
33
+
34
+ /**
35
+ * The box's height in rows, given the terminal height.
36
+ *
37
+ * Mirrors kimi's `collapsedBodyLimit`: at most a THIRD of the terminal, at least
38
+ * MIN_PANEL_LINES. Returns the TOTAL rows the box occupies (borders included), and
39
+ * 0 when there is nothing to show so the caller can skip it entirely.
40
+ */
41
+ export function btwPanelHeight(state, terminalRows, width) {
42
+ const panel = state && state.btwPanel;
43
+ if (!panel || !panel.turns || !panel.turns.length) return 0;
44
+ const rows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 24;
45
+ // At most a THIRD of the terminal (kimi's rule), floor of MIN_PANEL_LINES, and the
46
+ // rest of the screen keeps the transcript visible.
47
+ const maxBody = Math.max(MIN_PANEL_LINES, Math.floor(rows / 3)) - 1;
48
+ const inner = Math.max(1, (width | 0) - 4);
49
+ const needed = btwBodyLines(panel, inner);
50
+ // `_btwMinBody` grows monotonically, like kimi's `minBodyLines`: a box that shrank
51
+ // as a streamed answer reflowed would make the screen jump on most tokens.
52
+ const mark = Math.max(Number(panel._btwMinBody) || 0, needed);
53
+ if (panel && typeof panel === 'object') panel._btwMinBody = mark;
54
+ const want = Math.max(1, Math.min(maxBody, mark));
55
+ return want + 2; // the two border rows
56
+ }
57
+
58
+ /**
59
+ * How many content rows the box has ever needed.
60
+ *
61
+ * kimi keeps `minBodyLines` and grows it monotonically for the same reason: a box
62
+ * that resized on every streamed token would make the whole screen jump. Seeding it
63
+ * from this means the height only ever changes when the CONTENT needs more room.
64
+ */
65
+ export function btwBodyLines(panel, width) {
66
+ if (!panel || !panel.turns || !panel.turns.length) return 0;
67
+ let n = 0;
68
+ for (const t of panel.turns) {
69
+ if (n > 0) n += 1; // the blank line between turns
70
+ n += wrapCount(`Q: ${t.prompt}`, width);
71
+ const answer = String(t.answer || '');
72
+ if (answer.trim()) {
73
+ for (const line of answer.split('\n')) n += Math.max(1, wrapCount(line, width));
74
+ } else if (t.thinking && t.thinking.trim()) {
75
+ n += 1; // the thinking preview
76
+ } else if (!t.error) {
77
+ n += 1; // "Waiting for answer…"
78
+ }
79
+ if (t.error) n += wrapCount(t.error, width);
80
+ // The blinking caret row. It MUST be counted here or the box is one row short and
81
+ // the last content line is clipped off.
82
+ if (t.phase === 'running') n += 1;
83
+ }
84
+ return n;
85
+ }
86
+
87
+ /** How many display rows a string needs at `width`. Never returns 0. */
88
+ function wrapCount(text, width) {
89
+ const w = Math.max(1, width);
90
+ const s = String(text == null ? '' : text);
91
+ if (!s.length) return 1;
92
+ let rows = 0;
93
+ for (const line of s.split('\n')) {
94
+ const vis = visualWidth(line);
95
+ rows += Math.max(1, Math.ceil(vis / w));
96
+ }
97
+ return rows;
98
+ }
99
+
100
+ /** Clip a string to `width` display columns, adding `…` when it was cut. */
101
+ function clip(text, width) {
102
+ const s = String(text == null ? '' : text);
103
+ if (visualWidth(s) <= width) return s;
104
+ let out = '';
105
+ let w = 0;
106
+ for (const ch of s) {
107
+ const cw = visualWidth(ch);
108
+ if (w + cw > Math.max(0, width - 1)) break;
109
+ out += ch;
110
+ w += cw;
111
+ }
112
+ return out + '…';
113
+ }
114
+
115
+ /**
116
+ * Render the box: `{ lines }`, one entry per SCREEN row including the borders.
117
+ *
118
+ * `state.btwScroll` counts rows up from the bottom of the content, as an offset from
119
+ * the tail — the same convention the transcript uses, so ↑/↓ can share the code.
120
+ */
121
+ export function renderBtwPanel(state, width, opts = {}) {
122
+ const panel = state && state.btwPanel;
123
+ if (!panel || !panel.turns || !panel.turns.length) return [];
124
+ const w = Math.max(8, width | 0);
125
+ const inner = Math.max(1, w - 4); // '│ ' + content + ' │'
126
+ const terminalRows = opts.terminalRows;
127
+ const height = btwPanelHeight(state, terminalRows, w);
128
+ const bodyRows = Math.max(1, height - 2);
129
+
130
+ // ---- the content rows, in order ----
131
+ const content = [];
132
+ for (let i = 0; i < panel.turns.length; i++) {
133
+ const t = panel.turns[i];
134
+ // Every entry is a `{ l, color }` ROW. The blank separator between turns must be
135
+ // one too: a bare '' pushed in its place rendered as `undefined` because the
136
+ // renderer reads `row.l`.
137
+ if (i > 0) content.push({ l: '', color: C.white });
138
+ for (const l of wrapLine(`Q: ${t.prompt}`, inner)) content.push({ l, color: C.cyan });
139
+ const answer = String(t.answer || '');
140
+ if (answer.trim()) {
141
+ for (const raw of answer.split('\n')) {
142
+ // A blank line inside an answer stays blank; otherwise each line is wrapped on
143
+ // its own so a long paragraph does not reflow into the previous one.
144
+ if (!raw.trim()) { content.push({ l: '', color: C.white }); continue; }
145
+ for (const l of wrapLine(raw, inner)) content.push({ l, color: C.white });
146
+ }
147
+ } else if (t.thinking && t.thinking.trim()) {
148
+ content.push({ l: clip(` …${t.thinking.trim().split('\n').slice(-1)[0]}`, inner), color: C.gray });
149
+ } else if (!t.error) {
150
+ content.push({ l: 'Waiting for answer…', color: C.gray });
151
+ }
152
+ if (t.error) for (const l of wrapLine(t.error, inner)) content.push({ l, color: C.red });
153
+ if (t.phase === 'running') content.push({ l: '▍', color: C.gray });
154
+ }
155
+
156
+ // ---- scroll: offset from the tail, like the transcript ----
157
+
158
+ // ---- scroll: offset from the tail, like the transcript ----
159
+ const maxTop = Math.max(0, content.length - bodyRows);
160
+ const scroll = Math.max(0, Math.min(maxTop, Number(state.btwScroll) || 0));
161
+ const start = Math.max(0, content.length - bodyRows - scroll);
162
+ const shown = content.slice(start, start + bodyRows);
163
+ while (shown.length < bodyRows) shown.push({ l: '', color: C.white });
164
+
165
+ // ---- the frame ----
166
+ const out = [];
167
+ const hint = maxTop > 0 ? 'Esc close · ↑↓ scroll ' : 'Esc close ';
168
+ const title = ' BTW ';
169
+ const head = `${title}${'─'.repeat(Math.max(1, w - 2 - visualWidth(title) - visualWidth(hint) - 1))} ${hint}`;
170
+ // Every border segment is painted on its own, so the interior can carry the text
171
+ // colour while the frame keeps the border colour. Painting the whole line and
172
+ // patch-substituting the corners (as an earlier version did) left the colour escape
173
+ // codes embedded in the output.
174
+ out.push(col('╭', C.border) + col(fitBorder(head, w - 2), C.border) + col('╮', C.border));
175
+ for (const row of shown) {
176
+ const padded = row.l + ' '.repeat(Math.max(0, inner - visualWidth(row.l)));
177
+ out.push(col('│', C.border) + ' ' + col(padded, row.color) + ' ' + col('│', C.border));
178
+ }
179
+ const above = start > 0 ? `▲ ${start} more` : '';
180
+ const below = scroll > 0 ? `▼ ${scroll} more` : '';
181
+ const tailHint = [above, below].filter(Boolean).join(' ') || 'Esc close';
182
+ // The bottom border reads `╰─ Esc close ─────…────╯`: the hint sits in the middle of
183
+ // the rule. The two corners take a column each, so the MIDDLE segment must be exactly
184
+ // `w - 2` wide — `─ ` + hint + ` ` + fill.
185
+ const foot = clip(tailHint, Math.max(1, w - 6));
186
+ const mid = `─ ${foot} `;
187
+ const footFill = Math.max(0, w - 2 - visualWidth(mid));
188
+ out.push(col('╰', C.border) + col(mid + '─'.repeat(footFill), C.border) + col('╯', C.border));
189
+ return out;
190
+ }
191
+
192
+ /** Paint `s`, resetting after it so the next segment starts clean. */
193
+ function col(s, color) {
194
+ return color ? color + s + C.reset : s;
195
+ }
196
+
197
+
198
+ /** Pad or clip a border segment to exactly `width` columns. */
199
+ function fitBorder(s, width) {
200
+ const w = Math.max(0, width);
201
+ if (visualWidth(s) === w) return s;
202
+ if (visualWidth(s) > w) return clip(s, w);
203
+ return s + '─'.repeat(w - visualWidth(s));
204
+ }
205
+
206
+ /** Wrap one logical line into display rows of at most `width` columns. */
207
+ export function wrapLine(text, width) {
208
+ const w = Math.max(1, width);
209
+ const s = String(text == null ? '' : text);
210
+ if (!s.length) return [''];
211
+ const out = [];
212
+ let cur = '';
213
+ let curW = 0;
214
+ for (const ch of s) {
215
+ const cw = visualWidth(ch);
216
+ if (curW + cw > w) { out.push(cur); cur = ''; curW = 0; }
217
+ cur += ch;
218
+ curW += cw;
219
+ }
220
+ out.push(cur);
221
+ return out;
222
+ }
223
+
224
+
package/src/cache.js CHANGED
@@ -29,7 +29,7 @@ export const MAX_BREAKPOINTS = 4;
29
29
  export const MIN_CACHEABLE_TOKENS = 1024;
30
30
 
31
31
  export function supportsCaching(protocol) {
32
- return protocol === 'anthropic' || protocol === 'openai';
32
+ return protocol === 'anthropic' || protocol === 'openai' || protocol === 'responses';
33
33
  }
34
34
 
35
35
  // Index (into `messages`) of the last message that belongs to the STABLE prefix.
@@ -125,6 +125,7 @@ export function withOpenAiCache(body, opts = {}) {
125
125
  export function applyPromptCache(protocol, body, opts = {}) {
126
126
  if (!opts.enabled) return body;
127
127
  if (protocol === 'anthropic') return withAnthropicCache(body, opts);
128
- if (protocol === 'openai') return withOpenAiCache(body, opts);
128
+ // Responses API accepts the same stable `prompt_cache_key` as chat/completions.
129
+ if (protocol === 'openai' || protocol === 'responses') return withOpenAiCache(body, opts);
129
130
  return body;
130
131
  }
@@ -0,0 +1,173 @@
1
+ // A minimal retained-mode cell layer, modelled on OpenTUI's OptimizedBuffer.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // ---------------
5
+ // The picker used to be painted as a finished ANSI STRING assigned over a whole
6
+ // frame row (`lines[row] = boxRow`). That is not an overlay, it is a replacement:
7
+ // every column the card's padding happened to occupy was erased, so the transcript
8
+ // beside the card disappeared — a row of `11111111111111` rendered as
9
+ // ` |111111|`.
10
+ //
11
+ // OpenTUI gets this right because it never builds strings. It keeps a 2-D buffer of
12
+ // cells, paints the dialog into its OWN buffer, and composites that buffer onto the
13
+ // scene with `drawFrameBuffer`. The rule that makes it work is in `blendCells`:
14
+ //
15
+ // const preserveChar = (charIsDefaultSpace and destNotZero and
16
+ // destNotDefaultSpace and destWidthIsOne);
17
+ // const finalChar = if (preserveChar) destCell.char else overlayCell.char;
18
+ //
19
+ // An overlay cell that is a BLANK SPACE does not overwrite what is underneath — it
20
+ // keeps the destination's character. That single rule is the difference between a
21
+ // floating window and a full-screen takeover.
22
+ //
23
+ // hncode still renders its body as ANSI strings (rewriting all of it would be a much
24
+ // larger change), so this module does one job: turn a row of that text into cells,
25
+ // composite an overlay row onto it cell-by-cell using OpenTUI's rule, and turn the
26
+ // result back into an ANSI string. Zero dependencies, like the rest of hncode.
27
+
28
+ import { charWidth } from './term.js';
29
+
30
+ // A cell in a layer. `blank` marks a cell the overlay does not paint — the equivalent
31
+ // of OpenTUI's DEFAULT_SPACE_CHAR combined with a transparent alpha. Those cells
32
+ // defer to whatever is underneath instead of erasing it.
33
+ export function cell(ch, style, blank = false) {
34
+ return { ch, style: style || '', blank };
35
+ }
36
+
37
+ const RESET = '\x1b[0m';
38
+
39
+ /**
40
+ * The cell that follows a double-width glyph, occupying its second column.
41
+ *
42
+ * A terminal does not advance one column per CHARACTER, it advances one column per
43
+ * DISPLAY CELL. `你好` is two characters but eight columns; `😀` is one character and
44
+ * two columns. A layer indexed by character therefore drifts out of step with the
45
+ * screen as soon as it contains anything wide, and every glyph after it lands one
46
+ * or more columns to the left of where the terminal will actually draw it — which is
47
+ * how backdrop text ended up showing through the middle of an opaque dialog.
48
+ *
49
+ * The continuation cell holds an empty `ch`, so rendering emits the wide glyph once
50
+ * and the terminal supplies the second column itself. This is the same accounting
51
+ * OpenTUI does with `encodedCharWidth`.
52
+ */
53
+ const WIDE_CONT = { ch: '', style: '', blank: false, cont: true };
54
+
55
+ /** A cell that defers to the layer below it. */
56
+ export const BLANK = cell(' ', '', true);
57
+
58
+ /**
59
+ * Split an ANSI string into cells, ONE PER DISPLAY COLUMN.
60
+ *
61
+ * A wide (CJK) glyph is ONE cell followed by a continuation cell spanning its second
62
+ * column, so `cells.length` always equals `visualWidth(str)` and index N is always
63
+ * screen column N. Indexing by code point instead — which this used to do — silently
64
+ * desynchronised the layer from the screen for any row containing wide text: the
65
+ * layer came out shorter than the columns it had to cover, so the tail of the row was
66
+ * never overwritten and the transcript underneath showed through the dialog.
67
+ *
68
+ * Zero-width characters (combining marks, joiners) occupy no column and are dropped.
69
+ */
70
+ export function parseCells(s) {
71
+ const str = String(s == null ? '' : s);
72
+ const cells = [];
73
+ let style = '';
74
+ let i = 0;
75
+ while (i < str.length) {
76
+ if (str[i] === '\x1b') {
77
+ const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i))
78
+ || /^\x1b\[[0-9;?]* [a-zA-Z]/.exec(str.slice(i));
79
+ if (m) {
80
+ // A reset ends the current style rather than becoming part of the next one. Both
81
+ // spellings matter: `renderCells` emits `\x1b[0m`, but a row that came from
82
+ // elsewhere — a shell escape, a plugin, a hand-built string — can carry the bare
83
+ // `\x1b[m`, and treating that as an ordinary attribute left the old style attached
84
+ // to the next cell. The stale `48;…` then survived every later edit and the row
85
+ // never quite matched itself.
86
+ if (m[0] === '\x1b[0m' || m[0] === '\x1b[m') style = '';
87
+ else style += m[0];
88
+ i += m[0].length;
89
+ continue;
90
+ }
91
+ }
92
+ const cp = str.codePointAt(i);
93
+ const ch = String.fromCodePoint(cp);
94
+ const step = cp > 0xffff ? 2 : 1;
95
+ const cw = charWidth(ch);
96
+ if (cw === 0) { i += step; continue; } // combining mark: no column of its own
97
+ cells.push(cell(ch, style));
98
+ if (cw === 2) cells.push({ ...WIDE_CONT, style }); // its second column
99
+ i += step;
100
+ }
101
+ return cells;
102
+ }
103
+
104
+ /**
105
+ * Render cells back to an ANSI string.
106
+ *
107
+ * A RESET is emitted before each new style run, so the output is IDEMPOTENT: parsing
108
+ * it again reproduces exactly the styles it came from. Without the reset the codes
109
+ * accumulate — a row that was dimmed, re-parsed and re-rendered grew a fresh `\x1b[2m`
110
+ * on every pass, and after a few frames a cell's "style" was hundreds of repeated
111
+ * escape codes. That is invisible in a stripped-text check and eventually slows the
112
+ * whole render path down.
113
+ */
114
+ export function renderCells(cells) {
115
+ let out = '';
116
+ let cur = null;
117
+ for (const c of cells) {
118
+ // A wide glyph's continuation cell carries no character of its own: the glyph
119
+ // before it already occupies both columns on screen, so emitting anything here
120
+ // would shift the rest of the row one column to the right.
121
+ if (c.cont) continue;
122
+ if (c.style !== cur) {
123
+ // Close the previous run before opening the next, so neither leaks into the other.
124
+ out += c.style === '' ? RESET : RESET + c.style;
125
+ cur = c.style;
126
+ }
127
+ out += c.ch;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * OpenTUI's `blendCells`, reduced to the one rule that matters here.
134
+ *
135
+ * An overlay cell that is BLANK keeps the destination's character — and, since the
136
+ * destination has already been dimmed by the time the layer goes on top, that
137
+ * character arrives dimmed. Everything else in the overlay is opaque and wins.
138
+ */
139
+ export function blendCell(overlay, dest) {
140
+ if (!overlay.blank) return overlay;
141
+ // A continuation cell belongs to the wide glyph in the cell before it. If the
142
+ // overlay put a wide glyph here, that glyph owns both columns, so the
143
+ // destination's second half must not survive independently of the first.
144
+ if (overlay.cont) return overlay;
145
+ if (dest && dest.ch && dest.ch !== ' ' && !dest.cont) return dest;
146
+ return overlay;
147
+ }
148
+
149
+ /**
150
+ * Composite an overlay row onto a base row at column `destX`, cell by cell, using
151
+ * `blendCell`. This is `drawFrameBuffer` for a single row.
152
+ *
153
+ * `overlay` is a cell array (build one with `parseCells` and mark the cells that must
154
+ * stay transparent with `BLANK`), or a plain string for the common case of an opaque
155
+ * overlay. The base row is a string and is never mutated — each call parses its own
156
+ * copy, so compositing several rows cannot make one bleed into the next.
157
+ */
158
+ export function drawFrameBuffer(baseRow, overlay, destX) {
159
+ const base = parseCells(baseRow);
160
+ const layer = typeof overlay === 'string' ? parseCells(overlay) : overlay;
161
+ for (let i = 0; i < layer.length; i++) {
162
+ const at = destX + i;
163
+ if (at < 0) continue;
164
+ if (at >= base.length) {
165
+ // Past the end of the base row: pad so the overlay still lands.
166
+ while (base.length < at) base.push(BLANK);
167
+ base.push(blendCell(layer[i], null));
168
+ continue;
169
+ }
170
+ base[at] = blendCell(layer[i], base[at]);
171
+ }
172
+ return renderCells(base);
173
+ }
package/src/ci.js CHANGED
@@ -49,7 +49,7 @@ export function isValidFormat(f) {
49
49
  // files — files the run wrote/edited (from tool ARGS, best-effort)
50
50
  // error — a fatal error message, when the run failed outright
51
51
  // stopped — the turn was interrupted
52
- export function buildResult({ reply, toolCalls, files, error, stopped, durationMs, model, sessionId }) {
52
+ export function buildResult({ reply, toolCalls, files, error, stopped, durationMs, model, sessionId, timedOut }) {
53
53
  const hasReply = typeof reply === 'string' && reply.trim().length > 0;
54
54
  const ok = !error && !stopped && hasReply;
55
55
  const result = {
@@ -63,6 +63,9 @@ export function buildResult({ reply, toolCalls, files, error, stopped, durationM
63
63
  };
64
64
  if (error) result.error = String(error);
65
65
  if (stopped) result.stopped = true;
66
+ // `--timeout <seconds>` tripped: distinct from a user interrupt so a caller can
67
+ // tell "the model was too slow" from "someone pressed Esc" (both set stopped).
68
+ if (timedOut) result.timedOut = true;
66
69
  return result;
67
70
  }
68
71