@gajae-code/coding-agent 0.12.0 → 0.12.2

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 (247) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +56 -2
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  16. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
  17. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  18. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  19. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  20. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  21. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
  22. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  23. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  24. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  25. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  26. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  28. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  29. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  32. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  34. package/dist/types/modes/interactive-mode.d.ts +9 -0
  35. package/dist/types/modes/theme/theme.d.ts +3 -1
  36. package/dist/types/modes/types.d.ts +32 -0
  37. package/dist/types/reminders/star-reminder.d.ts +3 -13
  38. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  39. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  40. package/dist/types/sdk/broker/transport.d.ts +2 -0
  41. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  43. package/dist/types/sdk/bus/config.d.ts +6 -2
  44. package/dist/types/sdk/bus/index.d.ts +29 -0
  45. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  46. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  47. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  48. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  49. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  50. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  51. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  52. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
  53. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  54. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  55. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  56. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  57. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  58. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  59. package/dist/types/sdk/prompt-status.d.ts +23 -3
  60. package/dist/types/sdk/session.d.ts +6 -0
  61. package/dist/types/session/agent-session.d.ts +40 -2
  62. package/dist/types/session/artifacts.d.ts +8 -0
  63. package/dist/types/session/blob-store.d.ts +57 -4
  64. package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
  65. package/dist/types/session/session-manager.d.ts +23 -6
  66. package/dist/types/session/session-storage.d.ts +4 -0
  67. package/dist/types/session/streaming-output.d.ts +36 -7
  68. package/dist/types/setup/credential-import.d.ts +12 -0
  69. package/dist/types/skill-state/active-state.d.ts +12 -0
  70. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  71. package/dist/types/task/executor.d.ts +3 -2
  72. package/dist/types/task/index.d.ts +7 -1
  73. package/dist/types/task/output-manager.d.ts +1 -0
  74. package/dist/types/task/provider-retry-status.d.ts +8 -0
  75. package/dist/types/task/receipt.d.ts +9 -1
  76. package/dist/types/task/types.d.ts +23 -0
  77. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  78. package/dist/types/tools/bash-interactive.d.ts +4 -1
  79. package/dist/types/tools/bash.d.ts +3 -1
  80. package/dist/types/tools/image-gen.d.ts +4 -0
  81. package/dist/types/tools/index.d.ts +6 -0
  82. package/dist/types/tools/output-meta.d.ts +23 -2
  83. package/dist/types/tools/path-utils.d.ts +1 -0
  84. package/dist/types/tools/read.d.ts +0 -6
  85. package/dist/types/tools/review.d.ts +5 -7
  86. package/dist/types/tools/subagent.d.ts +2 -0
  87. package/dist/types/utils/gh.d.ts +13 -0
  88. package/dist/types/utils/image-resize.d.ts +1 -0
  89. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  90. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  91. package/dist/types/web/search/providers/xai.d.ts +2 -0
  92. package/package.json +10 -8
  93. package/scripts/acp-conformance-agent.ts +218 -0
  94. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  95. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  96. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  97. package/scripts/resident-memory-bench.ts +1341 -0
  98. package/scripts/run-acp-conformance.ts +290 -0
  99. package/src/async/job-manager.ts +99 -39
  100. package/src/cli/fast-help.ts +1 -1
  101. package/src/cli/plugin-cli.ts +176 -18
  102. package/src/commands/team.ts +19 -1
  103. package/src/config/file-lock.ts +11 -2
  104. package/src/config/settings-schema.ts +53 -2
  105. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  106. package/src/coordinator-mcp/server.ts +5 -12
  107. package/src/debug/index.ts +42 -17
  108. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  109. package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
  110. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  111. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
  112. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  113. package/src/defaults/gjc-defaults.ts +4 -4
  114. package/src/exa/mcp-client.ts +12 -3
  115. package/src/exec/bash-executor.ts +144 -27
  116. package/src/extensibility/extensions/runner.ts +15 -0
  117. package/src/extensibility/extensions/types.ts +18 -1
  118. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  119. package/src/extensibility/gjc-plugins/index.ts +19 -2
  120. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  121. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  122. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  123. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  124. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  125. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  126. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  127. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  128. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  129. package/src/extensibility/gjc-plugins/types.ts +141 -0
  130. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  131. package/src/gjc-runtime/launch-tmux.ts +25 -6
  132. package/src/gjc-runtime/launch-worktree.ts +103 -1
  133. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  134. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  135. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  136. package/src/gjc-runtime/ralplan-runtime.ts +491 -27
  137. package/src/gjc-runtime/team-launch.ts +8 -0
  138. package/src/gjc-runtime/team-runtime.ts +140 -32
  139. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  140. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  141. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  142. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  143. package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
  144. package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
  145. package/src/gjc-runtime/workflow-manifest.ts +21 -70
  146. package/src/internal-urls/docs-index.generated.ts +16 -16
  147. package/src/modes/DESIGN.md +104 -0
  148. package/src/modes/acp/acp-agent.ts +298 -186
  149. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  150. package/src/modes/components/irc-sidebar.ts +30 -1
  151. package/src/modes/components/notifications-settings-editor.ts +12 -1
  152. package/src/modes/components/oauth-selector.ts +68 -30
  153. package/src/modes/components/session-observer-overlay.ts +305 -86
  154. package/src/modes/components/settings-selector.ts +56 -5
  155. package/src/modes/components/status-line/gh.ts +16 -0
  156. package/src/modes/components/tool-status-header.ts +3 -15
  157. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  158. package/src/modes/controllers/command-controller.ts +42 -34
  159. package/src/modes/controllers/event-controller.ts +27 -16
  160. package/src/modes/controllers/extension-ui-controller.ts +193 -34
  161. package/src/modes/controllers/selector-controller.ts +160 -22
  162. package/src/modes/interactive-mode.ts +189 -62
  163. package/src/modes/jobs-observer.ts +1 -1
  164. package/src/modes/runtime-init.ts +3 -0
  165. package/src/modes/theme/theme.ts +11 -9
  166. package/src/modes/types.ts +88 -0
  167. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  168. package/src/prompts/system/eager-todo.md +8 -4
  169. package/src/prompts/system/system-prompt.md +11 -4
  170. package/src/prompts/tools/bash.md +5 -5
  171. package/src/prompts/tools/search-tool-bm25.md +5 -0
  172. package/src/prompts/tools/task.md +1 -1
  173. package/src/reminders/star-reminder.ts +4 -42
  174. package/src/runtime-mcp/manager.ts +19 -4
  175. package/src/sdk/acp/adapter.ts +22 -6
  176. package/src/sdk/broker/lifecycle.ts +18 -13
  177. package/src/sdk/broker/transport.ts +35 -3
  178. package/src/sdk/bus/attachment-registry.ts +5 -1
  179. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  180. package/src/sdk/bus/chat-daemon-control.ts +7 -3
  181. package/src/sdk/bus/config.ts +18 -2
  182. package/src/sdk/bus/index.ts +661 -126
  183. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  184. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  185. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  186. package/src/sdk/bus/reconciliation-store.ts +95 -16
  187. package/src/sdk/bus/rich-draft.ts +1 -1
  188. package/src/sdk/bus/rich-render.ts +2 -2
  189. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  190. package/src/sdk/bus/telegram-cli.ts +33 -8
  191. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  192. package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
  193. package/src/sdk/bus/telegram-daemon.ts +916 -23
  194. package/src/sdk/bus/telegram-reference.ts +40 -3
  195. package/src/sdk/bus/threaded-render.ts +48 -3
  196. package/src/sdk/bus/topic-registry.ts +20 -0
  197. package/src/sdk/host/query/revision-store.ts +36 -2
  198. package/src/sdk/host/reverse-leases.ts +44 -7
  199. package/src/sdk/prompt-status.ts +16 -3
  200. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  201. package/src/sdk/session.ts +89 -9
  202. package/src/session/agent-session.ts +844 -173
  203. package/src/session/artifacts.ts +22 -0
  204. package/src/session/auth-broker-config.ts +10 -3
  205. package/src/session/blob-store.ts +915 -24
  206. package/src/session/internal/managed-session-scope.ts +200 -111
  207. package/src/session/internal/managed-session-storage.ts +492 -62
  208. package/src/session/session-manager.ts +1614 -435
  209. package/src/session/session-storage.ts +38 -45
  210. package/src/session/streaming-output.ts +238 -68
  211. package/src/setup/credential-import.ts +75 -13
  212. package/src/skill-state/active-state.ts +32 -0
  213. package/src/skill-state/workflow-hud.ts +17 -0
  214. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  215. package/src/task/executor.ts +122 -39
  216. package/src/task/index.ts +280 -45
  217. package/src/task/output-manager.ts +24 -14
  218. package/src/task/provider-retry-status.ts +16 -0
  219. package/src/task/receipt.ts +34 -8
  220. package/src/task/render.ts +6 -2
  221. package/src/task/types.ts +108 -0
  222. package/src/tools/ask-answer-registry.ts +12 -6
  223. package/src/tools/ask.ts +86 -55
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash-interactive.ts +8 -3
  227. package/src/tools/bash.ts +503 -60
  228. package/src/tools/find.ts +1 -0
  229. package/src/tools/image-gen.ts +29 -5
  230. package/src/tools/index.ts +6 -0
  231. package/src/tools/output-meta.ts +108 -14
  232. package/src/tools/path-utils.ts +3 -2
  233. package/src/tools/read.ts +18 -0
  234. package/src/tools/review.ts +5 -7
  235. package/src/tools/search.ts +1 -0
  236. package/src/tools/subagent-render.ts +6 -2
  237. package/src/tools/subagent.ts +6 -0
  238. package/src/tools/telegram-send.ts +6 -3
  239. package/src/utils/gh.ts +39 -0
  240. package/src/utils/image-resize.ts +3 -0
  241. package/src/web/search/providers/anthropic.ts +4 -4
  242. package/src/web/search/providers/kimi.ts +15 -5
  243. package/src/web/search/providers/searxng.ts +47 -4
  244. package/src/web/search/providers/xai.ts +7 -2
  245. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  246. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  247. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -36,6 +36,7 @@ import {
36
36
  type ManagedAttemptDecision,
37
37
  type ManagedAttemptOutcome,
38
38
  type MidRunMaintenanceOutcome,
39
+ type RunSettlementProof,
39
40
  resolveTelemetry,
40
41
  type StablePrefixSnapshot,
41
42
  ThinkingLevel,
@@ -82,6 +83,7 @@ import type {
82
83
  ProviderSessionState,
83
84
  ServiceTier,
84
85
  SimpleStreamOptions,
86
+ StopReason,
85
87
  TextContent,
86
88
  ToolCall,
87
89
  ToolChoice,
@@ -105,6 +107,7 @@ import {
105
107
  classifyFallbackTrigger,
106
108
  type FallbackAttemptToken,
107
109
  type FallbackTriggerClass,
110
+ STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE,
108
111
  } from "@gajae-code/ai/utils/fallback-transport";
109
112
  import {
110
113
  BTW_MAX_ANSWER_UTF8_BYTES,
@@ -116,6 +119,7 @@ import {
116
119
  truncateUtf8,
117
120
  utf8ByteLength,
118
121
  } from "./btw-contract";
122
+ import { DEFAULT_ARTIFACT_MAX_BYTES, truncateHeadBytes } from "./streaming-output";
119
123
 
120
124
  export interface ForkContextSeedMetadata {
121
125
  sourceSessionId: string;
@@ -162,12 +166,15 @@ export interface ForkContextSeedOptions {
162
166
  import { MacOSPowerAssertion } from "@gajae-code/natives";
163
167
  import {
164
168
  extractRetryHint,
169
+ hasFsCode,
170
+ isEacces,
165
171
  isEnoent,
166
172
  isUnexpectedSocketCloseMessage,
167
173
  logger,
168
174
  prompt,
169
175
  Snowflake,
170
176
  } from "@gajae-code/utils";
177
+
171
178
  import { createAppendOnlyContextManager, resolveAppendOnlyMode } from "../append-only-mode";
172
179
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
173
180
  import { reset as resetCapabilities } from "../capability";
@@ -208,7 +215,7 @@ import {
208
215
  executePython as executePythonCommand,
209
216
  type PythonResult,
210
217
  } from "../eval/py/executor";
211
- import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
218
+ import { type BashArtifactSaveResult, type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
212
219
  import { exportSessionToHtml } from "../export/html";
213
220
  import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
214
221
  import type { LoadedCustomCommand } from "../extensibility/custom-commands";
@@ -242,6 +249,7 @@ import {
242
249
  resolveSubskillActivationForSkillInvocation,
243
250
  } from "../extensibility/gjc-plugins";
244
251
  import { resolveCurrentPhaseForParent } from "../extensibility/gjc-plugins/injection";
252
+ import type { GjcRuntimeSnapshotProvider } from "../extensibility/gjc-plugins/runtime-quarantine";
245
253
  import { readActiveSubskillsForParent, toActiveSubskillEntry } from "../extensibility/gjc-plugins/state";
246
254
  import { loadActiveSubskillTools } from "../extensibility/gjc-plugins/tools";
247
255
  import type { HookCommandContext } from "../extensibility/hooks/types";
@@ -272,6 +280,7 @@ import {
272
280
  registerCoordinatorRuntimeStateFinalizer,
273
281
  } from "../gjc-runtime/session-state-sidecar";
274
282
  import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
283
+ import { GjcTeamWorkerHeartbeatReporter } from "../gjc-runtime/team-worker-heartbeat";
275
284
  import { GoalRuntime } from "../goals/runtime";
276
285
  import type { Goal, GoalModeState } from "../goals/state";
277
286
  import type { HindsightSessionState } from "../hindsight/state";
@@ -305,6 +314,7 @@ import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obf
305
314
  import { formatNoCredentialOnboardingError, formatNoModelOnboardingError } from "../setup/model-onboarding-guidance";
306
315
  import {
307
316
  isCanonicalGjcWorkflowSkill,
317
+ isWorkflowContinuationInert,
308
318
  readVisibleSkillActiveState,
309
319
  syncSkillActiveState,
310
320
  } from "../skill-state/active-state";
@@ -417,6 +427,32 @@ import { ToolChoiceQueue } from "./tool-choice-queue";
417
427
  import { pruneSupersededMaintenanceReminders, pruneSupersededVolatileProjectContext } from "./volatile-context-pruning";
418
428
  import { YieldQueue } from "./yield-queue";
419
429
 
430
+ interface CompactionStateSnapshot {
431
+ goal: { objective: string; status: Goal["status"]; enabled: boolean } | undefined;
432
+ openTodos: string[];
433
+ activeSkills: Array<{ skill: string; phase: string }>;
434
+ queuedMessages: boolean;
435
+ lastAssistantStopReason: StopReason | undefined;
436
+ }
437
+
438
+ /** Escape XML-ish metacharacters and flatten newlines so state text cannot break compaction prompt framing. */
439
+ function sanitizeCompactionStateText(value: string, maxLength: number): string {
440
+ return value
441
+ .replace(/&/g, "&amp;")
442
+ .replace(/</g, "&lt;")
443
+ .replace(/>/g, "&gt;")
444
+ .replace(/\r\n/g, " ")
445
+ .replace(/[\r\n]/g, " ")
446
+ .slice(0, maxLength);
447
+ }
448
+
449
+ function appendCompactionStateContext(summary: string, stateContext: string[]): string {
450
+ if (stateContext.length === 0) return summary;
451
+ return `${summary}\n\n<compaction-state>\n${stateContext.join("\n")}\n</compaction-state>`;
452
+ }
453
+
454
+ const PRUNED_ARTIFACT_REF_MAX_CHARS = 64;
455
+
420
456
  /** Session-specific events that extend the core AgentEvent */
421
457
  export type AutoCompactionContinuationSkipReason = "auto_continue_disabled_non_resumable_tail";
422
458
 
@@ -506,6 +542,11 @@ export interface AgentSessionConfig {
506
542
  workerIntegrationRequest?: (signal: AbortSignal) => Promise<void>;
507
543
  /** Bound terminal worker-integration settlement for embedded hosts and deterministic lifecycle tests. */
508
544
  workerIntegrationTimeoutMs?: number;
545
+ /**
546
+ * Override the runtime-owned `gjc team` worker heartbeat reporter. Defaults to a
547
+ * reporter derived from the team worker environment, or none outside a worker pane.
548
+ */
549
+ teamWorkerHeartbeatReporter?: GjcTeamWorkerHeartbeatReporter;
509
550
 
510
551
  /** Loaded skills (already discovered by SDK) */
511
552
  skills?: Skill[];
@@ -828,6 +869,7 @@ type RetryErrorClassification =
828
869
 
829
870
  const BARE_DEFAULT_WATCHDOG_ERROR =
830
871
  /^(?:[A-Za-z][A-Za-z0-9-]*(?: [A-Za-z][A-Za-z0-9-]*){0,3} )stream (?:timed out while waiting for the first event|stalled while waiting for the next event)$/;
872
+ const BARE_DEFAULT_CODEX_OVERLOAD_ERROR = /^Codex error event(?:: .*)? \(code=server_is_overloaded(?:, [^)]+)*\)$/;
831
873
  const KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES = {
832
874
  "anthropic-messages": new Set([
833
875
  "Provider stream timed out while waiting for the first event",
@@ -864,6 +906,14 @@ function hasBareDefaultRetryDisqualifyingFacts(message: AssistantMessage): boole
864
906
  (facts.headers !== undefined && Object.keys(facts.headers).length > 0)
865
907
  );
866
908
  }
909
+ function isBareDefaultCodexOverload(message: AssistantMessage): boolean {
910
+ return (
911
+ message.api === "openai-codex-responses" &&
912
+ BARE_DEFAULT_CODEX_OVERLOAD_ERROR.test(message.errorMessage ?? "") &&
913
+ !hasBareDefaultRetryDisqualifyingFacts(message) &&
914
+ !assistantMessageHasVisibleOrToolContent(message)
915
+ );
916
+ }
867
917
 
868
918
  function assistantMessageHasVisibleOrToolContent(message: AssistantMessage): boolean {
869
919
  return message.content.some(content => {
@@ -966,6 +1016,50 @@ interface EphemeralTurnResult {
966
1016
  */
967
1017
  const SIGNAL_TEARDOWN_TIMEOUT_MS = 5_000;
968
1018
 
1019
+ const AGENT_BASH_ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
1020
+
1021
+ function boundAgentBashArtifactSaveDiagnostic(error: unknown): string {
1022
+ const message = (error instanceof Error ? error.message : String(error)).replace(/\s+/gu, " ").trim();
1023
+ const normalized = message || "unknown storage error";
1024
+ return truncateHeadBytes(normalized, AGENT_BASH_ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES).text;
1025
+ }
1026
+
1027
+ function summarizeAgentBashArtifactSave(
1028
+ artifactId: string,
1029
+ originalText: string,
1030
+ ): Extract<BashArtifactSaveResult, { status: "saved" }> {
1031
+ const originalBytes = utf8ByteLength(originalText);
1032
+ if (originalBytes <= DEFAULT_ARTIFACT_MAX_BYTES) {
1033
+ return { status: "saved", artifactId, complete: true };
1034
+ }
1035
+ const retainedBytes = truncateHeadBytes(originalText, DEFAULT_ARTIFACT_MAX_BYTES).bytes;
1036
+ return {
1037
+ status: "saved",
1038
+ artifactId,
1039
+ complete: false,
1040
+ omittedBytes: originalBytes - retainedBytes,
1041
+ };
1042
+ }
1043
+
1044
+ export interface AgentBashArtifactStore {
1045
+ saveArtifact(content: string, toolType: string): Promise<string | undefined>;
1046
+ getArtifactPath(id: string): Promise<string | null>;
1047
+ }
1048
+
1049
+ export async function saveAgentBashOriginalArtifact(
1050
+ store: AgentBashArtifactStore,
1051
+ originalText: string,
1052
+ ): Promise<BashArtifactSaveResult> {
1053
+ try {
1054
+ const artifactId = await store.saveArtifact(originalText, "bash-original");
1055
+ if (!artifactId) return { status: "failed", diagnostic: "storage returned no artifact id" };
1056
+ const artifactPath = await store.getArtifactPath(artifactId);
1057
+ return artifactPath ? summarizeAgentBashArtifactSave(artifactId, originalText) : { status: "unavailable" };
1058
+ } catch (error) {
1059
+ return { status: "failed", diagnostic: boundAgentBashArtifactSaveDiagnostic(error) };
1060
+ }
1061
+ }
1062
+
969
1063
  /**
970
1064
  * Throttle window for the per-turn volatile workspace-tree scan. Date/cwd are
971
1065
  * refreshed every turn; the mtime-sorted tree is rescanned + re-embedded at most
@@ -1698,6 +1792,7 @@ export class AgentSession {
1698
1792
  #resetRetryReplaySafety(): void {
1699
1793
  this.#retryReplayEpoch++;
1700
1794
  this.#retryReplayUnsafeEpoch = undefined;
1795
+ this.#firstEventTimeoutRetryStartedAt = Date.now();
1701
1796
  if (
1702
1797
  this.#extensionRunner?.hasHandlers("context") ||
1703
1798
  this.#extensionRunner?.hasHandlers("before_provider_request") ||
@@ -1731,6 +1826,7 @@ export class AgentSession {
1731
1826
  // Retry state
1732
1827
  #retryAbortController: AbortController | undefined = undefined;
1733
1828
  #retryNowRequested = false;
1829
+ #firstEventTimeoutRetryStartedAt: number | undefined;
1734
1830
  #retryAttempt = 0;
1735
1831
  #retryPromise: Promise<void> | undefined = undefined;
1736
1832
  #retryResolve: (() => void) | undefined = undefined;
@@ -1788,6 +1884,7 @@ export class AgentSession {
1788
1884
  #providerCacheSessionId: string | undefined;
1789
1885
  #isDisposed = false;
1790
1886
  #disposePromise: Promise<void> | undefined;
1887
+ readonly #toolSessionCleanups = new Set<() => Promise<void> | void>();
1791
1888
  #newSessionTransition: Promise<boolean> | undefined;
1792
1889
  // Extension system
1793
1890
  #extensionRunner: ExtensionRunner | undefined = undefined;
@@ -1795,6 +1892,10 @@ export class AgentSession {
1795
1892
  #turnIndex = 0;
1796
1893
  #workerIntegrationScheduler: WorkerIntegrationRequestScheduler;
1797
1894
  #workerIntegrationRequestedForTurn = false;
1895
+ /** Runtime-owned team worker heartbeat; `undefined` outside a `gjc team` worker pane. */
1896
+ #teamWorkerHeartbeat: GjcTeamWorkerHeartbeatReporter | undefined;
1897
+ #teamWorkerTurnsInFlight = 0;
1898
+ #unregisterTeamWorkerAsyncJobChange: (() => void) | undefined;
1798
1899
  // First-party internal before-agent-start contributors (not user hooks).
1799
1900
  #beforeAgentStartContributors: BeforeAgentStartContributor[] = [];
1800
1901
 
@@ -2149,6 +2250,8 @@ export class AgentSession {
2149
2250
  this.#promptInFlightCount++;
2150
2251
  if (this.#promptInFlightCount === 1) {
2151
2252
  this.#acquirePowerAssertion();
2253
+ // Runtime-owned worker liveness follows Agent turns and background jobs
2254
+ // in #refreshTeamWorkerHeartbeat(), including internally dispatched turns.
2152
2255
  }
2153
2256
  }
2154
2257
 
@@ -2205,6 +2308,15 @@ export class AgentSession {
2205
2308
  this.#flushPendingAgentEnd();
2206
2309
  }
2207
2310
 
2311
+ #retainDeferredAgentEndAfterContinuationBusy(
2312
+ hold: symbol | undefined,
2313
+ pending: AgentSessionEvent | undefined,
2314
+ ): void {
2315
+ if (!hold || !pending) return;
2316
+ this.#pendingAgentEndEmit = pending;
2317
+ this.#pendingAgentEndContinuationHolds.set(hold, pending);
2318
+ }
2319
+
2208
2320
  #releaseDeferredAgentEndContinuation(hold: symbol | undefined): void {
2209
2321
  if (!hold) return;
2210
2322
  const pending = this.#pendingAgentEndContinuationHolds.get(hold);
@@ -2255,11 +2367,23 @@ export class AgentSession {
2255
2367
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
2256
2368
  if (this.#promptInFlightCount === 0) {
2257
2369
  this.#releasePowerAssertion();
2370
+ this.#refreshTeamWorkerHeartbeat();
2258
2371
  this.#flushPendingBackgroundExchanges();
2259
2372
  this.#flushPendingAgentEnd();
2260
2373
  }
2261
2374
  }
2262
2375
 
2376
+ #refreshTeamWorkerHeartbeat(): void {
2377
+ const hasOwnedBackgroundJob =
2378
+ this.#agentId !== undefined &&
2379
+ (this.#ownedAsyncJobManager?.getRunningJobs({ ownerId: this.#agentId }).length ?? 0) > 0;
2380
+ if (this.#teamWorkerTurnsInFlight > 0 || hasOwnedBackgroundJob) {
2381
+ this.#teamWorkerHeartbeat?.start();
2382
+ } else {
2383
+ this.#teamWorkerHeartbeat?.stop();
2384
+ }
2385
+ }
2386
+
2263
2387
  #flushPendingAgentEnd(): void {
2264
2388
  if (
2265
2389
  this.#promptInFlightCount > 0 ||
@@ -2311,6 +2435,14 @@ export class AgentSession {
2311
2435
  }),
2312
2436
  config.workerIntegrationTimeoutMs,
2313
2437
  );
2438
+ // One publisher per worker process: subagent sessions share this process and its
2439
+ // team env, so letting each create a reporter would run N timers writing the same
2440
+ // record. The top-level session owns the pane's liveness.
2441
+ this.#teamWorkerHeartbeat =
2442
+ config.teamWorkerHeartbeatReporter ??
2443
+ ((config.taskDepth ?? 0) === 0
2444
+ ? GjcTeamWorkerHeartbeatReporter.forProcess(() => this.sessionManager.getCwd())
2445
+ : undefined);
2314
2446
  this.notificationSessionController = config.notificationSessionController;
2315
2447
  this.taskDepth = config.taskDepth ?? 0;
2316
2448
  // Register this session with the process-wide resource GC (idle/RSS browser-tab eviction
@@ -2472,6 +2604,9 @@ export class AgentSession {
2472
2604
  this.#ttsrManager = config.ttsrManager;
2473
2605
  this.#obfuscator = config.obfuscator;
2474
2606
  this.#agentId = config.agentId;
2607
+ this.#unregisterTeamWorkerAsyncJobChange = this.#ownedAsyncJobManager?.onChange(() => {
2608
+ this.#refreshTeamWorkerHeartbeat();
2609
+ });
2475
2610
  this.#agentRegistry = config.agentRegistry;
2476
2611
  this.#providerSessionId = config.providerSessionId;
2477
2612
  this.#providerCacheSessionId = config.providerCacheSessionId;
@@ -2566,7 +2701,24 @@ export class AgentSession {
2566
2701
 
2567
2702
  /** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */
2568
2703
  nextToolChoice(): ToolChoice | undefined {
2569
- return this.#toolChoiceQueue.nextToolChoice();
2704
+ const choice = this.#toolChoiceQueue.nextToolChoice();
2705
+ if (!choice || typeof choice === "string") return choice;
2706
+
2707
+ const toolName = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name;
2708
+ const activeTool = this.agent.state.tools.find(
2709
+ tool => tool.name === toolName || tool.customWireName === toolName,
2710
+ );
2711
+ if (!activeTool) {
2712
+ this.#toolChoiceQueue.degradeInFlight(`Tool "${toolName}" is no longer active.`);
2713
+ return undefined;
2714
+ }
2715
+
2716
+ const refreshed = buildNamedToolChoiceResult(activeTool.name, this.model);
2717
+ if (!refreshed.exactNamed || !refreshed.choice) {
2718
+ this.#toolChoiceQueue.degradeInFlight(refreshed.resolved?.reason ?? "Named tool choice is unavailable.");
2719
+ return undefined;
2720
+ }
2721
+ return refreshed.choice;
2570
2722
  }
2571
2723
 
2572
2724
  /**
@@ -3082,6 +3234,21 @@ export class AgentSession {
3082
3234
  return this.#agentId;
3083
3235
  }
3084
3236
 
3237
+ registerToolSessionCleanup(cleanup: () => Promise<void> | void): () => void {
3238
+ if (this.#isDisposed) throw new Error("Cannot register tool cleanup after session disposal has started.");
3239
+ this.#toolSessionCleanups.add(cleanup);
3240
+ return () => this.#toolSessionCleanups.delete(cleanup);
3241
+ }
3242
+
3243
+ async #runToolSessionCleanups(): Promise<void> {
3244
+ const cleanups = Array.from(this.#toolSessionCleanups);
3245
+ this.#toolSessionCleanups.clear();
3246
+ const results = await Promise.allSettled(cleanups.map(async cleanup => await cleanup()));
3247
+ for (const result of results) {
3248
+ if (result.status === "rejected") logger.warn("Tool session cleanup failed", { error: String(result.reason) });
3249
+ }
3250
+ }
3251
+
3085
3252
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
3086
3253
  const manager = AsyncJobManager.instance();
3087
3254
  if (!manager) return null;
@@ -3196,19 +3363,26 @@ export class AgentSession {
3196
3363
  return queued;
3197
3364
  }
3198
3365
 
3199
- #trackAgentEvent = async (event: AgentEvent): Promise<void> => {
3366
+ #trackAgentEvent = (event: AgentEvent): Promise<void> => {
3367
+ const activePromptHandle = this.activePromptHandle;
3200
3368
  const agentEndHandled = event.type === "agent_end" ? Promise.withResolvers<void>() : undefined;
3201
3369
  if (agentEndHandled) this.#agentEndHandlingPromise = agentEndHandled.promise;
3202
3370
  this.#agentEventHandlersInFlight++;
3203
- try {
3204
- await this.#handleAgentEvent(event);
3205
- } catch (error) {
3206
- logger.warn("Agent event handler failed", { event: event.type, error: String(error) });
3207
- } finally {
3208
- this.#agentEventHandlersInFlight = Math.max(0, this.#agentEventHandlersInFlight - 1);
3209
- this.#flushPendingAgentEnd();
3210
- agentEndHandled?.resolve();
3371
+ const handler = (async (): Promise<void> => {
3372
+ try {
3373
+ await this.#handleAgentEvent(event, activePromptHandle);
3374
+ } catch (error) {
3375
+ logger.warn("Agent event handler failed", { event: event.type, error: String(error) });
3376
+ } finally {
3377
+ this.#agentEventHandlersInFlight = Math.max(0, this.#agentEventHandlersInFlight - 1);
3378
+ this.#flushPendingAgentEnd();
3379
+ agentEndHandled?.resolve();
3380
+ }
3381
+ })();
3382
+ if (activePromptHandle) {
3383
+ this.agent.resourceLedger.track(activePromptHandle, "post_prompt", "agent-session-event", handler);
3211
3384
  }
3385
+ return handler;
3212
3386
  };
3213
3387
 
3214
3388
  #persistRuntimeStateInBackground(event: AgentSessionEvent): void {
@@ -3228,6 +3402,18 @@ export class AgentSession {
3228
3402
  } else if (event.type === "turn_end") {
3229
3403
  this.#closedExtensionTurnGeneration = this.#extensionTurnGeneration;
3230
3404
  }
3405
+ if (event.type === "turn_start") {
3406
+ this.#teamWorkerTurnsInFlight++;
3407
+ this.#refreshTeamWorkerHeartbeat();
3408
+ } else if (event.type === "turn_end") {
3409
+ this.#teamWorkerTurnsInFlight = Math.max(0, this.#teamWorkerTurnsInFlight - 1);
3410
+ this.#refreshTeamWorkerHeartbeat();
3411
+ } else if (event.type === "agent_end") {
3412
+ // Failed Agent runs can emit agent_end without turn_end. Reset rather than
3413
+ // leaking a positive count that would report an idle/failed worker forever.
3414
+ this.#teamWorkerTurnsInFlight = 0;
3415
+ this.#refreshTeamWorkerHeartbeat();
3416
+ }
3231
3417
  if (event.type === "message_update") {
3232
3418
  // Fast path: message_update maps to no sidecar state, so we must not
3233
3419
  // build the persistRuntimeState closure here (per-token hot path).
@@ -3347,7 +3533,7 @@ export class AgentSession {
3347
3533
  }
3348
3534
 
3349
3535
  /** Internal handler for agent events - shared by subscribe and reconnect */
3350
- #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
3536
+ #handleAgentEvent = async (event: AgentEvent, activePromptHandle?: string): Promise<void> => {
3351
3537
  if (this.#extensionRunner?.hasHandlers(event.type)) this.#markRetryReplayUnsafe();
3352
3538
  if (
3353
3539
  event.type === "tool_execution_start" ||
@@ -3696,9 +3882,10 @@ export class AgentSession {
3696
3882
  this.#ttsrAbortPending = false;
3697
3883
  this.#resolveTtsrResume();
3698
3884
  },
3885
+ resourceRunId: activePromptHandle,
3699
3886
  });
3700
3887
  },
3701
- { delayMs: 50 },
3888
+ { delayMs: 50, resourceRunId: activePromptHandle },
3702
3889
  );
3703
3890
  return;
3704
3891
  }
@@ -3857,7 +4044,11 @@ export class AgentSession {
3857
4044
  maintenanceGeneration !== undefined &&
3858
4045
  this.#promptGeneration === maintenanceGeneration
3859
4046
  ) {
3860
- this.#scheduleAgentContinue({ generation: maintenanceGeneration, skipCompactionCheck: true });
4047
+ this.#scheduleAgentContinue({
4048
+ generation: maintenanceGeneration,
4049
+ skipCompactionCheck: true,
4050
+ resourceRunId: activePromptHandle,
4051
+ });
3861
4052
  }
3862
4053
  return;
3863
4054
  }
@@ -3937,8 +4128,11 @@ export class AgentSession {
3937
4128
  }
3938
4129
  this.#resolveRetry();
3939
4130
 
3940
- const compactionTask = this.#checkCompaction(msg);
3941
- this.#trackPostPromptTask(compactionTask.then(() => undefined));
4131
+ const compactionTask = this.#checkCompaction(msg, true, undefined, activePromptHandle);
4132
+ this.#trackPostPromptTask(
4133
+ compactionTask.then(() => undefined),
4134
+ activePromptHandle,
4135
+ );
3942
4136
  await compactionTask;
3943
4137
  // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
3944
4138
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
@@ -4002,9 +4196,12 @@ export class AgentSession {
4002
4196
  this.#postPromptTasksPromise = undefined;
4003
4197
  }
4004
4198
 
4005
- #trackPostPromptTask(task: Promise<void>): void {
4199
+ #trackPostPromptTask(task: Promise<void>, resourceRunId = this.activePromptHandle): void {
4006
4200
  this.#postPromptTasks.add(task);
4007
4201
  this.#ensurePostPromptTasksPromise();
4202
+ if (resourceRunId) {
4203
+ this.agent.resourceLedger.track(resourceRunId, "post_prompt", "agent-session", task);
4204
+ }
4008
4205
  void task
4009
4206
  .catch(() => {})
4010
4207
  .finally(() => {
@@ -4017,7 +4214,7 @@ export class AgentSession {
4017
4214
 
4018
4215
  #schedulePostPromptTask(
4019
4216
  task: (signal: AbortSignal) => Promise<void>,
4020
- options?: { delayMs?: number; generation?: number; onSkip?: () => void },
4217
+ options?: { delayMs?: number; generation?: number; onSkip?: () => void; resourceRunId?: string },
4021
4218
  ): Promise<void> {
4022
4219
  const delayMs = options?.delayMs ?? 0;
4023
4220
  const signal = this.#postPromptTasksAbortController.signal;
@@ -4040,10 +4237,14 @@ export class AgentSession {
4040
4237
  }
4041
4238
  await task(signal);
4042
4239
  })();
4043
- this.#trackPostPromptTask(scheduled);
4240
+ this.#trackPostPromptTask(scheduled, options?.resourceRunId);
4044
4241
  return scheduled;
4045
4242
  }
4046
4243
 
4244
+ #isAgentBusyError(error: unknown): error is AgentBusyError {
4245
+ return error instanceof AgentBusyError;
4246
+ }
4247
+
4047
4248
  #scheduleAgentContinue(options?: {
4048
4249
  delayMs?: number;
4049
4250
  generation?: number;
@@ -4052,7 +4253,9 @@ export class AgentSession {
4052
4253
  shouldContinue?: () => boolean;
4053
4254
  onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => void;
4054
4255
  allowDuringCancelAndSubmit?: boolean;
4256
+ rescheduleOnBusy?: boolean;
4055
4257
  onError?: (error: unknown) => void;
4258
+ resourceRunId?: string;
4056
4259
  }): Promise<void> {
4057
4260
  const predecessorAgentEndHold = options?.suppressPredecessorAgentEnd
4058
4261
  ? this.#reserveDeferredAgentEndForContinuation()
@@ -4075,81 +4278,92 @@ export class AgentSession {
4075
4278
  };
4076
4279
  const scheduledGeneration = options?.generation;
4077
4280
  const signal = this.#postPromptTasksAbortController.signal;
4078
- return this.#schedulePostPromptTask(
4079
- async () => {
4080
- const canContinue = (): boolean => {
4081
- if (signal.aborted || this.#isDisposed) {
4082
- skip("aborted_signal");
4083
- return false;
4084
- }
4085
- if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4086
- skip("generation_changed");
4087
- return false;
4088
- }
4089
- if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) {
4090
- skip("queue_drained");
4091
- return false;
4092
- }
4093
- if (options?.shouldContinue && !options.shouldContinue()) {
4094
- skip("queue_drained");
4095
- return false;
4096
- }
4097
- return true;
4098
- };
4099
- if (!canContinue()) return;
4100
- try {
4101
- if (!options?.skipCompactionCheck) {
4102
- await this.#checkEstimatedContextBeforePrompt();
4103
- if (!canContinue()) return;
4104
- }
4105
- if (signal.aborted) {
4106
- skip("aborted_signal");
4107
- return;
4108
- }
4109
- if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4110
- skip("generation_changed");
4111
- return;
4112
- }
4113
- if (options?.shouldContinue && !options.shouldContinue()) {
4114
- skip("queue_drained");
4115
- return;
4116
- }
4117
- // A continuation scheduled before a handoff engaged must not start a
4118
- // turn against the session being handed off (or the restored
4119
- // predecessor). rearmIdle / normal delivery resumes after the fence.
4120
- if (this.#handoffTransitionActive) {
4121
- skip("handoff_in_progress");
4122
- return;
4123
- }
4124
- const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
4125
- const startsQueuedSuccessor =
4126
- this.agent.state.messages.at(-1)?.role === "assistant" && this.agent.hasQueuedMessages();
4281
+ const scheduleAttempt = (delayMs = options?.delayMs): Promise<void> =>
4282
+ this.#schedulePostPromptTask(
4283
+ async () => {
4284
+ const canContinue = (): boolean => {
4285
+ if (signal.aborted || this.#isDisposed) {
4286
+ skip("aborted_signal");
4287
+ return false;
4288
+ }
4289
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4290
+ skip("generation_changed");
4291
+ return false;
4292
+ }
4293
+ if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) {
4294
+ skip("queue_drained");
4295
+ return false;
4296
+ }
4297
+ if (options?.shouldContinue && !options.shouldContinue()) {
4298
+ skip("queue_drained");
4299
+ return false;
4300
+ }
4301
+ return true;
4302
+ };
4303
+ if (!canContinue()) return;
4127
4304
  try {
4128
- await this.agent.continue({
4129
- ...this.#managedFallbackPromptOptions(),
4130
- // Reset only after continue() has claimed the queued turn. Skipped or stale
4131
- // continuations retain predecessor accounting, and resetAttemptBudget keeps
4132
- // the sticky fallback cursor unchanged.
4133
- onRunAccepted: startsQueuedSuccessor
4134
- ? () => {
4135
- this.#defaultFallbackChain().resetAttemptBudget();
4136
- this.#overflowMaintenanceAttempts = 0;
4137
- }
4138
- : undefined,
4139
- });
4305
+ if (!options?.skipCompactionCheck) {
4306
+ await this.#checkEstimatedContextBeforePrompt();
4307
+ if (!canContinue()) return;
4308
+ }
4309
+ if (signal.aborted) {
4310
+ skip("aborted_signal");
4311
+ return;
4312
+ }
4313
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4314
+ skip("generation_changed");
4315
+ return;
4316
+ }
4317
+ if (options?.shouldContinue && !options.shouldContinue()) {
4318
+ skip("queue_drained");
4319
+ return;
4320
+ }
4321
+ // A continuation scheduled before a handoff engaged must not start a
4322
+ // turn against the session being handed off (or the restored
4323
+ // predecessor). rearmIdle / normal delivery resumes after the fence.
4324
+ if (this.#handoffTransitionActive) {
4325
+ skip("handoff_in_progress");
4326
+ return;
4327
+ }
4328
+ const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
4329
+ const startsQueuedSuccessor =
4330
+ this.agent.state.messages.at(-1)?.role === "assistant" && this.agent.hasQueuedMessages();
4331
+ try {
4332
+ await this.agent.continue({
4333
+ ...this.#managedFallbackPromptOptions(),
4334
+ // Reset only after continue() has claimed the queued turn. Skipped or stale
4335
+ // continuations retain predecessor accounting, and resetAttemptBudget keeps
4336
+ // the sticky fallback cursor unchanged.
4337
+ onRunAccepted: startsQueuedSuccessor
4338
+ ? () => {
4339
+ this.#defaultFallbackChain().resetAttemptBudget();
4340
+ this.#overflowMaintenanceAttempts = 0;
4341
+ }
4342
+ : undefined,
4343
+ });
4344
+ } catch (error) {
4345
+ if (options?.rescheduleOnBusy && this.#isAgentBusyError(error) && !terminalized && canContinue()) {
4346
+ this.#retainDeferredAgentEndAfterContinuationBusy(predecessorAgentEndHold, predecessorAgentEnd);
4347
+ logger.debug("agent.continue busy after scheduling; rescheduling", {
4348
+ error: error.message,
4349
+ });
4350
+ void scheduleAttempt(100);
4351
+ return;
4352
+ }
4353
+ this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
4354
+ throw error;
4355
+ }
4140
4356
  } catch (error) {
4141
- this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
4142
- throw error;
4357
+ fail(error);
4143
4358
  }
4144
- } catch (error) {
4145
- fail(error);
4146
- }
4147
- },
4148
- {
4149
- delayMs: options?.delayMs,
4150
- onSkip: () => skip("aborted_signal"),
4151
- },
4152
- );
4359
+ },
4360
+ {
4361
+ delayMs,
4362
+ onSkip: () => skip("aborted_signal"),
4363
+ resourceRunId: options?.resourceRunId,
4364
+ },
4365
+ );
4366
+ return scheduleAttempt();
4153
4367
  }
4154
4368
 
4155
4369
  #logCompactionContinuationSkipped(
@@ -4165,7 +4379,7 @@ export class AgentSession {
4165
4379
  ): void {
4166
4380
  logger.warn("Auto-compaction continuation failed", {
4167
4381
  source,
4168
- reason: error instanceof Error && error.name === "AgentBusyError" ? "queue_drained" : "not_resumable_tail",
4382
+ reason: "not_resumable_tail",
4169
4383
  error: error instanceof Error ? error.message : String(error),
4170
4384
  });
4171
4385
  }
@@ -4193,14 +4407,24 @@ export class AgentSession {
4193
4407
  return compactionSettings.autoContinue === false ? "auto_continue_disabled_non_resumable_tail" : undefined;
4194
4408
  }
4195
4409
 
4196
- #scheduleOverflowRetryContinuation(generation: number): boolean {
4410
+ async #scheduleOverflowRetryContinuation(generation: number, resourceRunId?: string): Promise<boolean> {
4197
4411
  this.#stripOverflowFailedTurnForRetry();
4412
+ const snapshot = await this.#compactionStateSnapshot();
4413
+ if (
4414
+ snapshot.goal?.status === "paused" &&
4415
+ !snapshot.queuedMessages &&
4416
+ snapshot.lastAssistantStopReason !== "length"
4417
+ ) {
4418
+ this.#logCompactionContinuationSkipped("overflow_retry", "paused_goal");
4419
+ return false;
4420
+ }
4198
4421
  if (this.#isResumableAgentTail()) {
4199
4422
  this.#scheduleAgentContinue({
4200
4423
  delayMs: 100,
4201
4424
  generation,
4202
4425
  suppressPredecessorAgentEnd: true,
4203
-
4426
+ resourceRunId,
4427
+ rescheduleOnBusy: true,
4204
4428
  onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
4205
4429
  onError: error => this.#logCompactionContinuationError("overflow_retry", error),
4206
4430
  });
@@ -4209,7 +4433,7 @@ export class AgentSession {
4209
4433
 
4210
4434
  const compactionSettings = this.settings.getGroup("compaction");
4211
4435
  if (compactionSettings.autoContinue !== false) {
4212
- this.#scheduleAutoContinuePrompt(generation);
4436
+ this.#scheduleAutoContinuePrompt(generation, false);
4213
4437
  return true;
4214
4438
  }
4215
4439
 
@@ -4217,8 +4441,32 @@ export class AgentSession {
4217
4441
  return false;
4218
4442
  }
4219
4443
 
4220
- #scheduleAutoContinuePrompt(generation: number): void {
4444
+ #scheduleAutoContinuePrompt(generation: number, requireUnfinishedWork = true): void {
4221
4445
  const predecessorAgentEndHold = this.#reserveDeferredAgentEndForContinuation();
4446
+ const scheduledGeneration = generation;
4447
+ const signal = this.#postPromptTasksAbortController.signal;
4448
+ const continuationAuthorized = async (hasPendingNextTurnMessages = false): Promise<boolean> => {
4449
+ const snapshot = await this.#compactionStateSnapshot();
4450
+ if (signal.aborted) {
4451
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
4452
+ return false;
4453
+ }
4454
+ if (this.#isDisposed || this.#promptGeneration !== scheduledGeneration) {
4455
+ this.#logCompactionContinuationSkipped(
4456
+ "auto_continue_prompt",
4457
+ this.#isDisposed ? "session_disposed" : "generation_changed",
4458
+ );
4459
+ return false;
4460
+ }
4461
+ const authorized = requireUnfinishedWork
4462
+ ? this.#hasUnfinishedWork(snapshot) || hasPendingNextTurnMessages
4463
+ : snapshot.queuedMessages ||
4464
+ hasPendingNextTurnMessages ||
4465
+ snapshot.lastAssistantStopReason === "length" ||
4466
+ snapshot.goal?.status !== "paused";
4467
+ if (!authorized) this.emitNotice("info", "Auto-continue skipped: no unfinished work detected");
4468
+ return authorized;
4469
+ };
4222
4470
  const continuePrompt = async () => {
4223
4471
  await this.#promptWithMessage(
4224
4472
  {
@@ -4232,14 +4480,15 @@ export class AgentSession {
4232
4480
  skipPostPromptRecoveryWait: true,
4233
4481
  skipCompactionCheck: true,
4234
4482
  predecessorAgentEndHold,
4483
+ onFinalPreflight: ({ hasPendingNextTurnMessages }) => continuationAuthorized(hasPendingNextTurnMessages),
4235
4484
  },
4236
4485
  );
4237
4486
  };
4238
- const scheduledGeneration = generation;
4239
- const signal = this.#postPromptTasksAbortController.signal;
4240
- this.#trackPostPromptTask(
4241
- (async () => {
4487
+ const scheduleAttempt = (delayMs = 0) => {
4488
+ const attempt = (async () => {
4489
+ let rescheduled = false;
4242
4490
  try {
4491
+ if (delayMs > 0) await scheduler.wait(delayMs, { signal });
4243
4492
  await Promise.resolve();
4244
4493
  if (signal.aborted) {
4245
4494
  this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
@@ -4249,14 +4498,31 @@ export class AgentSession {
4249
4498
  this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
4250
4499
  return;
4251
4500
  }
4501
+ if (!(await continuationAuthorized())) return;
4252
4502
  await continuePrompt();
4253
4503
  } catch (error) {
4504
+ if (signal.aborted) {
4505
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
4506
+ return;
4507
+ }
4508
+ if (this.#isAgentBusyError(error) && this.#promptGeneration === scheduledGeneration) {
4509
+ logger.debug("Auto-compaction continuation busy; rescheduling", {
4510
+ source: "auto_continue_prompt",
4511
+ reason: "queue_drained",
4512
+ error: error.message,
4513
+ });
4514
+ rescheduled = true;
4515
+ scheduleAttempt(100);
4516
+ return;
4517
+ }
4254
4518
  this.#logCompactionContinuationError("auto_continue_prompt", error);
4255
4519
  } finally {
4256
- this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
4520
+ if (!rescheduled) this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
4257
4521
  }
4258
- })(),
4259
- );
4522
+ })();
4523
+ this.#trackPostPromptTask(attempt);
4524
+ };
4525
+ scheduleAttempt();
4260
4526
  }
4261
4527
 
4262
4528
  async #cancelPostPromptTasks(): Promise<void> {
@@ -4749,6 +5015,19 @@ export class AgentSession {
4749
5015
  };
4750
5016
  }
4751
5017
 
5018
+ /** Defer local:// provisioning when a read-only session load cannot create its artifact root. */
5019
+ async #initializeLocalRootForLoadedSession(): Promise<void> {
5020
+ try {
5021
+ await initializeLocalRoot(this.#localProtocolOptions());
5022
+ } catch (error) {
5023
+ if (!isEacces(error) && !hasFsCode(error, "EPERM") && !hasFsCode(error, "EROFS")) throw error;
5024
+ logger.debug("Deferred local root initialization for read-only session load", {
5025
+ sessionFile: this.sessionManager.getSessionFile(),
5026
+ error: String(error),
5027
+ });
5028
+ }
5029
+ }
5030
+
4752
5031
  #maybeAbortStreamingEdit(event: AgentEvent, generation: number): void {
4753
5032
  if (!this.settings.get("edit.streamingAbort")) return;
4754
5033
  if (this.#streamingEditAbortTriggered) return;
@@ -5302,6 +5581,7 @@ export class AgentSession {
5302
5581
  AsyncJobManager.setInstance(undefined);
5303
5582
  }
5304
5583
  }
5584
+ await this.#runToolSessionCleanups();
5305
5585
  // Only disconnect the MCP manager THIS session owns (top-level sessions that
5306
5586
  // connected plugin-bundle MCP servers). Subagents and callers that merely
5307
5587
  // observe the process-global manager must never tear down a manager they do
@@ -5334,6 +5614,9 @@ export class AgentSession {
5334
5614
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
5335
5615
  await disposeVmContextsByOwner(this.#evalKernelOwnerId);
5336
5616
  this.#releasePowerAssertion();
5617
+ this.#unregisterTeamWorkerAsyncJobChange?.();
5618
+ this.#unregisterTeamWorkerAsyncJobChange = undefined;
5619
+ this.#teamWorkerHeartbeat?.dispose();
5337
5620
  // Disconnect the agent event listener BEFORE closing session resources so a late
5338
5621
  // provider/tool message_end cannot append to the closing SessionManager.
5339
5622
  this.#disconnectFromAgent();
@@ -5378,6 +5661,9 @@ export class AgentSession {
5378
5661
  const kernelOwnerId = this.#evalKernelOwnerId;
5379
5662
  this.#unregisterResourceGc?.();
5380
5663
  this.#unregisterResourceGc = undefined;
5664
+ this.#unregisterTeamWorkerAsyncJobChange?.();
5665
+ this.#unregisterTeamWorkerAsyncJobChange = undefined;
5666
+ this.#teamWorkerHeartbeat?.dispose();
5381
5667
  const work = Promise.allSettled([
5382
5668
  // kill:true so a forced exit also reaps spawned-app Chrome we own (headless
5383
5669
  // always closes; connected/attached browsers only disconnect — never killed).
@@ -6517,6 +6803,11 @@ export class AgentSession {
6517
6803
  return this.#postPromptTasks.size > 0;
6518
6804
  }
6519
6805
 
6806
+ /** Stable resource ownership identifier for the active prompt run. */
6807
+ get activePromptHandle(): string | undefined {
6808
+ return this.agent.activeResourceRunId;
6809
+ }
6810
+
6520
6811
  /** All messages including custom types like BashExecutionMessage */
6521
6812
  get messages(): AgentMessage[] {
6522
6813
  return this.agent.state.messages;
@@ -7768,6 +8059,7 @@ export class AgentSession {
7768
8059
  predecessorAgentEndHold?: symbol;
7769
8060
  admissionLease?: SessionAdmissionLease;
7770
8061
  onRunAccepted?: () => void;
8062
+ onFinalPreflight?: (context: { hasPendingNextTurnMessages: boolean }) => Promise<boolean>;
7771
8063
  resetRetryReplaySafety?: boolean;
7772
8064
  },
7773
8065
  ): Promise<void> {
@@ -7783,6 +8075,8 @@ export class AgentSession {
7783
8075
  const generation = this.#promptGeneration;
7784
8076
  const preflightSignal = this.#promptPreflightAbortController.signal;
7785
8077
  const rosterClaim = this.#claimIrcRosterCandidate();
8078
+ let hasPendingNextTurnMessages = false;
8079
+ let pendingNextTurnMessageCount = 0;
7786
8080
  let hindsightRecall: string | undefined;
7787
8081
  try {
7788
8082
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
@@ -7874,11 +8168,11 @@ export class AgentSession {
7874
8168
  }
7875
8169
 
7876
8170
  // Inject any pending "nextTurn" messages as context alongside the user message
7877
- for (const msg of this.#pendingNextTurnMessages) {
8171
+ pendingNextTurnMessageCount = this.#pendingNextTurnMessages.length;
8172
+ hasPendingNextTurnMessages = pendingNextTurnMessageCount > 0;
8173
+ for (const msg of this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount)) {
7878
8174
  messages.push(msg);
7879
8175
  }
7880
- this.#pendingNextTurnMessages = [];
7881
- if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
7882
8176
 
7883
8177
  // Auto-read @filepath mentions
7884
8178
  const fileMentions = extractFileMentions(expandedText);
@@ -7998,7 +8292,15 @@ export class AgentSession {
7998
8292
  };
7999
8293
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
8000
8294
  else options?.onPreflightAccepted?.();
8295
+ if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
8296
+ this.#resetInjectedContextSignatures();
8297
+ return;
8298
+ }
8001
8299
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
8300
+ if (pendingNextTurnMessageCount > 0) {
8301
+ this.#pendingNextTurnMessages.splice(0, pendingNextTurnMessageCount);
8302
+ }
8303
+ if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
8002
8304
  await this.#promptAgentWithIdleRetry(messages, agentPromptOptions, predecessorAgentEndHold);
8003
8305
  const terminalAssistant = this.#findLastAssistantMessage();
8004
8306
  if (
@@ -8039,6 +8341,7 @@ export class AgentSession {
8039
8341
  await this.#agentEndPublicationPromise;
8040
8342
  } else {
8041
8343
  await this.#agentEndHandlingPromise;
8344
+ await this.#waitForPostPromptRecovery();
8042
8345
  await this.#agentEndPublicationPromise;
8043
8346
  }
8044
8347
  }
@@ -8087,6 +8390,7 @@ export class AgentSession {
8087
8390
  sessionManager: createReadonlySessionManager(this.sessionManager),
8088
8391
  modelRegistry: this.#modelRegistry,
8089
8392
  model: this.model ?? undefined,
8393
+ getActivePromptHandle: () => this.activePromptHandle,
8090
8394
  isIdle: () => !this.isStreaming,
8091
8395
  abort: () => {
8092
8396
  void this.abort();
@@ -8862,6 +9166,103 @@ export class AgentSession {
8862
9166
  this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
8863
9167
  }
8864
9168
 
9169
+ async #compactionStateSnapshot(): Promise<CompactionStateSnapshot> {
9170
+ const snapshot: CompactionStateSnapshot = {
9171
+ goal: undefined,
9172
+ openTodos: [],
9173
+ activeSkills: [],
9174
+ queuedMessages: false,
9175
+ lastAssistantStopReason: undefined,
9176
+ };
9177
+ try {
9178
+ const goalState = this.getGoalModeState();
9179
+ const goal = goalState?.goal;
9180
+ if (goal) snapshot.goal = { objective: goal.objective, status: goal.status, enabled: goalState.enabled };
9181
+ } catch (error) {
9182
+ logger.warn("Failed to read goal state for compaction snapshot", {
9183
+ error: error instanceof Error ? error.message : String(error),
9184
+ });
9185
+ }
9186
+ try {
9187
+ const todos = this.getTodoPhases()
9188
+ .flatMap(phase => phase.tasks)
9189
+ .filter(task => task.status === "pending" || task.status === "in_progress")
9190
+ .slice(0, 10)
9191
+ .map(task => task.content);
9192
+ snapshot.openTodos = todos;
9193
+ } catch (error) {
9194
+ logger.warn("Failed to read todo state for compaction snapshot", {
9195
+ error: error instanceof Error ? error.message : String(error),
9196
+ });
9197
+ }
9198
+ try {
9199
+ const state = await readVisibleSkillActiveState(this.sessionManager.getCwd(), this.sessionId, {
9200
+ bypassCache: true,
9201
+ });
9202
+ snapshot.activeSkills = (state?.active_skills ?? [])
9203
+ .filter(entry => entry.active !== false)
9204
+ .slice(0, 5)
9205
+ .map(entry => ({ skill: entry.skill, phase: entry.phase ?? "unknown" }));
9206
+ } catch (error) {
9207
+ logger.warn("Failed to read workflow state for compaction snapshot", {
9208
+ error: error instanceof Error ? error.message : String(error),
9209
+ });
9210
+ }
9211
+ try {
9212
+ snapshot.queuedMessages = this.agent.hasQueuedMessages() || this.#pendingNextTurnMessages.length > 0;
9213
+ } catch (error) {
9214
+ logger.warn("Failed to read queued-message state for compaction snapshot", {
9215
+ error: error instanceof Error ? error.message : String(error),
9216
+ });
9217
+ }
9218
+ try {
9219
+ for (let index = this.messages.length - 1; index >= 0; index--) {
9220
+ const message = this.messages[index];
9221
+ if (message.role !== "assistant") continue;
9222
+ snapshot.lastAssistantStopReason = (message as AssistantMessage).stopReason;
9223
+ break;
9224
+ }
9225
+ } catch (error) {
9226
+ logger.warn("Failed to read assistant stop state for compaction snapshot", {
9227
+ error: error instanceof Error ? error.message : String(error),
9228
+ });
9229
+ }
9230
+ return snapshot;
9231
+ }
9232
+
9233
+ #compactionStateContext(snapshot: CompactionStateSnapshot): string[] {
9234
+ const context: string[] = [];
9235
+ const goal = snapshot.goal;
9236
+ if (goal?.enabled) {
9237
+ context.push(
9238
+ `Active goal: ${sanitizeCompactionStateText(goal.objective, 160)} (status: ${sanitizeCompactionStateText(goal.status, 40)})`,
9239
+ );
9240
+ } else if (goal?.status === "paused") {
9241
+ context.push(`Paused goal: ${sanitizeCompactionStateText(goal.objective, 160)} (status: paused)`);
9242
+ }
9243
+ for (const entry of snapshot.activeSkills) {
9244
+ const skill = sanitizeCompactionStateText(entry.skill, 100);
9245
+ const phase = sanitizeCompactionStateText(entry.phase, 80);
9246
+ if (skill) context.push(`Active skill: ${skill} phase=${phase}`);
9247
+ }
9248
+ if (snapshot.openTodos.length > 0) {
9249
+ const todos = snapshot.openTodos.map(todo => sanitizeCompactionStateText(todo, 120));
9250
+ context.push(`Open todos: ${todos.join("; ")}`);
9251
+ }
9252
+ return context;
9253
+ }
9254
+
9255
+ #hasUnfinishedWork(snapshot: CompactionStateSnapshot): boolean {
9256
+ if (snapshot.queuedMessages) return true;
9257
+ if (snapshot.lastAssistantStopReason === "length") return true;
9258
+ // A verified paused goal is a human-wait terminus. It suppresses stale
9259
+ // todo/workflow activity until a queued user message resumes the run.
9260
+ if (snapshot.goal?.status === "paused") return false;
9261
+ if (snapshot.goal?.enabled && snapshot.goal.status === "active") return true;
9262
+ if (snapshot.openTodos.length > 0) return true;
9263
+ return snapshot.activeSkills.some(entry => !isWorkflowContinuationInert(entry.skill, entry.phase));
9264
+ }
9265
+
8865
9266
  async #applyCompactionPostAppend(
8866
9267
  compactionEntryId: string,
8867
9268
  firstKeptEntryId: string,
@@ -9060,6 +9461,26 @@ export class AgentSession {
9060
9461
  const outcome = await this.#abortWithOutcome(options);
9061
9462
  if (outcome.kind === "error") throw outcome.cause;
9062
9463
  }
9464
+ /**
9465
+ * Abort a specific active prompt and prove whether its tracked resources settled.
9466
+ */
9467
+ async abortPromptAndWait(handle: string, options: { graceMs: number }): Promise<RunSettlementProof> {
9468
+ // Only the abort trigger is gated on active ownership; settlement is always proven
9469
+ // from the ledger, because a run can go inactive while its resources still run.
9470
+ if (handle === this.agent.activeResourceRunId)
9471
+ // Awaiting the abort unboundedly would let a provider or tool that ignores
9472
+ // cancellation defer the grace forever, so the proof races the fixed grace
9473
+ // independently of abort completion.
9474
+ void this.abort({ timeoutMs: options.graceMs }).catch(() => {});
9475
+
9476
+ const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs });
9477
+ if (proof.status === "unfenced") {
9478
+ // Detach the unsettled resources so they can never re-enter this run; their real
9479
+ // promises still release them inside the ledger.
9480
+ this.agent.resourceLedger.quarantine(handle);
9481
+ }
9482
+ return proof;
9483
+ }
9063
9484
 
9064
9485
  /** Atomically interrupt the active run and make text the next prompt. */
9065
9486
  async cancelAndSubmit(text: string, options?: { queuedEntryId?: string }): Promise<CancelAndSubmitOutcome> {
@@ -9713,6 +10134,16 @@ export class AgentSession {
9713
10134
  getSessionDefaultModelSelector(): string | undefined {
9714
10135
  return getSessionContextForInternalRead(this.sessionManager).models.default;
9715
10136
  }
10137
+ /**
10138
+ * Resolve the model that `modelRoles.default` currently points to, independent
10139
+ * of whatever model this session's log last recorded. Used by the TUI resume
10140
+ * flow's `session.resumeModelBehavior: "ask"` prompt to offer the currently
10141
+ * configured default as an alternative to the session's saved model.
10142
+ */
10143
+ resolveConfiguredDefaultModel(): Model | undefined {
10144
+ const availableModels = this.#modelRegistry.getAvailable();
10145
+ return this.#resolveRoleModelFull("default", availableModels, undefined).model;
10146
+ }
9716
10147
 
9717
10148
  /**
9718
10149
  * Re-assert the session resume default ("provider/id") in the session log
@@ -10604,6 +11035,16 @@ export class AgentSession {
10604
11035
  return getSupportedEfforts(this.model);
10605
11036
  }
10606
11037
 
11038
+ /**
11039
+ * Runtime evidence published for the current GJC bundle activation
11040
+ * generation, set once by `createAgentSession` after every producer has run.
11041
+ * Undefined until that publication happens, so consumers report runtime
11042
+ * status as unavailable rather than falsely clear.
11043
+ */
11044
+ gjcRuntimeSnapshot?: GjcRuntimeSnapshotProvider;
11045
+ /** Activation generation a published snapshot must match to be merged. */
11046
+ gjcActivationGeneration?: number;
11047
+
10607
11048
  // =========================================================================
10608
11049
  // Message Queue Mode Management
10609
11050
  // =========================================================================
@@ -10645,13 +11086,93 @@ export class AgentSession {
10645
11086
  async #pruneToolOutputs(
10646
11087
  signal?: AbortSignal,
10647
11088
  overThreshold = false,
10648
- ): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
11089
+ options?: { commitGate?: (actual: { prunedCount: number; tokensSaved: number }) => boolean },
11090
+ ): Promise<{ prunedCount: number; tokensSaved: number; committed: boolean } | undefined> {
10649
11091
  const branchEntries = this.sessionManager.getBranch();
10650
- const result = pruneToolOutputs(
10651
- branchEntries,
10652
- DEFAULT_PRUNE_CONFIG,
10653
- overThreshold ? { relaxedMinimum: 0 } : undefined,
10654
- );
11092
+ const artifactManager = this.sessionManager.getArtifactManager();
11093
+ const prunedArtifacts: Array<{ entryId: string; id: string; toolType: string; originalText: string }> = [];
11094
+ let reservedArtifactId: string | undefined;
11095
+ if (artifactManager) {
11096
+ try {
11097
+ reservedArtifactId = (await artifactManager.allocatePath("tool-output")).id;
11098
+ } catch (error) {
11099
+ logger.warn("Failed to reserve artifact ID for pruned tool output", {
11100
+ error: error instanceof Error ? error.message : String(error),
11101
+ });
11102
+ }
11103
+ }
11104
+ const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG, {
11105
+ relaxedMinimum: overThreshold ? 0 : undefined,
11106
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11107
+ artifactRef: candidate => {
11108
+ if (!artifactManager) return undefined;
11109
+ const id = reservedArtifactId ?? String(artifactManager.allocateId());
11110
+ reservedArtifactId = undefined;
11111
+ const toolType = (candidate.toolName ?? "tool-output").replace(/[^a-zA-Z0-9_-]/g, "_") || "tool-output";
11112
+ prunedArtifacts.push({ entryId: candidate.entryId, id, toolType, originalText: candidate.originalText });
11113
+ return `artifact://${id}`;
11114
+ },
11115
+ });
11116
+ const failedArtifactEntryIds = new Set<string>();
11117
+ const publishedArtifacts: typeof prunedArtifacts = [];
11118
+ for (const artifact of prunedArtifacts.filter(artifact =>
11119
+ result.originals.some(original => original.entryId === artifact.entryId),
11120
+ )) {
11121
+ try {
11122
+ await artifactManager?.publishNamedNoReplace(
11123
+ `${artifact.id}.${artifact.toolType}.log`,
11124
+ new TextEncoder().encode(artifact.originalText),
11125
+ );
11126
+ publishedArtifacts.push(artifact);
11127
+ } catch (error) {
11128
+ failedArtifactEntryIds.add(artifact.entryId);
11129
+ logger.warn("Failed to persist pruned tool output artifact", {
11130
+ artifactId: artifact.id,
11131
+ error: error instanceof Error ? error.message : String(error),
11132
+ });
11133
+ }
11134
+ }
11135
+ const rollbackPublishedArtifacts = async (reason: "aborted" | "commit_gate_rejected") => {
11136
+ let removedArtifacts = 0;
11137
+ let unremovedArtifacts = 0;
11138
+ for (const artifact of publishedArtifacts) {
11139
+ const filename = `${artifact.id}.${artifact.toolType}.log`;
11140
+ let removed = false;
11141
+ for (let attempt = 0; attempt < 3 && !removed; attempt++) {
11142
+ removed = (await artifactManager?.removeNamedBestEffort(filename)) ?? false;
11143
+ if (!removed && attempt < 2) await Bun.sleep(10);
11144
+ }
11145
+ if (removed) removedArtifacts++;
11146
+ else {
11147
+ unremovedArtifacts++;
11148
+ logger.warn("Failed to roll back staged pruned-output artifact after retries", {
11149
+ artifactId: artifact.id,
11150
+ reason,
11151
+ });
11152
+ }
11153
+ }
11154
+ return { removedArtifacts, unremovedArtifacts };
11155
+ };
11156
+ let toolTokensSaved = result.tokensSaved;
11157
+ let toolPrunedCount = result.prunedCount;
11158
+ const committedToolEntries = result.prunedEntries.filter(entry => {
11159
+ if (!failedArtifactEntryIds.has(entry.id)) return true;
11160
+ const original = result.originals.find(candidate => candidate.entryId === entry.id);
11161
+ if (!original) return true;
11162
+ const message = entry.message as ToolResultMessage;
11163
+ toolTokensSaved -=
11164
+ original.tokens -
11165
+ estimateTextTokensHeuristic(
11166
+ message.content
11167
+ .filter((part): part is TextContent => part.type === "text")
11168
+ .map(part => part.text)
11169
+ .join(""),
11170
+ );
11171
+ toolPrunedCount--;
11172
+ message.content = [{ type: "text", text: original.originalText }];
11173
+ delete message.prunedAt;
11174
+ return false;
11175
+ });
10655
11176
  const argumentResult = pruneAssistantToolArguments(branchEntries, DEFAULT_PRUNE_CONFIG);
10656
11177
  const fileMentionResult = pruneStaleFileMentions(branchEntries, p =>
10657
11178
  resolveReadPath(p, this.sessionManager.getCwd()),
@@ -10659,22 +11180,37 @@ export class AgentSession {
10659
11180
  const volatileContextResult = pruneSupersededVolatileProjectContext(branchEntries);
10660
11181
  const reminderResult = pruneSupersededMaintenanceReminders(branchEntries);
10661
11182
  const tokensSaved =
10662
- result.tokensSaved +
11183
+ toolTokensSaved +
10663
11184
  argumentResult.argumentTokensSaved +
10664
11185
  Math.round((fileMentionResult.bytesSaved + volatileContextResult.bytesSaved + reminderResult.bytesSaved) / 4);
10665
11186
  const prunedCount =
10666
- result.prunedCount +
11187
+ toolPrunedCount +
10667
11188
  argumentResult.argumentPrunedCount +
10668
11189
  fileMentionResult.changed.length +
10669
11190
  volatileContextResult.changed.length +
10670
11191
  reminderResult.changed.length;
10671
11192
  if (prunedCount === 0 || signal?.aborted) {
11193
+ if (publishedArtifacts.length > 0) await rollbackPublishedArtifacts("aborted");
11194
+ return undefined;
11195
+ }
11196
+ if (options?.commitGate && !options.commitGate({ prunedCount, tokensSaved })) {
11197
+ const rollback = await rollbackPublishedArtifacts("commit_gate_rejected");
11198
+ logger.info("Below-threshold maintenance pruning staged but not committed", {
11199
+ prunedCount,
11200
+ tokensSaved,
11201
+ rolledBackArtifacts: rollback.removedArtifacts,
11202
+ unremovedArtifacts: rollback.unremovedArtifacts,
11203
+ });
11204
+ return { prunedCount, tokensSaved, committed: false };
11205
+ }
11206
+ if (signal?.aborted) {
11207
+ await rollbackPublishedArtifacts("aborted");
10672
11208
  return undefined;
10673
11209
  }
10674
11210
 
10675
11211
  // getBranch() returns materialized copies for blob-externalized entries, so
10676
11212
  // the pruning mutations must be written back into the canonical store.
10677
- const combined = [...result.prunedEntries, ...argumentResult.prunedEntries, ...fileMentionResult.changed];
11213
+ const combined = [...committedToolEntries, ...argumentResult.prunedEntries, ...fileMentionResult.changed];
10678
11214
  this.sessionManager.applyEntryMessageUpdates(combined);
10679
11215
  this.sessionManager.applyCustomMessageEntryUpdates([...volatileContextResult.changed, ...reminderResult.changed]);
10680
11216
  await this.sessionManager.rewriteEntries();
@@ -10685,7 +11221,7 @@ export class AgentSession {
10685
11221
  this.#resetInjectedContextSignatures();
10686
11222
  this.#syncTodoPhasesFromBranch();
10687
11223
  this.#closeCodexProviderSessionsForHistoryRewrite();
10688
- return { prunedCount, tokensSaved };
11224
+ return { prunedCount, tokensSaved, committed: true };
10689
11225
  }
10690
11226
 
10691
11227
  /**
@@ -10718,7 +11254,9 @@ export class AgentSession {
10718
11254
  const compactionSettings = this.settings.getGroup("compaction");
10719
11255
  if (!compactionSettings.maintenancePruningEnabled) return;
10720
11256
 
10721
- const estimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG);
11257
+ const estimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11258
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11259
+ });
10722
11260
  const cacheEpochResetCost = this.#lastCacheEpochResetCost();
10723
11261
  if (
10724
11262
  !shouldRunMaintenancePrune({
@@ -10731,10 +11269,30 @@ export class AgentSession {
10731
11269
  return;
10732
11270
  }
10733
11271
 
10734
- const pruneResult = await this.#pruneToolOutputs();
11272
+ const pruneResult = await this.#pruneToolOutputs(undefined, false, {
11273
+ commitGate: actual => actual.tokensSaved > cacheEpochResetCost,
11274
+ });
10735
11275
  if (!pruneResult || pruneResult.prunedCount === 0) return;
10736
-
10737
- const resetReason = `below-threshold maintenance: reclaimed ${pruneResult.tokensSaved} tokens > cache-epoch reset cost ${cacheEpochResetCost}`;
11276
+ if (!pruneResult.committed) {
11277
+ const message = `Maintenance pruning skipped: actual savings ${pruneResult.tokensSaved} tokens <= cache-epoch reset cost ${cacheEpochResetCost} after artifact notices/publication rollback.`;
11278
+ await this.#emitSessionEvent({
11279
+ type: "notice",
11280
+ level: "info",
11281
+ source: "maintenance-prune",
11282
+ message,
11283
+ });
11284
+ logger.info("Below-threshold maintenance pruning skipped", {
11285
+ tokensSaved: pruneResult.tokensSaved,
11286
+ prunedCount: pruneResult.prunedCount,
11287
+ cacheEpochResetCost,
11288
+ minSavings: compactionSettings.maintenancePruningMinSavingsTokens,
11289
+ });
11290
+ return;
11291
+ }
11292
+ const resetReason =
11293
+ pruneResult.tokensSaved > cacheEpochResetCost
11294
+ ? `below-threshold maintenance: reclaimed ${pruneResult.tokensSaved} tokens > cache-epoch reset cost ${cacheEpochResetCost}`
11295
+ : `below-threshold maintenance: reclaimed ${pruneResult.tokensSaved} tokens <= cache-epoch reset cost ${cacheEpochResetCost}`;
10738
11296
  await this.#emitSessionEvent({
10739
11297
  type: "notice",
10740
11298
  level: "info",
@@ -10768,6 +11326,8 @@ export class AgentSession {
10768
11326
  await this.abort();
10769
11327
  const compactionAbortController = new AbortController();
10770
11328
  this.#compactionAbortController = compactionAbortController;
11329
+ // Take this invocation's state snapshot for the summarizer context.
11330
+ const compactionStateSnapshot = await this.#compactionStateSnapshot();
10771
11331
 
10772
11332
  try {
10773
11333
  if (!this.model) {
@@ -10813,7 +11373,11 @@ export class AgentSession {
10813
11373
  }
10814
11374
  }
10815
11375
 
10816
- const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
11376
+ const compactionPrep = await this.#prepareCompactionFromHooks(
11377
+ preparation,
11378
+ hookCompaction,
11379
+ compactionStateSnapshot,
11380
+ );
10817
11381
 
10818
11382
  let summary: string;
10819
11383
  let shortSummary: string | undefined;
@@ -11305,6 +11869,7 @@ export class AgentSession {
11305
11869
  assistantMessage: AssistantMessage,
11306
11870
  skipAbortedCheck = true,
11307
11871
  onTerminalOverflowNoop?: () => void,
11872
+ resourceRunId?: string,
11308
11873
  ): Promise<boolean> {
11309
11874
  // Safety stops are terminal and must not trigger context maintenance.
11310
11875
  if (
@@ -11351,7 +11916,12 @@ export class AgentSession {
11351
11916
  const promoted = await this.#tryContextPromotion(assistantMessage);
11352
11917
  if (promoted) {
11353
11918
  // Retry on the promoted (larger) model without compacting
11354
- this.#scheduleAgentContinue({ delayMs: 100, generation, suppressPredecessorAgentEnd: true });
11919
+ this.#scheduleAgentContinue({
11920
+ delayMs: 100,
11921
+ generation,
11922
+ suppressPredecessorAgentEnd: true,
11923
+ resourceRunId,
11924
+ });
11355
11925
 
11356
11926
  return true;
11357
11927
  }
@@ -11367,10 +11937,11 @@ export class AgentSession {
11367
11937
  this.agent.appendMessage(assistantMessage);
11368
11938
  }
11369
11939
  },
11940
+ resourceRunId,
11370
11941
  });
11371
11942
  return "continuationScheduled" in status && status.continuationScheduled === true;
11372
11943
  }
11373
- return this.#scheduleOverflowRetryContinuation(generation);
11944
+ return await this.#scheduleOverflowRetryContinuation(generation, resourceRunId);
11374
11945
  }
11375
11946
  const compactionSettings = this.settings.getGroup("compaction");
11376
11947
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
@@ -11390,6 +11961,7 @@ export class AgentSession {
11390
11961
  return true;
11391
11962
  const pruneEstimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11392
11963
  relaxedMinimum: 0,
11964
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11393
11965
  });
11394
11966
  if (
11395
11967
  pruneEstimate.tokensSaved > 0 &&
@@ -11494,6 +12066,7 @@ export class AgentSession {
11494
12066
  // prompt-cache epoch via #closeCodexProviderSessionsForHistoryRewrite.
11495
12067
  const pruneEstimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11496
12068
  relaxedMinimum: 0,
12069
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11497
12070
  });
11498
12071
  let pruneResult: { prunedCount: number; tokensSaved: number } | undefined;
11499
12072
  if (
@@ -11675,6 +12248,7 @@ export class AgentSession {
11675
12248
 
11676
12249
  const pruneEstimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11677
12250
  relaxedMinimum: 0,
12251
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11678
12252
  });
11679
12253
  if (
11680
12254
  pruneEstimate.tokensSaved > 0 &&
@@ -12614,6 +13188,7 @@ export class AgentSession {
12614
13188
  async #prepareCompactionFromHooks(
12615
13189
  preparation: CompactionPreparation,
12616
13190
  hookCompaction: CompactionResult | undefined,
13191
+ stateSnapshot: CompactionStateSnapshot,
12617
13192
  ): Promise<
12618
13193
  | {
12619
13194
  kind: "fromHook";
@@ -12653,11 +13228,17 @@ export class AgentSession {
12653
13228
  hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
12654
13229
  }
12655
13230
 
13231
+ const stateContext = this.#compactionStateContext(stateSnapshot);
13232
+
13233
+ if (stateContext.length > 0) {
13234
+ hookContext = hookContext ? [...hookContext, ...stateContext] : stateContext;
13235
+ }
13236
+
12656
13237
  if (hookCompaction) {
12657
13238
  preserveData ??= hookCompaction.preserveData;
12658
13239
  return {
12659
13240
  kind: "fromHook",
12660
- summary: hookCompaction.summary,
13241
+ summary: appendCompactionStateContext(hookCompaction.summary, stateContext),
12661
13242
  shortSummary: hookCompaction.shortSummary,
12662
13243
  firstKeptEntryId: hookCompaction.firstKeptEntryId,
12663
13244
  tokensBefore: hookCompaction.tokensBefore,
@@ -12682,6 +13263,7 @@ export class AgentSession {
12682
13263
  force?: boolean;
12683
13264
  signal?: AbortSignal;
12684
13265
  beforeTerminalOverflowNoop?: () => void;
13266
+ resourceRunId?: string;
12685
13267
  },
12686
13268
  ): Promise<AutoCompactionTerminalStatus> {
12687
13269
  const compactionSettings = this.settings.getGroup("compaction");
@@ -12703,7 +13285,7 @@ export class AgentSession {
12703
13285
  if (signal.aborted) return;
12704
13286
  await this.#runAutoCompaction(reason, willRetry, true, options);
12705
13287
  },
12706
- { generation },
13288
+ { generation, resourceRunId: options?.resourceRunId },
12707
13289
  );
12708
13290
  return { kind: "skipped" };
12709
13291
  }
@@ -12735,6 +13317,10 @@ export class AgentSession {
12735
13317
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
12736
13318
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
12737
13319
  if (autoCompactionSignal.aborted) return await emitAborted();
13320
+ const compactionStateSnapshot = await this.#compactionStateSnapshot();
13321
+ if (autoCompactionSignal.aborted || this.#isDisposed || this.#promptGeneration !== generation) {
13322
+ return await emitAborted();
13323
+ }
12738
13324
 
12739
13325
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
12740
13326
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
@@ -12774,6 +13360,7 @@ export class AgentSession {
12774
13360
  if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
12775
13361
  this.#scheduleAutoContinuePrompt(generation);
12776
13362
  }
13363
+
12777
13364
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
12778
13365
  return { kind: "compacted" };
12779
13366
  }
@@ -12827,15 +13414,22 @@ export class AgentSession {
12827
13414
  const overflowNoopWouldReplay = reason === "overflow" && willRetry && this.#isResumableAgentTail();
12828
13415
  const continuationSkipReason =
12829
13416
  !overflowNoopWouldReplay && willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
13417
+ if (continuationSkipReason) {
13418
+ this.#logCompactionContinuationSkipped("overflow_retry", continuationSkipReason);
13419
+ }
12830
13420
  if (overflowNoopWouldReplay) {
12831
13421
  options?.beforeTerminalOverflowNoop?.();
12832
13422
  }
13423
+ const overflowContinuationScheduled =
13424
+ !overflowNoopWouldReplay && willRetry && !continuationSkipReason
13425
+ ? await this.#scheduleOverflowRetryContinuation(generation)
13426
+ : false;
12833
13427
  await this.#emitSessionEvent({
12834
13428
  type: "auto_compaction_end",
12835
13429
  action,
12836
13430
  result: undefined,
12837
13431
  aborted: false,
12838
- willRetry: overflowNoopWouldReplay ? false : willRetry && !continuationSkipReason,
13432
+ willRetry: overflowContinuationScheduled,
12839
13433
  errorMessage: overflowNoopWouldReplay
12840
13434
  ? "Context overflow recovery skipped: nothing eligible to compact. Run /clear to preserve this session ID, or switch to a larger-context model before retrying."
12841
13435
  : undefined,
@@ -12848,6 +13442,7 @@ export class AgentSession {
12848
13442
  delayMs: 100,
12849
13443
  generation,
12850
13444
  shouldContinue: () => this.agent.hasQueuedMessages(),
13445
+ rescheduleOnBusy: true,
12851
13446
  onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
12852
13447
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
12853
13448
  });
@@ -12856,7 +13451,9 @@ export class AgentSession {
12856
13451
  return { kind: "skipped" };
12857
13452
  }
12858
13453
  if (willRetry) {
12859
- return { kind: "skipped", continuationScheduled: this.#scheduleOverflowRetryContinuation(generation) };
13454
+ return overflowContinuationScheduled
13455
+ ? { kind: "skipped", continuationScheduled: true }
13456
+ : { kind: "skipped" };
12860
13457
  }
12861
13458
  if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
12862
13459
  this.#scheduleAgentContinue({
@@ -12865,6 +13462,7 @@ export class AgentSession {
12865
13462
  suppressPredecessorAgentEnd: true,
12866
13463
 
12867
13464
  shouldContinue: () => this.agent.hasQueuedMessages(),
13465
+ rescheduleOnBusy: true,
12868
13466
  onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
12869
13467
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
12870
13468
  });
@@ -12905,7 +13503,11 @@ export class AgentSession {
12905
13503
  }
12906
13504
  }
12907
13505
 
12908
- const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
13506
+ const compactionPrep = await this.#prepareCompactionFromHooks(
13507
+ preparation,
13508
+ hookCompaction,
13509
+ compactionStateSnapshot,
13510
+ );
12909
13511
  if (autoCompactionSignal.aborted) return await emitAborted();
12910
13512
 
12911
13513
  let summary: string;
@@ -13088,18 +13690,25 @@ export class AgentSession {
13088
13690
  this.#lastOversizedAutoMaintenanceAttemptSignature = undefined;
13089
13691
 
13090
13692
  const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
13693
+ if (continuationSkipReason) {
13694
+ this.#logCompactionContinuationSkipped("overflow_retry", continuationSkipReason);
13695
+ }
13696
+ const overflowContinuationScheduled =
13697
+ willRetry && !continuationSkipReason ? await this.#scheduleOverflowRetryContinuation(generation) : false;
13091
13698
  await this.#emitSessionEvent({
13092
13699
  type: "auto_compaction_end",
13093
13700
  action,
13094
13701
  result,
13095
13702
  aborted: false,
13096
- willRetry: willRetry && !continuationSkipReason,
13703
+ willRetry: overflowContinuationScheduled,
13097
13704
  continuationSkipReason,
13098
13705
  });
13099
13706
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
13100
13707
 
13101
13708
  if (willRetry) {
13102
- return { kind: "compacted", continuationScheduled: this.#scheduleOverflowRetryContinuation(generation) };
13709
+ return overflowContinuationScheduled
13710
+ ? { kind: "compacted", continuationScheduled: true }
13711
+ : { kind: "compacted" };
13103
13712
  }
13104
13713
  if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
13105
13714
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
@@ -13109,6 +13718,7 @@ export class AgentSession {
13109
13718
  generation,
13110
13719
  suppressPredecessorAgentEnd: true,
13111
13720
  shouldContinue: () => this.agent.hasQueuedMessages(),
13721
+ rescheduleOnBusy: true,
13112
13722
  onSkip: reason => this.#logCompactionContinuationSkipped("queued_continue", reason),
13113
13723
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
13114
13724
  });
@@ -13223,6 +13833,10 @@ export class AgentSession {
13223
13833
  return false;
13224
13834
  }
13225
13835
 
13836
+ #isTypedFirstEventTimeout(message: AssistantMessage): boolean {
13837
+ return message.transportFailure?.providerCode?.toLowerCase() === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE;
13838
+ }
13839
+
13226
13840
  #isTerminalProviderFirstEventTimeout(message: AssistantMessage): boolean {
13227
13841
  return this.#isKimiCodeFirstEventTimeout(message) || this.#isAlibabaTokenPlanFirstEventTimeout(message);
13228
13842
  }
@@ -13348,6 +13962,7 @@ export class AgentSession {
13348
13962
  #classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
13349
13963
  if (message.stopReason !== "error") return "none";
13350
13964
  if (message.errorKind === "provider_safety_stop") return "terminal";
13965
+ if (this.#isTypedFirstEventTimeout(message)) return "first_event_timeout";
13351
13966
  if (!message.errorMessage) return "none";
13352
13967
  const err = message.errorMessage;
13353
13968
  // Provider safety refusals (e.g. Anthropic stop_reason "refusal" /
@@ -13388,10 +14003,8 @@ export class AgentSession {
13388
14003
  if (this.#isTerminalProviderFirstEventTimeout(message)) {
13389
14004
  return "terminal";
13390
14005
  }
13391
- // A first-event timeout on ollama-cloud (the ollama-chat API) must not
13392
- // join the unbounded transient class: each continuation retry re-issues
13393
- // the full request to a remote, billable backend, so an unbounded loop
13394
- // can silently spike usage (#713). Bound it to retry.maxRetries instead.
14006
+ // Legacy providers that have not yet stamped the typed timeout fact retain
14007
+ // their existing bounded ollama-cloud behavior.
13395
14008
  if (this.#isFirstEventTimeoutErrorMessage(err) && this.#shouldFailClosedOnFirstEventTimeout(message)) {
13396
14009
  return "first_event_timeout";
13397
14010
  }
@@ -13727,25 +14340,25 @@ export class AgentSession {
13727
14340
  return `Model fallback chain exhausted; models tried: ${tried}; models skipped: ${skipped}`;
13728
14341
  }
13729
14342
 
13730
- async #markFailedManagedCredential(trigger: {
13731
- class: FallbackTriggerClass;
13732
- retryAfterMs?: number;
13733
- }): Promise<boolean> {
14343
+ async #markFailedCredential(trigger: { class: FallbackTriggerClass; retryAfterMs?: number }): Promise<boolean> {
13734
14344
  if (!this.model || (trigger.class !== "auth" && trigger.class !== "quota" && trigger.class !== "rate_limit")) {
13735
14345
  return false;
13736
14346
  }
13737
14347
  const authStorage = this.#modelRegistry.authStorage;
14348
+ const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
13738
14349
  if (trigger.class === "auth") {
13739
- const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
14350
+ const apiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
13740
14351
  if (!isAuthenticated(apiKey)) return false;
13741
- return authStorage.invalidateCredentialMatching(this.model.provider, apiKey, { sessionId: this.sessionId });
14352
+ return authStorage.invalidateCredentialMatching(this.model.provider, apiKey, {
14353
+ sessionId: providerAffinitySessionId,
14354
+ });
13742
14355
  }
13743
14356
  if (authStorage.hasRuntimeApiKey(this.model.provider)) return false;
13744
- const activeApiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
13745
- const rotated = await authStorage.markUsageLimitReached(this.model.provider, this.sessionId, {
14357
+ const activeApiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14358
+ const rotated = await authStorage.markUsageLimitReached(this.model.provider, providerAffinitySessionId, {
13746
14359
  retryAfterMs: trigger.retryAfterMs,
13747
14360
  });
13748
- return rotated && (await this.#modelRegistry.getApiKey(this.model, this.sessionId)) !== activeApiKey;
14361
+ return rotated && (await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId)) !== activeApiKey;
13749
14362
  }
13750
14363
 
13751
14364
  /** Handle retryable errors with exponential backoff. */
@@ -13766,16 +14379,16 @@ export class AgentSession {
13766
14379
  // user opt-out.
13767
14380
  if (!managedFallback && !retrySettings.enabled) return false;
13768
14381
  const classification = managedFallback ? undefined : this.#classifyErrorForRetry(message);
13769
- // Bare defaults admit only clean, side-effect-free canonical stream watchdog failures.
13770
- if (!managedFallback && !legacyRetryConfigured) {
13771
- if (
13772
- hasBareDefaultRetryDisqualifyingFacts(message) ||
13773
- (classification !== "transient" && classification !== "first_event_timeout") ||
13774
- !BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? "") ||
13775
- !this.#hasCleanRetryReplaySafety
13776
- ) {
13777
- return false;
13778
- }
14382
+ if (classification === "first_event_timeout") {
14383
+ this.#firstEventTimeoutRetryStartedAt ??= Date.now();
14384
+ }
14385
+ // First-event timeouts are replay-safe only before any model progress,
14386
+ // abort, or extension/provider lifecycle participation.
14387
+ if (
14388
+ classification === "first_event_timeout" &&
14389
+ (!this.#hasCleanRetryReplaySafety || assistantMessageHasVisibleOrToolContent(message))
14390
+ ) {
14391
+ return false;
13779
14392
  }
13780
14393
  const trigger = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
13781
14394
  if (!trigger) {
@@ -13783,6 +14396,39 @@ export class AgentSession {
13783
14396
  ? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
13784
14397
  : false;
13785
14398
  }
14399
+ // Credential rotation: a content-free quota/rate-limit failure has no
14400
+ // observable state to corrupt, so it is replay-safe regardless of
14401
+ // extension lifecycle participation. Mark the failed credential and
14402
+ // retry with the next stored credential of the same provider.
14403
+ let credentialRotated =
14404
+ !managedFallback &&
14405
+ !assistantMessageHasVisibleOrToolContent(message) &&
14406
+ (trigger.class === "quota" || trigger.class === "rate_limit") &&
14407
+ (await this.#markFailedCredential(trigger));
14408
+ // A content-free credential rotation is inherently replay-safe: no partial
14409
+ // output, no tool calls, no extension-observable streaming state was produced
14410
+ // before the failure. This bypasses #hasCleanRetryReplaySafety because the
14411
+ // content-free check is the replay-safety guarantee for credential rotation.
14412
+ const canReplayRotatedCredential = credentialRotated;
14413
+ // Bare defaults admit only clean, side-effect-free canonical stream watchdog failures,
14414
+ // content-free quota/rate-limit credential rotations, and the explicit Codex
14415
+ // capacity-overload event.
14416
+ if (!managedFallback && !legacyRetryConfigured && !canReplayRotatedCredential) {
14417
+ const bareDefaultCodexOverload = isBareDefaultCodexOverload(message);
14418
+ // Content-free Codex overload is replay-safe by the same reasoning as
14419
+ // credential rotation: no partial output means no observable state.
14420
+ const canReplayCodexOverload = bareDefaultCodexOverload;
14421
+ if (
14422
+ (!canReplayCodexOverload &&
14423
+ !this.#isTypedFirstEventTimeout(message) &&
14424
+ (hasBareDefaultRetryDisqualifyingFacts(message) ||
14425
+ (classification !== "transient" && classification !== "first_event_timeout") ||
14426
+ !BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))) ||
14427
+ (!canReplayCodexOverload && !this.#hasCleanRetryReplaySafety)
14428
+ ) {
14429
+ return false;
14430
+ }
14431
+ }
13786
14432
  const legacyUnbounded = classification === "transient";
13787
14433
  const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
13788
14434
  const failedSelector = managedFallback ? controller.currentSelector() : undefined;
@@ -13791,12 +14437,14 @@ export class AgentSession {
13791
14437
  : legacyUnbounded || attemptsUsed <= retrySettings.maxRetries
13792
14438
  ? "retry"
13793
14439
  : "exhausted";
13794
- const credentialRotated =
13795
- managedFallback &&
13796
- outcome === "advance" &&
13797
- (trigger.class === "quota" || trigger.class === "rate_limit") &&
13798
- (await this.#markFailedManagedCredential(trigger));
13799
- if (credentialRotated && controller.restorePreviousEntryForRetry()) {
14440
+ // Credential rotation is unbounded: a fresh credential is a different
14441
+ // retry dimension from transient-error backoff, so it overrides maxRetries
14442
+ // exhaustion and forces an immediate same-model retry.
14443
+ if (managedFallback && outcome === "advance" && (trigger.class === "quota" || trigger.class === "rate_limit")) {
14444
+ credentialRotated = await this.#markFailedCredential(trigger);
14445
+ }
14446
+ if (credentialRotated) {
14447
+ if (managedFallback) controller.restorePreviousEntryForRetry();
13800
14448
  outcome = "retry";
13801
14449
  }
13802
14450
  if (outcome === "exhausted") {
@@ -13807,6 +14455,10 @@ export class AgentSession {
13807
14455
  controller.resetSticky();
13808
14456
  return managedOutcome ? this.#managedFallbackExhaustionDecision(message, errorMessage) : false;
13809
14457
  }
14458
+ if (classification === "first_event_timeout") {
14459
+ const elapsedMs = Date.now() - (this.#firstEventTimeoutRetryStartedAt ?? Date.now());
14460
+ message.errorMessage = `First-event stream timeout exhausted after ${attemptsUsed} attempts; waited ${elapsedMs}ms total: ${message.errorMessage ?? "Unknown error"}`;
14461
+ }
13810
14462
  return false;
13811
14463
  }
13812
14464
 
@@ -13828,7 +14480,7 @@ export class AgentSession {
13828
14480
  }
13829
14481
 
13830
14482
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
13831
- if (managedFallback && !credentialRotated) await this.#markFailedManagedCredential(trigger);
14483
+ if (managedFallback && !credentialRotated) await this.#markFailedCredential(trigger);
13832
14484
  let advanced = outcome !== "advance";
13833
14485
  let resolutionError: unknown;
13834
14486
  if (outcome === "advance") {
@@ -13870,7 +14522,12 @@ export class AgentSession {
13870
14522
  await this.#emitSessionEvent({
13871
14523
  type: "auto_retry_start",
13872
14524
  attempt: this.#retryAttempt,
13873
- maxAttempts: managedFallback ? controller.maxAttempts : retrySettings.maxRetries,
14525
+ maxAttempts:
14526
+ classification === "first_event_timeout"
14527
+ ? retrySettings.maxRetries + 1
14528
+ : managedFallback
14529
+ ? controller.maxAttempts
14530
+ : retrySettings.maxRetries,
13874
14531
  delayMs,
13875
14532
  errorMessage,
13876
14533
  unbounded: managedFallback ? false : legacyUnbounded,
@@ -13878,9 +14535,19 @@ export class AgentSession {
13878
14535
 
13879
14536
  const messages = this.agent.state.messages;
13880
14537
  if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
13881
- this.agent.replaceMessages(messages.slice(0, -1));
14538
+ let end = messages.length - 1;
14539
+ // A wedged turn can leave more than one assistant tail behind (e.g. the
14540
+ // agent loop's invalid_prompt repair resend commits its own rejected
14541
+ // message). agent.continue() refuses ANY assistant tail, so strip the
14542
+ // whole trailing run of failed attempts, not just the last one.
14543
+ while (end > 0) {
14544
+ const previous = messages[end - 1];
14545
+ if (previous.role !== "assistant") break;
14546
+ if (previous.stopReason !== "error" && previous.stopReason !== "aborted") break;
14547
+ end--;
14548
+ }
14549
+ this.agent.replaceMessages(messages.slice(0, end));
13882
14550
  }
13883
-
13884
14551
  try {
13885
14552
  await scheduler.wait(delayMs, { signal: retryAbortController.signal });
13886
14553
  } catch {
@@ -14120,12 +14787,8 @@ export class AgentSession {
14120
14787
  // Bash Execution
14121
14788
  // =========================================================================
14122
14789
 
14123
- async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
14124
- try {
14125
- return await this.sessionManager.saveArtifact(originalText, "bash-original");
14126
- } catch {
14127
- return undefined;
14128
- }
14790
+ async #saveBashOriginalArtifact(originalText: string): Promise<BashArtifactSaveResult> {
14791
+ return saveAgentBashOriginalArtifact(this.sessionManager, originalText);
14129
14792
  }
14130
14793
 
14131
14794
  /**
@@ -15057,7 +15720,10 @@ export class AgentSession {
15057
15720
  */
15058
15721
  async switchSession(
15059
15722
  sessionPath: string,
15060
- options?: { transition?: SessionSwitchEvent["transition"] },
15723
+ options?: {
15724
+ transition?: SessionSwitchEvent["transition"];
15725
+ onTransitionMutationStarted?: () => void;
15726
+ },
15061
15727
  ): Promise<boolean> {
15062
15728
  this.#beginSessionTransition("switch-session");
15063
15729
  try {
@@ -15077,6 +15743,7 @@ export class AgentSession {
15077
15743
  return false;
15078
15744
  }
15079
15745
  }
15746
+ options?.onTransitionMutationStarted?.();
15080
15747
 
15081
15748
  this.#disconnectFromAgent();
15082
15749
  await this.abort();
@@ -15119,7 +15786,7 @@ export class AgentSession {
15119
15786
  // workflow-gate emitter, and hooks cannot resolve against an ungated
15120
15787
  // root. The manager-rotation window itself is tracked in #3138
15121
15788
  // (#2797 / #2925).
15122
- if (switchingToDifferentSession) await initializeLocalRoot(this.#localProtocolOptions());
15789
+ if (switchingToDifferentSession) await this.#initializeLocalRootForLoadedSession();
15123
15790
  this.#syncAgentSessionId();
15124
15791
  this.#rekeyHindsightMemoryForCurrentSessionId();
15125
15792
 
@@ -15141,9 +15808,13 @@ export class AgentSession {
15141
15808
  this.#rebindProviderSessionState(new Map());
15142
15809
  }
15143
15810
 
15811
+ const resumeModelBehavior = this.settings.get("session.resumeModelBehavior");
15144
15812
  const configuredDefaultChain = sessionContext.configuredModelChains.default?.entries;
15813
+ const settingsDefaultEntries = normalizeModelSelectorValue(this.settings.getModelRole("default"));
15145
15814
  const defaultEntries =
15146
- configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []);
15815
+ resumeModelBehavior === "useCurrentDefault"
15816
+ ? settingsDefaultEntries
15817
+ : (configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []));
15147
15818
  this.#defaultFallbackController = undefined;
15148
15819
  if (defaultEntries.length > 0) {
15149
15820
  const resolution = await resolveModelChainWithAuth(
@@ -15200,7 +15871,7 @@ export class AgentSession {
15200
15871
  // Establish the successor's durable session identity only after every
15201
15872
  // restored state facet is live. Identity-bound extension hooks run below.
15202
15873
  await this.sessionManager.ensureOnDisk();
15203
- if (!switchingToDifferentSession) await initializeLocalRoot(this.#localProtocolOptions());
15874
+ if (!switchingToDifferentSession) await this.#initializeLocalRootForLoadedSession();
15204
15875
 
15205
15876
  if (switchingToDifferentSession) {
15206
15877
  // The local:// migration gate for this successor already ran above,