@hncode/hncode 0.5.1 → 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.
package/bin/install.cjs CHANGED
@@ -30,35 +30,27 @@ for (const dir of dirs) {
30
30
 
31
31
  // Check if config file exists
32
32
  if (!fs.existsSync(configFile)) {
33
- const defaultConfig = `# hncode Configuration
34
- # Generated by hncode install script
33
+ const defaultConfig = `# hncode configuration
34
+ #
35
+ # Nothing here is required: every value has a default, and the same keys can be set with
36
+ # HNCODE_* environment variables or from /settings once hncode is running.
37
+ #
38
+ # Model and provider are deliberately left out. Naming one here starts hncode on a model
39
+ # you may have no key for, and the first turn fails against that provider instead of
40
+ # asking you to choose. Set up a provider with /provider, then pick a model with /model.
35
41
 
36
- # Default model (change this to your preferred model)
37
- default_model = "gpt-4o-mini"
38
-
39
- # Optional: Add providers directly here
40
- # Uncomment and configure as needed:
41
- #
42
42
  # [providers.openai]
43
43
  # base_url = "https://api.openai.com/v1"
44
- # api_key = "your-api-key-here"
44
+ # api_key = "sk-..." # or set HNCODE_API_KEY
45
45
  # protocol = "openai"
46
-
47
- # Or use environment variables:
48
- # HNCODE_API_KEY="your-key"
49
- # HNCODE_PROVIDER="openai"
50
- # HNCODE_MODEL="gpt-4o-mini"
51
-
52
46
  `;
53
47
 
54
48
  fs.writeFileSync(configFile, defaultConfig);
55
49
  console.log(`\nāœ… Created default configuration at: ${configFile}`);
56
50
  console.log('\nšŸ’” Next steps:');
57
- console.log(' 1. Edit ~/.hncode/config.toml with your API key');
58
- console.log(' 2. Or set environment variables:');
59
- console.log(' export HNCODE_API_KEY="your-key"');
60
- console.log(' export HNCODE_PROVIDER="openai"');
61
- console.log(' 3. Run "hncode" to start!');
51
+ console.log(' 1. Start hncode and run /provider to add an API key');
52
+ console.log(' 2. Then /model to pick one');
53
+ console.log(' Both write to the config above, so there is nothing to edit by hand.');
62
54
  } else {
63
55
  console.log(`\nā„¹ļø Configuration already exists at: ${configFile}`);
64
56
  console.log(' Run "hncode init" to regenerate or edit manually.');
@@ -66,30 +58,18 @@ default_model = "gpt-4o-mini"
66
58
 
67
59
  console.log('\n✨ hncode is ready to use!');
68
60
 
69
- // Try to refresh environment automatically
70
- try {
71
- const platform = os.platform();
72
-
73
- if (platform === 'win32') {
74
- // Windows: Use PowerShell to refresh environment
75
- const { execSync } = require('child_process');
76
- try {
77
- execSync('$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User");', { stdio: 'pipe' });
78
- console.log('āœ“ Environment refreshed automatically (PowerShell)');
79
- } catch (e) {
80
- console.log('ā„¹ļø To refresh environment in PowerShell:');
81
- console.log(' . "$env:LOCALAPPDATA\\npm\\npm.cmd"');
82
- console.log(' Or simply close and reopen your terminal');
83
- }
84
- } else {
85
- // Unix-like systems
86
- console.log('ā„¹ļø Please run one of these to refresh environment:');
87
- console.log(' source ~/.zshrc # For Zsh');
88
- console.log(' source ~/.bashrc # For Bash');
89
- console.log(' Or simply close and reopen your terminal');
90
- }
91
- } catch (err) {
92
- console.log('ā„¹ļø Note: You may need to restart your terminal');
61
+ // Tell the user how to pick up the new command.
62
+ //
63
+ // This used to run PowerShell to prepend the npm global path, and print "Environment
64
+ // refreshed automatically". That cannot work: `execSync` starts a CHILD shell, so the
65
+ // `$env:Path` it sets dies with that process. The claim is gone; the note below is true.
66
+ if (os.platform() === 'win32') {
67
+ console.log('ā„¹ļø If "hncode" is not found, open a new terminal (the PATH is updated at');
68
+ console.log(' login), or run this in the current one:');
69
+ console.log(' $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" + $env:Path');
70
+ } else {
71
+ console.log('ā„¹ļø If "hncode" is not found, open a new terminal, or re-read your shell');
72
+ console.log(' configuration: source ~/.zshrc (or ~/.bashrc)');
93
73
  }
94
74
 
95
75
  console.log('');
@@ -25,18 +25,4 @@ if (major < 20) {
25
25
  const platform = os.platform();
26
26
  console.log(`āœ“ Platform: ${platform}`);
27
27
 
28
- // Check if already installed globally
29
- try {
30
- const { execSync } = require('child_process');
31
- const npm = execSync('npm list -g hncode 2>&1').toString();
32
- if (npm.includes('hncode')) {
33
- console.log('ā„¹ļø hncode is already installed globally');
34
- console.log(' Run "npm uninstall -g hncode" to reinstall.\n');
35
- } else {
36
- console.log('āœ“ Not installed globally yet\n');
37
- }
38
- } catch (e) {
39
- console.log('āœ“ Not installed globally yet\n');
40
- }
41
-
42
28
  console.log('āœ… Environment check passed!\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/agent-task.js CHANGED
@@ -76,7 +76,10 @@ function store(ctx) {
76
76
  */
77
77
  export function createTask(ctx, kind, options = {}) {
78
78
  const tasks = store(ctx);
79
- const prefix = kind === 'process' ? 'bash' : kind === 'agent' ? 'agent' : 'question';
79
+ // A teammate is its own kind rather than falling through to 'question': the id prefix is
80
+ // what a reader of a task list uses to tell at a glance what kind of work it is.
81
+ const PREFIX = { process: 'bash', agent: 'agent', teammate: 'mate' };
82
+ const prefix = PREFIX[kind] || 'question';
80
83
  const task = {
81
84
  taskId: newTaskId(prefix),
82
85
  kind,
@@ -101,10 +104,18 @@ export function createTask(ctx, kind, options = {}) {
101
104
  task.subagentType = options.subagentType;
102
105
  task.model = options.model;
103
106
  task.thinkingEffort = options.thinkingEffort;
107
+ } else if (kind === 'teammate') {
108
+ task.teamName = options.teamName;
109
+ task.memberName = options.memberName;
110
+ task.agentId = options.agentId;
104
111
  } else if (kind === 'question') {
105
112
  task.questionCount = options.questionCount || 0;
106
113
  task.toolCallId = options.toolCallId;
107
114
  }
115
+ // WHICH TASK THIS ONE BELONGS TO. A swarm's subagents are tasks of their own so /tasks can show
116
+ // what each is doing, and this is what groups them under the swarm that launched them. Absent
117
+ // for a top-level task, which is the common case.
118
+ if (options.parentTaskId) task.parentTaskId = String(options.parentTaskId);
108
119
  // Every task exposes its buffered output through `output`. The chunks are
109
120
  // stored raw and decoded lazily:
110
121
  // * a process appends Buffer chunks -> decode as UTF-8
package/src/agent.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // model answers without tool calls (or a step/interrupt condition is hit).
4
4
 
5
5
  import { LLM, setToolsList } from './llm.js';
6
- import { tools, getTool, llmTools } from './tools/index.js';
6
+ import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
7
7
  import { DELEGATION_TOOLS } from './subagent-types.js';
8
8
  import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
9
9
  import { runHooks, runPatch, runPatchSync } from './plugin.js';
@@ -140,9 +140,10 @@ function hashKey(s) {
140
140
  h = (h * 0x01000193) >>> 0;
141
141
  }
142
142
  return h.toString(36) + '-' + str.length.toString(36);
143
- return h.toString(36) + '-' + str.length.toString(36);
144
143
  }
145
144
 
145
+ // Render what an Edit actually changed, for the MODEL's tool result.
146
+
146
147
  // Render what an Edit actually changed, for the MODEL's tool result.
147
148
  //
148
149
  // The UI has always received the edit through `editDiff` (see the tool_result
@@ -166,10 +167,18 @@ export function formatEditDiffForModel(diff, maxLines = 400) {
166
167
  const out = [];
167
168
  out.push('');
168
169
  out.push('--- diff ---');
170
+ // Unchanged lines above and below, unmarked, so the change can be LOCATED. Without them this
171
+ // was a quoted block with no indication of where in the file it landed — and identical-looking
172
+ // code appears in many places, so neither the model nor the user could confirm the edit hit
173
+ // the intended one rather than a same-text block further down.
174
+ const before = (diff.context && Array.isArray(diff.context.before)) ? diff.context.before : [];
175
+ const after = (diff.context && Array.isArray(diff.context.after)) ? diff.context.after : [];
176
+ for (const c of before) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
169
177
  let n = startLine;
170
178
  for (const l of oldLines) out.push(' ' + String(n++).padStart(4) + ' - ' + l);
171
179
  n = startLine;
172
180
  for (const l of newLines) out.push(' ' + String(n++).padStart(4) + ' + ' + l);
181
+ for (const c of after) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
173
182
  // A whole-file edit (a Write-sized `new`) must not be echoed back in full.
174
183
  if (out.length > maxLines + 3) {
175
184
  const hidden = out.length - (maxLines + 3);
@@ -205,6 +214,37 @@ function toolResultLimitsEnabled() {
205
214
  return !/^(0|off|false|no)$/i.test(String(process.env.HNCODE_TOOL_RESULT_LIMITS || ''));
206
215
  }
207
216
 
217
+ /**
218
+ * A tool's return value, as the STRING the model can be sent.
219
+ *
220
+ * Every tool in `src/tools/` returns a plain string except the team tools, which return
221
+ * `{ content }` / `{ error }`. That object went into the `tool` message verbatim, and a provider
222
+ * that expects a string rejects the NEXT request with `missing type field in content item at
223
+ * index 0` — so a teammate's first `SendMessage` worked and then the conversation died, one
224
+ * message in. The symptom was a team that says hello and then never answers again.
225
+ *
226
+ * Returns `null` for the one shape that must stay an OBJECT: `ReadMediaFile` returns
227
+ * `{ text, media: [...] }`, and the media parts travel as structured blocks the caller builds
228
+ * from the object. Stringifying that would attach no image at all, so the caller keeps it.
229
+ *
230
+ * Normalised here, at the single point every tool result passes through, rather than in the
231
+ * team tools: a second tool family making the same choice cannot reintroduce the bug.
232
+ */
233
+ function toolResultText(value) {
234
+ if (typeof value === 'string') return value;
235
+ if (value == null) return '';
236
+ if (typeof value === 'object') {
237
+ // Leave a media result alone — the caller needs the parts, not a sentence about them.
238
+ if (Array.isArray(value.media)) return null;
239
+ if (typeof value.content === 'string') return value.content;
240
+ if (typeof value.error === 'string') return value.error;
241
+ if (typeof value.text === 'string') return value.text;
242
+ try { return JSON.stringify(value); } catch { return String(value); }
243
+ }
244
+ return String(value);
245
+ }
246
+
247
+
208
248
 
209
249
  export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
210
250
  You work inside a workspace directory using the tools provided.
@@ -280,9 +320,9 @@ export class Agent {
280
320
  // even if the filter would have allowed them. The catalog already excludes
281
321
  // them from every profile; this is the hard backstop that also covers the
282
322
  // `null` (= everything) case, so a subagent can never spawn another agent.
283
- const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
284
- ? tools.filter((t) => cfg.toolFilter.includes(t.name))
285
- : tools
323
+ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
324
+ ? combinedTools().filter((t) => cfg.toolFilter.includes(t.name))
325
+ : combinedTools()
286
326
  )
287
327
  .filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)))
288
328
  // AgentSwarm is gated behind swarm mode, like Agent is gated behind the user
@@ -420,9 +460,10 @@ export class Agent {
420
460
  const used = this.usedTokens();
421
461
  const maxSize = Math.max(1, Math.floor((used || (maxCtx || 512000) * 0.85) * keepRatio));
422
462
  const msgs = this.messages || [];
423
- // Never cut inside the LEADING system block. `msgs[0]` is the assembled system
424
- // prompt, and canSplitAfter() would happily allow a split right after it — which
425
- // would drop the prompt itself and leave the request with no instructions.
463
+ // How far the LEADING system block runs. A cut before `floor` would land inside it,
464
+ // splitting the assembled system prompt across the drop boundary. The block itself is
465
+ // carried over by the trim (renamed `leadingSystem` there), because a cut can only
466
+ // ever land at or after its end.
426
467
  let floor = 0;
427
468
  while (floor < msgs.length && msgs[floor] && msgs[floor].role === 'system') floor++;
428
469
  let recent = 1;
@@ -435,13 +476,66 @@ export class Agent {
435
476
  // call. It is also the memoised form, so repeating this walk after a large tool
436
477
  // result is a WeakMap hit rather than a re-measure.
437
478
  size += messageTokens(msgs[split], null);
438
- if (split > floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
479
+ // The guard the leading system block needs, and the reverse of the `split > floor`
480
+ // this used to test. A cut at `split` drops `[0..split-1]`, so `split >= floor`
481
+ // keeps the cut at or after the block's end and never inside it — which is what
482
+ // the block's comment above asks for, and what `split > floor` inverted.
483
+ if (split >= floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
439
484
  if (size >= maxSize && bestSplit !== undefined) break;
440
485
  }
441
486
  return bestSplit ?? 0;
442
487
  }
443
488
 
489
+ /**
490
+ * Trim the history at a legal cut and append the summary + continuation note.
491
+ *
492
+ * Extracted from `run()` so it can be tested against the REAL implementation. A test that
493
+ * re-implemented these four lines was green against a duplicated system prompt — the copy
494
+ * carried its own guard, which is the failure mode of testing a restatement.
495
+ *
496
+ * Returns `{ split, dropped }`.
497
+ *
498
+ * Shape: `[ ...lead, ...kept, summary, continuation ]`.
499
+ *
500
+ * The summary is a USER message wrapped in a `<system-reminder>` (kimi's shape), not a
501
+ * `system` one. Two reasons:
502
+ * * the TUI rebuilds the system prompt from scratch each turn and SKIPS every `system`
503
+ * entry in the stored history, so a system summary was dropped before the model saw it;
504
+ * * the turn-end save filters `system` out of the session, so it was not persisted either
505
+ * — history trimmed AND summary lost at both ends, which made "compaction" pure data loss.
506
+ * The continuation note goes LAST so the model's next move is "keep going" rather than
507
+ * "answer the summary".
508
+ */
509
+ applyCompactionSplit(maxCtx, summary) {
510
+ const split = this.computeCompactSplit(maxCtx);
511
+ const keep = this.messages.slice(split);
512
+ const dropped = this.messages.length - keep.length;
513
+ // The LEADING system block is carried over explicitly. It sits at the head of the array
514
+ // and the cut drops everything before `split`, so it would otherwise be taken by every
515
+ // compaction — leaving the request with no instructions, which is the loss the `floor`
516
+ // guard in computeCompactSplit exists to prevent.
517
+ let leadingSystem = 0;
518
+ while (leadingSystem < this.messages.length
519
+ && this.messages[leadingSystem] && this.messages[leadingSystem].role === 'system') leadingSystem++;
520
+ // Only what the cut actually REMOVED. `keep` is `slice(split)`, so at `split === 0` it
521
+ // still contains the whole array including the block — prepending the block then sends the
522
+ // system prompt twice, and `llm.js` concatenates every system message into the request, so
523
+ // it would stay doubled in every compacted request from then on.
524
+ const lead = split > 0 ? this.messages.slice(0, leadingSystem) : [];
525
+ const summaryText = summary
526
+ ? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
527
+ : `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
528
+ this.messages = [
529
+ ...lead,
530
+ ...keep,
531
+ { role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
532
+ { role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
533
+ ];
534
+ return { split, dropped };
535
+ }
536
+
444
537
  async run() {
538
+ // Clear any interrupt flag left from a previous (interrupted) turn. This runs at
445
539
  // Clear any interrupt flag left from a previous (interrupted) turn. This runs at
446
540
  // the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
447
541
  // never suppress auto-compaction on the next message. (The constructor also
@@ -655,29 +749,7 @@ export class Agent {
655
749
  break;
656
750
  }
657
751
  // NOW trim, at a split point the boundary rule allows.
658
- const split = this.computeCompactSplit(maxCtx);
659
- const keep = this.messages.slice(split);
660
- const droppedCount = this.messages.length - keep.length;
661
- // Shape: [ ...kept, summary, continuation ].
662
- //
663
- // The summary is a USER message wrapped in a <system-reminder> (kimi's
664
- // shape), not a `system` message. Two reasons:
665
- // * the TUI rebuilds the system prompt from scratch each turn and SKIPS
666
- // every `system` entry in the stored history, so a system summary was
667
- // dropped before the model ever saw it;
668
- // * the turn-end save filters `system` out of the session, so it was not
669
- // persisted either — history trimmed AND summary lost at both ends,
670
- // which made "compaction" pure data loss.
671
- // The continuation note goes LAST so the model's next move is "keep going"
672
- // rather than "answer the summary".
673
- const summaryText = summary
674
- ? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
675
- : `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
676
- this.messages = [
677
- ...keep,
678
- { role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
679
- { role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
680
- ];
752
+ const { split, dropped: droppedCount } = this.applyCompactionSplit(maxCtx, summary);
681
753
  // The array was REPLACED, so every count the ledger held describes messages
682
754
  // that no longer exist. `sync` would notice on its own (the first/last
683
755
  // identities no longer match), but resetting here states the intent at the
@@ -931,7 +1003,13 @@ export class Agent {
931
1003
  let _args = tc.args; let _skip = false;
932
1004
  try { const out = await runPatch('toolDispatch', { name: tc.name, args: tc.args, ctx: this.ctx }, (c) => c); if (out) { _args = out.args !== undefined ? out.args : tc.args; if (out.skip) _skip = true; } } catch (e) { console.error('[hncode-plugin] toolDispatch error:', e.message); }
933
1005
  if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
934
- else result = await tool.execute(_args, this.ctx);
1006
+ else {
1007
+ const raw = await tool.execute(_args, this.ctx);
1008
+ // `null` means the tool returned a structured MEDIA result: keep the object so the
1009
+ // image blocks can be built from it. Only a plain value is stringified.
1010
+ const text = toolResultText(raw);
1011
+ result = text === null ? raw : text;
1012
+ }
935
1013
  } catch (err) {
936
1014
  result = `Error running ${tc.name}: ${err.message}`;
937
1015
  ok = false;
@@ -126,6 +126,11 @@ export function renderCells(cells) {
126
126
  }
127
127
  out += c.ch;
128
128
  }
129
+ // Close the LAST run too. Without this the final style stayed open past the end of the
130
+ // row, and a caller that concatenates rows — `drawFrameBuffer` does, once per line —
131
+ // carried it into the next one: a card drawn over a dimmed or highlighted row tinted
132
+ // every line below it, and the row's own trailing SGR was simply lost.
133
+ if (cur !== null && cur !== '') out += RESET;
129
134
  return out;
130
135
  }
131
136
 
package/src/clipboard.js CHANGED
@@ -57,16 +57,34 @@ const ONESHOT_MS = 20000;
57
57
 
58
58
  function win() { return process.platform === 'win32'; }
59
59
 
60
- // ---------------------------------------------------------------- copy
60
+ /**
61
+ * Put text on the clipboard.
62
+ *
63
+ * WINDOWS: `Set-Clipboard` through PowerShell, NOT `clip.exe`.
64
+ *
65
+ * `clip.exe` takes UTF-16LE and needed a byte order mark to be read correctly — without it
66
+ * non-ASCII arrived as mojibake (`中文测试` came back as `-N噀Km諎`, measured). But the mark is then
67
+ * part of the clipboard text: every copy began with a zero-width U+FEFF that showed up as a stray
68
+ * invisible character wherever it was pasted. Measured over six samples, `Set-Clipboard` was better
69
+ * on every one:
70
+ *
71
+ * copied clip.exe + BOM Set-Clipboard
72
+ * hello U+FEFF hello hello
73
+ * 中文测试 U+FEFF 中文测试 中文测试
74
+ * line1\nline2 U+FEFF line1 CRLF line2 line1\nline2 (line endings untouched)
75
+ * emoji šŸŽ‰ ok U+FEFF emoji šŸŽ‰ ok emoji šŸŽ‰ ok
76
+ *
77
+ * It also needs no escaping: PowerShell receives a .NET string rather than a byte stream, so there
78
+ * is no console parser to satisfy — which is what the BOM and the quoting were working around.
79
+ *
80
+ * `clip.exe` stays as a fallback for a machine with no PowerShell at all, where a copy carrying a
81
+ * stray BOM beats no copy.
82
+ */
61
83
  export function copyText(text) {
62
84
  const s = String(text == null ? '' : text);
63
85
  if (!s) return false;
64
86
  if (win()) {
65
- // clip.exe takes UTF-16LE. (`cmd /c echo "…" | clip` mangled quotes, '%'
66
- // and newlines; plain UTF-8 input turned non-ASCII into mojibake.) Prefix the
67
- // UTF-16 byte order mark (FF FE) so clip.exe is never left to guess the endian
68
- // or treat input as ANSI — without it, non-ASCII on some Windows versions /
69
- // code pages came out mojibake.
87
+ if (setClipboardText(s)) return true;
70
88
  cp.execSync('clip', { input: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(s, 'utf16le')]), windowsHide: true, timeout: 5000 });
71
89
  return true;
72
90
  }
@@ -75,6 +93,24 @@ export function copyText(text) {
75
93
  return true;
76
94
  }
77
95
 
96
+ /** `Set-Clipboard` via PowerShell. False when no PowerShell can be run. */
97
+ function setClipboardText(s) {
98
+ // The text travels as base64 so nothing in it can end the script or be re-parsed. A quoted
99
+ // PowerShell literal would need every `'` doubled, and a newline or a backtick in the text would
100
+ // otherwise change what the script means.
101
+ const b64 = Buffer.from(s, 'utf16le').toString('base64');
102
+ const full = 'Add-Type -AssemblyName System.Windows.Forms; '
103
+ + '[System.Windows.Forms.Clipboard]::SetText('
104
+ + `[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('${b64}')))`;
105
+ for (const exe of [_psExe, 'powershell']) {
106
+ try {
107
+ cp.execFileSync(exe, ['-NoProfile', '-NonInteractive', '-Command', full], { timeout: 10000, windowsHide: true });
108
+ return true;
109
+ } catch { /* try the next exe */ }
110
+ }
111
+ return false;
112
+ }
113
+
78
114
  // Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
79
115
  // `powershell` (5.1) — measured 1.9s vs 11.5s for the same image probe — and the image
80
116
  // path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
@@ -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
@@ -306,8 +306,14 @@ function checkGit(workspace, cp) {
306
306
  const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
307
307
  out.push(check('git', 'ok', `${branch || '(detached)'} in ${top}`));
308
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.
309
313
  for (const key of ['user.name', 'user.email']) {
310
- if (!run(['config', '--get', key])) {
314
+ const value = run(['config', '--get', key]);
315
+ if (value) out.push(check(`git ${key}`, 'ok', value));
316
+ else {
311
317
  out.push(check(`git ${key}`, 'warn', 'not set',
312
318
  `Every commit fails without it. Run: git config --global ${key} "…"`));
313
319
  }
@@ -324,7 +330,6 @@ function checkGit(workspace, cp) {
324
330
  }
325
331
  }
326
332
  return out;
327
- return out;
328
333
  }
329
334
 
330
335
  /**
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();