@gajae-code/coding-agent 0.15.5 → 0.16.0

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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -1,12 +1,17 @@
1
- import { createHash, randomBytes } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
2
  import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import path from "node:path";
6
6
  import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
7
- import { logger } from "@gajae-code/utils";
7
+ import { logger, resolveEquivalentPath } from "@gajae-code/utils";
8
8
  import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
9
9
  import { planLaunchWorktree } from "../../gjc-runtime/launch-worktree";
10
+ import { SdkClient, SdkClientError } from "../client";
11
+ import {
12
+ BROKER_RUNTIME_ABORT_CAPABILITY_FIELD,
13
+ BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD,
14
+ } from "../host/control/runtime-gate";
10
15
  import { createDefaultSdkHostModelResolver, type SdkHostModelResolver } from "../host/model-pin";
11
16
  import {
12
17
  type DirectoryMigrationPolicy,
@@ -28,15 +33,42 @@ import {
28
33
  readBrokerDiscovery,
29
34
  redactBrokerDiscovery,
30
35
  } from "./discovery";
31
- import { deriveIdempotencyIdentity } from "./identity";
32
- import { canonicalDeleteLocatorPath, executeLifecycle, isCanonicalSessionId } from "./lifecycle";
36
+ import { matchesIndexedEndpointFile, readEndpointFile } from "./endpoint-authority";
37
+ import { deriveIdempotencyIdentity, getBrokerIdentityKey } from "./identity";
38
+ import {
39
+ canonicalDeleteLocatorPath,
40
+ executeLifecycle,
41
+ isCanonicalSessionId,
42
+ prepareSpawnChildHostLaunch,
43
+ validateBrokerModelPresetSync,
44
+ } from "./lifecycle";
33
45
  import {
34
46
  type LifecycleDurableEffectsReceipt,
35
47
  LifecycleLedger,
36
48
  type LifecycleStartupFailureReceipt,
37
49
  type LifecycleState,
38
50
  } from "./lifecycle-ledger";
51
+ import { createMasterCapabilityVerifier, readEndpoint } from "./master-capability";
39
52
  import { type IndexedSession, isSessionAuthorityEligible, SessionIndex, type SessionList } from "./session-index";
53
+ import {
54
+ type ResolvedScopeV1,
55
+ resolveScopeRequest,
56
+ ScopeRequestValidationError,
57
+ scopeMatchesLocator,
58
+ scopeRequestV1,
59
+ } from "./session-scope";
60
+ import {
61
+ type MasterCapabilityVerifier,
62
+ type SeedDeliveryV2,
63
+ SpawnAuthorityStore,
64
+ type SpawnAuthorityV1,
65
+ type SpawnClaimDecision,
66
+ type SpawnClaimV2,
67
+ type SpawnSubstrateFailure,
68
+ type SpawnSubstrateProof,
69
+ type SpawnSubstrateProvider,
70
+ } from "./spawn-authority";
71
+ import { createSpawnSubstrateProvider } from "./spawn-substrate";
40
72
  import { BrokerTransport } from "./transport";
41
73
 
42
74
  export interface BrokerSettings {
@@ -46,6 +78,16 @@ export interface BrokerSettings {
46
78
  heartbeatTtlMs?: number;
47
79
  /** Broker-owned migration policy. Client lifecycle frames cannot select it. */
48
80
  resolveDirectoryMigration?: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
81
+ /** Exact managed-substrate authority. Tests inject an in-memory provider. */
82
+ spawnSubstrateProvider?: SpawnSubstrateProvider;
83
+ /** Ordered Q26 host control seam; production uses exact endpoint attachments. */
84
+ spawnPromptLayer?: SpawnPromptLayer;
85
+ /** Live-only, host-mediated capability verifier. It retains no request input. */
86
+ masterCapabilityVerifier?: MasterCapabilityVerifier;
87
+ /** Grace before an orphaned spawn child closes; schema-bounded in production. */
88
+ masterOrphanGraceMs?: number;
89
+ /** Host model resolver override for lifecycle tests and embedders. */
90
+ resolveModelPin?: SdkHostModelResolver;
49
91
  }
50
92
 
51
93
  type ResolvedBrokerSettings = {
@@ -54,6 +96,10 @@ type ResolvedBrokerSettings = {
54
96
  port: number;
55
97
  heartbeatTtlMs: number;
56
98
  resolveDirectoryMigration: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
99
+ masterCapabilityVerifier?: MasterCapabilityVerifier;
100
+ spawnSubstrateProvider?: SpawnSubstrateProvider;
101
+ spawnPromptLayer?: SpawnPromptLayer;
102
+ masterOrphanGraceMs: number;
57
103
  };
58
104
 
59
105
  function modelResolutionCwd(input: Record<string, unknown>): string | undefined {
@@ -179,7 +225,7 @@ export type BrokerResponse =
179
225
  error: {
180
226
  code: BrokerErrorCode;
181
227
  message: string;
182
- details?: ModelProfileErrorDetails;
228
+ details?: ModelProfileErrorDetails | SpawnSubstrateFailure;
183
229
  endpoint?: "unavailable";
184
230
  cleanup?: BrokerCleanupEvidence;
185
231
  };
@@ -188,6 +234,14 @@ export type BrokerResponse =
188
234
  startupFailure?: LifecycleStartupFailureReceipt;
189
235
  };
190
236
  const error = (code: BrokerErrorCode, message: string): BrokerResponse => ({ ok: false, error: { code, message } });
237
+ const spawnFailureError = (failure: SpawnSubstrateFailure): BrokerResponse => ({
238
+ ok: false,
239
+ error: {
240
+ code: "spawn_failed",
241
+ message: `session.spawn substrate could not be safely established: ${failure.message}`,
242
+ details: failure,
243
+ },
244
+ });
191
245
 
192
246
  function isCleanupPending(response: BrokerResponse): boolean {
193
247
  if (response.ok) return false;
@@ -253,12 +307,151 @@ type SessionListCursor = {
253
307
  limit: number;
254
308
  offset: number;
255
309
  expiresAt: number;
310
+ scope?: ResolvedScopeV1;
311
+ observedAt?: string;
256
312
  };
257
313
 
258
314
  const SESSION_LIST_DEFAULT_LIMIT = 100;
259
315
  const SESSION_LIST_MAX_LIMIT = 100;
260
316
  const SESSION_LIST_CURSOR_TTL_MS = 15 * 60 * 1_000;
261
317
  const SESSION_LIST_MAX_CURSORS = 32;
318
+ type SpawnInFlight = {
319
+ completion: Promise<BrokerResponse>;
320
+ resolve: (response: BrokerResponse) => void;
321
+ claimId?: string;
322
+ phase?: SpawnClaimV2["state"];
323
+ };
324
+
325
+ /** Complete five-leg child endpoint pin captured at registration. */
326
+ export type SpawnHostRegistration = {
327
+ sessionId: string;
328
+ endpointGeneration: number;
329
+ pid: number;
330
+ processIncarnation: string;
331
+ /** Workspace the child was launched into; bound so a colliding pid elsewhere cannot pass. */
332
+ cwd: string;
333
+ stateRoot: string;
334
+ };
335
+
336
+ export type SpawnPromptDispatch =
337
+ | { kind: "accepted"; commandId: string; turnId: string; acceptedAt: number }
338
+ | { kind: "pre_send_rejected" }
339
+ | { kind: "uncertain" };
340
+
341
+ export type SpawnQ26Reconciliation = {
342
+ status: "accepted" | "in_flight" | "terminal_ok" | "failed" | "unknown";
343
+ clientRef?: string;
344
+ commandId?: string;
345
+ turnId?: string;
346
+ acceptedAt?: number;
347
+ };
348
+
349
+ /** Q26-only host control seam. It never receives or returns durable request material. */
350
+ export interface SpawnPromptLayer {
351
+ awaitRegistration(input: {
352
+ childId: string;
353
+ cwd: string;
354
+ stateRoot: string;
355
+ }): Promise<{ ok: true; registration: SpawnHostRegistration } | { ok: false }>;
356
+ dispatch(input: {
357
+ sessionId: string;
358
+ task: string;
359
+ clientRef: string;
360
+ /** Endpoint identity proven at registration; implementations must not talk to another endpoint. */
361
+ pinned: SpawnHostRegistration;
362
+ }): Promise<SpawnPromptDispatch>;
363
+ reconcile(input: {
364
+ sessionId: string;
365
+ clientRef: string;
366
+ pinned: SpawnHostRegistration;
367
+ }): Promise<SpawnQ26Reconciliation>;
368
+ }
369
+
370
+ type SpawnAdmissionInput = {
371
+ task: string;
372
+ masterCapability: string;
373
+ ownerSessionId: string;
374
+ attestationEpoch: string;
375
+ cwd: string;
376
+ modelId?: string;
377
+ modelPreset?: string;
378
+ };
379
+
380
+ function parseSpawnInput(
381
+ input: Record<string, unknown>,
382
+ idempotencyKey: string | undefined,
383
+ ): SpawnAdmissionInput | BrokerResponse {
384
+ if (!idempotencyKey || idempotencyKey.length > 512)
385
+ return error("invalid_input", "idempotencyKey is required for session.spawn");
386
+ const allowed = new Set([
387
+ "task",
388
+ "prompt",
389
+ "masterCapability",
390
+ "ownerSessionId",
391
+ "attestationEpoch",
392
+ "cwd",
393
+ "modelId",
394
+ "modelPreset",
395
+ ]);
396
+ if (Object.keys(input).some(key => !allowed.has(key)))
397
+ return error("invalid_input", "session.spawn input is invalid");
398
+ const task = input.task ?? input.prompt;
399
+ if (
400
+ typeof task !== "string" ||
401
+ task.length === 0 ||
402
+ task.length > 1_000_000 ||
403
+ (input.task !== undefined && input.prompt !== undefined && input.task !== input.prompt)
404
+ )
405
+ return error("invalid_input", "session.spawn task is invalid");
406
+ if (
407
+ typeof input.masterCapability !== "string" ||
408
+ input.masterCapability.length === 0 ||
409
+ input.masterCapability.length > 16_384
410
+ )
411
+ return error("invalid_input", "session.spawn capability is invalid");
412
+ if (
413
+ typeof input.ownerSessionId !== "string" ||
414
+ !isCanonicalSessionId(input.ownerSessionId) ||
415
+ typeof input.attestationEpoch !== "string" ||
416
+ input.attestationEpoch.length === 0 ||
417
+ input.attestationEpoch.length > 512 ||
418
+ typeof input.cwd !== "string" ||
419
+ input.cwd.length === 0 ||
420
+ (input.modelId !== undefined && (typeof input.modelId !== "string" || input.modelId.trim().length === 0)) ||
421
+ (input.modelPreset !== undefined && (typeof input.modelPreset !== "string" || input.modelPreset.length === 0))
422
+ )
423
+ return error("invalid_input", "session.spawn input is invalid");
424
+ return {
425
+ task,
426
+ masterCapability: input.masterCapability,
427
+ ownerSessionId: input.ownerSessionId,
428
+ attestationEpoch: input.attestationEpoch,
429
+ cwd: path.resolve(input.cwd),
430
+ ...(typeof input.modelId === "string" ? { modelId: input.modelId.trim() } : {}),
431
+ ...(typeof input.modelPreset === "string" ? { modelPreset: input.modelPreset } : {}),
432
+ };
433
+ }
434
+
435
+ function spawnBindingMac(
436
+ key: string,
437
+ admission: Pick<SpawnAdmissionInput, "ownerSessionId" | "attestationEpoch" | "cwd">,
438
+ modelId: string | null,
439
+ modelPreset: string | null,
440
+ ): string {
441
+ return createHmac("sha256", Buffer.from(key, "hex"))
442
+ .update(
443
+ canonicalJson({
444
+ version: 1,
445
+ operation: "session.spawn",
446
+ ownerSessionId: admission.ownerSessionId,
447
+ attestationEpoch: admission.attestationEpoch,
448
+ cwd: admission.cwd,
449
+ modelId,
450
+ modelPreset,
451
+ }),
452
+ )
453
+ .digest("hex");
454
+ }
262
455
 
263
456
  function sessionListLimit(input: Record<string, unknown>): number | BrokerResponse {
264
457
  const limit = input.limit;
@@ -272,6 +465,125 @@ function isBrokerResponse(value: unknown): value is BrokerResponse {
272
465
  return typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean";
273
466
  }
274
467
 
468
+ const SPAWN_HOST_REGISTRATION_TIMEOUT_MS = 10_000;
469
+ const SPAWN_HOST_REGISTRATION_POLL_MS = 50;
470
+ const SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS = 10_000;
471
+ const MASTER_ORPHAN_GRACE_DEFAULT_MS = 120_000;
472
+
473
+ /** The five legs every usable pin must carry; a partial pin is missing authority. */
474
+ type CompleteSpawnPinAuthority = SpawnAuthorityV1 & {
475
+ endpointGeneration: number;
476
+ endpointPid: number;
477
+ endpointIncarnation: string;
478
+ endpointCwd: string;
479
+ endpointStateRoot: string;
480
+ };
481
+
482
+ /**
483
+ * A pin is COMPLETE or it is missing authority. The fields are optional in the
484
+ * schema so older rows still reopen, but a partially populated pin carries
485
+ * strictly weaker evidence than the exchange assumes: generation plus pid is
486
+ * collidable across workspaces, so a partial pin must never be usable.
487
+ */
488
+ function isCompleteSpawnPin(authority: SpawnAuthorityV1): authority is CompleteSpawnPinAuthority {
489
+ return (
490
+ authority.endpointGeneration !== undefined &&
491
+ authority.endpointPid !== undefined &&
492
+ authority.endpointIncarnation !== undefined &&
493
+ authority.endpointCwd !== undefined &&
494
+ authority.endpointStateRoot !== undefined
495
+ );
496
+ }
497
+
498
+ /**
499
+ * Rebuilds the proven child-endpoint pin from durable authority. Returns
500
+ * undefined for a pre-pin or partially pinned row, which recovery must treat as
501
+ * missing authority.
502
+ */
503
+ function spawnPinFromAuthority(authority: SpawnAuthorityV1): SpawnHostRegistration | undefined {
504
+ // A pin is COMPLETE or it is missing authority. The fields are optional in the
505
+ // schema so older rows still reopen, but a partially populated pin carries
506
+ // strictly weaker evidence than the exchange assumes: generation plus pid is
507
+ // collidable across workspaces, so a partial pin must never be usable.
508
+ if (!isCompleteSpawnPin(authority)) return undefined;
509
+ return {
510
+ sessionId: authority.childId,
511
+ endpointGeneration: authority.endpointGeneration,
512
+ pid: authority.endpointPid,
513
+ processIncarnation: authority.endpointIncarnation,
514
+ cwd: authority.endpointCwd,
515
+ stateRoot: authority.endpointStateRoot,
516
+ };
517
+ }
518
+
519
+ /** Exact match of a live index row against a complete endpoint pin. */
520
+ function matchesSpawnPin(candidate: IndexedSession, pinned: SpawnHostRegistration): boolean {
521
+ return (
522
+ candidate.endpointGeneration === pinned.endpointGeneration &&
523
+ candidate.pid === pinned.pid &&
524
+ (candidate.hostIncarnation ?? candidate.processIncarnation) === pinned.processIncarnation &&
525
+ resolveEquivalentPath(candidate.locator.cwd) === resolveEquivalentPath(pinned.cwd) &&
526
+ resolveEquivalentPath(candidate.locator.stateRoot) === resolveEquivalentPath(pinned.stateRoot)
527
+ );
528
+ }
529
+
530
+ /** Rebuilds the provider proof from durable authority facts only. */
531
+ function spawnProofFromAuthority(authority: SpawnAuthorityV1): SpawnSubstrateProof {
532
+ return {
533
+ substrateKind: authority.substrateKind,
534
+ providerIdentity: authority.providerIdentity,
535
+ ...(authority.nativeSessionId === undefined ? {} : { nativeSessionId: authority.nativeSessionId }),
536
+ ...(authority.pid === undefined ? {} : { pid: authority.pid }),
537
+ ...(authority.processIncarnation === undefined ? {} : { processIncarnation: authority.processIncarnation }),
538
+ ...(authority.ownerGeneration === undefined ? {} : { ownerGeneration: authority.ownerGeneration }),
539
+ ...(authority.stateFileProof === undefined ? {} : { stateFileProof: authority.stateFileProof }),
540
+ };
541
+ }
542
+
543
+ function objectRecord(value: unknown): Record<string, unknown> | undefined {
544
+ return value !== null && typeof value === "object" && !Array.isArray(value)
545
+ ? (value as Record<string, unknown>)
546
+ : undefined;
547
+ }
548
+
549
+ function safeSpawnOpaque(value: unknown): value is string {
550
+ return typeof value === "string" && value.length > 0 && value.length <= 512;
551
+ }
552
+
553
+ function q26FromResponse(value: unknown): SpawnQ26Reconciliation {
554
+ const outer = objectRecord(value);
555
+ const result = objectRecord(outer?.result) ?? outer;
556
+ const rawStatus = result?.status;
557
+ const status =
558
+ rawStatus === "accepted" || rawStatus === "in_flight" || rawStatus === "terminal_ok" || rawStatus === "failed"
559
+ ? rawStatus
560
+ : "unknown";
561
+ return {
562
+ status,
563
+ ...(safeSpawnOpaque(result?.clientRef) ? { clientRef: result.clientRef } : {}),
564
+ ...(safeSpawnOpaque(result?.commandId) ? { commandId: result.commandId } : {}),
565
+ ...(safeSpawnOpaque(result?.turnId) ? { turnId: result.turnId } : {}),
566
+ ...(typeof result?.acceptedAt === "number" && Number.isSafeInteger(result.acceptedAt) && result.acceptedAt >= 0
567
+ ? { acceptedAt: result.acceptedAt }
568
+ : {}),
569
+ };
570
+ }
571
+
572
+ function promptAcceptanceFromResponse(
573
+ value: unknown,
574
+ clientRef: string,
575
+ ): { commandId: string; turnId: string } | undefined {
576
+ const outer = objectRecord(value);
577
+ const result = objectRecord(outer?.result) ?? outer;
578
+ if (
579
+ result?.accepted !== true ||
580
+ !safeSpawnOpaque(result.commandId) ||
581
+ !safeSpawnOpaque(result.turnId) ||
582
+ (result.clientRef !== undefined && result.clientRef !== clientRef)
583
+ )
584
+ return undefined;
585
+ return { commandId: result.commandId, turnId: result.turnId };
586
+ }
275
587
  function normalizeAliasedString(
276
588
  input: Record<string, unknown>,
277
589
  canonical: string,
@@ -316,6 +628,8 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
316
628
  return error("invalid_input", "cursor must be a non-empty opaque string");
317
629
  const limit = sessionListLimit(input);
318
630
  if (isBrokerResponse(limit)) return limit;
631
+ if (input.scope !== undefined && !scopeRequestV1(input.scope) && input.cursor === undefined)
632
+ return error("invalid_input", "scope must be a valid ScopeRequestV1");
319
633
  return { input: normalized };
320
634
  }
321
635
  if (
@@ -385,7 +699,13 @@ function credentialFreeLifecycleResponse(value: unknown): unknown {
385
699
  if (value === null || typeof value !== "object") return value;
386
700
  const output: Record<string, unknown> = {};
387
701
  for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
388
- if (key === "token" || key === "url" || (key === "endpoint" && nested !== null && typeof nested === "object"))
702
+ if (
703
+ key === BROKER_RUNTIME_ABORT_CAPABILITY_FIELD ||
704
+ key === BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD ||
705
+ key === "token" ||
706
+ key === "url" ||
707
+ (key === "endpoint" && nested !== null && typeof nested === "object")
708
+ )
389
709
  continue;
390
710
  output[key] = credentialFreeLifecycleResponse(nested);
391
711
  }
@@ -456,11 +776,98 @@ function sameEndpointRecord(expected: IndexedSession, current: IndexedSession):
456
776
  current.endpointGeneration === expected.endpointGeneration &&
457
777
  current.pid === expected.pid &&
458
778
  current.endpointMtimeMs === expected.endpointMtimeMs &&
459
- path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
779
+ (expected.endpointFileId === undefined || current.endpointFileId === expected.endpointFileId) &&
780
+ (expected.processIncarnation === undefined || current.processIncarnation === expected.processIncarnation) &&
781
+ (expected.hostIncarnation === undefined || current.hostIncarnation === expected.hostIncarnation) &&
782
+ path.resolve(current.locator.cwd) === path.resolve(expected.locator.cwd) &&
460
783
  path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
461
784
  );
462
785
  }
463
786
 
787
+ const BROKER_SESSION_CONTROL_FIELDS = new Set(["sessionId", "operation", "input", "confirm"]);
788
+ const BROKER_SESSION_CONTROL_ABORT_FIELDS = new Set(["mode", "scope", "operator"]);
789
+ const BROKER_SESSION_CONTROL_TIMEOUT_MS = 10_000;
790
+
791
+ type BrokerSessionControlRequest = {
792
+ sessionId: string;
793
+ abortInput: { mode: "terminal"; scope: "turn" | "owned"; operator: true };
794
+ };
795
+
796
+ type BrokerSessionControlAuthority = {
797
+ record: IndexedSession;
798
+ endpoint: Record<string, unknown>;
799
+ endpointIdentity: {
800
+ dev: bigint;
801
+ ino: bigint;
802
+ size: bigint;
803
+ mtimeNs: bigint;
804
+ ctimeNs: bigint;
805
+ };
806
+ };
807
+
808
+ function brokerSessionControlRequest(input: unknown): BrokerSessionControlRequest | BrokerResponse {
809
+ const frame = objectRecord(input);
810
+ if (!frame) return error("invalid_input", "session.control input must be an object");
811
+ for (const key of Object.keys(frame))
812
+ if (!BROKER_SESSION_CONTROL_FIELDS.has(key))
813
+ return error("invalid_input", `Unknown session.control field: ${key}`);
814
+ if (typeof frame.sessionId !== "string" || !isCanonicalSessionId(frame.sessionId))
815
+ return error("invalid_input", "sessionId must be a canonical safe identifier");
816
+ if (frame.operation !== "turn.abort") return error("invalid_input", "session.control only supports turn.abort");
817
+ if (frame.confirm !== true) return error("invalid_input", "session.control requires confirm:true");
818
+ const abortInput = objectRecord(frame.input);
819
+ if (!abortInput) return error("invalid_input", "session.control input must be an object");
820
+ for (const key of Object.keys(abortInput))
821
+ if (!BROKER_SESSION_CONTROL_ABORT_FIELDS.has(key))
822
+ return error("invalid_input", `Unknown turn.abort terminal field: ${key}`);
823
+ if (abortInput.mode !== "terminal") return error("invalid_input", 'turn.abort mode must be "terminal"');
824
+ const scope = abortInput.scope === undefined ? "turn" : abortInput.scope;
825
+ if (scope !== "turn" && scope !== "owned")
826
+ return error("invalid_input", 'turn.abort scope must be "turn" or "owned"');
827
+ if (abortInput.operator !== true) return error("invalid_input", "session.control requires operator:true");
828
+ return {
829
+ sessionId: frame.sessionId,
830
+ abortInput: { mode: "terminal", scope, operator: true },
831
+ };
832
+ }
833
+
834
+ function brokerControlResponse(value: unknown): BrokerResponse {
835
+ const response = objectRecord(value);
836
+ if (!response || typeof response.ok !== "boolean")
837
+ return error("unavailable", "session endpoint returned an invalid control response");
838
+ if (response.ok)
839
+ return response.result === undefined
840
+ ? { ok: true }
841
+ : { ok: true, result: credentialFreeLifecycleResponse(response.result) };
842
+ const failure = objectRecord(response.error);
843
+ const code = typeof failure?.code === "string" ? failure.code : "unavailable";
844
+ const message = typeof failure?.message === "string" ? failure.message : "session endpoint control failed";
845
+ return { ok: false, error: { code, message } };
846
+ }
847
+
848
+ function sameSessionControlAuthority(
849
+ left: BrokerSessionControlAuthority,
850
+ right: BrokerSessionControlAuthority,
851
+ ): boolean {
852
+ return (
853
+ sameEndpointRecord(left.record, right.record) &&
854
+ left.record.processIncarnation === right.record.processIncarnation &&
855
+ left.record.hostIncarnation === right.record.hostIncarnation &&
856
+ left.record.lifecycleRequestId === right.record.lifecycleRequestId &&
857
+ endpointIncarnation(left.record, left.record.sessionId) ===
858
+ endpointIncarnation(right.record, right.record.sessionId) &&
859
+ left.endpoint.sessionId === right.endpoint.sessionId &&
860
+ left.endpoint.pid === right.endpoint.pid &&
861
+ left.endpoint.url === right.endpoint.url &&
862
+ left.endpoint.token === right.endpoint.token &&
863
+ left.endpointIdentity.dev === right.endpointIdentity.dev &&
864
+ left.endpointIdentity.ino === right.endpointIdentity.ino &&
865
+ left.endpointIdentity.size === right.endpointIdentity.size &&
866
+ left.endpointIdentity.mtimeNs === right.endpointIdentity.mtimeNs &&
867
+ left.endpointIdentity.ctimeNs === right.endpointIdentity.ctimeNs
868
+ );
869
+ }
870
+
464
871
  function lifecycleTarget(operation: string, input: Record<string, unknown>): unknown {
465
872
  const target = input.target as Record<string, unknown> | undefined;
466
873
  const string = (...values: unknown[]): string | undefined =>
@@ -848,6 +1255,11 @@ export class Broker {
848
1255
  #owner = randomBytes(12).toString("hex");
849
1256
  #sessionListCursors = new Map<string, SessionListCursor>();
850
1257
  #chains = new Map<string, Promise<void>>();
1258
+ #spawnInFlight = new Map<string, SpawnInFlight>();
1259
+ #spawnTasks = new WeakMap<SpawnInFlight, string>();
1260
+ #spawnAuthority: SpawnAuthorityStore | null = null;
1261
+ #spawnPromptLayer: SpawnPromptLayer;
1262
+ #spawnReapInFlight = false;
851
1263
  #admitted = new Set<Promise<void>>();
852
1264
  #startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
853
1265
  #publication: RetainedBrokerDiscovery | null = null;
@@ -863,7 +1275,8 @@ export class Broker {
863
1275
  #completion!: Promise<void>;
864
1276
  #resolveCompletion!: () => void;
865
1277
  #rejectCompletion!: (error: unknown) => void;
866
- readonly #resolveModelPin: SdkHostModelResolver;
1278
+ #resolveModelPin: SdkHostModelResolver;
1279
+ #ownsResolveModelPin: boolean;
867
1280
  constructor(settings: BrokerSettings) {
868
1281
  this.settings = {
869
1282
  agentDir: settings.agentDir,
@@ -871,16 +1284,1031 @@ export class Broker {
871
1284
  port: settings.port ?? 0,
872
1285
  heartbeatTtlMs: settings.heartbeatTtlMs ?? BROKER_HEARTBEAT_TTL_MS,
873
1286
  resolveDirectoryMigration: settings.resolveDirectoryMigration ?? (async () => "copy-retain"),
1287
+ masterCapabilityVerifier: settings.masterCapabilityVerifier,
1288
+ spawnSubstrateProvider: settings.spawnSubstrateProvider,
1289
+ spawnPromptLayer: settings.spawnPromptLayer,
1290
+ masterOrphanGraceMs: settings.masterOrphanGraceMs ?? MASTER_ORPHAN_GRACE_DEFAULT_MS,
874
1291
  };
875
1292
  this.index = new SessionIndex(settings.agentDir);
876
1293
  this.ledger = new LifecycleLedger(settings.agentDir);
877
- this.#resolveModelPin = createDefaultSdkHostModelResolver(this.settings.agentDir);
1294
+ this.#ownsResolveModelPin = settings.resolveModelPin === undefined;
1295
+ this.#resolveModelPin = settings.resolveModelPin ?? createDefaultSdkHostModelResolver(this.settings.agentDir);
1296
+ if (!this.settings.masterCapabilityVerifier)
1297
+ this.settings.masterCapabilityVerifier = createMasterCapabilityVerifier(this.index);
1298
+ this.#spawnPromptLayer = settings.spawnPromptLayer ?? {
1299
+ awaitRegistration: async input => await this.#awaitSpawnHostRegistration(input),
1300
+ dispatch: async input => await this.#dispatchSpawnPrompt(input),
1301
+ reconcile: async input => await this.#reconcileSpawnPrompt(input),
1302
+ };
878
1303
  this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
879
1304
  const completion = Promise.withResolvers<void>();
880
1305
  this.#completion = completion.promise;
881
1306
  this.#resolveCompletion = completion.resolve;
882
1307
  this.#rejectCompletion = completion.reject;
883
1308
  }
1309
+ /** Host capability validation is live-only; no request material is retained. */
1310
+ async verifyMasterCapability(
1311
+ ownerSessionId: string,
1312
+ rawCapability: string,
1313
+ attestationEpoch: string,
1314
+ ): Promise<{ allowed: boolean }> {
1315
+ const verifier = this.settings.masterCapabilityVerifier;
1316
+ return verifier
1317
+ ? await verifier.verifyMasterCapability(ownerSessionId, rawCapability, attestationEpoch)
1318
+ : { allowed: false };
1319
+ }
1320
+ async #handleSpawn(input: Record<string, unknown>, idempotencyKey: string | undefined): Promise<BrokerResponse> {
1321
+ const admission = parseSpawnInput(input, idempotencyKey);
1322
+ if (isBrokerResponse(admission)) return admission;
1323
+ const lifecycleIdentity = await deriveIdempotencyIdentity(
1324
+ this.settings.agentDir,
1325
+ "session.spawn",
1326
+ idempotencyKey!,
1327
+ );
1328
+ const active = this.#spawnInFlight.get(lifecycleIdentity);
1329
+ if (active) {
1330
+ if (this.#spawnTasks.get(active) !== admission.task)
1331
+ return error("idempotency_conflict", "idempotency key conflicts with a live session.spawn request");
1332
+ return error("spawn_in_progress", `session.spawn is ${active.phase ?? "prepared"}`);
1333
+ }
1334
+ const completion = Promise.withResolvers<BrokerResponse>();
1335
+ const inFlight: SpawnInFlight = { completion: completion.promise, resolve: completion.resolve };
1336
+ // Installed before verification or a durable mutation. Task ownership is a
1337
+ // weak in-memory association and cannot reach durable generic code.
1338
+ this.#spawnInFlight.set(lifecycleIdentity, inFlight);
1339
+ this.#spawnTasks.set(inFlight, admission.task);
1340
+ let response: BrokerResponse | undefined;
1341
+ let becameOwner = false;
1342
+ const finish = (result: BrokerResponse): BrokerResponse => {
1343
+ response = result;
1344
+ return result;
1345
+ };
1346
+ try {
1347
+ let verified: { allowed: boolean };
1348
+ try {
1349
+ verified = await this.verifyMasterCapability(
1350
+ admission.ownerSessionId,
1351
+ admission.masterCapability,
1352
+ admission.attestationEpoch,
1353
+ );
1354
+ } finally {
1355
+ admission.masterCapability = "";
1356
+ }
1357
+ if (!verified.allowed) return finish(error("spawn_failed", "master capability verification was denied"));
1358
+ const key = await getBrokerIdentityKey(this.settings.agentDir);
1359
+ const authority = this.#spawnAuthority;
1360
+ if (!authority) return finish(error("unavailable", "spawn authority is unavailable"));
1361
+ const requestBindingMac = spawnBindingMac(
1362
+ key,
1363
+ admission,
1364
+ admission.modelId ?? null,
1365
+ admission.modelPreset ?? null,
1366
+ );
1367
+ const existing = authority.claim(lifecycleIdentity);
1368
+ const resolveModels = async (): Promise<BrokerResponse | undefined> => {
1369
+ // Validate the optional profile BEFORE any new durable claim or substrate
1370
+ // effect. Generic lifecycle create validates it; spawn must do the same.
1371
+ if (admission.modelPreset !== undefined) {
1372
+ const validated = validateBrokerModelPresetSync(this.settings.agentDir, admission.modelPreset);
1373
+ if (isBrokerResponse(validated)) return validated;
1374
+ admission.modelPreset = validated;
1375
+ }
1376
+ // Resolve the explicit model pin before a new durable claim. Raw
1377
+ // selectors must never reach a newly launched child.
1378
+ if (admission.modelId !== undefined) {
1379
+ const resolved = await this.#resolveModelPin(admission.modelId, { cwd: admission.cwd });
1380
+ if (!resolved.ok) return error("unknown_model", resolved.error);
1381
+ if (resolved.model === null)
1382
+ return error("unknown_model", "session.spawn modelId could not be resolved.");
1383
+ admission.modelId = resolved.model;
1384
+ }
1385
+ return undefined;
1386
+ };
1387
+ let decision: SpawnClaimDecision;
1388
+ if (existing !== undefined) {
1389
+ // An existing claim carries the raw-selector binding, so terminal and
1390
+ // replay responses do not depend on today's model/profile catalog.
1391
+ let existingBindingMac: string | undefined;
1392
+ if (existing.requestBindingMac === undefined) {
1393
+ // Claims written before raw-selector evidence was introduced only have
1394
+ // the canonical binding. Re-resolve that historical selector before
1395
+ // comparison; if the catalog no longer proves it, fail closed rather
1396
+ // than guessing or weakening idempotency conflict protection.
1397
+ const modelError = await resolveModels();
1398
+ if (modelError) return finish(modelError);
1399
+ existingBindingMac = spawnBindingMac(
1400
+ key,
1401
+ admission,
1402
+ admission.modelId ?? null,
1403
+ admission.modelPreset ?? null,
1404
+ );
1405
+ }
1406
+ decision = await authority.claimOrJoin(
1407
+ lifecycleIdentity,
1408
+ existingBindingMac,
1409
+ existingBindingMac ?? requestBindingMac,
1410
+ );
1411
+ if (decision.kind === "owner") {
1412
+ becameOwner = true;
1413
+ // A recovered pre-send claim may still need to launch. Preserve the
1414
+ // fail-closed model validation for that effectful path and reject a
1415
+ // catalog remap rather than launching a different model.
1416
+ const modelError = await resolveModels();
1417
+ if (modelError) return finish(modelError);
1418
+ const recoveredBindingMac = spawnBindingMac(
1419
+ key,
1420
+ admission,
1421
+ admission.modelId ?? null,
1422
+ admission.modelPreset ?? null,
1423
+ );
1424
+ if (recoveredBindingMac !== decision.claim.bindingMac)
1425
+ return finish(
1426
+ error("idempotency_conflict", "session.spawn model selection differs from its durable claim"),
1427
+ );
1428
+ }
1429
+ } else {
1430
+ const modelError = await resolveModels();
1431
+ if (modelError) {
1432
+ // A concurrent claimant may have durably established this exact raw
1433
+ // request while catalog loading was in flight. Re-check its opaque
1434
+ // binding before returning the validation failure.
1435
+ const raced = authority.claim(lifecycleIdentity);
1436
+ if (raced === undefined) return finish(modelError);
1437
+ decision = await authority.claimOrJoin(lifecycleIdentity, undefined, requestBindingMac);
1438
+ if (decision.kind === "owner") {
1439
+ becameOwner = true;
1440
+ return finish(modelError);
1441
+ }
1442
+ } else {
1443
+ const bindingMac = spawnBindingMac(
1444
+ key,
1445
+ admission,
1446
+ admission.modelId ?? null,
1447
+ admission.modelPreset ?? null,
1448
+ );
1449
+ decision = await authority.claimOrJoin(lifecycleIdentity, bindingMac, requestBindingMac);
1450
+ }
1451
+ }
1452
+ if (decision.kind === "idempotency_conflict")
1453
+ return finish(
1454
+ error("idempotency_conflict", "idempotency key conflicts with an existing session.spawn claim"),
1455
+ );
1456
+ if (decision.kind === "in_progress")
1457
+ return finish(error("spawn_in_progress", `session.spawn is ${decision.claim.state}`));
1458
+ if (decision.kind === "terminal_uncertain")
1459
+ return finish(error("terminal_uncertain", "session.spawn outcome is uncertain"));
1460
+ if (decision.kind === "terminal") return finish(this.#spawnTerminalResponse(decision.claim));
1461
+ if (decision.kind === "replay")
1462
+ return finish(await this.#reconcileSpawnReplay(lifecycleIdentity, decision.claim));
1463
+ becameOwner = true;
1464
+ inFlight.claimId = decision.claim.claimId;
1465
+ inFlight.phase = decision.claim.state;
1466
+ // Claim fsync precedes this audit mirror. Substrate work may start
1467
+ // only after the mirror succeeds; a startup reconciler rebuilds it claim-first.
1468
+ await this.ledger.begin(lifecycleIdentity, decision.claim.bindingMac);
1469
+ return finish(
1470
+ await this.#driveSpawn(lifecycleIdentity, decision.claim, admission, inFlight, decision.recovery),
1471
+ );
1472
+ } catch {
1473
+ return finish(error("spawn_failed", "session.spawn admission could not be durably established"));
1474
+ } finally {
1475
+ completion.resolve(response ?? error("spawn_failed", "session.spawn admission failed"));
1476
+ this.#spawnTasks.delete(inFlight);
1477
+ this.#spawnInFlight.delete(lifecycleIdentity);
1478
+ if (becameOwner) await this.#spawnAuthority?.releaseOwner(lifecycleIdentity);
1479
+ }
1480
+ }
1481
+
1482
+ /** Safe, allowlisted spawn result projection; never carries request material. */
1483
+ #spawnResult(code: "spawn_accepted" | "spawn_replayed", claim: SpawnClaimV2): Record<string, unknown> {
1484
+ const authorityRecord = this.#spawnAuthority?.authority(claim.lifecycleIdentity);
1485
+ const seed = claim.seed;
1486
+ return {
1487
+ code,
1488
+ claimId: claim.claimId,
1489
+ ...(claim.childId === undefined ? {} : { sessionId: claim.childId }),
1490
+ ...(authorityRecord === undefined ? {} : { substrateKind: authorityRecord.substrateKind }),
1491
+ ...(seed === undefined
1492
+ ? {}
1493
+ : {
1494
+ seed: {
1495
+ phase: seed.phase,
1496
+ clientRef: seed.clientRef,
1497
+ ...(seed.commandId === undefined ? {} : { commandId: seed.commandId }),
1498
+ ...(seed.turnId === undefined ? {} : { turnId: seed.turnId }),
1499
+ ...(seed.lastQ26Status === undefined ? {} : { status: seed.lastQ26Status }),
1500
+ },
1501
+ }),
1502
+ };
1503
+ }
1504
+
1505
+ #spawnTerminalResponse(claim: SpawnClaimV2): BrokerResponse {
1506
+ if (claim.state === "accepted") return { ok: true, result: this.#spawnResult("spawn_replayed", claim) };
1507
+ if (claim.state === "pre_send_rejected")
1508
+ return claim.failure
1509
+ ? spawnFailureError(claim.failure)
1510
+ : error("spawn_failed", "session.spawn was rejected before seed handoff");
1511
+ return error("resource_gone", "session.spawn claim is closed");
1512
+ }
1513
+
1514
+ #spawnSubstrateProvider(): SpawnSubstrateProvider {
1515
+ if (this.settings.spawnSubstrateProvider) return this.settings.spawnSubstrateProvider;
1516
+ this.settings.spawnSubstrateProvider = createSpawnSubstrateProvider();
1517
+ return this.settings.spawnSubstrateProvider;
1518
+ }
1519
+
1520
+ /**
1521
+ * Drives one exclusively owned claim through the durable spawn state machine.
1522
+ * Every effect is fenced by a prior fsynced transition: substrate_starting
1523
+ * precedes launch, dispatching (lease consumption) precedes the prompt frame,
1524
+ * and accepted precedes the success response.
1525
+ */
1526
+ async #driveSpawn(
1527
+ lifecycleIdentity: string,
1528
+ claim: SpawnClaimV2,
1529
+ admission: SpawnAdmissionInput,
1530
+ inFlight: SpawnInFlight,
1531
+ recovered = false,
1532
+ ): Promise<BrokerResponse> {
1533
+ const store = this.#spawnAuthority;
1534
+ if (!store) return error("unavailable", "spawn authority is unavailable");
1535
+ const provider = this.#spawnSubstrateProvider();
1536
+ let current = claim;
1537
+ let handedOff = false;
1538
+ // Set the moment a substrate exists. After this point a failure is
1539
+ // ambiguous, never an ordinary pre-effect failure.
1540
+ let launchedProof: SpawnSubstrateProof | undefined;
1541
+ let pinnedRegistration: SpawnHostRegistration | undefined;
1542
+ try {
1543
+ if (current.state === "prepared") {
1544
+ const prep = prepareSpawnChildHostLaunch(this, {
1545
+ cwd: admission.cwd,
1546
+ ...(admission.modelId === undefined ? {} : { modelId: admission.modelId }),
1547
+ ...(admission.modelPreset === undefined ? {} : { modelPreset: admission.modelPreset }),
1548
+ });
1549
+ current = (
1550
+ await store.persistTransition(lifecycleIdentity, {
1551
+ claimId: current.claimId,
1552
+ from: "prepared",
1553
+ to: "substrate_starting",
1554
+ childId: prep.childId,
1555
+ })
1556
+ ).claim;
1557
+ inFlight.phase = current.state;
1558
+ const launched = await provider.launch({
1559
+ childSessionId: prep.childId,
1560
+ cwd: prep.cwd,
1561
+ argv: prep.argv,
1562
+ inheritedEnv: prep.inheritedEnv,
1563
+ env: prep.env,
1564
+ });
1565
+ if (launched.ok) launchedProof = launched.proof;
1566
+ if (!launched.ok) {
1567
+ const failure: SpawnSubstrateFailure = {
1568
+ substrateKind: launched.code === "substrate_unavailable" ? "headless" : "tmux",
1569
+ code: launched.code,
1570
+ message: launched.message,
1571
+ };
1572
+ current = (
1573
+ await store.persistTransition(lifecycleIdentity, {
1574
+ claimId: current.claimId,
1575
+ from: "substrate_starting",
1576
+ to: "pre_send_rejected",
1577
+ failure,
1578
+ })
1579
+ ).claim;
1580
+ return spawnFailureError(failure);
1581
+ }
1582
+ const registration = await this.#spawnPromptLayer.awaitRegistration({
1583
+ childId: prep.childId,
1584
+ cwd: prep.cwd,
1585
+ stateRoot: prep.stateRoot,
1586
+ });
1587
+ if (!registration.ok) {
1588
+ await this.#releaseUnownedSubstrate(provider, launchedProof);
1589
+ launchedProof = undefined;
1590
+ current = (
1591
+ await store.persistTransition(lifecycleIdentity, {
1592
+ claimId: current.claimId,
1593
+ from: "substrate_starting",
1594
+ to: "uncertain",
1595
+ })
1596
+ ).claim;
1597
+ return error("terminal_uncertain", "session.spawn child registration is uncertain");
1598
+ }
1599
+ pinnedRegistration = registration.registration;
1600
+ const now = Date.now();
1601
+ const proof = launched.proof;
1602
+ const authorityRecord: SpawnAuthorityV1 = {
1603
+ version: 1,
1604
+ authorityId: randomBytes(24).toString("base64url"),
1605
+ claimId: current.claimId,
1606
+ childId: prep.childId,
1607
+ ownerSessionId: admission.ownerSessionId,
1608
+ lifecycleIdentity,
1609
+ substrateKind: proof.substrateKind,
1610
+ providerIdentity: proof.providerIdentity,
1611
+ ...(proof.nativeSessionId === undefined ? {} : { nativeSessionId: proof.nativeSessionId }),
1612
+ ...(proof.pid === undefined ? {} : { pid: proof.pid }),
1613
+ ...(proof.processIncarnation === undefined ? {} : { processIncarnation: proof.processIncarnation }),
1614
+ ...(proof.ownerGeneration === undefined ? {} : { ownerGeneration: proof.ownerGeneration }),
1615
+ ...(proof.stateFileProof === undefined ? {} : { stateFileProof: proof.stateFileProof }),
1616
+ endpointGeneration: registration.registration.endpointGeneration,
1617
+ endpointPid: registration.registration.pid,
1618
+ endpointIncarnation: registration.registration.processIncarnation,
1619
+ endpointCwd: registration.registration.cwd,
1620
+ endpointStateRoot: registration.registration.stateRoot,
1621
+ closeState: "active",
1622
+ createdAt: now,
1623
+ updatedAt: now,
1624
+ };
1625
+ current = (
1626
+ await store.persistTransition(lifecycleIdentity, {
1627
+ claimId: current.claimId,
1628
+ from: "substrate_starting",
1629
+ to: "authority_active",
1630
+ childId: prep.childId,
1631
+ authority: authorityRecord,
1632
+ })
1633
+ ).claim;
1634
+ inFlight.phase = current.state;
1635
+ }
1636
+ if (current.state === "authority_active") {
1637
+ const seed: SeedDeliveryV2 = {
1638
+ version: 2,
1639
+ phase: "prepared",
1640
+ clientRef: randomBytes(24).toString("base64url"),
1641
+ };
1642
+ current = (
1643
+ await store.persistTransition(lifecycleIdentity, {
1644
+ claimId: current.claimId,
1645
+ from: "authority_active",
1646
+ to: "seed_prepared",
1647
+ seed,
1648
+ })
1649
+ ).claim;
1650
+ inFlight.phase = current.state;
1651
+ }
1652
+ if (
1653
+ current.state !== "seed_prepared" ||
1654
+ current.preSendLease?.status !== "owned" ||
1655
+ !current.seed ||
1656
+ !current.childId
1657
+ )
1658
+ return error("terminal_uncertain", "session.spawn cannot proceed from its durable state");
1659
+ // A recovery owner resumes a claim whose substrate was launched by an
1660
+ // earlier process. Re-prove that exact substrate before handing off the
1661
+ // seed: a replaced or vanished substrate must never receive the prompt.
1662
+ if (recovered) {
1663
+ const recoveredAuthority = store.authority(lifecycleIdentity);
1664
+ if (!recoveredAuthority)
1665
+ return error("terminal_uncertain", "session.spawn authority is unavailable after restart");
1666
+ // A recovery owner has no in-process pin, so it must come from durable
1667
+ // authority. An absent pin is missing evidence, not permission to
1668
+ // match on session id alone.
1669
+ pinnedRegistration = spawnPinFromAuthority(recoveredAuthority);
1670
+ let verdict: "verified" | "mismatch" | "gone";
1671
+ try {
1672
+ verdict =
1673
+ pinnedRegistration === undefined
1674
+ ? "gone"
1675
+ : await provider.verify(spawnProofFromAuthority(recoveredAuthority));
1676
+ } catch {
1677
+ verdict = "gone";
1678
+ }
1679
+ if (verdict !== "verified") {
1680
+ current = (
1681
+ await store.persistTransition(lifecycleIdentity, {
1682
+ claimId: current.claimId,
1683
+ from: current.state,
1684
+ to: "uncertain",
1685
+ seed: { ...current.seed, phase: "uncertain" },
1686
+ })
1687
+ ).claim;
1688
+ inFlight.phase = current.state;
1689
+ return error("terminal_uncertain", "session.spawn substrate could not be re-proven after restart");
1690
+ }
1691
+ }
1692
+ const childId = current.childId;
1693
+ const preparedSeed = current.seed;
1694
+ // Dispatch without a complete pin must never consume the pre-send lease.
1695
+ if (pinnedRegistration === undefined)
1696
+ return error("terminal_uncertain", "session.spawn endpoint pin is unavailable before dispatch");
1697
+ current = (
1698
+ await store.persistTransition(lifecycleIdentity, {
1699
+ claimId: current.claimId,
1700
+ from: "seed_prepared",
1701
+ to: "dispatching",
1702
+ leaseEpoch: current.preSendLease.epoch,
1703
+ seed: { ...preparedSeed, phase: "dispatching" },
1704
+ })
1705
+ ).claim;
1706
+ inFlight.phase = current.state;
1707
+ handedOff = true;
1708
+ const dispatched = await this.#spawnPromptLayer.dispatch({
1709
+ sessionId: childId,
1710
+ task: admission.task,
1711
+ clientRef: preparedSeed.clientRef,
1712
+ pinned: pinnedRegistration,
1713
+ });
1714
+ if (dispatched.kind === "accepted") {
1715
+ current = (
1716
+ await store.persistTransition(lifecycleIdentity, {
1717
+ claimId: current.claimId,
1718
+ from: "dispatching",
1719
+ to: "accepted",
1720
+ seed: {
1721
+ ...preparedSeed,
1722
+ phase: "accepted",
1723
+ commandId: dispatched.commandId,
1724
+ turnId: dispatched.turnId,
1725
+ acceptedAt: dispatched.acceptedAt,
1726
+ lastQ26Status: "accepted",
1727
+ observedAt: Date.now(),
1728
+ },
1729
+ })
1730
+ ).claim;
1731
+ inFlight.phase = current.state;
1732
+ return { ok: true, result: this.#spawnResult("spawn_accepted", current) };
1733
+ }
1734
+ if (dispatched.kind === "pre_send_rejected") {
1735
+ current = (
1736
+ await store.persistTransition(lifecycleIdentity, {
1737
+ claimId: current.claimId,
1738
+ from: "dispatching",
1739
+ to: "pre_send_rejected",
1740
+ provenNoHandoff: true,
1741
+ seed: { ...preparedSeed, phase: "pre_send_rejected" },
1742
+ })
1743
+ ).claim;
1744
+ inFlight.phase = current.state;
1745
+ await this.#closeSpawnSubstrate(lifecycleIdentity);
1746
+ return error("spawn_failed", "session.spawn seed delivery was rejected before handoff");
1747
+ }
1748
+ current = (
1749
+ await store.persistTransition(lifecycleIdentity, {
1750
+ claimId: current.claimId,
1751
+ from: "dispatching",
1752
+ to: "uncertain",
1753
+ seed: { ...preparedSeed, phase: "uncertain" },
1754
+ })
1755
+ ).claim;
1756
+ inFlight.phase = current.state;
1757
+ return error("terminal_uncertain", "session.spawn seed delivery outcome is uncertain");
1758
+ } catch {
1759
+ // Once a substrate exists the outcome is ambiguous even before handoff:
1760
+ // reporting an ordinary failure would downgrade retained uncertainty.
1761
+ const ambiguous = handedOff || launchedProof !== undefined;
1762
+ // A launched substrate with no persisted authority is invisible to the
1763
+ // reaper, so in-process cleanup is its only chance. This must run on
1764
+ // EVERY post-launch failure exit, not just a returned registration
1765
+ // failure: a throw from awaitRegistration, verify, close, or a durable
1766
+ // transition all land here.
1767
+ if (!handedOff) await this.#releaseUnownedSubstrate(provider, launchedProof);
1768
+ return ambiguous
1769
+ ? error("terminal_uncertain", "session.spawn state could not be advanced durably")
1770
+ : error("spawn_failed", "session.spawn could not be advanced durably");
1771
+ }
1772
+ }
1773
+
1774
+ /**
1775
+ * Closes a substrate that was launched but never got a durable authority row.
1776
+ * The orphan reaper iterates authorities, so such a substrate has no durable
1777
+ * owner and would leak forever. `verify` and `close` are each contained: a
1778
+ * throw or a falsy close result must not skip the remaining work or escape.
1779
+ */
1780
+ async #releaseUnownedSubstrate(
1781
+ provider: SpawnSubstrateProvider,
1782
+ proof: SpawnSubstrateProof | undefined,
1783
+ ): Promise<void> {
1784
+ if (!proof) return;
1785
+ let verdict: "verified" | "mismatch" | "gone";
1786
+ try {
1787
+ verdict = await provider.verify(proof);
1788
+ } catch {
1789
+ // An unprovable substrate is never mutated; uncertainty is retained by
1790
+ // the caller's durable transition instead.
1791
+ return;
1792
+ }
1793
+ if (verdict !== "verified") return;
1794
+ try {
1795
+ await provider.close(proof);
1796
+ } catch {
1797
+ // The substrate may survive. The caller still records uncertainty, which
1798
+ // is the honest durable outcome for an unclosed unowned substrate.
1799
+ }
1800
+ }
1801
+
1802
+ /** Best-effort exact-close of a rejected claim's substrate; never name/PID-only. */
1803
+ async #closeSpawnSubstrate(lifecycleIdentity: string): Promise<void> {
1804
+ try {
1805
+ const authorityRecord = this.#spawnAuthority?.authority(lifecycleIdentity);
1806
+ if (authorityRecord?.closeState !== "active") return;
1807
+ const provider = this.#spawnSubstrateProvider();
1808
+ const proof = spawnProofFromAuthority(authorityRecord);
1809
+ if ((await provider.verify(proof)) !== "verified") return;
1810
+ await provider.close(proof);
1811
+ } catch {
1812
+ // Close is reconciled again by the reaper path; failure retains authority.
1813
+ }
1814
+ }
1815
+
1816
+ /**
1817
+ * Resolves a joined non-owner claim from stored facts only. dispatching+
1818
+ * replays exclusively through the stored opaque Q26 clientRef; unknown
1819
+ * outcomes stay retained-uncertain and never re-prompt.
1820
+ */
1821
+ async #reconcileSpawnReplay(lifecycleIdentity: string, claim: SpawnClaimV2): Promise<BrokerResponse> {
1822
+ const store = this.#spawnAuthority;
1823
+ if (!store) return error("unavailable", "spawn authority is unavailable");
1824
+ if (claim.state === "dispatching") {
1825
+ const childId = claim.childId;
1826
+ const seed = claim.seed;
1827
+ if (!childId || !seed) return error("terminal_uncertain", "session.spawn dispatch facts are unavailable");
1828
+ // Q26 replay must talk to the proven endpoint, not any live row with this
1829
+ // session id: a foreign same-id host could otherwise answer and have its
1830
+ // command/turn facts persisted as this claim's acceptance.
1831
+ const replayAuthority = store.authority(lifecycleIdentity);
1832
+ const replayPin = replayAuthority ? spawnPinFromAuthority(replayAuthority) : undefined;
1833
+ if (!replayPin)
1834
+ return error("terminal_uncertain", "session.spawn endpoint authority is unavailable for replay");
1835
+ const q26 = await this.#spawnPromptLayer.reconcile({
1836
+ sessionId: childId,
1837
+ clientRef: seed.clientRef,
1838
+ pinned: replayPin,
1839
+ });
1840
+ // A present-but-unequal clientRef means the responder answered for another
1841
+ // correlation; binding its facts would falsify this claim's acceptance.
1842
+ const refMismatch = q26.clientRef !== undefined && q26.clientRef !== seed.clientRef;
1843
+ if (
1844
+ !refMismatch &&
1845
+ (q26.status === "accepted" || q26.status === "in_flight" || q26.status === "terminal_ok") &&
1846
+ q26.commandId !== undefined &&
1847
+ q26.turnId !== undefined
1848
+ ) {
1849
+ try {
1850
+ const advanced = await store.persistTransition(lifecycleIdentity, {
1851
+ claimId: claim.claimId,
1852
+ from: "dispatching",
1853
+ to: "accepted",
1854
+ seed: {
1855
+ ...seed,
1856
+ phase: "accepted",
1857
+ commandId: q26.commandId,
1858
+ turnId: q26.turnId,
1859
+ acceptedAt: q26.acceptedAt ?? Date.now(),
1860
+ lastQ26Status: q26.status,
1861
+ observedAt: Date.now(),
1862
+ },
1863
+ });
1864
+ return { ok: true, result: this.#spawnResult("spawn_replayed", advanced.claim) };
1865
+ } catch {
1866
+ return error("terminal_uncertain", "session.spawn replay could not be advanced durably");
1867
+ }
1868
+ }
1869
+ if (!refMismatch && q26.status === "failed") {
1870
+ try {
1871
+ await store.persistSeedObservation(lifecycleIdentity, {
1872
+ ...seed,
1873
+ lastQ26Status: "failed",
1874
+ observedAt: Date.now(),
1875
+ });
1876
+ } catch {
1877
+ // The observation is best effort; the durable claim already proves dispatch.
1878
+ }
1879
+ return error("spawn_failed", "session.spawn seed turn failed after handoff");
1880
+ }
1881
+ try {
1882
+ await store.persistSeedObservation(lifecycleIdentity, {
1883
+ ...seed,
1884
+ lastQ26Status: "unknown",
1885
+ observedAt: Date.now(),
1886
+ });
1887
+ } catch {
1888
+ // Retained uncertainty never blocks the typed response below.
1889
+ }
1890
+ return error("terminal_uncertain", "session.spawn outcome is unknown");
1891
+ }
1892
+ if (claim.state === "substrate_starting")
1893
+ return error("terminal_uncertain", "session.spawn substrate state requires reconciliation");
1894
+ return error("spawn_in_progress", `session.spawn is ${claim.state}`);
1895
+ }
1896
+
1897
+ /**
1898
+ * Startup reconciliation of non-terminal spawn claims. It never creates a
1899
+ * replacement child or re-sends a prompt: substrate_starting without exact
1900
+ * authority proof retains uncertainty, authority_active durably allocates the
1901
+ * opaque clientRef so one recovery lease may retry, and dispatching resolves
1902
+ * through Q26 only.
1903
+ */
1904
+ async #recoverSpawnClaims(): Promise<void> {
1905
+ const store = this.#spawnAuthority;
1906
+ if (!store) return;
1907
+ for (const claim of store.claims()) {
1908
+ try {
1909
+ if (claim.state === "substrate_starting") {
1910
+ await store.persistTransition(claim.lifecycleIdentity, {
1911
+ claimId: claim.claimId,
1912
+ from: "substrate_starting",
1913
+ to: "uncertain",
1914
+ });
1915
+ continue;
1916
+ }
1917
+ if (claim.state === "authority_active") {
1918
+ // Only exact evidence that the original substrate is still active may
1919
+ // advance this claim; otherwise it retains uncertainty rather than
1920
+ // letting a later recovery owner prompt a replaced substrate.
1921
+ const authorityRecord = store.authority(claim.lifecycleIdentity);
1922
+ // A thrown verify is not evidence of a healthy substrate. Treat it
1923
+ // exactly like `gone`, so the claim retains uncertainty instead of
1924
+ // staying authority_active and later answering spawn_in_progress.
1925
+ let verdict: "verified" | "mismatch" | "gone" = "gone";
1926
+ // A row written before the endpoint pin existed carries no proof of
1927
+ // WHICH host answers on this session id. That is missing authority,
1928
+ // so the claim fails closed here rather than becoming eligible for a
1929
+ // recovery lease that would match by session id alone.
1930
+ if (authorityRecord && spawnPinFromAuthority(authorityRecord)) {
1931
+ try {
1932
+ verdict = await this.#spawnSubstrateProvider().verify(spawnProofFromAuthority(authorityRecord));
1933
+ } catch {
1934
+ verdict = "gone";
1935
+ }
1936
+ }
1937
+ if (verdict !== "verified") {
1938
+ await store.persistTransition(claim.lifecycleIdentity, {
1939
+ claimId: claim.claimId,
1940
+ from: "authority_active",
1941
+ to: "uncertain",
1942
+ });
1943
+ continue;
1944
+ }
1945
+ await store.persistTransition(claim.lifecycleIdentity, {
1946
+ claimId: claim.claimId,
1947
+ from: "authority_active",
1948
+ to: "seed_prepared",
1949
+ seed: { version: 2, phase: "prepared", clientRef: randomBytes(24).toString("base64url") },
1950
+ });
1951
+ continue;
1952
+ }
1953
+ if (claim.state === "dispatching") await this.#reconcileSpawnReplay(claim.lifecycleIdentity, claim);
1954
+ } catch {
1955
+ // Recovery is fail-closed: an unreconciled claim stays retained as-is.
1956
+ }
1957
+ }
1958
+ }
1959
+
1960
+ async #awaitSpawnHostRegistration(input: {
1961
+ childId: string;
1962
+ cwd: string;
1963
+ stateRoot: string;
1964
+ }): Promise<{ ok: true; registration: SpawnHostRegistration } | { ok: false }> {
1965
+ const deadline = Date.now() + SPAWN_HOST_REGISTRATION_TIMEOUT_MS;
1966
+ for (;;) {
1967
+ try {
1968
+ await this.index.refresh();
1969
+ // The launch locator is authority: a same-id row registered by an
1970
+ // unrelated workspace must never be adopted as this spawn's child.
1971
+ const row = this.index.listSessionIdentities().find(
1972
+ candidate =>
1973
+ candidate.sessionId === input.childId &&
1974
+ candidate.endpointGeneration > 0 &&
1975
+ candidate.live &&
1976
+ !candidate.terminal &&
1977
+ !candidate.terminalUncertain &&
1978
+ // Path IDENTITY, not spelling. The child publishes a
1979
+ // realpath-canonicalized locator while the launch spec carries the
1980
+ // caller's lexical path, so `path.resolve` never reconciles a
1981
+ // symlinked workspace (macOS /var vs /private/var) and a
1982
+ // legitimate child would never match.
1983
+ resolveEquivalentPath(candidate.locator.cwd) === resolveEquivalentPath(input.cwd) &&
1984
+ resolveEquivalentPath(candidate.locator.stateRoot) === resolveEquivalentPath(input.stateRoot),
1985
+ );
1986
+ if (row) {
1987
+ const incarnation = row.hostIncarnation ?? row.processIncarnation;
1988
+ // An incarnation-less row is incomplete endpoint evidence; a partial pin
1989
+ // previously just failed later at dispatch, so failing at registration keeps one boundary.
1990
+ if (incarnation !== undefined) {
1991
+ return {
1992
+ ok: true,
1993
+ registration: {
1994
+ sessionId: row.sessionId,
1995
+ endpointGeneration: row.endpointGeneration,
1996
+ pid: row.pid,
1997
+ processIncarnation: incarnation,
1998
+ cwd: row.locator.cwd,
1999
+ stateRoot: row.locator.stateRoot,
2000
+ },
2001
+ };
2002
+ }
2003
+ }
2004
+ } catch {
2005
+ // A transient index read failure only delays the poll.
2006
+ }
2007
+ if (Date.now() > deadline) return { ok: false };
2008
+ await Bun.sleep(SPAWN_HOST_REGISTRATION_POLL_MS);
2009
+ }
2010
+ }
2011
+
2012
+ /** One nonce-correlated frame exchange over a child host's authenticated endpoint. */
2013
+ async #spawnChildExchange(input: {
2014
+ sessionId: string;
2015
+ /** Endpoint identity captured at registration; the exchange refuses to talk to anything else. */
2016
+ pinned: SpawnHostRegistration;
2017
+ frame: (id: string) => Record<string, unknown>;
2018
+ responseType: string;
2019
+ timeoutMs: number;
2020
+ }): Promise<{ handedOff: boolean; frame?: Record<string, unknown> }> {
2021
+ let row: IndexedSession | undefined;
2022
+ try {
2023
+ await this.index.refresh();
2024
+ row = this.index.listSessionIdentities().find(
2025
+ candidate =>
2026
+ candidate.sessionId === input.sessionId &&
2027
+ candidate.endpointGeneration > 0 &&
2028
+ candidate.live &&
2029
+ !candidate.terminal &&
2030
+ !candidate.terminalUncertain &&
2031
+ // Bind to the exact endpoint proven at registration. Without this a
2032
+ // replaced host or an alternate endpoint for the same id could
2033
+ // receive the seed prompt or answer a Q26 reconciliation.
2034
+ // Every pin leg is required. Identity fields alone collide across
2035
+ // workspaces (pids are reused), so a complete pin is required evidence.
2036
+ matchesSpawnPin(candidate, input.pinned),
2037
+ );
2038
+ } catch {
2039
+ row = undefined;
2040
+ }
2041
+ if (!row) return { handedOff: false };
2042
+ const endpoint = await readEndpoint(row);
2043
+ if (!endpoint) return { handedOff: false };
2044
+ const id = randomBytes(16).toString("base64url");
2045
+ const settled = Promise.withResolvers<Record<string, unknown> | undefined>();
2046
+ let handedOff = false;
2047
+ let complete = false;
2048
+ const settle = (frame?: Record<string, unknown>): void => {
2049
+ if (complete) return;
2050
+ complete = true;
2051
+ settled.resolve(frame);
2052
+ };
2053
+ let socket: WebSocket | undefined;
2054
+ try {
2055
+ const url = new URL(endpoint.url);
2056
+ url.searchParams.set("token", endpoint.token);
2057
+ socket = new WebSocket(url);
2058
+ socket.addEventListener("error", () => settle(undefined));
2059
+ socket.addEventListener("close", () => settle(undefined));
2060
+ socket.addEventListener("message", event => {
2061
+ let frame: Record<string, unknown>;
2062
+ try {
2063
+ const parsed = JSON.parse(String(event.data));
2064
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
2065
+ frame = parsed as Record<string, unknown>;
2066
+ } catch {
2067
+ return;
2068
+ }
2069
+ if (frame.type === "hello") {
2070
+ try {
2071
+ socket?.send(JSON.stringify(input.frame(id)));
2072
+ handedOff = true;
2073
+ } catch {
2074
+ settle(undefined);
2075
+ }
2076
+ return;
2077
+ }
2078
+ if (frame.type !== input.responseType || frame.id !== id) return;
2079
+ settle(frame);
2080
+ });
2081
+ const frame = await Promise.race([settled.promise, Bun.sleep(input.timeoutMs).then(() => undefined)]);
2082
+ return { handedOff, frame };
2083
+ } catch {
2084
+ return { handedOff };
2085
+ } finally {
2086
+ try {
2087
+ socket?.close();
2088
+ } catch {
2089
+ // Closing an already failed attachment is best effort.
2090
+ }
2091
+ }
2092
+ }
2093
+
2094
+ async #dispatchSpawnPrompt(input: {
2095
+ sessionId: string;
2096
+ task: string;
2097
+ clientRef: string;
2098
+ pinned: SpawnHostRegistration;
2099
+ }): Promise<SpawnPromptDispatch> {
2100
+ const exchange = await this.#spawnChildExchange({
2101
+ sessionId: input.sessionId,
2102
+ pinned: input.pinned,
2103
+ frame: id => ({
2104
+ type: "control_request",
2105
+ id,
2106
+ operation: "turn.prompt",
2107
+ input: { text: input.task, clientRef: input.clientRef },
2108
+ }),
2109
+ responseType: "control_response",
2110
+ timeoutMs: SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS,
2111
+ });
2112
+ if (!exchange.handedOff) return { kind: "pre_send_rejected" };
2113
+ const frame = exchange.frame;
2114
+ if (!frame) return { kind: "uncertain" };
2115
+ if (frame.ok === true) {
2116
+ const acceptance = promptAcceptanceFromResponse(frame, input.clientRef);
2117
+ return acceptance === undefined
2118
+ ? { kind: "uncertain" }
2119
+ : { kind: "accepted", commandId: acceptance.commandId, turnId: acceptance.turnId, acceptedAt: Date.now() };
2120
+ }
2121
+ // An explicit rejection response proves the prompt never reached the turn loop.
2122
+ return frame.ok === false ? { kind: "pre_send_rejected" } : { kind: "uncertain" };
2123
+ }
2124
+
2125
+ async #reconcileSpawnPrompt(input: {
2126
+ sessionId: string;
2127
+ clientRef: string;
2128
+ pinned: SpawnHostRegistration;
2129
+ }): Promise<SpawnQ26Reconciliation> {
2130
+ const exchange = await this.#spawnChildExchange({
2131
+ sessionId: input.sessionId,
2132
+ pinned: input.pinned,
2133
+ frame: id => ({
2134
+ type: "query_request",
2135
+ id,
2136
+ query: "turn.result",
2137
+ input: { kind: "prompt", clientRef: input.clientRef },
2138
+ }),
2139
+ responseType: "query_response",
2140
+ timeoutMs: SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS,
2141
+ });
2142
+ const frame = exchange.frame;
2143
+ if (frame?.ok !== true) return { status: "unknown" };
2144
+ return q26FromResponse(frame);
2145
+ }
2146
+
2147
+ /**
2148
+ * Routes session.close for a spawn-created child through its claim/authority
2149
+ * record. Non-spawn sessions fall through to the generic lifecycle path.
2150
+ */
2151
+ async #maybeCloseSpawnChild(input: Record<string, unknown>): Promise<BrokerResponse | undefined> {
2152
+ const store = this.#spawnAuthority;
2153
+ // This runs BEFORE generic normalization, so it must resolve the same
2154
+ // `id` alias that normalizeBrokerInput accepts. Reading only `sessionId`
2155
+ // let `session.close {id}` fall through to the generic path, which can
2156
+ // signal the child PID without the provider's exact substrate proof.
2157
+ const alias = normalizeAliasedString(input, "sessionId", ["id"]);
2158
+ const sessionId = alias.error ? undefined : alias.value;
2159
+ if (!store || !sessionId) return undefined;
2160
+ const claim = store.claims().find(candidate => candidate.childId === sessionId);
2161
+ if (!claim) return undefined;
2162
+ const outcome = await this.#closeSpawnAuthority(claim.lifecycleIdentity);
2163
+ if (outcome === "closed") return { ok: true, result: { code: "spawn_child_closed", sessionId } };
2164
+ if (outcome === "uncertain")
2165
+ return error("terminal_uncertain", "session.close substrate identity could not be re-proven");
2166
+ return error("close_refused", "session.close could not complete for the spawned child");
2167
+ }
2168
+
2169
+ /**
2170
+ * Ordinary exact close for one spawn authority. It mutates only a re-proven
2171
+ * substrate; identity mismatch retains durable uncertainty and never falls
2172
+ * back to name-only or PID-only cleanup. Repeated close replays safely.
2173
+ */
2174
+ async #closeSpawnAuthority(lifecycleIdentity: string): Promise<"closed" | "uncertain" | "retained"> {
2175
+ const store = this.#spawnAuthority;
2176
+ if (!store) return "retained";
2177
+ try {
2178
+ const claim = store.claim(lifecycleIdentity);
2179
+ let authority = store.authority(lifecycleIdentity);
2180
+ if (!claim || !authority) return "retained";
2181
+ if (claim.state === "closed" && authority.closeState === "closed") return "closed";
2182
+ if (authority.closeState === "closed") return "closed";
2183
+ const provider = this.#spawnSubstrateProvider();
2184
+ const proof = spawnProofFromAuthority(authority);
2185
+ const verdict = await provider.verify(proof);
2186
+ if (verdict === "mismatch") {
2187
+ if (authority.closeState !== "uncertain") {
2188
+ const at = Math.max(Date.now(), authority.updatedAt + 1);
2189
+ await store.persistAuthority(lifecycleIdentity, {
2190
+ ...authority,
2191
+ closeState: "uncertain",
2192
+ updatedAt: at,
2193
+ });
2194
+ }
2195
+ return "uncertain";
2196
+ }
2197
+ if (authority.closeState === "active") {
2198
+ const at = Math.max(Date.now(), authority.updatedAt + 1);
2199
+ authority = (
2200
+ await store.persistAuthority(lifecycleIdentity, {
2201
+ ...authority,
2202
+ closeState: "close_requested",
2203
+ closeRequestedAt: at,
2204
+ updatedAt: at,
2205
+ })
2206
+ ).authority!;
2207
+ }
2208
+ if (verdict === "verified") {
2209
+ const closedSubstrate = await provider.close(proof);
2210
+ if (!closedSubstrate.ok) return "retained";
2211
+ // Signal delivery is not terminal evidence: only a second exact proof
2212
+ // that observes this substrate gone permits the durable close.
2213
+ if ((await provider.verify(proof)) !== "gone") return "retained";
2214
+ }
2215
+ const at = Math.max(Date.now(), authority.updatedAt + 1);
2216
+ const closedAuthority: SpawnAuthorityV1 = { ...authority, closeState: "closed", closedAt: at, updatedAt: at };
2217
+ const currentClaim = store.claim(lifecycleIdentity);
2218
+ if (!currentClaim) return "retained";
2219
+ if (currentClaim.state !== "closed")
2220
+ await store.persistTransition(lifecycleIdentity, {
2221
+ claimId: currentClaim.claimId,
2222
+ from: currentClaim.state,
2223
+ to: "closed",
2224
+ authority: closedAuthority,
2225
+ });
2226
+ return "closed";
2227
+ } catch {
2228
+ return "retained";
2229
+ }
2230
+ }
2231
+
2232
+ /**
2233
+ * Replays pending exact closes, then reaps children after confirmed master
2234
+ * loss. Orphan clocks survive recovery and expiry converges through the same
2235
+ * exact close path for only the matching owned child.
2236
+ */
2237
+ /** Deterministic single reap pass; used by maintenance paths and tests. */
2238
+ async reapSpawnOrphansOnce(): Promise<void> {
2239
+ await this.#reapSpawnOrphans();
2240
+ }
2241
+
2242
+ async #reapSpawnOrphans(): Promise<void> {
2243
+ if (this.#spawnReapInFlight || this.#stopping) return;
2244
+ this.#spawnReapInFlight = true;
2245
+ try {
2246
+ const store = this.#spawnAuthority;
2247
+ if (!store) return;
2248
+ const grace = this.settings.masterOrphanGraceMs;
2249
+ let rows: readonly IndexedSession[] | undefined;
2250
+ for (const claim of store.claims()) {
2251
+ const authority = store.authority(claim.lifecycleIdentity);
2252
+ if (!authority || (authority.closeState !== "active" && authority.closeState !== "close_requested"))
2253
+ continue;
2254
+ if (authority.closeState === "close_requested") {
2255
+ try {
2256
+ await this.#closeSpawnAuthority(claim.lifecycleIdentity);
2257
+ } catch {
2258
+ // One authority's reap failure never blocks the remaining scan.
2259
+ }
2260
+ continue;
2261
+ }
2262
+ if (rows === undefined) {
2263
+ try {
2264
+ await this.index.refresh();
2265
+ } catch {
2266
+ return;
2267
+ }
2268
+ rows = this.index.listSessionIdentities();
2269
+ }
2270
+ const masterAlive = rows.some(
2271
+ row =>
2272
+ row.endpointGeneration > 0 &&
2273
+ row.live &&
2274
+ !row.terminal &&
2275
+ !row.terminalUncertain &&
2276
+ row.masterRole?.role === "master" &&
2277
+ row.masterRole.ownerSessionId === authority.ownerSessionId,
2278
+ );
2279
+ const orphanPending =
2280
+ authority.orphanedAt !== undefined &&
2281
+ (authority.orphanRecoveredAt === undefined || authority.orphanRecoveredAt < authority.orphanedAt);
2282
+ const now = Date.now();
2283
+ try {
2284
+ if (masterAlive) {
2285
+ if (orphanPending) {
2286
+ const at = Math.max(now, authority.updatedAt + 1);
2287
+ await store.persistAuthority(claim.lifecycleIdentity, {
2288
+ ...authority,
2289
+ orphanRecoveredAt: at,
2290
+ updatedAt: at,
2291
+ });
2292
+ }
2293
+ continue;
2294
+ }
2295
+ if (!orphanPending) {
2296
+ const at = Math.max(now, authority.updatedAt + 1);
2297
+ const { orphanRecoveredAt: _cleared, ...rest } = authority;
2298
+ await store.persistAuthority(claim.lifecycleIdentity, { ...rest, orphanedAt: at, updatedAt: at });
2299
+ continue;
2300
+ }
2301
+ if (authority.orphanedAt !== undefined && now - authority.orphanedAt >= grace)
2302
+ await this.#closeSpawnAuthority(claim.lifecycleIdentity);
2303
+ } catch {
2304
+ // One authority's reap failure never blocks the remaining scan.
2305
+ }
2306
+ }
2307
+ } finally {
2308
+ this.#spawnReapInFlight = false;
2309
+ }
2310
+ }
2311
+
884
2312
  runStartup<T>(
885
2313
  queueWaitMs: number,
886
2314
  timing: StartupAdmissionTiming,
@@ -1023,6 +2451,8 @@ export class Broker {
1023
2451
  this.#resolveCompletion = completion.resolve;
1024
2452
  this.#rejectCompletion = completion.reject;
1025
2453
  this.#completionTask = null;
2454
+ if (this.#ownsResolveModelPin)
2455
+ this.#resolveModelPin = createDefaultSdkHostModelResolver(this.settings.agentDir);
1026
2456
  // A drained queue refuses every later startup by design, so a restarted broker
1027
2457
  // needs a new one or it would admit nothing for the rest of the process.
1028
2458
  this.#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
@@ -1080,6 +2510,16 @@ export class Broker {
1080
2510
  try {
1081
2511
  await this.index.open();
1082
2512
  await this.ledger.open();
2513
+ const brokerIdentityKey = await getBrokerIdentityKey(this.settings.agentDir);
2514
+ this.#spawnAuthority = new SpawnAuthorityStore(this.settings.agentDir, brokerIdentityKey);
2515
+ await this.#spawnAuthority.open();
2516
+ for (const claim of this.#spawnAuthority.claims()) {
2517
+ const mirror = this.ledger.get(claim.lifecycleIdentity);
2518
+ if (!mirror) await this.ledger.begin(claim.lifecycleIdentity, claim.bindingMac);
2519
+ else if (mirror.requestHash !== claim.bindingMac)
2520
+ throw new Error("Spawn claim lifecycle mirror binding differs from durable authority.");
2521
+ }
2522
+ await this.#recoverSpawnClaims();
1083
2523
  const now = Date.now();
1084
2524
  const incarnation = brokerProcessIncarnation(process.pid);
1085
2525
  if (!incarnation) throw new Error("Broker process incarnation is unavailable.");
@@ -1107,7 +2547,10 @@ export class Broker {
1107
2547
  10,
1108
2548
  Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
1109
2549
  );
1110
- this.#heartbeatTimer = setInterval(() => void this.#watchPublication(), cadenceMs);
2550
+ this.#heartbeatTimer = setInterval(() => {
2551
+ void this.#watchPublication();
2552
+ void this.#reapSpawnOrphans();
2553
+ }, cadenceMs);
1111
2554
  await this.#checkpointSessionHeartbeats();
1112
2555
  return this.discovery;
1113
2556
  } catch (error) {
@@ -1285,25 +2728,35 @@ export class Broker {
1285
2728
  if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
1286
2729
  this.#heartbeatTimer = null;
1287
2730
  this.#completionTask = (async () => {
1288
- await this.#transport?.stop();
1289
- this.#transport = null;
1290
- if (mode === "lost-root")
1291
- await Promise.race([Promise.allSettled(this.#admitted), Bun.sleep(BROKER_SETTLEMENT_MS)]);
1292
- else await Promise.allSettled(this.#admitted);
1293
- this.#publication?.close();
1294
- this.#publication = null;
1295
- if (mode === "owned-root" && this.discovery?.ownerId === this.#owner) {
1296
- try {
1297
- const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
1298
- ownerId?: string;
1299
- };
1300
- if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
1301
- } catch (e) {
1302
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
2731
+ try {
2732
+ await this.#transport?.stop();
2733
+ this.#transport = null;
2734
+ if (mode === "lost-root")
2735
+ await Promise.race([Promise.allSettled(this.#admitted), Bun.sleep(BROKER_SETTLEMENT_MS)]);
2736
+ else await Promise.allSettled(this.#admitted);
2737
+ this.#publication?.close();
2738
+ this.#publication = null;
2739
+ if (mode === "owned-root" && this.discovery?.ownerId === this.#owner) {
2740
+ try {
2741
+ const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
2742
+ ownerId?: string;
2743
+ };
2744
+ if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
2745
+ } catch (e) {
2746
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
2747
+ }
2748
+ await this.#releaseOwnedLock();
1303
2749
  }
1304
- await this.#releaseOwnedLock();
2750
+ } finally {
2751
+ const disposeModelPin = this.#ownsResolveModelPin ? this.#resolveModelPin.dispose?.() : undefined;
2752
+ if (mode === "lost-root" && disposeModelPin !== undefined) {
2753
+ void disposeModelPin.catch(() => undefined);
2754
+ await Promise.race([disposeModelPin, Bun.sleep(BROKER_SETTLEMENT_MS)]);
2755
+ } else {
2756
+ await disposeModelPin;
2757
+ }
2758
+ this.discovery = null;
1305
2759
  }
1306
- this.discovery = null;
1307
2760
  })();
1308
2761
  void this.#completionTask.then(this.#resolveCompletion, this.#rejectCompletion);
1309
2762
  return this.#completionTask;
@@ -1417,14 +2870,22 @@ export class Broker {
1417
2870
 
1418
2871
  try {
1419
2872
  const endpointPath = path.join(record.locator.stateRoot, "sdk", `${record.sessionId}.json`);
1420
- const [source, metadata] = await Promise.all([fs.readFile(endpointPath, "utf8"), fs.stat(endpointPath)]);
1421
- const endpoint = JSON.parse(source) as Record<string, unknown>;
2873
+ const file = await readEndpointFile(endpointPath);
2874
+ if (!file) {
2875
+ try {
2876
+ await fs.lstat(endpointPath);
2877
+ } catch (statError) {
2878
+ if ((statError as NodeJS.ErrnoException).code === "ENOENT")
2879
+ return error("resource_gone", "session endpoint record is gone");
2880
+ }
2881
+ return error("endpoint_stale", "session endpoint is stale");
2882
+ }
2883
+ const endpoint = JSON.parse(file.source) as Record<string, unknown>;
1422
2884
  if (
1423
2885
  endpoint.sessionId !== record.sessionId ||
1424
2886
  endpoint.pid !== record.pid ||
1425
2887
  endpoint.stale === true ||
1426
- record.endpointMtimeMs === undefined ||
1427
- Math.abs(metadata.mtimeMs - record.endpointMtimeMs) > 0.001
2888
+ !matchesIndexedEndpointFile(file, record)
1428
2889
  )
1429
2890
  return error("endpoint_stale", "session endpoint is stale");
1430
2891
  await this.index.refresh();
@@ -1438,6 +2899,139 @@ export class Broker {
1438
2899
  throw e;
1439
2900
  }
1440
2901
  }
2902
+ async #sessionControlAuthority(sessionId: string): Promise<BrokerSessionControlAuthority | BrokerResponse> {
2903
+ await this.index.refresh();
2904
+ const indexed = this.index.listSessions().sessions.find(session => session.sessionId === sessionId);
2905
+ if (
2906
+ !indexed ||
2907
+ !isSessionAuthorityEligible(indexed) ||
2908
+ !indexed.live ||
2909
+ indexed.terminal ||
2910
+ indexed.terminalUncertain
2911
+ )
2912
+ return error("resource_gone", "session endpoint record is gone");
2913
+ if (
2914
+ !Number.isSafeInteger(indexed.endpointGeneration) ||
2915
+ indexed.endpointGeneration <= 0 ||
2916
+ !Number.isSafeInteger(indexed.pid) ||
2917
+ indexed.pid <= 0 ||
2918
+ indexed.endpointMtimeMs === undefined ||
2919
+ !Number.isFinite(indexed.endpointMtimeMs) ||
2920
+ indexed.endpointMtimeMs <= 0 ||
2921
+ typeof indexed.lifecycleRequestId !== "string" ||
2922
+ indexed.lifecycleRequestId.length === 0 ||
2923
+ indexed.lifecycleRequestId.length > 128 ||
2924
+ !/^[A-Za-z0-9._-]+$/u.test(indexed.lifecycleRequestId) ||
2925
+ endpointIncarnation(indexed, indexed.sessionId) === undefined
2926
+ )
2927
+ return error("endpoint_stale", "session endpoint authority is incomplete");
2928
+ let endpointResponse: BrokerResponse;
2929
+ try {
2930
+ endpointResponse = await this.#readEndpoint(indexed, {});
2931
+ } catch {
2932
+ return error("endpoint_stale", "session endpoint is stale");
2933
+ }
2934
+ if (!endpointResponse.ok) return endpointResponse;
2935
+ const endpoint = objectRecord(endpointResponse.result);
2936
+ if (
2937
+ !endpoint ||
2938
+ endpoint.sessionId !== indexed.sessionId ||
2939
+ endpoint.pid !== indexed.pid ||
2940
+ typeof endpoint.url !== "string" ||
2941
+ endpoint.url.length === 0 ||
2942
+ typeof endpoint.token !== "string" ||
2943
+ endpoint.token.length === 0
2944
+ )
2945
+ return error("endpoint_stale", "session endpoint is malformed");
2946
+ try {
2947
+ const endpointIdentity = await fs.lstat(
2948
+ path.join(indexed.locator.stateRoot, "sdk", `${indexed.sessionId}.json`),
2949
+ { bigint: true },
2950
+ );
2951
+ if (!endpointIdentity.isFile()) return error("endpoint_stale", "session endpoint is not a regular file");
2952
+ return {
2953
+ record: indexed,
2954
+ endpoint,
2955
+ endpointIdentity: {
2956
+ dev: endpointIdentity.dev,
2957
+ ino: endpointIdentity.ino,
2958
+ size: endpointIdentity.size,
2959
+ mtimeNs: endpointIdentity.mtimeNs,
2960
+ ctimeNs: endpointIdentity.ctimeNs,
2961
+ },
2962
+ };
2963
+ } catch (caught) {
2964
+ if ((caught as NodeJS.ErrnoException).code === "ENOENT")
2965
+ return error("resource_gone", "session endpoint record is gone");
2966
+ return error("endpoint_stale", "session endpoint is stale");
2967
+ }
2968
+ }
2969
+ async #sessionControl(input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
2970
+ if (
2971
+ typeof idempotencyKey !== "string" ||
2972
+ idempotencyKey.length === 0 ||
2973
+ /[\u0000-\u001f\u007f]/u.test(idempotencyKey) ||
2974
+ new TextEncoder().encode(idempotencyKey).length > 128
2975
+ )
2976
+ return error("invalid_input", "idempotencyKey must be a bounded non-empty string");
2977
+ const request = brokerSessionControlRequest(input);
2978
+ if (isBrokerResponse(request)) return request;
2979
+ const target = `session.control\u0000${request.sessionId}`;
2980
+ const previous = this.#chains.get(target) ?? Promise.resolve();
2981
+ let release!: () => void;
2982
+ const current = new Promise<void>(resolve => (release = resolve));
2983
+ const chain = previous.then(() => current);
2984
+ this.#chains.set(target, chain);
2985
+ await previous;
2986
+ try {
2987
+ const authority = await this.#sessionControlAuthority(request.sessionId);
2988
+ if (isBrokerResponse(authority)) return authority;
2989
+ let client: SdkClient;
2990
+ try {
2991
+ client = await SdkClient.connect(authority.endpoint.url as string, authority.endpoint.token as string, {
2992
+ timeoutMs: BROKER_SESSION_CONTROL_TIMEOUT_MS,
2993
+ reconnectAttempts: 0,
2994
+ });
2995
+ } catch (caught) {
2996
+ return caught instanceof SdkClientError
2997
+ ? error(caught.code, caught.message)
2998
+ : error("unavailable", "session endpoint is unavailable");
2999
+ }
3000
+ try {
3001
+ const currentAuthority = await this.#sessionControlAuthority(request.sessionId);
3002
+ if (isBrokerResponse(currentAuthority)) return currentAuthority;
3003
+ if (!sameSessionControlAuthority(authority, currentAuthority))
3004
+ return error("endpoint_stale", "session endpoint changed before control dispatch");
3005
+ const response = await client.control(
3006
+ "turn.abort",
3007
+ {
3008
+ ...request.abortInput,
3009
+ [BROKER_RUNTIME_ABORT_CAPABILITY_FIELD]: authority.record.lifecycleRequestId,
3010
+ },
3011
+ {
3012
+ confirm: true,
3013
+ idempotencyKey,
3014
+ beforeDispatch: () => {
3015
+ if (!this.runSynchronousEffectWithFreshPublicationAuthority(() => undefined).authorized)
3016
+ throw new SdkClientError("unavailable", "broker publication is unavailable");
3017
+ },
3018
+ },
3019
+ );
3020
+ return brokerControlResponse(response);
3021
+ } catch (caught) {
3022
+ return caught instanceof SdkClientError
3023
+ ? error(caught.code, caught.message)
3024
+ : error("unavailable", "session endpoint control is unavailable");
3025
+ } finally {
3026
+ await client.close().catch(() => undefined);
3027
+ }
3028
+ } catch {
3029
+ return error("unavailable", "session control is unavailable");
3030
+ } finally {
3031
+ release();
3032
+ if (this.#chains.get(target) === chain) this.#chains.delete(target);
3033
+ }
3034
+ }
1441
3035
  #storeSessionListCursor(cursor: SessionListCursor, replacingToken?: string): string | BrokerResponse {
1442
3036
  const now = Date.now();
1443
3037
  for (const [token, stored] of this.#sessionListCursors) {
@@ -1452,7 +3046,7 @@ export class Broker {
1452
3046
  return token;
1453
3047
  }
1454
3048
 
1455
- #sessionListPage(input: Record<string, unknown>, result: SessionList): BrokerResponse {
3049
+ async #sessionListPage(input: Record<string, unknown>, result: SessionList): Promise<BrokerResponse> {
1456
3050
  const requestedLimit = input.limit === undefined ? undefined : sessionListLimit(input);
1457
3051
  if (isBrokerResponse(requestedLimit)) return requestedLimit;
1458
3052
  const cursor = input.cursor;
@@ -1463,14 +3057,46 @@ export class Broker {
1463
3057
  }
1464
3058
  if (stored && requestedLimit !== undefined && stored.limit !== requestedLimit)
1465
3059
  return error("invalid_input", "limit must match the cursor page shape");
3060
+ let scope: ResolvedScopeV1 | undefined;
3061
+ if (stored?.scope !== undefined) {
3062
+ if (input.scope !== undefined) {
3063
+ const supplied = scopeRequestV1(input.scope);
3064
+ if (!supplied) return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
3065
+ try {
3066
+ scope = await resolveScopeRequest(supplied);
3067
+ } catch (cause) {
3068
+ if (cause instanceof ScopeRequestValidationError)
3069
+ return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
3070
+ throw cause;
3071
+ }
3072
+ if (JSON.stringify(scope) !== JSON.stringify(stored.scope))
3073
+ return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
3074
+ }
3075
+ scope = stored.scope;
3076
+ } else if (input.scope !== undefined) {
3077
+ if (stored !== undefined) return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
3078
+ const request = scopeRequestV1(input.scope);
3079
+ if (!request) return error("invalid_input", "scope must be a valid ScopeRequestV1");
3080
+ try {
3081
+ scope = await resolveScopeRequest(request);
3082
+ } catch (cause) {
3083
+ if (cause instanceof ScopeRequestValidationError) return error("invalid_input", cause.message);
3084
+ throw cause;
3085
+ }
3086
+ }
1466
3087
  const limit = stored?.limit ?? requestedLimit ?? SESSION_LIST_DEFAULT_LIMIT;
3088
+ const observedAt = stored?.observedAt ?? (scope === undefined ? undefined : new Date().toISOString());
1467
3089
  const snapshot = stored ?? {
1468
- sessions: [...result.sessions],
3090
+ sessions:
3091
+ scope === undefined
3092
+ ? [...result.sessions]
3093
+ : result.sessions.filter(session => scopeMatchesLocator(scope, session.locator)),
1469
3094
  indexSeq: result.indexSeq,
1470
3095
  warnings: [...result.warnings],
1471
3096
  limit,
1472
3097
  offset: 0,
1473
3098
  expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS,
3099
+ ...(scope === undefined ? {} : { scope, observedAt: observedAt! }),
1474
3100
  };
1475
3101
  const sessions = snapshot.sessions.slice(snapshot.offset, snapshot.offset + snapshot.limit).map(session => {
1476
3102
  const { lifecycleRequestId: _lifecycleRequestId, ...publicSession } = session;
@@ -1492,6 +3118,7 @@ export class Broker {
1492
3118
  indexSeq: snapshot.indexSeq,
1493
3119
  sessions,
1494
3120
  warnings: snapshot.warnings,
3121
+ ...(snapshot.scope === undefined ? {} : { scope: snapshot.scope, observedAt: snapshot.observedAt }),
1495
3122
  ...(continuationCursor ? { continuationCursor } : {}),
1496
3123
  },
1497
3124
  indexSeq: snapshot.indexSeq,
@@ -1514,13 +3141,21 @@ export class Broker {
1514
3141
  idempotencyKey?: string,
1515
3142
  ): Promise<BrokerResponse> {
1516
3143
  if (this.#stopping) return error("broker_restarting", "broker is stopping");
3144
+ // Spawn bypasses generic raw-input transforms. They must never observe task
3145
+ // or capability fields, even transiently.
3146
+ if (operation === "session.spawn") return this.#handleSpawn(input, idempotencyKey);
3147
+ if (operation === "session.close") {
3148
+ const spawnClose = await this.#maybeCloseSpawnChild(input);
3149
+ if (spawnClose) return spawnClose;
3150
+ }
3151
+ if (operation === "session.control") return this.#sessionControl(input, idempotencyKey);
1517
3152
  const fingerprint = lifecycleFingerprint(operation, input);
1518
3153
  const normalization = normalizeBrokerInput(operation, input);
1519
3154
  if (isBrokerResponse(normalization)) return normalization;
1520
3155
  input = normalization.input;
1521
3156
  if (operation === "session.list") {
1522
3157
  if (input.cursor === undefined) await this.index.refresh();
1523
- const page = this.#sessionListPage(input, this.index.listSessions());
3158
+ const page = await this.#sessionListPage(input, this.index.listSessions());
1524
3159
  if (!page.ok) return page;
1525
3160
  const pageResult = page.result as Record<string, unknown>;
1526
3161
  const resolveSessionId = typeof input.resolveSessionId === "string" ? input.resolveSessionId : undefined;
@@ -1578,6 +3213,8 @@ export class Broker {
1578
3213
  const requestedFingerprint = typeof input.fingerprint === "string" ? input.fingerprint : undefined;
1579
3214
  if (!idempotencyKey || !requestedOperation || !requestedFingerprint)
1580
3215
  return error("invalid_input", "operation, idempotencyKey, and fingerprint are required");
3216
+ if (requestedOperation === "session.spawn")
3217
+ return error("invalid_input", "session.spawn does not support lifecycle lookup");
1581
3218
  if (!LIFECYCLE_OPERATIONS.has(requestedOperation))
1582
3219
  return error("not_found", "lifecycle operation was not found");
1583
3220
  const identity = await deriveIdempotencyIdentity(this.settings.agentDir, requestedOperation, idempotencyKey);