@trenchwork/coder 1.5.6 → 1.5.7

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 (50) 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/updateChecker.d.ts.map +1 -1
  15. package/dist/core/updateChecker.js +39 -0
  16. package/dist/core/updateChecker.js.map +1 -1
  17. package/dist/headless/interactiveShell.d.ts.map +1 -1
  18. package/dist/headless/interactiveShell.js +207 -138
  19. package/dist/headless/interactiveShell.js.map +1 -1
  20. package/dist/leanAgent.d.ts.map +1 -1
  21. package/dist/leanAgent.js +13 -3
  22. package/dist/leanAgent.js.map +1 -1
  23. package/dist/providers/openaiChatCompletionsProvider.d.ts +6 -0
  24. package/dist/providers/openaiChatCompletionsProvider.d.ts.map +1 -1
  25. package/dist/providers/openaiChatCompletionsProvider.js +31 -11
  26. package/dist/providers/openaiChatCompletionsProvider.js.map +1 -1
  27. package/dist/runtime/agentSession.d.ts.map +1 -1
  28. package/dist/runtime/agentSession.js +22 -8
  29. package/dist/runtime/agentSession.js.map +1 -1
  30. package/dist/ui/ink/App.d.ts.map +1 -1
  31. package/dist/ui/ink/App.js +10 -1
  32. package/dist/ui/ink/App.js.map +1 -1
  33. package/dist/ui/ink/ChatStatic.d.ts.map +1 -1
  34. package/dist/ui/ink/ChatStatic.js +4 -2
  35. package/dist/ui/ink/ChatStatic.js.map +1 -1
  36. package/dist/ui/ink/InkPromptController.d.ts +15 -0
  37. package/dist/ui/ink/InkPromptController.d.ts.map +1 -1
  38. package/dist/ui/ink/InkPromptController.js +92 -14
  39. package/dist/ui/ink/InkPromptController.js.map +1 -1
  40. package/dist/ui/ink/Menu.d.ts +5 -0
  41. package/dist/ui/ink/Menu.d.ts.map +1 -1
  42. package/dist/ui/ink/Menu.js +7 -3
  43. package/dist/ui/ink/Menu.js.map +1 -1
  44. package/dist/ui/ink/Prompt.d.ts +2 -0
  45. package/dist/ui/ink/Prompt.d.ts.map +1 -1
  46. package/dist/ui/ink/Prompt.js +23 -1
  47. package/dist/ui/ink/Prompt.js.map +1 -1
  48. package/dist/ui/ink/StatusLine.js +1 -1
  49. package/dist/ui/ink/StatusLine.js.map +1 -1
  50. 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,9 @@ 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;
288
309
  // The turn's final assistant text, captured BEFORE currentResponseBuffer is
289
310
  // cleared on message.complete. The auto-continue refusal/completion/governor
290
311
  // reads run in the `finally`, AFTER that clear, so reading the buffer there saw
@@ -491,31 +512,25 @@ class InteractiveShell {
491
512
  return key.slice(0, 3) + '...' + key.slice(-3);
492
513
  return key.slice(0, 6) + '...' + key.slice(-4);
493
514
  };
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) {
515
+ // Update check: NEVER gates the banner. The old order awaited an
516
+ // `npm view` subprocess (a full network round trip, up to the 2s cap, on
517
+ // EVERY launch) before the welcome box — and before any queued startup
518
+ // prompt rendered. Now the banner renders immediately and the update
519
+ // offer arrives as a follow-up system line when the check resolves.
520
+ void checkForUpdates(version).then((updateInfo) => {
521
+ if (!updateInfo?.updateAvailable || this.shouldExit)
522
+ return;
508
523
  // Detect + OFFER (don't force) — the user applies it in-shell with
509
524
  // /update. Auto-installing on every startup ran `npm i -g` without
510
525
  // consent and could fail silently; making it user-initiated is clearer.
511
526
  this.pendingUpdate = updateInfo;
512
- updateLines.push(chalk.cyan(' ⬆ ') +
527
+ this.promptController?.getRenderer()?.addEvent('system', chalk.cyan('⬆ ') +
513
528
  chalk.dim('Update available: ') +
514
529
  chalk.yellow(`v${updateInfo.current}`) +
515
530
  chalk.dim(' → ') +
516
531
  chalk.green(`v${updateInfo.latest}`) +
517
532
  chalk.dim(' · type ') + chalk.hex('#ffd666')('/update') + chalk.dim(' to upgrade'));
518
- }
533
+ }).catch(() => { });
519
534
  // Clean, minimal welcome — a sparkle + the essentials in a rounded box,
520
535
  // mirroring Claude Code. The pure composeWelcomeLines() is the contract for
521
536
  // WHICH lines appear; here we draw the same box with brand colour.
@@ -533,7 +548,10 @@ class InteractiveShell {
533
548
  version: `v${version}`,
534
549
  });
535
550
  const boxed = roundedBox(body, (cell) => cell.replace('✻', starlight('✻')), (s) => wire(s));
536
- const welcomeContent = ['', ...updateLines, ...boxed, ''].join('\n');
551
+ // No leading/trailing '' sentinels: the banner string used to embed its
552
+ // own blank lines AND ChatStatic adds marginTop on the next block — a
553
+ // double-blank gap (§1 violation) around every banner.
554
+ const welcomeContent = boxed.join('\n');
537
555
  // Use renderer event system instead of direct stdout writes
538
556
  renderer.addEvent('banner', welcomeContent);
539
557
  // Update renderer meta with model info
@@ -808,12 +826,16 @@ class InteractiveShell {
808
826
  maxBuffer: 4 * 1024 * 1024,
809
827
  });
810
828
  const output = [out, stderr].filter(Boolean).join('').trim() || '(no output)';
811
- renderer?.addEvent('tool', `$ ${command}\n${output}`);
829
+ // §2/§3: header and result are separate blocks — one combined 'tool'
830
+ // event rendered the whole output bold as if it were the tool name.
831
+ renderer?.addEvent('tool', `$ ${command}`);
832
+ renderer?.addEvent('tool-result', formatToolResult('bash', output, { command }));
812
833
  }
813
834
  catch (error) {
814
835
  const err = error;
815
836
  const output = [err.stdout, err.stderr, err.message].filter(Boolean).join('\n').trim();
816
- renderer?.addEvent('error', `$ ${command}\n${output || 'command failed'}`);
837
+ renderer?.addEvent('tool', `$ ${command}`);
838
+ renderer?.addEvent('error', formatToolError(output || 'command failed'));
817
839
  }
818
840
  finally {
819
841
  this.promptController?.setStatusMessage(null);
@@ -1441,7 +1463,6 @@ class InteractiveShell {
1441
1463
  dim(`System prompt ~${formatTokenCount(usage.systemTokens)} · conversation ~${formatTokenCount(usage.conversationTokens)} · ${usage.messageCount} messages`),
1442
1464
  ];
1443
1465
  this.promptController.setInlinePanel(lines);
1444
- this.scheduleInlinePanelDismiss();
1445
1466
  }
1446
1467
  /** /cost — DeepSeek tokens + Tavily searches consumed (this install). */
1447
1468
  showUsage() {
@@ -1460,10 +1481,12 @@ class InteractiveShell {
1460
1481
  label('DeepSeek') + dim(`${ds(cumulative)} · this session ${ds(session)}`),
1461
1482
  label('Tavily') + dim(`${cumulative.tavilySearches} searches · this session ${session.tavilySearches}`),
1462
1483
  '',
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).`),
1484
+ // Two ≤78-col lines the single-sentence version measured 97 cols and
1485
+ // word-wrapped onto an unindented row at the default 80-col terminal.
1486
+ dim(`Tavily free pool: ${TAVILY_MONTHLY_FREE.toLocaleString('en-US')}/mo + ${TAVILY_ONE_TIME_BONUS.toLocaleString('en-US')} one-time bonus (shared proxy).`),
1487
+ dim('Set your own key for unlimited: /key tvly-…'),
1464
1488
  ];
1465
1489
  this.promptController.setInlinePanel(lines);
1466
- this.scheduleInlinePanelDismiss();
1467
1490
  }
1468
1491
  /**
1469
1492
  * /diff — review every file the agent changed this run as a colored diff,
@@ -1509,7 +1532,6 @@ class InteractiveShell {
1509
1532
  ...panel.lines,
1510
1533
  ];
1511
1534
  this.promptController.setInlinePanel(lines);
1512
- this.scheduleInlinePanelDismiss();
1513
1535
  }
1514
1536
  /**
1515
1537
  * /rewind — restore the files changed this run. Two-step: bare `/rewind`
@@ -1578,7 +1600,6 @@ class InteractiveShell {
1578
1600
  dim('Shift+Tab cycles permission mode · Ctrl+D exits · ? for shortcuts'),
1579
1601
  ];
1580
1602
  this.promptController.setInlinePanel(lines);
1581
- this.scheduleInlinePanelDismiss();
1582
1603
  }
1583
1604
  showKeyboardShortcuts() {
1584
1605
  if (!this.promptController?.supportsInlinePanel()) {
@@ -1588,6 +1609,10 @@ class InteractiveShell {
1588
1609
  }
1589
1610
  const kb = (key) => chalk.hex('#ffd666')(key);
1590
1611
  const desc = (text) => chalk.dim(text);
1612
+ // Pad the PLAIN key text before colouring so the description column is
1613
+ // grid-aligned — hand-counted spaces inside coloured templates drifted
1614
+ // by 1-2 cols per row.
1615
+ const row = (keys, text) => ` ${kb(keys.padEnd(14))}${desc(text)}`;
1591
1616
  // Only shortcuts the Ink Prompt (src/ui/ink/Prompt.tsx) actually
1592
1617
  // implements are listed — advertising keys the input handler ignores
1593
1618
  // would be a deceptive panel (Glasswing transparency).
@@ -1595,52 +1620,35 @@ class InteractiveShell {
1595
1620
  chalk.bold.hex('#e8e9ed')('Keyboard Shortcuts') + chalk.dim(' (press any key to dismiss)'),
1596
1621
  '',
1597
1622
  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')}`,
1623
+ row('Ctrl+A / Home', 'Move to start of line'),
1624
+ row('Ctrl+E / End', 'Move to end of line'),
1625
+ row('← / →', 'Move cursor'),
1626
+ row('↑ / ↓', 'Prompt history (older / newer)'),
1627
+ row('Ctrl+R', 'Reverse-search prompt history'),
1603
1628
  '',
1604
1629
  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')}`,
1630
+ row('Ctrl+U', 'Delete to start of line'),
1631
+ row('Ctrl+W', 'Delete word backward'),
1632
+ row('Ctrl+K', 'Delete to end of line'),
1608
1633
  '',
1609
1634
  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')}`,
1635
+ row('Shift+Tab', 'Cycle permission mode (default · accept edits · plan)'),
1636
+ row('Ctrl+O', 'Expand the last truncated tool result'),
1612
1637
  '',
1613
1638
  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)')}`,
1639
+ row('@', 'Autocomplete a file; its content is inlined for the agent'),
1640
+ row('/', 'Autocomplete a command (↑/↓ · Tab to complete; Enter runs it)'),
1616
1641
  '',
1617
1642
  chalk.hex('#64d2ff')('Control'),
1618
- ` ${kb('Ctrl+C')} ${desc('Clear input / interrupt')}`,
1619
- ` ${kb('Ctrl+D')} ${desc('Exit (when empty)')}`,
1643
+ row('Ctrl+C', 'Clear input / interrupt'),
1644
+ row('Ctrl+D', 'Exit (when empty)'),
1620
1645
  ];
1621
1646
  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
1647
  }
1648
+ // Panels dismiss on the next keypress (Prompt → onDismissPanel), never on a
1649
+ // timer: the old 8s auto-dismiss yanked /context and /help mid-read, which
1650
+ // Claude Code never does.
1639
1651
  dismissInlinePanel() {
1640
- if (this.inlinePanelDismissTimer) {
1641
- clearTimeout(this.inlinePanelDismissTimer);
1642
- this.inlinePanelDismissTimer = null;
1643
- }
1644
1652
  this.promptController?.clearInlinePanel();
1645
1653
  }
1646
1654
  handleSubmit(text) {
@@ -1699,7 +1707,15 @@ class InteractiveShell {
1699
1707
  renderer?.setQueuedPrompts(this.pendingPrompts.slice());
1700
1708
  return;
1701
1709
  }
1702
- void this.processPrompt(trimmed);
1710
+ void this.processPrompt(trimmed).catch((e) => {
1711
+ // processPrompt handles its own errors; this is the last net so a
1712
+ // rejection can't reach the global unhandledRejection handler (which
1713
+ // exits the CLI with code 1).
1714
+ try {
1715
+ this.promptController?.getRenderer()?.addEvent('error', formatErrorForDisplay(e instanceof Error ? e.message : String(e)));
1716
+ }
1717
+ catch { /* ignore */ }
1718
+ });
1703
1719
  }
1704
1720
  /**
1705
1721
  * Dequeue and run the next live follow-up, if any: commit its user line to
@@ -1754,6 +1770,10 @@ class InteractiveShell {
1754
1770
  // displayed above the chat box.
1755
1771
  }
1756
1772
  enterCriticalSection();
1773
+ // Per-turn dedupe latch for error display: a provider failure arrives
1774
+ // both as an 'error' event AND as the sink rejection thrown out of the
1775
+ // event loop — without the latch the same message printed twice.
1776
+ this.lastShownTurnError = null;
1757
1777
  this.isProcessing = true;
1758
1778
  this.currentResponseBuffer = '';
1759
1779
  this.finalResponseText = '';
@@ -1776,6 +1796,12 @@ class InteractiveShell {
1776
1796
  let reasoningTimedOut = false;
1777
1797
  let stepTimedOut = false;
1778
1798
  let hitlDepth = 0;
1799
+ // The `⏺ Tool(arg)` header most recently emitted into history, or null
1800
+ // once any other event rendered after it. With PARALLEL tools the
1801
+ // start/start/complete/complete interleave glued tool B's result under
1802
+ // tool A's header (§3); tool.complete re-emits its own header when it
1803
+ // isn't the last thing on screen.
1804
+ let lastToolHeaderEmitted = null;
1779
1805
  // Track total prompt processing time to prevent infinite loops
1780
1806
  const promptStartTime = Date.now();
1781
1807
  const TOTAL_PROMPT_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours max for entire prompt without meaningful content
@@ -1910,6 +1936,7 @@ class InteractiveShell {
1910
1936
  }
1911
1937
  }
1912
1938
  renderer.addEvent('response', '\n');
1939
+ lastToolHeaderEmitted = null; // prose rendered since the last header
1913
1940
  // Capture the authoritative final text BEFORE the buffer is cleared
1914
1941
  // (the finally's auto-continue reads run after this clear).
1915
1942
  this.finalResponseText = sourceText || this.finalResponseText;
@@ -1940,7 +1967,9 @@ class InteractiveShell {
1940
1967
  // (subagent.start/complete) are the visible surface instead of a
1941
1968
  // raw `parallel_agents({"tasks":…})` JSON dump.
1942
1969
  if (renderer && toolName !== 'parallel_agents') {
1943
- renderer.addEvent('tool', formatToolCall(toolName, args, this.workingDir));
1970
+ const header = formatToolCall(toolName, args, this.workingDir);
1971
+ renderer.addEvent('tool', header);
1972
+ lastToolHeaderEmitted = header;
1944
1973
  }
1945
1974
  this.promptController?.setStatusMessage(toolActivityLabel(toolName, args, this.workingDir));
1946
1975
  break;
@@ -1964,6 +1993,16 @@ class InteractiveShell {
1964
1993
  if (event.result && typeof event.result === 'string' && event.result.trim() && renderer) {
1965
1994
  const params = event.parameters;
1966
1995
  const summary = formatToolResult(event.toolName, event.result, params);
1996
+ // Pair the ⎿ result with ITS call: if another tool's header (or
1997
+ // any other event) rendered since this tool's start, re-emit
1998
+ // this call's header so the result lands under the right one.
1999
+ if (event.toolName !== 'parallel_agents') {
2000
+ const ownHeader = formatToolCall(event.toolName, params, this.workingDir);
2001
+ if (lastToolHeaderEmitted !== ownHeader) {
2002
+ renderer.addEvent('tool', ownHeader);
2003
+ }
2004
+ lastToolHeaderEmitted = null; // a result now sits below the header
2005
+ }
1967
2006
  renderer.addEvent('tool-result', summary);
1968
2007
  // Remember the full result so Ctrl+O can expand it — but only
1969
2008
  // when the summary actually truncated (the `(ctrl+o to expand)`
@@ -1983,11 +2022,18 @@ class InteractiveShell {
1983
2022
  if (renderer) {
1984
2023
  // Red ` ⎿ Error: …` line, mirroring a failed tool result.
1985
2024
  renderer.addEvent('error', formatToolError(event.error));
2025
+ lastToolHeaderEmitted = null;
1986
2026
  }
1987
2027
  break;
1988
2028
  case 'error':
1989
2029
  if (renderer) {
1990
- renderer.addEvent('error', event.error);
2030
+ // Compact display (no multi-KB HTML/JSON walls) + remember what
2031
+ // was shown so the catch below doesn't print it a second time —
2032
+ // the same failure also arrives as the sink rejection.
2033
+ const shown = formatErrorForDisplay(event.error);
2034
+ this.lastShownTurnError = shown;
2035
+ renderer.addEvent('error', shown);
2036
+ lastToolHeaderEmitted = null;
1991
2037
  }
1992
2038
  break;
1993
2039
  case 'usage': {
@@ -2118,7 +2164,14 @@ class InteractiveShell {
2118
2164
  catch (error) {
2119
2165
  const message = error instanceof Error ? error.message : String(error);
2120
2166
  if (renderer) {
2121
- renderer.addEvent('error', message);
2167
+ // Same failure usually already arrived (and rendered) as the turn's
2168
+ // 'error' event — the sink queues the event BEFORE rejecting. Skip
2169
+ // the duplicate; render compactly when it really is new.
2170
+ const shown = formatErrorForDisplay(message);
2171
+ if (shown !== this.lastShownTurnError) {
2172
+ this.lastShownTurnError = shown;
2173
+ renderer.addEvent('error', shown);
2174
+ }
2122
2175
  }
2123
2176
  // Fallback: If we have reasoning content but no response was generated, synthesize one
2124
2177
  if (!episodeSuccess && shouldSynthesizeFromReasoning({ hasReceivedResponseContent, finalResponseText: this.finalResponseText, currentResponseBuffer: this.currentResponseBuffer, reasoningBuffer })) {
@@ -2185,80 +2238,96 @@ class InteractiveShell {
2185
2238
  // Process any queued follow-up — single source of truth (drainNextQueuedPrompt).
2186
2239
  // This takes priority over auto-continue: a user's explicit follow-up runs
2187
2240
  // 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);
2241
+ //
2242
+ // GUARDED: processPrompt is launched fire-and-forget (void) and the
2243
+ // global unhandledRejection handler exits the whole CLI with code 1 —
2244
+ // an exception anywhere in this post-turn pipeline (drain, completion
2245
+ // heuristics, governor, renderer calls) must degrade to an error line,
2246
+ // never kill the live session.
2247
+ try {
2248
+ if (await this.drainNextQueuedPrompt()) {
2249
+ // handled
2250
+ }
2251
+ else if (refusedTurn) {
2252
+ // Refusal terminates the turn. Don't re-prompt the model — the
2253
+ // user's request is finished from the agent's side. Clear the
2254
+ // stored "original prompt" so a stray Alt+G later doesn't pick
2255
+ // up where this turn left off.
2256
+ this.originalPromptForAutoContinue = null;
2257
+ }
2258
+ else if (!this.shouldExit && !this.userInterruptedRun) {
2259
+ // Auto mode: keep running until user's prompt is fully completed.
2260
+ // Skipped after a Ctrl+C interrupt so we don't immediately resume
2261
+ // the work the user just cancelled.
2262
+ const autoMode = this.promptController?.getAutoMode() ?? 'off';
2263
+ if (autoMode !== 'off') {
2264
+ // Check if original user prompt is fully completed
2265
+ const detector = getTaskCompletionDetector();
2266
+ const analysis = detector.analyzeCompletion(this.finalResponseText, toolsUsed);
2267
+ // Record this turn with the governor (bounds the loop + detects a
2268
+ // stall: the same tools/files/failure repeating with no new progress)
2269
+ // and the failure registry (catches the same error recurring across
2270
+ // NON-consecutive turns — a thrash the stall check would miss).
2271
+ this.autoGovernor.recordTurn({
2272
+ toolsUsed,
2273
+ filesModified,
2274
+ failingSignal: detectFailingTestOrBuild(combinedTurnOutput),
2275
+ });
2276
+ this.failureRegistry.trackTurn(combinedTurnOutput);
2277
+ const gov = this.autoGovernor.check();
2278
+ const failureNudge = this.failureRegistry.nudge();
2279
+ const todos = getCurrentTodos();
2280
+ const pending = pendingTodos(todos);
2281
+ if (gov.stop) {
2282
+ // Yield to the user WITH state instead of thrashing forever.
2283
+ const note = gov.reason === 'limit'
2284
+ ? `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.`
2285
+ : `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.`;
2286
+ this.promptController?.getRenderer()?.addEvent('system', chalk.dim(note));
2287
+ this.promptController?.setStatusMessage(null);
2288
+ this.originalPromptForAutoContinue = null;
2289
+ }
2290
+ else if (turnAdversarialFindings && this.adversarialCorrectionCount < MAX_ADVERSARIAL_CORRECTIONS) {
2291
+ // The reviewer refuted this turn's draft — re-run the FULL tool loop
2292
+ // to actually fix the findings (not just show the caveat), bounded
2293
+ // by the governor + this per-request cap.
2294
+ this.adversarialCorrectionCount += 1;
2295
+ this.promptController?.setStatusMessage('Addressing reviewer findings…');
2296
+ await new Promise(resolve => setTimeout(resolve, 300));
2297
+ await this.processPrompt(buildAdversarialCorrectionPrompt(turnAdversarialFindings));
2298
+ }
2299
+ else if (!analysis.isComplete || pending.length > 0) {
2300
+ // Continue — but only stop when the LIVE PLAN is also clear: pending
2301
+ // todos force a continue even if the response sounded "done".
2302
+ this.promptController?.setStatusMessage('Continuing...');
2303
+ await new Promise(resolve => setTimeout(resolve, 500));
2304
+ // Prefer the plan's next task; fall back to the response heuristic.
2305
+ const base = nextTodoPrompt(todos)
2306
+ ?? this.generateAutoContinuePrompt(this.originalPromptForAutoContinue || '', combinedTurnOutput, toolsUsed)
2307
+ ?? 'continue';
2308
+ // When a failure keeps recurring, lead with the change-approach nudge.
2309
+ // Keep an IMPORTANT: prefix so this counts as an auto-continue (not a
2310
+ // fresh user prompt, which would reset the governor).
2311
+ const autoPrompt = failureNudge
2312
+ ? `IMPORTANT: ${failureNudge}\n\n${base.replace(/^IMPORTANT:\s*/, '')}`
2313
+ : base;
2314
+ await this.processPrompt(autoPrompt);
2315
+ }
2316
+ else {
2317
+ this.promptController?.setStatusMessage('Task complete');
2318
+ setTimeout(() => this.promptController?.setStatusMessage(null), 2000);
2319
+ }
2259
2320
  }
2260
2321
  }
2261
2322
  }
2323
+ catch (postTurnError) {
2324
+ const msg = postTurnError instanceof Error ? postTurnError.message : String(postTurnError);
2325
+ try {
2326
+ this.promptController?.getRenderer()?.addEvent('error', formatErrorForDisplay(msg));
2327
+ this.promptController?.setStatusMessage(null);
2328
+ }
2329
+ catch { /* renderer down — nothing more to do */ }
2330
+ }
2262
2331
  }
2263
2332
  }
2264
2333
  generateAutoContinuePrompt(originalPrompt, response, toolsUsed) {