@gajae-code/coding-agent 0.12.21 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/dist/types/cli/args.d.ts +5 -2
  3. package/dist/types/cli/root-flags.d.ts +246 -0
  4. package/dist/types/cli.d.ts +11 -19
  5. package/dist/types/commands/daemon.d.ts +0 -30
  6. package/dist/types/commands/launch.d.ts +11 -19
  7. package/dist/types/commands/quick-lane.d.ts +32 -0
  8. package/dist/types/commands/sdk.d.ts +21 -15
  9. package/dist/types/config/file-lock-gc.d.ts +2 -0
  10. package/dist/types/config/model-equivalence.d.ts +20 -0
  11. package/dist/types/config/model-profile-activation.d.ts +62 -17
  12. package/dist/types/config/model-profiles.d.ts +10 -0
  13. package/dist/types/config/model-registry.d.ts +68 -1
  14. package/dist/types/config/model-resolver.d.ts +15 -4
  15. package/dist/types/config/models-config-schema.d.ts +7 -2
  16. package/dist/types/config/provider-selection-policy.d.ts +62 -0
  17. package/dist/types/config/settings-schema.d.ts +36 -78
  18. package/dist/types/config/settings.d.ts +2 -0
  19. package/dist/types/edit/renderer.d.ts +5 -2
  20. package/dist/types/edit/streaming.d.ts +13 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  22. package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
  23. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
  25. package/dist/types/internal-urls/local-protocol.d.ts +1 -0
  26. package/dist/types/modes/components/model-selector.d.ts +0 -2
  27. package/dist/types/modes/components/provider-order-context.d.ts +45 -0
  28. package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
  29. package/dist/types/modes/components/settings-defs.d.ts +5 -1
  30. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  31. package/dist/types/quick-lane/classify.d.ts +40 -0
  32. package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
  33. package/dist/types/sdk/broker/broker.d.ts +56 -2
  34. package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
  35. package/dist/types/sdk/broker/session-index.d.ts +83 -2
  36. package/dist/types/sdk/broker/state-version.d.ts +1 -1
  37. package/dist/types/sdk/bus/notification-service.d.ts +6 -0
  38. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
  41. package/dist/types/sdk/cli/rows.d.ts +98 -0
  42. package/dist/types/sdk/cli/session-cli.d.ts +35 -3
  43. package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
  44. package/dist/types/sdk/elevation/audit.d.ts +93 -0
  45. package/dist/types/sdk/elevation/capability.d.ts +3 -0
  46. package/dist/types/sdk/elevation/digest.d.ts +72 -0
  47. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
  48. package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
  49. package/dist/types/sdk/elevation/owner.d.ts +48 -0
  50. package/dist/types/sdk/guides/cache.d.ts +98 -0
  51. package/dist/types/sdk/guides/catalog.d.ts +156 -0
  52. package/dist/types/sdk/guides/cli.d.ts +39 -0
  53. package/dist/types/sdk/guides/manifest.d.ts +66 -0
  54. package/dist/types/sdk/guides/verify.d.ts +66 -0
  55. package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
  56. package/dist/types/sdk/host/control/index.d.ts +1 -1
  57. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  58. package/dist/types/sdk/host/query/cursor.d.ts +10 -0
  59. package/dist/types/sdk/host/query/handlers.d.ts +21 -0
  60. package/dist/types/sdk/host/session-runtime.d.ts +9 -0
  61. package/dist/types/sdk/mcp/server.d.ts +2 -2
  62. package/dist/types/sdk/prompt-status.d.ts +11 -0
  63. package/dist/types/sdk/session.d.ts +4 -0
  64. package/dist/types/sdk/transport/index.d.ts +1 -0
  65. package/dist/types/sdk/transport/relay.d.ts +1 -0
  66. package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
  67. package/dist/types/session/agent-session.d.ts +32 -18
  68. package/dist/types/session/blob-store.d.ts +13 -7
  69. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  70. package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
  71. package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
  72. package/dist/types/session/session-manager.d.ts +139 -10
  73. package/dist/types/session/session-storage.d.ts +48 -0
  74. package/dist/types/setup/provider-onboarding.d.ts +7 -1
  75. package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
  76. package/dist/types/stt/setup.d.ts +2 -0
  77. package/dist/types/task/executor.d.ts +10 -2
  78. package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
  79. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  80. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  81. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  82. package/dist/types/tools/browser.d.ts +2 -0
  83. package/dist/types/tools/image-gen.d.ts +14 -18
  84. package/dist/types/tools/index.d.ts +2 -0
  85. package/dist/types/tools/resource-gc.d.ts +2 -0
  86. package/dist/types/utils/pasted-image-path.d.ts +6 -0
  87. package/package.json +9 -9
  88. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  89. package/scripts/run-test-manifest.ts +61 -21
  90. package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
  91. package/src/cli/args.ts +62 -9
  92. package/src/cli/completion-cli.ts +1 -0
  93. package/src/cli/fast-help.ts +1 -0
  94. package/src/cli/root-flags.ts +100 -0
  95. package/src/cli/setup-cli.ts +6 -2
  96. package/src/cli.ts +42 -65
  97. package/src/commands/acp.ts +1 -1
  98. package/src/commands/daemon.ts +2 -31
  99. package/src/commands/gc.ts +1 -1
  100. package/src/commands/launch.ts +8 -129
  101. package/src/commands/quick-lane.ts +51 -0
  102. package/src/commands/sdk.ts +200 -26
  103. package/src/commands/setup.ts +4 -2
  104. package/src/config/file-lock-gc.ts +4 -1
  105. package/src/config/model-equivalence.ts +76 -7
  106. package/src/config/model-profile-activation.ts +1115 -265
  107. package/src/config/model-profiles.ts +134 -2
  108. package/src/config/model-registry.ts +352 -50
  109. package/src/config/model-resolver.ts +289 -38
  110. package/src/config/models-config-schema.ts +11 -4
  111. package/src/config/provider-selection-policy.ts +146 -0
  112. package/src/config/settings-schema.ts +50 -77
  113. package/src/config/settings.ts +40 -0
  114. package/src/coordinator-mcp/server.ts +32 -2
  115. package/src/edit/renderer.ts +175 -223
  116. package/src/edit/streaming.ts +108 -1
  117. package/src/export/html/index.ts +57 -7
  118. package/src/extensibility/custom-tools/types.ts +2 -0
  119. package/src/extensibility/extensions/compact-handler.ts +3 -2
  120. package/src/extensibility/extensions/runner.ts +6 -0
  121. package/src/extensibility/extensions/types.ts +3 -0
  122. package/src/gjc-runtime/gc-runtime.ts +379 -20
  123. package/src/gjc-runtime/launch-worktree.ts +5 -1
  124. package/src/gjc-runtime/team-runtime.ts +59 -2
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
  126. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
  127. package/src/internal-urls/docs-index.generated.ts +17 -13
  128. package/src/internal-urls/local-protocol.ts +1 -0
  129. package/src/main.ts +40 -3
  130. package/src/memories/index.ts +4 -2
  131. package/src/modes/acp/acp-agent.ts +235 -91
  132. package/src/modes/components/model-selector.ts +206 -42
  133. package/src/modes/components/provider-order-context.ts +197 -0
  134. package/src/modes/components/provider-order-editor.ts +441 -0
  135. package/src/modes/components/settings-defs.ts +21 -1
  136. package/src/modes/components/settings-selector.ts +45 -2
  137. package/src/modes/components/tool-execution.ts +47 -15
  138. package/src/modes/controllers/command-controller.ts +6 -3
  139. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  140. package/src/modes/controllers/input-controller.ts +73 -2
  141. package/src/modes/controllers/selector-controller.ts +260 -153
  142. package/src/modes/prompt-suggestion-controller.ts +1 -1
  143. package/src/modes/runtime-init.ts +1 -0
  144. package/src/modes/utils/ui-helpers.ts +18 -0
  145. package/src/prompts/tools/browser.md +1 -0
  146. package/src/quick-lane/classify.ts +257 -0
  147. package/src/rlm/index.ts +1 -1
  148. package/src/runtime/stt-settings-setup.ts +57 -0
  149. package/src/sdk/acp/adapter.ts +2 -1
  150. package/src/sdk/broker/broker.ts +780 -12
  151. package/src/sdk/broker/lifecycle.ts +70 -29
  152. package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
  153. package/src/sdk/broker/session-index.ts +463 -48
  154. package/src/sdk/broker/state-version.ts +13 -9
  155. package/src/sdk/broker/transport.ts +14 -1
  156. package/src/sdk/bus/index.ts +34 -13
  157. package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
  158. package/src/sdk/bus/notification-service.ts +47 -1
  159. package/src/sdk/bus/prompt-reconciliation.ts +22 -3
  160. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  161. package/src/sdk/bus/telegram-daemon.ts +54 -3
  162. package/src/sdk/cli/rows.ts +247 -0
  163. package/src/sdk/cli/session-cli.ts +930 -79
  164. package/src/sdk/elevation/allowlist.ts +54 -0
  165. package/src/sdk/elevation/audit.ts +208 -0
  166. package/src/sdk/elevation/capability.ts +41 -0
  167. package/src/sdk/elevation/digest.ts +129 -0
  168. package/src/sdk/elevation/dispatch-receipt.ts +184 -0
  169. package/src/sdk/elevation/grant-ledger.ts +1142 -0
  170. package/src/sdk/elevation/owner.ts +109 -0
  171. package/src/sdk/guides/cache.ts +363 -0
  172. package/src/sdk/guides/catalog.ts +554 -0
  173. package/src/sdk/guides/cli.ts +235 -0
  174. package/src/sdk/guides/manifest.ts +152 -0
  175. package/src/sdk/guides/verify.ts +178 -0
  176. package/src/sdk/host/control/dispatch.ts +49 -1
  177. package/src/sdk/host/control/index.ts +1 -0
  178. package/src/sdk/host/control/operations.ts +8 -1
  179. package/src/sdk/host/query/cursor.ts +47 -3
  180. package/src/sdk/host/query/handlers.ts +155 -2
  181. package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
  182. package/src/sdk/host/session-runtime.test.ts +391 -1
  183. package/src/sdk/host/session-runtime.ts +308 -73
  184. package/src/sdk/host/surface-policy.ts +3 -0
  185. package/src/sdk/host/websocket-transport.ts +7 -1
  186. package/src/sdk/mcp/server.ts +88 -15
  187. package/src/sdk/prompt-status.ts +8 -0
  188. package/src/sdk/protocol/operation-inventory.generated.json +62 -0
  189. package/src/sdk/protocol/operation-registry.ts +3 -2
  190. package/src/sdk/session.ts +87 -39
  191. package/src/sdk/transport/index.ts +1 -0
  192. package/src/sdk/transport/relay.ts +3 -0
  193. package/src/sdk/transport/serve-cli.ts +111 -24
  194. package/src/secrets/obfuscator.ts +92 -12
  195. package/src/session/agent-session.ts +322 -93
  196. package/src/session/blob-store.ts +60 -8
  197. package/src/session/fallback-chain-controller.ts +35 -0
  198. package/src/session/internal/managed-session-scope.ts +25 -3
  199. package/src/session/internal/managed-session-storage.ts +574 -73
  200. package/src/session/internal/session-memory-sidecar.ts +19 -29
  201. package/src/session/session-manager.ts +2166 -743
  202. package/src/session/session-storage.ts +715 -48
  203. package/src/setup/provider-onboarding.ts +19 -3
  204. package/src/setup/provider-presets.json +3 -1
  205. package/src/slash-commands/builtin-registry.ts +85 -3
  206. package/src/stt/downloader.ts +2 -3
  207. package/src/stt/setup.ts +29 -4
  208. package/src/task/executor.ts +22 -18
  209. package/src/task/index.ts +24 -1
  210. package/src/tools/archive-reader.ts +11 -2
  211. package/src/tools/browser/runtime-diagnostics.ts +249 -0
  212. package/src/tools/browser/tab-protocol.ts +4 -0
  213. package/src/tools/browser/tab-supervisor.ts +5 -0
  214. package/src/tools/browser/tab-worker.ts +36 -1
  215. package/src/tools/browser.ts +7 -0
  216. package/src/tools/image-gen.ts +110 -166
  217. package/src/tools/index.ts +2 -0
  218. package/src/tools/read.ts +1 -1
  219. package/src/tools/resource-gc.ts +6 -1
  220. package/src/tools/tool-catalog.generated.ts +5 -1
  221. package/src/utils/pasted-image-path.ts +93 -0
  222. package/src/web/search/index.ts +3 -3
@@ -6,6 +6,25 @@ import path from "node:path";
6
6
  import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
7
7
  import { logger } from "@gajae-code/utils";
8
8
  import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
9
+ import { SdkClient, SdkClientError } from "../client";
10
+ import { elevationAuthorityPath, signElevationCapability } from "../elevation/capability";
11
+ import { type ElevationOperation, elevationRequestDigest, isElevatableOperation } from "../elevation/digest";
12
+ import {
13
+ type ElevationAnswerAuthority,
14
+ type ElevationClaimIdentity,
15
+ type ElevationDispatchOutcome,
16
+ isElevationRequestId,
17
+ } from "../elevation/dispatch-receipt";
18
+ import {
19
+ type ElevationAnswerValue,
20
+ type ElevationGrantRecord,
21
+ ElevationLedger,
22
+ type ElevationRequester,
23
+ type ElevationResult,
24
+ type ElevationSessionIdentity,
25
+ } from "../elevation/grant-ledger";
26
+ import { type BrokerOwnerPrincipal, readBrokerOwnerPrincipal } from "../elevation/owner";
27
+ import { findOperation } from "../protocol/operation-registry";
9
28
  import {
10
29
  type DirectoryMigrationPolicy,
11
30
  listManagedSessionCandidates,
@@ -28,14 +47,14 @@ import {
28
47
  } from "./discovery";
29
48
  import { deriveIdempotencyIdentity } from "./identity";
30
49
  import { canonicalDeleteLocatorPath, executeLifecycle, isCanonicalSessionId } from "./lifecycle";
31
-
32
50
  import {
33
51
  type LifecycleDurableEffectsReceipt,
34
52
  LifecycleLedger,
35
53
  type LifecycleStartupFailureReceipt,
36
54
  type LifecycleState,
37
55
  } from "./lifecycle-ledger";
38
- import { type IndexedSession, SessionIndex } from "./session-index";
56
+ import { OperationReceiptLedger, operationReceiptDigest, operationReceiptKey } from "./operation-receipt-ledger";
57
+ import { type IndexedSession, SessionIndex, type SessionList } from "./session-index";
39
58
  import { BrokerTransport } from "./transport";
40
59
 
41
60
  export interface BrokerSettings {
@@ -71,6 +90,7 @@ export type BrokerErrorCode =
71
90
  | "not_found"
72
91
  | "live_session"
73
92
  | "cleanup_pending"
93
+ | "ambiguous_session"
74
94
  | (string & {});
75
95
 
76
96
  export type BrokerCleanupIdentity = {
@@ -170,6 +190,35 @@ export type BrokerResponse =
170
190
  };
171
191
  const error = (code: BrokerErrorCode, message: string): BrokerResponse => ({ ok: false, error: { code, message } });
172
192
 
193
+ function elevationResultToBroker<T>(result: ElevationResult<T>): BrokerResponse {
194
+ if (result.ok) return { ok: true, result: result.value };
195
+ return error(result.error.code as BrokerErrorCode, result.error.message);
196
+ }
197
+
198
+ function elevationAnswerToBroker(result: ElevationResult<ElevationAnswerValue>): BrokerResponse {
199
+ if (!result.ok) return elevationResultToBroker(result);
200
+ const { outcome, grant } = result.value;
201
+ if (outcome === "granted")
202
+ return { ok: true, result: { elevationRequestId: grant.elevationRequestId, state: grant.state, outcome, grant } };
203
+ return error(outcome as BrokerErrorCode, `elevation ${outcome}: ${grant.elevationRequestId}`);
204
+ }
205
+
206
+ /**
207
+ * Result of resolving the session behind an elevation grant/request. An
208
+ * ambiguous session (C6) can never bind an exact endpoint identity.
209
+ */
210
+ type ElevationSessionResolution =
211
+ | { kind: "identity"; identity: ElevationSessionIdentity }
212
+ | { kind: "ambiguous" }
213
+ | { kind: "unavailable" };
214
+ function sameElevationSessionIdentity(left: ElevationSessionIdentity, right: ElevationSessionIdentity): boolean {
215
+ return (
216
+ left.sessionId === right.sessionId &&
217
+ path.resolve(left.endpointStateRoot) === path.resolve(right.endpointStateRoot) &&
218
+ left.endpointGeneration === right.endpointGeneration &&
219
+ left.endpointIncarnation === right.endpointIncarnation
220
+ );
221
+ }
173
222
  function isCleanupPending(response: BrokerResponse): boolean {
174
223
  return !response.ok && response.error.code === "cleanup_pending" && response.error.cleanup !== undefined;
175
224
  }
@@ -190,6 +239,28 @@ function lifecycleResponseState(response: BrokerResponse): LifecycleState {
190
239
 
191
240
  type InputNormalization = { input: Record<string, unknown> } | BrokerResponse;
192
241
 
242
+ type SessionListCursor = {
243
+ sessions: IndexedSession[];
244
+ indexSeq: number;
245
+ warnings: string[];
246
+ limit: number;
247
+ offset: number;
248
+ expiresAt: number;
249
+ };
250
+
251
+ const SESSION_LIST_DEFAULT_LIMIT = 100;
252
+ const SESSION_LIST_MAX_LIMIT = 100;
253
+ const SESSION_LIST_CURSOR_TTL_MS = 15 * 60 * 1_000;
254
+ const SESSION_LIST_MAX_CURSORS = 32;
255
+
256
+ function sessionListLimit(input: Record<string, unknown>): number | BrokerResponse {
257
+ const limit = input.limit;
258
+ if (limit === undefined) return SESSION_LIST_DEFAULT_LIMIT;
259
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > SESSION_LIST_MAX_LIMIT)
260
+ return error("invalid_input", `limit must be a safe integer from 1 to ${SESSION_LIST_MAX_LIMIT}`);
261
+ return limit;
262
+ }
263
+
193
264
  function isBrokerResponse(value: unknown): value is BrokerResponse {
194
265
  return typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean";
195
266
  }
@@ -234,6 +305,10 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
234
305
  const resolved = input.resolveSessionId;
235
306
  if (resolved !== undefined && (typeof resolved !== "string" || !isCanonicalSessionId(resolved)))
236
307
  return error("invalid_input", "resolveSessionId must be a canonical safe identifier");
308
+ if (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length === 0))
309
+ return error("invalid_input", "cursor must be a non-empty opaque string");
310
+ const limit = sessionListLimit(input);
311
+ if (isBrokerResponse(limit)) return limit;
237
312
  return { input: normalized };
238
313
  }
239
314
  if (
@@ -389,6 +464,7 @@ function lifecycleTarget(operation: string, input: Record<string, unknown>): unk
389
464
  }
390
465
 
391
466
  const BROKER_LOCK_RECORD = "owner.json";
467
+ const BROKER_INDEX_COMPACT_INTERVAL_MS = 6 * 60 * 60 * 1_000;
392
468
  const BROKER_LOCK_STARTUP_WAIT_MS = 1_000;
393
469
  const BROKER_LOCK_RETRY_MS = 10;
394
470
 
@@ -696,14 +772,32 @@ const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
696
772
  const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
697
773
  const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
698
774
  const lockArtifactGraceOverridesForTest = new WeakMap<Broker, number>();
775
+ function elevationLedgerEnabledForBroker(): boolean {
776
+ const raw = (process.env.GJC_SDK_ELEVATION_ENABLED ?? "").trim().toLowerCase();
777
+ return raw === "1" || raw === "true";
778
+ }
779
+
780
+ /** Recursive reserved-key rejection: the claim selector never travels inside approved input. */
781
+ function containsReservedKey(value: unknown, key: string, seen = new Set<object>()): boolean {
782
+ if (!value || typeof value !== "object" || seen.has(value)) return false;
783
+ seen.add(value);
784
+ if (Array.isArray(value)) return value.some(item => containsReservedKey(item, key, seen));
785
+ return (
786
+ Object.hasOwn(value, key) ||
787
+ Object.values(value as Record<string, unknown>).some(nested => containsReservedKey(nested, key, seen))
788
+ );
789
+ }
699
790
 
700
791
  export class Broker {
701
792
  readonly settings: ResolvedBrokerSettings;
702
793
  readonly index: SessionIndex;
703
794
  readonly ledger: LifecycleLedger;
795
+ readonly elevation: ElevationLedger;
796
+ readonly operationReceipts: OperationReceiptLedger;
704
797
  discovery: BrokerDiscovery | null = null;
705
798
  #lock: string;
706
799
  #owner = randomBytes(12).toString("hex");
800
+ #sessionListCursors = new Map<string, SessionListCursor>();
707
801
  #chains = new Map<string, Promise<void>>();
708
802
  #admitted = new Set<Promise<void>>();
709
803
  #startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
@@ -718,6 +812,12 @@ export class Broker {
718
812
  #completion!: Promise<void>;
719
813
  #resolveCompletion!: () => void;
720
814
  #rejectCompletion!: (error: unknown) => void;
815
+ /** Broker tenure counter: increments per start so a restart is a new epoch. */
816
+ #epoch = 0;
817
+ #incarnation: string | null = null;
818
+ #elevationEnforced: boolean;
819
+ #compactTimer: NodeJS.Timeout | null = null;
820
+ #operatorTimer: NodeJS.Timeout | null = null;
721
821
  constructor(settings: BrokerSettings) {
722
822
  this.settings = {
723
823
  agentDir: settings.agentDir,
@@ -728,7 +828,10 @@ export class Broker {
728
828
  };
729
829
  this.index = new SessionIndex(settings.agentDir);
730
830
  this.ledger = new LifecycleLedger(settings.agentDir);
831
+ this.elevation = new ElevationLedger(settings.agentDir);
832
+ this.operationReceipts = new OperationReceiptLedger(settings.agentDir);
731
833
  this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
834
+ this.#elevationEnforced = elevationLedgerEnabledForBroker();
732
835
  const completion = Promise.withResolvers<void>();
733
836
  this.#completion = completion.promise;
734
837
  this.#resolveCompletion = completion.resolve;
@@ -881,10 +984,15 @@ export class Broker {
881
984
  this.#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
882
985
  }
883
986
  this.#stopping = false;
987
+ this.#epoch += 1;
884
988
  this.#publicationState = "healthy-owned";
885
989
  this.#lossAt = null;
886
990
  this.#ambiguousAt = null;
887
- await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
991
+ await Promise.all([
992
+ this.ledger.assertSupportedStateVersions(),
993
+ this.elevation.assertSupportedStateVersions(),
994
+ readBrokerDiscovery(this.settings.agentDir),
995
+ ]);
888
996
  await fs.mkdir(path.dirname(this.#lock), { recursive: true, mode: 0o700 });
889
997
  for (;;) {
890
998
  try {
@@ -931,9 +1039,20 @@ export class Broker {
931
1039
  try {
932
1040
  await this.index.open();
933
1041
  await this.ledger.open();
1042
+ await this.elevation.open();
1043
+ await this.operationReceipts.open();
1044
+ if (this.#elevationEnforced)
1045
+ await fs.mkdir(path.join(this.settings.agentDir, "sdk", "elevation", "operator"), {
1046
+ recursive: true,
1047
+ mode: 0o700,
1048
+ });
1049
+ // Broker-scheduled compaction (C3): apply the retention policy once at
1050
+ // startup and again every six hours, independently of log rotation.
1051
+ await this.#compactSessionIndex();
934
1052
  const now = Date.now();
935
1053
  const incarnation = brokerProcessIncarnation(process.pid);
936
1054
  if (!incarnation) throw new Error("Broker process incarnation is unavailable.");
1055
+ this.#incarnation = incarnation;
937
1056
  const token = newBrokerToken();
938
1057
  this.#transport = new BrokerTransport(this, token, this.settings.port);
939
1058
  const port = await this.#transport.start();
@@ -958,6 +1077,8 @@ export class Broker {
958
1077
  Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
959
1078
  );
960
1079
  this.#heartbeatTimer = setInterval(() => void this.#watchPublication(), cadenceMs);
1080
+ this.#compactTimer = setInterval(() => void this.#compactSessionIndex(), BROKER_INDEX_COMPACT_INTERVAL_MS);
1081
+ if (this.#elevationEnforced) this.#operatorTimer = setInterval(() => void this.#pollOperatorDirectives(), 250);
961
1082
  return this.discovery;
962
1083
  } catch (error) {
963
1084
  await this.#transport?.stop();
@@ -1024,6 +1145,7 @@ export class Broker {
1024
1145
  this.#lossAt = null;
1025
1146
  this.#ambiguousAt = null;
1026
1147
  if (writeHeartbeat) await this.#writeHeartbeat();
1148
+ await this.#checkpointSessionHeartbeats();
1027
1149
  return;
1028
1150
  }
1029
1151
  this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
@@ -1050,6 +1172,33 @@ export class Broker {
1050
1172
  if (this.#publicationState !== "healthy-owned") return;
1051
1173
  await this.#writeHeartbeat();
1052
1174
  }
1175
+
1176
+ /**
1177
+ * Production coalesced heartbeat checkpoint seam (C2): delegates to the session
1178
+ * index writer, which appends at most one `host_heartbeat` per session per
1179
+ * {@link SESSION_HEARTBEAT_INTERVAL_MS} for hosts that are alive and, when recorded,
1180
+ * still carry the same OS process incarnation. Returns the number of checkpoints
1181
+ * written. Used by the periodic publication watch, by session reads so a fresh
1182
+ * registration is observed as live, and by tests.
1183
+ */
1184
+ async heartbeatSessions(now = Date.now()): Promise<number> {
1185
+ return await this.index.checkpointLiveHeartbeats(now);
1186
+ }
1187
+
1188
+ async #checkpointSessionHeartbeats(): Promise<void> {
1189
+ try {
1190
+ await this.heartbeatSessions();
1191
+ } catch {
1192
+ // Best-effort observation; a failed checkpoint never fences the broker.
1193
+ }
1194
+ }
1195
+ async #compactSessionIndex(): Promise<void> {
1196
+ try {
1197
+ await this.index.compact();
1198
+ } catch {
1199
+ // Compaction is best-effort maintenance; the next scheduled pass retries.
1200
+ }
1201
+ }
1053
1202
  async #complete(mode: BrokerStopMode): Promise<void> {
1054
1203
  if (this.#completionTask) return this.#completionTask;
1055
1204
  this.#stopping = true;
@@ -1063,6 +1212,10 @@ export class Broker {
1063
1212
  if (mode === "lost-root") this.#startupAdmissions.close();
1064
1213
  if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
1065
1214
  this.#heartbeatTimer = null;
1215
+ if (this.#compactTimer) clearInterval(this.#compactTimer);
1216
+ this.#compactTimer = null;
1217
+ if (this.#operatorTimer) clearInterval(this.#operatorTimer);
1218
+ this.#operatorTimer = null;
1066
1219
  this.#completionTask = (async () => {
1067
1220
  await this.#transport?.stop();
1068
1221
  this.#transport = null;
@@ -1142,8 +1295,12 @@ export class Broker {
1142
1295
  const authority = expectedEndpointAuthority(input);
1143
1296
  if ("ok" in authority) return authority;
1144
1297
  await this.index.refresh();
1298
+ await this.heartbeatSessions();
1145
1299
  const record = this.index.listSessions().sessions.find(session => session.sessionId === sessionId);
1146
1300
  if (!record) return error("resource_gone", "session is not indexed");
1301
+ // C6: a cross-repo duplicate cannot resolve an endpoint credential; require
1302
+ // disambiguation before any credential is minted.
1303
+ if (record.ambiguous) return error("ambiguous_session", "session id maps to more than one state root");
1147
1304
  if (!record.live || !matchesEndpointAuthority(record, authority))
1148
1305
  return error("endpoint_stale", "session endpoint is stale");
1149
1306
  return this.#readEndpoint(record, authority);
@@ -1175,13 +1332,74 @@ export class Broker {
1175
1332
  throw e;
1176
1333
  }
1177
1334
  }
1178
- handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
1335
+ #storeSessionListCursor(cursor: SessionListCursor, replacingToken?: string): string | BrokerResponse {
1336
+ const now = Date.now();
1337
+ for (const [token, stored] of this.#sessionListCursors) {
1338
+ if (stored.expiresAt <= now) this.#sessionListCursors.delete(token);
1339
+ }
1340
+ const replacing = replacingToken !== undefined && this.#sessionListCursors.has(replacingToken);
1341
+ if (!replacing && this.#sessionListCursors.size >= SESSION_LIST_MAX_CURSORS)
1342
+ return error("invalid_input", "session.list cursor capacity is exhausted");
1343
+ const token = randomBytes(24).toString("base64url");
1344
+ if (replacingToken !== undefined) this.#sessionListCursors.delete(replacingToken);
1345
+ this.#sessionListCursors.set(token, cursor);
1346
+ return token;
1347
+ }
1348
+
1349
+ #sessionListPage(input: Record<string, unknown>, result: SessionList): BrokerResponse {
1350
+ const requestedLimit = input.limit === undefined ? undefined : sessionListLimit(input);
1351
+ if (isBrokerResponse(requestedLimit)) return requestedLimit;
1352
+ const cursor = input.cursor;
1353
+ const stored = typeof cursor === "string" ? this.#sessionListCursors.get(cursor) : undefined;
1354
+ if (cursor !== undefined && (!stored || stored.expiresAt <= Date.now())) {
1355
+ if (typeof cursor === "string") this.#sessionListCursors.delete(cursor);
1356
+ return error("invalid_input", "cursor is expired or invalid");
1357
+ }
1358
+ if (stored && requestedLimit !== undefined && stored.limit !== requestedLimit)
1359
+ return error("invalid_input", "limit must match the cursor page shape");
1360
+ const limit = stored?.limit ?? requestedLimit ?? SESSION_LIST_DEFAULT_LIMIT;
1361
+ const snapshot = stored ?? {
1362
+ sessions: [...result.sessions],
1363
+ indexSeq: result.indexSeq,
1364
+ warnings: [...result.warnings],
1365
+ limit,
1366
+ offset: 0,
1367
+ expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS,
1368
+ };
1369
+ const sessions = snapshot.sessions.slice(snapshot.offset, snapshot.offset + snapshot.limit);
1370
+ const offset = snapshot.offset + sessions.length;
1371
+ if (offset >= snapshot.sessions.length && typeof cursor === "string") this.#sessionListCursors.delete(cursor);
1372
+ const continuationCursor =
1373
+ offset >= snapshot.sessions.length
1374
+ ? undefined
1375
+ : this.#storeSessionListCursor(
1376
+ { ...snapshot, offset, expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS },
1377
+ typeof cursor === "string" ? cursor : undefined,
1378
+ );
1379
+ if (isBrokerResponse(continuationCursor)) return continuationCursor;
1380
+ return {
1381
+ ok: true,
1382
+ result: {
1383
+ indexSeq: snapshot.indexSeq,
1384
+ sessions,
1385
+ warnings: snapshot.warnings,
1386
+ ...(continuationCursor ? { continuationCursor } : {}),
1387
+ },
1388
+ indexSeq: snapshot.indexSeq,
1389
+ };
1390
+ }
1391
+ handleRequest(
1392
+ operation: string,
1393
+ input: Record<string, unknown>,
1394
+ idempotencyKey?: string,
1395
+ elevationRequestId?: string,
1396
+ ): Promise<BrokerResponse> {
1179
1397
  if (this.#stopping || (this.#publication !== null && this.#publicationState !== "healthy-owned"))
1180
1398
  return Promise.resolve(error("unavailable", "broker publication is unavailable"));
1181
1399
  let release!: () => void;
1182
1400
  const admission = new Promise<void>(resolve => (release = resolve));
1183
1401
  this.#admitted.add(admission);
1184
- return this.#handleRequest(operation, input, idempotencyKey).finally(() => {
1402
+ return this.#handleRequest(operation, input, idempotencyKey, elevationRequestId).finally(() => {
1185
1403
  release();
1186
1404
  this.#admitted.delete(admission);
1187
1405
  });
@@ -1190,14 +1408,24 @@ export class Broker {
1190
1408
  operation: string,
1191
1409
  input: Record<string, unknown>,
1192
1410
  idempotencyKey?: string,
1411
+ elevationRequestId?: string,
1193
1412
  ): Promise<BrokerResponse> {
1194
1413
  if (this.#stopping) return error("broker_restarting", "broker is stopping");
1195
1414
  const normalization = normalizeBrokerInput(operation, input);
1196
1415
  if (isBrokerResponse(normalization)) return normalization;
1416
+ const approvedInput = input;
1197
1417
  input = normalization.input;
1198
1418
  if (operation === "session.list") {
1199
- await this.index.refresh();
1200
- const result = this.index.listSessions();
1419
+ if (input.cursor === undefined) {
1420
+ await this.index.refresh();
1421
+ // Self-healing heartbeat checkpoint (C2): a host registered since the
1422
+ // broker's last pass is observed as live on this read, so a fresh
1423
+ // registration never reads as unknown (while a restart still does).
1424
+ await this.heartbeatSessions();
1425
+ }
1426
+ const page = this.#sessionListPage(input, this.index.listSessions());
1427
+ if (!page.ok) return page;
1428
+ const pageResult = page.result as Record<string, unknown>;
1201
1429
  const resolveSessionId = typeof input.resolveSessionId === "string" ? input.resolveSessionId : undefined;
1202
1430
  const cwd = typeof input.cwd === "string" ? input.cwd : undefined;
1203
1431
  if (resolveSessionId && cwd) {
@@ -1210,21 +1438,69 @@ export class Broker {
1210
1438
  : [];
1211
1439
  const match = matches.length === 1 ? matches[0] : undefined;
1212
1440
  return {
1213
- ok: true,
1441
+ ...page,
1214
1442
  result: {
1215
- ...result,
1443
+ ...pageResult,
1216
1444
  savedSession:
1217
1445
  match && match.sessionId === resolveSessionId
1218
1446
  ? { id: match.sessionId, path: match.path }
1219
1447
  : undefined,
1220
1448
  },
1221
- indexSeq: result.indexSeq,
1222
1449
  };
1223
1450
  }
1224
- return { ok: true, result, indexSeq: result.indexSeq };
1451
+ return page;
1225
1452
  }
1226
1453
  if (operation === "session.get_endpoint") return this.#endpoint(input);
1454
+ if (operation === "elevation.issue") return this.#elevationIssueRequest(input);
1455
+ if (operation === "elevation.status") return this.#elevationStatusRequest(input);
1456
+ if (operation === "session.control") return this.#sessionControlRequest(input);
1227
1457
  if (!idempotencyKey) return error("invalid_input", "idempotencyKey is required for lifecycle operations");
1458
+ // Elevation gate (F1.3): allowlisted raw operations execute only behind a
1459
+ // broker-owned single-use grant. Validation is fail-closed and happens
1460
+ // before any lifecycle ledger write; the claim itself is consumed right
1461
+ // before dispatch and the truthful outcome is recorded after it.
1462
+ const elevationGateKind = this.#elevationGateFor(operation);
1463
+ let elevationRequestIdForDispatch: string | undefined;
1464
+ if (elevationGateKind !== undefined) {
1465
+ if (elevationRequestId === undefined)
1466
+ return error("elevation_required", "an elevation grant claim is required for this operation");
1467
+ if (!isElevationRequestId(elevationRequestId))
1468
+ return error("invalid_input", "elevationRequestId must be an RFC4122 lowercase UUID v4");
1469
+ const resolved = await this.elevation.get(elevationRequestId);
1470
+ if (!resolved.ok) {
1471
+ if (resolved.error.code === "not_found")
1472
+ return error(
1473
+ "elevation_required",
1474
+ "elevation grant was not found; a grant is required for this operation",
1475
+ );
1476
+ return elevationResultToBroker(resolved);
1477
+ }
1478
+ const grant = resolved.value.grant;
1479
+ if (grant.operation.kind !== elevationGateKind || grant.operation.sdkId !== operation)
1480
+ return error("elevation_digest_mismatch", "elevation grant was issued for a different operation");
1481
+ const digest = elevationRequestDigest({ kind: elevationGateKind, sdkId: operation, input: approvedInput });
1482
+ if (!digest.ok || digest.digest !== grant.requestDigest)
1483
+ return error("elevation_digest_mismatch", "elevation grant digest does not match the dispatched input");
1484
+ // Bind the grant to the actual lifecycle target before any claim: the
1485
+ // approved input must resolve to the exact session identity the grant
1486
+ // was issued against (C10). session.delete may target a retained
1487
+ // stopped session; everything else requires a live endpoint.
1488
+ const targetSessionId = typeof input.sessionId === "string" ? input.sessionId : undefined;
1489
+ if (targetSessionId !== undefined) {
1490
+ const target = await this.#elevationSessionResolution(targetSessionId, {
1491
+ allowStopped: operation === "session.delete",
1492
+ });
1493
+ if (target.kind === "ambiguous")
1494
+ return error("ambiguous_session", "session id maps to more than one state root");
1495
+ if (target.kind !== "identity")
1496
+ return error("target_unavailable", "session endpoint is unreachable or its identity is not provable");
1497
+ if (grant.sessionIdentity.sessionId !== target.identity.sessionId)
1498
+ return error("elevation_digest_mismatch", "elevation grant was issued for a different session target");
1499
+ if (!sameElevationSessionIdentity(grant.sessionIdentity, target.identity))
1500
+ return error("endpoint_stale", "session endpoint identity no longer matches the elevation grant");
1501
+ }
1502
+ elevationRequestIdForDispatch = elevationRequestId;
1503
+ }
1228
1504
  const target = createHash("sha256")
1229
1505
  .update(canonicalJson(lifecycleTarget(operation, input)))
1230
1506
  .digest("hex");
@@ -1328,8 +1604,46 @@ export class Broker {
1328
1604
  return response;
1329
1605
  }
1330
1606
  if (begun.kind === "in_progress") return error("broker_restarting", "lifecycle operation is in progress");
1331
- const outcome = await executeLifecycle(this, operation, input, identity);
1607
+ // Single-use grant claim (F1.3): consume the grant before any dispatch.
1608
+ // A claimed grant that crashes before outcome recording is replayed
1609
+ // truthfully as consumed/uncertain; retry requires a new grant.
1610
+ if (elevationRequestIdForDispatch !== undefined) {
1611
+ const claimed = await this.elevationClaim({ elevationRequestId: elevationRequestIdForDispatch });
1612
+ if (!claimed.ok) return claimed;
1613
+ }
1614
+ let outcome: Awaited<ReturnType<typeof executeLifecycle>>;
1615
+ try {
1616
+ outcome = await executeLifecycle(this, operation, input, identity);
1617
+ } catch (cause) {
1618
+ if (elevationRequestIdForDispatch !== undefined)
1619
+ await this.elevationDispatch({
1620
+ elevationRequestId: elevationRequestIdForDispatch,
1621
+ outcome: {
1622
+ status: "failed",
1623
+ code: "unavailable",
1624
+ message: cause instanceof Error ? cause.message : String(cause),
1625
+ dispatchedAt: Date.now(),
1626
+ },
1627
+ });
1628
+ throw cause;
1629
+ }
1332
1630
  const response = outcome.response;
1631
+ // Record the truthful dispatch outcome for the claimed grant.
1632
+ if (elevationRequestIdForDispatch !== undefined) {
1633
+ const dispatchedAt = Date.now();
1634
+ const recorded = await this.elevationDispatch({
1635
+ elevationRequestId: elevationRequestIdForDispatch,
1636
+ outcome: response.ok
1637
+ ? { status: "ok", dispatchedAt }
1638
+ : {
1639
+ status: "failed",
1640
+ code: response.error.code,
1641
+ message: response.error.message,
1642
+ dispatchedAt,
1643
+ },
1644
+ });
1645
+ if (!recorded.ok) return recorded;
1646
+ }
1333
1647
  await this.ledger.transition(identity, lifecycleResponseState(response), {
1334
1648
  ...(pendingCleanupSessionId(response) ? { intendedSessionId: pendingCleanupSessionId(response) } : {}),
1335
1649
  resultSessionId:
@@ -1369,6 +1683,460 @@ export class Broker {
1369
1683
  if (this.#chains.get(target) === current) this.#chains.delete(target);
1370
1684
  }
1371
1685
  }
1686
+
1687
+ #elevationGateFor(operation: string): "global" | undefined {
1688
+ return this.#elevationEnforced && isElevatableOperation("global", operation) ? "global" : undefined;
1689
+ }
1690
+
1691
+ async #elevationIssueRequest(input: Record<string, unknown>): Promise<BrokerResponse> {
1692
+ const sessionId = input.sessionId;
1693
+ if (typeof sessionId !== "string" || !isCanonicalSessionId(sessionId))
1694
+ return error("invalid_input", "sessionId must be a canonical safe identifier");
1695
+ const rawOperation = input.operation;
1696
+ if (typeof rawOperation !== "object" || rawOperation === null || Array.isArray(rawOperation))
1697
+ return error("invalid_input", "operation must be an object with kind and sdkId");
1698
+ const { kind, sdkId } = rawOperation as { kind?: unknown; sdkId?: unknown };
1699
+ if (
1700
+ typeof kind !== "string" ||
1701
+ (kind !== "control" && kind !== "query" && kind !== "global") ||
1702
+ typeof sdkId !== "string" ||
1703
+ !findOperation(kind, sdkId)
1704
+ )
1705
+ return error("invalid_request", `unknown elevation operation: ${String(kind)}:${String(sdkId)}`);
1706
+ if (!isElevatableOperation(kind, sdkId))
1707
+ return error("elevation_not_allowlisted", `${sdkId} does not require elevation`);
1708
+ const approved = input.input;
1709
+ if (typeof approved !== "object" || approved === null || Array.isArray(approved))
1710
+ return error("invalid_input", "input must be the approved operation arguments");
1711
+ if (containsReservedKey(approved, "elevationRequestId"))
1712
+ return error(
1713
+ "invalid_input",
1714
+ "elevationRequestId is reserved and cannot appear inside the approved operation input",
1715
+ );
1716
+ if (input.idempotencyKey !== undefined)
1717
+ return error(
1718
+ "invalid_input",
1719
+ "idempotencyKey is broker-frame metadata and cannot appear inside elevation.issue input",
1720
+ );
1721
+ const elevationRequestId = input.elevationRequestId;
1722
+ if (elevationRequestId !== undefined && !isElevationRequestId(elevationRequestId))
1723
+ return error("invalid_input", "elevationRequestId must be an RFC4122 lowercase UUID v4");
1724
+ const expiresInMs = input.expiresInMs;
1725
+ if (
1726
+ expiresInMs !== undefined &&
1727
+ (typeof expiresInMs !== "number" || !Number.isSafeInteger(expiresInMs) || expiresInMs <= 0)
1728
+ )
1729
+ return error("invalid_input", "expiresInMs must be a positive safe integer");
1730
+ let requester: ElevationRequester;
1731
+ if (input.requester === undefined) {
1732
+ requester = { source: "broker_connection", connectionId: `broker:${this.#owner}` };
1733
+ } else {
1734
+ const candidate = input.requester as { source?: unknown; connectionId?: unknown };
1735
+ if (
1736
+ candidate?.source !== "broker_connection" ||
1737
+ typeof candidate.connectionId !== "string" ||
1738
+ candidate.connectionId.length === 0
1739
+ )
1740
+ return error("invalid_input", "requester must be a broker_connection audit identity");
1741
+ requester = { source: "broker_connection", connectionId: candidate.connectionId };
1742
+ }
1743
+ return await this.elevationIssue({
1744
+ sessionId,
1745
+ operation: { kind, sdkId },
1746
+ input: approved as Record<string, unknown>,
1747
+ requester,
1748
+ ...(elevationRequestId === undefined ? {} : { elevationRequestId }),
1749
+ ...(expiresInMs === undefined ? {} : { expiresInMs }),
1750
+ });
1751
+ }
1752
+
1753
+ async #elevationStatusRequest(input: Record<string, unknown>): Promise<BrokerResponse> {
1754
+ const elevationRequestId = input.elevationRequestId;
1755
+ if (typeof elevationRequestId !== "string" || !isElevationRequestId(elevationRequestId))
1756
+ return error("invalid_input", "elevationRequestId must be an RFC4122 lowercase UUID v4");
1757
+ return await this.elevationResolve(elevationRequestId);
1758
+ }
1759
+
1760
+ #elevationClaimIdentity(): ElevationClaimIdentity {
1761
+ const incarnation = this.#incarnation;
1762
+ if (incarnation === null) throw new Error("Broker process incarnation is unavailable.");
1763
+ return { ownerId: this.#owner, epoch: this.#epoch, pid: process.pid, incarnation };
1764
+ }
1765
+ async #elevationPrincipal(): Promise<BrokerOwnerPrincipal> {
1766
+ const incarnation = this.#incarnation;
1767
+ if (incarnation === null) throw new Error("Broker process incarnation is unavailable.");
1768
+ return readBrokerOwnerPrincipal(this.settings.agentDir, process.pid, incarnation);
1769
+ }
1770
+ /**
1771
+ * Resolves the exact elevation session identity (C6/C10). A cross-repo
1772
+ * duplicate (`ambiguous`) is rejected before any endpoint credential or
1773
+ * elevation grant can bind; an unreachable or unprovable session is
1774
+ * `unavailable`. The heartbeat checkpoint pass runs first so a fresh
1775
+ * registration is observable as live.
1776
+ *
1777
+ * `allowStopped` is the session.delete-only exemption: a retained stopped
1778
+ * session keeps its exact endpoint identity tuple (generation/pid/mtime)
1779
+ * in the index row, so a delete grant can still be bound and dispatched
1780
+ * against it. Everything else fails closed unless the endpoint is live.
1781
+ */
1782
+ async #elevationSessionResolution(
1783
+ sessionId: string,
1784
+ options?: { allowStopped?: boolean },
1785
+ ): Promise<ElevationSessionResolution> {
1786
+ if (!isCanonicalSessionId(sessionId)) return { kind: "unavailable" };
1787
+ await this.index.refresh();
1788
+ await this.heartbeatSessions();
1789
+ const record = this.index.listSessions().sessions.find(session => session.sessionId === sessionId);
1790
+ if (!record) return { kind: "unavailable" };
1791
+ if (record.ambiguous) return { kind: "ambiguous" };
1792
+ if (!record.live && options?.allowStopped !== true) return { kind: "unavailable" };
1793
+ const incarnation = endpointIncarnation(record, sessionId);
1794
+ if (incarnation === undefined) return { kind: "unavailable" };
1795
+ return {
1796
+ kind: "identity",
1797
+ identity: {
1798
+ sessionId: record.sessionId,
1799
+ endpointStateRoot: record.locator.stateRoot,
1800
+ endpointGeneration: record.endpointGeneration,
1801
+ endpointIncarnation: incarnation,
1802
+ },
1803
+ };
1804
+ }
1805
+
1806
+ async #pollOperatorDirectives(): Promise<void> {
1807
+ const directory = path.join(this.settings.agentDir, "sdk", "elevation", "operator");
1808
+ let names: string[];
1809
+ try {
1810
+ names = await fs.readdir(directory);
1811
+ } catch {
1812
+ return;
1813
+ }
1814
+ for (const name of names) {
1815
+ if (!name.endsWith(".json")) continue;
1816
+ const file = path.join(directory, name);
1817
+ try {
1818
+ const stat = await fs.stat(file);
1819
+ if ((stat.mode & 0o077) !== 0) throw new Error("operator directive is not private");
1820
+ const raw: unknown = JSON.parse(await fs.readFile(file, "utf8"));
1821
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("malformed operator directive");
1822
+ const directive = raw as Record<string, unknown>;
1823
+ if (
1824
+ typeof directive.elevationRequestId !== "string" ||
1825
+ (directive.answer !== "approve" && directive.answer !== "deny") ||
1826
+ typeof directive.presentedDigest !== "string"
1827
+ )
1828
+ throw new Error("malformed operator directive");
1829
+ const response = await this.elevationAnswer({
1830
+ elevationRequestId: directive.elevationRequestId,
1831
+ answer: directive.answer,
1832
+ presentedDigest: directive.presentedDigest,
1833
+ answerer: { source: "local_operator", attestedBy: "gjc-sdk-session-cli" },
1834
+ });
1835
+ if (!response.ok) throw new Error(response.error.message);
1836
+ await fs.rm(file, { force: true });
1837
+ } catch {
1838
+ await fs.rename(file, `${file}.rejected`).catch(() => undefined);
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ async #sessionControlRequest(input: Record<string, unknown>): Promise<BrokerResponse> {
1844
+ const sessionId = typeof input.sessionId === "string" ? input.sessionId : undefined;
1845
+ const operation = typeof input.operation === "string" ? input.operation : undefined;
1846
+ const rawInput = input.input;
1847
+ const controlInput =
1848
+ rawInput && typeof rawInput === "object" && !Array.isArray(rawInput)
1849
+ ? (rawInput as Record<string, unknown>)
1850
+ : undefined;
1851
+ if (!sessionId || !operation || !controlInput || !findOperation("control", operation))
1852
+ return error(
1853
+ "invalid_input",
1854
+ "session.control requires a valid sessionId, control operation, and object input",
1855
+ );
1856
+ const elevationRequestId = typeof input.elevationRequestId === "string" ? input.elevationRequestId : undefined;
1857
+ const elevatable = this.#elevationEnforced && isElevatableOperation("control", operation);
1858
+ // Elevation preflight (F1.3) runs before any clientRef reservation so a
1859
+ // no-grant attempt can never poison a later granted retry with a stale
1860
+ // pending receipt.
1861
+ let grant: ElevationGrantRecord | undefined;
1862
+ if (elevatable) {
1863
+ if (!elevationRequestId) return error("elevation_required", "an elevation grant is required for this control");
1864
+ const resolved = await this.elevation.get(elevationRequestId);
1865
+ if (!resolved.ok) return elevationResultToBroker(resolved);
1866
+ grant = resolved.value.grant;
1867
+ const digest = elevationRequestDigest({ kind: "control", sdkId: operation, input: controlInput });
1868
+ if (
1869
+ grant.operation.kind !== "control" ||
1870
+ grant.operation.sdkId !== operation ||
1871
+ !digest.ok ||
1872
+ digest.digest !== grant.requestDigest
1873
+ )
1874
+ return error("elevation_digest_mismatch", "elevation grant does not match the requested control");
1875
+ }
1876
+ await this.index.refresh();
1877
+ const matches = this.index.listSessions().sessions.filter(session => session.sessionId === sessionId);
1878
+ if (matches.some(session => session.ambiguous) || matches.length > 1)
1879
+ return error("ambiguous_session", "session id maps to more than one state root");
1880
+ const record = matches[0];
1881
+ if (!record?.live) return error("target_unavailable", "session endpoint is unavailable");
1882
+ const incarnation = endpointIncarnation(record, sessionId);
1883
+ if (!incarnation) return error("endpoint_stale", "session endpoint incarnation is unavailable");
1884
+ // Bind the grant to the actual approved target session identity before
1885
+ // claim/capability minting: a grant issued for a different session can
1886
+ // never authorize this control, even with byte-identical input.
1887
+ if (grant !== undefined) {
1888
+ if (grant.sessionIdentity.sessionId !== record.sessionId)
1889
+ return error("elevation_digest_mismatch", "elevation grant was issued for a different session target");
1890
+ const targetIdentity: ElevationSessionIdentity = {
1891
+ sessionId: record.sessionId,
1892
+ endpointStateRoot: record.locator.stateRoot,
1893
+ endpointGeneration: record.endpointGeneration,
1894
+ endpointIncarnation: incarnation,
1895
+ };
1896
+ if (!sameElevationSessionIdentity(grant.sessionIdentity, targetIdentity))
1897
+ return error("endpoint_stale", "session endpoint identity no longer matches the elevation grant");
1898
+ }
1899
+ const clientRef =
1900
+ typeof controlInput.clientRef === "string" && controlInput.clientRef.length > 0
1901
+ ? controlInput.clientRef
1902
+ : undefined;
1903
+ // clientRef idempotency is target-session scoped: identical refs and
1904
+ // input on different sessions never collide, conflict, or replay.
1905
+ const receiptKey = clientRef === undefined ? undefined : operationReceiptKey(sessionId, clientRef);
1906
+ const receiptDigest =
1907
+ clientRef === undefined ? undefined : operationReceiptDigest(operation, controlInput, sessionId);
1908
+ if (receiptKey !== undefined && receiptDigest !== undefined) {
1909
+ const reservation = await this.operationReceipts.reserve(receiptKey, receiptDigest);
1910
+ if (reservation.status === "replay") return reservation.response;
1911
+ if (reservation.status === "in_progress")
1912
+ return error("operation_in_progress", "operation receipt is pending");
1913
+ if (reservation.status === "conflict")
1914
+ return error("idempotency_conflict", "clientRef was reused with different operation input");
1915
+ }
1916
+ const settle = async (response: BrokerResponse): Promise<BrokerResponse> => {
1917
+ if (receiptKey !== undefined && receiptDigest !== undefined)
1918
+ await this.operationReceipts.complete(receiptKey, receiptDigest, response);
1919
+ return response;
1920
+ };
1921
+ const endpointResponse = await this.#endpoint({
1922
+ sessionId,
1923
+ endpointGeneration: record.endpointGeneration,
1924
+ endpointIncarnation: incarnation,
1925
+ });
1926
+ if (!endpointResponse.ok) return settle(endpointResponse);
1927
+ const endpoint = endpointResponse.result as { url?: unknown; token?: unknown };
1928
+ if (typeof endpoint.url !== "string" || typeof endpoint.token !== "string")
1929
+ return settle(error("unavailable", "session endpoint is malformed"));
1930
+ let elevationCapability: string | undefined;
1931
+ if (elevatable && elevationRequestId) {
1932
+ let authorityToken: string | undefined;
1933
+ try {
1934
+ const authority = JSON.parse(
1935
+ await fs.readFile(elevationAuthorityPath(record.locator.stateRoot, sessionId), "utf8"),
1936
+ ) as { token?: unknown };
1937
+ authorityToken = typeof authority.token === "string" ? authority.token : undefined;
1938
+ } catch {
1939
+ return settle(error("unavailable", "session elevation authority is unavailable"));
1940
+ }
1941
+ if (!authorityToken) return settle(error("unavailable", "session elevation authority is malformed"));
1942
+ const claimed = await this.elevationClaim({ elevationRequestId });
1943
+ if (!claimed.ok) return settle(claimed);
1944
+ elevationCapability = signElevationCapability(authorityToken, elevationRequestId, operation, controlInput);
1945
+ }
1946
+ let client: SdkClient | undefined;
1947
+ try {
1948
+ client = await SdkClient.connect(endpoint.url, endpoint.token);
1949
+ const response = await client.control(operation, controlInput, {
1950
+ confirm: input.confirm === true,
1951
+ ...(elevationCapability === undefined ? {} : { elevationRequestId: elevationCapability }),
1952
+ });
1953
+ const ok = (response as { ok?: unknown }).ok === true;
1954
+ if (elevatable && elevationRequestId) {
1955
+ await this.elevationDispatch({
1956
+ elevationRequestId,
1957
+ outcome: ok
1958
+ ? { status: "ok", dispatchedAt: Date.now() }
1959
+ : {
1960
+ status: "failed",
1961
+ code: String((response as { error?: { code?: unknown } }).error?.code ?? "control_failed"),
1962
+ message: String(
1963
+ (response as { error?: { message?: unknown } }).error?.message ?? "control failed",
1964
+ ),
1965
+ dispatchedAt: Date.now(),
1966
+ },
1967
+ });
1968
+ }
1969
+ const brokerResponse = ok
1970
+ ? { ok: true as const, result: (response as { result?: unknown }).result }
1971
+ : error(
1972
+ String((response as { error?: { code?: unknown } }).error?.code ?? "unavailable"),
1973
+ String((response as { error?: { message?: unknown } }).error?.message ?? "control failed"),
1974
+ );
1975
+ return settle(brokerResponse);
1976
+ } catch (cause) {
1977
+ if (elevatable && elevationRequestId)
1978
+ await this.elevationDispatch({
1979
+ elevationRequestId,
1980
+ outcome: {
1981
+ status: "failed",
1982
+ code: "unavailable",
1983
+ message: cause instanceof Error ? cause.message : String(cause),
1984
+ dispatchedAt: Date.now(),
1985
+ },
1986
+ });
1987
+ return settle(
1988
+ cause instanceof SdkClientError
1989
+ ? error(cause.code, cause.message)
1990
+ : error("unavailable", cause instanceof Error ? cause.message : String(cause)),
1991
+ );
1992
+ } finally {
1993
+ await client?.close();
1994
+ }
1995
+ }
1996
+ /**
1997
+ * Elevation issuance (internal). Binds the exact endpoint identity
1998
+ * (endpointStateRoot/generation/incarnation) from the live index record;
1999
+ * the request digest is broker-computed from the full `{kind,sdkId,input}`
2000
+ * triple with the top-level `elevationRequestId` excluded.
2001
+ */
2002
+ async elevationIssue(params: {
2003
+ sessionId: string;
2004
+ operation: ElevationOperation;
2005
+ input: Record<string, unknown>;
2006
+ requester: ElevationRequester;
2007
+ elevationRequestId?: string;
2008
+ expiresInMs?: number;
2009
+ }): Promise<BrokerResponse> {
2010
+ if (!isCanonicalSessionId(params.sessionId))
2011
+ return error("invalid_input", "sessionId must be a canonical safe identifier");
2012
+ // Retained stopped sessions may only back an elevation request for the
2013
+ // exact global lifecycle operation that needs them (session.delete); all
2014
+ // other operations still require a live endpoint (fail closed otherwise).
2015
+ const allowStopped = params.operation.kind === "global" && params.operation.sdkId === "session.delete";
2016
+ const resolution = await this.#elevationSessionResolution(params.sessionId, { allowStopped });
2017
+ if (resolution.kind === "ambiguous")
2018
+ return error("ambiguous_session", "session id maps to more than one state root");
2019
+ if (resolution.kind === "unavailable")
2020
+ return error("target_unavailable", "session endpoint is unreachable or its identity is not provable");
2021
+ // Bind the grant to the actual approved target session: the approved
2022
+ // input for a global lifecycle operation must name the elevation session
2023
+ // itself, never a different session (C10).
2024
+ if (params.operation.kind === "global") {
2025
+ const approvedTarget = typeof params.input.sessionId === "string" ? params.input.sessionId : undefined;
2026
+ if (approvedTarget !== undefined && approvedTarget !== params.sessionId)
2027
+ return error("invalid_input", "approved input targets a different session than the elevation session");
2028
+ }
2029
+ const { identity: sessionIdentity } = resolution;
2030
+ let principal: BrokerOwnerPrincipal;
2031
+ try {
2032
+ principal = await this.#elevationPrincipal();
2033
+ } catch {
2034
+ return error("unavailable", "broker owner principal is unavailable");
2035
+ }
2036
+ const result = await this.elevation.issue({ ...params, sessionIdentity, principal });
2037
+ return elevationResultToBroker(result);
2038
+ }
2039
+
2040
+ /**
2041
+ * Elevation answer (internal, operator-only). There is no public
2042
+ * `elevation.answer` operation: this entry point is reserved for the local
2043
+ * operator approval surface and the separately authorized gate-answer
2044
+ * source, and the ledger enforces the distinct-answer-source boundary.
2045
+ */
2046
+ async elevationAnswer(params: {
2047
+ elevationRequestId: string;
2048
+ answer: "approve" | "deny";
2049
+ presentedDigest: string;
2050
+ answerer: ElevationAnswerAuthority;
2051
+ }): Promise<BrokerResponse> {
2052
+ const resolved = await this.elevation.get(params.elevationRequestId);
2053
+ if (!resolved.ok) return elevationResultToBroker(resolved);
2054
+ const sessionId = resolved.value.grant.sessionIdentity.sessionId;
2055
+ const grantOperation = resolved.value.grant.operation;
2056
+ const resolution = await this.#elevationSessionResolution(sessionId, {
2057
+ allowStopped: grantOperation.kind === "global" && grantOperation.sdkId === "session.delete",
2058
+ });
2059
+ if (resolution.kind === "ambiguous")
2060
+ return error("ambiguous_session", "session id maps to more than one state root");
2061
+ const currentSessionIdentity = resolution.kind === "identity" ? resolution.identity : undefined;
2062
+ let principal: BrokerOwnerPrincipal;
2063
+ try {
2064
+ principal = await this.#elevationPrincipal();
2065
+ } catch {
2066
+ return error("unavailable", "broker owner principal is unavailable");
2067
+ }
2068
+ const result = await this.elevation.answer({ ...params, principal, currentSessionIdentity });
2069
+ return elevationAnswerToBroker(result);
2070
+ }
2071
+
2072
+ /** Single-use grant claim (internal): consumes the grant before dispatch. */
2073
+ async elevationClaim(params: { elevationRequestId: string }): Promise<BrokerResponse> {
2074
+ const resolved = await this.elevation.get(params.elevationRequestId);
2075
+ if (!resolved.ok) return elevationResultToBroker(resolved);
2076
+ const sessionId = resolved.value.grant.sessionIdentity.sessionId;
2077
+ const grantOperation = resolved.value.grant.operation;
2078
+ const resolution = await this.#elevationSessionResolution(sessionId, {
2079
+ allowStopped: grantOperation.kind === "global" && grantOperation.sdkId === "session.delete",
2080
+ });
2081
+ if (resolution.kind === "ambiguous")
2082
+ return error("ambiguous_session", "session id maps to more than one state root");
2083
+ const currentSessionIdentity = resolution.kind === "identity" ? resolution.identity : undefined;
2084
+ let claimIdentity: ElevationClaimIdentity;
2085
+ try {
2086
+ claimIdentity = this.#elevationClaimIdentity();
2087
+ } catch {
2088
+ return error("unavailable", "broker process incarnation is unavailable");
2089
+ }
2090
+ const result = await this.elevation.claim({
2091
+ elevationRequestId: params.elevationRequestId,
2092
+ claimIdentity,
2093
+ currentSessionIdentity,
2094
+ });
2095
+ return elevationResultToBroker(result);
2096
+ }
2097
+
2098
+ /** Records the dispatch outcome for a claimed grant (internal). */
2099
+ async elevationDispatch(params: {
2100
+ elevationRequestId: string;
2101
+ outcome: ElevationDispatchOutcome;
2102
+ }): Promise<BrokerResponse> {
2103
+ let dispatchIdentity: ElevationClaimIdentity;
2104
+ try {
2105
+ dispatchIdentity = this.#elevationClaimIdentity();
2106
+ } catch {
2107
+ return error("unavailable", "broker process incarnation is unavailable");
2108
+ }
2109
+ const result = await this.elevation.dispatch({
2110
+ elevationRequestId: params.elevationRequestId,
2111
+ dispatchIdentity,
2112
+ outcome: params.outcome,
2113
+ });
2114
+ return elevationResultToBroker(result);
2115
+ }
2116
+
2117
+ /** Truthful elevation status read (internal; reconciles expiry and crash state). */
2118
+ async elevationResolve(elevationRequestId: string): Promise<BrokerResponse> {
2119
+ let claimIdentity: ElevationClaimIdentity;
2120
+ try {
2121
+ claimIdentity = this.#elevationClaimIdentity();
2122
+ } catch {
2123
+ return error("unavailable", "broker process incarnation is unavailable");
2124
+ }
2125
+ const result = await this.elevation.resolve(elevationRequestId, claimIdentity);
2126
+ return elevationResultToBroker(result);
2127
+ }
2128
+
2129
+ /** Truthful elevation listing (internal). */
2130
+ async elevationList(): Promise<BrokerResponse> {
2131
+ let claimIdentity: ElevationClaimIdentity;
2132
+ try {
2133
+ claimIdentity = this.#elevationClaimIdentity();
2134
+ } catch {
2135
+ return error("unavailable", "broker process incarnation is unavailable");
2136
+ }
2137
+ const result = await this.elevation.list(claimIdentity);
2138
+ return elevationResultToBroker(result);
2139
+ }
1372
2140
  }
1373
2141
 
1374
2142
  /** Test-only hook for simulating a process crash after terminal persistence verification. */