@sayknow-cli/coding-agent 0.5.2 → 0.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 (94) hide show
  1. package/CHANGELOG.md +66 -16
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  4. package/dist/types/config/keybindings.d.ts +10 -0
  5. package/dist/types/config/model-registry.d.ts +1 -1
  6. package/dist/types/config/models-config-schema.d.ts +4 -0
  7. package/dist/types/config/settings-schema.d.ts +73 -0
  8. package/dist/types/dap/client.d.ts +1 -0
  9. package/dist/types/lsp/client.d.ts +5 -0
  10. package/dist/types/modes/components/welcome.d.ts +1 -0
  11. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  12. package/dist/types/modes/interactive-mode.d.ts +13 -1
  13. package/dist/types/modes/types.d.ts +7 -1
  14. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  15. package/dist/types/session/agent-session.d.ts +8 -0
  16. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  17. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  18. package/dist/types/session/session-manager.d.ts +9 -0
  19. package/dist/types/session/session-storage.d.ts +17 -0
  20. package/dist/types/session-import/claude.d.ts +26 -0
  21. package/dist/types/session-import/codex.d.ts +22 -0
  22. package/dist/types/session-import/command.d.ts +20 -0
  23. package/dist/types/session-import/detect.d.ts +22 -0
  24. package/dist/types/session-import/index.d.ts +7 -0
  25. package/dist/types/session-import/redact.d.ts +23 -0
  26. package/dist/types/session-import/service.d.ts +28 -0
  27. package/dist/types/session-import/types.d.ts +125 -0
  28. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  29. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  30. package/dist/types/slash-commands/types.d.ts +5 -0
  31. package/dist/types/system-prompt.d.ts +5 -0
  32. package/dist/types/tools/ask.d.ts +5 -0
  33. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  34. package/package.json +13 -7
  35. package/scripts/compile-args.ts +8 -4
  36. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  37. package/src/cli/config-cli.ts +23 -8
  38. package/src/cli/update-cli.ts +265 -21
  39. package/src/config/atomic-yaml-patch.ts +167 -30
  40. package/src/config/keybindings.ts +10 -0
  41. package/src/config/model-profiles.ts +84 -16
  42. package/src/config/model-registry.ts +60 -38
  43. package/src/config/models-config-schema.ts +1 -1
  44. package/src/config/settings-schema.ts +79 -0
  45. package/src/config/settings.ts +13 -2
  46. package/src/dap/client.ts +78 -30
  47. package/src/internal-urls/docs-index.generated.ts +5 -4
  48. package/src/lsp/client.ts +17 -29
  49. package/src/modes/action-registry.ts +2 -0
  50. package/src/modes/components/welcome.ts +5 -0
  51. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  52. package/src/modes/controllers/input-controller.ts +71 -6
  53. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  54. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  55. package/src/modes/controllers/selector-controller.ts +47 -19
  56. package/src/modes/interactive-mode.ts +54 -4
  57. package/src/modes/types.ts +5 -1
  58. package/src/prompts/system/system-prompt.md +8 -0
  59. package/src/runtime-mcp/transports/stdio.ts +96 -33
  60. package/src/sdk/broker/lifecycle.ts +6 -5
  61. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  62. package/src/sdk/session.ts +27 -18
  63. package/src/session/agent-session.ts +155 -15
  64. package/src/session/internal/managed-session-scope.ts +41 -6
  65. package/src/session/internal/managed-session-storage.ts +14 -0
  66. package/src/session/session-manager.ts +113 -11
  67. package/src/session/session-storage.ts +70 -0
  68. package/src/session-import/claude.ts +382 -0
  69. package/src/session-import/codex.ts +458 -0
  70. package/src/session-import/command.ts +61 -0
  71. package/src/session-import/detect.ts +133 -0
  72. package/src/session-import/index.ts +27 -0
  73. package/src/session-import/redact.ts +125 -0
  74. package/src/session-import/service.ts +749 -0
  75. package/src/session-import/types.ts +163 -0
  76. package/src/skc-runtime/launch-worktree.ts +277 -4
  77. package/src/slash-commands/acp-builtins.ts +5 -1
  78. package/src/slash-commands/builtin-registry.ts +100 -1
  79. package/src/slash-commands/types.ts +5 -0
  80. package/src/system-prompt.ts +7 -0
  81. package/src/tools/ask.ts +5 -0
  82. package/src/tools/fetch.ts +7 -2
  83. package/src/utils/markit.ts +24 -2
  84. package/src/utils/mupdf-wasm.ts +26 -0
  85. package/src/export/html/template.css +0 -1060
  86. package/src/export/html/template.html +0 -47
  87. package/src/export/html/template.js +0 -2348
  88. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  89. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  90. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  91. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  92. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  93. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  94. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -42,10 +42,13 @@ import {
42
42
  } from "@sayknow-cli/agent-core";
43
43
  import { normalizeMessagesForProvider } from "@sayknow-cli/agent-core/agent-loop";
44
44
  import {
45
+ type AdaptiveCompactionOptions,
46
+ AdaptiveCompactionTracker,
45
47
  AUTO_HANDOFF_THRESHOLD_FOCUS,
46
48
  CompactionCancelledError,
47
49
  type CompactionPreparation,
48
50
  type CompactionResult,
51
+ type CompactionSettings as CoreCompactionSettings,
49
52
  calculateContextTokens,
50
53
  calculatePromptTokens,
51
54
  collectEntriesForBranchSummary,
@@ -545,6 +548,8 @@ export interface AgentSessionConfig {
545
548
  /** Rebuild the SSH tool from current capability discovery results. */
546
549
  reloadSshTool?: () => Promise<AgentTool | null>;
547
550
  requestedToolNames?: ReadonlySet<string>;
551
+ /** True only when the caller explicitly selected no tools. */
552
+ explicitlyDisabledTools?: boolean;
548
553
  /** Optional per-session allowlist for tools exposed through search_tool_bm25. */
549
554
  discoverableToolAllowedNames?: readonly string[];
550
555
  /** Optional accessor for live MCP server instructions, injected as untrusted user-role request data. */
@@ -1748,6 +1753,7 @@ export class AgentSession {
1748
1753
  // Handoff state
1749
1754
  #handoffAbortController: AbortController | undefined = undefined;
1750
1755
  #skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined;
1756
+ #suppressNextPostTurnMaintenanceAfterAutoHandoff = false;
1751
1757
 
1752
1758
  // Retry state
1753
1759
  #retryAbortController: AbortController | undefined = undefined;
@@ -1759,6 +1765,7 @@ export class AgentSession {
1759
1765
  #overflowMaintenanceAttempts = 0;
1760
1766
  #defaultFallbackExhaustedLastTurn = false;
1761
1767
  #fallbackInvocationId = 0;
1768
+ #fallbackSuppressedSelectors = new Set<string>();
1762
1769
  // Todo completion reminder state
1763
1770
  #todoReminderCount = 0;
1764
1771
  #todoWriteFailureCount = 0;
@@ -1853,6 +1860,7 @@ export class AgentSession {
1853
1860
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1854
1861
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
1855
1862
  #requestedToolNames: ReadonlySet<string> | undefined;
1863
+ #explicitlyDisabledTools: boolean;
1856
1864
  #baseSystemPrompt: string[];
1857
1865
  #initialWorkspaceTree: WorkspaceTree | undefined;
1858
1866
  /** Throttle cache for the per-turn volatile workspace-tree scan (see #buildVolatileProjectContextMessage). */
@@ -2043,6 +2051,12 @@ export class AgentSession {
2043
2051
  });
2044
2052
  }
2045
2053
 
2054
+ #sessionTransitionAdmissionBusyError(): AgentBusyError {
2055
+ return Object.assign(new AgentBusyError("Cannot start a turn while a session transition is in progress."), {
2056
+ code: "busy",
2057
+ });
2058
+ }
2059
+
2046
2060
  /**
2047
2061
  * Reject a turn start while a handoff transition owns the session. Handoff never
2048
2062
  * routes its own generation/injection through these turn-start chokepoints, and
@@ -2068,6 +2082,7 @@ export class AgentSession {
2068
2082
  * orchestrator does not hold it), so there is no self-deadlock.
2069
2083
  */
2070
2084
  #sessionTransitionKind: string | undefined;
2085
+ #sessionTurnAdmissionFenced = false;
2071
2086
 
2072
2087
  #beginSessionTransition(kind: string): void {
2073
2088
  if (this.#sessionTransitionKind !== undefined) {
@@ -2108,6 +2123,7 @@ export class AgentSession {
2108
2123
  code: "busy",
2109
2124
  });
2110
2125
  }
2126
+ if (this.#sessionTurnAdmissionFenced) throw this.#sessionTransitionAdmissionBusyError();
2111
2127
 
2112
2128
  const entry: SessionAdmissionEntry = {
2113
2129
  kind,
@@ -2136,6 +2152,13 @@ export class AgentSession {
2136
2152
  code: "busy",
2137
2153
  });
2138
2154
  }
2155
+ if (this.#sessionTurnAdmissionFenced) {
2156
+ entry.released = true;
2157
+ entry.settled.resolve();
2158
+ if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
2159
+ this.#activateNextSessionAdmission();
2160
+ throw this.#sessionTransitionAdmissionBusyError();
2161
+ }
2139
2162
 
2140
2163
  const release = () => {
2141
2164
  if (entry.released) return;
@@ -2372,6 +2395,7 @@ export class AgentSession {
2372
2395
  this.#toolRegistry = config.toolRegistry ?? new Map();
2373
2396
  this.#workflowGateToolSession = config.workflowGateToolSession;
2374
2397
  this.#requestedToolNames = config.requestedToolNames;
2398
+ this.#explicitlyDisabledTools = config.explicitlyDisabledTools === true;
2375
2399
  this.#transformContext = config.transformContext ?? (messages => messages);
2376
2400
  this.#onPayload = config.onPayload;
2377
2401
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
@@ -3779,8 +3803,9 @@ export class AgentSession {
3779
3803
  this.#resolveTtsrResume();
3780
3804
  }
3781
3805
  this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
3782
- if (this.#handoffAbortController) {
3806
+ if (this.#handoffAbortController || this.#suppressNextPostTurnMaintenanceAfterAutoHandoff) {
3783
3807
  this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
3808
+ this.#suppressNextPostTurnMaintenanceAfterAutoHandoff = false;
3784
3809
  }
3785
3810
  if (
3786
3811
  assistantMsg.stopReason !== "error" &&
@@ -5872,6 +5897,7 @@ export class AgentSession {
5872
5897
  }
5873
5898
 
5874
5899
  async activateDiscoveredTools(toolNames: string[]): Promise<string[]> {
5900
+ if (this.#explicitlyDisabledTools) return [];
5875
5901
  const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
5876
5902
  const previousSelectedDiscoveredBuiltinToolNames = this.#getSelectedDiscoveredBuiltinToolNames();
5877
5903
  const nextActiveToolNames = this.getActiveToolNames();
@@ -6099,7 +6125,9 @@ export class AgentSession {
6099
6125
  nextSelectedDiscoveredBuiltinToolNames?: string[];
6100
6126
  },
6101
6127
  ): Promise<void> {
6102
- toolNames = [...new Set([...toolNames.map(name => name.toLowerCase()), ...this.#mandatoryMCPToolNames])];
6128
+ toolNames = this.#explicitlyDisabledTools
6129
+ ? []
6130
+ : [...new Set([...toolNames.map(name => name.toLowerCase()), ...this.#mandatoryMCPToolNames])];
6103
6131
  const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
6104
6132
  const previousSelectedDiscoveredBuiltinToolNames =
6105
6133
  options?.previousSelectedDiscoveredBuiltinToolNames ?? this.#getSelectedDiscoveredBuiltinToolNames();
@@ -6209,6 +6237,11 @@ export class AgentSession {
6209
6237
  await this.#applyActiveToolsByName(nextActive);
6210
6238
  }
6211
6239
 
6240
+ /** Whether the caller explicitly selected no tools for this session. */
6241
+ hasExplicitlyDisabledTools(): boolean {
6242
+ return this.#explicitlyDisabledTools;
6243
+ }
6244
+
6212
6245
  /**
6213
6246
  * Set active tools by name.
6214
6247
  * Only tools in the registry can be enabled. Unknown tool names are ignored.
@@ -6220,6 +6253,11 @@ export class AgentSession {
6220
6253
  }
6221
6254
 
6222
6255
  async #restoreMCPSelectionsForSessionContext(sessionContext: SessionContext): Promise<void> {
6256
+ if (this.#explicitlyDisabledTools) {
6257
+ this.#selectedDiscoveredToolNames.clear();
6258
+ await this.#applyActiveToolsByName([], { persistMCPSelection: false });
6259
+ return;
6260
+ }
6223
6261
  if (!this.#mcpDiscoveryEnabled && this.#resolveEffectiveDiscoveryMode() !== "all") {
6224
6262
  await this.#attachAskToolIfWorkflowActive();
6225
6263
  return;
@@ -7073,6 +7111,7 @@ export class AgentSession {
7073
7111
  this.#toolRegistry.set(askTool.name, askTool);
7074
7112
  }
7075
7113
 
7114
+ if (this.#explicitlyDisabledTools) return;
7076
7115
  try {
7077
7116
  if ((this.#workflowGateEmitter?.listPendingGates?.().length ?? 0) > 0) {
7078
7117
  this.#attachAskTool();
@@ -7118,6 +7157,7 @@ export class AgentSession {
7118
7157
  }
7119
7158
 
7120
7159
  #attachAskTool(): void {
7160
+ if (this.#explicitlyDisabledTools) return;
7121
7161
  const askTool = this.#toolRegistry.get("ask");
7122
7162
  if (!askTool || this.getActiveToolNames().includes(askTool.name)) return;
7123
7163
  this.#setGuardedAgentTools([...this.agent.state.tools, askTool]);
@@ -7200,6 +7240,7 @@ export class AgentSession {
7200
7240
  }
7201
7241
 
7202
7242
  async #activatePendingSkcGoalModeRequest(): Promise<boolean> {
7243
+ if (this.#explicitlyDisabledTools) return false;
7203
7244
  if (!this.settings.get("goal.enabled")) return false;
7204
7245
  const pendingGoal = await consumePendingGoalModeRequest(
7205
7246
  this.sessionManager.getCwd(),
@@ -7212,9 +7253,10 @@ export class AgentSession {
7212
7253
  }
7213
7254
 
7214
7255
  const previousTools = this.getActiveToolNames();
7215
- const goalTools = [...new Set([...previousTools, "goal"])];
7216
7256
  await this.#goalRuntime.createGoal({ objective: pendingGoal.objective, provenance: pendingGoal.provenance });
7217
- await this.setActiveToolsByName(goalTools);
7257
+ if (!this.#explicitlyDisabledTools) {
7258
+ await this.setActiveToolsByName([...new Set([...previousTools, "goal"])]);
7259
+ }
7218
7260
  if (this.isStreaming) {
7219
7261
  await this.sendGoalModeContext({ deliverAs: "steer" });
7220
7262
  }
@@ -9472,7 +9514,6 @@ export class AgentSession {
9472
9514
  persistMCPSelection: false,
9473
9515
  nextSelectedDiscoveredBuiltinToolNames: [],
9474
9516
  });
9475
- } else {
9476
9517
  }
9477
9518
  }
9478
9519
  this.#todoReminderCount = 0;
@@ -10066,7 +10107,13 @@ export class AgentSession {
10066
10107
  });
10067
10108
  if (!resolved.model) continue;
10068
10109
 
10069
- if (roleModels.some(candidate => modelsAreEqual(candidate.model, resolved.model))) continue;
10110
+ if (
10111
+ roleModels.some(
10112
+ candidate =>
10113
+ modelsAreEqual(candidate.model, resolved.model) && candidate.thinkingLevel === resolved.thinkingLevel,
10114
+ )
10115
+ )
10116
+ continue;
10070
10117
  roleModels.push({
10071
10118
  role,
10072
10119
  model: resolved.model,
@@ -10726,6 +10773,43 @@ export class AgentSession {
10726
10773
  return 0;
10727
10774
  }
10728
10775
 
10776
+ /**
10777
+ * Call-rate tracker backing opt-in adaptive compaction. Always maintained so
10778
+ * enabling the setting mid-session has immediate, honest state; the recorded
10779
+ * rate only reaches a threshold decision while `compaction.adaptive.enabled`
10780
+ * is true.
10781
+ */
10782
+ #adaptiveCompaction = new AdaptiveCompactionTracker();
10783
+
10784
+ #adaptiveCompactionOptions(): AdaptiveCompactionOptions {
10785
+ return {
10786
+ enabled: this.settings.get("compaction.adaptive.enabled"),
10787
+ baseThresholdPercent: this.settings.get("compaction.adaptive.baseThresholdPercent"),
10788
+ aggression: this.settings.get("compaction.adaptive.aggression"),
10789
+ turnWindow: this.settings.get("compaction.adaptive.turnWindow"),
10790
+ minThresholdPercent: this.settings.get("compaction.adaptive.minThresholdPercent"),
10791
+ };
10792
+ }
10793
+
10794
+ /**
10795
+ * Compaction settings for a threshold decision.
10796
+ *
10797
+ * `getGroup("compaction")` flattens `compaction.adaptive.*` into dotted keys,
10798
+ * so the nested options object is assembled explicitly here. The tracker
10799
+ * window follows the configured minutes so a settings change takes effect
10800
+ * without restarting the session.
10801
+ */
10802
+ #compactionSettingsWithAdaptive(): CoreCompactionSettings {
10803
+ const adaptive = this.#adaptiveCompactionOptions();
10804
+ const windowMinutes = Number.isFinite(adaptive.turnWindow) ? Math.max(1, adaptive.turnWindow) : 15;
10805
+ this.#adaptiveCompaction.setWindowMs(windowMinutes * 60_000);
10806
+ return {
10807
+ ...this.settings.getGroup("compaction"),
10808
+ adaptive,
10809
+ adaptiveState: this.#adaptiveCompaction.decisionState(),
10810
+ };
10811
+ }
10812
+
10729
10813
  /**
10730
10814
  * Evidence-gated below-threshold maintenance pruning (Finding 13). Runs
10731
10815
  * #pruneToolOutputs ONCE, below the compaction threshold, only when it is
@@ -11392,13 +11476,15 @@ export class AgentSession {
11392
11476
  }
11393
11477
  return this.#scheduleOverflowRetryContinuation(generation);
11394
11478
  }
11395
- const compactionSettings = this.settings.getGroup("compaction");
11479
+ const compactionSettings = this.#compactionSettingsWithAdaptive();
11396
11480
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
11397
11481
 
11398
11482
  // Case 2: Threshold - turn succeeded but context is getting large
11399
11483
  // Skip if this was an error (non-overflow errors don't have usage data)
11400
11484
  if (assistantMessage.stopReason === "error") return false;
11401
11485
  let contextTokens = calculateContextTokens(assistantMessage.usage);
11486
+ this.#adaptiveCompaction.recordCall(contextTokens);
11487
+ compactionSettings.adaptiveState = this.#adaptiveCompaction.decisionState();
11402
11488
  // Model maxTokens is a capability ceiling, not a per-turn reservation.
11403
11489
  // Auto maintenance should track actual context fullness.
11404
11490
  const autoCompactionOutputReserveTokens = 0;
@@ -11626,6 +11712,11 @@ export class AgentSession {
11626
11712
  this.#resourceSampler = sampler;
11627
11713
  }
11628
11714
 
11715
+ /** Test seam: drive the pre-prompt emergency/threshold check directly. */
11716
+ runPrePromptContextCheckForTests(pendingMessages: readonly AgentMessage[] = []): Promise<void> {
11717
+ return this.#checkEstimatedContextBeforePrompt(pendingMessages);
11718
+ }
11719
+
11629
11720
  setRetainedMemorySampler(sampler: (() => RetainedMemorySample) | undefined): void {
11630
11721
  this.#retainedMemorySampler = sampler;
11631
11722
  }
@@ -11659,26 +11750,31 @@ export class AgentSession {
11659
11750
  materializedResidentBytes: this.#streamingEditFileCache.totalBytes,
11660
11751
  tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
11661
11752
  tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
11753
+ transcriptFileBytes: this.sessionManager.getTranscriptFileBytes(),
11662
11754
  };
11663
11755
  }
11664
11756
 
11665
11757
  async #checkEstimatedContextBeforePromptOnce(pendingMessages: readonly AgentMessage[]): Promise<void> {
11666
11758
  const model = this.model;
11667
11759
  if (!model) return;
11668
- const contextWindow = model.contextWindow ?? 0;
11669
- if (contextWindow <= 0) return;
11670
11760
  // F6: non-disableable emergency floor — compact before OOM even when token-based
11671
11761
  // compaction is disabled or its threshold is set too high (weak-hardware protection).
11672
11762
  const emergencyReason = emergencyCompactionReason(this.#resourceSampler());
11673
11763
  if (emergencyReason) {
11674
11764
  logger.warn("Emergency compaction triggered (resource floor exceeded)", { reason: emergencyReason });
11675
- await this.#runAutoCompaction("overflow", false, false, {
11765
+ const status = await this.#runAutoCompaction("overflow", false, false, {
11676
11766
  continueAfterMaintenance: false,
11677
11767
  deferHandoffMaintenance: false,
11678
11768
  force: true,
11679
11769
  });
11770
+ if (emergencyReason === "transcriptFile" && status.kind === "skipped") {
11771
+ logger.warn("Transcript emergency compaction was skipped; rewriting exact live entries");
11772
+ await this.sessionManager.rewriteEntries();
11773
+ }
11680
11774
  return;
11681
11775
  }
11776
+ const contextWindow = model.contextWindow ?? 0;
11777
+ if (contextWindow <= 0) return;
11682
11778
  const compactionSettings = this.settings.getGroup("compaction");
11683
11779
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
11684
11780
 
@@ -11712,6 +11808,7 @@ export class AgentSession {
11712
11808
  await this.#runAutoCompaction("threshold", false, false, {
11713
11809
  continueAfterMaintenance: false,
11714
11810
  deferHandoffMaintenance: false,
11811
+ suppressNextPostTurnMaintenanceAfterHandoff: true,
11715
11812
  });
11716
11813
  }
11717
11814
  }
@@ -11803,6 +11900,7 @@ export class AgentSession {
11803
11900
  return;
11804
11901
  }
11805
11902
 
11903
+ if (this.#explicitlyDisabledTools) return;
11806
11904
  this.#attachAskTool();
11807
11905
 
11808
11906
  const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
@@ -12691,6 +12789,11 @@ export class AgentSession {
12691
12789
 
12692
12790
  /**
12693
12791
  * Internal: Run auto-compaction with events.
12792
+ *
12793
+ * Wraps the run so a completed compaction resets the adaptive call-rate
12794
+ * tracker in exactly one place. The post-compaction context size is reported
12795
+ * as 0 because the next recorded call carries the real provider number; the
12796
+ * tracker only uses this value as a fallback for callers that pass none.
12694
12797
  */
12695
12798
  async #runAutoCompaction(
12696
12799
  reason: "overflow" | "threshold" | "idle",
@@ -12702,6 +12805,25 @@ export class AgentSession {
12702
12805
  force?: boolean;
12703
12806
  signal?: AbortSignal;
12704
12807
  beforeTerminalOverflowNoop?: () => void;
12808
+ suppressNextPostTurnMaintenanceAfterHandoff?: boolean;
12809
+ },
12810
+ ): Promise<AutoCompactionTerminalStatus> {
12811
+ const status = await this.#runAutoCompactionInner(reason, willRetry, deferred, options);
12812
+ if (status.kind === "compacted") this.#adaptiveCompaction.recordCompact(0);
12813
+ return status;
12814
+ }
12815
+
12816
+ async #runAutoCompactionInner(
12817
+ reason: "overflow" | "threshold" | "idle",
12818
+ willRetry: boolean,
12819
+ deferred = false,
12820
+ options?: {
12821
+ continueAfterMaintenance?: boolean;
12822
+ deferHandoffMaintenance?: boolean;
12823
+ force?: boolean;
12824
+ signal?: AbortSignal;
12825
+ beforeTerminalOverflowNoop?: () => void;
12826
+ suppressNextPostTurnMaintenanceAfterHandoff?: boolean;
12705
12827
  },
12706
12828
  ): Promise<AutoCompactionTerminalStatus> {
12707
12829
  const compactionSettings = this.settings.getGroup("compaction");
@@ -12783,6 +12905,9 @@ export class AgentSession {
12783
12905
  if (autoCompactionSignal.aborted) return await emitAborted();
12784
12906
 
12785
12907
  if (handoffResult) {
12908
+ if (options?.suppressNextPostTurnMaintenanceAfterHandoff) {
12909
+ this.#suppressNextPostTurnMaintenanceAfterAutoHandoff = true;
12910
+ }
12786
12911
  await this.#emitSessionEvent({
12787
12912
  type: "auto_compaction_end",
12788
12913
  action,
@@ -13502,12 +13627,15 @@ export class AgentSession {
13502
13627
  if (controller.chain.entries.length > 1) await this.#advanceDefaultFallback(controller, "new_turn", 0);
13503
13628
  return;
13504
13629
  }
13630
+ const primarySelector = controller.chain.entries[0];
13505
13631
  if (
13506
13632
  this.settings.get("retry.fallbackRevertPolicy") === "cooldown-expiry" &&
13507
- controller.activeIndex > 0
13508
- // TODO(upstream-v0.11): ModelRegistry.getSelectorSuppressionStatus was removed;
13509
- // rewire the cooldown-expiry revert policy to the new suppression API when available.
13633
+ controller.activeIndex > 0 &&
13634
+ primarySelector &&
13635
+ this.#fallbackSuppressedSelectors.has(primarySelector) &&
13636
+ !this.#modelRegistry.isSelectorSuppressed(primarySelector)
13510
13637
  ) {
13638
+ this.#fallbackSuppressedSelectors.delete(primarySelector);
13511
13639
  controller.resetForNewTurn();
13512
13640
  }
13513
13641
  }
@@ -13560,7 +13688,9 @@ export class AgentSession {
13560
13688
  const existing = this.#defaultFallbackController;
13561
13689
  if (
13562
13690
  existing &&
13563
- (existing.chain.origin === "runtime" || existing.chain.entries.join("\u0000") === chain.entries.join("\u0000"))
13691
+ (existing.chain.origin === "runtime" ||
13692
+ (existing.activeIndex > 0 && existing.chain.entries.length > 1) ||
13693
+ existing.chain.entries.join("\u0000") === chain.entries.join("\u0000"))
13564
13694
  ) {
13565
13695
  return existing;
13566
13696
  }
@@ -13846,6 +13976,7 @@ export class AgentSession {
13846
13976
 
13847
13977
  if (managedFallback && trigger.class === "rate_limit" && trigger.retryAfterMs !== undefined && failedSelector) {
13848
13978
  this.#modelRegistry.suppressSelector(failedSelector, Date.now() + trigger.retryAfterMs);
13979
+ this.#fallbackSuppressedSelectors.add(failedSelector);
13849
13980
  }
13850
13981
 
13851
13982
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
@@ -15074,14 +15205,22 @@ export class AgentSession {
15074
15205
  * Switch to a different session file.
15075
15206
  * Aborts current operation, loads messages, restores model/thinking.
15076
15207
  * Listeners are preserved and will continue receiving events.
15208
+ * `requireIdle` rejects an active turn and fences new admissions for the transition.
15077
15209
  * @returns true if switch completed, false if cancelled by hook
15078
15210
  */
15079
15211
  async switchSession(
15080
15212
  sessionPath: string,
15081
- options?: { transition?: SessionSwitchEvent["transition"] },
15213
+ options?: { transition?: SessionSwitchEvent["transition"]; requireIdle?: boolean },
15082
15214
  ): Promise<boolean> {
15083
15215
  this.#beginSessionTransition("switch-session");
15084
15216
  try {
15217
+ if (options?.requireIdle) {
15218
+ this.#sessionTurnAdmissionFenced = true;
15219
+ if (this.isStreaming || this.#activeSessionAdmission !== undefined)
15220
+ throw Object.assign(new AgentBusyError("Cannot switch sessions while a response or turn is active."), {
15221
+ code: "busy",
15222
+ });
15223
+ }
15085
15224
  const previousSessionFile = this.sessionManager.getSessionFile();
15086
15225
  const switchingToDifferentSession = previousSessionFile
15087
15226
  ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
@@ -15307,6 +15446,7 @@ export class AgentSession {
15307
15446
  throw error;
15308
15447
  }
15309
15448
  } finally {
15449
+ this.#sessionTurnAdmissionFenced = false;
15310
15450
  this.#endSessionTransition();
15311
15451
  }
15312
15452
  }
@@ -702,7 +702,7 @@ function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): Manage
702
702
  provenance,
703
703
  migrationState: provenance === "v2" ? "native_v2" : "legacy_unmigrated",
704
704
  identity: {
705
- canonicalPath: path.resolve(filePath),
705
+ canonicalPath: canonicalizeTrustedPath(filePath),
706
706
  sessionId: header.id,
707
707
  ...snapshot.identity,
708
708
  mtimeMs: Number(named.mtimeMs),
@@ -740,6 +740,7 @@ function listDirectoryCandidates(
740
740
  directory: string,
741
741
  provenance: "v2" | "legacy",
742
742
  scope: ManagedScope,
743
+ maxCandidates?: number,
743
744
  ): readonly CandidateInspection[] {
744
745
  let directoryStat: fs.Stats;
745
746
  try {
@@ -749,8 +750,28 @@ function listDirectoryCandidates(
749
750
  throw error;
750
751
  }
751
752
  if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error("unsafe directory");
752
- return fs
753
- .readdirSync(directory, { withFileTypes: true })
753
+ let entries: fs.Dirent[];
754
+ if (maxCandidates === undefined) {
755
+ entries = fs.readdirSync(directory, { withFileTypes: true });
756
+ } else {
757
+ entries = [];
758
+ let scannedEntries = 0;
759
+ const handle = fs.opendirSync(directory);
760
+ try {
761
+ for (;;) {
762
+ const entry = handle.readSync();
763
+ if (!entry) break;
764
+ scannedEntries++;
765
+ if (scannedEntries > maxCandidates * 4 + 128) throw new Error("candidate_capacity_exceeded");
766
+ if (!entry.name.endsWith(".jsonl")) continue;
767
+ entries.push(entry);
768
+ if (entries.length > maxCandidates) throw new Error("candidate_capacity_exceeded");
769
+ }
770
+ } finally {
771
+ handle.closeSync();
772
+ }
773
+ }
774
+ return entries
754
775
  .filter(entry => entry.name.endsWith(".jsonl"))
755
776
  .map(entry => {
756
777
  const filePath = path.join(directory, entry.name);
@@ -967,7 +988,10 @@ export function prepareManagedSessionScopeForWriteSync(
967
988
  }
968
989
  }
969
990
 
970
- export function listManagedCandidates(scope: ManagedScope): ManagedCandidateListing {
991
+ export function listManagedCandidates(
992
+ scope: ManagedScope,
993
+ options?: { maxCandidates?: number },
994
+ ): ManagedCandidateListing {
971
995
  try {
972
996
  let root: fs.Stats;
973
997
  try {
@@ -990,8 +1014,19 @@ export function listManagedCandidates(scope: ManagedScope): ManagedCandidateList
990
1014
  })),
991
1015
  ];
992
1016
  const seen = new Set<string>();
1017
+ let inspectedCandidates = 0;
993
1018
  for (const directory of directories) {
994
- for (const candidate of listDirectoryCandidates(directory.path, directory.provenance, scope)) {
1019
+ const remainingCandidates =
1020
+ options?.maxCandidates === undefined ? undefined : options.maxCandidates - inspectedCandidates;
1021
+ for (const candidate of listDirectoryCandidates(
1022
+ directory.path,
1023
+ directory.provenance,
1024
+ scope,
1025
+ remainingCandidates,
1026
+ )) {
1027
+ inspectedCandidates++;
1028
+ if (options?.maxCandidates !== undefined && inspectedCandidates > options.maxCandidates)
1029
+ throw new Error("candidate_capacity_exceeded");
995
1030
  if ("code" in candidate) {
996
1031
  invalid.push({ code: candidate.code });
997
1032
  continue;
@@ -1108,7 +1143,7 @@ function sameCandidate(left: ManagedCandidate, right: ManagedCandidate): boolean
1108
1143
 
1109
1144
  function matchesExpectedResumeIdentity(candidate: ManagedCandidate, expected: ResumeSessionIdentity): boolean {
1110
1145
  return (
1111
- path.resolve(candidate.identity.canonicalPath) === path.resolve(expected.canonicalPath) &&
1146
+ canonicalizeTrustedPath(candidate.identity.canonicalPath) === canonicalizeTrustedPath(expected.canonicalPath) &&
1112
1147
  candidate.identity.sessionId === expected.sessionId &&
1113
1148
  candidate.identity.dev === expected.dev &&
1114
1149
  candidate.identity.ino === expected.ino &&
@@ -880,6 +880,20 @@ export class ManagedSessionDescendantStore {
880
880
  };
881
881
  }
882
882
 
883
+ /** Read only the exact managed file size through the retained root authority. */
884
+ sizeSync(relativePath: string): number | null {
885
+ this.#assertBound();
886
+ if (!this.#authority) throw new Error("managed_size_authority_unavailable");
887
+ const relative = this.#relative(this.#resolve(relativePath));
888
+ const result = this.#authority.stat(relative);
889
+ if (!result.ok) {
890
+ if (result.code === "not_found") return null;
891
+ throw new Error(result.code ?? "managed_size_failed");
892
+ }
893
+ if (!result.identity) throw new Error("Managed descendant identity is unavailable");
894
+ return Number(result.identity.size);
895
+ }
896
+
883
897
  /** Remove an exact captured file without reopening its pathname as authority. */
884
898
  removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
885
899
  this.#assertBound();