@mono-agent/agent-runtime 0.20.11 → 0.21.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 (148) hide show
  1. package/ARCHITECTURE.md +50 -11
  2. package/MIGRATION.md +288 -26
  3. package/README.md +352 -477
  4. package/package.json +13 -44
  5. package/src/agent/tool-bloat.js +145 -9
  6. package/src/agent/tools/agent-tool.js +108 -9
  7. package/src/agent/tools/bash.js +11 -26
  8. package/src/agent/tools/codex-subscription-search.js +123 -29
  9. package/src/agent/tools/exec.js +10 -2
  10. package/src/agent/tools/index.js +7 -0
  11. package/src/agent/tools/monitor.js +149 -0
  12. package/src/agent/tools/pi-bridge.js +123 -19
  13. package/src/agent/tools/shared/bash-environment.js +31 -0
  14. package/src/agent/tools/shared/monitors.js +293 -0
  15. package/src/agent/tools/shared/path-resolver.js +25 -6
  16. package/src/agent/tools/shared/process-jobs.js +6 -1
  17. package/src/agent/tools/shared/process-runner.js +26 -6
  18. package/src/agent/tools/shared/tool-context.js +8 -0
  19. package/src/agent/tools/web-access-interstitial.js +70 -0
  20. package/src/agent/tools/web-browser-render.js +83 -58
  21. package/src/agent/tools/web-controller.js +112 -21
  22. package/src/agent/tools/web-document-extractor.js +379 -0
  23. package/src/agent/tools/web-fetch.js +271 -243
  24. package/src/agent/tools/web-request.js +65 -0
  25. package/src/agent/tools/web-search-output.js +165 -0
  26. package/src/agent/tools/web-search-state.js +75 -0
  27. package/src/agent/tools/web-search.js +532 -71
  28. package/src/ai/cost.js +13 -68
  29. package/src/ai/failure.js +3 -3
  30. package/src/ai/index.js +5 -17
  31. package/src/ai/observer.js +8 -0
  32. package/src/ai/pi-interop.js +221 -1
  33. package/src/ai/pi-oauth-compat.js +1 -1
  34. package/src/ai/provider-check.js +131 -0
  35. package/src/ai/providers/codex/app-server-client.js +592 -0
  36. package/src/ai/providers/pi-models.js +18 -10
  37. package/src/ai/providers/pi-native/compaction-driver.js +94 -42
  38. package/src/ai/providers/pi-native/compaction-summary.js +140 -0
  39. package/src/ai/providers/pi-native/harness-adapter.js +376 -0
  40. package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
  41. package/src/ai/providers/pi-native/provider-attribution.js +102 -0
  42. package/src/ai/providers/pi-native/result-builder.js +38 -14
  43. package/src/ai/providers/pi-native/session-lifecycle.js +253 -55
  44. package/src/ai/providers/pi-native/stream-subscriber.js +52 -6
  45. package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
  46. package/src/ai/providers/pi-native/turn-runner.js +279 -28
  47. package/src/ai/providers/pi-native.js +206 -61
  48. package/src/ai/runtime/capabilities.js +11 -56
  49. package/src/ai/runtime/live-input-events.js +250 -54
  50. package/src/ai/runtime/model-refs.js +118 -153
  51. package/src/ai/runtime/registry.js +22 -56
  52. package/src/ai/runtime/router.js +76 -417
  53. package/src/ai/runtime/session-liveness.js +3 -4
  54. package/src/ai/runtime/sessions.js +4 -5
  55. package/src/ai/runtime/tool-policy.js +0 -2
  56. package/src/ai/tool-lifecycle.js +32 -18
  57. package/src/ai/types.js +37 -112
  58. package/src/index.js +0 -6
  59. package/src/runtime.js +29 -16
  60. package/types/agent/tool-bloat.d.ts +1 -1
  61. package/types/agent/tools/agent-tool.d.ts +4 -2
  62. package/types/agent/tools/bash.d.ts +5 -3
  63. package/types/agent/tools/codex-subscription-search.d.ts +7 -3
  64. package/types/agent/tools/exec.d.ts +5 -3
  65. package/types/agent/tools/index.d.ts +1 -0
  66. package/types/agent/tools/monitor.d.ts +47 -0
  67. package/types/agent/tools/pi-bridge.d.ts +7 -4
  68. package/types/agent/tools/shared/bash-environment.d.ts +4 -0
  69. package/types/agent/tools/shared/monitors.d.ts +98 -0
  70. package/types/agent/tools/shared/process-jobs.d.ts +5 -1
  71. package/types/agent/tools/shared/process-runner.d.ts +14 -4
  72. package/types/agent/tools/shared/tool-context.d.ts +2 -0
  73. package/types/agent/tools/web-access-interstitial.d.ts +23 -0
  74. package/types/agent/tools/web-browser-render.d.ts +4 -1
  75. package/types/agent/tools/web-controller.d.ts +4 -2
  76. package/types/agent/tools/web-document-extractor.d.ts +27 -0
  77. package/types/agent/tools/web-fetch.d.ts +19 -24
  78. package/types/agent/tools/web-request.d.ts +20 -0
  79. package/types/agent/tools/web-search-output.d.ts +31 -0
  80. package/types/agent/tools/web-search-state.d.ts +21 -0
  81. package/types/agent/tools/web-search.d.ts +10 -45
  82. package/types/ai/cost.d.ts +1 -2
  83. package/types/ai/index.d.ts +2 -4
  84. package/types/ai/observer.d.ts +6 -0
  85. package/types/ai/pi-interop.d.ts +81 -0
  86. package/types/ai/provider-check.d.ts +53 -0
  87. package/types/ai/providers/codex/app-server-client.d.ts +37 -0
  88. package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
  89. package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
  90. package/types/ai/providers/pi-native/harness-adapter.d.ts +58 -0
  91. package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
  92. package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
  93. package/types/ai/providers/pi-native/result-builder.d.ts +14 -4
  94. package/types/ai/providers/pi-native/session-lifecycle.d.ts +25 -6
  95. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -2
  96. package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
  97. package/types/ai/providers/pi-native/turn-runner.d.ts +68 -10
  98. package/types/ai/providers/pi-native.d.ts +21 -4
  99. package/types/ai/runtime/capabilities.d.ts +21 -70
  100. package/types/ai/runtime/live-input-events.d.ts +32 -8
  101. package/types/ai/runtime/model-refs.d.ts +0 -24
  102. package/types/ai/runtime/router.d.ts +3 -10
  103. package/types/ai/runtime/tool-policy.d.ts +0 -2
  104. package/types/ai/tool-lifecycle.d.ts +4 -3
  105. package/types/ai/types.d.ts +162 -256
  106. package/types/index.d.ts +0 -1
  107. package/src/ai/providers/acp-client.js +0 -1149
  108. package/src/ai/providers/acp-privacy.js +0 -124
  109. package/src/ai/providers/acp-public.js +0 -21
  110. package/src/ai/providers/acp-session-tokens.js +0 -282
  111. package/src/ai/providers/acp-transport.js +0 -356
  112. package/src/ai/providers/acp.js +0 -543
  113. package/src/ai/providers/claude-cli.js +0 -883
  114. package/src/ai/providers/claude-sandbox.js +0 -71
  115. package/src/ai/providers/claude-sdk-discovery-worker.js +0 -53
  116. package/src/ai/providers/claude-sdk-discovery.js +0 -352
  117. package/src/ai/providers/claude-sdk.js +0 -1127
  118. package/src/ai/providers/claude-subagent-activity.js +0 -719
  119. package/src/ai/providers/claude-subagents.js +0 -88
  120. package/src/ai/providers/codex-app.js +0 -2946
  121. package/src/ai/providers/opencode-app.js +0 -1109
  122. package/src/ai/providers/opencode-discovery.js +0 -39
  123. package/src/ai/providers/opencode-server.js +0 -508
  124. package/src/ai/runtime/context-windows.js +0 -46
  125. package/src/ai/runtime/fast-mode.js +0 -8
  126. package/src/ai/streaming/codex-events.js +0 -146
  127. package/src/ai/streaming/opencode-events.js +0 -59
  128. package/types/ai/providers/acp-client.d.ts +0 -227
  129. package/types/ai/providers/acp-privacy.d.ts +0 -25
  130. package/types/ai/providers/acp-public.d.ts +0 -7
  131. package/types/ai/providers/acp-session-tokens.d.ts +0 -41
  132. package/types/ai/providers/acp-transport.d.ts +0 -45
  133. package/types/ai/providers/acp.d.ts +0 -93
  134. package/types/ai/providers/claude-cli.d.ts +0 -305
  135. package/types/ai/providers/claude-sandbox.d.ts +0 -79
  136. package/types/ai/providers/claude-sdk-discovery-worker.d.ts +0 -1
  137. package/types/ai/providers/claude-sdk-discovery.d.ts +0 -97
  138. package/types/ai/providers/claude-sdk.d.ts +0 -138
  139. package/types/ai/providers/claude-subagent-activity.d.ts +0 -53
  140. package/types/ai/providers/claude-subagents.d.ts +0 -18
  141. package/types/ai/providers/codex-app.d.ts +0 -151
  142. package/types/ai/providers/opencode-app.d.ts +0 -96
  143. package/types/ai/providers/opencode-discovery.d.ts +0 -4
  144. package/types/ai/providers/opencode-server.d.ts +0 -20
  145. package/types/ai/runtime/context-windows.d.ts +0 -9
  146. package/types/ai/runtime/fast-mode.d.ts +0 -2
  147. package/types/ai/streaming/codex-events.d.ts +0 -40
  148. package/types/ai/streaming/opencode-events.d.ts +0 -42
@@ -32,6 +32,24 @@ export function usageFromMessages(messages = []) {
32
32
  return usage;
33
33
  }
34
34
 
35
+ /**
36
+ * Pi initializes failed assistant messages with an all-zero usage object before
37
+ * any provider telemetry arrives. A successful response makes those zeros
38
+ * measured; a failed response is measured only when real usage is non-zero.
39
+ * @param {Array<any>} [messages]
40
+ */
41
+ export function hasMeasuredUsage(messages = []) {
42
+ return messages.some((message) => {
43
+ if (message?.role !== "assistant" || !message.usage) return false;
44
+ if (message.stopReason !== "error" && message.stopReason !== "aborted") return true;
45
+ const usage = message.usage;
46
+ return [usage.input, usage.output, usage.cacheRead, usage.cacheWrite, usage.totalTokens,
47
+ usage.cost?.input, usage.cost?.output, usage.cost?.cacheRead,
48
+ usage.cost?.cacheWrite, usage.cost?.total]
49
+ .some((value) => Number.isFinite(Number(value)) && Number(value) > 0);
50
+ });
51
+ }
52
+
35
53
  /**
36
54
  * Add what this run's subagents spent to the run's own usage.
37
55
  *
@@ -70,7 +88,7 @@ export function withSubagentUsage(usage, delegated, estimatedCost) {
70
88
  * requests in the run: the last assistant usage is the same provider-counted
71
89
  * value Pi's compaction logic trusts, so it can decrease after compaction.
72
90
  * @param {any} assistantMessage
73
- * @returns {{input: number, output: number, cacheRead: number, cacheCreation: number, total: number}|null}
91
+ * @returns {{input: number, output: number, cacheRead: number, cacheCreation: number, total: number, costUsd: number}|null}
74
92
  */
75
93
  export function contextUsageFromAssistantMessage(assistantMessage) {
76
94
  if (assistantMessage?.role !== "assistant" || !assistantMessage.usage) return null;
@@ -83,6 +101,7 @@ export function contextUsageFromAssistantMessage(assistantMessage) {
83
101
  cacheRead: Number(assistantMessage.usage.cacheRead) || 0,
84
102
  cacheCreation: Number(assistantMessage.usage.cacheWrite) || 0,
85
103
  total,
104
+ costUsd: Number(assistantMessage.usage.cost?.total) || 0,
86
105
  };
87
106
  }
88
107
 
@@ -118,14 +137,14 @@ export function emitUsageCostEvents({
118
137
  externalAbort,
119
138
  }) {
120
139
  if (usage.cacheRead > 0) {
121
- onEvent({ type: "cache_hit", sdk: resolved.sdk, model: reference, tokens: usage.cacheRead, source: "prompt_cache" });
140
+ onEvent({ type: "cache_hit", sdk: "pi", model: reference, tokens: usage.cacheRead, source: "prompt_cache" });
122
141
  }
123
142
  if (usage.cacheWrite > 0) {
124
- onEvent({ type: "cache_miss", sdk: resolved.sdk, model: reference, tokens: usage.cacheWrite, source: "prompt_cache" });
143
+ onEvent({ type: "cache_miss", sdk: "pi", model: reference, tokens: usage.cacheWrite, source: "prompt_cache" });
125
144
  }
126
145
  onEvent({
127
146
  type: "cost_accumulated",
128
- sdk: resolved.sdk,
147
+ sdk: "pi",
129
148
  model: reference,
130
149
  cumulativeUsd: Number(usage.cost) || Number(estimatedCost) || 0,
131
150
  tokens: {
@@ -137,7 +156,7 @@ export function emitUsageCostEvents({
137
156
  });
138
157
  onEvent({
139
158
  type: "provider_request_completed",
140
- sdk: resolved.sdk,
159
+ sdk: "pi",
141
160
  model: reference,
142
161
  runtime: "pi",
143
162
  timestamp: Date.now(),
@@ -167,9 +186,9 @@ export function abortedResult({ resolved, options, events, runtimeWarnings, star
167
186
  usage: {},
168
187
  durationMs: Date.now() - start,
169
188
  numTurns: 0,
170
- model: resolved?.reference || resolved?.model || null,
189
+ model: resolved?.reference || (resolved?.provider && resolved?.model ? `${resolved.provider}:${resolved.model}` : null),
171
190
  effort: options.effort || null,
172
- sdk: resolved?.sdk || "pi",
191
+ sdk: "pi",
173
192
  cancelled: true,
174
193
  error: null,
175
194
  failureKind: null,
@@ -211,6 +230,8 @@ export function buildSuccessResult(params) {
211
230
  runtimeWarnings,
212
231
  capabilitiesUsed,
213
232
  structuredResult,
233
+ usageMeasured = true,
234
+ effectiveEffort,
214
235
  } = params;
215
236
  return {
216
237
  text: finalText,
@@ -219,16 +240,17 @@ export function buildSuccessResult(params) {
219
240
  usage: {
220
241
  input_tokens: usage.input || null,
221
242
  output_tokens: usage.output || null,
222
- cache_read_tokens: usage.cacheRead || null,
223
- cache_creation_tokens: usage.cacheWrite || null,
224
- cache_write_tokens: usage.cacheWrite || null,
243
+ cache_read_tokens: usageMeasured ? usage.cacheRead : null,
244
+ cache_creation_tokens: usageMeasured ? usage.cacheWrite : null,
245
+ cache_write_tokens: usageMeasured ? usage.cacheWrite : null,
225
246
  cost_usd: usage.cost || estimatedCost,
226
247
  },
227
248
  durationMs: Date.now() - start,
228
249
  numTurns: turnCount || runAssistantCount,
229
- model: resolved.reference || `pi:${resolved.provider}:${resolved.model}`,
250
+ model: resolved.reference || `${resolved.provider}:${resolved.model}`,
230
251
  effort: options.effort || null,
231
- sdk: resolved.sdk,
252
+ effectiveEffort: effectiveEffort || null,
253
+ sdk: "pi",
232
254
  cancelled: externalAbort,
233
255
  error: errorMessage,
234
256
  errorDetails,
@@ -266,6 +288,7 @@ export function buildErrorResult(params) {
266
288
  runtimeWarnings,
267
289
  isRetryable,
268
290
  piTransport,
291
+ effectiveEffort,
269
292
  } = params;
270
293
  return {
271
294
  text: assistantTexts.join("") || null,
@@ -273,9 +296,10 @@ export function buildErrorResult(params) {
273
296
  usage: {},
274
297
  durationMs: Date.now() - start,
275
298
  numTurns: turnCount,
276
- model: resolved?.reference || resolved?.model || null,
299
+ model: resolved?.reference || (resolved?.provider && resolved?.model ? `${resolved.provider}:${resolved.model}` : null),
277
300
  effort: options.effort || null,
278
- sdk: resolved?.sdk || "pi",
301
+ effectiveEffort: effectiveEffort || null,
302
+ sdk: "pi",
279
303
  cancelled: externalAbort,
280
304
  error: externalAbort ? null : errorMessage,
281
305
  errorDetails: externalAbort ? null : {
@@ -10,12 +10,15 @@
10
10
  // createSessionLiveness primitives so the await-free spans are enforced by
11
11
  // construction rather than by inline sequencing.
12
12
 
13
- import { InMemorySessionRepo, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
13
+ import { JsonlSessionRepo, MemorySessionRepo, laneConfig, laneState, operationMeta, operationResult, operationState } from "@earendil-works/pi-agent-core";
14
+ import { validRecoveryProjection } from "./terminal-recovery.js";
14
15
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
- import { open } from "node:fs/promises";
16
- import { dirname, resolve } from "node:path";
16
+ import { createHash } from "node:crypto";
17
+ import { access, open, readdir, unlink } from "node:fs/promises";
18
+ import { dirname, join, resolve } from "node:path";
17
19
  import { createSessionRegistry } from "../../runtime/sessions.js";
18
20
  import { createSessionLiveness } from "../../runtime/session-liveness.js";
21
+ import { buildPiSessionContext, createPiSessionAdapter, PI_CONTEXT } from "./harness-adapter.js";
19
22
 
20
23
  async function syncPath(path) {
21
24
  const handle = await open(path, "r");
@@ -27,6 +30,7 @@ async function syncPath(path) {
27
30
  }
28
31
 
29
32
  async function syncDurableTranscript(entry) {
33
+ if (entry.recoveryPending) throw new Error("Pi terminal recovery is pending");
30
34
  if (!entry.durable) return;
31
35
  const path = entry.metadata?.path;
32
36
  if (typeof path !== "string" || !path) {
@@ -40,31 +44,61 @@ async function syncDurableTranscript(entry) {
40
44
  }
41
45
 
42
46
  async function invalidateNativeSession(entry) {
43
- await entry.repo.delete(entry.metadata);
44
47
  if (entry.durable) {
45
48
  const path = entry.metadata?.path;
46
49
  if (typeof path !== "string" || !path) {
47
50
  throw new Error("Durable Pi session metadata is missing its JSONL path");
48
51
  }
52
+ // Explicit invalidation is idempotent. A preceding failed sync may have
53
+ // observed that the transcript was already removed outside this process.
54
+ try {
55
+ await access(path);
56
+ } catch (error) {
57
+ if (error?.code === "ENOENT") return;
58
+ throw error;
59
+ }
60
+ }
61
+ await entry.repo.delete(entry.metadata, PI_CONTEXT);
62
+ if (entry.durable) {
63
+ const path = entry.metadata.path;
49
64
  // Make the unlink durable before the registry forgets the busy marker.
50
65
  await syncPath(dirname(path));
51
66
  }
52
67
  }
53
68
 
69
+ /**
70
+ * Release a Pi session handle and remove its repository record as independent
71
+ * best-effort operations. A close failure must not prevent deletion of a fresh
72
+ * or otherwise poisoned transcript.
73
+ * @param {any} session
74
+ * @param {any} repo
75
+ * @param {any} [knownMetadata]
76
+ */
77
+ async function closeAndDeleteSession(session, repo, knownMetadata) {
78
+ let metadata = knownMetadata;
79
+ if (!metadata) {
80
+ try { metadata = await session.getMetadata(); } catch { /* best-effort */ }
81
+ }
82
+ try { await session.close(); } catch { /* best-effort */ }
83
+ if (metadata) {
84
+ try { await repo.delete(metadata, PI_CONTEXT); } catch { /* best-effort */ }
85
+ }
86
+ }
87
+
54
88
  // Live pi-native sessions, keyed by provider session id. Entries are
55
- // { session, metadata, repo, durable, busy } identical shape and lifecycle
56
- // policy to the (now-retired) pi-sdk bridge: in-memory transcripts are freed
57
- // when the registry evicts them; durable (jsonl) transcripts survive eviction
58
- // so a later resume can reopen them from disk. Registering here gives
89
+ // { metadata, repo, durable, busy }. Pi 0.85 sessions are closed after every
90
+ // turn so their repository record can be reopened safely; the registry owns
91
+ // only liveness and metadata. In-memory transcripts are freed when the registry
92
+ // evicts them; durable (jsonl) transcripts survive eviction. Registering here gives
59
93
  // runtime.disposeSession / disposeProviderSession + idle-TTL eviction the same
60
94
  // reach over native pi sessions that the legacy bridge had.
61
- const nativeSessionRepo = new InMemorySessionRepo();
95
+ const nativeSessionRepo = new MemorySessionRepo();
62
96
  const nativeSessions = createSessionRegistry({
63
- isBusy: (entry) => entry.busy === true,
97
+ isBusy: (entry) => entry.busy === true || entry.recoveryPending === true,
64
98
  onSync: syncDurableTranscript,
65
99
  onEvict: async (entry, reason) => {
66
- // Ordinary disposal/TTL only drops the live handle so durable sessions can
67
- // reopen after restart. Explicit invalidation means the host rejected the
100
+ // Ordinary disposal/TTL only drops registry metadata so durable sessions
101
+ // can reopen later. Explicit invalidation means the host rejected the
68
102
  // turn before canonical history commit; that poisoned transcript must be
69
103
  // deleted too or it could silently reappear on the next stable-id resume.
70
104
  if (reason === "invalidated") {
@@ -75,7 +109,7 @@ const nativeSessions = createSessionRegistry({
75
109
  return;
76
110
  }
77
111
  if (entry.durable) return;
78
- await entry.repo.delete(entry.metadata);
112
+ await entry.repo.delete(entry.metadata, PI_CONTEXT);
79
113
  },
80
114
  });
81
115
  const liveness = createSessionLiveness(nativeSessions);
@@ -88,7 +122,7 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
88
122
  let repo = durableNativeSessionRepos.get(root);
89
123
  if (!repo) {
90
124
  repo = new JsonlSessionRepo({
91
- fs: new NodeExecutionEnv({ cwd: process.cwd() }),
125
+ fileSystem: new NodeExecutionEnv({ cwd: process.cwd() }),
92
126
  sessionsRoot: root,
93
127
  });
94
128
  durableNativeSessionRepos.set(root, repo);
@@ -97,11 +131,13 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
97
131
  }
98
132
 
99
133
  /**
100
- * Permanently retire every durable Pi transcript with this exact logical id.
101
- * This is intentionally stronger than live-session invalidation: history
102
- * rotation and retention can retire an epoch after its registry entry was
103
- * already evicted or after a process restart. Absence is success; any cleanup
104
- * or verification uncertainty rejects so canonical history remains reachable.
134
+ * Retire every currently materialized durable Pi transcript with this exact
135
+ * logical id. This is intentionally stronger than live-session invalidation:
136
+ * history rotation and retention can retire an epoch after its registry entry
137
+ * was already evicted or after a process restart. When an active old run later
138
+ * recreates its pathname, its post-runtime caller retries this operation. The
139
+ * canonical epoch has already rotated, so that old id is never resumable in the
140
+ * interim. Absence is success; cleanup or verification uncertainty rejects.
105
141
  */
106
142
  export async function retireDurableNativeSession(providerSessionId, piSessionsRoot) {
107
143
  if (!isSafeSessionId(providerSessionId)) {
@@ -111,29 +147,87 @@ export async function retireDurableNativeSession(providerSessionId, piSessionsRo
111
147
  throw new TypeError("piSessionsRoot must be a non-empty path");
112
148
  }
113
149
 
114
- // First guarantee this process cannot keep writing through a stale handle.
150
+ // First guarantee this process cannot resume through a stale registry entry.
151
+ // A host cancellation can rotate canonical history while the provider is
152
+ // still unwinding an open Pi session. The Pi repo deliberately refuses to
153
+ // delete an open session, so detach the registry and unlink its current file.
154
+ // Pi appends by pathname and can recreate a headerless orphan if the old run
155
+ // writes later; the raw exact-name sweep below removes those files on the
156
+ // post-runtime retry. The rotated canonical epoch never refers to this id.
157
+ const liveEntry = nativeSessions.get(providerSessionId);
158
+ const providerStillUnwinding = liveEntry?.busy === true;
115
159
  // Other processes are serialized by the history coordinator and must pass
116
160
  // the same cold-refresh barrier before their next turn.
117
161
  await nativeSessions.refresh(providerSessionId);
118
162
 
119
163
  const repo = resolveDurableNativeSessionRepo(piSessionsRoot);
120
164
  if (!repo) throw new Error("Durable Pi session repository is unavailable");
121
- const matches = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
165
+ const matches = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
122
166
  const changedDirectories = new Set();
123
167
  for (const metadata of matches) {
124
168
  if (typeof metadata?.path !== "string" || !metadata.path) {
125
169
  throw new Error(`Durable Pi session ${providerSessionId} has invalid metadata`);
126
170
  }
127
- await repo.delete(metadata);
171
+ if (providerStillUnwinding) {
172
+ try {
173
+ await unlink(metadata.path);
174
+ } catch (error) {
175
+ if (error?.code !== "ENOENT") throw error;
176
+ }
177
+ } else {
178
+ await repo.delete(metadata, PI_CONTEXT);
179
+ }
128
180
  changedDirectories.add(dirname(metadata.path));
129
181
  }
182
+ // A prior active-session unlink can be followed by Pi recreating the same
183
+ // pathname with a transaction line but no header. JsonlSessionRepo.list()
184
+ // intentionally ignores that invalid file, so scan only Pi's fixed
185
+ // root/directory/filename layout to make the later retirement retry reclaim
186
+ // it. Safe ids are single filename components and symlink directories/files
187
+ // are ignored.
188
+ for (const path of await exactDurableSessionFiles(piSessionsRoot, providerSessionId)) {
189
+ try {
190
+ await unlink(path);
191
+ } catch (error) {
192
+ if (error?.code !== "ENOENT") throw error;
193
+ }
194
+ changedDirectories.add(dirname(path));
195
+ }
130
196
  for (const directory of changedDirectories) await syncPath(directory);
131
197
  if (changedDirectories.size > 0) await syncPath(resolve(piSessionsRoot));
132
198
 
133
- const remaining = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
134
- if (remaining.length > 0) {
199
+ const remaining = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
200
+ const remainingPaths = await exactDurableSessionFiles(piSessionsRoot, providerSessionId);
201
+ if (!providerStillUnwinding && (remaining.length > 0 || remainingPaths.length > 0)) {
135
202
  throw new Error(`Durable Pi session ${providerSessionId} could not be retired completely`);
136
203
  }
204
+ // An active Pi write can race the final exact-name check after the sweep.
205
+ // Canonical history is already preparing a fresh epoch, so the retired id is
206
+ // unreachable; the harness retries retirement when that old run returns.
207
+ }
208
+
209
+ async function exactDurableSessionFiles(piSessionsRoot, providerSessionId) {
210
+ const root = resolve(piSessionsRoot);
211
+ const suffix = `_${encodeURIComponent(providerSessionId)}.jsonl`;
212
+ let rootEntries;
213
+ try {
214
+ rootEntries = await readdir(root, { withFileTypes: true });
215
+ } catch (error) {
216
+ if (error?.code === "ENOENT") return [];
217
+ throw error;
218
+ }
219
+ const paths = [];
220
+ for (const directory of rootEntries) {
221
+ if (!directory.isDirectory()) continue;
222
+ const directoryPath = join(root, directory.name);
223
+ const entries = await readdir(directoryPath, { withFileTypes: true });
224
+ for (const entry of entries) {
225
+ if (entry.isFile() && entry.name.endsWith(suffix)) {
226
+ paths.push(join(directoryPath, entry.name));
227
+ }
228
+ }
229
+ }
230
+ return paths;
137
231
  }
138
232
 
139
233
  // Defense in depth (R4): create-on-miss passes the caller-controlled session id
@@ -158,10 +252,9 @@ function isSafeSessionId(id) {
158
252
 
159
253
  async function reopenDurableNativeSession(repo, sessionId) {
160
254
  try {
161
- const metadata = (await repo.list()).find((entry) => entry?.id === sessionId);
255
+ const metadata = (await repo.list(undefined, PI_CONTEXT)).find((entry) => entry?.id === sessionId);
162
256
  if (!metadata) return null;
163
- const session = await repo.open(metadata);
164
- return { session, metadata, repo, durable: true, busy: false };
257
+ return { metadata, repo, durable: true, busy: false };
165
258
  } catch {
166
259
  return null;
167
260
  }
@@ -185,9 +278,9 @@ function sessionUnavailableResult({
185
278
  usage: {},
186
279
  durationMs: Date.now() - start,
187
280
  numTurns: 0,
188
- model: resolved?.reference || resolved?.model || null,
281
+ model: resolved?.reference || (resolved?.provider && resolved?.model ? `${resolved.provider}:${resolved.model}` : null),
189
282
  effort: options.effort || null,
190
- sdk: resolved?.sdk || "pi",
283
+ sdk: "pi",
191
284
  cancelled: false,
192
285
  error: errorMessage,
193
286
  failureKind,
@@ -290,11 +383,14 @@ export async function resolveSession(runState, {
290
383
  } else {
291
384
  // Reserved the id with a busy placeholder BEFORE the create await so a
292
385
  // second concurrent first turn observes busy and returns session_busy.
293
- // The keep-alive success path (reservation.commit with busy:false)
294
- // overwrites this placeholder on success; the drop/abort/catch paths
295
- // release it. Keyed by requestedSessionId === providerSessionId.
386
+ // The keep-alive success path overwrites this placeholder with an
387
+ // entry that remains busy until its harness closes; drop/abort/catch
388
+ // paths release it. Keyed by requestedSessionId === providerSessionId.
296
389
  runState.reservation = reservation;
297
- runState.session = await durableRepo.create({ id: providerSessionId, cwd: cwd || process.cwd() });
390
+ runState.session = createPiSessionAdapter(await durableRepo.create(
391
+ { id: providerSessionId, cwd: cwd || process.cwd() },
392
+ PI_CONTEXT,
393
+ ));
298
394
  runState.createdOnMiss = true;
299
395
  }
300
396
  } else {
@@ -322,7 +418,7 @@ export async function resolveSession(runState, {
322
418
  // (F4) safe. Do not introduce any await in this span or the TOCTOU window
323
419
  // reopens. `claim` re-reads the same registry entry and sets busy in one
324
420
  // await-free step; a busy entry loses and returns session_busy.
325
- const claimed = liveness.claim(requestedSessionId);
421
+ const claimed = entry.recoveryPending ? { ok: /** @type {const} */ (false), reason: "busy" } : liveness.claim(requestedSessionId);
326
422
  if (!claimed.ok) {
327
423
  // claim() can lose two ways: "busy" (the entry adopted above is
328
424
  // mid-turn) or "missing" (no live entry). "missing" is UNREACHABLE on
@@ -353,14 +449,30 @@ export async function resolveSession(runState, {
353
449
  };
354
450
  }
355
451
  runState.sessionEntry = claimed.entry;
356
- runState.session = claimed.entry.session;
452
+ delete claimed.entry.recovery;
453
+ try {
454
+ runState.session = createPiSessionAdapter(await claimed.entry.repo.open(
455
+ claimed.entry.metadata,
456
+ PI_CONTEXT,
457
+ ));
458
+ } catch (error) {
459
+ // The claim made this registry entry busy. An open failure means the
460
+ // entry cannot be driven, so remove its liveness record before
461
+ // propagating; otherwise every later resume retries the same broken
462
+ // entry forever. Preserve the durable transcript for a later cold
463
+ // reopen/recovery attempt.
464
+ liveness.release(requestedSessionId);
465
+ runState.sessionEntry = null;
466
+ throw error;
467
+ }
357
468
  }
358
469
  } else {
359
- // Fresh runs persist into the durable jsonl repo when piSessionsRoot is
360
- // set, so a kept-alive session can be reopened from disk after the live
361
- // entry is evicted; otherwise the in-memory repo is used.
362
- runState.session = await (durableRepo || nativeSessionRepo)
363
- .create({ id: providerSessionId, cwd: cwd || process.cwd() });
470
+ // Attribution may equal a live primary's id on a stateless retry/backup.
471
+ // A private ephemeral repo prevents both create collisions and cleanup of
472
+ // that primary's transcript. Only keep-alive calls use a shared repository.
473
+ if (options.sessionKeepAlive !== true) runState.ephemeralSessionRepo = new MemorySessionRepo();
474
+ runState.session = createPiSessionAdapter(await (runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo)
475
+ .create({ id: providerSessionId, cwd: cwd || process.cwd() }, PI_CONTEXT));
364
476
  }
365
477
  return { done: false };
366
478
  }
@@ -379,7 +491,10 @@ export async function discardUncommittedSession(runState, { durableRepo }) {
379
491
  // transcript was appended yet (prompt never ran), so the live session is
380
492
  // already at its pre-turn leaf and needs no rollback.
381
493
  if (runState.session && !runState.sessionEntry) {
382
- try { await (durableRepo || nativeSessionRepo).delete(await runState.session.getMetadata()); } catch { /* best-effort */ }
494
+ await closeAndDeleteSession(
495
+ runState.session,
496
+ runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo,
497
+ );
383
498
  }
384
499
  // Drop the create-on-miss BUSY reservation too, else the busy placeholder
385
500
  // leaks and every future resume of this conversation's stable id returns
@@ -407,7 +522,7 @@ export async function commitSession(runState, {
407
522
  onEvent,
408
523
  }) {
409
524
  const { session, sessionEntry, baselineLeafId, reservation } = runState;
410
- if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
525
+ if (options.sessionKeepAlive === true && ((!externalAbort && !errorMessage) || runState.retainRecoveryTail)) {
411
526
  try {
412
527
  if (sessionEntry) {
413
528
  // Resumed run: the harness appended this run's turns onto the live
@@ -419,17 +534,19 @@ export async function commitSession(runState, {
419
534
  } else {
420
535
  const metadata = await session.getMetadata();
421
536
  const entry = {
422
- session,
423
537
  metadata,
424
538
  repo: durableRepo || nativeSessionRepo,
425
539
  durable: !!durableRepo,
426
- busy: false,
540
+ busy: true,
427
541
  };
428
542
  // A create-on-miss reservation is overwritten by its commit (same id);
429
543
  // a plain fresh keep-alive run registers directly.
430
544
  if (reservation) reservation.commit(entry);
431
545
  else nativeSessions.set(providerSessionId, entry, { idleTimeoutMs: sessionTtlMs });
546
+ runState.registeredSessionEntry = entry;
432
547
  }
548
+ const retained = runState.sessionEntry || runState.registeredSessionEntry;
549
+ if (runState.retainRecoveryTail && retained) retained.recoveryPending = !!externalAbort || !!errorMessage;
433
550
  } catch (err) {
434
551
  // Session persistence must never fail the run; drop the (now
435
552
  // inconsistent) session instead of resuming from a broken transcript.
@@ -442,7 +559,7 @@ export async function commitSession(runState, {
442
559
  if (requestedSessionId) nativeSessions.delete(requestedSessionId);
443
560
  const broken = sessionEntry;
444
561
  if (broken) {
445
- try { await broken.repo.delete(broken.metadata); } catch { /* best-effort */ }
562
+ await closeAndDeleteSession(session, broken.repo, broken.metadata);
446
563
  }
447
564
  }
448
565
  } else if (sessionEntry) {
@@ -463,9 +580,7 @@ export async function commitSession(runState, {
463
580
  // (the success keep-alive path overwrites it with the finalized entry, so
464
581
  // it is only this drop branch that must clean it up).
465
582
  if (reservation) reservation.release();
466
- try {
467
- await (durableRepo || nativeSessionRepo).delete(await session.getMetadata());
468
- } catch { /* best-effort */ }
583
+ await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
469
584
  }
470
585
  }
471
586
 
@@ -486,8 +601,9 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
486
601
  }
487
602
  nativeSessions.delete(requestedSessionId);
488
603
  } else {
489
- nativeSessions.delete(providerSessionId);
490
- try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
604
+ // A stateless call never registered this id; it may belong to the primary.
605
+ if (!runState.ephemeralSessionRepo) nativeSessions.delete(providerSessionId);
606
+ await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
491
607
  }
492
608
  }
493
609
 
@@ -495,7 +611,8 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
495
611
  * Outer-catch session cleanup: drop a just-created fresh durable session, drop a
496
612
  * create-on-miss reservation placeholder, and roll a resumed session back to its
497
613
  * pre-turn leaf for host/runtime-side throws that landed after the harness
498
- * already mutated the live session.
614
+ * already mutated the live session. Resumed handles are always closed here so
615
+ * setup failures before a harness is returned cannot leave the repo wedged.
499
616
  * @param {any} runState
500
617
  * @param {{durableRepo: any}} params
501
618
  */
@@ -509,7 +626,7 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
509
626
  // resumed user session here would be data loss) and never when the throw
510
627
  // preceded session create.
511
628
  if (session && !sessionEntry) {
512
- try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
629
+ await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
513
630
  }
514
631
  // Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
515
632
  // during/after the reservation — including a throw inside the create await
@@ -522,8 +639,89 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
522
639
  // harness already mutated the live session. Mirrors the success-path
523
640
  // rollback: move the live session back to the pre-turn leaf so the failed
524
641
  // turn never leaks into a later resume. Gated on `sessionEntry &&
525
- // baselineLeafId` so it only fires for resumes that captured a baseline.
526
- if (sessionEntry && baselineLeafId) {
527
- try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
642
+ // baselineLeafId` so rollback only fires for resumes that captured a baseline.
643
+ // Closing is independently gated on the resumed session existing: a failure
644
+ // may land before the baseline was readable, but that handle must still be
645
+ // released without deleting the user-owned transcript.
646
+ if (sessionEntry && session) {
647
+ if (baselineLeafId) {
648
+ try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
649
+ }
650
+ try { await session.close(); } catch { /* best-effort */ }
651
+ }
652
+ }
653
+
654
+ /** Capture only after close; pending entries cannot be driven by another turn. */
655
+ export async function captureSessionRecovery(runState, { options, providerSessionId, modelKey, model, pending }) {
656
+ const entry = runState.sessionEntry || runState.registeredSessionEntry;
657
+ if (!entry?.durable) return undefined;
658
+ try {
659
+ if (!Array.isArray(runState.recoveryInputIds)) throw new Error("Pi session recovery input identities are unavailable");
660
+ const tipId = await runState.session.getLeafId();
661
+ if (typeof tipId !== "string" || !tipId) throw new Error("Pi session recovery tip is unavailable");
662
+ const ancestry = createHash("sha256").update(JSON.stringify(await runState.session.getEntries())).digest("hex");
663
+ await runState.session.close();
664
+ const receipt = { runId: options.sessionRecovery.runId, revision: options.sessionRecovery.revision, providerSessionId, modelKey, tipId };
665
+ entry.recovery = { receipt: { ...receipt }, model: { ...model, input: [...model.input] }, ancestry, operationId: runState.recoveryOperationId, baselineTipId: runState.recoveryBaselineTipId, inputIds: runState.recoveryInputIds };
666
+ entry.recoveryPending = pending || !!options.abortSignal?.aborted;
667
+ return receipt;
668
+ } catch (error) {
669
+ // The run's outer catch performs legacy rollback/close or fresh deletion.
670
+ // Release provisional recovery state first so failed capture cannot strand
671
+ // an entry as busy without a receipt that could settle it.
672
+ entry.recoveryPending = false;
673
+ delete entry.recovery;
674
+ throw error;
675
+ }
676
+ }
677
+
678
+ /** Read-only settlement: never drive an operation or append host-authored prose. */
679
+ export async function recoverDurableNativeSession(receipt, context) {
680
+ const entry = nativeSessions.get(receipt?.providerSessionId);
681
+ const proof = entry?.recovery;
682
+ if (!entry?.durable || entry.busy || !proof
683
+ || !["runId", "revision", "providerSessionId", "modelKey", "tipId"].every((key) => receipt[key] === proof.receipt[key])
684
+ || !Array.isArray(context?.appliedInputIds)
685
+ || proof.inputIds.some((id) => typeof id !== "string")
686
+ || JSON.stringify([...proof.inputIds].sort()) !== JSON.stringify([...context.appliedInputIds].sort())) return false;
687
+ entry.busy = true;
688
+ let raw;
689
+ try {
690
+ const matches = (await entry.repo.list(undefined, PI_CONTEXT)).filter((record) => record.id === receipt.providerSessionId);
691
+ if (matches.length !== 1 || matches[0].path !== entry.metadata.path) return false;
692
+ raw = await entry.repo.open(matches[0], PI_CONTEXT);
693
+ const branch = await raw.branch("main", PI_CONTEXT);
694
+ if (!branch || await branch.getTipId(PI_CONTEXT) !== receipt.tipId) return false;
695
+ const state = (await raw.getValue(laneState("main"), PI_CONTEXT))?.value;
696
+ const config = (await raw.getValue(laneConfig("main"), PI_CONTEXT))?.value;
697
+ const terminal = (await raw.getValue(operationResult(proof.operationId), PI_CONTEXT))?.value;
698
+ const meta = (await raw.getValue(operationMeta(proof.operationId), PI_CONTEXT))?.value;
699
+ if (!state || state.currentOperationId !== null || state.lastOperationId !== proof.operationId || state.inbox.length !== 0
700
+ || config?.model.provider !== proof.model.provider || config?.model.modelId !== proof.model.id
701
+ || !terminal || !["completed", "failed", "aborted"].includes(terminal.status) || terminal.tipId !== receipt.tipId
702
+ || meta !== undefined || terminal.kind !== "run" || terminal.fromTipId !== proof.baselineTipId
703
+ || await raw.getValue(operationState(proof.operationId), PI_CONTEXT)) return false;
704
+ const entries = await branch.findEntries({ order: "oldestFirst" }, PI_CONTEXT);
705
+ if (createHash("sha256").update(JSON.stringify(entries)).digest("hex") !== proof.ancestry) return false;
706
+ const baseline = proof.baselineTipId === null ? -1 : entries.findIndex((item) => item.id === proof.baselineTipId);
707
+ if (proof.baselineTipId !== null && baseline < 0) return false;
708
+ const tail = entries.slice(baseline + 1);
709
+ if (tail[0]?.type !== "message" || tail[0].message.role !== "user"
710
+ || tail.filter((item) => item.type === "message" && item.message.role === "user").length !== 1 + proof.inputIds.length) return false;
711
+ if (!validRecoveryProjection(buildPiSessionContext(entries), proof.model)) return false;
712
+ await raw.close(PI_CONTEXT);
713
+ raw = undefined;
714
+ // Pending is cleared only after persistence is certain. Bypass the ordinary
715
+ // sync guard while keeping the public busy reservation throughout the fsync.
716
+ await syncPath(entry.metadata.path);
717
+ await syncPath(dirname(entry.metadata.path));
718
+ entry.recoveryPending = false;
719
+ delete entry.recovery;
720
+ return true;
721
+ } catch {
722
+ return false;
723
+ } finally {
724
+ try { await raw?.close(PI_CONTEXT); } catch { /* already failed closed */ }
725
+ entry.busy = false;
528
726
  }
529
727
  }