@gajae-code/coding-agent 0.15.6 → 0.16.1

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