@trenchwork/coder 1.5.6 → 1.5.8

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 (56) hide show
  1. package/dist/core/agent.d.ts.map +1 -1
  2. package/dist/core/agent.js +31 -9
  3. package/dist/core/agent.js.map +1 -1
  4. package/dist/core/contextManager.d.ts.map +1 -1
  5. package/dist/core/contextManager.js +27 -8
  6. package/dist/core/contextManager.js.map +1 -1
  7. package/dist/core/contextWindow.d.ts.map +1 -1
  8. package/dist/core/contextWindow.js +8 -5
  9. package/dist/core/contextWindow.js.map +1 -1
  10. package/dist/core/errorDisplay.d.ts +17 -0
  11. package/dist/core/errorDisplay.d.ts.map +1 -0
  12. package/dist/core/errorDisplay.js +47 -0
  13. package/dist/core/errorDisplay.js.map +1 -0
  14. package/dist/core/sessionStore.d.ts.map +1 -1
  15. package/dist/core/sessionStore.js +14 -2
  16. package/dist/core/sessionStore.js.map +1 -1
  17. package/dist/core/updateChecker.d.ts.map +1 -1
  18. package/dist/core/updateChecker.js +39 -0
  19. package/dist/core/updateChecker.js.map +1 -1
  20. package/dist/headless/interactiveShell.d.ts.map +1 -1
  21. package/dist/headless/interactiveShell.js +220 -138
  22. package/dist/headless/interactiveShell.js.map +1 -1
  23. package/dist/leanAgent.d.ts.map +1 -1
  24. package/dist/leanAgent.js +13 -3
  25. package/dist/leanAgent.js.map +1 -1
  26. package/dist/plugins/providers/deepseek/index.d.ts.map +1 -1
  27. package/dist/plugins/providers/deepseek/index.js +14 -2
  28. package/dist/plugins/providers/deepseek/index.js.map +1 -1
  29. package/dist/providers/openaiChatCompletionsProvider.d.ts +6 -0
  30. package/dist/providers/openaiChatCompletionsProvider.d.ts.map +1 -1
  31. package/dist/providers/openaiChatCompletionsProvider.js +42 -12
  32. package/dist/providers/openaiChatCompletionsProvider.js.map +1 -1
  33. package/dist/runtime/agentSession.d.ts.map +1 -1
  34. package/dist/runtime/agentSession.js +22 -8
  35. package/dist/runtime/agentSession.js.map +1 -1
  36. package/dist/ui/ink/App.d.ts.map +1 -1
  37. package/dist/ui/ink/App.js +10 -1
  38. package/dist/ui/ink/App.js.map +1 -1
  39. package/dist/ui/ink/ChatStatic.d.ts.map +1 -1
  40. package/dist/ui/ink/ChatStatic.js +4 -2
  41. package/dist/ui/ink/ChatStatic.js.map +1 -1
  42. package/dist/ui/ink/InkPromptController.d.ts +15 -0
  43. package/dist/ui/ink/InkPromptController.d.ts.map +1 -1
  44. package/dist/ui/ink/InkPromptController.js +92 -14
  45. package/dist/ui/ink/InkPromptController.js.map +1 -1
  46. package/dist/ui/ink/Menu.d.ts +5 -0
  47. package/dist/ui/ink/Menu.d.ts.map +1 -1
  48. package/dist/ui/ink/Menu.js +7 -3
  49. package/dist/ui/ink/Menu.js.map +1 -1
  50. package/dist/ui/ink/Prompt.d.ts +2 -0
  51. package/dist/ui/ink/Prompt.d.ts.map +1 -1
  52. package/dist/ui/ink/Prompt.js +23 -1
  53. package/dist/ui/ink/Prompt.js.map +1 -1
  54. package/dist/ui/ink/StatusLine.js +1 -1
  55. package/dist/ui/ink/StatusLine.js.map +1 -1
  56. package/package.json +1 -1
@@ -20,6 +20,7 @@ import { exec as childExec } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
21
  import chalk from 'chalk';
22
22
  import { getHITL, hitlEvents, setDecisionPresenter } from '../core/hitl.js';
23
+ import { formatErrorForDisplay } from '../core/errorDisplay.js';
23
24
  // Connector imports removed — CLI is local-only, no GitHub gate.
24
25
  // Stub functions (antiTermination removed)
25
26
  const initializeProtection = (_config) => { };
@@ -105,9 +106,21 @@ async function* iterateWithTimeout(iterator, timeoutMs, onTimeout) {
105
106
  result = await pending;
106
107
  }
107
108
  else {
108
- // Race between pending result and timeout
109
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve({ __timeout: true }), timeoutMs));
110
- result = await Promise.race([pending, timeoutPromise]);
109
+ // Race between pending result and timeout. The timer MUST be cleared
110
+ // once the race settles: Promise.race does not cancel losers, so the
111
+ // old discarded timer id left one live 10-minute timer PER CONSUMED
112
+ // EVENT — tens of thousands of armed timers (and ~15MB of pinned
113
+ // closures) per fast-streaming turn, holding the event loop open.
114
+ let timeoutId;
115
+ const timeoutPromise = new Promise((resolve) => {
116
+ timeoutId = setTimeout(() => resolve({ __timeout: true }), timeoutMs);
117
+ });
118
+ try {
119
+ result = await Promise.race([pending, timeoutPromise]);
120
+ }
121
+ finally {
122
+ clearTimeout(timeoutId);
123
+ }
111
124
  }
112
125
  if ('__timeout' in result) {
113
126
  onTimeout?.();
@@ -155,7 +168,10 @@ function welcomeBodyLines(input) {
155
168
  const body = [title, ''];
156
169
  const mode = input.keyMode ?? (input.hasApiKey ? 'own' : 'none');
157
170
  if (mode === 'own') {
158
- body.push(`${input.model} · ${input.provider}`, `Key: ${input.maskedKey} · /help for commands`);
171
+ // §7 shape: model + /help, then cwd. No provider chip (redundant with
172
+ // the model name) and no key material in chrome — Claude Code never
173
+ // surfaces credentials in the banner; /keys still shows the masked key.
174
+ body.push(`${input.model} · /help for commands`);
159
175
  }
160
176
  else {
161
177
  body.push('⚠ No DeepSeek API key configured', '', ' /key sk-… DeepSeek (required) · platform.deepseek.com', ' /key tvly-… Tavily web search (optional) · tavily.com');
@@ -190,7 +206,9 @@ function roundedBox(content, paint = (s) => s, border = (s) => s) {
190
206
  * WHICH lines appear.
191
207
  */
192
208
  export function composeWelcomeLines(input) {
193
- return ['', ...(input.updateLines ?? []), ...roundedBox(welcomeBodyLines(input)), ''];
209
+ // No trailing blank: ChatStatic already adds the §1 one-line gap before the
210
+ // next block — a built-in trailing blank made it a double gap.
211
+ return ['', ...(input.updateLines ?? []), ...roundedBox(welcomeBodyLines(input))];
194
212
  }
195
213
  /**
196
214
  * Run the fully interactive shell with rich UI.
@@ -285,6 +303,11 @@ class InteractiveShell {
285
303
  };
286
304
  pendingModelSwitch = null;
287
305
  currentResponseBuffer = '';
306
+ // What this turn already rendered as an 'error' — dedupes the event-vs-
307
+ // rejection double print of the same provider failure.
308
+ lastShownTurnError = null;
309
+ // One-time per session: real prompt_tokens exceeded the configured window.
310
+ warnedWindowDrift = false;
288
311
  // The turn's final assistant text, captured BEFORE currentResponseBuffer is
289
312
  // cleared on message.complete. The auto-continue refusal/completion/governor
290
313
  // reads run in the `finally`, AFTER that clear, so reading the buffer there saw
@@ -491,31 +514,25 @@ class InteractiveShell {
491
514
  return key.slice(0, 3) + '...' + key.slice(-3);
492
515
  return key.slice(0, 6) + '...' + key.slice(-4);
493
516
  };
494
- // Update check runs for everyone (no account required), with a hard
495
- // race-timeout so a slow registry never delays the banner.
496
- const updateLines = [];
497
- const updatePromise = Promise.race([
498
- checkForUpdates(version).catch(() => null),
499
- new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
500
- ]);
501
- // Resolve the update check BEFORE composing the welcome lines — the
502
- // previous order built welcomeLines with `...updateLines` (the array
503
- // was empty at that point) and only populated updateLines afterwards,
504
- // so the upgrade banner literally never rendered. Bug shipped before
505
- // the scoped-package rename made the check return wrong data anyway.
506
- const updateInfo = await updatePromise;
507
- if (updateInfo?.updateAvailable) {
517
+ // Update check: NEVER gates the banner. The old order awaited an
518
+ // `npm view` subprocess (a full network round trip, up to the 2s cap, on
519
+ // EVERY launch) before the welcome box — and before any queued startup
520
+ // prompt rendered. Now the banner renders immediately and the update
521
+ // offer arrives as a follow-up system line when the check resolves.
522
+ void checkForUpdates(version).then((updateInfo) => {
523
+ if (!updateInfo?.updateAvailable || this.shouldExit)
524
+ return;
508
525
  // Detect + OFFER (don't force) — the user applies it in-shell with
509
526
  // /update. Auto-installing on every startup ran `npm i -g` without
510
527
  // consent and could fail silently; making it user-initiated is clearer.
511
528
  this.pendingUpdate = updateInfo;
512
- updateLines.push(chalk.cyan(' ⬆ ') +
529
+ this.promptController?.getRenderer()?.addEvent('system', chalk.cyan('⬆ ') +
513
530
  chalk.dim('Update available: ') +
514
531
  chalk.yellow(`v${updateInfo.current}`) +
515
532
  chalk.dim(' → ') +
516
533
  chalk.green(`v${updateInfo.latest}`) +
517
534
  chalk.dim(' · type ') + chalk.hex('#ffd666')('/update') + chalk.dim(' to upgrade'));
518
- }
535
+ }).catch(() => { });
519
536
  // Clean, minimal welcome — a sparkle + the essentials in a rounded box,
520
537
  // mirroring Claude Code. The pure composeWelcomeLines() is the contract for
521
538
  // WHICH lines appear; here we draw the same box with brand colour.
@@ -533,7 +550,10 @@ class InteractiveShell {
533
550
  version: `v${version}`,
534
551
  });
535
552
  const boxed = roundedBox(body, (cell) => cell.replace('✻', starlight('✻')), (s) => wire(s));
536
- const welcomeContent = ['', ...updateLines, ...boxed, ''].join('\n');
553
+ // No leading/trailing '' sentinels: the banner string used to embed its
554
+ // own blank lines AND ChatStatic adds marginTop on the next block — a
555
+ // double-blank gap (§1 violation) around every banner.
556
+ const welcomeContent = boxed.join('\n');
537
557
  // Use renderer event system instead of direct stdout writes
538
558
  renderer.addEvent('banner', welcomeContent);
539
559
  // Update renderer meta with model info
@@ -808,12 +828,16 @@ class InteractiveShell {
808
828
  maxBuffer: 4 * 1024 * 1024,
809
829
  });
810
830
  const output = [out, stderr].filter(Boolean).join('').trim() || '(no output)';
811
- renderer?.addEvent('tool', `$ ${command}\n${output}`);
831
+ // §2/§3: header and result are separate blocks — one combined 'tool'
832
+ // event rendered the whole output bold as if it were the tool name.
833
+ renderer?.addEvent('tool', `$ ${command}`);
834
+ renderer?.addEvent('tool-result', formatToolResult('bash', output, { command }));
812
835
  }
813
836
  catch (error) {
814
837
  const err = error;
815
838
  const output = [err.stdout, err.stderr, err.message].filter(Boolean).join('\n').trim();
816
- renderer?.addEvent('error', `$ ${command}\n${output || 'command failed'}`);
839
+ renderer?.addEvent('tool', `$ ${command}`);
840
+ renderer?.addEvent('error', formatToolError(output || 'command failed'));
817
841
  }
818
842
  finally {
819
843
  this.promptController?.setStatusMessage(null);
@@ -1441,7 +1465,6 @@ class InteractiveShell {
1441
1465
  dim(`System prompt ~${formatTokenCount(usage.systemTokens)} · conversation ~${formatTokenCount(usage.conversationTokens)} · ${usage.messageCount} messages`),
1442
1466
  ];
1443
1467
  this.promptController.setInlinePanel(lines);
1444
- this.scheduleInlinePanelDismiss();
1445
1468
  }
1446
1469
  /** /cost — DeepSeek tokens + Tavily searches consumed (this install). */
1447
1470
  showUsage() {
@@ -1460,10 +1483,12 @@ class InteractiveShell {
1460
1483
  label('DeepSeek') + dim(`${ds(cumulative)} · this session ${ds(session)}`),
1461
1484
  label('Tavily') + dim(`${cumulative.tavilySearches} searches · this session ${session.tavilySearches}`),
1462
1485
  '',
1463
- dim(`Tavily free pool: ${TAVILY_MONTHLY_FREE.toLocaleString('en-US')}/mo + ${TAVILY_ONE_TIME_BONUS.toLocaleString('en-US')} one-time bonus (shared proxy; set your own key for unlimited).`),
1486
+ // Two ≤78-col lines the single-sentence version measured 97 cols and
1487
+ // word-wrapped onto an unindented row at the default 80-col terminal.
1488
+ dim(`Tavily free pool: ${TAVILY_MONTHLY_FREE.toLocaleString('en-US')}/mo + ${TAVILY_ONE_TIME_BONUS.toLocaleString('en-US')} one-time bonus (shared proxy).`),
1489
+ dim('Set your own key for unlimited: /key tvly-…'),
1464
1490
  ];
1465
1491
  this.promptController.setInlinePanel(lines);
1466
- this.scheduleInlinePanelDismiss();
1467
1492
  }
1468
1493
  /**
1469
1494
  * /diff — review every file the agent changed this run as a colored diff,
@@ -1509,7 +1534,6 @@ class InteractiveShell {
1509
1534
  ...panel.lines,
1510
1535
  ];
1511
1536
  this.promptController.setInlinePanel(lines);
1512
- this.scheduleInlinePanelDismiss();
1513
1537
  }
1514
1538
  /**
1515
1539
  * /rewind — restore the files changed this run. Two-step: bare `/rewind`
@@ -1578,7 +1602,6 @@ class InteractiveShell {
1578
1602
  dim('Shift+Tab cycles permission mode · Ctrl+D exits · ? for shortcuts'),
1579
1603
  ];
1580
1604
  this.promptController.setInlinePanel(lines);
1581
- this.scheduleInlinePanelDismiss();
1582
1605
  }
1583
1606
  showKeyboardShortcuts() {
1584
1607
  if (!this.promptController?.supportsInlinePanel()) {
@@ -1588,6 +1611,10 @@ class InteractiveShell {
1588
1611
  }
1589
1612
  const kb = (key) => chalk.hex('#ffd666')(key);
1590
1613
  const desc = (text) => chalk.dim(text);
1614
+ // Pad the PLAIN key text before colouring so the description column is
1615
+ // grid-aligned — hand-counted spaces inside coloured templates drifted
1616
+ // by 1-2 cols per row.
1617
+ const row = (keys, text) => ` ${kb(keys.padEnd(14))}${desc(text)}`;
1591
1618
  // Only shortcuts the Ink Prompt (src/ui/ink/Prompt.tsx) actually
1592
1619
  // implements are listed — advertising keys the input handler ignores
1593
1620
  // would be a deceptive panel (Glasswing transparency).
@@ -1595,52 +1622,35 @@ class InteractiveShell {
1595
1622
  chalk.bold.hex('#e8e9ed')('Keyboard Shortcuts') + chalk.dim(' (press any key to dismiss)'),
1596
1623
  '',
1597
1624
  chalk.hex('#64d2ff')('Navigation'),
1598
- ` ${kb('Ctrl+A')} / ${kb('Home')} ${desc('Move to start of line')}`,
1599
- ` ${kb('Ctrl+E')} / ${kb('End')} ${desc('Move to end of line')}`,
1600
- ` ${kb('←')} / ${kb('→')} ${desc('Move cursor')}`,
1601
- ` ${kb('↑')} / ${kb('↓')} ${desc('Prompt history (older / newer)')}`,
1602
- ` ${kb('Ctrl+R')} ${desc('Reverse-search prompt history')}`,
1625
+ row('Ctrl+A / Home', 'Move to start of line'),
1626
+ row('Ctrl+E / End', 'Move to end of line'),
1627
+ row('← / →', 'Move cursor'),
1628
+ row('↑ / ↓', 'Prompt history (older / newer)'),
1629
+ row('Ctrl+R', 'Reverse-search prompt history'),
1603
1630
  '',
1604
1631
  chalk.hex('#64d2ff')('Editing'),
1605
- ` ${kb('Ctrl+U')} ${desc('Delete to start of line')}`,
1606
- ` ${kb('Ctrl+W')} ${desc('Delete word backward')}`,
1607
- ` ${kb('Ctrl+K')} ${desc('Delete to end of line')}`,
1632
+ row('Ctrl+U', 'Delete to start of line'),
1633
+ row('Ctrl+W', 'Delete word backward'),
1634
+ row('Ctrl+K', 'Delete to end of line'),
1608
1635
  '',
1609
1636
  chalk.hex('#64d2ff')('Modes'),
1610
- ` ${kb('Shift+Tab')} ${desc('Cycle permission mode (default · accept edits · plan)')}`,
1611
- ` ${kb('Ctrl+O')} ${desc('Expand the last truncated tool result')}`,
1637
+ row('Shift+Tab', 'Cycle permission mode (default · accept edits · plan)'),
1638
+ row('Ctrl+O', 'Expand the last truncated tool result'),
1612
1639
  '',
1613
1640
  chalk.hex('#64d2ff')('Completion'),
1614
- ` ${kb('@')} ${desc('Autocomplete a file (↑/↓ · Tab/Enter); its content is inlined for the agent')}`,
1615
- ` ${kb('/')} ${desc('Autocomplete a command (↑/↓ · Tab to complete; Enter runs it)')}`,
1641
+ row('@', 'Autocomplete a file; its content is inlined for the agent'),
1642
+ row('/', 'Autocomplete a command (↑/↓ · Tab to complete; Enter runs it)'),
1616
1643
  '',
1617
1644
  chalk.hex('#64d2ff')('Control'),
1618
- ` ${kb('Ctrl+C')} ${desc('Clear input / interrupt')}`,
1619
- ` ${kb('Ctrl+D')} ${desc('Exit (when empty)')}`,
1645
+ row('Ctrl+C', 'Clear input / interrupt'),
1646
+ row('Ctrl+D', 'Exit (when empty)'),
1620
1647
  ];
1621
1648
  this.promptController.setInlinePanel(lines);
1622
- this.scheduleInlinePanelDismiss();
1623
- }
1624
- /**
1625
- * Auto-dismiss inline panel after timeout or on next input.
1626
- */
1627
- inlinePanelDismissTimer = null;
1628
- scheduleInlinePanelDismiss() {
1629
- // Clear any existing timer
1630
- if (this.inlinePanelDismissTimer) {
1631
- clearTimeout(this.inlinePanelDismissTimer);
1632
- }
1633
- // Auto-dismiss after 8 seconds
1634
- this.inlinePanelDismissTimer = setTimeout(() => {
1635
- this.promptController?.clearInlinePanel();
1636
- this.inlinePanelDismissTimer = null;
1637
- }, 8000);
1638
1649
  }
1650
+ // Panels dismiss on the next keypress (Prompt → onDismissPanel), never on a
1651
+ // timer: the old 8s auto-dismiss yanked /context and /help mid-read, which
1652
+ // Claude Code never does.
1639
1653
  dismissInlinePanel() {
1640
- if (this.inlinePanelDismissTimer) {
1641
- clearTimeout(this.inlinePanelDismissTimer);
1642
- this.inlinePanelDismissTimer = null;
1643
- }
1644
1654
  this.promptController?.clearInlinePanel();
1645
1655
  }
1646
1656
  handleSubmit(text) {
@@ -1699,7 +1709,15 @@ class InteractiveShell {
1699
1709
  renderer?.setQueuedPrompts(this.pendingPrompts.slice());
1700
1710
  return;
1701
1711
  }
1702
- void this.processPrompt(trimmed);
1712
+ void this.processPrompt(trimmed).catch((e) => {
1713
+ // processPrompt handles its own errors; this is the last net so a
1714
+ // rejection can't reach the global unhandledRejection handler (which
1715
+ // exits the CLI with code 1).
1716
+ try {
1717
+ this.promptController?.getRenderer()?.addEvent('error', formatErrorForDisplay(e instanceof Error ? e.message : String(e)));
1718
+ }
1719
+ catch { /* ignore */ }
1720
+ });
1703
1721
  }
1704
1722
  /**
1705
1723
  * Dequeue and run the next live follow-up, if any: commit its user line to
@@ -1754,6 +1772,10 @@ class InteractiveShell {
1754
1772
  // displayed above the chat box.
1755
1773
  }
1756
1774
  enterCriticalSection();
1775
+ // Per-turn dedupe latch for error display: a provider failure arrives
1776
+ // both as an 'error' event AND as the sink rejection thrown out of the
1777
+ // event loop — without the latch the same message printed twice.
1778
+ this.lastShownTurnError = null;
1757
1779
  this.isProcessing = true;
1758
1780
  this.currentResponseBuffer = '';
1759
1781
  this.finalResponseText = '';
@@ -1776,6 +1798,12 @@ class InteractiveShell {
1776
1798
  let reasoningTimedOut = false;
1777
1799
  let stepTimedOut = false;
1778
1800
  let hitlDepth = 0;
1801
+ // The `⏺ Tool(arg)` header most recently emitted into history, or null
1802
+ // once any other event rendered after it. With PARALLEL tools the
1803
+ // start/start/complete/complete interleave glued tool B's result under
1804
+ // tool A's header (§3); tool.complete re-emits its own header when it
1805
+ // isn't the last thing on screen.
1806
+ let lastToolHeaderEmitted = null;
1779
1807
  // Track total prompt processing time to prevent infinite loops
1780
1808
  const promptStartTime = Date.now();
1781
1809
  const TOTAL_PROMPT_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours max for entire prompt without meaningful content
@@ -1910,6 +1938,7 @@ class InteractiveShell {
1910
1938
  }
1911
1939
  }
1912
1940
  renderer.addEvent('response', '\n');
1941
+ lastToolHeaderEmitted = null; // prose rendered since the last header
1913
1942
  // Capture the authoritative final text BEFORE the buffer is cleared
1914
1943
  // (the finally's auto-continue reads run after this clear).
1915
1944
  this.finalResponseText = sourceText || this.finalResponseText;
@@ -1940,7 +1969,9 @@ class InteractiveShell {
1940
1969
  // (subagent.start/complete) are the visible surface instead of a
1941
1970
  // raw `parallel_agents({"tasks":…})` JSON dump.
1942
1971
  if (renderer && toolName !== 'parallel_agents') {
1943
- renderer.addEvent('tool', formatToolCall(toolName, args, this.workingDir));
1972
+ const header = formatToolCall(toolName, args, this.workingDir);
1973
+ renderer.addEvent('tool', header);
1974
+ lastToolHeaderEmitted = header;
1944
1975
  }
1945
1976
  this.promptController?.setStatusMessage(toolActivityLabel(toolName, args, this.workingDir));
1946
1977
  break;
@@ -1964,6 +1995,16 @@ class InteractiveShell {
1964
1995
  if (event.result && typeof event.result === 'string' && event.result.trim() && renderer) {
1965
1996
  const params = event.parameters;
1966
1997
  const summary = formatToolResult(event.toolName, event.result, params);
1998
+ // Pair the ⎿ result with ITS call: if another tool's header (or
1999
+ // any other event) rendered since this tool's start, re-emit
2000
+ // this call's header so the result lands under the right one.
2001
+ if (event.toolName !== 'parallel_agents') {
2002
+ const ownHeader = formatToolCall(event.toolName, params, this.workingDir);
2003
+ if (lastToolHeaderEmitted !== ownHeader) {
2004
+ renderer.addEvent('tool', ownHeader);
2005
+ }
2006
+ lastToolHeaderEmitted = null; // a result now sits below the header
2007
+ }
1967
2008
  renderer.addEvent('tool-result', summary);
1968
2009
  // Remember the full result so Ctrl+O can expand it — but only
1969
2010
  // when the summary actually truncated (the `(ctrl+o to expand)`
@@ -1983,11 +2024,18 @@ class InteractiveShell {
1983
2024
  if (renderer) {
1984
2025
  // Red ` ⎿ Error: …` line, mirroring a failed tool result.
1985
2026
  renderer.addEvent('error', formatToolError(event.error));
2027
+ lastToolHeaderEmitted = null;
1986
2028
  }
1987
2029
  break;
1988
2030
  case 'error':
1989
2031
  if (renderer) {
1990
- renderer.addEvent('error', event.error);
2032
+ // Compact display (no multi-KB HTML/JSON walls) + remember what
2033
+ // was shown so the catch below doesn't print it a second time —
2034
+ // the same failure also arrives as the sink rejection.
2035
+ const shown = formatErrorForDisplay(event.error);
2036
+ this.lastShownTurnError = shown;
2037
+ renderer.addEvent('error', shown);
2038
+ lastToolHeaderEmitted = null;
1991
2039
  }
1992
2040
  break;
1993
2041
  case 'usage': {
@@ -2005,6 +2053,17 @@ class InteractiveShell {
2005
2053
  this.lastInputTokens = contextTokens;
2006
2054
  }
2007
2055
  const windowTokens = getModelContextInfo(this.profileConfig.model).contextWindow;
2056
+ // Window-drift self-report: the provider's prompt_tokens is REAL
2057
+ // API data — if it ever exceeds the configured window, the static
2058
+ // context table is provably stale (this exact drift hid the
2059
+ // 131k-vs-1M bug; the meter clamps to 100% so nothing else
2060
+ // surfaces it). DeepSeek's /models returns no window metadata
2061
+ // (probed), so this is the only runtime verification available.
2062
+ if (!this.warnedWindowDrift &&
2063
+ typeof contextTokens === 'number' && contextTokens > windowTokens) {
2064
+ this.warnedWindowDrift = true;
2065
+ renderer?.addEvent('system', chalk.dim(`Note: the provider reports ${contextTokens.toLocaleString('en-US')} input tokens — more than the configured ${windowTokens.toLocaleString('en-US')}-token window for ${this.profileConfig.model}. The context table is likely stale; context % may be wrong.`));
2066
+ }
2008
2067
  this.promptController?.setMetaStatus({
2009
2068
  outputTokens: this.turnTokenMeter.current(),
2010
2069
  contextTokens,
@@ -2118,7 +2177,14 @@ class InteractiveShell {
2118
2177
  catch (error) {
2119
2178
  const message = error instanceof Error ? error.message : String(error);
2120
2179
  if (renderer) {
2121
- renderer.addEvent('error', message);
2180
+ // Same failure usually already arrived (and rendered) as the turn's
2181
+ // 'error' event — the sink queues the event BEFORE rejecting. Skip
2182
+ // the duplicate; render compactly when it really is new.
2183
+ const shown = formatErrorForDisplay(message);
2184
+ if (shown !== this.lastShownTurnError) {
2185
+ this.lastShownTurnError = shown;
2186
+ renderer.addEvent('error', shown);
2187
+ }
2122
2188
  }
2123
2189
  // Fallback: If we have reasoning content but no response was generated, synthesize one
2124
2190
  if (!episodeSuccess && shouldSynthesizeFromReasoning({ hasReceivedResponseContent, finalResponseText: this.finalResponseText, currentResponseBuffer: this.currentResponseBuffer, reasoningBuffer })) {
@@ -2185,80 +2251,96 @@ class InteractiveShell {
2185
2251
  // Process any queued follow-up — single source of truth (drainNextQueuedPrompt).
2186
2252
  // This takes priority over auto-continue: a user's explicit follow-up runs
2187
2253
  // before the loop decides the original task is "complete".
2188
- if (await this.drainNextQueuedPrompt()) {
2189
- // handled
2190
- }
2191
- else if (refusedTurn) {
2192
- // Refusal terminates the turn. Don't re-prompt the model the
2193
- // user's request is finished from the agent's side. Clear the
2194
- // stored "original prompt" so a stray Alt+G later doesn't pick
2195
- // up where this turn left off.
2196
- this.originalPromptForAutoContinue = null;
2197
- }
2198
- else if (!this.shouldExit && !this.userInterruptedRun) {
2199
- // Auto mode: keep running until user's prompt is fully completed.
2200
- // Skipped after a Ctrl+C interrupt so we don't immediately resume
2201
- // the work the user just cancelled.
2202
- const autoMode = this.promptController?.getAutoMode() ?? 'off';
2203
- if (autoMode !== 'off') {
2204
- // Check if original user prompt is fully completed
2205
- const detector = getTaskCompletionDetector();
2206
- const analysis = detector.analyzeCompletion(this.finalResponseText, toolsUsed);
2207
- // Record this turn with the governor (bounds the loop + detects a
2208
- // stall: the same tools/files/failure repeating with no new progress)
2209
- // and the failure registry (catches the same error recurring across
2210
- // NON-consecutive turns a thrash the stall check would miss).
2211
- this.autoGovernor.recordTurn({
2212
- toolsUsed,
2213
- filesModified,
2214
- failingSignal: detectFailingTestOrBuild(combinedTurnOutput),
2215
- });
2216
- this.failureRegistry.trackTurn(combinedTurnOutput);
2217
- const gov = this.autoGovernor.check();
2218
- const failureNudge = this.failureRegistry.nudge();
2219
- const todos = getCurrentTodos();
2220
- const pending = pendingTodos(todos);
2221
- if (gov.stop) {
2222
- // Yield to the user WITH state instead of thrashing forever.
2223
- const note = gov.reason === 'limit'
2224
- ? `Paused after ${gov.turn} auto-continue turns (turn limit).${pending.length ? ` ${pending.length} task${pending.length === 1 ? '' : 's'} still pending` : ''} — say "continue" to keep going.`
2225
- : `Paused: no new progress over the last few turns (same actions repeating).${pending.length ? ` ${pending.length} task${pending.length === 1 ? '' : 's'} pending` : ''} — tell me how to proceed.`;
2226
- this.promptController?.getRenderer()?.addEvent('system', chalk.dim(note));
2227
- this.promptController?.setStatusMessage(null);
2228
- this.originalPromptForAutoContinue = null;
2229
- }
2230
- else if (turnAdversarialFindings && this.adversarialCorrectionCount < MAX_ADVERSARIAL_CORRECTIONS) {
2231
- // The reviewer refuted this turn's draftre-run the FULL tool loop
2232
- // to actually fix the findings (not just show the caveat), bounded
2233
- // by the governor + this per-request cap.
2234
- this.adversarialCorrectionCount += 1;
2235
- this.promptController?.setStatusMessage('Addressing reviewer findings…');
2236
- await new Promise(resolve => setTimeout(resolve, 300));
2237
- await this.processPrompt(buildAdversarialCorrectionPrompt(turnAdversarialFindings));
2238
- }
2239
- else if (!analysis.isComplete || pending.length > 0) {
2240
- // Continue — but only stop when the LIVE PLAN is also clear: pending
2241
- // todos force a continue even if the response sounded "done".
2242
- this.promptController?.setStatusMessage('Continuing...');
2243
- await new Promise(resolve => setTimeout(resolve, 500));
2244
- // Prefer the plan's next task; fall back to the response heuristic.
2245
- const base = nextTodoPrompt(todos)
2246
- ?? this.generateAutoContinuePrompt(this.originalPromptForAutoContinue || '', combinedTurnOutput, toolsUsed)
2247
- ?? 'continue';
2248
- // When a failure keeps recurring, lead with the change-approach nudge.
2249
- // Keep an IMPORTANT: prefix so this counts as an auto-continue (not a
2250
- // fresh user prompt, which would reset the governor).
2251
- const autoPrompt = failureNudge
2252
- ? `IMPORTANT: ${failureNudge}\n\n${base.replace(/^IMPORTANT:\s*/, '')}`
2253
- : base;
2254
- await this.processPrompt(autoPrompt);
2255
- }
2256
- else {
2257
- this.promptController?.setStatusMessage('Task complete');
2258
- setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
2254
+ //
2255
+ // GUARDED: processPrompt is launched fire-and-forget (void) and the
2256
+ // global unhandledRejection handler exits the whole CLI with code 1 —
2257
+ // an exception anywhere in this post-turn pipeline (drain, completion
2258
+ // heuristics, governor, renderer calls) must degrade to an error line,
2259
+ // never kill the live session.
2260
+ try {
2261
+ if (await this.drainNextQueuedPrompt()) {
2262
+ // handled
2263
+ }
2264
+ else if (refusedTurn) {
2265
+ // Refusal terminates the turn. Don't re-prompt the model — the
2266
+ // user's request is finished from the agent's side. Clear the
2267
+ // stored "original prompt" so a stray Alt+G later doesn't pick
2268
+ // up where this turn left off.
2269
+ this.originalPromptForAutoContinue = null;
2270
+ }
2271
+ else if (!this.shouldExit && !this.userInterruptedRun) {
2272
+ // Auto mode: keep running until user's prompt is fully completed.
2273
+ // Skipped after a Ctrl+C interrupt so we don't immediately resume
2274
+ // the work the user just cancelled.
2275
+ const autoMode = this.promptController?.getAutoMode() ?? 'off';
2276
+ if (autoMode !== 'off') {
2277
+ // Check if original user prompt is fully completed
2278
+ const detector = getTaskCompletionDetector();
2279
+ const analysis = detector.analyzeCompletion(this.finalResponseText, toolsUsed);
2280
+ // Record this turn with the governor (bounds the loop + detects a
2281
+ // stall: the same tools/files/failure repeating with no new progress)
2282
+ // and the failure registry (catches the same error recurring across
2283
+ // NON-consecutive turns — a thrash the stall check would miss).
2284
+ this.autoGovernor.recordTurn({
2285
+ toolsUsed,
2286
+ filesModified,
2287
+ failingSignal: detectFailingTestOrBuild(combinedTurnOutput),
2288
+ });
2289
+ this.failureRegistry.trackTurn(combinedTurnOutput);
2290
+ const gov = this.autoGovernor.check();
2291
+ const failureNudge = this.failureRegistry.nudge();
2292
+ const todos = getCurrentTodos();
2293
+ const pending = pendingTodos(todos);
2294
+ if (gov.stop) {
2295
+ // Yield to the user WITH state instead of thrashing forever.
2296
+ const note = gov.reason === 'limit'
2297
+ ? `Paused after ${gov.turn} auto-continue turns (turn limit).${pending.length ? ` ${pending.length} task${pending.length === 1 ? '' : 's'} still pending` : ''} say "continue" to keep going.`
2298
+ : `Paused: no new progress over the last few turns (same actions repeating).${pending.length ? ` ${pending.length} task${pending.length === 1 ? '' : 's'} pending` : ''} — tell me how to proceed.`;
2299
+ this.promptController?.getRenderer()?.addEvent('system', chalk.dim(note));
2300
+ this.promptController?.setStatusMessage(null);
2301
+ this.originalPromptForAutoContinue = null;
2302
+ }
2303
+ else if (turnAdversarialFindings && this.adversarialCorrectionCount < MAX_ADVERSARIAL_CORRECTIONS) {
2304
+ // The reviewer refuted this turn's draft — re-run the FULL tool loop
2305
+ // to actually fix the findings (not just show the caveat), bounded
2306
+ // by the governor + this per-request cap.
2307
+ this.adversarialCorrectionCount += 1;
2308
+ this.promptController?.setStatusMessage('Addressing reviewer findings…');
2309
+ await new Promise(resolve => setTimeout(resolve, 300));
2310
+ await this.processPrompt(buildAdversarialCorrectionPrompt(turnAdversarialFindings));
2311
+ }
2312
+ else if (!analysis.isComplete || pending.length > 0) {
2313
+ // Continue — but only stop when the LIVE PLAN is also clear: pending
2314
+ // todos force a continue even if the response sounded "done".
2315
+ this.promptController?.setStatusMessage('Continuing...');
2316
+ await new Promise(resolve => setTimeout(resolve, 500));
2317
+ // Prefer the plan's next task; fall back to the response heuristic.
2318
+ const base = nextTodoPrompt(todos)
2319
+ ?? this.generateAutoContinuePrompt(this.originalPromptForAutoContinue || '', combinedTurnOutput, toolsUsed)
2320
+ ?? 'continue';
2321
+ // When a failure keeps recurring, lead with the change-approach nudge.
2322
+ // Keep an IMPORTANT: prefix so this counts as an auto-continue (not a
2323
+ // fresh user prompt, which would reset the governor).
2324
+ const autoPrompt = failureNudge
2325
+ ? `IMPORTANT: ${failureNudge}\n\n${base.replace(/^IMPORTANT:\s*/, '')}`
2326
+ : base;
2327
+ await this.processPrompt(autoPrompt);
2328
+ }
2329
+ else {
2330
+ this.promptController?.setStatusMessage('Task complete');
2331
+ setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
2332
+ }
2259
2333
  }
2260
2334
  }
2261
2335
  }
2336
+ catch (postTurnError) {
2337
+ const msg = postTurnError instanceof Error ? postTurnError.message : String(postTurnError);
2338
+ try {
2339
+ this.promptController?.getRenderer()?.addEvent('error', formatErrorForDisplay(msg));
2340
+ this.promptController?.setStatusMessage(null);
2341
+ }
2342
+ catch { /* renderer down — nothing more to do */ }
2343
+ }
2262
2344
  }
2263
2345
  }
2264
2346
  generateAutoContinuePrompt(originalPrompt, response, toolsUsed) {