@gajae-code/coding-agent 0.12.0 → 0.12.1

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 (223) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +54 -0
  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-runtime.d.ts +18 -0
  16. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  17. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  18. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  19. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  21. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  22. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  23. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  24. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  25. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  26. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  27. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  28. package/dist/types/reminders/star-reminder.d.ts +3 -13
  29. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  30. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  31. package/dist/types/sdk/broker/transport.d.ts +2 -0
  32. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  33. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  34. package/dist/types/sdk/bus/config.d.ts +6 -2
  35. package/dist/types/sdk/bus/index.d.ts +29 -0
  36. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  37. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  38. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  39. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  40. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  41. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  42. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  43. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  44. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  45. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  46. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  47. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  48. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  49. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  50. package/dist/types/sdk/prompt-status.d.ts +23 -3
  51. package/dist/types/sdk/session.d.ts +6 -0
  52. package/dist/types/session/agent-session.d.ts +38 -2
  53. package/dist/types/session/artifacts.d.ts +8 -0
  54. package/dist/types/session/blob-store.d.ts +57 -4
  55. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  56. package/dist/types/session/session-manager.d.ts +23 -6
  57. package/dist/types/session/session-storage.d.ts +4 -0
  58. package/dist/types/session/streaming-output.d.ts +36 -7
  59. package/dist/types/setup/credential-import.d.ts +12 -0
  60. package/dist/types/skill-state/active-state.d.ts +12 -0
  61. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  62. package/dist/types/task/executor.d.ts +3 -2
  63. package/dist/types/task/index.d.ts +3 -1
  64. package/dist/types/task/provider-retry-status.d.ts +8 -0
  65. package/dist/types/task/receipt.d.ts +7 -1
  66. package/dist/types/task/types.d.ts +23 -0
  67. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  68. package/dist/types/tools/bash-interactive.d.ts +4 -1
  69. package/dist/types/tools/bash.d.ts +3 -1
  70. package/dist/types/tools/image-gen.d.ts +4 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/output-meta.d.ts +23 -2
  73. package/dist/types/tools/path-utils.d.ts +1 -0
  74. package/dist/types/tools/read.d.ts +0 -6
  75. package/dist/types/tools/review.d.ts +5 -7
  76. package/dist/types/tools/subagent.d.ts +2 -0
  77. package/dist/types/utils/gh.d.ts +13 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  80. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  81. package/dist/types/web/search/providers/xai.d.ts +2 -0
  82. package/package.json +10 -8
  83. package/scripts/acp-conformance-agent.ts +218 -0
  84. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  85. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  86. package/scripts/resident-memory-bench.ts +1341 -0
  87. package/scripts/run-acp-conformance.ts +285 -0
  88. package/src/async/job-manager.ts +78 -20
  89. package/src/cli/fast-help.ts +1 -1
  90. package/src/cli/plugin-cli.ts +176 -18
  91. package/src/commands/team.ts +19 -1
  92. package/src/config/file-lock.ts +11 -2
  93. package/src/config/settings-schema.ts +51 -0
  94. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  95. package/src/coordinator-mcp/server.ts +5 -12
  96. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  97. package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
  98. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  99. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
  100. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  101. package/src/defaults/gjc-defaults.ts +4 -4
  102. package/src/exa/mcp-client.ts +12 -3
  103. package/src/exec/bash-executor.ts +144 -27
  104. package/src/extensibility/extensions/runner.ts +15 -0
  105. package/src/extensibility/extensions/types.ts +18 -1
  106. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  107. package/src/extensibility/gjc-plugins/index.ts +19 -2
  108. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  109. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  110. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  111. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  112. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  113. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  114. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  115. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  116. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  117. package/src/extensibility/gjc-plugins/types.ts +141 -0
  118. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  119. package/src/gjc-runtime/launch-tmux.ts +25 -6
  120. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  121. package/src/gjc-runtime/ralplan-runtime.ts +411 -17
  122. package/src/gjc-runtime/team-launch.ts +8 -0
  123. package/src/gjc-runtime/team-runtime.ts +140 -32
  124. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  126. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  127. package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
  128. package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
  129. package/src/gjc-runtime/workflow-manifest.ts +4 -68
  130. package/src/internal-urls/docs-index.generated.ts +15 -15
  131. package/src/modes/DESIGN.md +51 -0
  132. package/src/modes/acp/acp-agent.ts +298 -186
  133. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  134. package/src/modes/components/notifications-settings-editor.ts +12 -1
  135. package/src/modes/components/oauth-selector.ts +68 -30
  136. package/src/modes/components/session-observer-overlay.ts +305 -86
  137. package/src/modes/components/settings-selector.ts +56 -5
  138. package/src/modes/components/status-line/gh.ts +16 -0
  139. package/src/modes/components/tool-status-header.ts +3 -14
  140. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  141. package/src/modes/controllers/command-controller.ts +4 -0
  142. package/src/modes/controllers/extension-ui-controller.ts +7 -0
  143. package/src/modes/controllers/selector-controller.ts +45 -0
  144. package/src/modes/runtime-init.ts +3 -0
  145. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  146. package/src/prompts/system/eager-todo.md +8 -4
  147. package/src/prompts/system/system-prompt.md +11 -4
  148. package/src/prompts/tools/bash.md +5 -5
  149. package/src/prompts/tools/search-tool-bm25.md +5 -0
  150. package/src/prompts/tools/task.md +1 -1
  151. package/src/reminders/star-reminder.ts +4 -42
  152. package/src/runtime-mcp/manager.ts +19 -4
  153. package/src/sdk/acp/adapter.ts +22 -6
  154. package/src/sdk/broker/transport.ts +35 -3
  155. package/src/sdk/bus/attachment-registry.ts +5 -1
  156. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  157. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  158. package/src/sdk/bus/config.ts +18 -2
  159. package/src/sdk/bus/index.ts +661 -126
  160. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  161. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  162. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  163. package/src/sdk/bus/reconciliation-store.ts +95 -16
  164. package/src/sdk/bus/rich-draft.ts +1 -1
  165. package/src/sdk/bus/rich-render.ts +2 -2
  166. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  167. package/src/sdk/bus/telegram-cli.ts +33 -8
  168. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  169. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  170. package/src/sdk/bus/telegram-daemon.ts +916 -23
  171. package/src/sdk/bus/telegram-reference.ts +40 -3
  172. package/src/sdk/bus/threaded-render.ts +48 -3
  173. package/src/sdk/bus/topic-registry.ts +20 -0
  174. package/src/sdk/host/query/revision-store.ts +36 -2
  175. package/src/sdk/host/reverse-leases.ts +44 -7
  176. package/src/sdk/prompt-status.ts +16 -3
  177. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  178. package/src/sdk/session.ts +88 -9
  179. package/src/session/agent-session.ts +822 -172
  180. package/src/session/artifacts.ts +22 -0
  181. package/src/session/auth-broker-config.ts +10 -3
  182. package/src/session/blob-store.ts +915 -24
  183. package/src/session/internal/managed-session-scope.ts +44 -6
  184. package/src/session/internal/managed-session-storage.ts +48 -6
  185. package/src/session/session-manager.ts +1614 -435
  186. package/src/session/session-storage.ts +9 -1
  187. package/src/session/streaming-output.ts +238 -68
  188. package/src/setup/credential-import.ts +75 -13
  189. package/src/skill-state/active-state.ts +32 -0
  190. package/src/skill-state/workflow-hud.ts +17 -0
  191. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  192. package/src/task/executor.ts +122 -39
  193. package/src/task/index.ts +23 -14
  194. package/src/task/provider-retry-status.ts +16 -0
  195. package/src/task/receipt.ts +23 -7
  196. package/src/task/render.ts +6 -2
  197. package/src/task/types.ts +108 -0
  198. package/src/tools/ask-answer-registry.ts +12 -6
  199. package/src/tools/ask.ts +86 -55
  200. package/src/tools/ast-edit.ts +1 -0
  201. package/src/tools/ast-grep.ts +1 -0
  202. package/src/tools/bash-interactive.ts +8 -3
  203. package/src/tools/bash.ts +503 -60
  204. package/src/tools/find.ts +1 -0
  205. package/src/tools/image-gen.ts +29 -5
  206. package/src/tools/index.ts +4 -0
  207. package/src/tools/output-meta.ts +108 -14
  208. package/src/tools/path-utils.ts +3 -2
  209. package/src/tools/read.ts +18 -0
  210. package/src/tools/review.ts +5 -7
  211. package/src/tools/search.ts +1 -0
  212. package/src/tools/subagent-render.ts +6 -2
  213. package/src/tools/subagent.ts +6 -0
  214. package/src/tools/telegram-send.ts +6 -3
  215. package/src/utils/gh.ts +39 -0
  216. package/src/utils/image-resize.ts +3 -0
  217. package/src/web/search/providers/anthropic.ts +4 -4
  218. package/src/web/search/providers/kimi.ts +15 -5
  219. package/src/web/search/providers/searxng.ts +47 -4
  220. package/src/web/search/providers/xai.ts +7 -2
  221. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  222. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  223. 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;
@@ -1795,6 +1891,10 @@ export class AgentSession {
1795
1891
  #turnIndex = 0;
1796
1892
  #workerIntegrationScheduler: WorkerIntegrationRequestScheduler;
1797
1893
  #workerIntegrationRequestedForTurn = false;
1894
+ /** Runtime-owned team worker heartbeat; `undefined` outside a `gjc team` worker pane. */
1895
+ #teamWorkerHeartbeat: GjcTeamWorkerHeartbeatReporter | undefined;
1896
+ #teamWorkerTurnsInFlight = 0;
1897
+ #unregisterTeamWorkerAsyncJobChange: (() => void) | undefined;
1798
1898
  // First-party internal before-agent-start contributors (not user hooks).
1799
1899
  #beforeAgentStartContributors: BeforeAgentStartContributor[] = [];
1800
1900
 
@@ -2149,6 +2249,8 @@ export class AgentSession {
2149
2249
  this.#promptInFlightCount++;
2150
2250
  if (this.#promptInFlightCount === 1) {
2151
2251
  this.#acquirePowerAssertion();
2252
+ // Runtime-owned worker liveness follows Agent turns and background jobs
2253
+ // in #refreshTeamWorkerHeartbeat(), including internally dispatched turns.
2152
2254
  }
2153
2255
  }
2154
2256
 
@@ -2205,6 +2307,15 @@ export class AgentSession {
2205
2307
  this.#flushPendingAgentEnd();
2206
2308
  }
2207
2309
 
2310
+ #retainDeferredAgentEndAfterContinuationBusy(
2311
+ hold: symbol | undefined,
2312
+ pending: AgentSessionEvent | undefined,
2313
+ ): void {
2314
+ if (!hold || !pending) return;
2315
+ this.#pendingAgentEndEmit = pending;
2316
+ this.#pendingAgentEndContinuationHolds.set(hold, pending);
2317
+ }
2318
+
2208
2319
  #releaseDeferredAgentEndContinuation(hold: symbol | undefined): void {
2209
2320
  if (!hold) return;
2210
2321
  const pending = this.#pendingAgentEndContinuationHolds.get(hold);
@@ -2255,11 +2366,23 @@ export class AgentSession {
2255
2366
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
2256
2367
  if (this.#promptInFlightCount === 0) {
2257
2368
  this.#releasePowerAssertion();
2369
+ this.#refreshTeamWorkerHeartbeat();
2258
2370
  this.#flushPendingBackgroundExchanges();
2259
2371
  this.#flushPendingAgentEnd();
2260
2372
  }
2261
2373
  }
2262
2374
 
2375
+ #refreshTeamWorkerHeartbeat(): void {
2376
+ const hasOwnedBackgroundJob =
2377
+ this.#agentId !== undefined &&
2378
+ (this.#ownedAsyncJobManager?.getRunningJobs({ ownerId: this.#agentId }).length ?? 0) > 0;
2379
+ if (this.#teamWorkerTurnsInFlight > 0 || hasOwnedBackgroundJob) {
2380
+ this.#teamWorkerHeartbeat?.start();
2381
+ } else {
2382
+ this.#teamWorkerHeartbeat?.stop();
2383
+ }
2384
+ }
2385
+
2263
2386
  #flushPendingAgentEnd(): void {
2264
2387
  if (
2265
2388
  this.#promptInFlightCount > 0 ||
@@ -2311,6 +2434,14 @@ export class AgentSession {
2311
2434
  }),
2312
2435
  config.workerIntegrationTimeoutMs,
2313
2436
  );
2437
+ // One publisher per worker process: subagent sessions share this process and its
2438
+ // team env, so letting each create a reporter would run N timers writing the same
2439
+ // record. The top-level session owns the pane's liveness.
2440
+ this.#teamWorkerHeartbeat =
2441
+ config.teamWorkerHeartbeatReporter ??
2442
+ ((config.taskDepth ?? 0) === 0
2443
+ ? GjcTeamWorkerHeartbeatReporter.forProcess(() => this.sessionManager.getCwd())
2444
+ : undefined);
2314
2445
  this.notificationSessionController = config.notificationSessionController;
2315
2446
  this.taskDepth = config.taskDepth ?? 0;
2316
2447
  // Register this session with the process-wide resource GC (idle/RSS browser-tab eviction
@@ -2472,6 +2603,9 @@ export class AgentSession {
2472
2603
  this.#ttsrManager = config.ttsrManager;
2473
2604
  this.#obfuscator = config.obfuscator;
2474
2605
  this.#agentId = config.agentId;
2606
+ this.#unregisterTeamWorkerAsyncJobChange = this.#ownedAsyncJobManager?.onChange(() => {
2607
+ this.#refreshTeamWorkerHeartbeat();
2608
+ });
2475
2609
  this.#agentRegistry = config.agentRegistry;
2476
2610
  this.#providerSessionId = config.providerSessionId;
2477
2611
  this.#providerCacheSessionId = config.providerCacheSessionId;
@@ -2566,7 +2700,24 @@ export class AgentSession {
2566
2700
 
2567
2701
  /** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */
2568
2702
  nextToolChoice(): ToolChoice | undefined {
2569
- return this.#toolChoiceQueue.nextToolChoice();
2703
+ const choice = this.#toolChoiceQueue.nextToolChoice();
2704
+ if (!choice || typeof choice === "string") return choice;
2705
+
2706
+ const toolName = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name;
2707
+ const activeTool = this.agent.state.tools.find(
2708
+ tool => tool.name === toolName || tool.customWireName === toolName,
2709
+ );
2710
+ if (!activeTool) {
2711
+ this.#toolChoiceQueue.degradeInFlight(`Tool "${toolName}" is no longer active.`);
2712
+ return undefined;
2713
+ }
2714
+
2715
+ const refreshed = buildNamedToolChoiceResult(activeTool.name, this.model);
2716
+ if (!refreshed.exactNamed || !refreshed.choice) {
2717
+ this.#toolChoiceQueue.degradeInFlight(refreshed.resolved?.reason ?? "Named tool choice is unavailable.");
2718
+ return undefined;
2719
+ }
2720
+ return refreshed.choice;
2570
2721
  }
2571
2722
 
2572
2723
  /**
@@ -3196,19 +3347,26 @@ export class AgentSession {
3196
3347
  return queued;
3197
3348
  }
3198
3349
 
3199
- #trackAgentEvent = async (event: AgentEvent): Promise<void> => {
3350
+ #trackAgentEvent = (event: AgentEvent): Promise<void> => {
3351
+ const activePromptHandle = this.activePromptHandle;
3200
3352
  const agentEndHandled = event.type === "agent_end" ? Promise.withResolvers<void>() : undefined;
3201
3353
  if (agentEndHandled) this.#agentEndHandlingPromise = agentEndHandled.promise;
3202
3354
  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();
3355
+ const handler = (async (): Promise<void> => {
3356
+ try {
3357
+ await this.#handleAgentEvent(event, activePromptHandle);
3358
+ } catch (error) {
3359
+ logger.warn("Agent event handler failed", { event: event.type, error: String(error) });
3360
+ } finally {
3361
+ this.#agentEventHandlersInFlight = Math.max(0, this.#agentEventHandlersInFlight - 1);
3362
+ this.#flushPendingAgentEnd();
3363
+ agentEndHandled?.resolve();
3364
+ }
3365
+ })();
3366
+ if (activePromptHandle) {
3367
+ this.agent.resourceLedger.track(activePromptHandle, "post_prompt", "agent-session-event", handler);
3211
3368
  }
3369
+ return handler;
3212
3370
  };
3213
3371
 
3214
3372
  #persistRuntimeStateInBackground(event: AgentSessionEvent): void {
@@ -3228,6 +3386,18 @@ export class AgentSession {
3228
3386
  } else if (event.type === "turn_end") {
3229
3387
  this.#closedExtensionTurnGeneration = this.#extensionTurnGeneration;
3230
3388
  }
3389
+ if (event.type === "turn_start") {
3390
+ this.#teamWorkerTurnsInFlight++;
3391
+ this.#refreshTeamWorkerHeartbeat();
3392
+ } else if (event.type === "turn_end") {
3393
+ this.#teamWorkerTurnsInFlight = Math.max(0, this.#teamWorkerTurnsInFlight - 1);
3394
+ this.#refreshTeamWorkerHeartbeat();
3395
+ } else if (event.type === "agent_end") {
3396
+ // Failed Agent runs can emit agent_end without turn_end. Reset rather than
3397
+ // leaking a positive count that would report an idle/failed worker forever.
3398
+ this.#teamWorkerTurnsInFlight = 0;
3399
+ this.#refreshTeamWorkerHeartbeat();
3400
+ }
3231
3401
  if (event.type === "message_update") {
3232
3402
  // Fast path: message_update maps to no sidecar state, so we must not
3233
3403
  // build the persistRuntimeState closure here (per-token hot path).
@@ -3347,7 +3517,7 @@ export class AgentSession {
3347
3517
  }
3348
3518
 
3349
3519
  /** Internal handler for agent events - shared by subscribe and reconnect */
3350
- #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
3520
+ #handleAgentEvent = async (event: AgentEvent, activePromptHandle?: string): Promise<void> => {
3351
3521
  if (this.#extensionRunner?.hasHandlers(event.type)) this.#markRetryReplayUnsafe();
3352
3522
  if (
3353
3523
  event.type === "tool_execution_start" ||
@@ -3696,9 +3866,10 @@ export class AgentSession {
3696
3866
  this.#ttsrAbortPending = false;
3697
3867
  this.#resolveTtsrResume();
3698
3868
  },
3869
+ resourceRunId: activePromptHandle,
3699
3870
  });
3700
3871
  },
3701
- { delayMs: 50 },
3872
+ { delayMs: 50, resourceRunId: activePromptHandle },
3702
3873
  );
3703
3874
  return;
3704
3875
  }
@@ -3857,7 +4028,11 @@ export class AgentSession {
3857
4028
  maintenanceGeneration !== undefined &&
3858
4029
  this.#promptGeneration === maintenanceGeneration
3859
4030
  ) {
3860
- this.#scheduleAgentContinue({ generation: maintenanceGeneration, skipCompactionCheck: true });
4031
+ this.#scheduleAgentContinue({
4032
+ generation: maintenanceGeneration,
4033
+ skipCompactionCheck: true,
4034
+ resourceRunId: activePromptHandle,
4035
+ });
3861
4036
  }
3862
4037
  return;
3863
4038
  }
@@ -3937,8 +4112,11 @@ export class AgentSession {
3937
4112
  }
3938
4113
  this.#resolveRetry();
3939
4114
 
3940
- const compactionTask = this.#checkCompaction(msg);
3941
- this.#trackPostPromptTask(compactionTask.then(() => undefined));
4115
+ const compactionTask = this.#checkCompaction(msg, true, undefined, activePromptHandle);
4116
+ this.#trackPostPromptTask(
4117
+ compactionTask.then(() => undefined),
4118
+ activePromptHandle,
4119
+ );
3942
4120
  await compactionTask;
3943
4121
  // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
3944
4122
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
@@ -4002,9 +4180,12 @@ export class AgentSession {
4002
4180
  this.#postPromptTasksPromise = undefined;
4003
4181
  }
4004
4182
 
4005
- #trackPostPromptTask(task: Promise<void>): void {
4183
+ #trackPostPromptTask(task: Promise<void>, resourceRunId = this.activePromptHandle): void {
4006
4184
  this.#postPromptTasks.add(task);
4007
4185
  this.#ensurePostPromptTasksPromise();
4186
+ if (resourceRunId) {
4187
+ this.agent.resourceLedger.track(resourceRunId, "post_prompt", "agent-session", task);
4188
+ }
4008
4189
  void task
4009
4190
  .catch(() => {})
4010
4191
  .finally(() => {
@@ -4017,7 +4198,7 @@ export class AgentSession {
4017
4198
 
4018
4199
  #schedulePostPromptTask(
4019
4200
  task: (signal: AbortSignal) => Promise<void>,
4020
- options?: { delayMs?: number; generation?: number; onSkip?: () => void },
4201
+ options?: { delayMs?: number; generation?: number; onSkip?: () => void; resourceRunId?: string },
4021
4202
  ): Promise<void> {
4022
4203
  const delayMs = options?.delayMs ?? 0;
4023
4204
  const signal = this.#postPromptTasksAbortController.signal;
@@ -4040,10 +4221,14 @@ export class AgentSession {
4040
4221
  }
4041
4222
  await task(signal);
4042
4223
  })();
4043
- this.#trackPostPromptTask(scheduled);
4224
+ this.#trackPostPromptTask(scheduled, options?.resourceRunId);
4044
4225
  return scheduled;
4045
4226
  }
4046
4227
 
4228
+ #isAgentBusyError(error: unknown): error is AgentBusyError {
4229
+ return error instanceof AgentBusyError;
4230
+ }
4231
+
4047
4232
  #scheduleAgentContinue(options?: {
4048
4233
  delayMs?: number;
4049
4234
  generation?: number;
@@ -4052,7 +4237,9 @@ export class AgentSession {
4052
4237
  shouldContinue?: () => boolean;
4053
4238
  onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => void;
4054
4239
  allowDuringCancelAndSubmit?: boolean;
4240
+ rescheduleOnBusy?: boolean;
4055
4241
  onError?: (error: unknown) => void;
4242
+ resourceRunId?: string;
4056
4243
  }): Promise<void> {
4057
4244
  const predecessorAgentEndHold = options?.suppressPredecessorAgentEnd
4058
4245
  ? this.#reserveDeferredAgentEndForContinuation()
@@ -4075,81 +4262,92 @@ export class AgentSession {
4075
4262
  };
4076
4263
  const scheduledGeneration = options?.generation;
4077
4264
  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();
4265
+ const scheduleAttempt = (delayMs = options?.delayMs): Promise<void> =>
4266
+ this.#schedulePostPromptTask(
4267
+ async () => {
4268
+ const canContinue = (): boolean => {
4269
+ if (signal.aborted || this.#isDisposed) {
4270
+ skip("aborted_signal");
4271
+ return false;
4272
+ }
4273
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4274
+ skip("generation_changed");
4275
+ return false;
4276
+ }
4277
+ if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) {
4278
+ skip("queue_drained");
4279
+ return false;
4280
+ }
4281
+ if (options?.shouldContinue && !options.shouldContinue()) {
4282
+ skip("queue_drained");
4283
+ return false;
4284
+ }
4285
+ return true;
4286
+ };
4287
+ if (!canContinue()) return;
4127
4288
  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
- });
4289
+ if (!options?.skipCompactionCheck) {
4290
+ await this.#checkEstimatedContextBeforePrompt();
4291
+ if (!canContinue()) return;
4292
+ }
4293
+ if (signal.aborted) {
4294
+ skip("aborted_signal");
4295
+ return;
4296
+ }
4297
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4298
+ skip("generation_changed");
4299
+ return;
4300
+ }
4301
+ if (options?.shouldContinue && !options.shouldContinue()) {
4302
+ skip("queue_drained");
4303
+ return;
4304
+ }
4305
+ // A continuation scheduled before a handoff engaged must not start a
4306
+ // turn against the session being handed off (or the restored
4307
+ // predecessor). rearmIdle / normal delivery resumes after the fence.
4308
+ if (this.#handoffTransitionActive) {
4309
+ skip("handoff_in_progress");
4310
+ return;
4311
+ }
4312
+ const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
4313
+ const startsQueuedSuccessor =
4314
+ this.agent.state.messages.at(-1)?.role === "assistant" && this.agent.hasQueuedMessages();
4315
+ try {
4316
+ await this.agent.continue({
4317
+ ...this.#managedFallbackPromptOptions(),
4318
+ // Reset only after continue() has claimed the queued turn. Skipped or stale
4319
+ // continuations retain predecessor accounting, and resetAttemptBudget keeps
4320
+ // the sticky fallback cursor unchanged.
4321
+ onRunAccepted: startsQueuedSuccessor
4322
+ ? () => {
4323
+ this.#defaultFallbackChain().resetAttemptBudget();
4324
+ this.#overflowMaintenanceAttempts = 0;
4325
+ }
4326
+ : undefined,
4327
+ });
4328
+ } catch (error) {
4329
+ if (options?.rescheduleOnBusy && this.#isAgentBusyError(error) && !terminalized && canContinue()) {
4330
+ this.#retainDeferredAgentEndAfterContinuationBusy(predecessorAgentEndHold, predecessorAgentEnd);
4331
+ logger.debug("agent.continue busy after scheduling; rescheduling", {
4332
+ error: error.message,
4333
+ });
4334
+ void scheduleAttempt(100);
4335
+ return;
4336
+ }
4337
+ this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
4338
+ throw error;
4339
+ }
4140
4340
  } catch (error) {
4141
- this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
4142
- throw error;
4341
+ fail(error);
4143
4342
  }
4144
- } catch (error) {
4145
- fail(error);
4146
- }
4147
- },
4148
- {
4149
- delayMs: options?.delayMs,
4150
- onSkip: () => skip("aborted_signal"),
4151
- },
4152
- );
4343
+ },
4344
+ {
4345
+ delayMs,
4346
+ onSkip: () => skip("aborted_signal"),
4347
+ resourceRunId: options?.resourceRunId,
4348
+ },
4349
+ );
4350
+ return scheduleAttempt();
4153
4351
  }
4154
4352
 
4155
4353
  #logCompactionContinuationSkipped(
@@ -4165,7 +4363,7 @@ export class AgentSession {
4165
4363
  ): void {
4166
4364
  logger.warn("Auto-compaction continuation failed", {
4167
4365
  source,
4168
- reason: error instanceof Error && error.name === "AgentBusyError" ? "queue_drained" : "not_resumable_tail",
4366
+ reason: "not_resumable_tail",
4169
4367
  error: error instanceof Error ? error.message : String(error),
4170
4368
  });
4171
4369
  }
@@ -4193,14 +4391,24 @@ export class AgentSession {
4193
4391
  return compactionSettings.autoContinue === false ? "auto_continue_disabled_non_resumable_tail" : undefined;
4194
4392
  }
4195
4393
 
4196
- #scheduleOverflowRetryContinuation(generation: number): boolean {
4394
+ async #scheduleOverflowRetryContinuation(generation: number, resourceRunId?: string): Promise<boolean> {
4197
4395
  this.#stripOverflowFailedTurnForRetry();
4396
+ const snapshot = await this.#compactionStateSnapshot();
4397
+ if (
4398
+ snapshot.goal?.status === "paused" &&
4399
+ !snapshot.queuedMessages &&
4400
+ snapshot.lastAssistantStopReason !== "length"
4401
+ ) {
4402
+ this.#logCompactionContinuationSkipped("overflow_retry", "paused_goal");
4403
+ return false;
4404
+ }
4198
4405
  if (this.#isResumableAgentTail()) {
4199
4406
  this.#scheduleAgentContinue({
4200
4407
  delayMs: 100,
4201
4408
  generation,
4202
4409
  suppressPredecessorAgentEnd: true,
4203
-
4410
+ resourceRunId,
4411
+ rescheduleOnBusy: true,
4204
4412
  onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
4205
4413
  onError: error => this.#logCompactionContinuationError("overflow_retry", error),
4206
4414
  });
@@ -4209,7 +4417,7 @@ export class AgentSession {
4209
4417
 
4210
4418
  const compactionSettings = this.settings.getGroup("compaction");
4211
4419
  if (compactionSettings.autoContinue !== false) {
4212
- this.#scheduleAutoContinuePrompt(generation);
4420
+ this.#scheduleAutoContinuePrompt(generation, false);
4213
4421
  return true;
4214
4422
  }
4215
4423
 
@@ -4217,8 +4425,32 @@ export class AgentSession {
4217
4425
  return false;
4218
4426
  }
4219
4427
 
4220
- #scheduleAutoContinuePrompt(generation: number): void {
4428
+ #scheduleAutoContinuePrompt(generation: number, requireUnfinishedWork = true): void {
4221
4429
  const predecessorAgentEndHold = this.#reserveDeferredAgentEndForContinuation();
4430
+ const scheduledGeneration = generation;
4431
+ const signal = this.#postPromptTasksAbortController.signal;
4432
+ const continuationAuthorized = async (hasPendingNextTurnMessages = false): Promise<boolean> => {
4433
+ const snapshot = await this.#compactionStateSnapshot();
4434
+ if (signal.aborted) {
4435
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
4436
+ return false;
4437
+ }
4438
+ if (this.#isDisposed || this.#promptGeneration !== scheduledGeneration) {
4439
+ this.#logCompactionContinuationSkipped(
4440
+ "auto_continue_prompt",
4441
+ this.#isDisposed ? "session_disposed" : "generation_changed",
4442
+ );
4443
+ return false;
4444
+ }
4445
+ const authorized = requireUnfinishedWork
4446
+ ? this.#hasUnfinishedWork(snapshot) || hasPendingNextTurnMessages
4447
+ : snapshot.queuedMessages ||
4448
+ hasPendingNextTurnMessages ||
4449
+ snapshot.lastAssistantStopReason === "length" ||
4450
+ snapshot.goal?.status !== "paused";
4451
+ if (!authorized) this.emitNotice("info", "Auto-continue skipped: no unfinished work detected");
4452
+ return authorized;
4453
+ };
4222
4454
  const continuePrompt = async () => {
4223
4455
  await this.#promptWithMessage(
4224
4456
  {
@@ -4232,14 +4464,15 @@ export class AgentSession {
4232
4464
  skipPostPromptRecoveryWait: true,
4233
4465
  skipCompactionCheck: true,
4234
4466
  predecessorAgentEndHold,
4467
+ onFinalPreflight: ({ hasPendingNextTurnMessages }) => continuationAuthorized(hasPendingNextTurnMessages),
4235
4468
  },
4236
4469
  );
4237
4470
  };
4238
- const scheduledGeneration = generation;
4239
- const signal = this.#postPromptTasksAbortController.signal;
4240
- this.#trackPostPromptTask(
4241
- (async () => {
4471
+ const scheduleAttempt = (delayMs = 0) => {
4472
+ const attempt = (async () => {
4473
+ let rescheduled = false;
4242
4474
  try {
4475
+ if (delayMs > 0) await scheduler.wait(delayMs, { signal });
4243
4476
  await Promise.resolve();
4244
4477
  if (signal.aborted) {
4245
4478
  this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
@@ -4249,14 +4482,31 @@ export class AgentSession {
4249
4482
  this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
4250
4483
  return;
4251
4484
  }
4485
+ if (!(await continuationAuthorized())) return;
4252
4486
  await continuePrompt();
4253
4487
  } catch (error) {
4488
+ if (signal.aborted) {
4489
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
4490
+ return;
4491
+ }
4492
+ if (this.#isAgentBusyError(error) && this.#promptGeneration === scheduledGeneration) {
4493
+ logger.debug("Auto-compaction continuation busy; rescheduling", {
4494
+ source: "auto_continue_prompt",
4495
+ reason: "queue_drained",
4496
+ error: error.message,
4497
+ });
4498
+ rescheduled = true;
4499
+ scheduleAttempt(100);
4500
+ return;
4501
+ }
4254
4502
  this.#logCompactionContinuationError("auto_continue_prompt", error);
4255
4503
  } finally {
4256
- this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
4504
+ if (!rescheduled) this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
4257
4505
  }
4258
- })(),
4259
- );
4506
+ })();
4507
+ this.#trackPostPromptTask(attempt);
4508
+ };
4509
+ scheduleAttempt();
4260
4510
  }
4261
4511
 
4262
4512
  async #cancelPostPromptTasks(): Promise<void> {
@@ -4749,6 +4999,19 @@ export class AgentSession {
4749
4999
  };
4750
5000
  }
4751
5001
 
5002
+ /** Defer local:// provisioning when a read-only session load cannot create its artifact root. */
5003
+ async #initializeLocalRootForLoadedSession(): Promise<void> {
5004
+ try {
5005
+ await initializeLocalRoot(this.#localProtocolOptions());
5006
+ } catch (error) {
5007
+ if (!isEacces(error) && !hasFsCode(error, "EPERM") && !hasFsCode(error, "EROFS")) throw error;
5008
+ logger.debug("Deferred local root initialization for read-only session load", {
5009
+ sessionFile: this.sessionManager.getSessionFile(),
5010
+ error: String(error),
5011
+ });
5012
+ }
5013
+ }
5014
+
4752
5015
  #maybeAbortStreamingEdit(event: AgentEvent, generation: number): void {
4753
5016
  if (!this.settings.get("edit.streamingAbort")) return;
4754
5017
  if (this.#streamingEditAbortTriggered) return;
@@ -5334,6 +5597,9 @@ export class AgentSession {
5334
5597
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
5335
5598
  await disposeVmContextsByOwner(this.#evalKernelOwnerId);
5336
5599
  this.#releasePowerAssertion();
5600
+ this.#unregisterTeamWorkerAsyncJobChange?.();
5601
+ this.#unregisterTeamWorkerAsyncJobChange = undefined;
5602
+ this.#teamWorkerHeartbeat?.dispose();
5337
5603
  // Disconnect the agent event listener BEFORE closing session resources so a late
5338
5604
  // provider/tool message_end cannot append to the closing SessionManager.
5339
5605
  this.#disconnectFromAgent();
@@ -5378,6 +5644,9 @@ export class AgentSession {
5378
5644
  const kernelOwnerId = this.#evalKernelOwnerId;
5379
5645
  this.#unregisterResourceGc?.();
5380
5646
  this.#unregisterResourceGc = undefined;
5647
+ this.#unregisterTeamWorkerAsyncJobChange?.();
5648
+ this.#unregisterTeamWorkerAsyncJobChange = undefined;
5649
+ this.#teamWorkerHeartbeat?.dispose();
5381
5650
  const work = Promise.allSettled([
5382
5651
  // kill:true so a forced exit also reaps spawned-app Chrome we own (headless
5383
5652
  // always closes; connected/attached browsers only disconnect — never killed).
@@ -6517,6 +6786,11 @@ export class AgentSession {
6517
6786
  return this.#postPromptTasks.size > 0;
6518
6787
  }
6519
6788
 
6789
+ /** Stable resource ownership identifier for the active prompt run. */
6790
+ get activePromptHandle(): string | undefined {
6791
+ return this.agent.activeResourceRunId;
6792
+ }
6793
+
6520
6794
  /** All messages including custom types like BashExecutionMessage */
6521
6795
  get messages(): AgentMessage[] {
6522
6796
  return this.agent.state.messages;
@@ -7768,6 +8042,7 @@ export class AgentSession {
7768
8042
  predecessorAgentEndHold?: symbol;
7769
8043
  admissionLease?: SessionAdmissionLease;
7770
8044
  onRunAccepted?: () => void;
8045
+ onFinalPreflight?: (context: { hasPendingNextTurnMessages: boolean }) => Promise<boolean>;
7771
8046
  resetRetryReplaySafety?: boolean;
7772
8047
  },
7773
8048
  ): Promise<void> {
@@ -7783,6 +8058,8 @@ export class AgentSession {
7783
8058
  const generation = this.#promptGeneration;
7784
8059
  const preflightSignal = this.#promptPreflightAbortController.signal;
7785
8060
  const rosterClaim = this.#claimIrcRosterCandidate();
8061
+ let hasPendingNextTurnMessages = false;
8062
+ let pendingNextTurnMessageCount = 0;
7786
8063
  let hindsightRecall: string | undefined;
7787
8064
  try {
7788
8065
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
@@ -7874,11 +8151,11 @@ export class AgentSession {
7874
8151
  }
7875
8152
 
7876
8153
  // Inject any pending "nextTurn" messages as context alongside the user message
7877
- for (const msg of this.#pendingNextTurnMessages) {
8154
+ pendingNextTurnMessageCount = this.#pendingNextTurnMessages.length;
8155
+ hasPendingNextTurnMessages = pendingNextTurnMessageCount > 0;
8156
+ for (const msg of this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount)) {
7878
8157
  messages.push(msg);
7879
8158
  }
7880
- this.#pendingNextTurnMessages = [];
7881
- if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
7882
8159
 
7883
8160
  // Auto-read @filepath mentions
7884
8161
  const fileMentions = extractFileMentions(expandedText);
@@ -7998,7 +8275,15 @@ export class AgentSession {
7998
8275
  };
7999
8276
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
8000
8277
  else options?.onPreflightAccepted?.();
8278
+ if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
8279
+ this.#resetInjectedContextSignatures();
8280
+ return;
8281
+ }
8001
8282
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
8283
+ if (pendingNextTurnMessageCount > 0) {
8284
+ this.#pendingNextTurnMessages.splice(0, pendingNextTurnMessageCount);
8285
+ }
8286
+ if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
8002
8287
  await this.#promptAgentWithIdleRetry(messages, agentPromptOptions, predecessorAgentEndHold);
8003
8288
  const terminalAssistant = this.#findLastAssistantMessage();
8004
8289
  if (
@@ -8039,6 +8324,7 @@ export class AgentSession {
8039
8324
  await this.#agentEndPublicationPromise;
8040
8325
  } else {
8041
8326
  await this.#agentEndHandlingPromise;
8327
+ await this.#waitForPostPromptRecovery();
8042
8328
  await this.#agentEndPublicationPromise;
8043
8329
  }
8044
8330
  }
@@ -8087,6 +8373,7 @@ export class AgentSession {
8087
8373
  sessionManager: createReadonlySessionManager(this.sessionManager),
8088
8374
  modelRegistry: this.#modelRegistry,
8089
8375
  model: this.model ?? undefined,
8376
+ getActivePromptHandle: () => this.activePromptHandle,
8090
8377
  isIdle: () => !this.isStreaming,
8091
8378
  abort: () => {
8092
8379
  void this.abort();
@@ -8862,6 +9149,103 @@ export class AgentSession {
8862
9149
  this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
8863
9150
  }
8864
9151
 
9152
+ async #compactionStateSnapshot(): Promise<CompactionStateSnapshot> {
9153
+ const snapshot: CompactionStateSnapshot = {
9154
+ goal: undefined,
9155
+ openTodos: [],
9156
+ activeSkills: [],
9157
+ queuedMessages: false,
9158
+ lastAssistantStopReason: undefined,
9159
+ };
9160
+ try {
9161
+ const goalState = this.getGoalModeState();
9162
+ const goal = goalState?.goal;
9163
+ if (goal) snapshot.goal = { objective: goal.objective, status: goal.status, enabled: goalState.enabled };
9164
+ } catch (error) {
9165
+ logger.warn("Failed to read goal state for compaction snapshot", {
9166
+ error: error instanceof Error ? error.message : String(error),
9167
+ });
9168
+ }
9169
+ try {
9170
+ const todos = this.getTodoPhases()
9171
+ .flatMap(phase => phase.tasks)
9172
+ .filter(task => task.status === "pending" || task.status === "in_progress")
9173
+ .slice(0, 10)
9174
+ .map(task => task.content);
9175
+ snapshot.openTodos = todos;
9176
+ } catch (error) {
9177
+ logger.warn("Failed to read todo state for compaction snapshot", {
9178
+ error: error instanceof Error ? error.message : String(error),
9179
+ });
9180
+ }
9181
+ try {
9182
+ const state = await readVisibleSkillActiveState(this.sessionManager.getCwd(), this.sessionId, {
9183
+ bypassCache: true,
9184
+ });
9185
+ snapshot.activeSkills = (state?.active_skills ?? [])
9186
+ .filter(entry => entry.active !== false)
9187
+ .slice(0, 5)
9188
+ .map(entry => ({ skill: entry.skill, phase: entry.phase ?? "unknown" }));
9189
+ } catch (error) {
9190
+ logger.warn("Failed to read workflow state for compaction snapshot", {
9191
+ error: error instanceof Error ? error.message : String(error),
9192
+ });
9193
+ }
9194
+ try {
9195
+ snapshot.queuedMessages = this.agent.hasQueuedMessages() || this.#pendingNextTurnMessages.length > 0;
9196
+ } catch (error) {
9197
+ logger.warn("Failed to read queued-message state for compaction snapshot", {
9198
+ error: error instanceof Error ? error.message : String(error),
9199
+ });
9200
+ }
9201
+ try {
9202
+ for (let index = this.messages.length - 1; index >= 0; index--) {
9203
+ const message = this.messages[index];
9204
+ if (message.role !== "assistant") continue;
9205
+ snapshot.lastAssistantStopReason = (message as AssistantMessage).stopReason;
9206
+ break;
9207
+ }
9208
+ } catch (error) {
9209
+ logger.warn("Failed to read assistant stop state for compaction snapshot", {
9210
+ error: error instanceof Error ? error.message : String(error),
9211
+ });
9212
+ }
9213
+ return snapshot;
9214
+ }
9215
+
9216
+ #compactionStateContext(snapshot: CompactionStateSnapshot): string[] {
9217
+ const context: string[] = [];
9218
+ const goal = snapshot.goal;
9219
+ if (goal?.enabled) {
9220
+ context.push(
9221
+ `Active goal: ${sanitizeCompactionStateText(goal.objective, 160)} (status: ${sanitizeCompactionStateText(goal.status, 40)})`,
9222
+ );
9223
+ } else if (goal?.status === "paused") {
9224
+ context.push(`Paused goal: ${sanitizeCompactionStateText(goal.objective, 160)} (status: paused)`);
9225
+ }
9226
+ for (const entry of snapshot.activeSkills) {
9227
+ const skill = sanitizeCompactionStateText(entry.skill, 100);
9228
+ const phase = sanitizeCompactionStateText(entry.phase, 80);
9229
+ if (skill) context.push(`Active skill: ${skill} phase=${phase}`);
9230
+ }
9231
+ if (snapshot.openTodos.length > 0) {
9232
+ const todos = snapshot.openTodos.map(todo => sanitizeCompactionStateText(todo, 120));
9233
+ context.push(`Open todos: ${todos.join("; ")}`);
9234
+ }
9235
+ return context;
9236
+ }
9237
+
9238
+ #hasUnfinishedWork(snapshot: CompactionStateSnapshot): boolean {
9239
+ if (snapshot.queuedMessages) return true;
9240
+ if (snapshot.lastAssistantStopReason === "length") return true;
9241
+ // A verified paused goal is a human-wait terminus. It suppresses stale
9242
+ // todo/workflow activity until a queued user message resumes the run.
9243
+ if (snapshot.goal?.status === "paused") return false;
9244
+ if (snapshot.goal?.enabled && snapshot.goal.status === "active") return true;
9245
+ if (snapshot.openTodos.length > 0) return true;
9246
+ return snapshot.activeSkills.some(entry => !isWorkflowContinuationInert(entry.skill, entry.phase));
9247
+ }
9248
+
8865
9249
  async #applyCompactionPostAppend(
8866
9250
  compactionEntryId: string,
8867
9251
  firstKeptEntryId: string,
@@ -9060,6 +9444,26 @@ export class AgentSession {
9060
9444
  const outcome = await this.#abortWithOutcome(options);
9061
9445
  if (outcome.kind === "error") throw outcome.cause;
9062
9446
  }
9447
+ /**
9448
+ * Abort a specific active prompt and prove whether its tracked resources settled.
9449
+ */
9450
+ async abortPromptAndWait(handle: string, options: { graceMs: number }): Promise<RunSettlementProof> {
9451
+ // Only the abort trigger is gated on active ownership; settlement is always proven
9452
+ // from the ledger, because a run can go inactive while its resources still run.
9453
+ if (handle === this.agent.activeResourceRunId)
9454
+ // Awaiting the abort unboundedly would let a provider or tool that ignores
9455
+ // cancellation defer the grace forever, so the proof races the fixed grace
9456
+ // independently of abort completion.
9457
+ void this.abort({ timeoutMs: options.graceMs }).catch(() => {});
9458
+
9459
+ const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs });
9460
+ if (proof.status === "unfenced") {
9461
+ // Detach the unsettled resources so they can never re-enter this run; their real
9462
+ // promises still release them inside the ledger.
9463
+ this.agent.resourceLedger.quarantine(handle);
9464
+ }
9465
+ return proof;
9466
+ }
9063
9467
 
9064
9468
  /** Atomically interrupt the active run and make text the next prompt. */
9065
9469
  async cancelAndSubmit(text: string, options?: { queuedEntryId?: string }): Promise<CancelAndSubmitOutcome> {
@@ -9713,6 +10117,16 @@ export class AgentSession {
9713
10117
  getSessionDefaultModelSelector(): string | undefined {
9714
10118
  return getSessionContextForInternalRead(this.sessionManager).models.default;
9715
10119
  }
10120
+ /**
10121
+ * Resolve the model that `modelRoles.default` currently points to, independent
10122
+ * of whatever model this session's log last recorded. Used by the TUI resume
10123
+ * flow's `session.resumeModelBehavior: "ask"` prompt to offer the currently
10124
+ * configured default as an alternative to the session's saved model.
10125
+ */
10126
+ resolveConfiguredDefaultModel(): Model | undefined {
10127
+ const availableModels = this.#modelRegistry.getAvailable();
10128
+ return this.#resolveRoleModelFull("default", availableModels, undefined).model;
10129
+ }
9716
10130
 
9717
10131
  /**
9718
10132
  * Re-assert the session resume default ("provider/id") in the session log
@@ -10604,6 +11018,16 @@ export class AgentSession {
10604
11018
  return getSupportedEfforts(this.model);
10605
11019
  }
10606
11020
 
11021
+ /**
11022
+ * Runtime evidence published for the current GJC bundle activation
11023
+ * generation, set once by `createAgentSession` after every producer has run.
11024
+ * Undefined until that publication happens, so consumers report runtime
11025
+ * status as unavailable rather than falsely clear.
11026
+ */
11027
+ gjcRuntimeSnapshot?: GjcRuntimeSnapshotProvider;
11028
+ /** Activation generation a published snapshot must match to be merged. */
11029
+ gjcActivationGeneration?: number;
11030
+
10607
11031
  // =========================================================================
10608
11032
  // Message Queue Mode Management
10609
11033
  // =========================================================================
@@ -10645,13 +11069,93 @@ export class AgentSession {
10645
11069
  async #pruneToolOutputs(
10646
11070
  signal?: AbortSignal,
10647
11071
  overThreshold = false,
10648
- ): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
11072
+ options?: { commitGate?: (actual: { prunedCount: number; tokensSaved: number }) => boolean },
11073
+ ): Promise<{ prunedCount: number; tokensSaved: number; committed: boolean } | undefined> {
10649
11074
  const branchEntries = this.sessionManager.getBranch();
10650
- const result = pruneToolOutputs(
10651
- branchEntries,
10652
- DEFAULT_PRUNE_CONFIG,
10653
- overThreshold ? { relaxedMinimum: 0 } : undefined,
10654
- );
11075
+ const artifactManager = this.sessionManager.getArtifactManager();
11076
+ const prunedArtifacts: Array<{ entryId: string; id: string; toolType: string; originalText: string }> = [];
11077
+ let reservedArtifactId: string | undefined;
11078
+ if (artifactManager) {
11079
+ try {
11080
+ reservedArtifactId = (await artifactManager.allocatePath("tool-output")).id;
11081
+ } catch (error) {
11082
+ logger.warn("Failed to reserve artifact ID for pruned tool output", {
11083
+ error: error instanceof Error ? error.message : String(error),
11084
+ });
11085
+ }
11086
+ }
11087
+ const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG, {
11088
+ relaxedMinimum: overThreshold ? 0 : undefined,
11089
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11090
+ artifactRef: candidate => {
11091
+ if (!artifactManager) return undefined;
11092
+ const id = reservedArtifactId ?? String(artifactManager.allocateId());
11093
+ reservedArtifactId = undefined;
11094
+ const toolType = (candidate.toolName ?? "tool-output").replace(/[^a-zA-Z0-9_-]/g, "_") || "tool-output";
11095
+ prunedArtifacts.push({ entryId: candidate.entryId, id, toolType, originalText: candidate.originalText });
11096
+ return `artifact://${id}`;
11097
+ },
11098
+ });
11099
+ const failedArtifactEntryIds = new Set<string>();
11100
+ const publishedArtifacts: typeof prunedArtifacts = [];
11101
+ for (const artifact of prunedArtifacts.filter(artifact =>
11102
+ result.originals.some(original => original.entryId === artifact.entryId),
11103
+ )) {
11104
+ try {
11105
+ await artifactManager?.publishNamedNoReplace(
11106
+ `${artifact.id}.${artifact.toolType}.log`,
11107
+ new TextEncoder().encode(artifact.originalText),
11108
+ );
11109
+ publishedArtifacts.push(artifact);
11110
+ } catch (error) {
11111
+ failedArtifactEntryIds.add(artifact.entryId);
11112
+ logger.warn("Failed to persist pruned tool output artifact", {
11113
+ artifactId: artifact.id,
11114
+ error: error instanceof Error ? error.message : String(error),
11115
+ });
11116
+ }
11117
+ }
11118
+ const rollbackPublishedArtifacts = async (reason: "aborted" | "commit_gate_rejected") => {
11119
+ let removedArtifacts = 0;
11120
+ let unremovedArtifacts = 0;
11121
+ for (const artifact of publishedArtifacts) {
11122
+ const filename = `${artifact.id}.${artifact.toolType}.log`;
11123
+ let removed = false;
11124
+ for (let attempt = 0; attempt < 3 && !removed; attempt++) {
11125
+ removed = (await artifactManager?.removeNamedBestEffort(filename)) ?? false;
11126
+ if (!removed && attempt < 2) await Bun.sleep(10);
11127
+ }
11128
+ if (removed) removedArtifacts++;
11129
+ else {
11130
+ unremovedArtifacts++;
11131
+ logger.warn("Failed to roll back staged pruned-output artifact after retries", {
11132
+ artifactId: artifact.id,
11133
+ reason,
11134
+ });
11135
+ }
11136
+ }
11137
+ return { removedArtifacts, unremovedArtifacts };
11138
+ };
11139
+ let toolTokensSaved = result.tokensSaved;
11140
+ let toolPrunedCount = result.prunedCount;
11141
+ const committedToolEntries = result.prunedEntries.filter(entry => {
11142
+ if (!failedArtifactEntryIds.has(entry.id)) return true;
11143
+ const original = result.originals.find(candidate => candidate.entryId === entry.id);
11144
+ if (!original) return true;
11145
+ const message = entry.message as ToolResultMessage;
11146
+ toolTokensSaved -=
11147
+ original.tokens -
11148
+ estimateTextTokensHeuristic(
11149
+ message.content
11150
+ .filter((part): part is TextContent => part.type === "text")
11151
+ .map(part => part.text)
11152
+ .join(""),
11153
+ );
11154
+ toolPrunedCount--;
11155
+ message.content = [{ type: "text", text: original.originalText }];
11156
+ delete message.prunedAt;
11157
+ return false;
11158
+ });
10655
11159
  const argumentResult = pruneAssistantToolArguments(branchEntries, DEFAULT_PRUNE_CONFIG);
10656
11160
  const fileMentionResult = pruneStaleFileMentions(branchEntries, p =>
10657
11161
  resolveReadPath(p, this.sessionManager.getCwd()),
@@ -10659,22 +11163,37 @@ export class AgentSession {
10659
11163
  const volatileContextResult = pruneSupersededVolatileProjectContext(branchEntries);
10660
11164
  const reminderResult = pruneSupersededMaintenanceReminders(branchEntries);
10661
11165
  const tokensSaved =
10662
- result.tokensSaved +
11166
+ toolTokensSaved +
10663
11167
  argumentResult.argumentTokensSaved +
10664
11168
  Math.round((fileMentionResult.bytesSaved + volatileContextResult.bytesSaved + reminderResult.bytesSaved) / 4);
10665
11169
  const prunedCount =
10666
- result.prunedCount +
11170
+ toolPrunedCount +
10667
11171
  argumentResult.argumentPrunedCount +
10668
11172
  fileMentionResult.changed.length +
10669
11173
  volatileContextResult.changed.length +
10670
11174
  reminderResult.changed.length;
10671
11175
  if (prunedCount === 0 || signal?.aborted) {
11176
+ if (publishedArtifacts.length > 0) await rollbackPublishedArtifacts("aborted");
11177
+ return undefined;
11178
+ }
11179
+ if (options?.commitGate && !options.commitGate({ prunedCount, tokensSaved })) {
11180
+ const rollback = await rollbackPublishedArtifacts("commit_gate_rejected");
11181
+ logger.info("Below-threshold maintenance pruning staged but not committed", {
11182
+ prunedCount,
11183
+ tokensSaved,
11184
+ rolledBackArtifacts: rollback.removedArtifacts,
11185
+ unremovedArtifacts: rollback.unremovedArtifacts,
11186
+ });
11187
+ return { prunedCount, tokensSaved, committed: false };
11188
+ }
11189
+ if (signal?.aborted) {
11190
+ await rollbackPublishedArtifacts("aborted");
10672
11191
  return undefined;
10673
11192
  }
10674
11193
 
10675
11194
  // getBranch() returns materialized copies for blob-externalized entries, so
10676
11195
  // the pruning mutations must be written back into the canonical store.
10677
- const combined = [...result.prunedEntries, ...argumentResult.prunedEntries, ...fileMentionResult.changed];
11196
+ const combined = [...committedToolEntries, ...argumentResult.prunedEntries, ...fileMentionResult.changed];
10678
11197
  this.sessionManager.applyEntryMessageUpdates(combined);
10679
11198
  this.sessionManager.applyCustomMessageEntryUpdates([...volatileContextResult.changed, ...reminderResult.changed]);
10680
11199
  await this.sessionManager.rewriteEntries();
@@ -10685,7 +11204,7 @@ export class AgentSession {
10685
11204
  this.#resetInjectedContextSignatures();
10686
11205
  this.#syncTodoPhasesFromBranch();
10687
11206
  this.#closeCodexProviderSessionsForHistoryRewrite();
10688
- return { prunedCount, tokensSaved };
11207
+ return { prunedCount, tokensSaved, committed: true };
10689
11208
  }
10690
11209
 
10691
11210
  /**
@@ -10718,7 +11237,9 @@ export class AgentSession {
10718
11237
  const compactionSettings = this.settings.getGroup("compaction");
10719
11238
  if (!compactionSettings.maintenancePruningEnabled) return;
10720
11239
 
10721
- const estimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG);
11240
+ const estimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11241
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11242
+ });
10722
11243
  const cacheEpochResetCost = this.#lastCacheEpochResetCost();
10723
11244
  if (
10724
11245
  !shouldRunMaintenancePrune({
@@ -10731,10 +11252,30 @@ export class AgentSession {
10731
11252
  return;
10732
11253
  }
10733
11254
 
10734
- const pruneResult = await this.#pruneToolOutputs();
11255
+ const pruneResult = await this.#pruneToolOutputs(undefined, false, {
11256
+ commitGate: actual => actual.tokensSaved > cacheEpochResetCost,
11257
+ });
10735
11258
  if (!pruneResult || pruneResult.prunedCount === 0) return;
10736
-
10737
- const resetReason = `below-threshold maintenance: reclaimed ${pruneResult.tokensSaved} tokens > cache-epoch reset cost ${cacheEpochResetCost}`;
11259
+ if (!pruneResult.committed) {
11260
+ const message = `Maintenance pruning skipped: actual savings ${pruneResult.tokensSaved} tokens <= cache-epoch reset cost ${cacheEpochResetCost} after artifact notices/publication rollback.`;
11261
+ await this.#emitSessionEvent({
11262
+ type: "notice",
11263
+ level: "info",
11264
+ source: "maintenance-prune",
11265
+ message,
11266
+ });
11267
+ logger.info("Below-threshold maintenance pruning skipped", {
11268
+ tokensSaved: pruneResult.tokensSaved,
11269
+ prunedCount: pruneResult.prunedCount,
11270
+ cacheEpochResetCost,
11271
+ minSavings: compactionSettings.maintenancePruningMinSavingsTokens,
11272
+ });
11273
+ return;
11274
+ }
11275
+ const resetReason =
11276
+ pruneResult.tokensSaved > cacheEpochResetCost
11277
+ ? `below-threshold maintenance: reclaimed ${pruneResult.tokensSaved} tokens > cache-epoch reset cost ${cacheEpochResetCost}`
11278
+ : `below-threshold maintenance: reclaimed ${pruneResult.tokensSaved} tokens <= cache-epoch reset cost ${cacheEpochResetCost}`;
10738
11279
  await this.#emitSessionEvent({
10739
11280
  type: "notice",
10740
11281
  level: "info",
@@ -10768,6 +11309,8 @@ export class AgentSession {
10768
11309
  await this.abort();
10769
11310
  const compactionAbortController = new AbortController();
10770
11311
  this.#compactionAbortController = compactionAbortController;
11312
+ // Take this invocation's state snapshot for the summarizer context.
11313
+ const compactionStateSnapshot = await this.#compactionStateSnapshot();
10771
11314
 
10772
11315
  try {
10773
11316
  if (!this.model) {
@@ -10813,7 +11356,11 @@ export class AgentSession {
10813
11356
  }
10814
11357
  }
10815
11358
 
10816
- const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
11359
+ const compactionPrep = await this.#prepareCompactionFromHooks(
11360
+ preparation,
11361
+ hookCompaction,
11362
+ compactionStateSnapshot,
11363
+ );
10817
11364
 
10818
11365
  let summary: string;
10819
11366
  let shortSummary: string | undefined;
@@ -11305,6 +11852,7 @@ export class AgentSession {
11305
11852
  assistantMessage: AssistantMessage,
11306
11853
  skipAbortedCheck = true,
11307
11854
  onTerminalOverflowNoop?: () => void,
11855
+ resourceRunId?: string,
11308
11856
  ): Promise<boolean> {
11309
11857
  // Safety stops are terminal and must not trigger context maintenance.
11310
11858
  if (
@@ -11351,7 +11899,12 @@ export class AgentSession {
11351
11899
  const promoted = await this.#tryContextPromotion(assistantMessage);
11352
11900
  if (promoted) {
11353
11901
  // Retry on the promoted (larger) model without compacting
11354
- this.#scheduleAgentContinue({ delayMs: 100, generation, suppressPredecessorAgentEnd: true });
11902
+ this.#scheduleAgentContinue({
11903
+ delayMs: 100,
11904
+ generation,
11905
+ suppressPredecessorAgentEnd: true,
11906
+ resourceRunId,
11907
+ });
11355
11908
 
11356
11909
  return true;
11357
11910
  }
@@ -11367,10 +11920,11 @@ export class AgentSession {
11367
11920
  this.agent.appendMessage(assistantMessage);
11368
11921
  }
11369
11922
  },
11923
+ resourceRunId,
11370
11924
  });
11371
11925
  return "continuationScheduled" in status && status.continuationScheduled === true;
11372
11926
  }
11373
- return this.#scheduleOverflowRetryContinuation(generation);
11927
+ return await this.#scheduleOverflowRetryContinuation(generation, resourceRunId);
11374
11928
  }
11375
11929
  const compactionSettings = this.settings.getGroup("compaction");
11376
11930
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
@@ -11390,6 +11944,7 @@ export class AgentSession {
11390
11944
  return true;
11391
11945
  const pruneEstimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11392
11946
  relaxedMinimum: 0,
11947
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11393
11948
  });
11394
11949
  if (
11395
11950
  pruneEstimate.tokensSaved > 0 &&
@@ -11494,6 +12049,7 @@ export class AgentSession {
11494
12049
  // prompt-cache epoch via #closeCodexProviderSessionsForHistoryRewrite.
11495
12050
  const pruneEstimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11496
12051
  relaxedMinimum: 0,
12052
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11497
12053
  });
11498
12054
  let pruneResult: { prunedCount: number; tokensSaved: number } | undefined;
11499
12055
  if (
@@ -11675,6 +12231,7 @@ export class AgentSession {
11675
12231
 
11676
12232
  const pruneEstimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG, {
11677
12233
  relaxedMinimum: 0,
12234
+ artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
11678
12235
  });
11679
12236
  if (
11680
12237
  pruneEstimate.tokensSaved > 0 &&
@@ -12614,6 +13171,7 @@ export class AgentSession {
12614
13171
  async #prepareCompactionFromHooks(
12615
13172
  preparation: CompactionPreparation,
12616
13173
  hookCompaction: CompactionResult | undefined,
13174
+ stateSnapshot: CompactionStateSnapshot,
12617
13175
  ): Promise<
12618
13176
  | {
12619
13177
  kind: "fromHook";
@@ -12653,11 +13211,17 @@ export class AgentSession {
12653
13211
  hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
12654
13212
  }
12655
13213
 
13214
+ const stateContext = this.#compactionStateContext(stateSnapshot);
13215
+
13216
+ if (stateContext.length > 0) {
13217
+ hookContext = hookContext ? [...hookContext, ...stateContext] : stateContext;
13218
+ }
13219
+
12656
13220
  if (hookCompaction) {
12657
13221
  preserveData ??= hookCompaction.preserveData;
12658
13222
  return {
12659
13223
  kind: "fromHook",
12660
- summary: hookCompaction.summary,
13224
+ summary: appendCompactionStateContext(hookCompaction.summary, stateContext),
12661
13225
  shortSummary: hookCompaction.shortSummary,
12662
13226
  firstKeptEntryId: hookCompaction.firstKeptEntryId,
12663
13227
  tokensBefore: hookCompaction.tokensBefore,
@@ -12682,6 +13246,7 @@ export class AgentSession {
12682
13246
  force?: boolean;
12683
13247
  signal?: AbortSignal;
12684
13248
  beforeTerminalOverflowNoop?: () => void;
13249
+ resourceRunId?: string;
12685
13250
  },
12686
13251
  ): Promise<AutoCompactionTerminalStatus> {
12687
13252
  const compactionSettings = this.settings.getGroup("compaction");
@@ -12703,7 +13268,7 @@ export class AgentSession {
12703
13268
  if (signal.aborted) return;
12704
13269
  await this.#runAutoCompaction(reason, willRetry, true, options);
12705
13270
  },
12706
- { generation },
13271
+ { generation, resourceRunId: options?.resourceRunId },
12707
13272
  );
12708
13273
  return { kind: "skipped" };
12709
13274
  }
@@ -12735,6 +13300,10 @@ export class AgentSession {
12735
13300
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
12736
13301
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
12737
13302
  if (autoCompactionSignal.aborted) return await emitAborted();
13303
+ const compactionStateSnapshot = await this.#compactionStateSnapshot();
13304
+ if (autoCompactionSignal.aborted || this.#isDisposed || this.#promptGeneration !== generation) {
13305
+ return await emitAborted();
13306
+ }
12738
13307
 
12739
13308
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
12740
13309
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
@@ -12774,6 +13343,7 @@ export class AgentSession {
12774
13343
  if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
12775
13344
  this.#scheduleAutoContinuePrompt(generation);
12776
13345
  }
13346
+
12777
13347
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
12778
13348
  return { kind: "compacted" };
12779
13349
  }
@@ -12827,15 +13397,22 @@ export class AgentSession {
12827
13397
  const overflowNoopWouldReplay = reason === "overflow" && willRetry && this.#isResumableAgentTail();
12828
13398
  const continuationSkipReason =
12829
13399
  !overflowNoopWouldReplay && willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
13400
+ if (continuationSkipReason) {
13401
+ this.#logCompactionContinuationSkipped("overflow_retry", continuationSkipReason);
13402
+ }
12830
13403
  if (overflowNoopWouldReplay) {
12831
13404
  options?.beforeTerminalOverflowNoop?.();
12832
13405
  }
13406
+ const overflowContinuationScheduled =
13407
+ !overflowNoopWouldReplay && willRetry && !continuationSkipReason
13408
+ ? await this.#scheduleOverflowRetryContinuation(generation)
13409
+ : false;
12833
13410
  await this.#emitSessionEvent({
12834
13411
  type: "auto_compaction_end",
12835
13412
  action,
12836
13413
  result: undefined,
12837
13414
  aborted: false,
12838
- willRetry: overflowNoopWouldReplay ? false : willRetry && !continuationSkipReason,
13415
+ willRetry: overflowContinuationScheduled,
12839
13416
  errorMessage: overflowNoopWouldReplay
12840
13417
  ? "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
13418
  : undefined,
@@ -12848,6 +13425,7 @@ export class AgentSession {
12848
13425
  delayMs: 100,
12849
13426
  generation,
12850
13427
  shouldContinue: () => this.agent.hasQueuedMessages(),
13428
+ rescheduleOnBusy: true,
12851
13429
  onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
12852
13430
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
12853
13431
  });
@@ -12856,7 +13434,9 @@ export class AgentSession {
12856
13434
  return { kind: "skipped" };
12857
13435
  }
12858
13436
  if (willRetry) {
12859
- return { kind: "skipped", continuationScheduled: this.#scheduleOverflowRetryContinuation(generation) };
13437
+ return overflowContinuationScheduled
13438
+ ? { kind: "skipped", continuationScheduled: true }
13439
+ : { kind: "skipped" };
12860
13440
  }
12861
13441
  if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
12862
13442
  this.#scheduleAgentContinue({
@@ -12865,6 +13445,7 @@ export class AgentSession {
12865
13445
  suppressPredecessorAgentEnd: true,
12866
13446
 
12867
13447
  shouldContinue: () => this.agent.hasQueuedMessages(),
13448
+ rescheduleOnBusy: true,
12868
13449
  onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
12869
13450
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
12870
13451
  });
@@ -12905,7 +13486,11 @@ export class AgentSession {
12905
13486
  }
12906
13487
  }
12907
13488
 
12908
- const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
13489
+ const compactionPrep = await this.#prepareCompactionFromHooks(
13490
+ preparation,
13491
+ hookCompaction,
13492
+ compactionStateSnapshot,
13493
+ );
12909
13494
  if (autoCompactionSignal.aborted) return await emitAborted();
12910
13495
 
12911
13496
  let summary: string;
@@ -13088,18 +13673,25 @@ export class AgentSession {
13088
13673
  this.#lastOversizedAutoMaintenanceAttemptSignature = undefined;
13089
13674
 
13090
13675
  const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
13676
+ if (continuationSkipReason) {
13677
+ this.#logCompactionContinuationSkipped("overflow_retry", continuationSkipReason);
13678
+ }
13679
+ const overflowContinuationScheduled =
13680
+ willRetry && !continuationSkipReason ? await this.#scheduleOverflowRetryContinuation(generation) : false;
13091
13681
  await this.#emitSessionEvent({
13092
13682
  type: "auto_compaction_end",
13093
13683
  action,
13094
13684
  result,
13095
13685
  aborted: false,
13096
- willRetry: willRetry && !continuationSkipReason,
13686
+ willRetry: overflowContinuationScheduled,
13097
13687
  continuationSkipReason,
13098
13688
  });
13099
13689
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
13100
13690
 
13101
13691
  if (willRetry) {
13102
- return { kind: "compacted", continuationScheduled: this.#scheduleOverflowRetryContinuation(generation) };
13692
+ return overflowContinuationScheduled
13693
+ ? { kind: "compacted", continuationScheduled: true }
13694
+ : { kind: "compacted" };
13103
13695
  }
13104
13696
  if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
13105
13697
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
@@ -13109,6 +13701,7 @@ export class AgentSession {
13109
13701
  generation,
13110
13702
  suppressPredecessorAgentEnd: true,
13111
13703
  shouldContinue: () => this.agent.hasQueuedMessages(),
13704
+ rescheduleOnBusy: true,
13112
13705
  onSkip: reason => this.#logCompactionContinuationSkipped("queued_continue", reason),
13113
13706
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
13114
13707
  });
@@ -13223,6 +13816,10 @@ export class AgentSession {
13223
13816
  return false;
13224
13817
  }
13225
13818
 
13819
+ #isTypedFirstEventTimeout(message: AssistantMessage): boolean {
13820
+ return message.transportFailure?.providerCode?.toLowerCase() === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE;
13821
+ }
13822
+
13226
13823
  #isTerminalProviderFirstEventTimeout(message: AssistantMessage): boolean {
13227
13824
  return this.#isKimiCodeFirstEventTimeout(message) || this.#isAlibabaTokenPlanFirstEventTimeout(message);
13228
13825
  }
@@ -13348,6 +13945,7 @@ export class AgentSession {
13348
13945
  #classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
13349
13946
  if (message.stopReason !== "error") return "none";
13350
13947
  if (message.errorKind === "provider_safety_stop") return "terminal";
13948
+ if (this.#isTypedFirstEventTimeout(message)) return "first_event_timeout";
13351
13949
  if (!message.errorMessage) return "none";
13352
13950
  const err = message.errorMessage;
13353
13951
  // Provider safety refusals (e.g. Anthropic stop_reason "refusal" /
@@ -13388,10 +13986,8 @@ export class AgentSession {
13388
13986
  if (this.#isTerminalProviderFirstEventTimeout(message)) {
13389
13987
  return "terminal";
13390
13988
  }
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.
13989
+ // Legacy providers that have not yet stamped the typed timeout fact retain
13990
+ // their existing bounded ollama-cloud behavior.
13395
13991
  if (this.#isFirstEventTimeoutErrorMessage(err) && this.#shouldFailClosedOnFirstEventTimeout(message)) {
13396
13992
  return "first_event_timeout";
13397
13993
  }
@@ -13727,25 +14323,25 @@ export class AgentSession {
13727
14323
  return `Model fallback chain exhausted; models tried: ${tried}; models skipped: ${skipped}`;
13728
14324
  }
13729
14325
 
13730
- async #markFailedManagedCredential(trigger: {
13731
- class: FallbackTriggerClass;
13732
- retryAfterMs?: number;
13733
- }): Promise<boolean> {
14326
+ async #markFailedCredential(trigger: { class: FallbackTriggerClass; retryAfterMs?: number }): Promise<boolean> {
13734
14327
  if (!this.model || (trigger.class !== "auth" && trigger.class !== "quota" && trigger.class !== "rate_limit")) {
13735
14328
  return false;
13736
14329
  }
13737
14330
  const authStorage = this.#modelRegistry.authStorage;
14331
+ const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
13738
14332
  if (trigger.class === "auth") {
13739
- const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
14333
+ const apiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
13740
14334
  if (!isAuthenticated(apiKey)) return false;
13741
- return authStorage.invalidateCredentialMatching(this.model.provider, apiKey, { sessionId: this.sessionId });
14335
+ return authStorage.invalidateCredentialMatching(this.model.provider, apiKey, {
14336
+ sessionId: providerAffinitySessionId,
14337
+ });
13742
14338
  }
13743
14339
  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, {
14340
+ const activeApiKey = await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId);
14341
+ const rotated = await authStorage.markUsageLimitReached(this.model.provider, providerAffinitySessionId, {
13746
14342
  retryAfterMs: trigger.retryAfterMs,
13747
14343
  });
13748
- return rotated && (await this.#modelRegistry.getApiKey(this.model, this.sessionId)) !== activeApiKey;
14344
+ return rotated && (await this.#modelRegistry.getApiKey(this.model, providerAffinitySessionId)) !== activeApiKey;
13749
14345
  }
13750
14346
 
13751
14347
  /** Handle retryable errors with exponential backoff. */
@@ -13766,16 +14362,16 @@ export class AgentSession {
13766
14362
  // user opt-out.
13767
14363
  if (!managedFallback && !retrySettings.enabled) return false;
13768
14364
  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
- }
14365
+ if (classification === "first_event_timeout") {
14366
+ this.#firstEventTimeoutRetryStartedAt ??= Date.now();
14367
+ }
14368
+ // First-event timeouts are replay-safe only before any model progress,
14369
+ // abort, or extension/provider lifecycle participation.
14370
+ if (
14371
+ classification === "first_event_timeout" &&
14372
+ (!this.#hasCleanRetryReplaySafety || assistantMessageHasVisibleOrToolContent(message))
14373
+ ) {
14374
+ return false;
13779
14375
  }
13780
14376
  const trigger = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
13781
14377
  if (!trigger) {
@@ -13783,6 +14379,39 @@ export class AgentSession {
13783
14379
  ? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
13784
14380
  : false;
13785
14381
  }
14382
+ // Credential rotation: a content-free quota/rate-limit failure has no
14383
+ // observable state to corrupt, so it is replay-safe regardless of
14384
+ // extension lifecycle participation. Mark the failed credential and
14385
+ // retry with the next stored credential of the same provider.
14386
+ let credentialRotated =
14387
+ !managedFallback &&
14388
+ !assistantMessageHasVisibleOrToolContent(message) &&
14389
+ (trigger.class === "quota" || trigger.class === "rate_limit") &&
14390
+ (await this.#markFailedCredential(trigger));
14391
+ // A content-free credential rotation is inherently replay-safe: no partial
14392
+ // output, no tool calls, no extension-observable streaming state was produced
14393
+ // before the failure. This bypasses #hasCleanRetryReplaySafety because the
14394
+ // content-free check is the replay-safety guarantee for credential rotation.
14395
+ const canReplayRotatedCredential = credentialRotated;
14396
+ // Bare defaults admit only clean, side-effect-free canonical stream watchdog failures,
14397
+ // content-free quota/rate-limit credential rotations, and the explicit Codex
14398
+ // capacity-overload event.
14399
+ if (!managedFallback && !legacyRetryConfigured && !canReplayRotatedCredential) {
14400
+ const bareDefaultCodexOverload = isBareDefaultCodexOverload(message);
14401
+ // Content-free Codex overload is replay-safe by the same reasoning as
14402
+ // credential rotation: no partial output means no observable state.
14403
+ const canReplayCodexOverload = bareDefaultCodexOverload;
14404
+ if (
14405
+ (!canReplayCodexOverload &&
14406
+ !this.#isTypedFirstEventTimeout(message) &&
14407
+ (hasBareDefaultRetryDisqualifyingFacts(message) ||
14408
+ (classification !== "transient" && classification !== "first_event_timeout") ||
14409
+ !BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))) ||
14410
+ (!canReplayCodexOverload && !this.#hasCleanRetryReplaySafety)
14411
+ ) {
14412
+ return false;
14413
+ }
14414
+ }
13786
14415
  const legacyUnbounded = classification === "transient";
13787
14416
  const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
13788
14417
  const failedSelector = managedFallback ? controller.currentSelector() : undefined;
@@ -13791,12 +14420,14 @@ export class AgentSession {
13791
14420
  : legacyUnbounded || attemptsUsed <= retrySettings.maxRetries
13792
14421
  ? "retry"
13793
14422
  : "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()) {
14423
+ // Credential rotation is unbounded: a fresh credential is a different
14424
+ // retry dimension from transient-error backoff, so it overrides maxRetries
14425
+ // exhaustion and forces an immediate same-model retry.
14426
+ if (managedFallback && outcome === "advance" && (trigger.class === "quota" || trigger.class === "rate_limit")) {
14427
+ credentialRotated = await this.#markFailedCredential(trigger);
14428
+ }
14429
+ if (credentialRotated) {
14430
+ if (managedFallback) controller.restorePreviousEntryForRetry();
13800
14431
  outcome = "retry";
13801
14432
  }
13802
14433
  if (outcome === "exhausted") {
@@ -13807,6 +14438,10 @@ export class AgentSession {
13807
14438
  controller.resetSticky();
13808
14439
  return managedOutcome ? this.#managedFallbackExhaustionDecision(message, errorMessage) : false;
13809
14440
  }
14441
+ if (classification === "first_event_timeout") {
14442
+ const elapsedMs = Date.now() - (this.#firstEventTimeoutRetryStartedAt ?? Date.now());
14443
+ message.errorMessage = `First-event stream timeout exhausted after ${attemptsUsed} attempts; waited ${elapsedMs}ms total: ${message.errorMessage ?? "Unknown error"}`;
14444
+ }
13810
14445
  return false;
13811
14446
  }
13812
14447
 
@@ -13828,7 +14463,7 @@ export class AgentSession {
13828
14463
  }
13829
14464
 
13830
14465
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
13831
- if (managedFallback && !credentialRotated) await this.#markFailedManagedCredential(trigger);
14466
+ if (managedFallback && !credentialRotated) await this.#markFailedCredential(trigger);
13832
14467
  let advanced = outcome !== "advance";
13833
14468
  let resolutionError: unknown;
13834
14469
  if (outcome === "advance") {
@@ -13870,7 +14505,12 @@ export class AgentSession {
13870
14505
  await this.#emitSessionEvent({
13871
14506
  type: "auto_retry_start",
13872
14507
  attempt: this.#retryAttempt,
13873
- maxAttempts: managedFallback ? controller.maxAttempts : retrySettings.maxRetries,
14508
+ maxAttempts:
14509
+ classification === "first_event_timeout"
14510
+ ? retrySettings.maxRetries + 1
14511
+ : managedFallback
14512
+ ? controller.maxAttempts
14513
+ : retrySettings.maxRetries,
13874
14514
  delayMs,
13875
14515
  errorMessage,
13876
14516
  unbounded: managedFallback ? false : legacyUnbounded,
@@ -13878,9 +14518,19 @@ export class AgentSession {
13878
14518
 
13879
14519
  const messages = this.agent.state.messages;
13880
14520
  if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
13881
- this.agent.replaceMessages(messages.slice(0, -1));
14521
+ let end = messages.length - 1;
14522
+ // A wedged turn can leave more than one assistant tail behind (e.g. the
14523
+ // agent loop's invalid_prompt repair resend commits its own rejected
14524
+ // message). agent.continue() refuses ANY assistant tail, so strip the
14525
+ // whole trailing run of failed attempts, not just the last one.
14526
+ while (end > 0) {
14527
+ const previous = messages[end - 1];
14528
+ if (previous.role !== "assistant") break;
14529
+ if (previous.stopReason !== "error" && previous.stopReason !== "aborted") break;
14530
+ end--;
14531
+ }
14532
+ this.agent.replaceMessages(messages.slice(0, end));
13882
14533
  }
13883
-
13884
14534
  try {
13885
14535
  await scheduler.wait(delayMs, { signal: retryAbortController.signal });
13886
14536
  } catch {
@@ -14120,12 +14770,8 @@ export class AgentSession {
14120
14770
  // Bash Execution
14121
14771
  // =========================================================================
14122
14772
 
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
- }
14773
+ async #saveBashOriginalArtifact(originalText: string): Promise<BashArtifactSaveResult> {
14774
+ return saveAgentBashOriginalArtifact(this.sessionManager, originalText);
14129
14775
  }
14130
14776
 
14131
14777
  /**
@@ -15119,7 +15765,7 @@ export class AgentSession {
15119
15765
  // workflow-gate emitter, and hooks cannot resolve against an ungated
15120
15766
  // root. The manager-rotation window itself is tracked in #3138
15121
15767
  // (#2797 / #2925).
15122
- if (switchingToDifferentSession) await initializeLocalRoot(this.#localProtocolOptions());
15768
+ if (switchingToDifferentSession) await this.#initializeLocalRootForLoadedSession();
15123
15769
  this.#syncAgentSessionId();
15124
15770
  this.#rekeyHindsightMemoryForCurrentSessionId();
15125
15771
 
@@ -15141,9 +15787,13 @@ export class AgentSession {
15141
15787
  this.#rebindProviderSessionState(new Map());
15142
15788
  }
15143
15789
 
15790
+ const resumeModelBehavior = this.settings.get("session.resumeModelBehavior");
15144
15791
  const configuredDefaultChain = sessionContext.configuredModelChains.default?.entries;
15792
+ const settingsDefaultEntries = normalizeModelSelectorValue(this.settings.getModelRole("default"));
15145
15793
  const defaultEntries =
15146
- configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []);
15794
+ resumeModelBehavior === "useCurrentDefault"
15795
+ ? settingsDefaultEntries
15796
+ : (configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []));
15147
15797
  this.#defaultFallbackController = undefined;
15148
15798
  if (defaultEntries.length > 0) {
15149
15799
  const resolution = await resolveModelChainWithAuth(
@@ -15200,7 +15850,7 @@ export class AgentSession {
15200
15850
  // Establish the successor's durable session identity only after every
15201
15851
  // restored state facet is live. Identity-bound extension hooks run below.
15202
15852
  await this.sessionManager.ensureOnDisk();
15203
- if (!switchingToDifferentSession) await initializeLocalRoot(this.#localProtocolOptions());
15853
+ if (!switchingToDifferentSession) await this.#initializeLocalRootForLoadedSession();
15204
15854
 
15205
15855
  if (switchingToDifferentSession) {
15206
15856
  // The local:// migration gate for this successor already ran above,