@gajae-code/coding-agent 0.15.4 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -5,16 +5,30 @@ import * as fs from "node:fs/promises";
5
5
  import * as path from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { ThinkingLevel } from "@gajae-code/agent-core";
8
- import type { Api, Model } from "@gajae-code/ai/core";
8
+ import type { Api, ImageContent, Model } from "@gajae-code/ai/core";
9
9
  import { logger } from "@gajae-code/utils";
10
10
  import { AsyncJobManager } from "../../async";
11
+ import {
12
+ getProxyRoutableProviders,
13
+ inspectProxyProviderId,
14
+ requiresQualifiedModelProfileRoleResolution,
15
+ resolveProxyMode,
16
+ rewriteSelectorForProxy,
17
+ tryResolveProxyProviderId,
18
+ } from "../../config/model-profile-activation";
11
19
  import { isModelProfileProviderAvailable, projectModelProfileCatalog } from "../../config/model-profile-contract";
12
- import { type ModelProfileDefinition, resolveProfileBindings } from "../../config/model-profiles";
20
+ import {
21
+ deriveModelProfileMappedProviders,
22
+ type ModelProfileDefinition,
23
+ resolveProfileBindings,
24
+ } from "../../config/model-profiles";
25
+ import { isAuthenticated, kNoAuth } from "../../config/model-registry";
13
26
  import { resolveModelChainWithAuth, splitSelectorThinkingSuffix } from "../../config/model-resolver";
14
27
  import { type ModelSelectorValue, normalizeModelSelectorValue } from "../../config/model-selector-value";
15
28
  import { type Settings, validateSettingPatch } from "../../config/settings";
16
29
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
17
30
  import { normalizeGoal } from "../../goals/state";
31
+ import type { SdkRunCapability } from "../../session/sdk-run-capability";
18
32
  import {
19
33
  boundTerminalRetentionState,
20
34
  findOwnedRegistrationsForTurn,
@@ -26,7 +40,6 @@ import { parseThinkingLevel } from "../../thinking";
26
40
  import { ensureBroker } from "../broker/ensure";
27
41
  import { SessionIndex } from "../broker/session-index";
28
42
  import {
29
- collectAuthenticatedProfileProviders,
30
43
  parseSyntheticModelId,
31
44
  resolveSyntheticModelSelection,
32
45
  SYNTHETIC_PROVIDER_ID,
@@ -34,8 +47,9 @@ import {
34
47
  syntheticNamespaceCollision,
35
48
  } from "../model-profile-model";
36
49
  import { projectQ10Models } from "../models.js";
37
- import { PromptDeadlineManager } from "../prompt-deadline-manager";
50
+ import { PromptDeadlineManager, type PromptTerminalTransitionEvidence } from "../prompt-deadline-manager";
38
51
  import { formatPromptFailureForLocalLog, sanitizePromptFailure } from "../prompt-failure";
52
+ import { validateRequiredPromptText } from "../protocol/adapter-validation";
39
53
  import { OPERATIONS } from "../protocol/operation-registry";
40
54
  import {
41
55
  createKindAwareReconciliation,
@@ -43,11 +57,13 @@ import {
43
57
  type KindAwareReconciliation,
44
58
  resolveReconciliationSessionFile,
45
59
  } from "../reconciliation-extensions";
60
+ import { sanitizeTurnResultContent, type TurnResultContent } from "../turn-result";
46
61
  import { type ControlSurface, controlRequestFromFrame, dispatchControl } from "./control";
47
62
  import { BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD } from "./control/runtime-gate";
48
63
  import { SessionSdkHost, type SessionSdkHostOptions } from "./host";
49
64
  import { clearAutoroutingInactive, isAutoroutingInactive, markAutoroutingInactive } from "./internal-autorouting-state";
50
65
  import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "./query";
66
+ import { createSdkRunCapability } from "./sdk-run-capability";
51
67
  import {
52
68
  createSdkCapabilities,
53
69
  createSdkSurfacePolicyForContext,
@@ -87,6 +103,8 @@ const SDK_ONLY_TERMINAL_PUBLICATION_WAIT_MS = 1_000;
87
103
  * after this bound are abandoned — their durable broker state is the recovery
88
104
  * authority, and outcomes are inherently uncertain. */
89
105
  const GATE_RESOLUTION_QUIESCENCE_MS = 5_000;
106
+ /** Maximum time a replaced runtime may retain a lifecycle persistence task. */
107
+ const LIFECYCLE_QUIESCENCE_MS = 1_000;
90
108
 
91
109
  class DiffQueryError extends Error {
92
110
  constructor(
@@ -397,6 +415,10 @@ export interface CreateSdkSessionRuntimeOptions {
397
415
  terminalAbortSeams?: SdkOnlyTerminalAbortSeams;
398
416
  /** Callback when a frame is admitted to the runtime (test harness). */
399
417
  onFrameAdmitted?: () => void;
418
+ /** Test-only observation of a genuinely timed-out lifecycle drain. */
419
+ onLifecycleDrainTimeoutForTests?: () => void;
420
+ /** Test-only observation of bounded failure-diagnostic deduplication state. */
421
+ onFailureDiagnosticKeyCountForTests?: (count: number) => void;
400
422
  }
401
423
 
402
424
  function unavailable(operation: string): () => never {
@@ -414,6 +436,7 @@ export interface InvocationCorrelation {
414
436
 
415
437
  export type InvocationKind = "prompt" | "skill" | "steer";
416
438
  type InvocationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed" | "uncertain";
439
+ const EMPTY_PROMPT_FAILURE = { code: "prompt_failed", message: "Prompt submission failed." } as const;
417
440
  interface InvocationRecord extends InvocationCorrelation {
418
441
  kind: InvocationKind;
419
442
  revision: number;
@@ -434,7 +457,14 @@ export interface InvocationReconciliation {
434
457
  noteTransition(
435
458
  kind: InvocationKind,
436
459
  correlation: InvocationCorrelation | undefined,
437
- frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
460
+ frame:
461
+ | {
462
+ type: "agent_start" | "agent_end";
463
+ content?: TurnResultContent;
464
+ hasActivity?: boolean;
465
+ outcome?: { kind: "stopped"; reason: "cancelled"; provenance: "client_cancel" };
466
+ }
467
+ | { type: "agent_failed"; error: unknown; content?: TurnResultContent; hasActivity?: boolean },
438
468
  ): Promise<void>;
439
469
  lookup(kind: InvocationKind, selector: { commandId?: string; turnId?: string; clientRef?: string }): unknown;
440
470
  lookupResult(kind: InvocationKind, selector: { commandId?: string; turnId?: string; clientRef?: string }): unknown;
@@ -458,7 +488,13 @@ export interface InvocationReconciliation {
458
488
  correlation: InvocationCorrelation,
459
489
  outcome?: { kind: string; code: string; message: string; provenance?: string },
460
490
  arg4?: (() => boolean) | { code: string; message: string },
491
+ // Terminal evidence accepted by finalizeOutcome (exact-head review P1/P2):
492
+ // when an explicit outcome is absent, a content-bearing, activity-bearing,
493
+ // or explicitly stopped finalization is still a successful terminal; only a
494
+ // genuinely empty, no-activity completion fails closed with the sanitized
495
+ // empty-prompt failure. Mirrors the noteTransition evidence predicate.
461
496
  arg5?: unknown,
497
+ arg6?: { content?: TurnResultContent; hasActivity?: boolean; outcomeKind?: string },
462
498
  ): Promise<void>;
463
499
  markUncertain(
464
500
  kind: InvocationKind,
@@ -693,7 +729,14 @@ export function createInvocationReconciliation(
693
729
  // so a real lifecycle event is never swallowed.
694
730
  }
695
731
  const current = records.get(recordKey);
696
- if (frame.type === "agent_end" && current === pending.finalizedRecord) {
732
+ if (
733
+ frame.type === "agent_end" &&
734
+ current === pending.finalizedRecord &&
735
+ (kind !== "prompt" ||
736
+ frame.outcome?.kind === "stopped" ||
737
+ frame.hasActivity === true ||
738
+ frame.content?.text.trim())
739
+ ) {
697
740
  // A real lifecycle end that arrived during deadline finalization is
698
741
  // stronger evidence than the synthetic deadline outcome. Upgrade the
699
742
  // durable terminal instead of treating the event as a duplicate.
@@ -728,7 +771,14 @@ export function createInvocationReconciliation(
728
771
  }
729
772
  }
730
773
  if (record.terminalAt !== undefined) {
731
- if (frame.type === "agent_end" && record.error?.code === "prompt_deadline_exceeded") {
774
+ if (
775
+ frame.type === "agent_end" &&
776
+ record.error?.code === "prompt_deadline_exceeded" &&
777
+ (kind !== "prompt" ||
778
+ frame.outcome?.kind === "stopped" ||
779
+ frame.hasActivity === true ||
780
+ frame.content?.text.trim())
781
+ ) {
732
782
  const upgraded = { ...record, revision: ++mutationRevision, status: "terminal_ok" as const };
733
783
  // The synthetic deadline error is superseded evidence; terminal_ok must not
734
784
  // surface a deadline failure for a prompt that actually completed.
@@ -746,7 +796,13 @@ export function createInvocationReconciliation(
746
796
  // failure reason may arrive on a different delivery path than the one that
747
797
  // claimed the terminal. Enrich the settled record instead of dropping it;
748
798
  // never resurrect (status/terminalAt untouched), and first reason wins.
749
- if (frame.type === "agent_failed" && record.error === undefined) {
799
+ if (frame.type === "agent_failed") {
800
+ const failure = sanitizePromptFailure(frame.error);
801
+ if (
802
+ record.error !== undefined &&
803
+ !(record.error.code === "agent_failed" && failure.code !== "agent_failed")
804
+ )
805
+ return;
750
806
  const next = { ...record, revision: ++mutationRevision };
751
807
  logger.error("SDK invocation failed (late)", {
752
808
  kind,
@@ -754,7 +810,7 @@ export function createInvocationReconciliation(
754
810
  turnId: correlation.turnId,
755
811
  error: formatPromptFailureForLocalLog(frame.error),
756
812
  });
757
- next.error = sanitizePromptFailure(frame.error);
813
+ next.error = failure;
758
814
  records.set(recordKey, next);
759
815
  try {
760
816
  await persist();
@@ -779,9 +835,21 @@ export function createInvocationReconciliation(
779
835
  turnId: correlation.turnId,
780
836
  error: formatPromptFailureForLocalLog(frame.error),
781
837
  });
782
- next.error ??= sanitizePromptFailure(frame.error);
838
+ const failure = sanitizePromptFailure(frame.error);
839
+ if (next.error === undefined || (next.error.code === "agent_failed" && failure.code !== "agent_failed"))
840
+ next.error = failure;
783
841
  } else {
784
- next.status = next.error === undefined ? "terminal_ok" : "failed";
842
+ if (
843
+ kind === "prompt" &&
844
+ next.error === undefined &&
845
+ frame.type === "agent_end" &&
846
+ frame.outcome?.kind !== "stopped" &&
847
+ !frame.content?.text.trim() &&
848
+ !frame.hasActivity
849
+ ) {
850
+ next.status = "failed";
851
+ next.error = EMPTY_PROMPT_FAILURE;
852
+ } else next.status = next.error === undefined ? "terminal_ok" : "failed";
785
853
  next.terminalAt = Date.now();
786
854
  }
787
855
  records.set(recordKey, next);
@@ -854,6 +922,7 @@ export function createInvocationReconciliation(
854
922
  outcome,
855
923
  arg4?: (() => boolean) | { code: string; message: string },
856
924
  arg5?: unknown,
925
+ arg6?: { content?: TurnResultContent; hasActivity?: boolean; outcomeKind?: string },
857
926
  ) {
858
927
  // Normalize every supported positional form (exact-head review P1):
859
928
  // (…, isCurrent) | (…, recordError) | (…, undefined, recordError) |
@@ -866,6 +935,14 @@ export function createInvocationReconciliation(
866
935
  ? (arg5 as { code: string; message: string })
867
936
  : undefined;
868
937
  const recordError = recordErrorCandidate;
938
+ // Terminal evidence may also arrive positionally through arg5 (legacy
939
+ // callers) when it does not carry a recordError shape; normalize it so
940
+ // the empty predicate below sees every evidence channel consistently.
941
+ const legacyEvidenceCandidate =
942
+ typeof arg5 === "object" && arg5 !== null && !("code" in arg5)
943
+ ? (arg5 as { content?: TurnResultContent; hasActivity?: boolean; outcomeKind?: string })
944
+ : undefined;
945
+ const evidence = arg6 ?? legacyEvidenceCandidate;
869
946
  const recordKey = key(kind, correlation);
870
947
  const record = records.get(recordKey);
871
948
  if (!record || record.terminalAt !== undefined || record.kind !== kind) return;
@@ -882,6 +959,19 @@ export function createInvocationReconciliation(
882
959
  recordError !== undefined
883
960
  ? { code: recordError.code, message: recordError.message }
884
961
  : { code: finalOutcome.code, message: finalOutcome.message };
962
+ } else if (kind === "prompt" && finalOutcome === undefined) {
963
+ // Evidence-based empty predicate (exact-head review P1/P2): the same
964
+ // semantics as the noteTransition agent_end classifier. A
965
+ // content-bearing, activity-bearing, or explicitly stopped
966
+ // finalization without an explicit outcome is a successful terminal;
967
+ // only a genuinely empty, no-activity completion fails closed.
968
+ const terminalText = evidence?.content?.text.trim() ?? "";
969
+ if (terminalText === "" && !evidence?.hasActivity && evidence?.outcomeKind !== "stopped") {
970
+ finalizedRecord.status = "failed";
971
+ finalizedRecord.error = EMPTY_PROMPT_FAILURE;
972
+ } else {
973
+ finalizedRecord.status = "terminal_ok";
974
+ }
885
975
  } else {
886
976
  finalizedRecord.status = "terminal_ok";
887
977
  }
@@ -1043,21 +1133,50 @@ function createQuerySurface(
1043
1133
  return undefined;
1044
1134
  };
1045
1135
  const getProfileCredentialSessionId = () => ctx.credentialSessionId ?? id;
1136
+ const profileSettings = (options.settings ?? ctx.settings) as Pick<Settings, "get"> | undefined;
1137
+ const getProfileAvailableModels = (): Model<Api>[] => {
1138
+ const getAvailableForProfileActivation = ctx.modelRegistry.getAvailableForProfileActivation;
1139
+ return typeof getAvailableForProfileActivation === "function"
1140
+ ? getAvailableForProfileActivation.call(ctx.modelRegistry)
1141
+ : ctx.modelRegistry.getAvailable();
1142
+ };
1046
1143
  const resolveProfileAvailability = async (
1047
1144
  profile: ModelProfileDefinition,
1048
1145
  authenticatedProviders: ReadonlySet<string>,
1049
1146
  ): Promise<{ available: boolean; defaultModel?: Model<Api> }> => {
1147
+ if (profile.source !== "user" && inspectProxyProviderId(profileSettings).status === "invalid")
1148
+ return { available: false };
1149
+ const proxyProvider = profile.source === "user" ? undefined : tryResolveProxyProviderId(profileSettings);
1150
+ const proxyAuthenticated = proxyProvider !== undefined && authenticatedProviders.has(proxyProvider);
1151
+ const profileAuthenticated = new Set(authenticatedProviders);
1152
+ if (proxyAuthenticated) {
1153
+ for (const provider of getProxyRoutableProviders(profile)) profileAuthenticated.add(provider);
1154
+ }
1050
1155
  const rewriteSelectorProvider = (selector: string): string => {
1051
1156
  const slash = selector.indexOf("/");
1052
1157
  if (slash < 0) return selector;
1053
1158
  const provider = selector.slice(0, slash);
1054
- if (authenticatedProviders.has(provider)) return selector;
1159
+ if (profileAuthenticated.has(provider)) return selector;
1055
1160
  const group = (profile.alternativeProviderGroups ?? []).find(candidates => candidates.includes(provider));
1056
1161
  if (!group) return selector;
1057
- const replacement = group.find(candidate => authenticatedProviders.has(candidate));
1162
+ const replacement = group.find(candidate => profileAuthenticated.has(candidate));
1058
1163
  return replacement ? replacement + selector.slice(slash) : selector;
1059
1164
  };
1060
1165
  try {
1166
+ const proxyMode = profile.source === "user" ? "fallback" : resolveProxyMode(profileSettings);
1167
+ if (profile.source !== "user") {
1168
+ const configuredProviders = ctx.modelRegistry.getConfiguredProviderIds?.() ?? [];
1169
+ if (proxyProvider !== undefined && !configuredProviders.includes(proxyProvider))
1170
+ return { available: false };
1171
+ }
1172
+ if (profile.source !== "user" && proxyMode === "always") {
1173
+ if (
1174
+ proxyProvider === undefined ||
1175
+ !proxyAuthenticated ||
1176
+ !(ctx.modelRegistry.getConfiguredProviderIds?.() ?? []).includes(proxyProvider)
1177
+ )
1178
+ return { available: false };
1179
+ }
1061
1180
  const bindings = resolveProfileBindings(profile);
1062
1181
  const assignments: Array<{ value: ModelSelectorValue; isDefault: boolean }> = [];
1063
1182
  if (bindings.defaultSelector !== undefined) {
@@ -1065,23 +1184,44 @@ function createQuerySurface(
1065
1184
  }
1066
1185
  for (const value of Object.values(bindings.modelRoles)) assignments.push({ value, isDefault: false });
1067
1186
  for (const value of Object.values(bindings.agentModelOverrides)) assignments.push({ value, isDefault: false });
1187
+ const availableModels = getProfileAvailableModels();
1188
+ const resolutionRegistry = {
1189
+ ...ctx.modelRegistry,
1190
+ getAvailable: () => availableModels,
1191
+ getApiKey: (model: Model<Api>, sessionId?: string) =>
1192
+ ctx.modelRegistry.getApiKeyForProvider(model.provider, sessionId, model.baseUrl),
1193
+ resolveCanonicalModel: ctx.modelRegistry.resolveCanonicalModel?.bind(ctx.modelRegistry),
1194
+ getCanonicalVariants: ctx.modelRegistry.getCanonicalVariants?.bind(ctx.modelRegistry),
1195
+ getCanonicalId: ctx.modelRegistry.getCanonicalId?.bind(ctx.modelRegistry),
1196
+ resolveModelByLookupAlias: ctx.modelRegistry.resolveModelByLookupAlias?.bind(ctx.modelRegistry),
1197
+ lookupAliasExists: ctx.modelRegistry.lookupAliasExists?.bind(ctx.modelRegistry),
1198
+ clearCanonicalVariant: ctx.modelRegistry.clearCanonicalVariant?.bind(ctx.modelRegistry),
1199
+ };
1068
1200
  let defaultModel: Model<Api> | undefined;
1069
1201
  for (const assignment of assignments) {
1070
- const selectors = normalizeModelSelectorValue(assignment.value).map(rewriteSelectorProvider);
1202
+ let selectors = normalizeModelSelectorValue(assignment.value).map(rewriteSelectorProvider);
1203
+ if (proxyProvider !== undefined && proxyAuthenticated && profile.source !== "user") {
1204
+ selectors = selectors.map(selector =>
1205
+ rewriteSelectorForProxy(
1206
+ selector,
1207
+ proxyProvider,
1208
+ proxyMode,
1209
+ availableModels,
1210
+ new Set(authenticatedProviders),
1211
+ getProxyRoutableProviders(profile),
1212
+ ),
1213
+ );
1214
+ }
1071
1215
  const hasBareSelector = selectors.some(selector => {
1072
1216
  const suffix = splitSelectorThinkingSuffix(selector);
1073
1217
  const identity = suffix.thinkingLevel ? suffix.selector : selector;
1074
1218
  return !identity.includes("/");
1075
1219
  });
1076
- if (!assignment.isDefault && !hasBareSelector) continue;
1220
+ if (!assignment.isDefault && !requiresQualifiedModelProfileRoleResolution(profile) && !hasBareSelector)
1221
+ continue;
1077
1222
  const resolution = await resolveModelChainWithAuth(
1078
1223
  selectors,
1079
- {
1080
- ...ctx.modelRegistry,
1081
- getAvailable: () => ctx.modelRegistry.getAvailable(),
1082
- getApiKey: (model: Model<Api>, sessionId?: string) =>
1083
- ctx.modelRegistry.getApiKeyForProvider(model.provider, sessionId, model.baseUrl),
1084
- },
1224
+ resolutionRegistry,
1085
1225
  options.settings,
1086
1226
  getProfileCredentialSessionId(),
1087
1227
  {
@@ -1099,6 +1239,45 @@ function createQuerySurface(
1099
1239
  return { available: false };
1100
1240
  }
1101
1241
  };
1242
+ const collectProfileAuthentication = async (
1243
+ profiles: ReadonlyMap<string, ModelProfileDefinition>,
1244
+ ): Promise<Set<string>> => {
1245
+ const providers = new Set<string>();
1246
+ for (const profile of profiles.values()) {
1247
+ for (const provider of profile.requiredProviders) providers.add(provider);
1248
+ for (const group of profile.alternativeProviderGroups ?? []) {
1249
+ for (const provider of group) providers.add(provider);
1250
+ }
1251
+ for (const provider of deriveModelProfileMappedProviders(profile)) providers.add(provider);
1252
+ }
1253
+ const authenticated = new Set<string>();
1254
+ await Promise.all(
1255
+ [...providers].map(async provider => {
1256
+ try {
1257
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider, getProfileCredentialSessionId());
1258
+ if (apiKey === kNoAuth || isAuthenticated(apiKey)) authenticated.add(provider);
1259
+ } catch {
1260
+ // A provider whose credential state cannot be read is not currently configurable.
1261
+ }
1262
+ }),
1263
+ );
1264
+ const proxyProviders = new Set<string>();
1265
+ for (const profile of profiles.values()) {
1266
+ if (profile.source === "user") continue;
1267
+ const proxyProvider = tryResolveProxyProviderId(profileSettings);
1268
+ if (proxyProvider !== undefined) proxyProviders.add(proxyProvider);
1269
+ }
1270
+ for (const proxyProvider of proxyProviders) {
1271
+ try {
1272
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(proxyProvider, getProfileCredentialSessionId());
1273
+ if (apiKey === kNoAuth || isAuthenticated(apiKey)) authenticated.add(proxyProvider);
1274
+ } catch {
1275
+ // Passive availability must degrade to unavailable when proxy credential
1276
+ // refresh/storage fails; explicit activation retains its diagnostics.
1277
+ }
1278
+ }
1279
+ return authenticated;
1280
+ };
1102
1281
  const getDiff = async () => {
1103
1282
  try {
1104
1283
  const { stdout } = await execFileAsync("git", ["diff", "--no-ext-diff"], {
@@ -1231,9 +1410,7 @@ function createQuerySurface(
1231
1410
  if (collision) return degraded();
1232
1411
  let authenticatedProviders: ReadonlySet<string>;
1233
1412
  try {
1234
- authenticatedProviders = await collectAuthenticatedProfileProviders(profiles, provider =>
1235
- ctx.modelRegistry.getApiKeyForProvider(provider, getProfileCredentialSessionId()),
1236
- );
1413
+ authenticatedProviders = await collectProfileAuthentication(profiles);
1237
1414
  } catch {
1238
1415
  // Availability join failed: degrade only the synthetic facade,
1239
1416
  // retain concrete rows and the active marker readback.
@@ -1251,7 +1428,12 @@ function createQuerySurface(
1251
1428
  );
1252
1429
  const availableProfileIds = new Set<string>();
1253
1430
  for (const [name, profile] of profiles) {
1254
- if (!isModelProfileProviderAvailable(profile, authenticatedProviders)) continue;
1431
+ if (profile.source !== "user" && inspectProxyProviderId(profileSettings).status === "invalid") continue;
1432
+ const profileAuthenticated = new Set(authenticatedProviders);
1433
+ const proxyProvider = profile.source === "user" ? undefined : tryResolveProxyProviderId(profileSettings);
1434
+ if (proxyProvider !== undefined && profileAuthenticated.has(proxyProvider))
1435
+ for (const provider of getProxyRoutableProviders(profile)) profileAuthenticated.add(provider);
1436
+ if (!isModelProfileProviderAvailable(profile, profileAuthenticated)) continue;
1255
1437
  if (!fullyResolvedProfiles.has(name)) continue;
1256
1438
  // A profile with a default mapping is selectable only when its
1257
1439
  // default chain actually resolves to an authenticated model:
@@ -1322,18 +1504,19 @@ function createQuerySurface(
1322
1504
  (options.steerStatusLookup ?? (value => reconciliation.lookup("steer", value)))(selector),
1323
1505
  getModelProfiles: async () => {
1324
1506
  const profiles = ctx.modelRegistry.getModelProfiles();
1325
- const authenticatedProviders = await collectAuthenticatedProfileProviders(profiles, provider =>
1326
- ctx.modelRegistry.getApiKeyForProvider(provider, getProfileCredentialSessionId()),
1327
- );
1507
+ const authenticatedProviders = await collectProfileAuthentication(profiles);
1328
1508
  return (await Promise.all(
1329
- projectModelProfileCatalog(profiles, ctx.modelRegistry.getError()).map(async item => ({
1330
- ...item,
1331
- available:
1332
- isModelProfileProviderAvailable(profiles.get(item.id)!, authenticatedProviders) &&
1333
- (
1334
- await resolveProfileAvailability(profiles.get(item.id)!, authenticatedProviders)
1335
- ).available,
1336
- })),
1509
+ projectModelProfileCatalog(profiles, ctx.modelRegistry.getError()).map(async item => {
1510
+ const profile = profiles.get(item.id)!;
1511
+ const profileAuthenticated = new Set(authenticatedProviders);
1512
+ const proxyProvider = profile.source === "user" ? undefined : tryResolveProxyProviderId(profileSettings);
1513
+ if (proxyProvider !== undefined && profileAuthenticated.has(proxyProvider))
1514
+ for (const provider of getProxyRoutableProviders(profile)) profileAuthenticated.add(provider);
1515
+ const available =
1516
+ isModelProfileProviderAvailable(profile, profileAuthenticated) &&
1517
+ (await resolveProfileAvailability(profile, authenticatedProviders)).available;
1518
+ return { ...item, available };
1519
+ }),
1337
1520
  )) as unknown[];
1338
1521
  },
1339
1522
  installedQueries: policy.installedQueries,
@@ -1507,6 +1690,134 @@ function rejectionRecoveryIntent(error: unknown): { code: string; message: strin
1507
1690
  return sanitizePromptFailure(error);
1508
1691
  }
1509
1692
 
1693
+ /**
1694
+ * A provider can finish a stream with an assistant error message rather than
1695
+ * rejecting the prompt promise. The SDK extension receives the final agent_end
1696
+ * after retry/fallback policy has settled, so this is the safe boundary at which
1697
+ * to publish the additive failure diagnostic before terminal reconciliation.
1698
+ */
1699
+ function providerFailureFromAgentEnd(event: unknown): { code: string; message: string } | undefined {
1700
+ try {
1701
+ if (!event || typeof event !== "object") return undefined;
1702
+ const messages = (event as { messages?: unknown }).messages;
1703
+ if (!Array.isArray(messages)) return undefined;
1704
+ const lastAssistant = [...messages]
1705
+ .reverse()
1706
+ .find(
1707
+ message => message && typeof message === "object" && (message as { role?: unknown }).role === "assistant",
1708
+ );
1709
+ if (!lastAssistant || typeof lastAssistant !== "object") return undefined;
1710
+ const assistant = lastAssistant as {
1711
+ stopReason?: unknown;
1712
+ errorMessage?: unknown;
1713
+ errorKind?: unknown;
1714
+ errorStatus?: unknown;
1715
+ transportFailure?: { status?: unknown };
1716
+ };
1717
+ let stopReason: unknown;
1718
+ try {
1719
+ stopReason = assistant.stopReason;
1720
+ } catch {
1721
+ return undefined;
1722
+ }
1723
+ if (stopReason !== "error") return undefined;
1724
+ let errorKind: unknown;
1725
+ try {
1726
+ errorKind = assistant.errorKind;
1727
+ } catch {
1728
+ return undefined;
1729
+ }
1730
+ if (errorKind === "local_snapshot_failure" || errorKind === "local_buffer_overflow") return undefined;
1731
+ let errorMessage: unknown;
1732
+ try {
1733
+ errorMessage = assistant.errorMessage;
1734
+ } catch {
1735
+ return undefined;
1736
+ }
1737
+ // Agent-local malformed-tool and composer policy circuit breakers also use
1738
+ // stopReason=error, but they are not provider failures and must not be
1739
+ // reported as provider_rejected to SDK callers.
1740
+ if (
1741
+ typeof errorMessage === "string" &&
1742
+ (errorMessage.includes("Composer bash policy blocked repository file I/O again") ||
1743
+ errorMessage.includes("consecutive turns of malformed tool calls"))
1744
+ )
1745
+ return undefined;
1746
+ let status: number | undefined;
1747
+ try {
1748
+ const errorStatus = assistant.errorStatus;
1749
+ if (typeof errorStatus === "number" && Number.isSafeInteger(errorStatus)) status = errorStatus;
1750
+ } catch {
1751
+ // A malformed errorStatus is equivalent to a statusless provider error.
1752
+ }
1753
+ if (status === undefined) {
1754
+ try {
1755
+ const transportStatus = assistant.transportFailure?.status;
1756
+ if (typeof transportStatus === "number" && Number.isSafeInteger(transportStatus)) status = transportStatus;
1757
+ } catch {
1758
+ // A throwing transport metadata accessor is also statusless.
1759
+ }
1760
+ }
1761
+ if (status === 402 || status === 429)
1762
+ return { code: `provider_http_${status}`, message: "Prompt submission failed." };
1763
+ if (status !== undefined) return { code: "provider_rejected", message: "Prompt submission failed." };
1764
+ return { code: "provider_rejected", message: "Prompt submission failed." };
1765
+ } catch {
1766
+ // Provider metadata is untrusted: an SDK/provider adapter may expose a
1767
+ // throwing getter while the lifecycle boundary still needs to terminalize.
1768
+ return undefined;
1769
+ }
1770
+ }
1771
+
1772
+ interface PromptTerminalEvidence {
1773
+ content?: TurnResultContent;
1774
+ hasActivity: boolean;
1775
+ }
1776
+
1777
+ function promptTerminalEvidenceFromAgentEnd(event: unknown): PromptTerminalEvidence {
1778
+ try {
1779
+ if (!event || typeof event !== "object") return { hasActivity: false };
1780
+ const messages = (event as { messages?: unknown }).messages;
1781
+ if (!Array.isArray(messages)) return { hasActivity: false };
1782
+ const assistant = [...messages]
1783
+ .reverse()
1784
+ .find(
1785
+ message => message && typeof message === "object" && (message as { role?: unknown }).role === "assistant",
1786
+ );
1787
+ if (!assistant || typeof assistant !== "object") return { hasActivity: false };
1788
+ const content = (assistant as { content?: unknown }).content;
1789
+ if (typeof content === "string") {
1790
+ const bounded = sanitizeTurnResultContent(content);
1791
+ return { content: bounded, hasActivity: content.trim().length > 0 };
1792
+ }
1793
+ if (Array.isArray(content)) {
1794
+ const hasActivity = content.some(block => {
1795
+ if (block === null || typeof block !== "object") return false;
1796
+ const type = (block as { type?: unknown }).type;
1797
+ if (type === "text") {
1798
+ const text = (block as { text?: unknown }).text;
1799
+ return typeof text === "string" && text.trim().length > 0;
1800
+ }
1801
+ return typeof type === "string" && type.length > 0;
1802
+ });
1803
+ const text = content
1804
+ .filter(
1805
+ (block): block is { type: "text"; text: string } =>
1806
+ block !== null &&
1807
+ typeof block === "object" &&
1808
+ (block as { type?: unknown }).type === "text" &&
1809
+ typeof (block as { text?: unknown }).text === "string",
1810
+ )
1811
+ .map(block => block.text)
1812
+ .join("");
1813
+ return { content: sanitizeTurnResultContent(text), hasActivity };
1814
+ }
1815
+ return { content: sanitizeTurnResultContent(""), hasActivity: false };
1816
+ } catch {
1817
+ return { hasActivity: false };
1818
+ }
1819
+ }
1820
+
1510
1821
  function createControlSurface(
1511
1822
  ctx: ExtensionContext,
1512
1823
  api: ExtensionAPI,
@@ -1516,12 +1827,15 @@ function createControlSurface(
1516
1827
  correlation: InvocationCorrelation,
1517
1828
  connectionId: string | undefined,
1518
1829
  startsOwnTurn: boolean,
1830
+ sdkRunToken: string,
1519
1831
  ) => void,
1832
+ armPromptDeadline: (correlation: InvocationCorrelation) => void,
1520
1833
  steerReconciliation: KindAwareReconciliation,
1521
1834
  onPromotedTurn?: (
1522
1835
  kind: InvocationKind,
1523
1836
  correlation: InvocationCorrelation,
1524
1837
  connectionId: string | undefined,
1838
+ sdkRunToken: string,
1525
1839
  promotion?: { startsOwnRun?: boolean; removed?: boolean },
1526
1840
  ) => void,
1527
1841
  policy?: SdkSurfacePolicy,
@@ -1543,6 +1857,36 @@ function createControlSurface(
1543
1857
  canResolveGate: () => boolean = () => true,
1544
1858
  trackGateResolution: <T>(resolution: Promise<T>) => Promise<T> = async resolution => await resolution,
1545
1859
  ): ControlSurface {
1860
+ const normalizePromptImages = (value: unknown): ImageContent[] => {
1861
+ if (!Array.isArray(value)) return [];
1862
+ const normalized: ImageContent[] = [];
1863
+ for (const candidate of value) {
1864
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
1865
+ const image = candidate as Record<string, unknown>;
1866
+ if (typeof image.data !== "string" || image.data.trim().length === 0) continue;
1867
+ if (image.mimeType !== undefined && typeof image.mimeType !== "string") continue;
1868
+ normalized.push({
1869
+ type: "image",
1870
+ data: image.data,
1871
+ mimeType: typeof image.mimeType === "string" && image.mimeType.length > 0 ? image.mimeType : "image/png",
1872
+ });
1873
+ }
1874
+ return normalized;
1875
+ };
1876
+ type InternalSendOptions = NonNullable<Parameters<ExtensionAPI["sendUserMessage"]>[1]> & {
1877
+ sdkRunCapability?: SdkRunCapability;
1878
+ };
1879
+ type InternalSdkApi = Omit<ExtensionAPI, "sendUserMessage"> & {
1880
+ sendUserMessage: (
1881
+ content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
1882
+ options?: InternalSendOptions,
1883
+ ) => Promise<void>;
1884
+ };
1885
+ const internalApi = api as InternalSdkApi;
1886
+ const sendSdkUserMessage = (
1887
+ content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
1888
+ options?: Record<string, unknown>,
1889
+ ): Promise<void> => internalApi.sendUserMessage(content, options);
1546
1890
  const surfacePolicy =
1547
1891
  policy ?? createSdkSurfacePolicyForContext(ctx, hasSdkWorkflowGateCapability(ctx.workflowGate));
1548
1892
  const typed = (operation: string, input: Record<string, unknown> = {}) =>
@@ -1627,6 +1971,7 @@ function createControlSurface(
1627
1971
  kind: InvocationKind,
1628
1972
  clientRef: string | undefined,
1629
1973
  run: (options: {
1974
+ sdkRunCapability: SdkRunCapability;
1630
1975
  onPreflightAccepted: () => void;
1631
1976
  onPreflightAcceptCommit: () => Promise<void>;
1632
1977
  /** Internal disposition before a queued submission is actually consumed. */
@@ -1646,6 +1991,8 @@ function createControlSurface(
1646
1991
  const retainedClientRef = normalizeClientRef(clientRef);
1647
1992
  reconciliation.admit(kind, retainedClientRef);
1648
1993
  const correlation = newCorrelation();
1994
+ const sdkRunToken = `${correlation.commandId}:${correlation.turnId}`;
1995
+ const sdkRunCapability = createSdkRunCapability(sdkRunToken);
1649
1996
  const preflight = Promise.withResolvers<void>();
1650
1997
  let accepted = false;
1651
1998
  let settled = false;
@@ -1669,10 +2016,11 @@ function createControlSurface(
1669
2016
  await reconciliation.noteAccepted(kind, correlation, retainedClientRef);
1670
2017
  accepted = true;
1671
2018
  settled = true;
2019
+ if (kind === "prompt" && startsOwnTurn) armPromptDeadline(correlation);
1672
2020
  // The accepted submission does NOT own the active turn until its run
1673
2021
  // actually STARTS: the connection is carried on the pending entry and
1674
2022
  // associated at agent_start instead (review thread P1).
1675
- onAccepted(kind, correlation, requesterConnectionId, startsOwnTurn);
2023
+ onAccepted(kind, correlation, requesterConnectionId, startsOwnTurn, sdkRunToken);
1676
2024
  preflight.resolve();
1677
2025
  } catch (error) {
1678
2026
  settled = true;
@@ -1704,6 +2052,7 @@ function createControlSurface(
1704
2052
  const submission = Promise.resolve(
1705
2053
  run({
1706
2054
  onPreflightAccepted: () => void accept().catch(() => undefined),
2055
+ sdkRunCapability,
1707
2056
  onPreflightAcceptCommit: accept,
1708
2057
  onDispatchDisposition: promotion => {
1709
2058
  promotionStartsOwnRun = promotion.startsOwnRun;
@@ -1724,7 +2073,7 @@ function createControlSurface(
1724
2073
  // terminal-abort that turn (review threads P1/P2).
1725
2074
  onQueuedPromoted: (promotion?: { startsOwnRun?: boolean; removed?: boolean }) => {
1726
2075
  promotionStartsOwnRun = promotion?.startsOwnRun;
1727
- onPromotedTurn?.(kind, correlation, requesterConnectionId, promotion);
2076
+ onPromotedTurn?.(kind, correlation, requesterConnectionId, sdkRunToken, promotion);
1728
2077
  },
1729
2078
  queuedAtDispatch,
1730
2079
  }),
@@ -1768,7 +2117,7 @@ function createControlSurface(
1768
2117
  });
1769
2118
  retirePendingOwner?.(kind, correlation, "always");
1770
2119
  } catch (transitionError) {
1771
- if (kind === "prompt") {
2120
+ if (kind === "prompt" || kind === "skill") {
1772
2121
  retirePendingOwner?.(kind, correlation, "recover-terminal");
1773
2122
  return;
1774
2123
  }
@@ -1830,7 +2179,7 @@ function createControlSurface(
1830
2179
  try {
1831
2180
  await reconciliation.noteTransition(kind, correlation, { type: "agent_failed", error });
1832
2181
  } catch {
1833
- if (kind === "prompt") {
2182
+ if (kind === "prompt" || kind === "skill") {
1834
2183
  retirePendingOwner?.(
1835
2184
  kind,
1836
2185
  correlation,
@@ -1863,7 +2212,7 @@ function createControlSurface(
1863
2212
  try {
1864
2213
  await reconciliation.noteTransition(kind, correlation, { type: "agent_end" });
1865
2214
  } catch (transitionError) {
1866
- if (kind === "prompt") {
2215
+ if (kind === "prompt" || kind === "skill") {
1867
2216
  retirePendingOwner?.(
1868
2217
  kind,
1869
2218
  correlation,
@@ -2783,48 +3132,77 @@ function createControlSurface(
2783
3132
  }
2784
3133
  };
2785
3134
  return {
2786
- prompt: async (text, images, clientRef) =>
2787
- submit("prompt", clientRef, ({ queuedAtDispatch, ...options }) =>
2788
- api.sendUserMessage(
2789
- typeof images === "undefined" ? text : ([{ type: "text", text }, ...(images as never[])] as never),
2790
- queuedAtDispatch ? { ...options, queuedAtDispatch: true } : options,
3135
+ prompt: async (text, images, clientRef) => {
3136
+ const invalid = validateRequiredPromptText("turn.prompt", { text, images });
3137
+ if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code });
3138
+ return await submit("prompt", clientRef, ({ queuedAtDispatch, sdkRunCapability, ...options }) =>
3139
+ sendSdkUserMessage(
3140
+ typeof images === "undefined"
3141
+ ? text
3142
+ : ([{ type: "text", text }, ...normalizePromptImages(images)] as [
3143
+ { type: "text"; text: string },
3144
+ ...ImageContent[],
3145
+ ]),
3146
+ {
3147
+ ...options,
3148
+ sdkRunCapability,
3149
+ // ACP terminal settlement is owned by the correlated agent_end
3150
+ // publication. Post-prompt recovery may include independent
3151
+ // subagent work and must not hold that client-facing boundary.
3152
+ ...(queuedAtDispatch ? { queuedAtDispatch: true } : {}),
3153
+ },
2791
3154
  ),
2792
- ),
3155
+ );
3156
+ },
2793
3157
  steer: async (text, clientRef) => {
3158
+ const invalid = validateRequiredPromptText("turn.steer", { text });
3159
+ if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code });
2794
3160
  const retainedClientRef = normalizeClientRef(clientRef);
2795
3161
  if (retainedClientRef === undefined) {
2796
3162
  const correlation = newCorrelation();
2797
- await api.sendUserMessage(text, { deliverAs: "steer" });
3163
+ await sendSdkUserMessage(text, { deliverAs: "steer" });
2798
3164
  return { accepted: true, ...correlation };
2799
3165
  }
2800
3166
  const durable = steerReconciliation;
2801
3167
  const reservation = await durable.reserveSteer(retainedClientRef, text);
2802
3168
  if (reservation.replay) return { accepted: reservation.result.status === "accepted", ...reservation.result };
2803
3169
  try {
2804
- await api.sendUserMessage(text, { deliverAs: "steer" });
3170
+ await sendSdkUserMessage(text, { deliverAs: "steer" });
2805
3171
  return { accepted: true, ...(await durable.settleSteer(retainedClientRef, "accepted")) };
2806
3172
  } catch (error) {
2807
3173
  return { accepted: false, ...(await durable.settleSteer(retainedClientRef, "rejected", error)) };
2808
3174
  }
2809
3175
  },
2810
- followUp: async text =>
2811
- submit(
3176
+ followUp: async text => {
3177
+ const invalid = validateRequiredPromptText("turn.follow_up", { text });
3178
+ if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code });
3179
+ return await submit(
2812
3180
  "prompt",
2813
3181
  undefined,
2814
- options => api.sendUserMessage(text, { ...options, deliverAs: "followUp" }),
3182
+ ({ sdkRunCapability, ...options }) =>
3183
+ sendSdkUserMessage(text, {
3184
+ ...options,
3185
+ sdkRunCapability,
3186
+ deliverAs: "followUp",
3187
+ }),
2815
3188
  undefined,
2816
3189
  false,
2817
3190
  // Follow-ups never start inline; ownership correlates at promotion.
2818
3191
  true,
2819
- ),
3192
+ );
3193
+ },
2820
3194
  abort: async () => {
2821
3195
  await Promise.resolve(ctx.abort()).catch(() => undefined);
2822
3196
  return { aborted: true };
2823
3197
  },
2824
3198
  abortTerminal: terminalAbort,
2825
3199
  abortAndPrompt: async text => {
3200
+ const invalid = validateRequiredPromptText("turn.abort_and_prompt", { text });
3201
+ if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code });
2826
3202
  await ctx.abort();
2827
- return await submit("prompt", undefined, options => api.sendUserMessage(text, options));
3203
+ return await submit("prompt", undefined, ({ sdkRunCapability, ...options }) =>
3204
+ sendSdkUserMessage(text, { ...options, sdkRunCapability }),
3205
+ );
2828
3206
  },
2829
3207
  answerAsk: unavailable("ask.answer"),
2830
3208
  answerGate: async (id, response, expectedSessionId, idempotencyKey) =>
@@ -2851,9 +3229,10 @@ function createControlSurface(
2851
3229
  return await submit(
2852
3230
  "skill",
2853
3231
  clientRef,
2854
- options =>
3232
+ ({ sdkRunCapability, ...options }) =>
2855
3233
  ctx.invokeSkill!(name, args, {
2856
3234
  ...options,
3235
+ sdkRunCapability,
2857
3236
  onSkillPrepared: meta => {
2858
3237
  prepared = meta;
2859
3238
  },
@@ -3033,10 +3412,28 @@ export function registerSdkOnlyNotificationCommand(api: ExtensionAPI): void {
3033
3412
  });
3034
3413
  }
3035
3414
 
3415
+ function quiescingFrame(frame: Record<string, unknown>): Record<string, unknown> | undefined {
3416
+ const error = { code: "session_quiescing", message: "The session endpoint is being replaced." };
3417
+ const id = typeof frame.id === "string" ? frame.id : "";
3418
+ if (frame.type === "control_request") return { type: "control_response", id, ok: false, error };
3419
+ if (frame.type === "query_request") return { type: "query_response", id, ok: false, error };
3420
+ if (frame.type === "event_replay") return { type: "event_replay_result", id, ok: false, error };
3421
+ if (frame.type === "session_activate")
3422
+ return { type: "session_activate_result", id, ok: false, status: "authority_unavailable", error };
3423
+ if (frame.type === "register_provider") return { type: "register_provider_result", id, ok: false, error };
3424
+ if (frame.type === "reverse_response")
3425
+ return { type: "transport_error", id, code: error.code, message: error.message };
3426
+ if (frame.type === "provider_heartbeat" || frame.type === "lease_release")
3427
+ return { type: "transport_error", code: "session_quiescing", message: error.message };
3428
+ return undefined;
3429
+ }
3430
+
3036
3431
  /** Install a complete SDK host for a session when notifications are inactive. */
3037
3432
  export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {
3038
3433
  let active:
3039
3434
  | {
3435
+ sessionId: string;
3436
+ sessionIdentity: string;
3040
3437
  runtime: SessionSdkSessionRuntime;
3041
3438
  revisions: RevisionStore;
3042
3439
  cursors: CursorRegistry;
@@ -3047,8 +3444,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3047
3444
  kind: InvocationKind;
3048
3445
  correlation: InvocationCorrelation;
3049
3446
  connectionId: string | undefined;
3447
+ sdkRunToken: string;
3050
3448
  }>;
3051
3449
  registerBroker: () => Promise<void>;
3450
+ quiesceInput: () => void;
3052
3451
  fenceGateResolutions: () => void;
3053
3452
  waitForGateResolutionQuiescence: () => Promise<void>;
3054
3453
  activeInvocation?: { kind: InvocationKind; correlation: InvocationCorrelation };
@@ -3060,18 +3459,163 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3060
3459
  kind: InvocationKind;
3061
3460
  correlation: InvocationCorrelation;
3062
3461
  connectionId: string | undefined;
3462
+ sdkRunToken: string;
3063
3463
  }>;
3064
3464
  attachedInvocations: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
3065
3465
  }>;
3066
3466
  disposeGate?: () => void;
3067
3467
  lifecycleActive: boolean;
3068
3468
  lifecycleEpoch: number;
3469
+ failureDiagnosticKeys: Set<string>;
3470
+ failureDiagnosticCodes: Map<string, string>;
3471
+ lifecycleTasks: Set<Promise<void>>;
3069
3472
  /** Failure reasons whose durable agent_failed write failed; the
3070
3473
  * subsequent agent_end must re-record them before terminalizing or
3071
3474
  * the record classifies terminal_ok (exact-head review P1). */
3072
3475
  unrecordedFailureReasons?: Map<string, { code: string; message: string }>;
3073
3476
  }
3074
3477
  | undefined;
3478
+ type RuntimeState = NonNullable<typeof active>;
3479
+ type LifecycleBatch = RuntimeState["openLifecycleBatches"][number];
3480
+ type LifecycleOwner = { state: RuntimeState; sessionId: string; batch?: LifecycleBatch };
3481
+ const retiredLifecycleOwners = new Map<string, RuntimeState[]>();
3482
+ const retiredLifecycleOwnerTimers = new Map<RuntimeState, ReturnType<typeof setTimeout>>();
3483
+ const skillRecoveryControllers = new Map<string, AbortController>();
3484
+ const skillTerminalRecoveryControllers = new Map<string, AbortController>();
3485
+ const ambiguousLifecycleIdentities = new Set<string>();
3486
+ const lifecycleRunOwners = new Map<
3487
+ string,
3488
+ { state: RuntimeState; batch?: LifecycleBatch; correlationKey?: string }
3489
+ >();
3490
+ const lifecycleCorrelationKey = (correlation: InvocationCorrelation): string =>
3491
+ `${correlation.commandId}:${correlation.turnId}`;
3492
+ const sessionIdentityForContext = (ctx: ExtensionContext): string | undefined => {
3493
+ const sessionId = ctx.sessionManager.getSessionId();
3494
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
3495
+ if (sessionFile) return `${sessionId}\u0000${sessionFile}`;
3496
+ const stateRoot = path.join(ctx.cwd, ".gjc", "state");
3497
+ return `${sessionId}\u0000${resolveReconciliationSessionFile(undefined, stateRoot, sessionId)}`;
3498
+ };
3499
+ const lifecycleStateForContext = (
3500
+ ctx: ExtensionContext,
3501
+ type: "agent_start" | "agent_end" | "agent_failed" = "agent_start",
3502
+ ): RuntimeState | undefined => {
3503
+ const sessionId = ctx.sessionManager.getSessionId();
3504
+ const sessionIdentity = sessionIdentityForContext(ctx);
3505
+ if (!sessionIdentity) return undefined;
3506
+ const retired = (retiredLifecycleOwners.get(sessionId) ?? []).filter(
3507
+ owner => owner.sessionIdentity === sessionIdentity,
3508
+ );
3509
+ if (type !== "agent_start") {
3510
+ if (ambiguousLifecycleIdentities.has(sessionIdentity)) return undefined;
3511
+ if (retired.length > 1) {
3512
+ ambiguousLifecycleIdentities.add(sessionIdentity);
3513
+ return undefined;
3514
+ }
3515
+ const delayedOwner = retired.find(owner => owner.openLifecycleBatches.length > 0);
3516
+ if (delayedOwner) {
3517
+ if (active?.sessionIdentity === sessionIdentity && active.lifecycleActive) {
3518
+ ambiguousLifecycleIdentities.add(sessionIdentity);
3519
+ return undefined;
3520
+ }
3521
+ return delayedOwner;
3522
+ }
3523
+ }
3524
+ if (active?.sessionIdentity === sessionIdentity) return active;
3525
+ return retired[0];
3526
+ };
3527
+ const lifecycleStateForEvent = (
3528
+ ctx: ExtensionContext,
3529
+ type: "agent_start" | "agent_end" | "agent_failed",
3530
+ sdkRunToken: unknown,
3531
+ ): RuntimeState | undefined =>
3532
+ typeof sdkRunToken === "string" && sdkRunToken.length > 0
3533
+ ? lifecycleRunOwners.get(sdkRunToken)?.state
3534
+ : lifecycleStateForContext(ctx, type);
3535
+ const removeRetiredLifecycleOwner = (owner: RuntimeState): void => {
3536
+ const timer = retiredLifecycleOwnerTimers.get(owner);
3537
+ if (timer !== undefined) {
3538
+ clearTimeout(timer);
3539
+ retiredLifecycleOwnerTimers.delete(owner);
3540
+ }
3541
+ const owners = retiredLifecycleOwners.get(owner.sessionId);
3542
+ if (!owners) {
3543
+ ambiguousLifecycleIdentities.delete(owner.sessionIdentity);
3544
+ return;
3545
+ }
3546
+ const remaining = owners.filter(candidate => candidate !== owner);
3547
+ if (remaining.length === 0) retiredLifecycleOwners.delete(owner.sessionId);
3548
+ else retiredLifecycleOwners.set(owner.sessionId, remaining);
3549
+ const remainingSameIdentity = remaining.filter(candidate => candidate.sessionIdentity === owner.sessionIdentity);
3550
+ if (remainingSameIdentity.length < 2) ambiguousLifecycleIdentities.delete(owner.sessionIdentity);
3551
+ // Expiry is the final lifecycle-reference boundary. Do not retain
3552
+ // diagnostic suppression or failed-write recovery state after the owner is
3553
+ // no longer eligible to receive a delayed event.
3554
+ owner.failureDiagnosticKeys.clear();
3555
+ owner.failureDiagnosticCodes.clear();
3556
+ owner.unrecordedFailureReasons?.clear();
3557
+ for (const [token, binding] of lifecycleRunOwners) if (binding.state === owner) lifecycleRunOwners.delete(token);
3558
+ };
3559
+ const maybeRetireLifecycleOwner = (owner: RuntimeState): void => {
3560
+ if (
3561
+ owner.pending.length > 0 ||
3562
+ owner.openLifecycleBatches.length > 0 ||
3563
+ (owner.attachedInvocations?.length ?? 0) > 0 ||
3564
+ (owner.drainedInvocations?.length ?? 0) > 0 ||
3565
+ owner.lifecycleTasks.size > 0
3566
+ )
3567
+ return;
3568
+ removeRetiredLifecycleOwner(owner);
3569
+ };
3570
+ const removeLifecycleTokenAliases = (owner: RuntimeState, correlation: InvocationCorrelation): void => {
3571
+ const target = lifecycleCorrelationKey(correlation);
3572
+ for (const [token, binding] of lifecycleRunOwners) {
3573
+ if (binding.state === owner && binding.correlationKey === target) lifecycleRunOwners.delete(token);
3574
+ }
3575
+ };
3576
+ const removeLifecycleReferences = (owner: RuntimeState, correlation: InvocationCorrelation): void => {
3577
+ const target = lifecycleCorrelationKey(correlation);
3578
+ const retainedPending = owner.pending.filter(entry => lifecycleCorrelationKey(entry.correlation) !== target);
3579
+ owner.pending.splice(0, owner.pending.length, ...retainedPending);
3580
+ for (const batch of owner.openLifecycleBatches) {
3581
+ batch.invocations = batch.invocations.filter(entry => lifecycleCorrelationKey(entry.correlation) !== target);
3582
+ batch.attachedInvocations = batch.attachedInvocations.filter(
3583
+ entry => lifecycleCorrelationKey(entry.correlation) !== target,
3584
+ );
3585
+ }
3586
+ owner.openLifecycleBatches = owner.openLifecycleBatches.filter(
3587
+ batch => batch.invocations.length > 0 || batch.attachedInvocations.length > 0,
3588
+ );
3589
+ owner.drainedInvocations = owner.drainedInvocations?.filter(
3590
+ entry => lifecycleCorrelationKey(entry.correlation) !== target,
3591
+ );
3592
+ owner.attachedInvocations = owner.attachedInvocations?.filter(
3593
+ entry => lifecycleCorrelationKey(entry.correlation) !== target,
3594
+ );
3595
+ if (owner.activeInvocation && lifecycleCorrelationKey(owner.activeInvocation.correlation) === target) {
3596
+ const replacement =
3597
+ owner.openLifecycleBatches.flatMap(batch => batch.invocations)[0] ?? owner.attachedInvocations?.[0];
3598
+ owner.activeInvocation = replacement
3599
+ ? { kind: replacement.kind, correlation: replacement.correlation }
3600
+ : undefined;
3601
+ }
3602
+ removeLifecycleTokenAliases(owner, correlation);
3603
+ if (owner === active) {
3604
+ const owners = new Set<string>();
3605
+ const activeBatch = owner.activeInvocation
3606
+ ? owner.openLifecycleBatches.find(batch =>
3607
+ batch.invocations.some(
3608
+ entry =>
3609
+ lifecycleCorrelationKey(entry.correlation) ===
3610
+ lifecycleCorrelationKey(owner.activeInvocation!.correlation),
3611
+ ),
3612
+ )
3613
+ : undefined;
3614
+ for (const entry of activeBatch?.invocations ?? [])
3615
+ if (entry.connectionId !== undefined) owners.add(entry.connectionId);
3616
+ activePromptOwnerHolder.connectionIds = owners.size > 0 ? owners : undefined;
3617
+ }
3618
+ };
3075
3619
  // Shared with the control surface's terminal abort: the correlated
3076
3620
  // agent_end publication capture (AC 19). terminalAbort installs a fresh
3077
3621
  // resolver before settling the abort; emitLifecycle resolves it with the
@@ -3089,15 +3633,31 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3089
3633
  // turn it did not submit, and never set for agent-initiated turns (review
3090
3634
  // thread P1).
3091
3635
  const activePromptOwnerHolder: { connectionIds?: Set<string>; lifecycleEpoch?: number } = {};
3636
+ let nextLifecycleEpoch = 0;
3092
3637
  const skillTerminalRecoveryKeys = new Set<string>();
3638
+ const trackLifecycle = (handler: () => Promise<void>, owner: RuntimeState | undefined): Promise<void> => {
3639
+ if (!owner) return Promise.resolve();
3640
+ let task: Promise<void>;
3641
+ task = handler().finally(() => {
3642
+ owner.lifecycleTasks.delete(task);
3643
+ maybeRetireLifecycleOwner(owner);
3644
+ });
3645
+ owner.lifecycleTasks.add(task);
3646
+ return task;
3647
+ };
3093
3648
  const emitLifecycle = async (
3094
3649
  type: "agent_start" | "agent_end" | "agent_failed",
3095
3650
  ctx: ExtensionContext,
3096
3651
  failureCause?: unknown,
3097
3652
  maintenanceOutcome?: string,
3653
+ lifecycleOwner?: LifecycleOwner,
3654
+ terminalContent?: TurnResultContent,
3655
+ terminalHasActivity = false,
3656
+ terminalOutcome?: { kind: "stopped"; reason: "cancelled"; provenance: "client_cancel" },
3098
3657
  ): Promise<void> => {
3099
- const current = active;
3658
+ const current = lifecycleOwner?.state ?? active;
3100
3659
  if (!current) return;
3660
+ const failureBatch = lifecycleOwner?.batch;
3101
3661
  const adoptLifecycleBatch = (
3102
3662
  batch:
3103
3663
  | Array<{
@@ -3111,7 +3671,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3111
3671
  current.activeInvocation = undefined;
3112
3672
  current.drainedInvocations = undefined;
3113
3673
  current.attachedInvocations = undefined;
3114
- activePromptOwnerHolder.connectionIds = undefined;
3674
+ if (current === active) activePromptOwnerHolder.connectionIds = undefined;
3115
3675
  return;
3116
3676
  }
3117
3677
  current.activeInvocation = batch[0];
@@ -3119,19 +3679,20 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3119
3679
  current.attachedInvocations = undefined;
3120
3680
  const owners = new Set<string>();
3121
3681
  for (const entry of batch) if (entry.connectionId !== undefined) owners.add(entry.connectionId);
3122
- activePromptOwnerHolder.connectionIds = owners;
3682
+ if (current === active) activePromptOwnerHolder.connectionIds = owners;
3123
3683
  };
3124
3684
  let transitions: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }> = [];
3125
3685
  if (type === "agent_failed") {
3126
- const activeInvocation = current.activeInvocation;
3686
+ const activeInvocation = failureBatch?.invocations[0] ?? current.activeInvocation;
3127
3687
  const activeBatch = activeInvocation
3128
- ? current.openLifecycleBatches.find(batch =>
3688
+ ? (failureBatch ??
3689
+ current.openLifecycleBatches.find(batch =>
3129
3690
  batch.invocations.some(
3130
3691
  entry =>
3131
3692
  entry.correlation.commandId === activeInvocation.correlation.commandId &&
3132
3693
  entry.correlation.turnId === activeInvocation.correlation.turnId,
3133
3694
  ),
3134
- )
3695
+ ))
3135
3696
  : undefined;
3136
3697
  const fallback =
3137
3698
  activeBatch?.invocations ??
@@ -3152,8 +3713,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3152
3713
  );
3153
3714
  transitions = [...fallback, ...attached].map(({ kind, correlation }) => ({ kind, correlation }));
3154
3715
  } else if (type === "agent_start") {
3155
- current.lifecycleEpoch += 1;
3156
- activePromptOwnerHolder.lifecycleEpoch = current.lifecycleEpoch;
3716
+ current.lifecycleEpoch = ++nextLifecycleEpoch;
3717
+ if (current === active) activePromptOwnerHolder.lifecycleEpoch = current.lifecycleEpoch;
3157
3718
  // Mark lifecycle active even when the drain is empty: a monitor/cron
3158
3719
  // run started by the session has no SDK pending entry but is still a
3159
3720
  // real active run that later in-run promotions must attach to instead
@@ -3180,6 +3741,11 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3180
3741
  if (current.activeInvocation?.kind === "prompt") {
3181
3742
  current.deadlineManager.onAccepted(current.activeInvocation.correlation);
3182
3743
  }
3744
+ } else {
3745
+ // An empty drain is an agent-initiated successor run. Clear the
3746
+ // predecessor's SDK owner and active invocation so abort ownership and
3747
+ // tool-progress renewal cannot leak into the successor.
3748
+ adoptLifecycleBatch(undefined);
3183
3749
  }
3184
3750
  transitions = drained.map(({ kind, correlation }) => ({ kind, correlation }));
3185
3751
  } else {
@@ -3211,8 +3777,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3211
3777
  // may already have advanced to. Publication proof and race retirement must
3212
3778
  // use the same batch identity as reconciliation.
3213
3779
  const eventLifecycleEpoch =
3214
- type === "agent_end"
3215
- ? (current.openLifecycleBatches[0]?.epoch ?? current.lifecycleEpoch)
3780
+ (type === "agent_failed" || type === "agent_end") && failureBatch
3781
+ ? failureBatch.epoch
3216
3782
  : current.lifecycleEpoch;
3217
3783
  const resolveTerminalPublicationWaiters = (observed: boolean): void => {
3218
3784
  const waiters = terminalPublicationCapture.waiters;
@@ -3223,17 +3789,19 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3223
3789
  for (const waiter of matched) waiter.resolve(observed);
3224
3790
  };
3225
3791
  const retireEndedLifecycleBatch = (): void => {
3226
- const ended = current.openLifecycleBatches[0];
3792
+ const ended = failureBatch ?? current.openLifecycleBatches[0];
3227
3793
  if (!ended) return;
3228
3794
  const transitionKeys = new Set(
3229
3795
  transitions.map(({ correlation }) => `${correlation.commandId}:${correlation.turnId}`),
3230
3796
  );
3231
3797
  if (
3232
- ended.invocations.some(({ correlation }) =>
3798
+ !ended.invocations.some(({ correlation }) =>
3233
3799
  transitionKeys.has(`${correlation.commandId}:${correlation.turnId}`),
3234
3800
  )
3235
3801
  )
3236
- current.openLifecycleBatches.shift();
3802
+ return;
3803
+ const index = current.openLifecycleBatches.indexOf(ended);
3804
+ if (index >= 0) current.openLifecycleBatches.splice(index, 1);
3237
3805
  };
3238
3806
  const scheduleSkillTerminalRecovery = (invocation: {
3239
3807
  kind: InvocationKind;
@@ -3243,6 +3811,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3243
3811
  const recoveryKey = `${invocation.correlation.commandId}:${invocation.correlation.turnId}`;
3244
3812
  if (skillTerminalRecoveryKeys.has(recoveryKey)) return;
3245
3813
  skillTerminalRecoveryKeys.add(recoveryKey);
3814
+ const controller = new AbortController();
3815
+ skillTerminalRecoveryControllers.set(recoveryKey, controller);
3246
3816
  const attempt = async (): Promise<void> => {
3247
3817
  try {
3248
3818
  const unrecorded = current.unrecordedFailureReasons?.get(recoveryKey);
@@ -3255,6 +3825,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3255
3825
  }
3256
3826
  await current.reconciliation.noteTransition("skill", invocation.correlation, { type: "agent_end" });
3257
3827
  skillTerminalRecoveryKeys.delete(recoveryKey);
3828
+ current.failureDiagnosticKeys.delete(recoveryKey);
3258
3829
  for (const batch of current.openLifecycleBatches) {
3259
3830
  batch.invocations = batch.invocations.filter(
3260
3831
  entry =>
@@ -3277,16 +3848,33 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3277
3848
  adoptLifecycleBatch(current.openLifecycleBatches[0]?.invocations);
3278
3849
  if (current.openLifecycleBatches.length === 0) current.lifecycleActive = false;
3279
3850
  } catch (error) {
3851
+ if (controller.signal.aborted) {
3852
+ skillTerminalRecoveryKeys.delete(recoveryKey);
3853
+ return;
3854
+ }
3280
3855
  logger.error("SDK skill lifecycle terminal recovery retrying", {
3281
3856
  commandId: invocation.correlation.commandId,
3282
3857
  turnId: invocation.correlation.turnId,
3283
3858
  error: sanitizePromptFailure(error),
3284
3859
  });
3285
- await Bun.sleep(1_000);
3860
+ const wait = Promise.withResolvers<void>();
3861
+ const timer = setTimeout(wait.resolve, 1_000);
3862
+ timer.unref();
3863
+ const onAbort = () => {
3864
+ clearTimeout(timer);
3865
+ wait.resolve();
3866
+ };
3867
+ controller.signal.addEventListener("abort", onAbort, { once: true });
3868
+ await wait.promise;
3869
+ controller.signal.removeEventListener("abort", onAbort);
3870
+ if (controller.signal.aborted) {
3871
+ skillTerminalRecoveryKeys.delete(recoveryKey);
3872
+ return;
3873
+ }
3286
3874
  return attempt();
3287
3875
  }
3288
3876
  };
3289
- void attempt();
3877
+ void attempt().finally(() => skillTerminalRecoveryControllers.delete(recoveryKey));
3290
3878
  };
3291
3879
  if (type === "agent_end" && maintenanceOutcome !== undefined && maintenanceOutcome !== "aborted") {
3292
3880
  try {
@@ -3296,6 +3884,20 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3296
3884
  }
3297
3885
  return;
3298
3886
  }
3887
+ const correlationKey = (correlation: InvocationCorrelation): string =>
3888
+ `${correlation.commandId}:${correlation.turnId}`;
3889
+ if (type === "agent_failed") {
3890
+ transitions = transitions.filter(
3891
+ invocation => !current.failureDiagnosticKeys.has(correlationKey(invocation.correlation)),
3892
+ );
3893
+ const diagnosticCode = sanitizePromptFailure(
3894
+ failureCause ?? Object.assign(new Error("agent run failed"), { code: "agent_failed" }),
3895
+ ).code;
3896
+ for (const invocation of transitions)
3897
+ current.failureDiagnosticKeys.add(correlationKey(invocation.correlation));
3898
+ for (const invocation of transitions)
3899
+ current.failureDiagnosticCodes.set(correlationKey(invocation.correlation), diagnosticCode);
3900
+ }
3299
3901
  // Observe whether the lifecycle publication actually landed: a terminal
3300
3902
  // abort awaits this result so its durable row only claims
3301
3903
  // terminalPublished when the correlated agent_end event reached the
@@ -3309,7 +3911,17 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3309
3911
  const reasonKey = `${invocation.correlation.commandId}:${invocation.correlation.turnId}`;
3310
3912
  const unrecorded = type === "agent_end" ? current.unrecordedFailureReasons?.get(reasonKey) : undefined;
3311
3913
  if (type === "agent_end" && invocation.kind === "prompt")
3312
- current.deadlineManager.noteTerminalTransition(invocation.correlation, unrecorded);
3914
+ current.deadlineManager.noteTerminalTransition(
3915
+ invocation.correlation,
3916
+ unrecorded,
3917
+ terminalContent !== undefined || terminalHasActivity || terminalOutcome !== undefined
3918
+ ? ({
3919
+ content: terminalContent,
3920
+ hasActivity: terminalHasActivity,
3921
+ outcome: terminalOutcome,
3922
+ } satisfies PromptTerminalTransitionEvidence)
3923
+ : undefined,
3924
+ );
3313
3925
  if (type === "agent_end") {
3314
3926
  // Compound recovery (exact-head review P1): if this run's
3315
3927
  // agent_failed write failed, re-record the sanitized reason
@@ -3333,7 +3945,16 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3333
3945
  error:
3334
3946
  failureCause ?? Object.assign(new Error("agent run failed"), { code: "agent_failed" }),
3335
3947
  }
3336
- : { type };
3948
+ : {
3949
+ type,
3950
+ ...(type === "agent_end" && terminalContent !== undefined
3951
+ ? { content: terminalContent }
3952
+ : {}),
3953
+ ...(type === "agent_end" && terminalHasActivity ? { hasActivity: true } : {}),
3954
+ ...(type === "agent_end" && terminalOutcome !== undefined
3955
+ ? { outcome: terminalOutcome }
3956
+ : {}),
3957
+ };
3337
3958
  await current.reconciliation.noteTransition(invocation.kind, invocation.correlation, frame as never);
3338
3959
  if ((type as string) === "agent_end") {
3339
3960
  if (invocation.kind === "prompt") current.deadlineManager.clear(invocation.correlation);
@@ -3356,7 +3977,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3356
3977
  }
3357
3978
  }
3358
3979
  }
3359
- const sessionId = ctx.sessionManager.getSessionId();
3980
+ const sessionId = lifecycleOwner?.sessionId ?? ctx.sessionManager.getSessionId();
3360
3981
  if (type === "agent_failed") {
3361
3982
  // Failure is a correlated diagnostic, not the terminal lifecycle boundary.
3362
3983
  // Publish one safe frame per invocation so clients can attribute a shared
@@ -3395,6 +4016,16 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3395
4016
  // and the next agent_end is paired with stale ownership.
3396
4017
  for (const invocation of failedTransitions) scheduleSkillTerminalRecovery(invocation);
3397
4018
  retireEndedLifecycleBatch();
4019
+ const failedKeys = new Set(failedTransitions.map(({ correlation }) => correlationKey(correlation)));
4020
+ for (const invocation of transitions) {
4021
+ if (!failedKeys.has(correlationKey(invocation.correlation)))
4022
+ removeLifecycleReferences(current, invocation.correlation);
4023
+ if (!failedKeys.has(correlationKey(invocation.correlation)))
4024
+ current.failureDiagnosticKeys.delete(correlationKey(invocation.correlation));
4025
+ if (!failedKeys.has(correlationKey(invocation.correlation)))
4026
+ current.failureDiagnosticCodes.delete(correlationKey(invocation.correlation));
4027
+ }
4028
+ options.onFailureDiagnosticKeyCountForTests?.(current.failureDiagnosticKeys.size);
3398
4029
  resolveTerminalPublicationWaiters(observed);
3399
4030
  return;
3400
4031
  }
@@ -3410,7 +4041,17 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3410
4041
  resolveTerminalPublicationWaiters(observed);
3411
4042
  return;
3412
4043
  }
3413
- if (current.openLifecycleBatches.length > 0) current.openLifecycleBatches.shift();
4044
+ const ended = failureBatch ?? current.openLifecycleBatches[0];
4045
+ if (ended) {
4046
+ const index = current.openLifecycleBatches.indexOf(ended);
4047
+ if (index >= 0) current.openLifecycleBatches.splice(index, 1);
4048
+ }
4049
+ for (const invocation of transitions) removeLifecycleReferences(current, invocation.correlation);
4050
+ for (const invocation of transitions)
4051
+ current.failureDiagnosticKeys.delete(correlationKey(invocation.correlation));
4052
+ for (const invocation of transitions)
4053
+ current.failureDiagnosticCodes.delete(correlationKey(invocation.correlation));
4054
+ options.onFailureDiagnosticKeyCountForTests?.(current.failureDiagnosticKeys.size);
3414
4055
  for (const invocation of transitions)
3415
4056
  if (invocation.kind === "prompt") current.deadlineManager.clear(invocation.correlation);
3416
4057
  adoptLifecycleBatch(current.openLifecycleBatches[0]?.invocations);
@@ -3422,33 +4063,130 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3422
4063
  resolveTerminalPublicationWaiters(observed);
3423
4064
  }
3424
4065
  };
3425
- api.on("agent_start", async (_event, ctx) => await emitLifecycle("agent_start", ctx));
3426
- api.on(
3427
- "agent_end",
3428
- async (event, ctx) =>
4066
+ api.on("agent_start", (event, ctx) => {
4067
+ const owner = lifecycleStateForEvent(ctx, "agent_start", event.sdkRunToken);
4068
+ return trackLifecycle(async () => {
4069
+ await emitLifecycle(
4070
+ "agent_start",
4071
+ ctx,
4072
+ undefined,
4073
+ undefined,
4074
+ owner ? { state: owner, sessionId: owner.sessionId } : undefined,
4075
+ );
4076
+ if (owner && typeof event.sdkRunToken === "string" && event.sdkRunToken.length > 0) {
4077
+ const batch = owner.openLifecycleBatches.at(-1);
4078
+ const entry = batch?.invocations.find(item => item.sdkRunToken === event.sdkRunToken);
4079
+ const previous = lifecycleRunOwners.get(event.sdkRunToken);
4080
+ lifecycleRunOwners.set(event.sdkRunToken, {
4081
+ state: owner,
4082
+ batch,
4083
+ ...(previous?.correlationKey !== undefined
4084
+ ? { correlationKey: previous.correlationKey }
4085
+ : entry
4086
+ ? { correlationKey: lifecycleCorrelationKey(entry.correlation) }
4087
+ : {}),
4088
+ });
4089
+ }
4090
+ }, owner);
4091
+ });
4092
+ api.on("agent_end", (event, ctx) => {
4093
+ const tokenBinding =
4094
+ typeof event.sdkRunToken === "string" ? lifecycleRunOwners.get(event.sdkRunToken) : undefined;
4095
+ const owner = tokenBinding?.state ?? lifecycleStateForEvent(ctx, "agent_end", event.sdkRunToken);
4096
+ // Capture the oldest unmatched batch synchronously. A successor may start
4097
+ // while the failed diagnostic persists; that must not retarget the
4098
+ // predecessor's reason or terminal boundary to the successor invocation.
4099
+ const endedBatch = tokenBinding?.batch ?? owner?.openLifecycleBatches[0];
4100
+ const lifecycleOwner = owner
4101
+ ? {
4102
+ state: owner,
4103
+ sessionId: owner.sessionId,
4104
+ ...(endedBatch ? { batch: endedBatch } : {}),
4105
+ }
4106
+ : undefined;
4107
+ const currentInvocation = endedBatch?.invocations[0] ?? owner?.activeInvocation;
4108
+ const failure = providerFailureFromAgentEnd(event);
4109
+ const failureCandidates = endedBatch
4110
+ ? [...endedBatch.invocations, ...endedBatch.attachedInvocations]
4111
+ : currentInvocation
4112
+ ? [currentInvocation]
4113
+ : [];
4114
+ const genericFailureKeys = failureCandidates
4115
+ .map(({ correlation }) => lifecycleCorrelationKey(correlation))
4116
+ .filter(key => owner?.failureDiagnosticCodes.get(key) === "agent_failed");
4117
+ const hasExistingFailure = failureCandidates.some(({ correlation }) =>
4118
+ owner?.failureDiagnosticKeys.has(lifecycleCorrelationKey(correlation)),
4119
+ );
4120
+ const failureAlreadyPublished = failure === undefined || (hasExistingFailure && genericFailureKeys.length === 0);
4121
+ const terminalEvidence = promptTerminalEvidenceFromAgentEnd(event);
4122
+ const terminalOutcome =
4123
+ event.stopReason === "cancelled"
4124
+ ? ({ kind: "stopped", reason: "cancelled", provenance: "client_cancel" } as const)
4125
+ : undefined;
4126
+ return trackLifecycle(async () => {
4127
+ if (failure && !failureAlreadyPublished) {
4128
+ for (const key of genericFailureKeys) owner?.failureDiagnosticKeys.delete(key);
4129
+ await emitLifecycle("agent_failed", ctx, failure, undefined, lifecycleOwner);
4130
+ }
3429
4131
  await emitLifecycle(
3430
4132
  "agent_end",
3431
4133
  ctx,
3432
4134
  undefined,
3433
4135
  event.stopReason === "maintenance" ? event.maintenanceOutcome : undefined,
3434
- ),
3435
- );
3436
- api.on("agent_failed", async (event, ctx) => emitLifecycle("agent_failed", ctx, event.error));
4136
+ lifecycleOwner,
4137
+ terminalEvidence.content,
4138
+ terminalEvidence.hasActivity,
4139
+ terminalOutcome,
4140
+ );
4141
+ }, owner).finally(() => {
4142
+ if (typeof event.sdkRunToken === "string" && lifecycleRunOwners.get(event.sdkRunToken)?.state === owner)
4143
+ lifecycleRunOwners.delete(event.sdkRunToken);
4144
+ });
4145
+ });
4146
+ api.on("agent_failed", (event, ctx) => {
4147
+ const tokenBinding =
4148
+ typeof event.sdkRunToken === "string" ? lifecycleRunOwners.get(event.sdkRunToken) : undefined;
4149
+ const owner = tokenBinding?.state ?? lifecycleStateForEvent(ctx, "agent_failed", event.sdkRunToken);
4150
+ const failedBatch = tokenBinding?.batch ?? owner?.openLifecycleBatches[0];
4151
+ return trackLifecycle(
4152
+ async () =>
4153
+ emitLifecycle(
4154
+ "agent_failed",
4155
+ ctx,
4156
+ event.error,
4157
+ undefined,
4158
+ owner
4159
+ ? {
4160
+ state: owner,
4161
+ sessionId: owner.sessionId,
4162
+ ...(failedBatch ? { batch: failedBatch } : {}),
4163
+ }
4164
+ : undefined,
4165
+ ),
4166
+ owner,
4167
+ );
4168
+ });
3437
4169
  api.on("turn_start", async (_event, ctx) => {
3438
- const current = active;
4170
+ const current = lifecycleStateForContext(ctx, "agent_start");
3439
4171
  if (!current) return;
3440
4172
  await current.registerBroker();
3441
4173
  current.runtime.emitEvent({ type: "turn_start", sessionId: ctx.sessionManager.getSessionId() });
3442
4174
  });
3443
4175
  api.on("turn_end", (_event, ctx) =>
3444
- active?.runtime.emitEvent({ type: "turn_end", sessionId: ctx.sessionManager.getSessionId() }),
4176
+ lifecycleStateForContext(ctx, "agent_end")?.runtime.emitEvent({
4177
+ type: "turn_end",
4178
+ sessionId: ctx.sessionManager.getSessionId(),
4179
+ }),
3445
4180
  );
3446
4181
  // Tool activity renews the deadline of EVERY prompt correlation attached to
3447
4182
  // the active run — the root invocation plus any in-run consumed follow-ups
3448
4183
  // sharing it — not only the head, or an attached correlation would
3449
- // false-fire prompt_deadline_exceeded during a long shared run (#4668).
3450
- const renewAttributableProgress = (eventType: string): void => {
3451
- const current = active;
4184
+ // false-fire prompt_deadline_exceeded during a long shared run.
4185
+ const renewAttributableProgress = (eventType: string, ctx: ExtensionContext): void => {
4186
+ // Tool events do not carry an SDK run token. Prefer the lifecycle-active
4187
+ // runtime for the current session so a retained predecessor cannot make a
4188
+ // live replacement look ambiguous and suppress its lease renewal.
4189
+ const current = lifecycleStateForContext(ctx, "agent_start");
3452
4190
  if (!current) return;
3453
4191
  // Renew only invocations adopted by the CURRENT run: the live lifecycle
3454
4192
  // batch plus in-run attachments. drainedInvocations may still hold
@@ -3488,12 +4226,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3488
4226
  }
3489
4227
  };
3490
4228
  api.on("tool_execution_start", async (_event, ctx) => {
3491
- renewAttributableProgress("tool_execution_start");
3492
- void ctx;
4229
+ renewAttributableProgress("tool_execution_start", ctx);
3493
4230
  });
3494
4231
  api.on("tool_execution_end", async (_event, ctx) => {
3495
- renewAttributableProgress("tool_execution_end");
3496
- void ctx;
4232
+ renewAttributableProgress("tool_execution_end", ctx);
3497
4233
  });
3498
4234
  const errorCode = (error: unknown): string | undefined =>
3499
4235
  typeof error === "object" &&
@@ -3513,6 +4249,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3513
4249
  const sessionFile =
3514
4250
  (typeof ctx.sessionManager.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : undefined) ??
3515
4251
  resolveReconciliationSessionFile(undefined, stateRoot, sessionId);
4252
+ const sessionIdentity = `${sessionId}\u0000${sessionFile}`;
3516
4253
  const reconciliationStore =
3517
4254
  options.terminalAbortSeams?.getReconciliationStore?.() ??
3518
4255
  createReconciliationStore({ sessionFile, sessionId });
@@ -3534,18 +4271,17 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3534
4271
  return typeof v === "number" && Number.isFinite(v) ? v : 21_600_000;
3535
4272
  },
3536
4273
  onExpired: correlation => {
3537
- const idx = pending.findIndex(
3538
- entry =>
3539
- entry.correlation.commandId === correlation.commandId &&
3540
- entry.correlation.turnId === correlation.turnId,
3541
- );
3542
- if (idx >= 0) pending.splice(idx, 1);
4274
+ const owner = lifecycleOwnerHolder.state;
4275
+ if (!owner) return;
4276
+ removeLifecycleReferences(owner, correlation);
4277
+ maybeRetireLifecycleOwner(owner);
3543
4278
  },
3544
4279
  });
3545
4280
  const pending: Array<{
3546
4281
  kind: InvocationKind;
3547
4282
  correlation: InvocationCorrelation;
3548
4283
  connectionId: string | undefined;
4284
+ sdkRunToken: string;
3549
4285
  }> = [];
3550
4286
  for (const { correlation, acceptedAt, deadlineMaxAt } of reconciliation.listDeadlineRecoveryPendingPrompts())
3551
4287
  deadlineManager.recoverPending(correlation, acceptedAt, deadlineMaxAt);
@@ -3555,6 +4291,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3555
4291
  kind: InvocationKind;
3556
4292
  correlation: InvocationCorrelation;
3557
4293
  connectionId: string | undefined;
4294
+ sdkRunToken: string;
3558
4295
  }>;
3559
4296
  attachedInvocations: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
3560
4297
  }> = [];
@@ -3584,6 +4321,60 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3584
4321
  settings: options.settings,
3585
4322
  });
3586
4323
  const queryHandlers = new QueryHandlers(surfaceFactory.query, sessionId, revisions, cursors);
4324
+ const inputGate = { quiescing: false };
4325
+ const lifecycleOwnerHolder: { state?: RuntimeState; quiescing: boolean } = { quiescing: false };
4326
+ const skillRecoveryTasks = new Map<string, Promise<void>>();
4327
+ const scheduleSkillRecovery = (
4328
+ correlation: InvocationCorrelation,
4329
+ failureIntent?: { code: string; message: string },
4330
+ ): void => {
4331
+ const key = lifecycleCorrelationKey(correlation);
4332
+ if (skillRecoveryTasks.has(key)) return;
4333
+ const controller = new AbortController();
4334
+ skillRecoveryControllers.set(key, controller);
4335
+ const intent = failureIntent;
4336
+ const task = (async (): Promise<void> => {
4337
+ let failureRecorded = intent === undefined;
4338
+ for (;;) {
4339
+ if (controller.signal.aborted) return;
4340
+ try {
4341
+ if (!failureRecorded) {
4342
+ await reconciliation.noteTransition("skill", correlation, {
4343
+ type: "agent_failed",
4344
+ error: Object.assign(new Error(intent?.message ?? "skill invocation failed"), {
4345
+ code: intent?.code ?? "skill_failed",
4346
+ }),
4347
+ } as never);
4348
+ failureRecorded = true;
4349
+ }
4350
+ await reconciliation.noteTransition("skill", correlation, { type: "agent_end" });
4351
+ return;
4352
+ } catch (error) {
4353
+ if (controller.signal.aborted) return;
4354
+ logger.error("SDK skill lifecycle recovery retrying", {
4355
+ commandId: correlation.commandId,
4356
+ turnId: correlation.turnId,
4357
+ error: sanitizePromptFailure(error),
4358
+ });
4359
+ const wait = Promise.withResolvers<void>();
4360
+ const timer = setTimeout(wait.resolve, 1_000);
4361
+ timer.unref();
4362
+ const onAbort = () => {
4363
+ clearTimeout(timer);
4364
+ wait.resolve();
4365
+ };
4366
+ controller.signal.addEventListener("abort", onAbort, { once: true });
4367
+ await wait.promise;
4368
+ controller.signal.removeEventListener("abort", onAbort);
4369
+ }
4370
+ }
4371
+ })();
4372
+ skillRecoveryTasks.set(key, task);
4373
+ void task.finally(() => {
4374
+ skillRecoveryTasks.delete(key);
4375
+ skillRecoveryControllers.delete(key);
4376
+ });
4377
+ };
3587
4378
  let runtime: SessionSdkSessionRuntime;
3588
4379
  // Durable-first bounded terminalization for accepted submissions that leave
3589
4380
  // their queue or race a run WITHOUT consumption (exact-head review: clearing
@@ -3597,6 +4388,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3597
4388
  correlation: InvocationCorrelation,
3598
4389
  error: { code: string; message: string },
3599
4390
  ): void => {
4391
+ if (lifecycleOwnerHolder.state) removeLifecycleTokenAliases(lifecycleOwnerHolder.state, correlation);
3600
4392
  const attempt = async (remaining: number): Promise<void> => {
3601
4393
  try {
3602
4394
  // 1. Record the failure reason durably FIRST. If this write fails,
@@ -3608,10 +4400,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3608
4400
  } as never);
3609
4401
  } catch (reasonError) {
3610
4402
  if (remaining <= 1) {
3611
- // Hand replay to the deadline manager: the lease stays armed and
3612
- // its bounded expiry retries the durable terminal transition
3613
- // instead of leaving an accepted row with no recovery owner.
3614
- deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
4403
+ // Prompts use the deadline lease; skills have no prompt lease and
4404
+ // must use their kind-aware durable retry owner instead.
4405
+ if (kind === "skill")
4406
+ scheduleSkillRecovery(correlation, { code: error.code, message: error.message });
4407
+ else
4408
+ deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
3615
4409
  logger.error("SDK abandoned submission failed to record its failure reason", {
3616
4410
  kind,
3617
4411
  commandId: correlation.commandId,
@@ -3630,8 +4424,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3630
4424
  // 3. Release recovery ownership ONLY after durable terminalization.
3631
4425
  deadlineManager.clear(correlation);
3632
4426
  } catch (transitionError) {
3633
- // Keep the lease armed; the manager replays the real agent_end.
3634
- deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
4427
+ // Keep prompt recovery leased; skill recovery has no prompt lease.
4428
+ if (kind === "skill") scheduleSkillRecovery(correlation);
4429
+ else deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
3635
4430
  logger.error("SDK abandoned submission failed to terminalize", {
3636
4431
  kind,
3637
4432
  commandId: correlation.commandId,
@@ -3647,13 +4442,26 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3647
4442
  ctx,
3648
4443
  api,
3649
4444
  reconciliation,
3650
- (kind, correlation, connectionId, startsOwnTurn) => {
4445
+ (kind, correlation, connectionId, startsOwnTurn, sdkRunToken) => {
4446
+ const owner = lifecycleOwnerHolder.state;
4447
+ if (lifecycleOwnerHolder.quiescing) {
4448
+ terminalizeAbandonedSubmission(kind, correlation, {
4449
+ code: "session_quiescing",
4450
+ message: "Session endpoint was replaced before the invocation started.",
4451
+ });
4452
+ return;
4453
+ }
4454
+ if (sdkRunToken && owner)
4455
+ lifecycleRunOwners.set(sdkRunToken, {
4456
+ state: owner,
4457
+ correlationKey: lifecycleCorrelationKey(correlation),
4458
+ });
3651
4459
  // Only submissions that start their OWN turn get a pending entry: a
3652
4460
  // steering-queued submission consumed inside the current run never
3653
4461
  // emits the agent_start that would consume the entry, so leaving it
3654
4462
  // queued would assign its stale connection as owner of a later
3655
4463
  // agent-initiated turn (review thread P1).
3656
- if (startsOwnTurn) pending.push({ kind, correlation, connectionId });
4464
+ if (startsOwnTurn) pending.push({ kind, correlation, connectionId, sdkRunToken });
3657
4465
  // Anchor the zero-progress deadline at durable acceptance, not at
3658
4466
  // agent_start (#4668): an accepted own-turn prompt that wedges
3659
4467
  // between acceptance and run start (provider/credential/compaction
@@ -3663,10 +4471,26 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3663
4471
  // promotion/agent_start instead, so a prompt waiting behind a
3664
4472
  // legitimately long turn never false-fires. The agent_start
3665
4473
  // re-entry in emitLifecycle is a no-op for an existing lease.
3666
- if (startsOwnTurn && kind === "prompt") deadlineManager.onAccepted(correlation);
3667
4474
  },
4475
+ correlation => deadlineManager.onAccepted(correlation),
3668
4476
  steerReconciliation,
3669
- (kind, correlation, connectionId, promotion) => {
4477
+ (kind, correlation, connectionId, sdkRunToken, promotion) => {
4478
+ const bindPromotedToken = (batch?: LifecycleBatch): void => {
4479
+ const owner = lifecycleOwnerHolder.state;
4480
+ if (sdkRunToken && owner)
4481
+ lifecycleRunOwners.set(sdkRunToken, {
4482
+ state: owner,
4483
+ batch,
4484
+ correlationKey: lifecycleCorrelationKey(correlation),
4485
+ });
4486
+ };
4487
+ if (lifecycleOwnerHolder.quiescing) {
4488
+ terminalizeAbandonedSubmission(kind, correlation, {
4489
+ code: "session_quiescing",
4490
+ message: "Session endpoint was replaced before the invocation started.",
4491
+ });
4492
+ return;
4493
+ }
3670
4494
  // Lease at the ACTUAL promotion boundary (#4668 review): a promoted
3671
4495
  // submission that wedges before its run's agent_start must still
3672
4496
  // terminalize boundedly instead of remaining accepted forever.
@@ -3696,7 +4520,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3696
4520
  // starts with an empty pending queue at its agent_start; create the
3697
4521
  // entry at promotion so the submitting connection owns that turn
3698
4522
  // (review thread P2).
3699
- pending.push({ kind, correlation, connectionId });
4523
+ pending.push({ kind, correlation, connectionId, sdkRunToken });
3700
4524
  return;
3701
4525
  }
3702
4526
  // Consumed INSIDE the currently running turn: no new agent_start
@@ -3759,6 +4583,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3759
4583
  )
3760
4584
  )
3761
4585
  activeBatch.attachedInvocations.push({ kind, correlation });
4586
+ bindPromotedToken(activeBatch);
3762
4587
  return;
3763
4588
  }
3764
4589
  // No in-flight run visible and this is an in-run consumption
@@ -3794,8 +4619,14 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3794
4619
  // the matching correlation so a later agent-initiated turn never
3795
4620
  // inherits the failed submission's connection as owner (review
3796
4621
  // thread P1).
3797
- const index = pending.findIndex(entry => entry.correlation === correlation);
3798
- if (index >= 0) pending.splice(index, 1);
4622
+ if (!lifecycleOwnerHolder.quiescing) {
4623
+ const index = pending.findIndex(entry => entry.correlation === correlation);
4624
+ if (index >= 0) {
4625
+ const [removed] = pending.splice(index, 1);
4626
+ if (removed && lifecycleRunOwners.get(removed.sdkRunToken)?.state === lifecycleOwnerHolder.state)
4627
+ lifecycleRunOwners.delete(removed.sdkRunToken);
4628
+ }
4629
+ }
3799
4630
  // Release the acceptance-anchored lease ONLY when the durable row is
3800
4631
  // already terminal (exact-head review #4): clearing it before the
3801
4632
  // settlement writes land strands the row accepted with no recovery
@@ -3808,6 +4639,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3808
4639
  return;
3809
4640
  }
3810
4641
  if (leaseRelease === "recover-failure" && failureIntent !== undefined) {
4642
+ if (kind === "skill") {
4643
+ scheduleSkillRecovery(correlation, failureIntent);
4644
+ return;
4645
+ }
3811
4646
  // Compound failure-plus-terminal recovery: the settlement's durable
3812
4647
  // writes failed, so the lease stays armed as the recovery owner and its
3813
4648
  // replay re-records the failure reason before agent_end (exact-head
@@ -3816,6 +4651,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3816
4651
  return;
3817
4652
  }
3818
4653
  if (leaseRelease === "recover-terminal") {
4654
+ if (kind === "skill") {
4655
+ scheduleSkillRecovery(correlation);
4656
+ return;
4657
+ }
3819
4658
  deadlineManager.noteTerminalTransition(correlation);
3820
4659
  return;
3821
4660
  }
@@ -3892,6 +4731,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3892
4731
  };
3893
4732
  runtime = new SessionSdkSessionRuntime({
3894
4733
  transport,
4734
+ onFrameAdmission: (_connectionId, frame) =>
4735
+ inputGate.quiescing ? quiescingFrame(frame as Record<string, unknown>) : undefined,
3895
4736
  control: async (connectionId, frame) => {
3896
4737
  options.onFrameAdmitted?.();
3897
4738
  const request = controlRequestFromFrame(frame as Record<string, unknown>);
@@ -4028,7 +4869,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4028
4869
  logger.warn(`sdk broker registration unavailable: ${String(error)}`);
4029
4870
  }
4030
4871
  };
4031
- active = {
4872
+ const runtimeOwner: RuntimeState = {
4873
+ sessionId,
4874
+ sessionIdentity,
4032
4875
  runtime,
4033
4876
  revisions,
4034
4877
  cursors,
@@ -4038,6 +4881,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4038
4881
  pending,
4039
4882
  openLifecycleBatches,
4040
4883
  registerBroker,
4884
+ quiesceInput: () => {
4885
+ inputGate.quiescing = true;
4886
+ lifecycleOwnerHolder.quiescing = true;
4887
+ },
4041
4888
  fenceGateResolutions: () => {
4042
4889
  acceptingGateResolutions = false;
4043
4890
  },
@@ -4045,7 +4892,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4045
4892
  disposeGate,
4046
4893
  lifecycleActive: false,
4047
4894
  lifecycleEpoch: 0,
4895
+ failureDiagnosticKeys: new Set(),
4896
+ failureDiagnosticCodes: new Map(),
4897
+ lifecycleTasks: new Set(),
4048
4898
  };
4899
+ lifecycleOwnerHolder.state = runtimeOwner;
4900
+ active = runtimeOwner;
4049
4901
  try {
4050
4902
  await runtime.start();
4051
4903
  await registerBroker();
@@ -4059,7 +4911,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4059
4911
  code: errorCode(cleanupError),
4060
4912
  error: String(cleanupError),
4061
4913
  });
4062
- active = {
4914
+ const failedRuntimeOwner: RuntimeState = {
4915
+ sessionId,
4916
+ sessionIdentity,
4063
4917
  runtime,
4064
4918
  revisions,
4065
4919
  cursors,
@@ -4069,6 +4923,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4069
4923
  pending,
4070
4924
  openLifecycleBatches,
4071
4925
  registerBroker,
4926
+ quiesceInput: () => {
4927
+ inputGate.quiescing = true;
4928
+ lifecycleOwnerHolder.quiescing = true;
4929
+ },
4072
4930
  fenceGateResolutions: () => {
4073
4931
  acceptingGateResolutions = false;
4074
4932
  },
@@ -4076,7 +4934,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4076
4934
  disposeGate,
4077
4935
  lifecycleActive: false,
4078
4936
  lifecycleEpoch: 0,
4937
+ failureDiagnosticKeys: new Set(),
4938
+ failureDiagnosticCodes: new Map(),
4939
+ lifecycleTasks: new Set(),
4079
4940
  };
4941
+ lifecycleOwnerHolder.state = failedRuntimeOwner;
4942
+ active = failedRuntimeOwner;
4080
4943
  throw new AggregateError([error, cleanupError], "SDK runtime startup failed and cleanup failed.");
4081
4944
  }
4082
4945
  cursors.close();
@@ -4084,19 +4947,70 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4084
4947
  throw error;
4085
4948
  }
4086
4949
  };
4087
- const stopActive = async (): Promise<void> => {
4950
+ const stopActive = async (cancelSkillRecovery = false): Promise<void> => {
4088
4951
  const current = active;
4089
4952
  if (!current) return;
4090
- current.deadlineManager.clearAll();
4953
+ if (cancelSkillRecovery) {
4954
+ for (const controller of skillRecoveryControllers.values()) controller.abort();
4955
+ for (const controller of skillTerminalRecoveryControllers.values()) controller.abort();
4956
+ }
4957
+ activePromptOwnerHolder.connectionIds = undefined;
4958
+ activePromptOwnerHolder.lifecycleEpoch = undefined;
4959
+ current.quiesceInput();
4091
4960
  current.fenceGateResolutions();
4092
4961
  try {
4093
4962
  await current.waitForGateResolutionQuiescence();
4963
+ if (current.lifecycleTasks.size > 0) {
4964
+ const lifecycleDrain = Promise.all([...current.lifecycleTasks]);
4965
+ const timeout = Promise.withResolvers<void>();
4966
+ const timer = setTimeout(timeout.resolve, LIFECYCLE_QUIESCENCE_MS);
4967
+ const drained = Promise.race([lifecycleDrain, timeout.promise]);
4968
+ await drained;
4969
+ clearTimeout(timer);
4970
+ if (current.lifecycleTasks.size > 0) {
4971
+ logger.warn("SDK runtime lifecycle drain timed out; durable lifecycle recovery remains authoritative.");
4972
+ options.onLifecycleDrainTimeoutForTests?.();
4973
+ }
4974
+ }
4094
4975
  active = undefined;
4976
+ const retainsLifecycleWork =
4977
+ current.pending.length > 0 ||
4978
+ current.openLifecycleBatches.length > 0 ||
4979
+ (current.attachedInvocations?.length ?? 0) > 0 ||
4980
+ (current.drainedInvocations?.length ?? 0) > 0 ||
4981
+ current.lifecycleTasks.size > 0;
4982
+ if (retainsLifecycleWork) {
4983
+ const owners = retiredLifecycleOwners.get(current.sessionId) ?? [];
4984
+ if (!owners.includes(current)) owners.push(current);
4985
+ retiredLifecycleOwners.set(current.sessionId, owners);
4986
+ const retryCleanup = (): void => {
4987
+ retiredLifecycleOwnerTimers.delete(current);
4988
+ if (
4989
+ current.pending.length > 0 ||
4990
+ current.openLifecycleBatches.length > 0 ||
4991
+ (current.attachedInvocations?.length ?? 0) > 0 ||
4992
+ (current.drainedInvocations?.length ?? 0) > 0 ||
4993
+ current.lifecycleTasks.size > 0
4994
+ ) {
4995
+ const retry = setTimeout(retryCleanup, LIFECYCLE_QUIESCENCE_MS);
4996
+ retry.unref();
4997
+ retiredLifecycleOwnerTimers.set(current, retry);
4998
+ return;
4999
+ }
5000
+ removeRetiredLifecycleOwner(current);
5001
+ };
5002
+ const timer = setTimeout(retryCleanup, LIFECYCLE_QUIESCENCE_MS);
5003
+ timer.unref();
5004
+ retiredLifecycleOwnerTimers.set(current, timer);
5005
+ } else current.deadlineManager.clearAll();
4095
5006
  current.disposeGate?.();
4096
5007
  await current.runtime.stop();
4097
5008
  } catch (error) {
5009
+ // Keep the immutable owner available for a retry when transport teardown
5010
+ // fails after quiescing. Clearing `active` before stop prevents a second
5011
+ // shutdown from retrying the failed endpoint removal.
5012
+ if (active === undefined) active = current;
4098
5013
  logger.error("sdk runtime stop failed", { code: errorCode(error), error: String(error) });
4099
- active = current;
4100
5014
  throw error;
4101
5015
  }
4102
5016
  current.cursors.close();
@@ -4114,6 +5028,6 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4114
5028
  await startRuntime(ctx);
4115
5029
  });
4116
5030
  api.on("session_shutdown", async () => {
4117
- await stopActive();
5031
+ await stopActive(true);
4118
5032
  });
4119
5033
  }