@hncode/hncode 0.4.2 → 0.5.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 (60) 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 +145 -33
  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 +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -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/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +611 -58
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +31 -13
  34. package/src/remote.js +20 -2
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/teleport.js +260 -0
  46. package/src/term-caps.js +411 -0
  47. package/src/term.js +283 -8
  48. package/src/tools/agent-control.js +98 -0
  49. package/src/tools/agent-swarm.js +11 -2
  50. package/src/tools/agent.js +35 -1
  51. package/src/tools/diagnostics.js +175 -0
  52. package/src/tools/edit.js +25 -10
  53. package/src/tools/grep.js +62 -20
  54. package/src/tools/ignore.js +38 -14
  55. package/src/tools/index.js +10 -0
  56. package/src/tools/notebook.js +113 -0
  57. package/src/tools/read-media-file.js +10 -3
  58. package/src/tools/shell.js +34 -6
  59. package/src/tui.js +5219 -792
  60. package/src/web-public/app.js +7 -0
@@ -0,0 +1,411 @@
1
+ // Terminal capability probing — ASK the terminal instead of guessing.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // ---------------
5
+ // Everything this app does that is terminal-specific used to be either unconditional
6
+ // or env-var-gated. Three of those were guesses with real consequences:
7
+ //
8
+ // * `ESC[?2026h/l` (synchronized output) was wrapped around EVERY diff write. A
9
+ // terminal that does not implement it is supposed to ignore the private mode,
10
+ // but "supposed to" is doing a lot of work for two sequences per frame.
11
+ // * the Kitty keyboard protocol was turned ON by default with only
12
+ // `HNCODE_NO_KITTY_KEYS=1` to undo it, and the code's own comment admitted why
13
+ // that is a gamble: a terminal that does not implement `>1u` "reacts badly
14
+ // (dropped/mangled input)". That is a wrong-guess-causes-a-broken-keyboard bug.
15
+ // * the theme was forced to `dark`, whose normal text is WHITE, while the frame
16
+ // pads its rows with PLAIN SPACES — no background is ever painted. On a terminal
17
+ // with a light background that is white text on white: the transcript is
18
+ // invisible. Nothing in the process could tell, because nothing asked.
19
+ //
20
+ // So this module asks. The protocol is the standard one, and it is worth spelling out
21
+ // because it is why this needs no timeout-based guessing:
22
+ //
23
+ // Terminals process input sequentially and answer queries IN ORDER. So the batch
24
+ // ends with a Device Attributes request (`ESC[c`) — the one query every terminal
25
+ // answers — and its reply doubles as the end-of-batch sentinel: everything asked
26
+ // before it has been answered by the time it arrives. No per-query timeout, no
27
+ // waiting for absent replies.
28
+ //
29
+ // A response arrives on the SAME stream as keystrokes, so whoever reads stdin while
30
+ // the probe runs must route it here instead of into the key parser. `probeTerminal`
31
+ // owns stdin for that window and hands back anything it could not consume, so a key
32
+ // pressed during the probe is still delivered.
33
+ //
34
+ // The whole thing is best-effort: an unanswered query leaves its field `null`, which
35
+ // every consumer reads as "no information", never as "not supported".
36
+
37
+ /** How long to wait for the answers. Real terminals reply in ~1ms; this only bites
38
+ * on a terminal that answers nothing at all, where it is the price of not guessing. */
39
+ export const PROBE_TIMEOUT_MS = 250;
40
+
41
+ /** The batch, in the order it is written. DA1 is LAST — it is the sentinel.
42
+ * OSC 11 `?` — report the background colour
43
+ * CSI ? u — report the Kitty KEYBOARD protocol flags
44
+ * CSI ? 2026$p — report whether synchronized output is known
45
+ * ESC _G ... ESC\ — query the Kitty GRAPHICS protocol: does it exist, and does it
46
+ * accept the transmission format we would send
47
+ * CSI 16 t — the pixel size of ONE CELL (XTWINOPS 16)
48
+ * CSI 14 t — the pixel size of the TEXT AREA (XTWINOPS 14), a cross-check
49
+ * for the cell size on a terminal that answers only that
50
+ * CSI c — Device Attributes: the sentinel, and the VT level
51
+ *
52
+ * Both query forms were wrong before, and both mattered only for images.
53
+ *
54
+ * The graphics query is its own APC, not a `CSI ? u` variant — an earlier guess collided
55
+ * with the keyboard probe, and since an unanswered query is inferred as "not supported"
56
+ * once the sentinel arrives, that switched images OFF on the terminals that have them.
57
+ * It carries a REAL payload: one transparent RGBA pixel, RFC 1950 zlib, direct data
58
+ * (f=32, o=z) — the same path the renderer uploads on. A probe that asks about formats we
59
+ * would not send reports on a format we would not send. (dsh-TUI's terminal-querier.ts
60
+ * sends exactly this, with the same base64.)
61
+ *
62
+ * The CELL size is XTWINOPS **16**, not 14: 14 is the text area. The reply to both is
63
+ * `CSI 6 ; w ; h t` — opcode 6 for the cell — so the parser matches on the REPORT
64
+ * opcode, and 14/18 were what an earlier version matched on and therefore never read.
65
+ */
66
+ export const PROBE_QUERY = '\x1b]11;?\x07' + '\x1b[?u' + '\x1b[?2026$p'
67
+ + '\x1b_Gi=31,s=1,v=1,a=q,t=d,f=32,o=z;eAFjYGBgAAAABAAB\x1b\\'
68
+ + '\x1b[16t' + '\x1b[14t' + '\x1b[c';
69
+
70
+ /** Synchronized output (BEGIN/END SYNCHRONIZED UPDATE). */
71
+ export const SYNC_OUTPUT_MODE = 2026;
72
+
73
+ /** DECRPM `status` values for a mode. 0 means "not recognised", which is the one that
74
+ * says the terminal has never heard of the mode. */
75
+ const DECRPM_SET = 1;
76
+ const DECRPM_RESET = 2;
77
+
78
+ /** VT level per the FIRST Device Attributes parameter. A small table on purpose:
79
+ * the parameter identifies the level, not the program — `xterm`, `kitty`, `vte` and
80
+ * Windows Terminal all report a VT level in this range, and telling THOSE apart is
81
+ * what the environment is for (see terminalFamily). */
82
+ const VT_LEVELS = {
83
+ 1: 'vt100', 2: 'vt100-avo', 6: 'vt102', 12: 'vt125', 15: 'vt131',
84
+ 16: 'vt132', 17: 'vt1xx', 18: 'vt330', 19: 'vt340',
85
+ 41: 'vt420', 61: 'vt510', 62: 'vt220', 63: 'vt320', 64: 'vt420', 65: 'vt525',
86
+ };
87
+
88
+ /** Meaning of the extension parameters that follow the VT level, for the ones that
89
+ * change what this app may attempt. */
90
+ const DA1_EXTENSIONS = {
91
+ 4: 'sixel',
92
+ 6: 'selective-erase',
93
+ 21: 'horizontal-scroll',
94
+ 22: 'ansi-color',
95
+ 28: 'rectangular-edit',
96
+ 52: 'clipboard',
97
+ };
98
+
99
+ function emptyCaps() {
100
+ return {
101
+ probed: false,
102
+ bg: null, // { r, g, b } of the terminal background, or null
103
+ kitty: null, // true/false: does it speak the Kitty keyboard protocol
104
+ kittyGraphics: null, // true/false: does it speak the Kitty GRAPHICS protocol
105
+ sync: null, // true/false: synchronized output usable
106
+ level: null, // 'vt220', …
107
+ extensions: [], // ['sixel', 'ansi-color', …]
108
+ textArea: null, // { w, h } in PIXELS: the terminal's text area, or null
109
+ cell: null, // { w, h } in pixels: one cell, or null
110
+ da1: null, // the raw DA1 parameter list, for diagnostics
111
+ family: null, // a human-readable name, from the environment
112
+ cols: null, // columns, when the terminal reported them
113
+ rows: null, // rows, when the terminal reported them
114
+ };
115
+ }
116
+
117
+ let caps = emptyCaps();
118
+
119
+ /** Everything learned so far. Read by the renderer and the theme picker. */
120
+ export function terminalCaps() { return caps; }
121
+
122
+
123
+ /** Merge in probe results. Exported so tests (and a future re-probe on resize) can
124
+ * install a set without running the protocol. */
125
+ export function setTerminalCaps(next) {
126
+ caps = Object.assign(emptyCaps(), caps, next, { probed: true });
127
+ return caps;
128
+ }
129
+
130
+ /** Whether a `ESC[?2026h … ESC[?2026l` wrapper should be emitted. Unknown (no probe,
131
+ * no reply) keeps the OLD unconditional behaviour — this must not silently change
132
+ * what an already-good terminal receives. */
133
+ export function syncOutputUsable() {
134
+ return caps.sync !== false;
135
+ }
136
+
137
+ /** Whether `ESC[>1u` may be sent. Unknown keeps the old default (on), so a terminal
138
+ * that answers nothing is treated exactly as before; only a terminal that ANSWERED
139
+ * and did not report Kitty support turns it off. */
140
+ export function kittyKeysUsable() {
141
+ return caps.kitty !== false;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Parsing
146
+ // ---------------------------------------------------------------------------
147
+
148
+ // `ESC ] 11 ; rgb :RRRR/GGGG/BBBB` then BEL or ST. Components are 1-4 hex digits.
149
+ // The `#rrggbb` spelling is also accepted: it is not xterm's, but several terminals
150
+ // answer OSC 11 that way and there is no reason to throw the reading away.
151
+ const OSC11_RE = /\x1b\]11;(?:rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})|#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2}))(?:\x07|\x1b\\)/g;
152
+ // Kitty keyboard flags reply.
153
+ const KITTY_RE = /\x1b\[\?(\d+)u/g;
154
+ // DECRPM: mode number, then `;`, then the status, then `$y`.
155
+ const DECRPM_RE = /\x1b\[\?(\d+);(\d+)\$y/g;
156
+ // Device Attributes reply: `ESC [ ? <params> c`.
157
+ const DA1_RE = /\x1b\[\?([0-9;]*)c/g;
158
+ // Kitty GRAPHICS query reply. The value is a bitmap of what the terminal accepts;
159
+ // per the spec, querying action=1 asks which features are supported.
160
+ const KITTY_GRAPHICS_RE = /\x1b\[_G([\s\S]*?)\x1b\\/g;
161
+ // XTWINOPS replies are `CSI <opcode> ; <value> ... t`, and the opcode in the
162
+ // REPLY is not the one in the QUERY. Reporting the cell size is opcode 6 and the
163
+ // text area is 4; 14 and 18 are those same operations’ query forms, so a parser
164
+ // written for the query numbers matched nothing at all: the cell size stayed unknown, so
165
+ // every image refused to draw ("image could not be displayed") on a terminal that had
166
+ // just reported one — and the unmatched bytes fell through to the key parser.
167
+ const XT_CELL_RE = /\x1b\[6;(\d+);(\d+)t/g;
168
+ const XT_AREA_RE = /\x1b\[4;(\d+);(\d+)t/g;
169
+
170
+ /** One hex component of an OSC 11 reply, scaled to 0-255. xterm sends four digits
171
+ * (`ffff`), others send two (`ff`), so the scale comes from the digit count. */
172
+ function scaleHex(h) {
173
+ const n = parseInt(h, 16);
174
+ const max = Math.pow(16, h.length) - 1;
175
+ return max > 0 ? Math.round((n / max) * 255) : 0;
176
+ }
177
+
178
+ /** Is this background colour light? Used to pick a readable theme: the frame paints no
179
+ * background of its own, so the terminal's shows through every padded row. */
180
+ export function isLightBg(bg) {
181
+ if (!bg) return false;
182
+ // Rec. 601 luma, which matches how a colour reads far better than a plain average.
183
+ return (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) > 127;
184
+ }
185
+
186
+ /** Human-readable terminal name. The ENVIRONMENT is what actually identifies the
187
+ * program (`TERM_PROGRAM`, `WT_SESSION`, `TERM`); the DA1 level only says which VT
188
+ * it emulates, which several of them report identically. */
189
+ export function terminalFamily(env = process.env) {
190
+ if (env.WT_SESSION) return 'Windows Terminal';
191
+ if (env.TERM_PROGRAM) {
192
+ return /vscode/i.test(String(env.TERM_PROGRAM)) ? 'VS Code' : String(env.TERM_PROGRAM);
193
+ }
194
+ const term = String(env.TERM || '');
195
+ if (!term) return null;
196
+ if (/kitty/i.test(term)) return 'kitty';
197
+ if (/wezterm/i.test(term)) return 'WezTerm';
198
+ if (/ghostty/i.test(term)) return 'Ghostty';
199
+ if (/^screen/i.test(term)) return 'screen';
200
+ if (/^tmux/i.test(term)) return 'tmux';
201
+ if (/^xterm/i.test(term)) return 'xterm';
202
+ return term;
203
+ }
204
+
205
+ /** Prefixes of the replies we are waiting for. Used to trim a dangling partial reply
206
+ * from the bytes handed back to the key parser, so half an escape sequence does not
207
+ * become garbage input. */
208
+ const REPLY_PREFIXES = ['\x1b]11;', '\x1b[?', '\x1b[_G'];
209
+
210
+ /** Longest suffix of `buf` that could still grow into one of the replies. */
211
+ function danglingReplyLen(buf) {
212
+ let best = 0;
213
+ for (const p of REPLY_PREFIXES) {
214
+ const max = Math.min(p.length, buf.length);
215
+ for (let len = max; len >= 2; len--) {
216
+ if (buf.endsWith(p.slice(0, len))) { if (len > best) best = len; break; }
217
+ }
218
+ }
219
+ return best;
220
+ }
221
+
222
+ /**
223
+ * Remove any complete probe reply from `s`.
224
+ *
225
+ * For ONE narrow case, after the probe has given up: a slow terminal answers anyway,
226
+ * and those bytes arrive on the keystroke stream. Without this they reach the key
227
+ * parser and are typed into the composer as garbage. A terminal that answered the DA1
228
+ * sentinel has nothing left outstanding, so its caller does not need this at all — see
229
+ * the `complete` flag `probeTerminal` returns.
230
+ */
231
+ export function stripProbeReplies(s) {
232
+ const buf = String(s == null ? '' : s);
233
+ // Almost every chunk is ordinary typing; only touch the buffer when it could hold a
234
+ // reply at all.
235
+ if (buf.indexOf('\x1b') < 0) return buf;
236
+ const spans = [];
237
+ const noop = () => {};
238
+ takeMatches(OSC11_RE, buf, spans, noop);
239
+ takeMatches(KITTY_RE, buf, spans, noop);
240
+ takeMatches(KITTY_GRAPHICS_RE, buf, spans, noop);
241
+ takeMatches(XT_CELL_RE, buf, spans, noop);
242
+ takeMatches(XT_AREA_RE, buf, spans, noop);
243
+ takeMatches(DECRPM_RE, buf, spans, noop);
244
+ takeMatches(DA1_RE, buf, spans, noop);
245
+ return removeSpans(buf, spans);
246
+ }
247
+
248
+ /** Collect the matches of `re` in `buf`, recording their spans so the caller can
249
+ * compute what is LEFT after removing them. */
250
+ function takeMatches(re, buf, spans, onMatch) {
251
+ re.lastIndex = 0;
252
+ let m;
253
+ while ((m = re.exec(buf)) !== null) {
254
+ spans.push([m.index, m.index + m[0].length]);
255
+ onMatch(m);
256
+ // A zero-length match cannot happen with these patterns, but guard the loop.
257
+ if (m[0].length === 0) re.lastIndex++;
258
+ }
259
+ }
260
+
261
+ /** Everything not covered by a span. */
262
+ function removeSpans(buf, spans) {
263
+ if (!spans.length) return buf;
264
+ spans.sort((a, b) => a[0] - b[0]);
265
+ let out = '';
266
+ let at = 0;
267
+ for (const [s, e] of spans) {
268
+ if (s > at) out += buf.slice(at, s);
269
+ at = Math.max(at, e);
270
+ }
271
+ return out + buf.slice(at);
272
+ }
273
+
274
+ /**
275
+ * Parse whatever has arrived. Pure, so it is directly testable.
276
+ *
277
+ * @returns {{ found: object, rest: string, complete: boolean }}
278
+ * `found` carries only the fields a reply actually provided; `rest` is the buffer
279
+ * with every reply removed (the caller keeps the tail it could still use); and
280
+ * `complete` is true once the DA1 sentinel has been seen.
281
+ */
282
+ export function parseProbeReplies(buf) {
283
+ const spans = [];
284
+ const found = {};
285
+
286
+ takeMatches(OSC11_RE, buf, spans, (m) => {
287
+ found.bg = m[4] != null
288
+ ? { r: parseInt(m[4], 16), g: parseInt(m[5], 16), b: parseInt(m[6], 16) }
289
+ : { r: scaleHex(m[1]), g: scaleHex(m[2]), b: scaleHex(m[3]) };
290
+ });
291
+ takeMatches(KITTY_RE, buf, spans, () => {
292
+ // Any flags value, including 0, means the terminal implements the protocol: 0 is
293
+ // "supported, nothing enabled yet", which is exactly the state we ask from.
294
+ found.kitty = true;
295
+ });
296
+ takeMatches(DECRPM_RE, buf, spans, (m) => {
297
+ const mode = Number(m[1]);
298
+ const status = Number(m[2]);
299
+ if (mode === SYNC_OUTPUT_MODE) {
300
+ // 0 is "not recognised"; SET and RESET both mean the terminal knows the mode,
301
+ // and either state is something the wrapper can work with.
302
+ found.sync = status === DECRPM_SET || status === DECRPM_RESET;
303
+ }
304
+ });
305
+ // Kitty GRAPHICS: a supported query answers `OK`; a terminal without the protocol says
306
+ // nothing at all. Silence is resolved in probeTerminal (a sentinel arriving means every
307
+ // query was answered, so silence means "no"), not here.
308
+ takeMatches(KITTY_GRAPHICS_RE, buf, spans, (m) => {
309
+ if (!/\bOK\b/.test(m[1])) return;
310
+ found.kittyGraphics = true;
311
+ });
312
+ // XTWINOPS: the CELL size (reported as opcode 6) and the TEXT AREA (opcode 4). Both
313
+ // are needed to place an image in cells rather than guessing — and a guess here is wrong
314
+ // on every display whose font is not square, which is most of them.
315
+ takeMatches(XT_CELL_RE, buf, spans, (m) => {
316
+ const w = Number(m[1]);
317
+ const h = Number(m[2]);
318
+ // A terminal that answers 0x0 has nothing to say — treat it as unknown rather than
319
+ // as a cell of zero width, which would divide by zero downstream.
320
+ if (w > 0 && h > 0) found.cell = { w, h };
321
+ });
322
+ takeMatches(XT_AREA_RE, buf, spans, (m) => {
323
+ const w = Number(m[1]);
324
+ const h = Number(m[2]);
325
+ if (w > 0 && h > 0) found.textArea = { w, h };
326
+ });
327
+ let complete = false;
328
+ takeMatches(DA1_RE, buf, spans, (m) => {
329
+ const params = m[1].split(';').map((s) => Number(s)).filter((n) => Number.isFinite(n));
330
+ found.da1 = params;
331
+ found.level = VT_LEVELS[params[0]] || (params.length ? `vt?${params[0]}` : null);
332
+ found.extensions = params.slice(1).map((p) => DA1_EXTENSIONS[p]).filter(Boolean);
333
+ // The sentinel is the LAST DA1 in the batch, and we only write one.
334
+ complete = true;
335
+ });
336
+
337
+ const rest = removeSpans(buf, spans);
338
+ return { found, rest, complete };
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // The probe
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * Ask the terminal. Owns `stdin` for the duration.
347
+ *
348
+ * @param {object} opts
349
+ * @param {import('node:stream').Readable} opts.stdin Raw-mode stdin.
350
+ * @param {{write: Function}} opts.stdout Where the queries go.
351
+ * @param {number} [opts.timeoutMs]
352
+ * @returns {Promise<{ caps: object, rest: string }>} `rest` is everything that was
353
+ * NOT a reply — a keystroke typed while the probe was waiting must not be eaten.
354
+ */
355
+ export function probeTerminal({ stdin, stdout, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
356
+ return new Promise((resolve) => {
357
+ // A probe REPLACES what is known: a second run must not inherit the first one's
358
+ // answers, or a terminal that changed would keep stale flags.
359
+ caps = emptyCaps();
360
+ // Declared before the closures that use them: `finish` can run from the catch
361
+ // below, i.e. before the listener is attached.
362
+ const found = {};
363
+ let buf = '';
364
+ let settled = false;
365
+ let timer = null;
366
+ // Whether the DA1 sentinel ever arrived: everything asked before it is answered by
367
+ // then, so if it did, nothing is outstanding.
368
+ let sawSentinel = false;
369
+
370
+ const finish = () => {
371
+ if (settled) return;
372
+ settled = true;
373
+ if (timer) clearTimeout(timer);
374
+ try { stdin.removeListener('data', onData); } catch { /* stream may be gone */ }
375
+ // Anything still buffered that could be the head of a reply is a reply we never
376
+ // finished receiving: drop just that tail, and hand the rest back as real input.
377
+ const tail = danglingReplyLen(buf);
378
+ const rest = tail ? buf.slice(0, buf.length - tail) : buf;
379
+ // The environment is the only thing that actually identifies the PROGRAM; DA1
380
+ // only reports which VT it emulates, which several of them do identically.
381
+ if (!found.family) found.family = terminalFamily();
382
+ // A terminal that answered the DA1 SENTINEL answered everything asked before it,
383
+ // in order — so a query it left unanswered is one it does not implement. For
384
+ // Kitty that inference is load-bearing: the reply is mandatory when the protocol
385
+ // is supported, so silence means NO, and sending `>1u` anyway is exactly what
386
+ // mangles input. Only inferred when the sentinel arrived; without it, unknown.
387
+ if (sawSentinel && found.kitty === undefined) found.kitty = false;
388
+ resolve({ caps: setTerminalCaps(found), rest, complete: sawSentinel });
389
+ };
390
+
391
+ const onData = (chunk) => {
392
+ buf += String(chunk);
393
+ const parsed = parseProbeReplies(buf);
394
+ Object.assign(found, parsed.found);
395
+ buf = parsed.rest;
396
+ if (parsed.complete) { sawSentinel = true; finish(); }
397
+ };
398
+
399
+ try {
400
+ stdin.setEncoding?.('utf8');
401
+ stdin.on('data', onData);
402
+ } catch {
403
+ // No usable stdin: report "nothing learned" rather than hanging.
404
+ finish();
405
+ return;
406
+ }
407
+ timer = setTimeout(finish, timeoutMs);
408
+ if (timer.unref) timer.unref();
409
+ try { stdout.write(PROBE_QUERY); } catch { finish(); }
410
+ });
411
+ }