@hncode/hncode 0.5.0 → 0.5.2

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 (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
package/src/config.js CHANGED
@@ -388,6 +388,14 @@ const b = baseUrl.replace(/\/+$/, '');
388
388
  // "use the built-in SYSTEM_PROMPT". Read from config.toml so it persists
389
389
  // across restarts.
390
390
  systemPrompt: process.env.HNCODE_SYSTEM_PROMPT || root.system_prompt || '',
391
+ // The prompt knobs added alongside it, all editable from /settings › Prompt. Separate
392
+ // keys rather than one blob, because they mean different things: `append` keeps the
393
+ // built-in prompt and adds to it, `personal` is the user's own standing note, and the
394
+ // per-mode ones only apply while that mode is on.
395
+ appendSystemPrompt: process.env.HNCODE_APPEND_SYSTEM_PROMPT || root.append_system_prompt || '',
396
+ planInstructions: root.plan_instructions || '',
397
+ focusInstructions: root.focus_instructions || '',
398
+ swarmInstructions: root.swarm_instructions || '',
391
399
  // ---- Web UI daemon -------------------------------------------------------
392
400
  // One daemon serves EVERY hncode session on this machine: it holds the fixed
393
401
  // port, proxies each session's requests to that session's own loopback
@@ -1146,24 +1154,43 @@ export function effortOptions(cfg, modelKeyName) {
1146
1154
  return ['off', 'on'];
1147
1155
  }
1148
1156
 
1157
+ // Token budget for a NAMED grade, on a protocol that wants a number rather than a word.
1158
+ //
1159
+ // The grades the catalog declares are low/medium/high, and a user may also write their own
1160
+ // in config.toml (`efforts = ["xhigh"]`). A grade with no entry here has no honest budget to
1161
+ // send, so the field is omitted — the previous code fell back to 8000, which silently gave
1162
+ // every unknown grade the same strength as `on` and `medium`.
1163
+ const EFFORT_BUDGETS = { low: 2000, medium: 8000, high: 16000, max: 32000 };
1164
+
1149
1165
  // Map a chosen effort to the wire value for the active protocol.
1166
+ //
1167
+ // `on` means "thinking on", NOT "medium". The two are different requests: a model may
1168
+ // support a plain toggle without any graded control, in which case there is no grade to
1169
+ // send and inventing one (`medium`) can be rejected outright by the provider. `on` therefore
1170
+ // sends the protocol's plain enable, or nothing at all.
1150
1171
  export function effortWire(cfg, effort) {
1151
1172
  if (!effort || effort === 'off') return null;
1173
+ const named = Object.prototype.hasOwnProperty.call(EFFORT_BUDGETS, effort);
1174
+
1152
1175
  if (cfg.protocol === 'anthropic') {
1153
- const budget = { on: 8000, low: 2000, medium: 8000, high: 16000, max: 32000 }[effort] || 8000;
1154
- return { thinking: { type: 'enabled', budget_tokens: budget } };
1176
+ // `thinking: { type: 'enabled' }` IS the plain toggle; `budget_tokens` is optional and
1177
+ // only meaningful for a named grade. Omitting it lets the provider pick its own budget,
1178
+ // which is what "on" asked for.
1179
+ return named
1180
+ ? { thinking: { type: 'enabled', budget_tokens: EFFORT_BUDGETS[effort] } }
1181
+ : { thinking: { type: 'enabled' } };
1155
1182
  }
1156
- // Responses API nests the grade: `reasoning: { effort }`.
1157
- if (cfg.protocol === 'responses') {
1158
- const grade = effort === 'on' ? 'medium' : effort;
1159
- return { reasoning: { effort: grade } };
1160
- }
1161
- // OpenAI-compatible: `reasoning_effort`. "on" is not a valid grade, so map it
1162
- // to "medium"; concrete grades pass through.
1163
- const grade = effort === 'on' ? 'medium' : effort;
1164
- return { reasoning_effort: grade };
1183
+
1184
+ // Responses API and the OpenAI-compatible shape both take a GRADE word, and neither has
1185
+ // a value meaning "on". So a named grade passes through, and `on` sends nothing — the
1186
+ // request carries whatever the provider does by default for that model, rather than a
1187
+ // grade the model may not accept.
1188
+ if (!named) return {};
1189
+ if (cfg.protocol === 'responses') return { reasoning: { effort } };
1190
+ return { reasoning_effort: effort };
1165
1191
  }
1166
1192
 
1193
+
1167
1194
  function toTomlLine(k, v) {
1168
1195
  if (typeof v === 'string') return `${k} = "${v.replace(/"/g, '\\"')}"`;
1169
1196
  if (typeof v === 'boolean') return `${k} = ${v}`;
@@ -228,10 +228,51 @@ export function formatReadout(used, window, freeCols) {
228
228
  return '';
229
229
  }
230
230
 
231
- /** 1200 -> `1.2k`, 97000 -> `97.0k`, 3_400_000 -> `3.4M`. */
231
+ /**
232
+ * A token count as a compact string: `1200` -> `1.2k`, `97000` -> `97k`, `3400000` -> `3.4M`.
233
+ *
234
+ * Deliberately the SAME shape `fmtTokensShort` produces for the working row, because both appear
235
+ * in one frame — the context bar and the live turn counter would otherwise disagree about the same
236
+ * quantity. Two rules:
237
+ *
238
+ * * one decimal while it carries information, dropped when it is a zero (`97000` is `97k`, not
239
+ * `97.0k` — the trailing `.0` is three characters saying nothing);
240
+ * * K/M/B/T suffixes, with only the kilo lower-case, so `3.4M` here and `3.4M` on the working row
241
+ * read as one vocabulary.
242
+ *
243
+ * The previous version kept `.0` and disagreed with the working row on units entirely: it divided
244
+ * by 1024 where the row divided by 1000, so a 97k context read `94.7k` beside a `97k` turn.
245
+ */
232
246
  export function compactTokens(n) {
233
- const v = Number(n) || 0;
234
- if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
235
- if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
236
- return String(Math.round(v));
247
+ const v = Math.max(0, Math.floor(Number(n) || 0));
248
+ const units = [
249
+ { at: 1e12, suffix: 'T' },
250
+ { at: 1e9, suffix: 'B' },
251
+ { at: 1e6, suffix: 'M' },
252
+ { at: 1e3, suffix: 'k' },
253
+ ];
254
+ for (let i = 0; i < units.length; i++) {
255
+ const u = units[i];
256
+ if (v < u.at) continue;
257
+ // The identical scaling rule the working row and the /ctx readout use: one decimal below 100,
258
+ // whole numbers above. Three formatters render token counts into one frame, so a single rule is
259
+ // the only way `262k` cannot appear beside `262.1k` for the same quantity.
260
+ const shown = scaleUnit(v / u.at);
261
+ // Rounding can reach the next unit (`999999` is `1000k`, which is `1m`), and showing `1000k`
262
+ // makes the reader convert a unit themselves.
263
+ if (Number(shown) >= 1000 && i > 0) return `${scaleUnit(v / units[i - 1].at)}${units[i - 1].suffix}`;
264
+ return `${shown}${u.suffix}`;
265
+ }
266
+ return String(v);
267
+ }
268
+
269
+ /**
270
+ * One unit's worth of a count: `1.2`, `97`, `262`.
271
+ *
272
+ * Duplicated from tui.js rather than imported, because `context-bar.js` must stay a leaf module the
273
+ * TUI can import without a cycle. The two are pinned together by test/context-bar.test.mjs and
274
+ * test/working-tokens.test.mjs, which assert the same table of values through both.
275
+ */
276
+ function scaleUnit(v) {
277
+ return v < 100 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v));
237
278
  }
package/src/doctor.js CHANGED
@@ -77,8 +77,10 @@ function checkConfig(cfg, files) {
77
77
  return out;
78
78
  }
79
79
 
80
- /** The session directory. A store that cannot be listed is a store whose work is invisible. */
81
- function checkSessions(files, sessionsDir, limit = 4) {
80
+ /** The session directory. A store that cannot be listed is a store whose work is invisible.
81
+ *
82
+ * Async because it profiles the largest session — see the memory breakdown below. */
83
+ async function checkSessions(files, sessionsDir, limit = 4) {
82
84
  const out = [];
83
85
  if (!fs.existsSync(sessionsDir)) {
84
86
  return [check('session store', 'warn', `${sessionsDir} does not exist yet`,
@@ -110,14 +112,17 @@ function checkSessions(files, sessionsDir, limit = 4) {
110
112
  // actually hit: past ~512 MB `JSON.stringify` throws, and every save of that session
111
113
  // then fails until the transcript is trimmed.
112
114
  const V8_LIMIT = 512 * 1024 * 1024;
113
- const big = [];
115
+ // Sizes of every session, so the memory profile can pick the largest regardless of how
116
+ // large it is. The `big` list below is filtered to the ones worth WARNING about; the
117
+ // profiler wants the largest whatever its size, because "this session holds 0.66 MB of
118
+ // tool arguments" is useful at 4 MB and not only at 500.
119
+ const sizes = [];
114
120
  for (const f of names) {
115
- try {
116
- const size = fs.statSync(path.join(sessionsDir, f)).size;
117
- if (size > 8 * 1024 * 1024) big.push({ f, size });
118
- } catch { /* raced away */ }
121
+ try { sizes.push({ f, size: fs.statSync(path.join(sessionsDir, f)).size }); } catch { /* raced away */ }
119
122
  }
120
- big.sort((a, b) => b.size - a.size);
123
+ sizes.sort((a, b) => b.size - a.size);
124
+ const big = sizes.filter((x) => x.size > 8 * 1024 * 1024);
125
+
121
126
  for (const b of big.slice(0, limit)) {
122
127
  const mb = (b.size / 1048576).toFixed(1);
123
128
  const sev = b.size > V8_LIMIT * 0.75 ? 'error' : 'warn';
@@ -127,6 +132,38 @@ function checkSessions(files, sessionsDir, limit = 4) {
127
132
  : 'Large; loading and saving this session will be slow.'));
128
133
  }
129
134
 
135
+ // WHERE THE BYTES ARE in the biggest session, as opposed to how big the file is.
136
+ //
137
+ // A file size says nothing actionable: the same 2.5 MB can be a conversation or a
138
+ // runaway transcript, and only one of those is worth fixing. The breakdown also explains
139
+ // the file being LARGER than the content it holds — measured on a real session, the
140
+ // serialised JSON was 2.29 MB while text + tool results + tool arguments summed to
141
+ // 1.27 MB, so structure and escaping are as expensive as the text.
142
+ //
143
+ // Skipped below 1 MB: loading and measuring a session to learn it is small is itself
144
+ // work, and the finding would be "nothing to see". Deliberately NOT gated on `big` —
145
+ // that list only holds sessions big enough to warn about, and the breakdown is most
146
+ // useful on an ordinary one: it is where "half your memory is tool arguments" shows up.
147
+ if (sizes.length && sizes[0].size > 1024 * 1024) {
148
+ const top = sizes[0];
149
+ try {
150
+ const { loadSession } = await import('./session.js');
151
+ const { profileMemory, memoryHeadline } = await import('./memory-profile.js');
152
+ const loaded = loadSession(top.f.replace(/\.json$/, ''));
153
+ if (loaded) {
154
+ const profile = profileMemory({ session: loaded });
155
+ out.push(check('largest session memory', 'info',
156
+ `${memoryHeadline(profile)} (${top.f})`,
157
+ 'Text, tool results and tool ARGUMENTS are counted separately; run /memory-profile for the full breakdown.'));
158
+ }
159
+ } catch (e) {
160
+ out.push(check('largest session memory', 'warn', `could not profile ${top.f}: ${e.message}`,
161
+ 'The file was readable by size but would not parse.'));
162
+ }
163
+ }
164
+
165
+ // Leftover temp files from an interrupted save.
166
+
130
167
  // Leftover temp files from an interrupted save.
131
168
  let tmp = [];
132
169
  try { tmp = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.tmp')); } catch { /* listed above */ }
@@ -213,6 +250,165 @@ function checkExtensions(files) {
213
250
  return out;
214
251
  }
215
252
 
253
+ /**
254
+ * Free space where the sessions live.
255
+ *
256
+ * A store that fills up is a store that loses work, and the failure is not obviously about
257
+ * disk: the write fails, the turn is lost, and the error text says ENOENT or EACCES. The
258
+ * session directory held 608 MB on this machine at one point, which is the shape that walks
259
+ * a disk to full without anyone noticing.
260
+ *
261
+ * `statfs` is used where the platform has it (Node 18.15+). Where it does not, the check is
262
+ * SKIPPED rather than guessed — a wrong number here is worse than no number.
263
+ */
264
+ function checkDisk(dir) {
265
+ if (typeof fs.statfsSync !== 'function') {
266
+ return [check('disk space', 'info', 'not measurable on this Node/platform', '')];
267
+ }
268
+ try {
269
+ const st = fs.statfsSync(dir);
270
+ const freeBytes = Number(st.bavail) * Number(st.bsize);
271
+ const totalBytes = Number(st.blocks) * Number(st.bsize);
272
+ const freeGb = freeBytes / 1024 ** 3;
273
+ const totalGb = totalBytes / 1024 ** 3;
274
+ // Thresholds are deliberately far apart: below 1 GB is an error (a long session can
275
+ // write hundreds of megabytes), below 5 GB is a warning.
276
+ const sev = freeGb < 1 ? 'error' : freeGb < 5 ? 'warn' : 'ok';
277
+ return [check('disk space', sev, `${freeGb.toFixed(1)} GB free of ${totalGb.toFixed(0)} GB`,
278
+ sev === 'ok' ? ''
279
+ : 'Saving a session writes the whole transcript, which has reached hundreds of MB. Free space, or point HNCODE_SESSIONS_DIR at another volume.')];
280
+ } catch (e) {
281
+ return [check('disk space', 'info', `could not measure: ${e.message}`, '')];
282
+ }
283
+ }
284
+
285
+ /**
286
+ * The workspace's git state.
287
+ *
288
+ * Two things here actually break work: a missing `user.name`/`user.email` makes every
289
+ * commit fail, and an unfinished merge or rebase means the tree is not what the agent
290
+ * thinks it is. Both are cheap to check and neither surfaced anywhere before.
291
+ */
292
+ function checkGit(workspace, cp) {
293
+ if (!workspace) return [];
294
+ if (!cp) return [check('git', 'info', 'the git module is unavailable', '')];
295
+ const run = (args) => {
296
+ try {
297
+ const r = cp.spawnSync('git', args, { cwd: workspace, encoding: 'utf8', timeout: 5000, windowsHide: true });
298
+ return r.status === 0 ? String(r.stdout || '').trim() : null;
299
+ } catch { return null; }
300
+ };
301
+ // A workspace that is not a repo is normal, and says nothing about health.
302
+ const top = run(['rev-parse', '--show-toplevel']);
303
+ if (!top) return [check('git', 'info', 'not a git repository', '')];
304
+
305
+ const out = [];
306
+ const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
307
+ out.push(check('git', 'ok', `${branch || '(detached)'} in ${top}`));
308
+
309
+ // Because `run` pins cwd to the workspace, `config --get` reports the identity that a
310
+ // commit HERE would actually use — not one inherited from an unrelated directory.
311
+ // A value that IS set is reported too: only a row in the report distinguishes
312
+ // "configured" from "the check never ran", and silence reads as success.
313
+ for (const key of ['user.name', 'user.email']) {
314
+ const value = run(['config', '--get', key]);
315
+ if (value) out.push(check(`git ${key}`, 'ok', value));
316
+ else {
317
+ out.push(check(`git ${key}`, 'warn', 'not set',
318
+ `Every commit fails without it. Run: git config --global ${key} "…"`));
319
+ }
320
+ }
321
+ // A half-finished operation changes what the agent is looking at.
322
+ const gitDir = run(['rev-parse', '--git-dir']);
323
+ if (gitDir) {
324
+ const abs = path.isAbsolute(gitDir) ? gitDir : path.join(workspace, gitDir);
325
+ for (const [file, what] of [['MERGE_HEAD', 'a merge'], ['REBASE_HEAD', 'a rebase'], ['CHERRY_PICK_HEAD', 'a cherry-pick']]) {
326
+ if (fs.existsSync(path.join(abs, file))) {
327
+ out.push(check('git in progress', 'warn', `${what} is unfinished`,
328
+ 'The working tree is mid-operation; resolve it before trusting a diff.'));
329
+ }
330
+ }
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /**
336
+ * A LIVE end-to-end probe: can this configuration actually complete a turn?
337
+ *
338
+ * This is the check a static report cannot make. Everything above reads files; a key that
339
+ * exists and an endpoint that parses still say nothing about whether a request SUCCEEDS, and
340
+ * the failures that matter — a revoked key, a wrong base URL, a model the account cannot
341
+ * reach, a provider that rejects `reasoning_effort` — are all invisible until a real request
342
+ * is made.
343
+ *
344
+ * It costs one request and a handful of tokens, so it is OPT-IN (`/doctor --probe`) and never
345
+ * part of a plain run. Modelled on jcode's provider-doctor, whose AUTH_CREDENTIAL_LOADED,
346
+ * NON_STREAMING_CHAT_COMPLETION and STREAMING_CHAT_COMPLETION checks do the same thing.
347
+ *
348
+ * The steps are reported as they happen, so a failure names the step that broke rather than
349
+ * only saying the whole thing did not work.
350
+ */
351
+ async function probeProvider(cfg, input = {}) {
352
+ const out = [];
353
+ const send = input.probeRequest; // injected by a test; see the note below
354
+ if (!cfg.provider || !cfg.model) {
355
+ return [check('probe', 'info', 'skipped: no provider/model configured', '')];
356
+ }
357
+ if (!cfg.endpoint) {
358
+ return [check('probe', 'info', 'skipped: no endpoint', '')];
359
+ }
360
+ // A test supplies its own transport. Without one, the real LLM class is used — lazily, so
361
+ // a plain /doctor never pays to import the provider stack.
362
+ const doRequest = send || (async () => {
363
+ const { LLM } = await import('./llm.js');
364
+ const llm = new LLM(cfg);
365
+ let text = '';
366
+ let sawError = null;
367
+ let usage = null;
368
+ await llm.request(
369
+ [{ role: 'user', content: 'Reply with the single word: ok' }],
370
+ (e) => {
371
+ if (e.type === 'data') text += e.text;
372
+ else if (e.type === 'error') sawError = e.error;
373
+ else if (e.type === 'usage') usage = e.usage;
374
+ },
375
+ // One turn, no tools, no streaming: the smallest request that proves the pipeline.
376
+ { noTools: true, streaming: false },
377
+ );
378
+ return { text, error: sawError, usage };
379
+ });
380
+
381
+ const t0 = Date.now();
382
+ let res;
383
+ try {
384
+ res = await doRequest();
385
+ } catch (e) {
386
+ return [check('probe', 'error', `request threw: ${(e && e.message) || e}`,
387
+ 'The configuration parses but a request does not complete. Check the key, the endpoint and the network.')];
388
+ }
389
+ const ms = Date.now() - t0;
390
+
391
+ if (res && res.error) {
392
+ return [check('probe', 'error', `provider error: ${String(res.error).slice(0, 160)}`,
393
+ 'The key may be revoked, the model unavailable, or the provider rejecting a field in the request.')];
394
+ }
395
+ const text = String((res && res.text) || '').trim();
396
+ if (!text) {
397
+ // A 200 with no text is the failure mode that looks like success: a reasoning model can
398
+ // spend its whole budget on `reasoning_content` and return an empty `content`.
399
+ return [check('probe', 'error', `empty reply in ${ms} ms`,
400
+ 'The request completed but returned no text. If this model always reasons, it may be spending the whole output budget on reasoning.')];
401
+ }
402
+ // `usage` comes off the RESULT, not out of the closure that built it — reading the local
403
+ // inside `doRequest` from here was a ReferenceError on every successful probe.
404
+ const usage = res && res.usage;
405
+ out.push(check('probe', 'ok', `replied "${text.slice(0, 20)}" in ${ms} ms`,
406
+ usage ? `provider reported ${JSON.stringify(usage)}` : ''));
407
+ return out;
408
+ return out;
409
+ }
410
+
411
+
216
412
  /** The terminal. An unknown size or a dumb TERM explains a broken-looking UI. */
217
413
  function checkTerminal(env, dims) {
218
414
  const out = [];
@@ -224,6 +420,8 @@ function checkTerminal(env, dims) {
224
420
  } else {
225
421
  out.push(check('TERM', 'ok', `${term}${env.COLORTERM ? ` / ${env.COLORTERM}` : ''}`));
226
422
  }
423
+ // Named distinctly from the TERM check: both used to be called "TERM", so a report with a
424
+ // small terminal showed the same name twice and read as a duplicated finding.
227
425
  if (dims && (dims.cols < 60 || dims.rows < 15)) {
228
426
  out.push(check('terminal size', 'warn', `${dims.cols}x${dims.rows}`,
229
427
  'Below 60x15 the layout cannot fit; widen the window.'));
@@ -257,7 +455,7 @@ function checkRuntime(node, platform, dirs) {
257
455
  * @param {string} [input.node] process.version, for tests
258
456
  * @returns {Array<{name:string,severity:string,detail:string,fix:string}>}
259
457
  */
260
- export function runDoctor(input = {}) {
458
+ export async function runDoctor(input = {}) {
261
459
  const cfg = input.cfg || {};
262
460
  const env = input.env || process.env;
263
461
  const workspace = input.workspace || cfg.workspace || process.cwd();
@@ -278,13 +476,18 @@ export function runDoctor(input = {}) {
278
476
  }),
279
477
  ...checkConfig(cfg, files),
280
478
  ...checkWorkspace(workspace),
281
- ...checkSessions(files, sessionsDir),
479
+ ...(await checkSessions(files, sessionsDir)),
282
480
  ...checkErrorLog(files),
283
481
  ...checkMcp(input.mcpServers, input.mcpConnections),
284
482
  ...checkHooks(files, workspace),
285
483
  ...checkExtensions(files),
286
- ...checkTerminal(env, input.dims),
484
+ ...checkDisk(sessionsDir),
485
+ ...checkGit(workspace, input.childProcess),
486
+ ...checkTerminal(env, input.dims),
287
487
  ];
488
+ // The live probe is OPT-IN: it spends a request and a few tokens, so it must never run
489
+ // as part of a plain /doctor.
490
+ if (input.probe) results.push(...await probeProvider(cfg, input));
288
491
  return results;
289
492
  }
290
493
 
package/src/git.js CHANGED
@@ -33,6 +33,10 @@ const GIT_ENV = [
33
33
 
34
34
  // Run git with args in `cwd`. Returns { ok, stdout, stderr, code, error }.
35
35
  // Never throws: a missing git binary or a non-repo is data, not an exception.
36
+ //
37
+ // SYNCHRONOUS, and therefore only for the queries made at STARTUP (the status bar's branch and
38
+ // sha). It blocks the event loop, so an AbortSignal cannot fire during it — use `runGitAsync` for
39
+ // anything a tool call reaches.
36
40
  export function runGit(args, cwd, opts = {}) {
37
41
  const timeout = opts.timeoutMs || 30_000;
38
42
  try {
@@ -53,7 +57,6 @@ export function runGit(args, cwd, opts = {}) {
53
57
  return { ok: false, stdout: '', stderr: '', code: null, error: e.message };
54
58
  }
55
59
  }
56
-
57
60
  export function isRepo(cwd) {
58
61
  const r = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
59
62
  return r.ok && r.stdout.trim() === 'true';
@@ -436,10 +439,20 @@ export function rebase(cwd, onto) {
436
439
  // The async form below runs the same command off the main thread, so the frame
437
440
  // draws immediately and the badge fills in when the data arrives.
438
441
 
442
+ /**
443
+ * Run git without blocking the event loop.
444
+ *
445
+ * `opts.signal` makes the command interruptible, which is the whole reason a tool call must use this
446
+ * rather than `runGit`: a synchronous `spawnSync` holds the event loop, so an AbortSignal can never
447
+ * fire during it — measured, `spawnSync` accepts a `signal` option and ignores it, waiting out the
448
+ * full timeout. With Esc interrupting a turn, `git status` or `git log` on a large repository would
449
+ * otherwise freeze the TUI for up to the timeout with no key able to reach it.
450
+ */
439
451
  export function runGitAsync(args, cwd, opts = {}) {
440
452
  return new Promise((resolve) => {
441
453
  let child;
442
454
  const timeout = opts.timeoutMs || 30_000;
455
+ const signal = opts.signal;
443
456
  try {
444
457
  child = cp.spawn('git', [...GIT_ENV, ...args], {
445
458
  cwd: cwd || process.cwd(),
@@ -453,11 +466,29 @@ export function runGitAsync(args, cwd, opts = {}) {
453
466
  let stdout = '';
454
467
  let stderr = '';
455
468
  let done = false;
456
- const finish = (r) => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
469
+ const finish = (r) => {
470
+ if (done) return;
471
+ done = true;
472
+ clearTimeout(timer);
473
+ if (signal) signal.removeEventListener('abort', onAbort);
474
+ resolve(r);
475
+ };
457
476
  const timer = setTimeout(() => {
458
- try { child.kill(); } catch {}
459
- finish({ ok: false, stdout: '', stderr: '', code: null, error: 'git timed out' });
477
+ try { child.kill(); } catch { /* already gone */ }
478
+ // The partial output is returned rather than dropped: it is often still useful, and an empty
479
+ // string would look like "git had nothing to say" instead of "git was cut short".
480
+ finish({ ok: false, stdout, stderr, code: null, error: 'git timed out' });
460
481
  }, timeout);
482
+ // `interrupted` is a distinct error from a timeout, so the caller can tell a user's Esc from a
483
+ // command that hung.
484
+ const onAbort = () => {
485
+ try { child.kill(); } catch { /* already gone */ }
486
+ finish({ ok: false, stdout, stderr, code: null, error: 'interrupted' });
487
+ };
488
+ if (signal) {
489
+ if (signal.aborted) { clearTimeout(timer); onAbort(); return; }
490
+ signal.addEventListener('abort', onAbort, { once: true });
491
+ }
461
492
  if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
462
493
  if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
463
494
  child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
package/src/llm.js CHANGED
@@ -421,6 +421,12 @@ export class LLM {
421
421
 
422
422
  for (let attempt = 1; attempt <= RETRIES; attempt++) {
423
423
  attempts = attempt;
424
+ // Reset per attempt. Left holding the PREVIOUS attempt's response, a network error on
425
+ // this attempt left `res` truthy and `!res.ok` true, so the loop reported the old
426
+ // status and the old body — `LLM request failed (503)` for a connection that was
427
+ // actually refused, with `lastError` (the real cause) never reaching the user.
428
+ res = null;
429
+ lastError = null;
424
430
  // Fresh controller per attempt so an abort aborts this specific request
425
431
  // and the retry loop stops.
426
432
  this.controller = new AbortController();