@sayknow-cli/coding-agent 0.5.2 → 0.5.6

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 (86) hide show
  1. package/CHANGELOG.md +55 -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 +9 -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/tools/ask.d.ts +5 -0
  32. package/package.json +13 -7
  33. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  34. package/src/cli/config-cli.ts +23 -8
  35. package/src/cli/update-cli.ts +265 -21
  36. package/src/config/atomic-yaml-patch.ts +167 -30
  37. package/src/config/keybindings.ts +10 -0
  38. package/src/config/model-profiles.ts +69 -1
  39. package/src/config/model-registry.ts +60 -38
  40. package/src/config/models-config-schema.ts +1 -1
  41. package/src/config/settings-schema.ts +9 -0
  42. package/src/config/settings.ts +13 -2
  43. package/src/dap/client.ts +78 -30
  44. package/src/internal-urls/docs-index.generated.ts +5 -4
  45. package/src/lsp/client.ts +17 -29
  46. package/src/modes/action-registry.ts +2 -0
  47. package/src/modes/components/welcome.ts +5 -0
  48. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  49. package/src/modes/controllers/input-controller.ts +71 -6
  50. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  51. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  52. package/src/modes/controllers/selector-controller.ts +47 -19
  53. package/src/modes/interactive-mode.ts +54 -4
  54. package/src/modes/types.ts +5 -1
  55. package/src/runtime-mcp/transports/stdio.ts +96 -33
  56. package/src/sdk/broker/lifecycle.ts +6 -5
  57. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  58. package/src/sdk/session.ts +25 -18
  59. package/src/session/agent-session.ts +89 -14
  60. package/src/session/internal/managed-session-scope.ts +41 -6
  61. package/src/session/internal/managed-session-storage.ts +14 -0
  62. package/src/session/session-manager.ts +113 -11
  63. package/src/session/session-storage.ts +70 -0
  64. package/src/session-import/claude.ts +382 -0
  65. package/src/session-import/codex.ts +458 -0
  66. package/src/session-import/command.ts +61 -0
  67. package/src/session-import/detect.ts +133 -0
  68. package/src/session-import/index.ts +27 -0
  69. package/src/session-import/redact.ts +125 -0
  70. package/src/session-import/service.ts +749 -0
  71. package/src/session-import/types.ts +163 -0
  72. package/src/skc-runtime/launch-worktree.ts +277 -4
  73. package/src/slash-commands/acp-builtins.ts +5 -1
  74. package/src/slash-commands/builtin-registry.ts +100 -1
  75. package/src/slash-commands/types.ts +5 -0
  76. package/src/tools/ask.ts +5 -0
  77. package/src/export/html/template.css +0 -1060
  78. package/src/export/html/template.html +0 -47
  79. package/src/export/html/template.js +0 -2348
  80. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  81. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  82. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  83. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  84. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  85. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  86. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -545,6 +545,8 @@ export interface AgentSessionConfig {
545
545
  /** Rebuild the SSH tool from current capability discovery results. */
546
546
  reloadSshTool?: () => Promise<AgentTool | null>;
547
547
  requestedToolNames?: ReadonlySet<string>;
548
+ /** True only when the caller explicitly selected no tools. */
549
+ explicitlyDisabledTools?: boolean;
548
550
  /** Optional per-session allowlist for tools exposed through search_tool_bm25. */
549
551
  discoverableToolAllowedNames?: readonly string[];
550
552
  /** Optional accessor for live MCP server instructions, injected as untrusted user-role request data. */
@@ -1748,6 +1750,7 @@ export class AgentSession {
1748
1750
  // Handoff state
1749
1751
  #handoffAbortController: AbortController | undefined = undefined;
1750
1752
  #skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined;
1753
+ #suppressNextPostTurnMaintenanceAfterAutoHandoff = false;
1751
1754
 
1752
1755
  // Retry state
1753
1756
  #retryAbortController: AbortController | undefined = undefined;
@@ -1759,6 +1762,7 @@ export class AgentSession {
1759
1762
  #overflowMaintenanceAttempts = 0;
1760
1763
  #defaultFallbackExhaustedLastTurn = false;
1761
1764
  #fallbackInvocationId = 0;
1765
+ #fallbackSuppressedSelectors = new Set<string>();
1762
1766
  // Todo completion reminder state
1763
1767
  #todoReminderCount = 0;
1764
1768
  #todoWriteFailureCount = 0;
@@ -1853,6 +1857,7 @@ export class AgentSession {
1853
1857
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1854
1858
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
1855
1859
  #requestedToolNames: ReadonlySet<string> | undefined;
1860
+ #explicitlyDisabledTools: boolean;
1856
1861
  #baseSystemPrompt: string[];
1857
1862
  #initialWorkspaceTree: WorkspaceTree | undefined;
1858
1863
  /** Throttle cache for the per-turn volatile workspace-tree scan (see #buildVolatileProjectContextMessage). */
@@ -2043,6 +2048,12 @@ export class AgentSession {
2043
2048
  });
2044
2049
  }
2045
2050
 
2051
+ #sessionTransitionAdmissionBusyError(): AgentBusyError {
2052
+ return Object.assign(new AgentBusyError("Cannot start a turn while a session transition is in progress."), {
2053
+ code: "busy",
2054
+ });
2055
+ }
2056
+
2046
2057
  /**
2047
2058
  * Reject a turn start while a handoff transition owns the session. Handoff never
2048
2059
  * routes its own generation/injection through these turn-start chokepoints, and
@@ -2068,6 +2079,7 @@ export class AgentSession {
2068
2079
  * orchestrator does not hold it), so there is no self-deadlock.
2069
2080
  */
2070
2081
  #sessionTransitionKind: string | undefined;
2082
+ #sessionTurnAdmissionFenced = false;
2071
2083
 
2072
2084
  #beginSessionTransition(kind: string): void {
2073
2085
  if (this.#sessionTransitionKind !== undefined) {
@@ -2108,6 +2120,7 @@ export class AgentSession {
2108
2120
  code: "busy",
2109
2121
  });
2110
2122
  }
2123
+ if (this.#sessionTurnAdmissionFenced) throw this.#sessionTransitionAdmissionBusyError();
2111
2124
 
2112
2125
  const entry: SessionAdmissionEntry = {
2113
2126
  kind,
@@ -2136,6 +2149,13 @@ export class AgentSession {
2136
2149
  code: "busy",
2137
2150
  });
2138
2151
  }
2152
+ if (this.#sessionTurnAdmissionFenced) {
2153
+ entry.released = true;
2154
+ entry.settled.resolve();
2155
+ if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
2156
+ this.#activateNextSessionAdmission();
2157
+ throw this.#sessionTransitionAdmissionBusyError();
2158
+ }
2139
2159
 
2140
2160
  const release = () => {
2141
2161
  if (entry.released) return;
@@ -2372,6 +2392,7 @@ export class AgentSession {
2372
2392
  this.#toolRegistry = config.toolRegistry ?? new Map();
2373
2393
  this.#workflowGateToolSession = config.workflowGateToolSession;
2374
2394
  this.#requestedToolNames = config.requestedToolNames;
2395
+ this.#explicitlyDisabledTools = config.explicitlyDisabledTools === true;
2375
2396
  this.#transformContext = config.transformContext ?? (messages => messages);
2376
2397
  this.#onPayload = config.onPayload;
2377
2398
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
@@ -3779,8 +3800,9 @@ export class AgentSession {
3779
3800
  this.#resolveTtsrResume();
3780
3801
  }
3781
3802
  this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
3782
- if (this.#handoffAbortController) {
3803
+ if (this.#handoffAbortController || this.#suppressNextPostTurnMaintenanceAfterAutoHandoff) {
3783
3804
  this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
3805
+ this.#suppressNextPostTurnMaintenanceAfterAutoHandoff = false;
3784
3806
  }
3785
3807
  if (
3786
3808
  assistantMsg.stopReason !== "error" &&
@@ -5872,6 +5894,7 @@ export class AgentSession {
5872
5894
  }
5873
5895
 
5874
5896
  async activateDiscoveredTools(toolNames: string[]): Promise<string[]> {
5897
+ if (this.#explicitlyDisabledTools) return [];
5875
5898
  const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
5876
5899
  const previousSelectedDiscoveredBuiltinToolNames = this.#getSelectedDiscoveredBuiltinToolNames();
5877
5900
  const nextActiveToolNames = this.getActiveToolNames();
@@ -6099,7 +6122,9 @@ export class AgentSession {
6099
6122
  nextSelectedDiscoveredBuiltinToolNames?: string[];
6100
6123
  },
6101
6124
  ): Promise<void> {
6102
- toolNames = [...new Set([...toolNames.map(name => name.toLowerCase()), ...this.#mandatoryMCPToolNames])];
6125
+ toolNames = this.#explicitlyDisabledTools
6126
+ ? []
6127
+ : [...new Set([...toolNames.map(name => name.toLowerCase()), ...this.#mandatoryMCPToolNames])];
6103
6128
  const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
6104
6129
  const previousSelectedDiscoveredBuiltinToolNames =
6105
6130
  options?.previousSelectedDiscoveredBuiltinToolNames ?? this.#getSelectedDiscoveredBuiltinToolNames();
@@ -6209,6 +6234,11 @@ export class AgentSession {
6209
6234
  await this.#applyActiveToolsByName(nextActive);
6210
6235
  }
6211
6236
 
6237
+ /** Whether the caller explicitly selected no tools for this session. */
6238
+ hasExplicitlyDisabledTools(): boolean {
6239
+ return this.#explicitlyDisabledTools;
6240
+ }
6241
+
6212
6242
  /**
6213
6243
  * Set active tools by name.
6214
6244
  * Only tools in the registry can be enabled. Unknown tool names are ignored.
@@ -6220,6 +6250,11 @@ export class AgentSession {
6220
6250
  }
6221
6251
 
6222
6252
  async #restoreMCPSelectionsForSessionContext(sessionContext: SessionContext): Promise<void> {
6253
+ if (this.#explicitlyDisabledTools) {
6254
+ this.#selectedDiscoveredToolNames.clear();
6255
+ await this.#applyActiveToolsByName([], { persistMCPSelection: false });
6256
+ return;
6257
+ }
6223
6258
  if (!this.#mcpDiscoveryEnabled && this.#resolveEffectiveDiscoveryMode() !== "all") {
6224
6259
  await this.#attachAskToolIfWorkflowActive();
6225
6260
  return;
@@ -7073,6 +7108,7 @@ export class AgentSession {
7073
7108
  this.#toolRegistry.set(askTool.name, askTool);
7074
7109
  }
7075
7110
 
7111
+ if (this.#explicitlyDisabledTools) return;
7076
7112
  try {
7077
7113
  if ((this.#workflowGateEmitter?.listPendingGates?.().length ?? 0) > 0) {
7078
7114
  this.#attachAskTool();
@@ -7118,6 +7154,7 @@ export class AgentSession {
7118
7154
  }
7119
7155
 
7120
7156
  #attachAskTool(): void {
7157
+ if (this.#explicitlyDisabledTools) return;
7121
7158
  const askTool = this.#toolRegistry.get("ask");
7122
7159
  if (!askTool || this.getActiveToolNames().includes(askTool.name)) return;
7123
7160
  this.#setGuardedAgentTools([...this.agent.state.tools, askTool]);
@@ -7200,6 +7237,7 @@ export class AgentSession {
7200
7237
  }
7201
7238
 
7202
7239
  async #activatePendingSkcGoalModeRequest(): Promise<boolean> {
7240
+ if (this.#explicitlyDisabledTools) return false;
7203
7241
  if (!this.settings.get("goal.enabled")) return false;
7204
7242
  const pendingGoal = await consumePendingGoalModeRequest(
7205
7243
  this.sessionManager.getCwd(),
@@ -7212,9 +7250,10 @@ export class AgentSession {
7212
7250
  }
7213
7251
 
7214
7252
  const previousTools = this.getActiveToolNames();
7215
- const goalTools = [...new Set([...previousTools, "goal"])];
7216
7253
  await this.#goalRuntime.createGoal({ objective: pendingGoal.objective, provenance: pendingGoal.provenance });
7217
- await this.setActiveToolsByName(goalTools);
7254
+ if (!this.#explicitlyDisabledTools) {
7255
+ await this.setActiveToolsByName([...new Set([...previousTools, "goal"])]);
7256
+ }
7218
7257
  if (this.isStreaming) {
7219
7258
  await this.sendGoalModeContext({ deliverAs: "steer" });
7220
7259
  }
@@ -9472,7 +9511,6 @@ export class AgentSession {
9472
9511
  persistMCPSelection: false,
9473
9512
  nextSelectedDiscoveredBuiltinToolNames: [],
9474
9513
  });
9475
- } else {
9476
9514
  }
9477
9515
  }
9478
9516
  this.#todoReminderCount = 0;
@@ -10066,7 +10104,13 @@ export class AgentSession {
10066
10104
  });
10067
10105
  if (!resolved.model) continue;
10068
10106
 
10069
- if (roleModels.some(candidate => modelsAreEqual(candidate.model, resolved.model))) continue;
10107
+ if (
10108
+ roleModels.some(
10109
+ candidate =>
10110
+ modelsAreEqual(candidate.model, resolved.model) && candidate.thinkingLevel === resolved.thinkingLevel,
10111
+ )
10112
+ )
10113
+ continue;
10070
10114
  roleModels.push({
10071
10115
  role,
10072
10116
  model: resolved.model,
@@ -11626,6 +11670,11 @@ export class AgentSession {
11626
11670
  this.#resourceSampler = sampler;
11627
11671
  }
11628
11672
 
11673
+ /** Test seam: drive the pre-prompt emergency/threshold check directly. */
11674
+ runPrePromptContextCheckForTests(pendingMessages: readonly AgentMessage[] = []): Promise<void> {
11675
+ return this.#checkEstimatedContextBeforePrompt(pendingMessages);
11676
+ }
11677
+
11629
11678
  setRetainedMemorySampler(sampler: (() => RetainedMemorySample) | undefined): void {
11630
11679
  this.#retainedMemorySampler = sampler;
11631
11680
  }
@@ -11659,26 +11708,31 @@ export class AgentSession {
11659
11708
  materializedResidentBytes: this.#streamingEditFileCache.totalBytes,
11660
11709
  tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
11661
11710
  tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
11711
+ transcriptFileBytes: this.sessionManager.getTranscriptFileBytes(),
11662
11712
  };
11663
11713
  }
11664
11714
 
11665
11715
  async #checkEstimatedContextBeforePromptOnce(pendingMessages: readonly AgentMessage[]): Promise<void> {
11666
11716
  const model = this.model;
11667
11717
  if (!model) return;
11668
- const contextWindow = model.contextWindow ?? 0;
11669
- if (contextWindow <= 0) return;
11670
11718
  // F6: non-disableable emergency floor — compact before OOM even when token-based
11671
11719
  // compaction is disabled or its threshold is set too high (weak-hardware protection).
11672
11720
  const emergencyReason = emergencyCompactionReason(this.#resourceSampler());
11673
11721
  if (emergencyReason) {
11674
11722
  logger.warn("Emergency compaction triggered (resource floor exceeded)", { reason: emergencyReason });
11675
- await this.#runAutoCompaction("overflow", false, false, {
11723
+ const status = await this.#runAutoCompaction("overflow", false, false, {
11676
11724
  continueAfterMaintenance: false,
11677
11725
  deferHandoffMaintenance: false,
11678
11726
  force: true,
11679
11727
  });
11728
+ if (emergencyReason === "transcriptFile" && status.kind === "skipped") {
11729
+ logger.warn("Transcript emergency compaction was skipped; rewriting exact live entries");
11730
+ await this.sessionManager.rewriteEntries();
11731
+ }
11680
11732
  return;
11681
11733
  }
11734
+ const contextWindow = model.contextWindow ?? 0;
11735
+ if (contextWindow <= 0) return;
11682
11736
  const compactionSettings = this.settings.getGroup("compaction");
11683
11737
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
11684
11738
 
@@ -11712,6 +11766,7 @@ export class AgentSession {
11712
11766
  await this.#runAutoCompaction("threshold", false, false, {
11713
11767
  continueAfterMaintenance: false,
11714
11768
  deferHandoffMaintenance: false,
11769
+ suppressNextPostTurnMaintenanceAfterHandoff: true,
11715
11770
  });
11716
11771
  }
11717
11772
  }
@@ -11803,6 +11858,7 @@ export class AgentSession {
11803
11858
  return;
11804
11859
  }
11805
11860
 
11861
+ if (this.#explicitlyDisabledTools) return;
11806
11862
  this.#attachAskTool();
11807
11863
 
11808
11864
  const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
@@ -12702,6 +12758,7 @@ export class AgentSession {
12702
12758
  force?: boolean;
12703
12759
  signal?: AbortSignal;
12704
12760
  beforeTerminalOverflowNoop?: () => void;
12761
+ suppressNextPostTurnMaintenanceAfterHandoff?: boolean;
12705
12762
  },
12706
12763
  ): Promise<AutoCompactionTerminalStatus> {
12707
12764
  const compactionSettings = this.settings.getGroup("compaction");
@@ -12783,6 +12840,9 @@ export class AgentSession {
12783
12840
  if (autoCompactionSignal.aborted) return await emitAborted();
12784
12841
 
12785
12842
  if (handoffResult) {
12843
+ if (options?.suppressNextPostTurnMaintenanceAfterHandoff) {
12844
+ this.#suppressNextPostTurnMaintenanceAfterAutoHandoff = true;
12845
+ }
12786
12846
  await this.#emitSessionEvent({
12787
12847
  type: "auto_compaction_end",
12788
12848
  action,
@@ -13502,12 +13562,15 @@ export class AgentSession {
13502
13562
  if (controller.chain.entries.length > 1) await this.#advanceDefaultFallback(controller, "new_turn", 0);
13503
13563
  return;
13504
13564
  }
13565
+ const primarySelector = controller.chain.entries[0];
13505
13566
  if (
13506
13567
  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.
13568
+ controller.activeIndex > 0 &&
13569
+ primarySelector &&
13570
+ this.#fallbackSuppressedSelectors.has(primarySelector) &&
13571
+ !this.#modelRegistry.isSelectorSuppressed(primarySelector)
13510
13572
  ) {
13573
+ this.#fallbackSuppressedSelectors.delete(primarySelector);
13511
13574
  controller.resetForNewTurn();
13512
13575
  }
13513
13576
  }
@@ -13560,7 +13623,9 @@ export class AgentSession {
13560
13623
  const existing = this.#defaultFallbackController;
13561
13624
  if (
13562
13625
  existing &&
13563
- (existing.chain.origin === "runtime" || existing.chain.entries.join("\u0000") === chain.entries.join("\u0000"))
13626
+ (existing.chain.origin === "runtime" ||
13627
+ (existing.activeIndex > 0 && existing.chain.entries.length > 1) ||
13628
+ existing.chain.entries.join("\u0000") === chain.entries.join("\u0000"))
13564
13629
  ) {
13565
13630
  return existing;
13566
13631
  }
@@ -13846,6 +13911,7 @@ export class AgentSession {
13846
13911
 
13847
13912
  if (managedFallback && trigger.class === "rate_limit" && trigger.retryAfterMs !== undefined && failedSelector) {
13848
13913
  this.#modelRegistry.suppressSelector(failedSelector, Date.now() + trigger.retryAfterMs);
13914
+ this.#fallbackSuppressedSelectors.add(failedSelector);
13849
13915
  }
13850
13916
 
13851
13917
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
@@ -15074,14 +15140,22 @@ export class AgentSession {
15074
15140
  * Switch to a different session file.
15075
15141
  * Aborts current operation, loads messages, restores model/thinking.
15076
15142
  * Listeners are preserved and will continue receiving events.
15143
+ * `requireIdle` rejects an active turn and fences new admissions for the transition.
15077
15144
  * @returns true if switch completed, false if cancelled by hook
15078
15145
  */
15079
15146
  async switchSession(
15080
15147
  sessionPath: string,
15081
- options?: { transition?: SessionSwitchEvent["transition"] },
15148
+ options?: { transition?: SessionSwitchEvent["transition"]; requireIdle?: boolean },
15082
15149
  ): Promise<boolean> {
15083
15150
  this.#beginSessionTransition("switch-session");
15084
15151
  try {
15152
+ if (options?.requireIdle) {
15153
+ this.#sessionTurnAdmissionFenced = true;
15154
+ if (this.isStreaming || this.#activeSessionAdmission !== undefined)
15155
+ throw Object.assign(new AgentBusyError("Cannot switch sessions while a response or turn is active."), {
15156
+ code: "busy",
15157
+ });
15158
+ }
15085
15159
  const previousSessionFile = this.sessionManager.getSessionFile();
15086
15160
  const switchingToDifferentSession = previousSessionFile
15087
15161
  ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
@@ -15307,6 +15381,7 @@ export class AgentSession {
15307
15381
  throw error;
15308
15382
  }
15309
15383
  } finally {
15384
+ this.#sessionTurnAdmissionFenced = false;
15310
15385
  this.#endSessionTransition();
15311
15386
  }
15312
15387
  }
@@ -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();
@@ -4350,6 +4350,7 @@ export class SessionManager {
4350
4350
  #sessionName: string | undefined;
4351
4351
  #titleSource: "auto" | "user" | undefined;
4352
4352
  #sessionFile: string | undefined;
4353
+ #transcriptStatFailureReported = false;
4353
4354
  #flushed: boolean = false;
4354
4355
  #needsFullRewriteOnNextPersist: boolean = false;
4355
4356
  #ensuredOnDisk: boolean = false;
@@ -6573,6 +6574,28 @@ export class SessionManager {
6573
6574
  getSessionFile(): string | undefined {
6574
6575
  return this.#sessionFile;
6575
6576
  }
6577
+ /**
6578
+ * Managed on-disk transcript size in bytes. Returns undefined when the
6579
+ * destination is explicit, the file does not exist yet, or its identity
6580
+ * cannot be verified.
6581
+ */
6582
+ getTranscriptFileBytes(): number | undefined {
6583
+ if (this.destination.kind !== "managed" || !this.#sessionFile) return undefined;
6584
+ try {
6585
+ const size = this.#managedTranscriptStore().sizeSync(path.basename(this.#sessionFile));
6586
+ this.#transcriptStatFailureReported = false;
6587
+ return size ?? undefined;
6588
+ } catch (error) {
6589
+ if (!this.#transcriptStatFailureReported) {
6590
+ this.#transcriptStatFailureReported = true;
6591
+ logger.warn("Managed transcript size sampling failed", {
6592
+ sessionFile: this.#sessionFile,
6593
+ error: toError(error).message,
6594
+ });
6595
+ }
6596
+ return undefined;
6597
+ }
6598
+ }
6576
6599
 
6577
6600
  acquireMemoryGuardParticipantIngressLease(): MemoryGuardParticipantIngressLease {
6578
6601
  if (this.#memoryGuardParticipantIngressToken)
@@ -7093,7 +7116,17 @@ export class SessionManager {
7093
7116
  new Map(),
7094
7117
  );
7095
7118
  const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
7096
- this.#appendManagedRecordSync(persistedEntry);
7119
+ try {
7120
+ this.#appendManagedRecordSync(persistedEntry);
7121
+ } catch (appendError) {
7122
+ // A managed append-only transcript can hit the per-file limit
7123
+ // even after compaction reduced the live entries. Only the
7124
+ // authority-bound append error may authorize a full rewrite.
7125
+ if (!(appendError instanceof Error) || appendError.message !== "content_too_large") {
7126
+ throw appendError;
7127
+ }
7128
+ this.#rewriteFileSync();
7129
+ }
7097
7130
  return;
7098
7131
  }
7099
7132
  const writer = this.#ensurePersistWriter();
@@ -7119,16 +7152,29 @@ export class SessionManager {
7119
7152
  }
7120
7153
 
7121
7154
  #appendEntry(entry: SessionEntry): void {
7155
+ if (this.#persistError) throw this.#persistError;
7122
7156
  this.#assertRecoveryHydrationWritable();
7123
7157
  const normalizedEntry = normalizeSessionEntryForStorage(entry);
7124
7158
  const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
7159
+ const previousLeafId = this.#leafId;
7125
7160
  this.#fileEntries.push(residentEntry);
7126
7161
  this.#byId.set(residentEntry.id, residentEntry);
7127
7162
  this.#leafId = residentEntry.id;
7128
7163
  this.#bumpEntryRevision();
7129
7164
  this.#leafRevision++;
7130
7165
  if (entry.type === "label") this.#labelRevision++;
7131
- this._persist(residentEntry);
7166
+ try {
7167
+ this._persist(residentEntry);
7168
+ } catch (error) {
7169
+ this.#fileEntries.pop();
7170
+ this.#byId.delete(residentEntry.id);
7171
+ this.#leafId = previousLeafId;
7172
+ this.#entryRevision--;
7173
+ this.#leafRevision--;
7174
+ if (entry.type === "label") this.#labelRevision--;
7175
+ this.#resetMaterializedCaches();
7176
+ throw error;
7177
+ }
7132
7178
  // Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
7133
7179
  // malformed task-result or assistant usage shape reaching the append path must not
7134
7180
  // crash or poison getUsageStatistics() either.
@@ -9395,7 +9441,14 @@ export class SessionManager {
9395
9441
  */
9396
9442
  static inventorySessionsStrict(
9397
9443
  cwd: string,
9398
- options?: { sessionDir?: string; storage?: SessionStorage; destination?: SessionDestination },
9444
+ options?: {
9445
+ sessionDir?: string;
9446
+ storage?: SessionStorage;
9447
+ destination?: SessionDestination;
9448
+ maxCandidates?: number;
9449
+ maxTotalBytes?: number;
9450
+ filterOtherWorkspaces?: boolean;
9451
+ },
9399
9452
  ): StrictInventoryResult {
9400
9453
  const storage = options?.storage ?? new FileSessionStorage();
9401
9454
  const destination = options?.destination;
@@ -9419,20 +9472,27 @@ export class SessionManager {
9419
9472
  });
9420
9473
  if (resolved.kind === "error")
9421
9474
  return { kind: "failure", failures: [{ kind: "root", message: resolved.message, path: dir }] };
9422
- const listing = listManagedCandidates(resolved.scope);
9475
+ const listing = listManagedCandidates(resolved.scope, { maxCandidates: options?.maxCandidates });
9423
9476
  if (listing.kind === "error")
9424
9477
  return { kind: "failure", failures: [{ kind: "scan", message: listing.message, path: dir }] };
9425
9478
  const failures: StrictInventoryFailure[] = [];
9426
9479
  const candidates: StrictInventoryCandidate[] = [];
9480
+ let remainingBytes = options?.maxTotalBytes;
9427
9481
  for (const managedCandidate of listing.owned) {
9428
9482
  const candidate = inventoryReadCandidate(
9429
9483
  storage,
9430
9484
  managedCandidate.path,
9431
9485
  cwd,
9432
9486
  path.dirname(managedCandidate.path),
9487
+ remainingBytes,
9488
+ options?.filterOtherWorkspaces,
9433
9489
  );
9434
9490
  if ("failures" in candidate) failures.push(...candidate.failures);
9435
- else candidates.push(candidate.candidate);
9491
+ else {
9492
+ if (remainingBytes !== undefined) remainingBytes -= candidate.bytes;
9493
+ if ("filtered" in candidate) continue;
9494
+ candidates.push(candidate.candidate);
9495
+ }
9436
9496
  }
9437
9497
  assertManagedDirectoryRoot(destination.securityContext.rootAuthority);
9438
9498
  store.assertBound();
@@ -9450,11 +9510,27 @@ export class SessionManager {
9450
9510
  failures: [{ kind: "scan", message: "Strict scoped session scan is unavailable", path: dir }],
9451
9511
  };
9452
9512
  }
9453
- const strictScan = storage.listFilesStrictSync.bind(storage);
9454
9513
 
9455
9514
  let files: string[];
9456
9515
  try {
9457
- files = strictScan(dir, "*.jsonl");
9516
+ if (options?.maxCandidates !== undefined) {
9517
+ if (!storage.listFilesStrictBoundedSync) {
9518
+ return {
9519
+ kind: "failure",
9520
+ failures: [{ kind: "scan", message: "Bounded strict session scan is unavailable", path: dir }],
9521
+ };
9522
+ }
9523
+ const bounded = storage.listFilesStrictBoundedSync(dir, "*.jsonl", options.maxCandidates);
9524
+ if (bounded.truncated) {
9525
+ return {
9526
+ kind: "failure",
9527
+ failures: [{ kind: "scan", message: "Strict session candidate limit exceeded", path: dir }],
9528
+ };
9529
+ }
9530
+ files = bounded.files;
9531
+ } else {
9532
+ files = storage.listFilesStrictSync(dir, "*.jsonl");
9533
+ }
9458
9534
  } catch (err) {
9459
9535
  const code = (err as NodeJS.ErrnoException)?.code;
9460
9536
  // Only a confirmed ENOENT proves the scoped root is genuinely absent,
@@ -9487,12 +9563,22 @@ export class SessionManager {
9487
9563
 
9488
9564
  const failures: StrictInventoryFailure[] = [];
9489
9565
  const candidates: StrictInventoryCandidate[] = [];
9566
+ let remainingBytes = options?.maxTotalBytes;
9490
9567
  for (const file of files) {
9491
- const candidate = inventoryReadCandidate(storage, file, cwd, dir);
9568
+ const candidate = inventoryReadCandidate(
9569
+ storage,
9570
+ file,
9571
+ cwd,
9572
+ dir,
9573
+ remainingBytes,
9574
+ options?.filterOtherWorkspaces,
9575
+ );
9492
9576
  if ("failures" in candidate) {
9493
9577
  failures.push(...candidate.failures);
9494
9578
  continue;
9495
9579
  }
9580
+ if (remainingBytes !== undefined) remainingBytes -= candidate.bytes;
9581
+ if ("filtered" in candidate) continue;
9496
9582
  candidates.push(candidate.candidate);
9497
9583
  }
9498
9584
  if (failures.length > 0) {
@@ -9521,7 +9607,12 @@ function inventoryReadCandidate(
9521
9607
  file: string,
9522
9608
  expectedCwd: string,
9523
9609
  sessionDir: string,
9524
- ): { candidate: StrictInventoryCandidate } | { failures: StrictInventoryFailure[] } {
9610
+ maxBytes?: number,
9611
+ filterOtherWorkspaces = false,
9612
+ ):
9613
+ | { candidate: StrictInventoryCandidate; bytes: number }
9614
+ | { filtered: true; bytes: number }
9615
+ | { failures: StrictInventoryFailure[] } {
9525
9616
  const failures: StrictInventoryFailure[] = [];
9526
9617
  const resolvedFile = path.resolve(file);
9527
9618
  if (!pathIsWithin(path.resolve(sessionDir), resolvedFile)) {
@@ -9534,7 +9625,14 @@ function inventoryReadCandidate(
9534
9625
  }
9535
9626
  let snapshot: SessionStorageSnapshot;
9536
9627
  try {
9537
- snapshot = storage.readSnapshotSync(file);
9628
+ if (maxBytes !== undefined) {
9629
+ if (maxBytes < 0 || !storage.readSnapshotBoundedSync) {
9630
+ return { failures: [{ kind: "read", message: "Bounded exact session read is unavailable", path: file }] };
9631
+ }
9632
+ snapshot = storage.readSnapshotBoundedSync(file, maxBytes);
9633
+ } else {
9634
+ snapshot = storage.readSnapshotSync(file);
9635
+ }
9538
9636
  } catch (err) {
9539
9637
  const code = (err as NodeJS.ErrnoException)?.code;
9540
9638
  if (code === "ELOOP" || code === "SYMLINK") {
@@ -9576,8 +9674,12 @@ function inventoryReadCandidate(
9576
9674
  }
9577
9675
  const canonicalHeaderCwd = resolveEquivalentPath(header.cwd);
9578
9676
  if (canonicalHeaderCwd !== resolveEquivalentPath(expectedCwd)) {
9677
+ if (filterOtherWorkspaces) return { filtered: true, bytes: snapshot.bytes.byteLength };
9579
9678
  failures.push({ kind: "cwd", message: "Candidate cwd does not match the scoped workspace", path: file });
9580
9679
  return { failures };
9581
9680
  }
9582
- return { candidate: { path: resolvedFile, id: header.id, cwd: header.cwd, identity: snapshot.stat } };
9681
+ return {
9682
+ candidate: { path: resolvedFile, id: header.id, cwd: header.cwd, identity: snapshot.stat },
9683
+ bytes: snapshot.bytes.byteLength,
9684
+ };
9583
9685
  }