@mono-agent/agent-runtime 0.4.0 → 0.5.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 (119) hide show
  1. package/ARCHITECTURE.md +37 -16
  2. package/MIGRATION.md +185 -7
  3. package/README.md +24 -14
  4. package/package.json +103 -10
  5. package/src/agent/allowlists.js +3 -0
  6. package/src/agent/approval.js +3 -0
  7. package/src/agent/compaction.js +244 -1
  8. package/src/agent/prompt/skill-index.js +6 -0
  9. package/src/agent/sandbox-seam.js +227 -0
  10. package/src/agent/tool-bloat.js +8 -2
  11. package/src/agent/tools/bash.js +16 -8
  12. package/src/agent/tools/edit.js +7 -3
  13. package/src/agent/tools/glob.js +11 -5
  14. package/src/agent/tools/grep.js +11 -5
  15. package/src/agent/tools/pi-bridge.js +227 -45
  16. package/src/agent/tools/read.js +28 -3
  17. package/src/agent/tools/shared/output-truncation.js +8 -3
  18. package/src/agent/tools/shared/path-resolver.js +24 -18
  19. package/src/agent/tools/shared/ripgrep.js +33 -6
  20. package/src/agent/tools/shared/runtime-context.js +60 -50
  21. package/src/agent/tools/shared/tool-context.js +157 -0
  22. package/src/agent/tools/web-fetch.js +21 -10
  23. package/src/agent/tools/web-search.js +11 -4
  24. package/src/agent/tools/write.js +7 -3
  25. package/src/agent/transcript.js +16 -1
  26. package/src/ai/cost.js +128 -3
  27. package/src/ai/failure.js +96 -4
  28. package/src/ai/live-input-prompt.js +11 -1
  29. package/src/ai/providers/claude-cli.js +2 -2
  30. package/src/ai/providers/claude-sdk.js +55 -12
  31. package/src/ai/providers/codex-app.js +36 -23
  32. package/src/ai/providers/opencode-app.js +2 -2
  33. package/src/ai/providers/opencode-discovery.js +2 -2
  34. package/src/ai/providers/pi-events.js +5 -0
  35. package/src/ai/providers/pi-models.js +21 -4
  36. package/src/ai/providers/pi-native/compaction-driver.js +394 -0
  37. package/src/ai/providers/pi-native/result-builder.js +312 -0
  38. package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
  39. package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
  40. package/src/ai/providers/pi-native/structured-output.js +130 -0
  41. package/src/ai/providers/pi-native/turn-runner.js +278 -0
  42. package/src/ai/providers/pi-native.js +415 -1106
  43. package/src/ai/runtime/model-refs.js +30 -0
  44. package/src/ai/runtime/registry.js +29 -0
  45. package/src/ai/runtime/router.js +96 -12
  46. package/src/ai/runtime/session-liveness.js +110 -0
  47. package/src/ai/runtime/sessions.js +16 -0
  48. package/src/ai/types.js +252 -19
  49. package/src/index.js +1 -1
  50. package/src/pi-auth.js +147 -16
  51. package/src/runtime-brand.js +21 -1
  52. package/src/runtime.js +72 -8
  53. package/types/agent/allowlists.d.ts +25 -0
  54. package/types/agent/approval.d.ts +30 -0
  55. package/types/agent/compaction.d.ts +97 -0
  56. package/types/agent/index.d.ts +5 -0
  57. package/types/agent/prompt/skill-index.d.ts +19 -0
  58. package/types/agent/sandbox-seam.d.ts +148 -0
  59. package/types/agent/tool-bloat.d.ts +22 -0
  60. package/types/agent/tools/bash.d.ts +16 -0
  61. package/types/agent/tools/edit.d.ts +14 -0
  62. package/types/agent/tools/glob.d.ts +16 -0
  63. package/types/agent/tools/grep.d.ts +22 -0
  64. package/types/agent/tools/index.d.ts +10 -0
  65. package/types/agent/tools/pi-bridge.d.ts +144 -0
  66. package/types/agent/tools/read.d.ts +19 -0
  67. package/types/agent/tools/shared/constants.d.ts +13 -0
  68. package/types/agent/tools/shared/dedup.d.ts +8 -0
  69. package/types/agent/tools/shared/output-truncation.d.ts +22 -0
  70. package/types/agent/tools/shared/path-resolver.d.ts +6 -0
  71. package/types/agent/tools/shared/ripgrep.d.ts +38 -0
  72. package/types/agent/tools/shared/runtime-context.d.ts +27 -0
  73. package/types/agent/tools/shared/tool-context.d.ts +48 -0
  74. package/types/agent/tools/web-fetch.d.ts +13 -0
  75. package/types/agent/tools/web-search.d.ts +11 -0
  76. package/types/agent/tools/write.d.ts +12 -0
  77. package/types/agent/transcript.d.ts +45 -0
  78. package/types/ai/backend.d.ts +41 -0
  79. package/types/ai/cost.d.ts +96 -0
  80. package/types/ai/failure.d.ts +117 -0
  81. package/types/ai/file-change-stats.d.ts +75 -0
  82. package/types/ai/index.d.ts +7 -0
  83. package/types/ai/live-input-prompt.d.ts +6 -0
  84. package/types/ai/observer.d.ts +68 -0
  85. package/types/ai/providers/claude-cli.d.ts +211 -0
  86. package/types/ai/providers/claude-sdk.d.ts +66 -0
  87. package/types/ai/providers/claude-subagents.d.ts +2 -0
  88. package/types/ai/providers/codex-app.d.ts +151 -0
  89. package/types/ai/providers/opencode-app.d.ts +95 -0
  90. package/types/ai/providers/opencode-discovery.d.ts +4 -0
  91. package/types/ai/providers/pi-errors.d.ts +3 -0
  92. package/types/ai/providers/pi-events.d.ts +24 -0
  93. package/types/ai/providers/pi-messages.d.ts +5 -0
  94. package/types/ai/providers/pi-models.d.ts +57 -0
  95. package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
  96. package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
  97. package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
  98. package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
  99. package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
  100. package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
  101. package/types/ai/providers/pi-native.d.ts +18 -0
  102. package/types/ai/registry.d.ts +1 -0
  103. package/types/ai/runtime/capabilities-used.d.ts +21 -0
  104. package/types/ai/runtime/capabilities.d.ts +33 -0
  105. package/types/ai/runtime/context-windows.d.ts +8 -0
  106. package/types/ai/runtime/fast-mode.d.ts +2 -0
  107. package/types/ai/runtime/model-refs.d.ts +35 -0
  108. package/types/ai/runtime/registry.d.ts +38 -0
  109. package/types/ai/runtime/router.d.ts +56 -0
  110. package/types/ai/runtime/session-liveness.d.ts +55 -0
  111. package/types/ai/runtime/sessions.d.ts +38 -0
  112. package/types/ai/streaming/codex-events.d.ts +30 -0
  113. package/types/ai/streaming/opencode-events.d.ts +41 -0
  114. package/types/ai/types.d.ts +702 -0
  115. package/types/index.d.ts +7 -0
  116. package/types/pi-auth.d.ts +28 -0
  117. package/types/runtime-brand.d.ts +30 -0
  118. package/types/runtime.d.ts +10 -0
  119. package/src/ai/providers/pi-sdk.js +0 -35
@@ -1,3 +1,7 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {import('../types.js').RuntimeModelRef} RuntimeModelRef */
4
+
1
5
  const RESERVED_RUNTIME_IDS = new Set(["openai", "vercel", "claude-code", "codex-cli"]);
2
6
  const ACTIVE_RUNTIME_IDS = new Set(["claude", "pi", "codex", "opencode"]);
3
7
 
@@ -14,6 +18,10 @@ function rejectTierAlias(model) {
14
18
  }
15
19
  }
16
20
 
21
+ /**
22
+ * @param {string} value
23
+ * @returns {string}
24
+ */
17
25
  export function canonicalizeLegacyModelReference(value) {
18
26
  if (!value || typeof value !== "string") throw new Error("model reference required");
19
27
 
@@ -42,15 +50,27 @@ export function canonicalizeLegacyModelReference(value) {
42
50
  return value;
43
51
  }
44
52
 
53
+ /**
54
+ * @param {string} value
55
+ * @returns {RuntimeModelRef}
56
+ */
45
57
  export function normalizeRuntimeModelReference(value) {
46
58
  return parseRuntimeModelReference(canonicalizeLegacyModelReference(value));
47
59
  }
48
60
 
61
+ /**
62
+ * @param {string} value
63
+ * @returns {RuntimeModelRef["sdk"]}
64
+ */
49
65
  export function sdkFromModelReference(value) {
50
66
  const parsed = parseRuntimeModelReference(value);
51
67
  return parsed.sdk;
52
68
  }
53
69
 
70
+ /**
71
+ * @param {string} value
72
+ * @returns {RuntimeModelRef}
73
+ */
54
74
  export function parseRuntimeModelReference(value) {
55
75
  if (!value || typeof value !== "string") throw new Error("model reference required");
56
76
 
@@ -107,6 +127,11 @@ export const RESERVED_RUNTIME_KINDS = [...RESERVED_RUNTIME_IDS];
107
127
 
108
128
  // Returns null when the combo is fine; otherwise a short reason string the
109
129
  // UI / API can show.
130
+ /**
131
+ * @param {string|RuntimeModelRef} modelRefOrParsed
132
+ * @param {string} executionMode
133
+ * @returns {string|null}
134
+ */
110
135
  export function executionModeIncompatibilityReason(modelRefOrParsed, executionMode) {
111
136
  let parsed;
112
137
  try {
@@ -139,6 +164,11 @@ export function executionModeIncompatibilityReason(modelRefOrParsed, executionMo
139
164
  return `sdk \`${parsed.sdk}\` is not supported under CLI execution mode.`;
140
165
  }
141
166
 
167
+ /**
168
+ * @param {string|RuntimeModelRef} modelRefOrParsed
169
+ * @param {string} executionMode
170
+ * @returns {boolean}
171
+ */
142
172
  export function isModelCompatibleWithExecutionMode(modelRefOrParsed, executionMode) {
143
173
  return executionModeIncompatibilityReason(modelRefOrParsed, executionMode) === null;
144
174
  }
@@ -1,8 +1,29 @@
1
+ // @ts-check
2
+
1
3
  import { COMMON_CAPABILITIES, runtimeCapabilities } from "./capabilities.js";
2
4
 
5
+ /**
6
+ * `RuntimeModelRef` is referenced inline (not aliased with a top-level
7
+ * `@typedef`) so this barrel does not re-export a second `RuntimeModelRef`
8
+ * type alongside model-refs.js's — that duplicate `export *` re-export is a
9
+ * TS2308 ambiguity. The canonical export stays in model-refs.js/types.js.
10
+ * @typedef {import('../types.js').RuntimeBridge} RuntimeBridge
11
+ * @typedef {import('../types.js').RuntimeBridgeDescriptor} RuntimeBridgeDescriptor
12
+ * @typedef {import('../types.js').RuntimeBridgeId} RuntimeBridgeId
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} BridgeSpec
17
+ * @property {RuntimeBridgeId} id
18
+ * @property {(ref: (import('../types.js').RuntimeModelRef|undefined), options?: Object) => boolean} supports
19
+ * @property {(ref?: import('../types.js').RuntimeModelRef) => Object} capabilities
20
+ * @property {(options?: Object) => Promise<RuntimeBridge>} load
21
+ */
22
+
3
23
  // CLI bridges are checked first when execution_mode='cli'. Without that flag
4
24
  // the resolver falls through to the SDK bridges below, preserving the
5
25
  // pre-Phase-2 behaviour for any agent that hasn't opted in.
26
+ /** @type {Object<string, BridgeSpec>} */
6
27
  const builtinBridgeSpecs = {
7
28
  "claude-code": {
8
29
  id: "claude-code",
@@ -41,6 +62,9 @@ const builtinBridgeSpecs = {
41
62
  },
42
63
  };
43
64
 
65
+ /**
66
+ * @returns {Array<RuntimeBridgeDescriptor>}
67
+ */
44
68
  export function listRuntimeBridges() {
45
69
  return Object.values(builtinBridgeSpecs).map((bridge) => ({
46
70
  id: bridge.id,
@@ -49,6 +73,11 @@ export function listRuntimeBridges() {
49
73
  }));
50
74
  }
51
75
 
76
+ /**
77
+ * @param {import('../types.js').RuntimeModelRef} modelRef
78
+ * @param {Object} [options]
79
+ * @returns {Promise<RuntimeBridge>}
80
+ */
52
81
  export async function resolveRuntimeBridge(modelRef, options = {}) {
53
82
  for (const spec of Object.values(builtinBridgeSpecs)) {
54
83
  if (spec.supports(modelRef, options)) return spec.load(options);
@@ -24,25 +24,74 @@
24
24
  // Result:
25
25
  // The success run's result, with `failoverHistory` appended describing every
26
26
  // prior attempt: [{ model, failureKind, requestId, retryableSubkind }].
27
- // If every entry in the chain fails, returns the last result with
28
- // `failureKind: "provider_unavailable_exhausted"`.
27
+ // If every eligible retryable/auth entry in the chain fails, returns the last
28
+ // result with `failureKind: "provider_unavailable_exhausted"`. Terminal
29
+ // non-retryable failures are returned as-is with their failover history.
30
+
31
+ // @ts-check
29
32
 
30
33
  import { createRuntime } from "../../runtime.js";
31
- import { retryableProviderFailureInfo } from "../failure.js";
34
+ import { isProviderAuthFailureText, retryableProviderFailureInfo } from "../failure.js";
32
35
  import { runtimeCapabilities } from "./capabilities.js";
33
36
  import { buildTranscriptTailSnapshot, renderResumeSnapshot } from "../../agent/transcript.js";
37
+ import { resolveRuntimeBrand } from "../../runtime-brand.js";
38
+
39
+ /**
40
+ * @typedef {import('../types.js').RuntimeModelRef} RuntimeModelRef
41
+ * @typedef {import('../types.js').AgentRuntimeHostOptions} AgentRuntimeHostOptions
42
+ * @typedef {import('../types.js').AgentRuntimeInstance} AgentRuntimeInstance
43
+ * @typedef {import('../types.js').RuntimeRunOptions} RuntimeRunOptions
44
+ * @typedef {import('../types.js').RuntimeResult} RuntimeResult
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} RouterChainEntryInput
49
+ * A chain entry as accepted by createRouterRuntime: either the shorthand bare
50
+ * RuntimeModelRef, or the full `{model, executionMode?, requires?}` form.
51
+ * @property {RuntimeModelRef} model
52
+ * @property {string} [executionMode]
53
+ * @property {Object<string, *>} [requires]
54
+ */
34
55
 
56
+ /**
57
+ * @typedef {Object} RouterChainEntry
58
+ * @property {RuntimeModelRef} model
59
+ * @property {string|null} executionMode
60
+ * @property {Object<string, *>|null} requires
61
+ */
62
+
63
+ /**
64
+ * @param {Object} [options]
65
+ * @param {AgentRuntimeHostOptions} [options.host]
66
+ * @param {ReadonlyArray<RuntimeModelRef|RouterChainEntryInput>} [options.chain]
67
+ * @returns {AgentRuntimeInstance & {chain: () => Array<RouterChainEntry>}}
68
+ */
35
69
  export function createRouterRuntime({ host = {}, chain = [] } = {}) {
36
70
  const entries = normaliseChain(chain);
37
71
  if (entries.length === 0) {
38
72
  throw new Error("createRouterRuntime requires a non-empty chain");
39
73
  }
40
74
  const inner = createRuntime(host);
75
+ // The router builds transcript-tail snapshots outside the inner runtime's
76
+ // bridge call (which is where the per-instance toolContext lives), so resolve
77
+ // the host brand here to stamp the snapshot schema id with the same brand the
78
+ // inner runtime uses — createRuntime no longer publishes it to a process global.
79
+ const runtimeBrand = resolveRuntimeBrand(host.runtimeBrand);
41
80
 
42
81
  return {
82
+ /**
83
+ * @param {string} systemPrompt
84
+ * @param {Partial<RuntimeRunOptions>} [options] Optional so a bare `{}` call
85
+ * is legal; the router always overrides `model`/`executionMode` per chain
86
+ * entry (see AgentRuntimeInstance.run for the public, model-required contract).
87
+ * @returns {Promise<RuntimeResult>}
88
+ */
43
89
  async run(systemPrompt, options = {}) {
90
+ /** @type {Array<{model: RuntimeModelRef, failureKind: (string|null), requestId?: (string|null|undefined), retryableSubkind?: (string|null|undefined), requirements?: (Object<string,*>|null)}>} */
44
91
  const failoverHistory = [];
92
+ /** @type {RuntimeResult|null} */
45
93
  let lastResult = null;
94
+ /** @type {*} */
46
95
  let resumeSnapshot = null;
47
96
 
48
97
  for (let i = 0; i < entries.length; i += 1) {
@@ -103,6 +152,8 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
103
152
  };
104
153
  }
105
154
 
155
+ result = normalizeProviderAuthFailure(result);
156
+
106
157
  const retryability = retryableProviderFailureInfo({
107
158
  errorText: result.error || "",
108
159
  stderrTail: result.stderrTail || "",
@@ -129,15 +180,19 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
129
180
  });
130
181
  lastResult = result;
131
182
 
132
- // Bail early on non-retryable failures (auth, billing, cancellation,
133
- // invalid_result). Only retryable provider errors trigger fallback.
134
- const shouldFallback = retryability.retryable && !result.cancelled;
135
- if (!shouldFallback) break;
183
+ // Provider auth is terminal for one provider, but chain-retryable: a
184
+ // fallback provider may have working credentials. Other non-retryable
185
+ // provider/request errors remain terminal.
186
+ const shouldFallback = (retryability.retryable || result.failureKind === "provider_auth")
187
+ && !result.cancelled;
188
+ if (!shouldFallback) {
189
+ return { ...result, failoverHistory };
190
+ }
136
191
 
137
192
  // Build a transcript-tail snapshot from this run's events so the
138
193
  // next provider can continue. If the run produced no usable events,
139
194
  // skip the snapshot (the next attempt starts fresh).
140
- const snapshot = buildTranscriptTailSnapshot(result.events);
195
+ const snapshot = buildTranscriptTailSnapshot(result.events, { runtimeBrand });
141
196
  if (snapshot) resumeSnapshot = snapshot;
142
197
  }
143
198
 
@@ -145,13 +200,13 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
145
200
  text: null,
146
201
  events: [],
147
202
  error: "router chain exhausted with no executions",
148
- failureKind: "provider_unavailable_exhausted",
203
+ failureKind: "skipped_capability_mismatch",
149
204
  cancelled: false,
150
205
  usage: {},
151
206
  };
152
207
  return {
153
208
  ...exhaustedResult,
154
- failureKind: "provider_unavailable_exhausted",
209
+ failureKind: lastResult ? "provider_unavailable_exhausted" : exhaustedResult.failureKind,
155
210
  failoverHistory,
156
211
  };
157
212
  },
@@ -168,9 +223,15 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
168
223
  };
169
224
  }
170
225
 
226
+ /**
227
+ * @param {ReadonlyArray<*>} chain ReadonlyArray<RuntimeModelRef|RouterChainEntryInput>, loosened
228
+ * here because distinguishing the two shapes is a runtime duck-type check
229
+ * (`entry.sdk && entry.model`), not something a union type narrows cleanly.
230
+ * @returns {Array<RouterChainEntry>}
231
+ */
171
232
  function normaliseChain(chain) {
172
233
  if (!Array.isArray(chain)) return [];
173
- return chain
234
+ return /** @type {Array<RouterChainEntry>} */ (chain
174
235
  .map((entry) => {
175
236
  if (!entry) return null;
176
237
  if (entry.sdk && entry.model) {
@@ -186,9 +247,14 @@ function normaliseChain(chain) {
186
247
  }
187
248
  return null;
188
249
  })
189
- .filter(Boolean);
250
+ .filter(Boolean));
190
251
  }
191
252
 
253
+ /**
254
+ * @param {RouterChainEntry} entry
255
+ * @param {Partial<RuntimeRunOptions>} options
256
+ * @returns {boolean}
257
+ */
192
258
  function entrySatisfiesRequirements(entry, options) {
193
259
  const requires = entry.requires;
194
260
  // Synthesize effective requirements: merge the entry's own `requires` with
@@ -225,6 +291,24 @@ function entrySatisfiesRequirements(entry, options) {
225
291
  return true;
226
292
  }
227
293
 
294
+ /**
295
+ * @param {Partial<RuntimeRunOptions>} callOptions
296
+ * @param {import('../types.js').RuntimeEvent} event
297
+ * @returns {void}
298
+ */
228
299
  function emit(callOptions, event) {
229
300
  try { callOptions.onEvent?.(event); } catch { /* swallow */ }
230
301
  }
302
+
303
+ /**
304
+ * @param {RuntimeResult} result
305
+ * @returns {RuntimeResult}
306
+ */
307
+ function normalizeProviderAuthFailure(result) {
308
+ if (result.cancelled) return result;
309
+ const failureKind = result.failureKind || null;
310
+ if (failureKind && failureKind !== "provider_unavailable") return result;
311
+ const haystack = `${result.error || ""}\n${result.stderrTail || ""}`;
312
+ if (!isProviderAuthFailureText(haystack)) return result;
313
+ return { ...result, failureKind: "provider_auth" };
314
+ }
@@ -0,0 +1,110 @@
1
+ // @ts-check
2
+ // Synchronous session-liveness primitives over a createSessionRegistry.
3
+ //
4
+ // A provider bridge owns session DURABILITY (pi keeps a Session transcript,
5
+ // codex keeps an app-server thread); this kernel layer owns session LIVENESS —
6
+ // the concurrency claim that keeps two turns from driving one session at once.
7
+ // The primitives here are deliberately SYNCHRONOUS: each does its registry
8
+ // get -> check -> set in a single await-free span, so the "busy claim must be
9
+ // await-free" invariant (I1) and the create-on-miss reservation race (R8) are
10
+ // enforced by construction rather than by careful inline sequencing.
11
+ //
12
+ // The registry stays the storage + idle-TTL + dispose fan-out layer (its
13
+ // exports are unchanged, worklab-compatible); liveness only adds the claim /
14
+ // reserve / release / adoptIfPresent seam that pi-native's session-lifecycle and
15
+ // codex-app both consume.
16
+
17
+ /**
18
+ * @typedef {object} SessionRegistryLike
19
+ * @property {(id: string) => any} get
20
+ * @property {(id: string, value: any, opts?: {idleTimeoutMs?: number}) => void} set
21
+ * @property {(id: string) => boolean} delete
22
+ */
23
+
24
+ /**
25
+ * @typedef {{ok: true, entry: any} | {ok: false, reason: "busy" | "missing"}} ClaimResult
26
+ * @typedef {{ok: true, id: string, release: () => void, commit: (entry: any) => void} | {ok: false, entry: any}} ReserveResult
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} SessionLiveness
31
+ * @property {(id: string) => ClaimResult} claim
32
+ * @property {(id: string) => any} adoptIfPresent
33
+ * @property {(id: string, seed: any, ttl: number|undefined) => ReserveResult} reserve
34
+ * @property {(id: string) => void} release
35
+ */
36
+
37
+ /**
38
+ * Wrap a session registry with the synchronous liveness primitives. The
39
+ * explicit return typedef pins the claim/reserve discriminated unions so
40
+ * callers narrow on `ok` (object-literal inference would otherwise widen `ok`
41
+ * to boolean and break narrowing).
42
+ * @param {SessionRegistryLike} registry
43
+ * @returns {SessionLiveness}
44
+ */
45
+ export function createSessionLiveness(registry) {
46
+ return {
47
+ /**
48
+ * Claim a live entry for a turn. The registry get -> busy check -> set-busy
49
+ * span is await-free (I1): a concurrent claim of a busy entry loses and is
50
+ * told `busy`, never adopting the session mid-turn.
51
+ * @param {string} id
52
+ * @returns {{ok: true, entry: any} | {ok: false, reason: "busy" | "missing"}}
53
+ */
54
+ claim(id) {
55
+ const entry = registry.get(id);
56
+ if (!entry) return { ok: false, reason: "missing" };
57
+ if (entry.busy) return { ok: false, reason: "busy" };
58
+ entry.busy = true;
59
+ return { ok: true, entry };
60
+ },
61
+
62
+ /**
63
+ * Re-read the registry after an awaited step (F4): a second concurrent cold
64
+ * resume could have reopened+inserted its own entry in the await window, so
65
+ * the caller adopts whatever is present (possibly busy) instead of racing a
66
+ * second insert. Returns null when nothing is present.
67
+ * @param {string} id
68
+ * @returns {any | null}
69
+ */
70
+ adoptIfPresent(id) {
71
+ return registry.get(id) ?? null;
72
+ },
73
+
74
+ /**
75
+ * Reserve an id with a BUSY placeholder before an awaited create (R8). The
76
+ * get -> check -> set span is await-free, so a second concurrent first turn
77
+ * observes the placeholder (busy) and loses the reservation — exactly one
78
+ * create per durable id. On loss the concurrent entry is returned so the
79
+ * caller can fall into the busy-claim path.
80
+ * @param {string} id
81
+ * @param {any} seed BUSY placeholder entry to insert.
82
+ * @param {number|undefined} ttl per-entry idle timeout.
83
+ * @returns {{ok: true, id: string, release: () => void, commit: (entry: any) => void} | {ok: false, entry: any}}
84
+ */
85
+ reserve(id, seed, ttl) {
86
+ const concurrent = registry.get(id);
87
+ if (concurrent) return { ok: false, entry: concurrent };
88
+ registry.set(id, seed, { idleTimeoutMs: ttl });
89
+ return {
90
+ ok: true,
91
+ id,
92
+ // Drop the placeholder (drop / error / abort paths). delete() removes
93
+ // without running onEvict, matching the raw registry.delete it replaces.
94
+ release: () => { registry.delete(id); },
95
+ // Overwrite the placeholder with the finalized entry on the keep-alive
96
+ // success path (same id, same ttl).
97
+ commit: (entry) => { registry.set(id, entry, { idleTimeoutMs: ttl }); },
98
+ };
99
+ },
100
+
101
+ /**
102
+ * Release a session's liveness (remove without running onEvict — the caller
103
+ * has already cleaned up, or is dropping an uncommitted session).
104
+ * @param {string} id
105
+ */
106
+ release(id) {
107
+ registry.delete(id);
108
+ },
109
+ };
110
+ }
@@ -25,6 +25,13 @@ function normalizeTtl(value, fallback) {
25
25
  return n;
26
26
  }
27
27
 
28
+ /**
29
+ * @param {object} [options]
30
+ * @param {number} [options.idleTimeoutMs]
31
+ * @param {(value: any, reason: string) => (void | Promise<void>)} [options.onEvict]
32
+ * @param {() => number} [options.now]
33
+ * @param {(value: any) => boolean} [options.isBusy]
34
+ */
28
35
  export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, onEvict, now = Date.now, isBusy } = {}) {
29
36
  const entries = new Map();
30
37
  const defaultTtlMs = normalizeTtl(idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS);
@@ -70,6 +77,11 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
70
77
  }
71
78
  return entry.value;
72
79
  },
80
+ /**
81
+ * @param {any} id
82
+ * @param {any} value
83
+ * @param {{idleTimeoutMs?: number}} [options]
84
+ */
73
85
  set(id, value, { idleTimeoutMs: entryTtl } = {}) {
74
86
  const previous = entries.get(id);
75
87
  if (previous) clearTimeout(previous.timer);
@@ -77,6 +89,10 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
77
89
  entries.set(id, entry);
78
90
  armTimer(id, entry);
79
91
  },
92
+ /**
93
+ * @param {any} id
94
+ * @param {{idleTimeoutMs?: number}} [options]
95
+ */
80
96
  touch(id, { idleTimeoutMs: entryTtl } = {}) {
81
97
  const entry = entries.get(id);
82
98
  if (!entry) return;