@dreb/coding-agent 2.71.0 → 2.73.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 (41) hide show
  1. package/README.md +3 -3
  2. package/dist/core/agent-session.d.ts +37 -9
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +226 -149
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/thinking.d.ts +2 -0
  7. package/dist/core/thinking.d.ts.map +1 -1
  8. package/dist/core/thinking.js +20 -14
  9. package/dist/core/thinking.js.map +1 -1
  10. package/dist/core/tools/index.d.ts +1 -1
  11. package/dist/core/tools/index.d.ts.map +1 -1
  12. package/dist/core/tools/index.js +1 -1
  13. package/dist/core/tools/index.js.map +1 -1
  14. package/dist/core/tools/subagent.d.ts +77 -6
  15. package/dist/core/tools/subagent.d.ts.map +1 -1
  16. package/dist/core/tools/subagent.js +312 -64
  17. package/dist/core/tools/subagent.js.map +1 -1
  18. package/dist/main.d.ts.map +1 -1
  19. package/dist/main.js +6 -8
  20. package/dist/main.js.map +1 -1
  21. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  22. package/dist/modes/interactive/interactive-mode.js +9 -5
  23. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  24. package/dist/modes/rpc/rpc-client.d.ts +6 -0
  25. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  26. package/dist/modes/rpc/rpc-client.js +9 -0
  27. package/dist/modes/rpc/rpc-client.js.map +1 -1
  28. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  29. package/dist/modes/rpc/rpc-mode.js +7 -1
  30. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  31. package/dist/modes/rpc/rpc-types.d.ts +11 -2
  32. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  33. package/dist/modes/rpc/rpc-types.js.map +1 -1
  34. package/docs/agent-models.md +2 -2
  35. package/docs/dashboard.md +46 -3
  36. package/docs/json.md +2 -2
  37. package/docs/models.md +1 -1
  38. package/docs/providers.md +26 -0
  39. package/docs/rpc.md +25 -6
  40. package/docs/settings.md +1 -1
  41. package/package.json +1 -1
@@ -15,7 +15,7 @@
15
15
  import { copyFileSync, existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
16
16
  import { tmpdir } from "node:os";
17
17
  import { basename, dirname, join, resolve } from "node:path";
18
- import { isContextOverflow, isOutputLimitExhaustion, modelsAreEqual, resetApiProviders, supportsMax, supportsXhigh, } from "@dreb/ai";
18
+ import { clampThinkingLevel, getAvailableThinkingLevels, isContextOverflow, isOutputLimitExhaustion, modelsAreEqual, resetApiProviders, } from "@dreb/ai";
19
19
  import { getDocsPath, getSubagentSessionsDir } from "../config.js";
20
20
  import { theme } from "../modes/interactive/theme/theme.js";
21
21
  import { sleep } from "../utils/sleep.js";
@@ -41,7 +41,7 @@ import { DEFAULT_BG_PARENT_TURN_LIMIT, DEFAULT_MAX_CONCURRENT_SUBAGENTS, } from
41
41
  import { createSyntheticSourceInfo } from "./source-info.js";
42
42
  import { buildSystemPrompt } from "./system-prompt.js";
43
43
  import { resolveThinkingDisplay } from "./thinking.js";
44
- import { createAllToolDefinitions, createSubagentConcurrencyGate, discoverAgentTypes, getRunningBackgroundAgents, } from "./tools/index.js";
44
+ import { createAllToolDefinitions, createSubagentConcurrencyGate, discoverAgentTypes, formatAgentOutputBlock, getRunningBackgroundAgents, } from "./tools/index.js";
45
45
  import { expandSkillContent } from "./tools/skill.js";
46
46
  import { createToolDefinitionFromAgentTool, wrapToolDefinition } from "./tools/tool-definition-wrapper.js";
47
47
  // ============================================================================
@@ -67,12 +67,6 @@ export function parseSkillBlock(text) {
67
67
  // ============================================================================
68
68
  // Constants
69
69
  // ============================================================================
70
- /** Standard thinking levels */
71
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
72
- /** Thinking levels including xhigh (for supported models). */
73
- const THINKING_LEVELS_WITH_XHIGH = ["off", "minimal", "low", "medium", "high", "xhigh"];
74
- /** Complete ordered scale, including the native max tier for supported models. */
75
- const THINKING_LEVELS_WITH_MAX = [...THINKING_LEVELS_WITH_XHIGH, "max"];
76
70
  const MIN_PERFORMANCE_DURATION_MS = 10;
77
71
  // ============================================================================
78
72
  // AgentSession Class
@@ -90,6 +84,11 @@ export class AgentSession {
90
84
  _unsubscribeAgent;
91
85
  _eventListeners = [];
92
86
  _agentEventQueue = Promise.resolve();
87
+ /**
88
+ * Agent events queued on `_agentEventQueue` that haven't finished processing yet.
89
+ * Persistence happens there, so the session log lags agent state until this is 0.
90
+ */
91
+ _agentEventsInFlight = 0;
93
92
  /** Tracks pending steering messages for UI display. Removed when delivered. */
94
93
  _steeringMessages = [];
95
94
  /** Tracks pending follow-up messages for UI display. Removed when delivered. */
@@ -114,6 +113,14 @@ export class AgentSession {
114
113
  // Bash execution state
115
114
  _bashAbortController = undefined;
116
115
  _pendingBashMessages = [];
116
+ /**
117
+ * Background-agent cancellation notices that arrived while the parent was busy (see
118
+ * `_canDeliverCancellationNotices`). Appending them then could land a user message
119
+ * between an assistant tool call and its result, or ahead of messages not yet persisted.
120
+ * They are held until the parent is idle again, or are persisted to their session before
121
+ * it is replaced.
122
+ */
123
+ _pendingCancellationNotices = [];
117
124
  // Extension system
118
125
  _extensionRunner = undefined;
119
126
  _turnIndex = 0;
@@ -652,11 +659,19 @@ export class AgentSession {
652
659
  if (cancelled) {
653
660
  parts.push("This agent was cancelled by the user.");
654
661
  }
655
- if (!cancelled && (result.exitCode !== 0 || result.errorMessage)) {
662
+ const failed = !cancelled && (result.exitCode !== 0 || !!result.errorMessage);
663
+ if (failed) {
656
664
  parts.push(`Error: ${result.errorMessage || "unknown"}`);
657
665
  }
666
+ if (result.notes && result.notes.length > 0) {
667
+ parts.push(...result.notes);
668
+ }
658
669
  if (result.output) {
659
- parts.push(result.output);
670
+ // The agent's own words are delimited from the system lines around them. A chain's
671
+ // output is already a per-step report with each step's output delimited
672
+ // (formatSingleResult), so it is not wrapped again.
673
+ const isChain = result.steps !== undefined;
674
+ parts.push(isChain ? result.output : formatAgentOutputBlock(result.output, cancelled || failed));
660
675
  }
661
676
  if (result.sessionFile) {
662
677
  parts.push(`Session log: ${result.sessionFile}`);
@@ -680,13 +695,15 @@ export class AgentSession {
680
695
  timestamp: Date.now(),
681
696
  };
682
697
  if (cancelled) {
683
- // Cancelled by user (Esc) — add to context and render in chat,
684
- // but do NOT trigger a response (the user hit Esc to stop, not to ask a question)
698
+ // Cancelled by the user (single-agent stop, Esc, or the parent stop) — add to
699
+ // context and render in chat, but do NOT trigger a response: the user chose to
700
+ // stop this work, and waking the parent could relaunch it behind their back.
701
+ // While the parent is busy (streaming, still persisting the run, retrying, or
702
+ // compacting) the notice is deferred, so it can never split an assistant tool call
703
+ // from its result.
704
+ this._pendingCancellationNotices.push(message);
685
705
  try {
686
- this.agent.appendMessage(message);
687
- this.sessionManager.appendMessage(message);
688
- this._emit({ type: "message_start", message });
689
- this._emit({ type: "message_end", message });
706
+ this._flushPendingCancellationNotices();
690
707
  }
691
708
  catch (err) {
692
709
  log.warn(`[subagent] Failed to deliver cancellation message for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
@@ -702,6 +719,8 @@ export class AgentSession {
702
719
  this.agent.steer(message);
703
720
  }
704
721
  else {
722
+ // Earlier deferred cancellation notices precede this completion in context.
723
+ this._flushPendingCancellationNotices();
705
724
  // Fallback: if streaming started between the isStreaming check and this call, deliver as follow-up
706
725
  this.agent.prompt(message).catch((promptErr) => {
707
726
  log.warn(`[subagent] prompt() failed for background agent ${agentId}: ${promptErr instanceof Error ? promptErr.message : String(promptErr)}`);
@@ -720,7 +739,8 @@ export class AgentSession {
720
739
  type: "background_agent_end",
721
740
  agentId,
722
741
  agentType: result.agent,
723
- success: result.exitCode === 0,
742
+ success: !cancelled && result.exitCode === 0,
743
+ cancelled,
724
744
  model: result.model,
725
745
  thinking: result.thinking,
726
746
  steps: result.steps,
@@ -750,6 +770,7 @@ export class AgentSession {
750
770
  // _processAgentEvent, slow earlier queued events can delay agent_end processing
751
771
  // and waitForRetry() can miss the in-flight retry.
752
772
  this._createRetryPromiseForAgentEnd(event);
773
+ this._agentEventsInFlight++;
753
774
  this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEvent(event), () => {
754
775
  // Prior event failed — already warned by the .catch() below.
755
776
  // Swallow the old rejection and continue with the current event.
@@ -853,101 +874,119 @@ export class AgentSession {
853
874
  return undefined;
854
875
  }
855
876
  async _processAgentEvent(event) {
856
- // When a user message starts, check if it's from either queue and remove it BEFORE emitting
857
- // This ensures the UI sees the updated queue state
858
- if (event.type === "message_start" && event.message.role === "user") {
859
- this._overflowRecoveryAttempted = false;
860
- const messageText = this._getUserMessageText(event.message);
861
- if (messageText) {
862
- // Check steering queue first
863
- const steeringIndex = this._steeringMessages.findIndex((message) => message.text === messageText);
864
- if (steeringIndex !== -1) {
865
- this._steeringMessages.splice(steeringIndex, 1);
866
- }
867
- else {
868
- // Check follow-up queue
869
- const followUpIndex = this._followUpMessages.findIndex((message) => message.text === messageText);
870
- if (followUpIndex !== -1) {
871
- this._followUpMessages.splice(followUpIndex, 1);
877
+ // The finally runs inline: an extra async wrapper here would add microtask hops to every
878
+ // queued event and delay event delivery relative to prompt() resolution.
879
+ try {
880
+ // When a user message starts, check if it's from either queue and remove it BEFORE emitting
881
+ // This ensures the UI sees the updated queue state
882
+ if (event.type === "message_start" && event.message.role === "user") {
883
+ this._overflowRecoveryAttempted = false;
884
+ const messageText = this._getUserMessageText(event.message);
885
+ if (messageText) {
886
+ // Check steering queue first
887
+ const steeringIndex = this._steeringMessages.findIndex((message) => message.text === messageText);
888
+ if (steeringIndex !== -1) {
889
+ this._steeringMessages.splice(steeringIndex, 1);
890
+ }
891
+ else {
892
+ // Check follow-up queue
893
+ const followUpIndex = this._followUpMessages.findIndex((message) => message.text === messageText);
894
+ if (followUpIndex !== -1) {
895
+ this._followUpMessages.splice(followUpIndex, 1);
896
+ }
872
897
  }
873
898
  }
874
899
  }
875
- }
876
- // Emit to extensions first
877
- await this._emitExtensionEvent(event);
878
- // Notify all listeners
879
- this._emit(event);
880
- // Keep a small, mode-independent rolling context for the headless arbiter.
881
- if (event.type === "message_end") {
882
- this._dispatchArbiter.onMessageEnd(event.message);
883
- }
884
- else if (event.type === "tool_execution_end") {
885
- this._dispatchArbiter.onToolEnd({
886
- toolName: event.toolName,
887
- isError: event.isError,
888
- result: event.result,
889
- });
890
- }
891
- // Handle session persistence
892
- if (event.type === "message_end") {
893
- // Check if this is a custom message from extensions
894
- if (event.message.role === "custom") {
895
- // Persist as CustomMessageEntry
896
- this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
897
- }
898
- else if (event.message.role === "user" ||
899
- event.message.role === "assistant" ||
900
- event.message.role === "toolResult") {
901
- // Regular LLM message - persist as SessionMessageEntry
902
- this.sessionManager.appendMessage(event.message);
903
- }
904
- // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
905
- // Track assistant message for auto-compaction (checked on agent_end)
906
- if (event.message.role === "assistant") {
907
- this._lastAssistantMessage = event.message;
908
- const assistantMsg = event.message;
909
- if (assistantMsg.stopReason !== "error") {
910
- this._overflowRecoveryAttempted = false;
900
+ // Emit to extensions first
901
+ await this._emitExtensionEvent(event);
902
+ // Notify all listeners
903
+ this._emit(event);
904
+ // Keep a small, mode-independent rolling context for the headless arbiter.
905
+ if (event.type === "message_end") {
906
+ this._dispatchArbiter.onMessageEnd(event.message);
907
+ }
908
+ else if (event.type === "tool_execution_end") {
909
+ this._dispatchArbiter.onToolEnd({
910
+ toolName: event.toolName,
911
+ isError: event.isError,
912
+ result: event.result,
913
+ });
914
+ }
915
+ // Handle session persistence
916
+ if (event.type === "message_end") {
917
+ // Check if this is a custom message from extensions
918
+ if (event.message.role === "custom") {
919
+ // Persist as CustomMessageEntry
920
+ this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
911
921
  }
912
- // Reset retry counter immediately on successful assistant response
913
- // This prevents accumulation across multiple LLM calls within a turn
914
- if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
915
- this._emit({
916
- type: "auto_retry_end",
917
- success: true,
918
- attempt: this._retryAttempt,
919
- });
920
- this._retryAttempt = 0;
922
+ else if (event.message.role === "user" ||
923
+ event.message.role === "assistant" ||
924
+ event.message.role === "toolResult") {
925
+ // Regular LLM message - persist as SessionMessageEntry
926
+ this.sessionManager.appendMessage(event.message);
921
927
  }
922
- const durationMs = assistantMsg.durationMs ?? 0;
923
- if (assistantMsg.stopReason !== "error" &&
924
- assistantMsg.stopReason !== "aborted" &&
925
- assistantMsg.usage.output > 0 &&
926
- durationMs >= MIN_PERFORMANCE_DURATION_MS) {
927
- this.performanceTracker.record({
928
- timestamp: new Date().toISOString(),
929
- sessionId: this.sessionId,
930
- provider: assistantMsg.provider,
931
- modelId: assistantMsg.model,
932
- outputTokens: assistantMsg.usage.output,
933
- durationMs,
934
- tps: (assistantMsg.usage.output * 1000) / durationMs,
935
- });
928
+ // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
929
+ // Track assistant message for auto-compaction (checked on agent_end)
930
+ if (event.message.role === "assistant") {
931
+ this._lastAssistantMessage = event.message;
932
+ const assistantMsg = event.message;
933
+ if (assistantMsg.stopReason !== "error") {
934
+ this._overflowRecoveryAttempted = false;
935
+ }
936
+ // Reset retry counter immediately on successful assistant response
937
+ // This prevents accumulation across multiple LLM calls within a turn
938
+ if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
939
+ this._emit({
940
+ type: "auto_retry_end",
941
+ success: true,
942
+ attempt: this._retryAttempt,
943
+ });
944
+ this._retryAttempt = 0;
945
+ }
946
+ const durationMs = assistantMsg.durationMs ?? 0;
947
+ if (assistantMsg.stopReason !== "error" &&
948
+ assistantMsg.stopReason !== "aborted" &&
949
+ assistantMsg.usage.output > 0 &&
950
+ durationMs >= MIN_PERFORMANCE_DURATION_MS) {
951
+ this.performanceTracker.record({
952
+ timestamp: new Date().toISOString(),
953
+ sessionId: this.sessionId,
954
+ provider: assistantMsg.provider,
955
+ modelId: assistantMsg.model,
956
+ outputTokens: assistantMsg.usage.output,
957
+ durationMs,
958
+ tps: (assistantMsg.usage.output * 1000) / durationMs,
959
+ });
960
+ }
936
961
  }
937
962
  }
938
- }
939
- // Check auto-retry and auto-compaction after agent completes
940
- if (event.type === "agent_end" && this._lastAssistantMessage) {
941
- const msg = this._lastAssistantMessage;
942
- this._lastAssistantMessage = undefined;
943
- // Check for retryable errors first (overloaded, rate limit, server errors)
944
- if (this._isRetryableError(msg)) {
945
- const didRetry = await this._handleRetryableError(msg);
946
- if (didRetry)
947
- return; // Retry was initiated, don't proceed to compaction
963
+ // Check auto-retry and auto-compaction after agent completes
964
+ if (event.type === "agent_end") {
965
+ if (this._lastAssistantMessage) {
966
+ const msg = this._lastAssistantMessage;
967
+ this._lastAssistantMessage = undefined;
968
+ // Check for retryable errors first (overloaded, rate limit, server errors)
969
+ if (this._isRetryableError(msg)) {
970
+ const didRetry = await this._handleRetryableError(msg);
971
+ // Retry was initiated, don't proceed to compaction. Deferred cancellation
972
+ // notices stay held while the retry is pending (isRetrying) and are delivered
973
+ // after the retried run's agent_end.
974
+ if (didRetry)
975
+ return;
976
+ }
977
+ this._resolveRetry();
978
+ await this._checkCompaction(msg);
979
+ }
980
+ // Deferred cancellation notices are delivered in the finally below once this
981
+ // agent_end has finished processing (after any retry decision or compaction above).
948
982
  }
949
- this._resolveRetry();
950
- await this._checkCompaction(msg);
983
+ }
984
+ finally {
985
+ this._agentEventsInFlight--;
986
+ // This event is handled and persisted. If the parent is now idle, deliver cancellation
987
+ // notices deferred while it was busy. After a run's final agent_end, that places them
988
+ // after everything the run persisted.
989
+ this._flushPendingCancellationNotices();
951
990
  }
952
991
  }
953
992
  /** Resolve the pending retry promise */
@@ -1410,8 +1449,9 @@ export class AgentSession {
1410
1449
  }
1411
1450
  return;
1412
1451
  }
1413
- // Flush any pending bash messages before the new prompt
1452
+ // Flush any pending bash messages and cancellation notices before the new prompt
1414
1453
  this._flushPendingBashMessages();
1454
+ this._flushPendingCancellationNotices();
1415
1455
  // Validate model
1416
1456
  if (!this.model) {
1417
1457
  throw new Error("No model selected.\n\n" +
@@ -1775,6 +1815,7 @@ export class AgentSession {
1775
1815
  }
1776
1816
  this._disconnectFromAgent();
1777
1817
  await this.abort();
1818
+ await this._persistPendingCancellationNoticesBeforeSessionChange();
1778
1819
  this.agent.reset();
1779
1820
  this._dispatchArbiter.clearContext();
1780
1821
  this.sessionManager.newSession({ parentSession: options?.parentSession, cwd: this.cwd });
@@ -1949,14 +1990,16 @@ export class AgentSession {
1949
1990
  * Saves to session and settings only if the level actually changes.
1950
1991
  */
1951
1992
  setThinkingLevel(level) {
1952
- const availableLevels = this.getAvailableThinkingLevels();
1953
- const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
1993
+ const effectiveLevel = clampThinkingLevel(this.model, level);
1954
1994
  // Only persist if actually changing
1955
1995
  const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
1956
1996
  this.agent.setThinkingLevel(effectiveLevel);
1957
1997
  if (isChanging) {
1958
1998
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
1959
- if (this.supportsThinking() || effectiveLevel !== "off") {
1999
+ // A capability clamp (non-reasoning model, `off` on an always-thinking model,
2000
+ // unsupported xhigh/max) must not overwrite the saved preference, so the next
2001
+ // capable model can honor it again.
2002
+ if (this.supportsThinking() && effectiveLevel === level) {
1960
2003
  this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
1961
2004
  }
1962
2005
  }
@@ -1976,25 +2019,12 @@ export class AgentSession {
1976
2019
  return nextLevel;
1977
2020
  }
1978
2021
  /**
1979
- * Get available thinking levels for current model.
1980
- * The provider will clamp to what the specific model supports internally.
2022
+ * Get available thinking levels for current model, in ascending order.
2023
+ * `off` is omitted for models whose thinking cannot be turned off (Claude Opus 5.5+),
2024
+ * and `xhigh` / `max` are included independently per model capability.
1981
2025
  */
1982
2026
  getAvailableThinkingLevels() {
1983
- if (!this.supportsThinking())
1984
- return ["off"];
1985
- if (this.supportsMaxThinking())
1986
- return THINKING_LEVELS_WITH_MAX;
1987
- return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS;
1988
- }
1989
- /**
1990
- * Check if current model supports xhigh thinking level.
1991
- */
1992
- supportsXhighThinking() {
1993
- return this.model ? supportsXhigh(this.model) : false;
1994
- }
1995
- /** Check if the current model supports the native max thinking level. */
1996
- supportsMaxThinking() {
1997
- return this.model ? supportsMax(this.model) : false;
2027
+ return getAvailableThinkingLevels(this.model);
1998
2028
  }
1999
2029
  /**
2000
2030
  * Check if current model supports thinking/reasoning.
@@ -2006,29 +2036,17 @@ export class AgentSession {
2006
2036
  if (explicitLevel !== undefined) {
2007
2037
  return explicitLevel;
2008
2038
  }
2039
+ const savedLevel = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
2009
2040
  if (!this.supportsThinking()) {
2010
- return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
2041
+ return savedLevel;
2011
2042
  }
2012
- return this.thinkingLevel;
2013
- }
2014
- _clampThinkingLevel(level, availableLevels) {
2015
- const ordered = THINKING_LEVELS_WITH_MAX;
2016
- const available = new Set(availableLevels);
2017
- const requestedIndex = ordered.indexOf(level);
2018
- if (requestedIndex === -1) {
2019
- return availableLevels[0] ?? "off";
2020
- }
2021
- for (let i = requestedIndex; i < ordered.length; i++) {
2022
- const candidate = ordered[i];
2023
- if (available.has(candidate))
2024
- return candidate;
2025
- }
2026
- for (let i = requestedIndex - 1; i >= 0; i--) {
2027
- const candidate = ordered[i];
2028
- if (available.has(candidate))
2029
- return candidate;
2043
+ // When the current level is only the capability clamp of the saved preference
2044
+ // (e.g. saved `off` shown as `minimal` on Opus 5.5), carry the saved preference
2045
+ // to the next model instead of the clamped value.
2046
+ if (clampThinkingLevel(this.model, savedLevel) === this.thinkingLevel) {
2047
+ return savedLevel;
2030
2048
  }
2031
- return availableLevels[0] ?? "off";
2049
+ return this.thinkingLevel;
2032
2050
  }
2033
2051
  // =========================================================================
2034
2052
  // Queue Mode Management
@@ -2148,6 +2166,9 @@ export class AgentSession {
2148
2166
  finally {
2149
2167
  this._compactionAbortController = undefined;
2150
2168
  this._reconnectToAgent();
2169
+ // Cancellation notices that arrived while compacting were held back; deliver them
2170
+ // now that the compacted context is in place.
2171
+ this._flushPendingCancellationNotices();
2151
2172
  }
2152
2173
  }
2153
2174
  /**
@@ -3146,6 +3167,60 @@ export class AgentSession {
3146
3167
  get hasPendingBashMessages() {
3147
3168
  return this._pendingBashMessages.length > 0;
3148
3169
  }
3170
+ /**
3171
+ * Whether deferred cancellation notices can be appended now without landing inside
3172
+ * work that is still changing the transcript. Not while the agent streams, while
3173
+ * earlier agent events are still being processed or persisted (the session log would
3174
+ * get the notice ahead of them), while an auto-retry is pending (its backoff or the
3175
+ * retried run), or while compaction or branch summarization runs.
3176
+ */
3177
+ _canDeliverCancellationNotices() {
3178
+ return !this.agent.state.isStreaming && this._agentEventsInFlight === 0 && !this.isRetrying && !this.isCompacting;
3179
+ }
3180
+ /**
3181
+ * Append deferred background-agent cancellation notices to agent state and the session
3182
+ * log, in arrival order, and render them, if the parent is idle (see
3183
+ * `_canDeliverCancellationNotices`). Otherwise they stay queued. They are retried after
3184
+ * each processed agent event (`_processAgentEvent`), before the next prompt, and after manual compaction or
3185
+ * branch navigation. Never triggers a response.
3186
+ */
3187
+ _flushPendingCancellationNotices() {
3188
+ if (this._pendingCancellationNotices.length === 0 || !this._canDeliverCancellationNotices())
3189
+ return;
3190
+ this._appendPendingCancellationNotices();
3191
+ }
3192
+ _appendPendingCancellationNotices() {
3193
+ while (this._pendingCancellationNotices.length > 0) {
3194
+ const message = this._pendingCancellationNotices.shift();
3195
+ this.agent.appendMessage(message);
3196
+ this.sessionManager.appendMessage(message);
3197
+ this._emit({ type: "message_start", message });
3198
+ this._emit({ type: "message_end", message });
3199
+ }
3200
+ }
3201
+ /**
3202
+ * Persist cancellation notices still deferred for the current session before it is replaced
3203
+ * (new session, resume/switch, fork). The run they wait on will never end in this
3204
+ * session, so they are written now instead of being dropped. First every event that session
3205
+ * already produced is processed, so the notices land after the last message it persisted.
3206
+ *
3207
+ * Callers that abort the run first (new session, switch) get a normal append to agent state
3208
+ * and the log. A fork keeps the run going, so if it is still streaming the notices are only
3209
+ * written to the outgoing session's log. Live context is rebuilt from the fork, and must
3210
+ * not get a user message between a tool call and its result.
3211
+ */
3212
+ async _persistPendingCancellationNoticesBeforeSessionChange() {
3213
+ if (this._pendingCancellationNotices.length === 0)
3214
+ return;
3215
+ await this._agentEventQueue.catch(() => undefined);
3216
+ if (this.agent.state.isStreaming) {
3217
+ for (const message of this._pendingCancellationNotices.splice(0)) {
3218
+ this.sessionManager.appendMessage(message);
3219
+ }
3220
+ return;
3221
+ }
3222
+ this._appendPendingCancellationNotices();
3223
+ }
3149
3224
  /**
3150
3225
  * Flush pending bash messages to agent state and session.
3151
3226
  * Called after agent turn completes to maintain proper message ordering.
@@ -3196,6 +3271,7 @@ export class AgentSession {
3196
3271
  }
3197
3272
  this._disconnectFromAgent();
3198
3273
  await this.abort();
3274
+ await this._persistPendingCancellationNoticesBeforeSessionChange();
3199
3275
  this._steeringMessages = [];
3200
3276
  this._followUpMessages = [];
3201
3277
  this._pendingNextTurnMessages = [];
@@ -3229,10 +3305,7 @@ export class AgentSession {
3229
3305
  this.setThinkingLevel(sessionContext.thinkingLevel);
3230
3306
  }
3231
3307
  else {
3232
- const availableLevels = this.getAvailableThinkingLevels();
3233
- const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
3234
- ? defaultThinkingLevel
3235
- : this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
3308
+ const effectiveLevel = clampThinkingLevel(this.model, defaultThinkingLevel);
3236
3309
  this.agent.setThinkingLevel(effectiveLevel);
3237
3310
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
3238
3311
  }
@@ -3329,6 +3402,8 @@ export class AgentSession {
3329
3402
  }
3330
3403
  skipConversationRestore = result?.skipConversationRestore ?? false;
3331
3404
  }
3405
+ // Cancellation notices belong to the outgoing session: persist them there.
3406
+ await this._persistPendingCancellationNoticesBeforeSessionChange();
3332
3407
  // Clear pending messages (bound to old session state)
3333
3408
  this._pendingNextTurnMessages = [];
3334
3409
  branch(previousSessionFile);
@@ -3540,6 +3615,8 @@ export class AgentSession {
3540
3615
  }
3541
3616
  finally {
3542
3617
  this._branchSummaryAbortController = undefined;
3618
+ // Deliver cancellation notices held back while the branch summary was generated.
3619
+ this._flushPendingCancellationNotices();
3543
3620
  }
3544
3621
  }
3545
3622
  /**