@sayknow-cli/coding-agent 0.5.1 → 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 (122) hide show
  1. package/CHANGELOG.md +185 -14
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/commands/session.d.ts +7 -0
  4. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-registry.d.ts +1 -1
  7. package/dist/types/config/models-config-schema.d.ts +4 -0
  8. package/dist/types/config/settings-schema.d.ts +9 -0
  9. package/dist/types/config/telegram-autostart.d.ts +9 -1
  10. package/dist/types/dap/client.d.ts +1 -0
  11. package/dist/types/lsp/client.d.ts +5 -0
  12. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  13. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  14. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  15. package/dist/types/modes/components/welcome.d.ts +1 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +13 -1
  18. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  19. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  20. package/dist/types/modes/types.d.ts +7 -1
  21. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  22. package/dist/types/session/agent-session.d.ts +9 -0
  23. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  24. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +9 -0
  26. package/dist/types/session/session-storage.d.ts +17 -0
  27. package/dist/types/session-import/claude.d.ts +26 -0
  28. package/dist/types/session-import/codex.d.ts +22 -0
  29. package/dist/types/session-import/command.d.ts +20 -0
  30. package/dist/types/session-import/detect.d.ts +22 -0
  31. package/dist/types/session-import/index.d.ts +7 -0
  32. package/dist/types/session-import/redact.d.ts +23 -0
  33. package/dist/types/session-import/service.d.ts +28 -0
  34. package/dist/types/session-import/types.d.ts +125 -0
  35. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  36. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  37. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  38. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  39. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  40. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  42. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  43. package/dist/types/slash-commands/types.d.ts +5 -0
  44. package/dist/types/tools/ask.d.ts +169 -4
  45. package/package.json +16 -7
  46. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  47. package/src/cli/config-cli.ts +23 -8
  48. package/src/cli/update-cli.ts +265 -21
  49. package/src/commands/session.ts +88 -2
  50. package/src/config/atomic-yaml-patch.ts +167 -30
  51. package/src/config/keybindings.ts +10 -0
  52. package/src/config/model-profiles.ts +69 -1
  53. package/src/config/model-registry.ts +72 -38
  54. package/src/config/models-config-schema.ts +1 -1
  55. package/src/config/settings-schema.ts +9 -0
  56. package/src/config/settings.ts +13 -2
  57. package/src/config/telegram-autostart.ts +11 -4
  58. package/src/dap/client.ts +78 -30
  59. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  60. package/src/internal-urls/docs-index.generated.ts +5 -4
  61. package/src/lsp/client.ts +17 -29
  62. package/src/main.ts +1 -1
  63. package/src/modes/action-registry.ts +2 -0
  64. package/src/modes/components/pet-capability.ts +22 -13
  65. package/src/modes/components/pet-selector.ts +1 -1
  66. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  67. package/src/modes/components/welcome.ts +5 -0
  68. package/src/modes/controllers/event-controller.ts +1 -1
  69. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  70. package/src/modes/controllers/input-controller.ts +71 -6
  71. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  72. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  73. package/src/modes/controllers/selector-controller.ts +47 -19
  74. package/src/modes/interactive-mode.ts +54 -4
  75. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  76. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  77. package/src/modes/types.ts +5 -1
  78. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  79. package/src/prompts/system/eager-todo.md +2 -0
  80. package/src/prompts/system/plan-mode-approved.md +1 -1
  81. package/src/prompts/system/system-prompt.md +4 -2
  82. package/src/runtime-mcp/transports/stdio.ts +96 -33
  83. package/src/sdk/broker/lifecycle.ts +6 -5
  84. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  85. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  86. package/src/sdk/session.ts +25 -18
  87. package/src/session/agent-session.ts +120 -25
  88. package/src/session/internal/managed-session-scope.ts +41 -6
  89. package/src/session/internal/managed-session-storage.ts +14 -0
  90. package/src/session/session-manager.ts +113 -11
  91. package/src/session/session-storage.ts +70 -0
  92. package/src/session-import/claude.ts +382 -0
  93. package/src/session-import/codex.ts +458 -0
  94. package/src/session-import/command.ts +61 -0
  95. package/src/session-import/detect.ts +133 -0
  96. package/src/session-import/index.ts +27 -0
  97. package/src/session-import/redact.ts +125 -0
  98. package/src/session-import/service.ts +749 -0
  99. package/src/session-import/types.ts +163 -0
  100. package/src/skc-runtime/boot-generation.ts +172 -0
  101. package/src/skc-runtime/launch-tmux.ts +219 -41
  102. package/src/skc-runtime/launch-worktree.ts +277 -4
  103. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  104. package/src/skc-runtime/session-restore.ts +296 -0
  105. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  106. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  107. package/src/skc-runtime/tmux-sessions.ts +284 -108
  108. package/src/slash-commands/acp-builtins.ts +5 -1
  109. package/src/slash-commands/builtin-registry.ts +109 -5
  110. package/src/slash-commands/types.ts +5 -0
  111. package/src/tools/ask.ts +188 -10
  112. package/src/tools/eval.ts +2 -2
  113. package/src/export/html/template.css +0 -1060
  114. package/src/export/html/template.html +0 -47
  115. package/src/export/html/template.js +0 -2348
  116. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  117. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  118. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  119. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  120. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  121. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  122. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -2272,13 +2272,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2272
2272
  };
2273
2273
  };
2274
2274
 
2275
+ const explicitlyDisabledTools = options.toolNames?.length === 0;
2275
2276
  const toolNamesFromRegistry = Array.from(toolRegistry.keys());
2276
2277
  const hasExplicitToolNames = options.toolNames !== undefined;
2277
2278
  const requestedToolNames = hasExplicitToolNames
2278
2279
  ? [
2279
2280
  ...new Set([
2280
2281
  ...options.toolNames!.map(name => name.toLowerCase()),
2281
- ...(settings.get("goal.enabled") ? ["goal"] : []),
2282
+ ...(settings.get("goal.enabled") && !explicitlyDisabledTools ? ["goal"] : []),
2282
2283
  ]),
2283
2284
  ]
2284
2285
  : toolNamesFromRegistry;
@@ -2298,7 +2299,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2298
2299
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
2299
2300
  );
2300
2301
  const requestedActiveToolNames = normalizedRequested;
2301
- const initialRequestedActiveToolNames = options.toolNames
2302
+ const initialRequestedActiveToolNames = hasExplicitToolNames
2302
2303
  ? requestedActiveToolNames
2303
2304
  : requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
2304
2305
  const discoverableMCPToolNames = new Set<string>();
@@ -2348,11 +2349,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2348
2349
  ];
2349
2350
  hasExplicitMCPToolSelection =
2350
2351
  hasExplicitToolNames && (options.toolNames!.length === 0 || selectableExplicitMCPToolNames.length > 0);
2351
- initialSelectedMCPToolNames = existingSession.hasPersistedMCPToolSelection
2352
- ? restoredSelectedMCPToolNames
2353
- : hasExplicitMCPToolSelection
2354
- ? selectableExplicitMCPToolNames
2355
- : defaultSelectedMCPToolNames;
2352
+ initialSelectedMCPToolNames = explicitlyDisabledTools
2353
+ ? []
2354
+ : existingSession.hasPersistedMCPToolSelection
2355
+ ? restoredSelectedMCPToolNames
2356
+ : hasExplicitMCPToolSelection
2357
+ ? selectableExplicitMCPToolNames
2358
+ : defaultSelectedMCPToolNames;
2356
2359
  initialToolNames = [
2357
2360
  ...new Set([
2358
2361
  ...initialRequestedActiveToolNames.filter(name => !discoverableMCPToolNames.has(name)),
@@ -2361,14 +2364,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2361
2364
  ];
2362
2365
  }
2363
2366
 
2364
- // Custom, extension-registered, and plugin-bundle MCP tools are always
2365
- // included regardless of the caller's built-in tool filter. Plugin MCPs
2366
- // remain always-on even when generic MCP discovery is disabled.
2367
- const alwaysInclude: string[] = [
2368
- ...(options.customTools?.map(t => (isCustomTool(t) ? t.name : t.name)) ?? []),
2369
- ...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
2370
- ...pluginMcpToolNames,
2371
- ];
2367
+ // Custom, extension-registered, and plugin-bundle MCP tools bypass
2368
+ // built-in filtering, but not an explicit zero-tool selection.
2369
+ const alwaysInclude: string[] = explicitlyDisabledTools
2370
+ ? []
2371
+ : [
2372
+ ...(options.customTools?.map(t => (isCustomTool(t) ? t.name : t.name)) ?? []),
2373
+ ...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
2374
+ ...pluginMcpToolNames,
2375
+ ];
2372
2376
  const pluginMcpToolNameSet = new Set(pluginMcpToolNames);
2373
2377
  for (const name of alwaysInclude) {
2374
2378
  if (mcpDiscoveryEnabled && discoverableMCPToolNames.has(name) && !pluginMcpToolNameSet.has(name)) {
@@ -2414,9 +2418,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2414
2418
  allowedDiscoveredBuiltinNames,
2415
2419
  essentialBuiltinNames,
2416
2420
  );
2417
- initialSelectedDiscoveredBuiltinToolNames = existingSession.hasPersistedDiscoveredBuiltinToolSelection
2418
- ? restoredDiscoveredNames
2419
- : explicitlyRequestedDiscoveredBuiltinToolNames;
2421
+ initialSelectedDiscoveredBuiltinToolNames = explicitlyDisabledTools
2422
+ ? []
2423
+ : existingSession.hasPersistedDiscoveredBuiltinToolSelection
2424
+ ? restoredDiscoveredNames
2425
+ : explicitlyRequestedDiscoveredBuiltinToolNames;
2420
2426
  initialToolNames = [...new Set([...baselineInitialToolNames, ...initialSelectedDiscoveredBuiltinToolNames])];
2421
2427
  hasExplicitDiscoveredBuiltinToolSelection =
2422
2428
  hasExplicitToolNames &&
@@ -2717,6 +2723,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2717
2723
  workspaceTree: resolvedWorkspaceTree,
2718
2724
  reloadSshTool,
2719
2725
  requestedToolNames: requestedToolNameSet,
2726
+ explicitlyDisabledTools,
2720
2727
  discoverableToolAllowedNames: options.discoverableToolAllowedNames,
2721
2728
  mcpDiscoveryEnabled,
2722
2729
  discoveryMode: effectiveDiscoveryMode,
@@ -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. */
@@ -1547,6 +1549,27 @@ type SessionAdmissionLease = {
1547
1549
  release(): void;
1548
1550
  };
1549
1551
 
1552
+ const TODO_WRITE_INFRASTRUCTURE_ERROR =
1553
+ /(?:HTTP2StreamReset|ECONNRESET|ECONNREFUSED|ETIMEDOUT|network error|transport error|connection reset|stream reset|timed out|timeout|fetching https?:)/iu;
1554
+ const TODO_WRITE_ERROR_CONTEXT_LIMIT = 1_000;
1555
+
1556
+ export function buildTodoWriteFailureReminder(errorText: string | undefined, failureCount: number): string {
1557
+ const normalizedError = errorText?.trim().slice(0, TODO_WRITE_ERROR_CONTEXT_LIMIT);
1558
+ const shouldRetry =
1559
+ failureCount === 1 && (!normalizedError || !TODO_WRITE_INFRASTRUCTURE_ERROR.test(normalizedError));
1560
+ const directive = shouldRetry
1561
+ ? "Correct the todo_write payload and retry once with a minimal argument object. If that retry fails, do not call todo_write again in this user turn; continue the requested work using a checklist in reasoning."
1562
+ : "Do not call todo_write again in this user turn. Continue the requested work now, track the remaining steps in reasoning, and report progress directly.";
1563
+
1564
+ return [
1565
+ "<system-reminder>",
1566
+ "todo_write failed, so todo progress is not visible to the user.",
1567
+ normalizedError ? `Failure: ${normalizedError}` : "Failure: todo_write returned an error.",
1568
+ directive,
1569
+ "</system-reminder>",
1570
+ ].join("\n");
1571
+ }
1572
+
1550
1573
  export class AgentSession {
1551
1574
  readonly agent: Agent;
1552
1575
  readonly sessionManager: SessionManager;
@@ -1727,6 +1750,7 @@ export class AgentSession {
1727
1750
  // Handoff state
1728
1751
  #handoffAbortController: AbortController | undefined = undefined;
1729
1752
  #skipPostTurnMaintenanceAssistantTimestamp: number | undefined = undefined;
1753
+ #suppressNextPostTurnMaintenanceAfterAutoHandoff = false;
1730
1754
 
1731
1755
  // Retry state
1732
1756
  #retryAbortController: AbortController | undefined = undefined;
@@ -1738,8 +1762,10 @@ export class AgentSession {
1738
1762
  #overflowMaintenanceAttempts = 0;
1739
1763
  #defaultFallbackExhaustedLastTurn = false;
1740
1764
  #fallbackInvocationId = 0;
1765
+ #fallbackSuppressedSelectors = new Set<string>();
1741
1766
  // Todo completion reminder state
1742
1767
  #todoReminderCount = 0;
1768
+ #todoWriteFailureCount = 0;
1743
1769
  #deepInterviewUserIntentEpoch = 0;
1744
1770
  #deepInterviewTurnOwnerEpoch = 0;
1745
1771
  #deepInterviewGenuineUserMessageEpochs = new WeakMap<object, number>();
@@ -1831,6 +1857,7 @@ export class AgentSession {
1831
1857
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1832
1858
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
1833
1859
  #requestedToolNames: ReadonlySet<string> | undefined;
1860
+ #explicitlyDisabledTools: boolean;
1834
1861
  #baseSystemPrompt: string[];
1835
1862
  #initialWorkspaceTree: WorkspaceTree | undefined;
1836
1863
  /** Throttle cache for the per-turn volatile workspace-tree scan (see #buildVolatileProjectContextMessage). */
@@ -2021,6 +2048,12 @@ export class AgentSession {
2021
2048
  });
2022
2049
  }
2023
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
+
2024
2057
  /**
2025
2058
  * Reject a turn start while a handoff transition owns the session. Handoff never
2026
2059
  * routes its own generation/injection through these turn-start chokepoints, and
@@ -2046,6 +2079,7 @@ export class AgentSession {
2046
2079
  * orchestrator does not hold it), so there is no self-deadlock.
2047
2080
  */
2048
2081
  #sessionTransitionKind: string | undefined;
2082
+ #sessionTurnAdmissionFenced = false;
2049
2083
 
2050
2084
  #beginSessionTransition(kind: string): void {
2051
2085
  if (this.#sessionTransitionKind !== undefined) {
@@ -2086,6 +2120,7 @@ export class AgentSession {
2086
2120
  code: "busy",
2087
2121
  });
2088
2122
  }
2123
+ if (this.#sessionTurnAdmissionFenced) throw this.#sessionTransitionAdmissionBusyError();
2089
2124
 
2090
2125
  const entry: SessionAdmissionEntry = {
2091
2126
  kind,
@@ -2114,6 +2149,13 @@ export class AgentSession {
2114
2149
  code: "busy",
2115
2150
  });
2116
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
+ }
2117
2159
 
2118
2160
  const release = () => {
2119
2161
  if (entry.released) return;
@@ -2350,6 +2392,7 @@ export class AgentSession {
2350
2392
  this.#toolRegistry = config.toolRegistry ?? new Map();
2351
2393
  this.#workflowGateToolSession = config.workflowGateToolSession;
2352
2394
  this.#requestedToolNames = config.requestedToolNames;
2395
+ this.#explicitlyDisabledTools = config.explicitlyDisabledTools === true;
2353
2396
  this.#transformContext = config.transformContext ?? (messages => messages);
2354
2397
  this.#onPayload = config.onPayload;
2355
2398
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
@@ -3757,8 +3800,9 @@ export class AgentSession {
3757
3800
  this.#resolveTtsrResume();
3758
3801
  }
3759
3802
  this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
3760
- if (this.#handoffAbortController) {
3803
+ if (this.#handoffAbortController || this.#suppressNextPostTurnMaintenanceAfterAutoHandoff) {
3761
3804
  this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
3805
+ this.#suppressNextPostTurnMaintenanceAfterAutoHandoff = false;
3762
3806
  }
3763
3807
  if (
3764
3808
  assistantMsg.stopReason !== "error" &&
@@ -3794,24 +3838,21 @@ export class AgentSession {
3794
3838
  if (toolName === "edit" && details?.path) {
3795
3839
  this.#invalidateFileCacheForPath(details.path);
3796
3840
  }
3797
- if (toolName === "todo_write" && !isError && Array.isArray(details?.phases)) {
3798
- this.setTodoPhases(details.phases);
3841
+ if (toolName === "todo_write" && !isError) {
3842
+ this.#todoWriteFailureCount = 0;
3843
+ if (Array.isArray(details?.phases)) {
3844
+ this.setTodoPhases(details.phases);
3845
+ }
3799
3846
  }
3800
3847
  if (toolName === "todo_write" && isError) {
3848
+ this.#todoWriteFailureCount++;
3801
3849
  const errorText = content?.find(part => part.type === "text")?.text;
3802
- const reminderText = [
3803
- "<system-reminder>",
3804
- "todo_write failed, so todo progress is not visible to the user.",
3805
- errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
3806
- "Fix the todo payload and call todo_write again before continuing.",
3807
- "</system-reminder>",
3808
- ].join("\n");
3809
3850
  await this.sendCustomMessage(
3810
3851
  {
3811
3852
  customType: "todo-write-error-reminder",
3812
- content: reminderText,
3853
+ content: buildTodoWriteFailureReminder(errorText, this.#todoWriteFailureCount),
3813
3854
  display: false,
3814
- details: { toolName, errorText },
3855
+ details: { toolName, errorText, failureCount: this.#todoWriteFailureCount },
3815
3856
  },
3816
3857
  { deliverAs: "nextTurn" },
3817
3858
  );
@@ -5853,6 +5894,7 @@ export class AgentSession {
5853
5894
  }
5854
5895
 
5855
5896
  async activateDiscoveredTools(toolNames: string[]): Promise<string[]> {
5897
+ if (this.#explicitlyDisabledTools) return [];
5856
5898
  const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
5857
5899
  const previousSelectedDiscoveredBuiltinToolNames = this.#getSelectedDiscoveredBuiltinToolNames();
5858
5900
  const nextActiveToolNames = this.getActiveToolNames();
@@ -6080,7 +6122,9 @@ export class AgentSession {
6080
6122
  nextSelectedDiscoveredBuiltinToolNames?: string[];
6081
6123
  },
6082
6124
  ): Promise<void> {
6083
- 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])];
6084
6128
  const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
6085
6129
  const previousSelectedDiscoveredBuiltinToolNames =
6086
6130
  options?.previousSelectedDiscoveredBuiltinToolNames ?? this.#getSelectedDiscoveredBuiltinToolNames();
@@ -6190,6 +6234,11 @@ export class AgentSession {
6190
6234
  await this.#applyActiveToolsByName(nextActive);
6191
6235
  }
6192
6236
 
6237
+ /** Whether the caller explicitly selected no tools for this session. */
6238
+ hasExplicitlyDisabledTools(): boolean {
6239
+ return this.#explicitlyDisabledTools;
6240
+ }
6241
+
6193
6242
  /**
6194
6243
  * Set active tools by name.
6195
6244
  * Only tools in the registry can be enabled. Unknown tool names are ignored.
@@ -6201,6 +6250,11 @@ export class AgentSession {
6201
6250
  }
6202
6251
 
6203
6252
  async #restoreMCPSelectionsForSessionContext(sessionContext: SessionContext): Promise<void> {
6253
+ if (this.#explicitlyDisabledTools) {
6254
+ this.#selectedDiscoveredToolNames.clear();
6255
+ await this.#applyActiveToolsByName([], { persistMCPSelection: false });
6256
+ return;
6257
+ }
6204
6258
  if (!this.#mcpDiscoveryEnabled && this.#resolveEffectiveDiscoveryMode() !== "all") {
6205
6259
  await this.#attachAskToolIfWorkflowActive();
6206
6260
  return;
@@ -7054,6 +7108,7 @@ export class AgentSession {
7054
7108
  this.#toolRegistry.set(askTool.name, askTool);
7055
7109
  }
7056
7110
 
7111
+ if (this.#explicitlyDisabledTools) return;
7057
7112
  try {
7058
7113
  if ((this.#workflowGateEmitter?.listPendingGates?.().length ?? 0) > 0) {
7059
7114
  this.#attachAskTool();
@@ -7099,6 +7154,7 @@ export class AgentSession {
7099
7154
  }
7100
7155
 
7101
7156
  #attachAskTool(): void {
7157
+ if (this.#explicitlyDisabledTools) return;
7102
7158
  const askTool = this.#toolRegistry.get("ask");
7103
7159
  if (!askTool || this.getActiveToolNames().includes(askTool.name)) return;
7104
7160
  this.#setGuardedAgentTools([...this.agent.state.tools, askTool]);
@@ -7181,6 +7237,7 @@ export class AgentSession {
7181
7237
  }
7182
7238
 
7183
7239
  async #activatePendingSkcGoalModeRequest(): Promise<boolean> {
7240
+ if (this.#explicitlyDisabledTools) return false;
7184
7241
  if (!this.settings.get("goal.enabled")) return false;
7185
7242
  const pendingGoal = await consumePendingGoalModeRequest(
7186
7243
  this.sessionManager.getCwd(),
@@ -7193,9 +7250,10 @@ export class AgentSession {
7193
7250
  }
7194
7251
 
7195
7252
  const previousTools = this.getActiveToolNames();
7196
- const goalTools = [...new Set([...previousTools, "goal"])];
7197
7253
  await this.#goalRuntime.createGoal({ objective: pendingGoal.objective, provenance: pendingGoal.provenance });
7198
- await this.setActiveToolsByName(goalTools);
7254
+ if (!this.#explicitlyDisabledTools) {
7255
+ await this.setActiveToolsByName([...new Set([...previousTools, "goal"])]);
7256
+ }
7199
7257
  if (this.isStreaming) {
7200
7258
  await this.sendGoalModeContext({ deliverAs: "steer" });
7201
7259
  }
@@ -7793,6 +7851,7 @@ export class AgentSession {
7793
7851
  this.#defaultFallbackChain().resetAttemptBudget();
7794
7852
  this.#overflowMaintenanceAttempts = 0;
7795
7853
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
7854
+ this.#todoWriteFailureCount = 0;
7796
7855
  }
7797
7856
  // Flush any pending bash messages before the new prompt
7798
7857
  this.#flushPendingBashMessages();
@@ -9452,7 +9511,6 @@ export class AgentSession {
9452
9511
  persistMCPSelection: false,
9453
9512
  nextSelectedDiscoveredBuiltinToolNames: [],
9454
9513
  });
9455
- } else {
9456
9514
  }
9457
9515
  }
9458
9516
  this.#todoReminderCount = 0;
@@ -10046,7 +10104,13 @@ export class AgentSession {
10046
10104
  });
10047
10105
  if (!resolved.model) continue;
10048
10106
 
10049
- 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;
10050
10114
  roleModels.push({
10051
10115
  role,
10052
10116
  model: resolved.model,
@@ -11606,6 +11670,11 @@ export class AgentSession {
11606
11670
  this.#resourceSampler = sampler;
11607
11671
  }
11608
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
+
11609
11678
  setRetainedMemorySampler(sampler: (() => RetainedMemorySample) | undefined): void {
11610
11679
  this.#retainedMemorySampler = sampler;
11611
11680
  }
@@ -11639,26 +11708,31 @@ export class AgentSession {
11639
11708
  materializedResidentBytes: this.#streamingEditFileCache.totalBytes,
11640
11709
  tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
11641
11710
  tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
11711
+ transcriptFileBytes: this.sessionManager.getTranscriptFileBytes(),
11642
11712
  };
11643
11713
  }
11644
11714
 
11645
11715
  async #checkEstimatedContextBeforePromptOnce(pendingMessages: readonly AgentMessage[]): Promise<void> {
11646
11716
  const model = this.model;
11647
11717
  if (!model) return;
11648
- const contextWindow = model.contextWindow ?? 0;
11649
- if (contextWindow <= 0) return;
11650
11718
  // F6: non-disableable emergency floor — compact before OOM even when token-based
11651
11719
  // compaction is disabled or its threshold is set too high (weak-hardware protection).
11652
11720
  const emergencyReason = emergencyCompactionReason(this.#resourceSampler());
11653
11721
  if (emergencyReason) {
11654
11722
  logger.warn("Emergency compaction triggered (resource floor exceeded)", { reason: emergencyReason });
11655
- await this.#runAutoCompaction("overflow", false, false, {
11723
+ const status = await this.#runAutoCompaction("overflow", false, false, {
11656
11724
  continueAfterMaintenance: false,
11657
11725
  deferHandoffMaintenance: false,
11658
11726
  force: true,
11659
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
+ }
11660
11732
  return;
11661
11733
  }
11734
+ const contextWindow = model.contextWindow ?? 0;
11735
+ if (contextWindow <= 0) return;
11662
11736
  const compactionSettings = this.settings.getGroup("compaction");
11663
11737
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
11664
11738
 
@@ -11692,6 +11766,7 @@ export class AgentSession {
11692
11766
  await this.#runAutoCompaction("threshold", false, false, {
11693
11767
  continueAfterMaintenance: false,
11694
11768
  deferHandoffMaintenance: false,
11769
+ suppressNextPostTurnMaintenanceAfterHandoff: true,
11695
11770
  });
11696
11771
  }
11697
11772
  }
@@ -11783,6 +11858,7 @@ export class AgentSession {
11783
11858
  return;
11784
11859
  }
11785
11860
 
11861
+ if (this.#explicitlyDisabledTools) return;
11786
11862
  this.#attachAskTool();
11787
11863
 
11788
11864
  const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
@@ -12682,6 +12758,7 @@ export class AgentSession {
12682
12758
  force?: boolean;
12683
12759
  signal?: AbortSignal;
12684
12760
  beforeTerminalOverflowNoop?: () => void;
12761
+ suppressNextPostTurnMaintenanceAfterHandoff?: boolean;
12685
12762
  },
12686
12763
  ): Promise<AutoCompactionTerminalStatus> {
12687
12764
  const compactionSettings = this.settings.getGroup("compaction");
@@ -12763,6 +12840,9 @@ export class AgentSession {
12763
12840
  if (autoCompactionSignal.aborted) return await emitAborted();
12764
12841
 
12765
12842
  if (handoffResult) {
12843
+ if (options?.suppressNextPostTurnMaintenanceAfterHandoff) {
12844
+ this.#suppressNextPostTurnMaintenanceAfterAutoHandoff = true;
12845
+ }
12766
12846
  await this.#emitSessionEvent({
12767
12847
  type: "auto_compaction_end",
12768
12848
  action,
@@ -13482,12 +13562,15 @@ export class AgentSession {
13482
13562
  if (controller.chain.entries.length > 1) await this.#advanceDefaultFallback(controller, "new_turn", 0);
13483
13563
  return;
13484
13564
  }
13565
+ const primarySelector = controller.chain.entries[0];
13485
13566
  if (
13486
13567
  this.settings.get("retry.fallbackRevertPolicy") === "cooldown-expiry" &&
13487
- controller.activeIndex > 0
13488
- // TODO(upstream-v0.11): ModelRegistry.getSelectorSuppressionStatus was removed;
13489
- // 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)
13490
13572
  ) {
13573
+ this.#fallbackSuppressedSelectors.delete(primarySelector);
13491
13574
  controller.resetForNewTurn();
13492
13575
  }
13493
13576
  }
@@ -13540,7 +13623,9 @@ export class AgentSession {
13540
13623
  const existing = this.#defaultFallbackController;
13541
13624
  if (
13542
13625
  existing &&
13543
- (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"))
13544
13629
  ) {
13545
13630
  return existing;
13546
13631
  }
@@ -13826,6 +13911,7 @@ export class AgentSession {
13826
13911
 
13827
13912
  if (managedFallback && trigger.class === "rate_limit" && trigger.retryAfterMs !== undefined && failedSelector) {
13828
13913
  this.#modelRegistry.suppressSelector(failedSelector, Date.now() + trigger.retryAfterMs);
13914
+ this.#fallbackSuppressedSelectors.add(failedSelector);
13829
13915
  }
13830
13916
 
13831
13917
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
@@ -15054,14 +15140,22 @@ export class AgentSession {
15054
15140
  * Switch to a different session file.
15055
15141
  * Aborts current operation, loads messages, restores model/thinking.
15056
15142
  * Listeners are preserved and will continue receiving events.
15143
+ * `requireIdle` rejects an active turn and fences new admissions for the transition.
15057
15144
  * @returns true if switch completed, false if cancelled by hook
15058
15145
  */
15059
15146
  async switchSession(
15060
15147
  sessionPath: string,
15061
- options?: { transition?: SessionSwitchEvent["transition"] },
15148
+ options?: { transition?: SessionSwitchEvent["transition"]; requireIdle?: boolean },
15062
15149
  ): Promise<boolean> {
15063
15150
  this.#beginSessionTransition("switch-session");
15064
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
+ }
15065
15159
  const previousSessionFile = this.sessionManager.getSessionFile();
15066
15160
  const switchingToDifferentSession = previousSessionFile
15067
15161
  ? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
@@ -15287,6 +15381,7 @@ export class AgentSession {
15287
15381
  throw error;
15288
15382
  }
15289
15383
  } finally {
15384
+ this.#sessionTurnAdmissionFenced = false;
15290
15385
  this.#endSessionTransition();
15291
15386
  }
15292
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();