@trenchwork/coder 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/contracts/v1/agent.d.ts +2 -0
  2. package/dist/contracts/v1/agent.d.ts.map +1 -1
  3. package/dist/core/agent.d.ts.map +1 -1
  4. package/dist/core/agent.js +77 -2
  5. package/dist/core/agent.js.map +1 -1
  6. package/dist/core/contextManager.d.ts +10 -137
  7. package/dist/core/contextManager.d.ts.map +1 -1
  8. package/dist/core/contextManager.js +74 -540
  9. package/dist/core/contextManager.js.map +1 -1
  10. package/dist/core/errorClassification.d.ts.map +1 -1
  11. package/dist/core/errorClassification.js +8 -0
  12. package/dist/core/errorClassification.js.map +1 -1
  13. package/dist/core/hooks.d.ts.map +1 -1
  14. package/dist/core/hooks.js +42 -19
  15. package/dist/core/hooks.js.map +1 -1
  16. package/dist/core/keyResolution.d.ts +30 -0
  17. package/dist/core/keyResolution.d.ts.map +1 -0
  18. package/dist/core/keyResolution.js +38 -0
  19. package/dist/core/keyResolution.js.map +1 -0
  20. package/dist/core/permissionMode.d.ts +17 -2
  21. package/dist/core/permissionMode.d.ts.map +1 -1
  22. package/dist/core/permissionMode.js +20 -7
  23. package/dist/core/permissionMode.js.map +1 -1
  24. package/dist/core/reasoningFallback.d.ts +22 -0
  25. package/dist/core/reasoningFallback.d.ts.map +1 -0
  26. package/dist/core/reasoningFallback.js +22 -0
  27. package/dist/core/reasoningFallback.js.map +1 -0
  28. package/dist/core/sessionStore.js +34 -8
  29. package/dist/core/sessionStore.js.map +1 -1
  30. package/dist/core/slashCommands.d.ts.map +1 -1
  31. package/dist/core/slashCommands.js +0 -3
  32. package/dist/core/slashCommands.js.map +1 -1
  33. package/dist/core/taskCompletionDetector.d.ts.map +1 -1
  34. package/dist/core/taskCompletionDetector.js +10 -3
  35. package/dist/core/taskCompletionDetector.js.map +1 -1
  36. package/dist/core/toolRuntime.d.ts +1 -0
  37. package/dist/core/toolRuntime.d.ts.map +1 -1
  38. package/dist/core/toolRuntime.js +21 -2
  39. package/dist/core/toolRuntime.js.map +1 -1
  40. package/dist/core/turnTokenMeter.d.ts +19 -0
  41. package/dist/core/turnTokenMeter.d.ts.map +1 -0
  42. package/dist/core/turnTokenMeter.js +36 -0
  43. package/dist/core/turnTokenMeter.js.map +1 -0
  44. package/dist/headless/interactiveShell.d.ts +3 -3
  45. package/dist/headless/interactiveShell.d.ts.map +1 -1
  46. package/dist/headless/interactiveShell.js +68 -115
  47. package/dist/headless/interactiveShell.js.map +1 -1
  48. package/dist/plugins/providers/deepseek/index.js +4 -6
  49. package/dist/plugins/providers/deepseek/index.js.map +1 -1
  50. package/dist/providers/openaiChatCompletionsProvider.d.ts.map +1 -1
  51. package/dist/providers/openaiChatCompletionsProvider.js +33 -26
  52. package/dist/providers/openaiChatCompletionsProvider.js.map +1 -1
  53. package/dist/runtime/agentController.d.ts.map +1 -1
  54. package/dist/runtime/agentController.js +16 -7
  55. package/dist/runtime/agentController.js.map +1 -1
  56. package/dist/runtime/agentSession.d.ts.map +1 -1
  57. package/dist/runtime/agentSession.js +10 -3
  58. package/dist/runtime/agentSession.js.map +1 -1
  59. package/dist/tools/bashTools.d.ts +63 -0
  60. package/dist/tools/bashTools.d.ts.map +1 -1
  61. package/dist/tools/bashTools.js +186 -77
  62. package/dist/tools/bashTools.js.map +1 -1
  63. package/dist/tools/grepTools.d.ts.map +1 -1
  64. package/dist/tools/grepTools.js +41 -23
  65. package/dist/tools/grepTools.js.map +1 -1
  66. package/dist/tools/searchTools.d.ts.map +1 -1
  67. package/dist/tools/searchTools.js +18 -8
  68. package/dist/tools/searchTools.js.map +1 -1
  69. package/dist/tools/webTools.d.ts.map +1 -1
  70. package/dist/tools/webTools.js +10 -2
  71. package/dist/tools/webTools.js.map +1 -1
  72. package/dist/ui/ink/App.d.ts +6 -1
  73. package/dist/ui/ink/App.d.ts.map +1 -1
  74. package/dist/ui/ink/App.js +20 -2
  75. package/dist/ui/ink/App.js.map +1 -1
  76. package/dist/ui/ink/ChatStatic.d.ts.map +1 -1
  77. package/dist/ui/ink/ChatStatic.js +9 -2
  78. package/dist/ui/ink/ChatStatic.js.map +1 -1
  79. package/dist/ui/ink/InkPromptController.d.ts +8 -8
  80. package/dist/ui/ink/InkPromptController.d.ts.map +1 -1
  81. package/dist/ui/ink/InkPromptController.js +19 -11
  82. package/dist/ui/ink/InkPromptController.js.map +1 -1
  83. package/dist/ui/ink/StatusLine.d.ts +6 -7
  84. package/dist/ui/ink/StatusLine.d.ts.map +1 -1
  85. package/dist/ui/ink/StatusLine.js +9 -9
  86. package/dist/ui/ink/StatusLine.js.map +1 -1
  87. package/dist/ui/ink/markdownRender.d.ts +2 -0
  88. package/dist/ui/ink/markdownRender.d.ts.map +1 -0
  89. package/dist/ui/ink/markdownRender.js +76 -0
  90. package/dist/ui/ink/markdownRender.js.map +1 -0
  91. package/package.json +2 -1
  92. package/dist/core/hostedAuth.d.ts +0 -88
  93. package/dist/core/hostedAuth.d.ts.map +0 -1
  94. package/dist/core/hostedAuth.js +0 -219
  95. package/dist/core/hostedAuth.js.map +0 -1
@@ -32,9 +32,10 @@ import { createAgentController } from '../runtime/agentController.js';
32
32
  import { expandFileMentions, listWorkspaceFiles } from '../core/fileMentions.js';
33
33
  import { resolveWorkspaceCaptureOptions, buildWorkspaceContext } from '../workspace.js';
34
34
  import { loadAllSecrets, listSecretDefinitions, setSecretValue, getSecretValue, getSecretDefinition, classifyKeyEntry } from '../core/secretStore.js';
35
- import { resolveKeyMode, keyModeLine, setPreferOwnKeys, clearHostedSession, loginViaLoopback } from '../core/hostedAuth.js';
35
+ import { resolveKeyMode, keyModeLine } from '../core/keyResolution.js';
36
36
  import { appendMemoryNote } from '../tools/memoryTools.js';
37
37
  import { recordDeepSeekUsage, getUsage, TAVILY_MONTHLY_FREE, TAVILY_ONE_TIME_BONUS } from '../core/usage.js';
38
+ import { TurnTokenMeter } from '../core/turnTokenMeter.js';
38
39
  import { listSessions, loadSessionById, saveSessionSnapshot } from '../core/sessionStore.js';
39
40
  import { relativeTime } from '../core/relativeTime.js';
40
41
  import { getModelContextInfo } from '../core/contextWindow.js';
@@ -59,6 +60,7 @@ import { startNewRun } from '../tools/fileChangeTracker.js';
59
60
  import { onSudoPasswordNeeded, offSudoPasswordNeeded, provideSudoPassword } from '../core/sudoPasswordManager.js';
60
61
  import { reportStatus, setStatusSink } from '../utils/statusReporter.js';
61
62
  import { isSafetyRefusal } from '../core/refusalDetection.js';
63
+ import { shouldSynthesizeFromReasoning } from '../core/reasoningFallback.js';
62
64
  import { formatToolCall, toolActivityLabel, formatToolResult, formatToolError } from '../shell/toolPresentation.js';
63
65
  // Tool-result display (ANSI stripping, summarisation, the `⎿` block) now lives
64
66
  // in ../shell/toolPresentation.ts — the shell just emits the formatted strings.
@@ -152,16 +154,11 @@ function welcomeBodyLines(input) {
152
154
  const title = input.version ? `✻ Welcome to Trenchwork Coder ${input.version}` : '✻ Welcome to Trenchwork Coder';
153
155
  const body = [title, ''];
154
156
  const mode = input.keyMode ?? (input.hasApiKey ? 'own' : 'none');
155
- if (mode === 'hosted') {
156
- // Signed in — running on hosted keys. The mode line names the account so
157
- // it's unmistakable this is NOT the user's own key.
158
- body.push(input.keyModeLine ?? 'Signed in · using hosted keys');
159
- }
160
- else if (mode === 'own') {
157
+ if (mode === 'own') {
161
158
  body.push(`${input.model} · ${input.provider}`, `Key: ${input.maskedKey} · /help for commands`);
162
159
  }
163
160
  else {
164
- body.push('⚠ No DeepSeek API key configured', '', '/login Sign in with Google for hosted keys', '', 'Or bring your own:', ' /key sk-… DeepSeek (required) · platform.deepseek.com', ' /key tvly-… Tavily web search (optional) · tavily.com');
161
+ body.push('⚠ No DeepSeek API key configured', '', ' /key sk-… DeepSeek (required) · platform.deepseek.com', ' /key tvly-… Tavily web search (optional) · tavily.com');
165
162
  }
166
163
  if (input.cwd)
167
164
  body.push(`cwd: ${input.cwd}`);
@@ -270,6 +267,9 @@ class InteractiveShell {
270
267
  // currently occupying the context window). Drives the accurate "% context
271
268
  // left" chrome indicator and the /context view.
272
269
  lastInputTokens = null;
270
+ // Live `↑ N tokens` source: estimates from streamed chars, snaps to the
271
+ // provider-exact count on each usage event; resets per user turn.
272
+ turnTokenMeter = new TurnTokenMeter();
273
273
  ctrlCCount = 0;
274
274
  lastCtrlCTime = 0;
275
275
  // Set when the user Ctrl+C interrupts a run; suppresses the auto-continue
@@ -457,16 +457,7 @@ class InteractiveShell {
457
457
  // even though the fake "run" had no controller.send). Pending items
458
458
  // will hit the normal early guard + error path, but the queue/dequeue
459
459
  // logic itself runs for the test assertions.
460
- if (this.pendingPrompts.length > 0 && !this.shouldExit) {
461
- const next = this.pendingPrompts.shift();
462
- if (next) {
463
- const r = this.promptController?.getRenderer();
464
- r?.setFollowUpQueueMode(false);
465
- r?.addUserHistoryItem(next);
466
- r?.setQueuedPrompts(this.pendingPrompts.slice());
467
- void this.processPrompt(next).catch(() => { });
468
- }
469
- }
460
+ void this.drainNextQueuedPrompt().catch(() => { });
470
461
  }, forceBusyMs);
471
462
  }
472
463
  // Process any queued prompts
@@ -840,6 +831,12 @@ class InteractiveShell {
840
831
  setSecretValue(entry.id, entry.value);
841
832
  const label = getSecretDefinition(entry.id)?.label ?? entry.id;
842
833
  renderer?.addEvent('system', chalk.green(`✓ ${label} saved`));
834
+ // Re-render the welcome banner so it reflects the now-saved DeepSeek
835
+ // key (masked key + model) instead of still showing "No DeepSeek API
836
+ // key configured". Tavily-only saves don't appear in the banner.
837
+ if (entry.id === 'DEEPSEEK_API_KEY') {
838
+ void this.showWelcome();
839
+ }
843
840
  }
844
841
  catch (error) {
845
842
  const msg = error instanceof Error ? error.message : String(error);
@@ -851,34 +848,6 @@ class InteractiveShell {
851
848
  }
852
849
  return true;
853
850
  }
854
- // /account — show the active key source (hosted vs your own) and switch
855
- // between them. `/account own` forces your own keys even while signed in;
856
- // `/account hosted` returns to hosted. Hosted keys come from sign-in
857
- // (server-side, never baked into this client) — see core/hostedAuth.ts.
858
- if (lower === '/account' || lower.startsWith('/account ')) {
859
- const r = this.promptController?.getRenderer();
860
- const arg = trimmed.slice('/account'.length).trim().toLowerCase();
861
- if (arg === 'own')
862
- setPreferOwnKeys(true);
863
- else if (arg === 'hosted')
864
- setPreferOwnKeys(false);
865
- if (arg === 'own' || arg === 'hosted')
866
- void this.showWelcome(); // banner reflects the switch
867
- r?.addEvent('system', this.accountStatusText(resolveKeyMode()));
868
- return true;
869
- }
870
- // /login — Google sign-in via ero.solar (loopback OAuth) to unlock hosted keys.
871
- if (lower === '/login' || lower === '/signin') {
872
- void this.handleLogin();
873
- return true;
874
- }
875
- // /logout — drop the hosted session (back to your own keys, or none).
876
- if (lower === '/logout' || lower === '/signout') {
877
- clearHostedSession();
878
- this.promptController?.getRenderer()?.addEvent('system', chalk.green('✓ Signed out — using your own keys.'));
879
- void this.showWelcome();
880
- return true;
881
- }
882
851
  // /update — check npm for a newer version and upgrade in-shell.
883
852
  if (lower === '/update' || lower === '/upgrade') {
884
853
  void this.handleUpdateCommand();
@@ -923,8 +892,7 @@ class InteractiveShell {
923
892
  return true;
924
893
  }
925
894
  // /cost — DeepSeek tokens + Tavily searches consumed (this session + all
926
- // time), and the hosted free-pool reference. Account-wide remaining is a
927
- // backend number shown in the ero.solar portal.
895
+ // time), and the Tavily shared-proxy free-pool reference.
928
896
  if (lower === '/cost' || lower === '/spend') {
929
897
  this.showUsage();
930
898
  return true;
@@ -1486,8 +1454,7 @@ class InteractiveShell {
1486
1454
  label('DeepSeek') + dim(`${ds(cumulative)} · this session ${ds(session)}`),
1487
1455
  label('Tavily') + dim(`${cumulative.tavilySearches} searches · this session ${session.tavilySearches}`),
1488
1456
  '',
1489
- dim(`Hosted free pool: Tavily ${TAVILY_MONTHLY_FREE.toLocaleString('en-US')}/mo + ${TAVILY_ONE_TIME_BONUS.toLocaleString('en-US')} one-time bonus.`),
1490
- dim('Account-wide totals + remaining show in the ero.solar portal after sign-in.'),
1457
+ 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).`),
1491
1458
  ];
1492
1459
  this.promptController.setInlinePanel(lines);
1493
1460
  this.scheduleInlinePanelDismiss();
@@ -1575,54 +1542,6 @@ class InteractiveShell {
1575
1542
  revertAllChanges(this.workingDir); // restores/deletes on disk + clears tracking
1576
1543
  renderer?.addEvent('system', chalk.green('✓ ' + rewindResultLine(restored, deleted)));
1577
1544
  }
1578
- /** One-line summary of the active key source for /account. */
1579
- accountStatusText(s) {
1580
- if (s.mode === 'hosted') {
1581
- return chalk.green(`Hosted keys · signed in as ${s.email}.`) +
1582
- chalk.dim(` /account own to use your own · /logout to sign out.`);
1583
- }
1584
- if (s.mode === 'own') {
1585
- return chalk.green(`Your own keys · DeepSeek${s.ownTavily ? ' + Tavily' : ''}.`) +
1586
- chalk.dim(s.signedIn ? ` /account hosted to use hosted keys.` : ` /login to use hosted keys.`);
1587
- }
1588
- return chalk.yellow('No keys configured.') +
1589
- chalk.dim(' /login for hosted keys, or set your own: /key sk-… (and /key tvly-…).');
1590
- }
1591
- /**
1592
- * /login — Google sign-in via ero.solar. Opens the browser to the SSO URL and
1593
- * runs a one-shot 127.0.0.1 loopback server that captures the redirect with
1594
- * the short-lived token (see core/hostedAuth.ts). On success the CLI is on
1595
- * hosted keys; no key ever touches this client.
1596
- */
1597
- async handleLogin() {
1598
- const r = this.promptController?.getRenderer();
1599
- const status = resolveKeyMode();
1600
- if (status.signedIn) {
1601
- r?.addEvent('system', chalk.green(`Already signed in as ${status.email}.`) +
1602
- chalk.dim(' /logout to sign out · /account to switch key source.'));
1603
- return;
1604
- }
1605
- r?.addEvent('system', chalk.dim('Opening ero.solar sign-in in your browser — finish there, then return here…'));
1606
- const result = await loginViaLoopback({ open: (url) => this.openInBrowser(url) });
1607
- if (result.ok && result.session) {
1608
- r?.addEvent('system', chalk.green(`✓ Signed in as ${result.session.email} — using hosted keys.`));
1609
- void this.showWelcome();
1610
- }
1611
- else {
1612
- r?.addEvent('system', chalk.yellow(`Sign-in didn't complete: ${result.error ?? 'unknown error'}.`) +
1613
- chalk.dim(' Retry /login, or use /key sk-… for your own key.'));
1614
- }
1615
- }
1616
- /** Best-effort open a URL in the OS browser; also prints it as a fallback. */
1617
- openInBrowser(url) {
1618
- const opener = process.platform === 'darwin' ? 'open'
1619
- : process.platform === 'win32' ? 'start ""'
1620
- : 'xdg-open';
1621
- // url is built by loginViaLoopback (no user input) and JSON-quoted, so the
1622
- // `&` in the query string can't break out of the argument.
1623
- childExec(`${opener} ${JSON.stringify(url)}`, () => { });
1624
- this.promptController?.getRenderer()?.addEvent('system', chalk.dim(`If the browser didn't open: ${url}`));
1625
- }
1626
1545
  showHelp() {
1627
1546
  if (!this.promptController?.supportsInlinePanel()) {
1628
1547
  this.promptController?.setStatusMessage('Help: /key sk-… (everything else is automatic)');
@@ -1637,10 +1556,8 @@ class InteractiveShell {
1637
1556
  const lines = [
1638
1557
  chalk.bold.hex('#e8e9ed')('Trenchwork Coder') + dim(' (press any key to dismiss)'),
1639
1558
  '',
1640
- cmd('/login') + dim(' Sign in with Google (ero.solar) to use hosted keys'),
1641
1559
  cmd('/key sk-…') + dim(' Set your DeepSeek API key (required)'),
1642
1560
  cmd('/key tvly-…') + dim(' Set your Tavily key for web search (optional)'),
1643
- cmd('/account') + dim(' Show / switch key source (hosted vs your own)'),
1644
1561
  cmd('/update') + dim(' Check npm and upgrade to the latest version'),
1645
1562
  cmd('/resume') + dim(' Restore a previous conversation'),
1646
1563
  cmd('/context') + dim(' Show context-window usage'),
@@ -1778,6 +1695,27 @@ class InteractiveShell {
1778
1695
  }
1779
1696
  void this.processPrompt(trimmed);
1780
1697
  }
1698
+ /**
1699
+ * Dequeue and run the next live follow-up, if any: commit its user line to
1700
+ * history, refresh the transient queue UI, then process it. Single source of
1701
+ * truth for the dequeue so the per-turn drain and the test seam can't drift —
1702
+ * that drift is exactly how a queued-prompt UX goes subtly wrong.
1703
+ */
1704
+ async drainNextQueuedPrompt() {
1705
+ if (this.pendingPrompts.length === 0 || this.shouldExit) {
1706
+ return false;
1707
+ }
1708
+ const next = this.pendingPrompts.shift();
1709
+ if (!next) {
1710
+ return false;
1711
+ }
1712
+ const r = this.promptController?.getRenderer();
1713
+ r?.setFollowUpQueueMode(false);
1714
+ r?.addUserHistoryItem(next);
1715
+ r?.setQueuedPrompts(this.pendingPrompts.slice());
1716
+ await this.processPrompt(next);
1717
+ return true;
1718
+ }
1781
1719
  async processPrompt(prompt) {
1782
1720
  if (this.isProcessing) {
1783
1721
  return;
@@ -1802,6 +1740,10 @@ class InteractiveShell {
1802
1740
  this.autoGovernor.reset();
1803
1741
  this.failureRegistry.reset();
1804
1742
  this.adversarialCorrectionCount = 0;
1743
+ // New user turn → `↑ N tokens` restarts from zero. Continuations
1744
+ // ('continue' / IMPORTANT:-prefixed) keep accumulating into the same turn.
1745
+ this.turnTokenMeter.reset();
1746
+ this.promptController?.setMetaStatus({ outputTokens: 0 });
1805
1747
  // Pinned-prompt persistence removed per request — no longer
1806
1748
  // displayed above the chat box.
1807
1749
  }
@@ -1886,6 +1828,14 @@ class InteractiveShell {
1886
1828
  // Stream content as it arrives
1887
1829
  this.currentResponseBuffer += event.content ?? '';
1888
1830
  this.finalResponseText += event.content ?? '';
1831
+ // Live `↑ N tokens`: estimate from streamed chars until the
1832
+ // provider's usage event snaps the exact count. Synthetic deltas
1833
+ // (already-streamed narration replays, retry notices) were never
1834
+ // provider output — metering them double-counts.
1835
+ if (!event.synthetic) {
1836
+ this.turnTokenMeter.addStreamedChars((event.content ?? '').length);
1837
+ this.promptController?.setMetaStatus({ outputTokens: this.turnTokenMeter.current() });
1838
+ }
1889
1839
  if (renderer) {
1890
1840
  renderer.addEvent('stream', event.content);
1891
1841
  }
@@ -1899,6 +1849,10 @@ class InteractiveShell {
1899
1849
  case 'reasoning':
1900
1850
  // Accumulate reasoning for potential fallback synthesis
1901
1851
  reasoningBuffer += event.content ?? '';
1852
+ // Reasoning streams count toward completion_tokens too (DeepSeek
1853
+ // thinking) — meter them so the live `↑` doesn't sit at zero.
1854
+ this.turnTokenMeter.addStreamedChars((event.content ?? '').length);
1855
+ this.promptController?.setMetaStatus({ outputTokens: this.turnTokenMeter.current() });
1902
1856
  // Update status to show reasoning is actively streaming
1903
1857
  this.promptController?.setActivityMessage('Thinking');
1904
1858
  // Start the reasoning timer on first reasoning event
@@ -2033,6 +1987,10 @@ class InteractiveShell {
2033
1987
  case 'usage': {
2034
1988
  // Meter cumulative DeepSeek consumption for /usage + the portal.
2035
1989
  recordDeepSeekUsage(event.inputTokens, event.outputTokens);
1990
+ // Snap the live `↑` estimate to the provider-exact output count
1991
+ // for this request; the meter keeps accumulating across the
1992
+ // turn's tool-loop requests.
1993
+ this.turnTokenMeter.recordExactOutput(event.outputTokens ?? 0);
2036
1994
  // inputTokens = exactly what occupies the context window this turn.
2037
1995
  // The real model window (not a hardcoded guess) is the denominator
2038
1996
  // so "% context left" reflects the actual model.
@@ -2042,7 +2000,8 @@ class InteractiveShell {
2042
2000
  }
2043
2001
  const windowTokens = getModelContextInfo(this.profileConfig.model).contextWindow;
2044
2002
  this.promptController?.setMetaStatus({
2045
- tokensUsed: contextTokens,
2003
+ outputTokens: this.turnTokenMeter.current(),
2004
+ contextTokens,
2046
2005
  tokenLimit: windowTokens,
2047
2006
  });
2048
2007
  break;
@@ -2134,7 +2093,7 @@ class InteractiveShell {
2134
2093
  // This handles models like deepseek-v4-pro that output thinking but empty response
2135
2094
  // Also handles step timeouts where the model was stuck
2136
2095
  // IMPORTANT: Don't add "Next steps" when only reasoning occurred - only after real work
2137
- if ((!episodeSuccess || reasoningTimedOut || stepTimedOut) && reasoningBuffer.trim() && !this.currentResponseBuffer.trim()) {
2096
+ if (shouldSynthesizeFromReasoning({ hasReceivedResponseContent, finalResponseText: this.finalResponseText, currentResponseBuffer: this.currentResponseBuffer, reasoningBuffer })) {
2138
2097
  const synthesized = this.synthesizeFromReasoning(reasoningBuffer);
2139
2098
  if (synthesized && renderer) {
2140
2099
  renderer.addEvent('stream', '\n' + synthesized);
@@ -2156,7 +2115,7 @@ class InteractiveShell {
2156
2115
  renderer.addEvent('error', message);
2157
2116
  }
2158
2117
  // Fallback: If we have reasoning content but no response was generated, synthesize one
2159
- if (!episodeSuccess && reasoningBuffer.trim() && !this.currentResponseBuffer.trim()) {
2118
+ if (!episodeSuccess && shouldSynthesizeFromReasoning({ hasReceivedResponseContent, finalResponseText: this.finalResponseText, currentResponseBuffer: this.currentResponseBuffer, reasoningBuffer })) {
2160
2119
  const synthesized = this.synthesizeFromReasoning(reasoningBuffer);
2161
2120
  if (synthesized && renderer) {
2162
2121
  renderer.addEvent('stream', '\n' + synthesized);
@@ -2169,7 +2128,7 @@ class InteractiveShell {
2169
2128
  // Exit critical section - allow termination again
2170
2129
  exitCriticalSection();
2171
2130
  // Final fallback: If stream ended without message.complete but we have reasoning
2172
- if (!episodeSuccess && reasoningBuffer.trim() && !this.currentResponseBuffer.trim()) {
2131
+ if (!episodeSuccess && shouldSynthesizeFromReasoning({ hasReceivedResponseContent, finalResponseText: this.finalResponseText, currentResponseBuffer: this.currentResponseBuffer, reasoningBuffer })) {
2173
2132
  const synthesized = this.synthesizeFromReasoning(reasoningBuffer);
2174
2133
  if (synthesized && renderer) {
2175
2134
  renderer.addEvent('stream', '\n' + synthesized);
@@ -2217,17 +2176,11 @@ class InteractiveShell {
2217
2176
  // Autosave the conversation so /resume has something to restore. Each
2218
2177
  // turn updates the same snapshot in place (keyed by this.sessionId).
2219
2178
  this.persistSessionSnapshot();
2220
- // Process any queued prompts (late safety net; primary drain is now per-turn
2221
- // after each assistant response for "ASAP before the running prompt finishes").
2222
- if (this.pendingPrompts.length > 0 && !this.shouldExit) {
2223
- const next = this.pendingPrompts.shift();
2224
- if (next) {
2225
- const r = this.promptController?.getRenderer();
2226
- r?.setFollowUpQueueMode(false);
2227
- r?.addUserHistoryItem(next);
2228
- r?.setQueuedPrompts(this.pendingPrompts.slice());
2229
- await this.processPrompt(next);
2230
- }
2179
+ // Process any queued follow-up single source of truth (drainNextQueuedPrompt).
2180
+ // This takes priority over auto-continue: a user's explicit follow-up runs
2181
+ // before the loop decides the original task is "complete".
2182
+ if (await this.drainNextQueuedPrompt()) {
2183
+ // handled
2231
2184
  }
2232
2185
  else if (refusedTurn) {
2233
2186
  // Refusal terminates the turn. Don't re-prompt the model — the