@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
@@ -741,6 +741,10 @@ async function reconcileReadyScope(broker: Broker, id: string, scope: string | u
741
741
  locator: { ...record.locator, repo: scope },
742
742
  endpointGeneration: record.endpointGeneration,
743
743
  pid: record.pid,
744
+ // Reconciliation only re-scopes the locator, so every identity fact the host
745
+ // published about its own process has to survive it: dropping the incarnation
746
+ // here would silently disarm the teardown fence for every lifecycle session.
747
+ ...(record.processIncarnation === undefined ? {} : { processIncarnation: record.processIncarnation }),
744
748
  endpointMtimeMs: record.endpointMtimeMs,
745
749
  });
746
750
  }
@@ -1947,15 +1951,35 @@ export async function readSessionLifecycleFailureForTest(
1947
1951
  : undefined;
1948
1952
  }
1949
1953
 
1950
- async function hasDurableProcessIdentity(
1951
- root: string,
1952
- id: string,
1953
- pid: number,
1954
- expected?: EffectMarker,
1955
- ): Promise<boolean> {
1956
- const marker = await readEffectMarker(lifecycleMarkerPath(root, id));
1957
- if (!marker || marker.pid !== pid || (expected && !sameEffectMarker(marker, expected))) return false;
1958
- return marker.incarnation === processIncarnation(pid);
1954
+ /** Everything a signal target must prove about itself before it can be signalled. */
1955
+ type SignalTarget = {
1956
+ locator: { stateRoot: string };
1957
+ pid: number;
1958
+ lifecycleRequestId?: string;
1959
+ processIncarnation?: string;
1960
+ };
1961
+
1962
+ /**
1963
+ * The spawn-time marker is the strongest evidence — the broker itself wrote it for
1964
+ * exactly this pid — but it lives in the session's own workspace state root. A
1965
+ * workspace deleted while its host keeps running takes that evidence with it, and
1966
+ * the host then survives every later `session.close` as an orphan still serving the
1967
+ * source it started with. The registration its host published into the broker-owned
1968
+ * index carries the same pid-to-incarnation binding, outlives the workspace, and is
1969
+ * therefore consulted when the marker is absent. A marker naming a different process
1970
+ * is contradiction rather than absence and still refuses the signal, and either
1971
+ * source is only accepted while the pid's current OS incarnation still matches it.
1972
+ */
1973
+ async function hasDurableProcessIdentity(target: SignalTarget, id: string, expected?: EffectMarker): Promise<boolean> {
1974
+ const marker = await readEffectMarker(lifecycleMarkerPath(target.locator.stateRoot, id));
1975
+ if (marker)
1976
+ return (
1977
+ marker.pid === target.pid &&
1978
+ (!expected || sameEffectMarker(marker, expected)) &&
1979
+ marker.incarnation === processIncarnation(target.pid)
1980
+ );
1981
+ if (expected && (expected.pid !== target.pid || target.lifecycleRequestId !== expected.effectMarker)) return false;
1982
+ return target.processIncarnation !== undefined && target.processIncarnation === processIncarnation(target.pid);
1959
1983
  }
1960
1984
 
1961
1985
  async function hasOwnedReadinessEvidence(
@@ -2229,14 +2253,14 @@ async function terminateSpawnedChild(
2229
2253
  }
2230
2254
 
2231
2255
  async function signalVerifiedSession(
2232
- record: { locator: { stateRoot: string }; pid: number },
2256
+ record: SignalTarget,
2233
2257
  id: string,
2234
2258
  signal: NodeJS.Signals,
2235
2259
  expected?: EffectMarker,
2236
2260
  ): Promise<boolean> {
2237
- if (!(await hasDurableProcessIdentity(record.locator.stateRoot, id, record.pid, expected))) return false;
2261
+ if (!(await hasDurableProcessIdentity(record, id, expected))) return false;
2238
2262
  try {
2239
- if (!(await hasDurableProcessIdentity(record.locator.stateRoot, id, record.pid, expected))) return false;
2263
+ if (!(await hasDurableProcessIdentity(record, id, expected))) return false;
2240
2264
  process.kill(record.pid, signal);
2241
2265
  return true;
2242
2266
  } catch {
@@ -2276,6 +2300,24 @@ async function waitForClose(
2276
2300
  hasObservedProcessExit(record.pid)
2277
2301
  )
2278
2302
  return true;
2303
+ // A host that dies before it can withdraw its own registration — killed
2304
+ // mid-teardown, or unable to finish one because its workspace is gone —
2305
+ // leaves the index advertising a session whose process is provably absent.
2306
+ // That is exactly the evidence the dead-registration sweep retires records
2307
+ // on, so retire it here too instead of escalating signals at a pid that no
2308
+ // longer exists and then reporting the teardown as unfinished.
2309
+ if (registration && hasObservedProcessExit(record.pid)) {
2310
+ await broker.index.append({
2311
+ type: "host_unregistered",
2312
+ sessionId: id,
2313
+ locator: registration.locator,
2314
+ endpointGeneration: registration.endpointGeneration,
2315
+ pid: registration.pid,
2316
+ ...(registration.endpointMtimeMs === undefined ? {} : { endpointMtimeMs: registration.endpointMtimeMs }),
2317
+ ...(registration.lifecycleRequestId ? { lifecycleRequestId: registration.lifecycleRequestId } : {}),
2318
+ });
2319
+ return true;
2320
+ }
2279
2321
  await timing.sleep(POLL_MS);
2280
2322
  }
2281
2323
  return false;
@@ -2996,27 +3038,22 @@ function sameCloseProcessIdentity(expected: CloseRecord, current: CloseRecord &
2996
3038
  );
2997
3039
  }
2998
3040
 
2999
- function sameCloseGeneration(expected: CloseRecord, current: CloseRecord & { live: boolean }): boolean {
3000
- return (
3001
- current.live &&
3002
- current.endpointGeneration === expected.endpointGeneration &&
3003
- current.pid === expected.pid &&
3004
- current.endpointMtimeMs === expected.endpointMtimeMs &&
3005
- path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
3006
- path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
3007
- );
3008
- }
3009
-
3010
3041
  async function revalidateCloseGeneration(
3011
3042
  broker: Broker,
3012
3043
  id: string,
3013
3044
  expected: CloseRecord,
3014
3045
  authority: CloseAuthority | undefined,
3046
+ allowUnreachable = false,
3015
3047
  ): Promise<BrokerResponse | undefined> {
3016
3048
  await broker.index.refresh();
3017
3049
  const current = broker.index.listSessions().sessions.find(session => session.sessionId === id);
3018
3050
  return current &&
3019
- sameCloseGeneration(expected, current) &&
3051
+ (allowUnreachable || current.live) &&
3052
+ current.endpointGeneration === expected.endpointGeneration &&
3053
+ current.pid === expected.pid &&
3054
+ current.endpointMtimeMs === expected.endpointMtimeMs &&
3055
+ path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
3056
+ path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot) &&
3020
3057
  (!authority || sameCloseAuthority(authority, current, id))
3021
3058
  ? undefined
3022
3059
  : fail("endpoint_stale", "session endpoint is stale");
@@ -3427,9 +3464,13 @@ async function executeLifecycleResponse(
3427
3464
  }
3428
3465
  }
3429
3466
  if (!endpointResult.ok) {
3430
- if (endpointResult.error.code === "endpoint_stale") return endpointResult;
3431
- if (endpointResult.error.code !== "resource_gone") return endpointResult;
3432
- usedSignalFallback = true;
3467
+ if (endpointResult.error.code === "endpoint_stale") {
3468
+ if (requestedAuthority.authority) return endpointResult;
3469
+ usedSignalFallback = true;
3470
+ } else {
3471
+ if (endpointResult.error.code !== "resource_gone") return endpointResult;
3472
+ usedSignalFallback = true;
3473
+ }
3433
3474
  } else {
3434
3475
  const endpoint = closeEndpoint(endpointResult.result);
3435
3476
  if (!endpoint) return fail("close_refused", "Session endpoint is malformed.");
@@ -3470,7 +3511,7 @@ async function executeLifecycleResponse(
3470
3511
  }
3471
3512
 
3472
3513
  if (usedSignalFallback) {
3473
- const stale = await revalidateCloseGeneration(broker, id, record, requestedAuthority.authority);
3514
+ const stale = await revalidateCloseGeneration(broker, id, record, requestedAuthority.authority, true);
3474
3515
  if (stale) return stale;
3475
3516
  if (!(await signalVerifiedSession(record, id, "SIGTERM")))
3476
3517
  return fail(
@@ -4403,7 +4444,7 @@ export async function executeLifecycle(
4403
4444
  }
4404
4445
  : {
4405
4446
  code: "spawn_failed",
4406
- message: "No ready SDK endpoint remains available.",
4447
+ message: startupFailure.message,
4407
4448
  endpoint: "unavailable" as const,
4408
4449
  },
4409
4450
  ...(durableEffects ? { durableEffects } : {}),
@@ -0,0 +1,144 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { canonicalElevationJson } from "../elevation/digest";
5
+ import type { BrokerResponse } from "./broker";
6
+
7
+ interface PendingReceipt {
8
+ version: 1;
9
+ clientRef: string;
10
+ digest: string;
11
+ state: "pending";
12
+ ts: number;
13
+ }
14
+
15
+ interface CompletedReceipt {
16
+ version: 1;
17
+ clientRef: string;
18
+ digest: string;
19
+ state: "completed";
20
+ response: BrokerResponse;
21
+ ts: number;
22
+ }
23
+
24
+ type OperationReceipt = PendingReceipt | CompletedReceipt;
25
+
26
+ export type OperationReserveResult =
27
+ | { status: "reserved" }
28
+ | { status: "replay"; response: BrokerResponse }
29
+ | { status: "in_progress" }
30
+ | { status: "conflict" };
31
+
32
+ function isReceipt(value: unknown): value is OperationReceipt {
33
+ if (!value || typeof value !== "object") return false;
34
+ const row = value as Partial<OperationReceipt>;
35
+ return (
36
+ row.version === 1 &&
37
+ typeof row.clientRef === "string" &&
38
+ row.clientRef.length > 0 &&
39
+ typeof row.digest === "string" &&
40
+ /^[0-9a-f]{64}$/.test(row.digest) &&
41
+ (row.state === "pending" || (row.state === "completed" && typeof row.response === "object")) &&
42
+ typeof row.ts === "number"
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Target-session-scoped idempotency key for a clientRef. Identical refs on
48
+ * different sessions are independent, so a receipt can never replay or
49
+ * conflict across sessions.
50
+ */
51
+ export function operationReceiptKey(sessionId: string, clientRef: string): string {
52
+ return `${sessionId}\u0000${clientRef}`;
53
+ }
54
+
55
+ export function operationReceiptDigest(operation: string, input: Record<string, unknown>, sessionId?: string): string {
56
+ return createHash("sha256")
57
+ .update(canonicalElevationJson({ operation, input, ...(sessionId === undefined ? {} : { sessionId }) }))
58
+ .digest("hex");
59
+ }
60
+
61
+ export class OperationReceiptLedger {
62
+ readonly #file: string;
63
+ readonly #latest = new Map<string, OperationReceipt>();
64
+ #chain: Promise<void> = Promise.resolve();
65
+
66
+ constructor(agentDir: string) {
67
+ this.#file = path.join(agentDir, "sdk", "operation-receipts.jsonl");
68
+ }
69
+
70
+ async open(): Promise<void> {
71
+ await fs.mkdir(path.dirname(this.#file), { recursive: true, mode: 0o700 });
72
+ let source = "";
73
+ try {
74
+ source = await fs.readFile(this.#file, "utf8");
75
+ } catch (error) {
76
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
77
+ }
78
+ for (const line of source.split("\n")) {
79
+ if (!line) continue;
80
+ try {
81
+ const row: unknown = JSON.parse(line);
82
+ if (isReceipt(row)) this.#latest.set(row.clientRef, row);
83
+ } catch {
84
+ // A torn final append is fail-closed: any earlier pending row remains authoritative.
85
+ }
86
+ }
87
+ }
88
+
89
+ async reserve(clientRef: string, digest: string): Promise<OperationReserveResult> {
90
+ return this.#serial(async () => {
91
+ const current = this.#latest.get(clientRef);
92
+ if (current) {
93
+ if (current.digest !== digest) return { status: "conflict" };
94
+ if (current.state === "completed") return { status: "replay", response: current.response };
95
+ return { status: "in_progress" };
96
+ }
97
+ const pending: PendingReceipt = { version: 1, clientRef, digest, state: "pending", ts: Date.now() };
98
+ await this.#append(pending);
99
+ this.#latest.set(clientRef, pending);
100
+ return { status: "reserved" };
101
+ });
102
+ }
103
+
104
+ async complete(clientRef: string, digest: string, response: BrokerResponse): Promise<void> {
105
+ await this.#serial(async () => {
106
+ const current = this.#latest.get(clientRef);
107
+ if (!current || current.digest !== digest)
108
+ throw new Error("Operation receipt reservation is missing or mismatched");
109
+ if (current.state === "completed") return;
110
+ const completed: CompletedReceipt = {
111
+ version: 1,
112
+ clientRef,
113
+ digest,
114
+ state: "completed",
115
+ response,
116
+ ts: Date.now(),
117
+ };
118
+ await this.#append(completed);
119
+ this.#latest.set(clientRef, completed);
120
+ });
121
+ }
122
+
123
+ async #append(row: OperationReceipt): Promise<void> {
124
+ const handle = await fs.open(this.#file, "a", 0o600);
125
+ try {
126
+ await handle.writeFile(`${JSON.stringify(row)}\n`);
127
+ await handle.sync();
128
+ } finally {
129
+ await handle.close();
130
+ }
131
+ }
132
+
133
+ async #serial<T>(operation: () => Promise<T>): Promise<T> {
134
+ const previous = this.#chain;
135
+ const next = Promise.withResolvers<void>();
136
+ this.#chain = next.promise;
137
+ await previous;
138
+ try {
139
+ return await operation();
140
+ } finally {
141
+ next.resolve();
142
+ }
143
+ }
144
+ }