@mono-agent/agent-runtime 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Provides five runtime bridges (Claude SDK, Claude Code CLI, Codex app-server, Op
16
16
  - `ai/providers/claude-sdk-discovery.js` — isolated Claude SDK model discovery without importing ambient auth/config
17
17
  - `createRouterRuntime({ chain, routeSafety, resolveAttempt })` — ordered fallback routing with exact route effort and bounded safety/failover telemetry
18
18
  - Provider bridges for `claude` (SDK + CLI), `codex` (app-server), `pi` (Pi SDK), and `opencode`
19
- - Provider session support: bridges accept `sessionId` in run options and report `provider_session_id`; the runtime exposes `disposeSession` / `disposeAllSessions`
19
+ - Provider session support: bridges accept `sessionId` in run options and report `provider_session_id`; the runtime exposes best-effort disposal, strict cold-refresh, exact-id durable retirement, invalidation, and whole-runtime shutdown operations
20
20
  - Sandbox-aware built-in tools and stdio MCP startup through an injectable `RuntimeSandbox` seam (`agent/sandbox-seam.js`) — no direct dependency on `@mono-agent/runtime-adapter`
21
21
 
22
22
  ## Dependency Boundary
@@ -219,6 +219,10 @@ Returns:
219
219
 
220
220
  - `run(systemPrompt, options)` — async, runs one agent turn against the chosen backend.
221
221
  - `configureTools(next)` — update the tool runtime context after construction.
222
+ - `syncSession(id)` — fsync provider-owned durable state before canonical history commits.
223
+ - `refreshSession(id)` — guarantee the next resume cannot reuse process-local state; absence succeeds and cleanup uncertainty rejects.
224
+ - `retireDurableSession(id, sessionsRoot)` — delete and verify every exact-id durable Pi transcript, including cold duplicates.
225
+ - `disposeSession(id)` / `invalidateSession(id)` / `disposeAllSessions()` — ordinary best-effort eviction, destructive live invalidation, and shutdown cleanup.
222
226
 
223
227
  ### `runtime.run(systemPrompt, options)`
224
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Agent runtime supporting Claude SDK/CLI, Codex, OpenCode, and Pi SDK bridges out of the box",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
package/src/ai/index.js CHANGED
@@ -7,6 +7,9 @@ export {
7
7
  createSessionRegistry,
8
8
  disposeAllProviderSessions,
9
9
  disposeProviderSession,
10
+ invalidateProviderSession,
11
+ refreshProviderSession,
12
+ syncProviderSession,
10
13
  } from "./runtime/sessions.js";
11
14
  export { createMetricsObserver, createObserverHub } from "./observer.js";
12
15
  export { generatePiNativeResponse, piNativeRuntimeBridge } from "./providers/pi-native.js";
@@ -12,9 +12,45 @@
12
12
 
13
13
  import { InMemorySessionRepo, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
14
14
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
+ import { open } from "node:fs/promises";
16
+ import { dirname, resolve } from "node:path";
15
17
  import { createSessionRegistry } from "../../runtime/sessions.js";
16
18
  import { createSessionLiveness } from "../../runtime/session-liveness.js";
17
19
 
20
+ async function syncPath(path) {
21
+ const handle = await open(path, "r");
22
+ try {
23
+ await handle.sync();
24
+ } finally {
25
+ await handle.close();
26
+ }
27
+ }
28
+
29
+ async function syncDurableTranscript(entry) {
30
+ if (!entry.durable) return;
31
+ const path = entry.metadata?.path;
32
+ if (typeof path !== "string" || !path) {
33
+ throw new Error("Durable Pi session metadata is missing its JSONL path");
34
+ }
35
+ // pi-core appends through short-lived file descriptors. Re-open the live
36
+ // JSONL and fsync it, then fsync its containing directory so both transcript
37
+ // bytes and the directory entry are stable before host history commits.
38
+ await syncPath(path);
39
+ await syncPath(dirname(path));
40
+ }
41
+
42
+ async function invalidateNativeSession(entry) {
43
+ await entry.repo.delete(entry.metadata);
44
+ if (entry.durable) {
45
+ const path = entry.metadata?.path;
46
+ if (typeof path !== "string" || !path) {
47
+ throw new Error("Durable Pi session metadata is missing its JSONL path");
48
+ }
49
+ // Make the unlink durable before the registry forgets the busy marker.
50
+ await syncPath(dirname(path));
51
+ }
52
+ }
53
+
18
54
  // Live pi-native sessions, keyed by provider session id. Entries are
19
55
  // { session, metadata, repo, durable, busy } — identical shape and lifecycle
20
56
  // policy to the (now-retired) pi-sdk bridge: in-memory transcripts are freed
@@ -25,11 +61,21 @@ import { createSessionLiveness } from "../../runtime/session-liveness.js";
25
61
  const nativeSessionRepo = new InMemorySessionRepo();
26
62
  const nativeSessions = createSessionRegistry({
27
63
  isBusy: (entry) => entry.busy === true,
28
- onEvict: async (entry) => {
64
+ onSync: syncDurableTranscript,
65
+ 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
68
+ // turn before canonical history commit; that poisoned transcript must be
69
+ // deleted too or it could silently reappear on the next stable-id resume.
70
+ if (reason === "invalidated") {
71
+ // Destructive invalidation is an honest API: deletion (and, for JSONL,
72
+ // parent-directory fsync) must finish before registry removal, and any
73
+ // failure must reach the caller so it cannot assume cleanup succeeded.
74
+ await invalidateNativeSession(entry);
75
+ return;
76
+ }
29
77
  if (entry.durable) return;
30
- try {
31
- await entry.repo.delete(entry.metadata);
32
- } catch { /* best-effort */ }
78
+ await entry.repo.delete(entry.metadata);
33
79
  },
34
80
  });
35
81
  const liveness = createSessionLiveness(nativeSessions);
@@ -50,6 +96,46 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
50
96
  return repo;
51
97
  }
52
98
 
99
+ /**
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.
105
+ */
106
+ export async function retireDurableNativeSession(providerSessionId, piSessionsRoot) {
107
+ if (!isSafeSessionId(providerSessionId)) {
108
+ throw new TypeError("providerSessionId must be a safe, non-empty session id");
109
+ }
110
+ if (typeof piSessionsRoot !== "string" || !piSessionsRoot.trim()) {
111
+ throw new TypeError("piSessionsRoot must be a non-empty path");
112
+ }
113
+
114
+ // First guarantee this process cannot keep writing through a stale handle.
115
+ // Other processes are serialized by the history coordinator and must pass
116
+ // the same cold-refresh barrier before their next turn.
117
+ await nativeSessions.refresh(providerSessionId);
118
+
119
+ const repo = resolveDurableNativeSessionRepo(piSessionsRoot);
120
+ if (!repo) throw new Error("Durable Pi session repository is unavailable");
121
+ const matches = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
122
+ const changedDirectories = new Set();
123
+ for (const metadata of matches) {
124
+ if (typeof metadata?.path !== "string" || !metadata.path) {
125
+ throw new Error(`Durable Pi session ${providerSessionId} has invalid metadata`);
126
+ }
127
+ await repo.delete(metadata);
128
+ changedDirectories.add(dirname(metadata.path));
129
+ }
130
+ for (const directory of changedDirectories) await syncPath(directory);
131
+ if (changedDirectories.size > 0) await syncPath(resolve(piSessionsRoot));
132
+
133
+ const remaining = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
134
+ if (remaining.length > 0) {
135
+ throw new Error(`Durable Pi session ${providerSessionId} could not be retired completely`);
136
+ }
137
+ }
138
+
53
139
  // Defense in depth (R4): create-on-miss passes the caller-controlled session id
54
140
  // straight to durableRepo.create({ id }), and JsonlSessionRepo writes
55
141
  // `${createdAt}_${id}.jsonl` — so an id like "../../../../tmp/pwn" would escape
@@ -14,7 +14,9 @@
14
14
  // API:
15
15
  // createRouterRuntime({ host, chain })
16
16
  // returns { run(systemPrompt, options) } plus configureTools /
17
- // disposeSession / disposeAllSessions delegated to the inner runtime,
17
+ // syncSession / refreshSession / retireDurableSession / disposeSession /
18
+ // invalidateSession / disposeAllSessions
19
+ // delegated to the inner runtime,
18
20
  // so the router is a drop-in replacement for createRuntime(host).
19
21
  //
20
22
  // chain entries:
@@ -388,6 +390,29 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
388
390
  // attempts use their isolated route runtime, and applying one route's
389
391
  // policy to this shared standby would defeat that isolation.
390
392
  },
393
+ async syncSession(providerSessionId) {
394
+ let synced = false;
395
+ for (const runtime of allRuntimes(inner, routeRuntimes)) {
396
+ synced = Boolean(await runtime.syncSession?.(providerSessionId)) || synced;
397
+ }
398
+ return synced;
399
+ },
400
+ async refreshSession(providerSessionId) {
401
+ for (const runtime of allRuntimes(inner, routeRuntimes)) {
402
+ if (typeof runtime.refreshSession !== "function") {
403
+ throw new Error("A routed runtime cannot guarantee a cold provider-session reopen");
404
+ }
405
+ await runtime.refreshSession(providerSessionId);
406
+ }
407
+ },
408
+ async retireDurableSession(providerSessionId, sessionsRoot) {
409
+ for (const runtime of allRuntimes(inner, routeRuntimes)) {
410
+ if (typeof runtime.retireDurableSession !== "function") {
411
+ throw new Error("A routed runtime cannot retire durable provider-session state");
412
+ }
413
+ await runtime.retireDurableSession(providerSessionId, sessionsRoot);
414
+ }
415
+ },
391
416
  async disposeSession(providerSessionId) {
392
417
  let disposed = false;
393
418
  for (const runtime of allRuntimes(inner, routeRuntimes)) {
@@ -395,6 +420,13 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
395
420
  }
396
421
  return disposed;
397
422
  },
423
+ async invalidateSession(providerSessionId) {
424
+ let invalidated = false;
425
+ for (const runtime of allRuntimes(inner, routeRuntimes)) {
426
+ invalidated = Boolean(await runtime.invalidateSession?.(providerSessionId)) || invalidated;
427
+ }
428
+ return invalidated;
429
+ },
398
430
  async disposeAllSessions() {
399
431
  await Promise.all(allRuntimes(inner, routeRuntimes).map(async (runtime) => runtime.disposeAllSessions?.()));
400
432
  },
@@ -11,7 +11,8 @@
11
11
  // sleep) still cannot resurrect an expired session.
12
12
  //
13
13
  // `createSessionRegistry` instances self-register in a module-level set so
14
- // the runtime surface can expose `disposeSession(id)` / `disposeAllSessions()`
14
+ // the runtime surface can expose `syncSession(id)` / `refreshSession(id)` /
15
+ // `disposeSession(id)` / `disposeAllSessions()`
15
16
  // without knowing which bridge owns the id. Provider session ids are unique
16
17
  // across bridges (codex thread ids, pi uuids), so fan-out dispose is safe.
17
18
 
@@ -29,16 +30,34 @@ function normalizeTtl(value, fallback) {
29
30
  * @param {object} [options]
30
31
  * @param {number} [options.idleTimeoutMs]
31
32
  * @param {(value: any, reason: string) => (void | Promise<void>)} [options.onEvict]
33
+ * @param {(value: any) => (void | Promise<void>)} [options.onSync]
32
34
  * @param {() => number} [options.now]
33
35
  * @param {(value: any) => boolean} [options.isBusy]
34
36
  */
35
- export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, onEvict, now = Date.now, isBusy } = {}) {
37
+ export function createSessionRegistry({
38
+ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
39
+ onEvict,
40
+ onSync,
41
+ now = Date.now,
42
+ isBusy,
43
+ } = {}) {
36
44
  const entries = new Map();
37
45
  const defaultTtlMs = normalizeTtl(idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS);
46
+ // A destructive invalidation must keep the id occupied until provider
47
+ // cleanup is durably complete. Returning a busy placeholder makes existing
48
+ // await-free liveness claims fail closed instead of treating the id as a
49
+ // cold-reopen miss while its JSONL is still being unlinked.
50
+ const unavailable = Object.freeze({ busy: true });
38
51
 
39
- async function evict(id, reason) {
52
+ async function evictBestEffort(id, reason) {
40
53
  const entry = entries.get(id);
41
54
  if (!entry) return false;
55
+ if (entry.state !== "active") {
56
+ if ((entry.state === "invalidating" || entry.state === "refreshing") && entry.operation) {
57
+ try { return await entry.operation; } catch { return false; }
58
+ }
59
+ return false;
60
+ }
42
61
  if (reason === "idle_timeout" && isBusy?.(entry.value)) {
43
62
  // A session executing a turn must not be torn down by the idle timer;
44
63
  // give it a fresh TTL window. Explicit dispose still wins.
@@ -59,10 +78,125 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
59
78
  return true;
60
79
  }
61
80
 
81
+ async function sync(id) {
82
+ const entry = entries.get(id);
83
+ if (!entry) return false;
84
+ if (entry.state === "syncing" && entry.operation) return entry.operation;
85
+ if (entry.state === "invalidating" && entry.operation) {
86
+ await entry.operation;
87
+ return false;
88
+ }
89
+ if (entry.state === "refreshing" && entry.operation) {
90
+ await entry.operation;
91
+ return false;
92
+ }
93
+ if (entry.state === "invalidation_failed") return false;
94
+
95
+ clearTimeout(entry.timer);
96
+ entry.state = "syncing";
97
+ const operation = (async () => {
98
+ try {
99
+ await onSync?.(entry.value);
100
+ if (entries.get(id) === entry) {
101
+ entry.state = "active";
102
+ entry.operation = null;
103
+ entry.lastActivityAt = now();
104
+ armTimer(id, entry);
105
+ }
106
+ return true;
107
+ } catch (error) {
108
+ // A provider that cannot prove its durable transcript is on disk must
109
+ // not become resumable. Keep the busy marker in place so the host can
110
+ // follow with destructive invalidation (or retry sync) honestly.
111
+ if (entries.get(id) === entry) {
112
+ entry.state = "sync_failed";
113
+ entry.operation = null;
114
+ }
115
+ throw error;
116
+ }
117
+ })();
118
+ entry.operation = operation;
119
+ return operation;
120
+ }
121
+
122
+ async function invalidate(id) {
123
+ const entry = entries.get(id);
124
+ if (!entry) return false;
125
+ if (entry.state === "invalidating" && entry.operation) return entry.operation;
126
+ if (entry.state === "syncing" && entry.operation) {
127
+ try { await entry.operation; } catch { /* invalidation supersedes sync */ }
128
+ return invalidate(id);
129
+ }
130
+ if (entry.state === "refreshing" && entry.operation) {
131
+ try { await entry.operation; } catch { /* invalidation supersedes refresh */ }
132
+ return invalidate(id);
133
+ }
134
+
135
+ clearTimeout(entry.timer);
136
+ entry.state = "invalidating";
137
+ const operation = (async () => {
138
+ try {
139
+ // Unlike ordinary eviction, destructive provider cleanup is the
140
+ // operation being promised to the caller. Run it before removal and
141
+ // let every error propagate; forgetting the entry first would report a
142
+ // lie and permit a poisoned durable transcript to cold-reopen.
143
+ await onEvict?.(entry.value, "invalidated");
144
+ if (entries.get(id) === entry) entries.delete(id);
145
+ return true;
146
+ } catch (error) {
147
+ if (entries.get(id) === entry) {
148
+ entry.state = "invalidation_failed";
149
+ entry.operation = null;
150
+ }
151
+ throw error;
152
+ }
153
+ })();
154
+ entry.operation = operation;
155
+ return operation;
156
+ }
157
+
158
+ async function refresh(id) {
159
+ const entry = entries.get(id);
160
+ if (!entry) return;
161
+ if (entry.state === "refreshing" && entry.operation) return entry.operation;
162
+ if (entry.state === "invalidating" && entry.operation) {
163
+ await entry.operation;
164
+ return;
165
+ }
166
+ if (entry.state === "syncing" && entry.operation) {
167
+ await entry.operation;
168
+ return refresh(id);
169
+ }
170
+ if (entry.state === "invalidation_failed") {
171
+ throw new Error(`Provider session ${String(id)} is awaiting destructive invalidation`);
172
+ }
173
+
174
+ clearTimeout(entry.timer);
175
+ entry.state = "refreshing";
176
+ const operation = (async () => {
177
+ try {
178
+ // A refresh is stronger than ordinary best-effort disposal: callers use
179
+ // its successful completion as proof that a subsequent resume cannot
180
+ // adopt stale process memory. Preserve provider-owned durable state,
181
+ // but propagate cleanup failures and retain the unavailable marker.
182
+ await onEvict?.(entry.value, "refreshed");
183
+ if (entries.get(id) === entry) entries.delete(id);
184
+ } catch (error) {
185
+ if (entries.get(id) === entry) {
186
+ entry.state = "refresh_failed";
187
+ entry.operation = null;
188
+ }
189
+ throw error;
190
+ }
191
+ })();
192
+ entry.operation = operation;
193
+ return operation;
194
+ }
195
+
62
196
  function armTimer(id, entry) {
63
197
  clearTimeout(entry.timer);
64
198
  entry.timer = setTimeout(() => {
65
- void evict(id, "idle_timeout");
199
+ void evictBestEffort(id, "idle_timeout");
66
200
  }, entry.ttlMs);
67
201
  entry.timer.unref?.();
68
202
  }
@@ -71,8 +205,9 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
71
205
  get(id) {
72
206
  const entry = entries.get(id);
73
207
  if (!entry) return undefined;
208
+ if (entry.state !== "active") return unavailable;
74
209
  if (now() - entry.lastActivityAt > entry.ttlMs && !isBusy?.(entry.value)) {
75
- void evict(id, "idle_timeout");
210
+ void evictBestEffort(id, "idle_timeout");
76
211
  return undefined;
77
212
  }
78
213
  return entry.value;
@@ -84,10 +219,22 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
84
219
  */
85
220
  set(id, value, { idleTimeoutMs: entryTtl } = {}) {
86
221
  const previous = entries.get(id);
222
+ // Never overwrite an in-flight/failed durability operation. The marker
223
+ // deliberately owns this id until sync recovers or invalidation removes
224
+ // it, preventing a cold reopen from winning an unlink race.
225
+ if (previous && previous.state !== "active") return false;
87
226
  if (previous) clearTimeout(previous.timer);
88
- const entry = { value, lastActivityAt: now(), timer: null, ttlMs: normalizeTtl(entryTtl, defaultTtlMs) };
227
+ const entry = {
228
+ value,
229
+ lastActivityAt: now(),
230
+ timer: null,
231
+ ttlMs: normalizeTtl(entryTtl, defaultTtlMs),
232
+ state: "active",
233
+ operation: null,
234
+ };
89
235
  entries.set(id, entry);
90
236
  armTimer(id, entry);
237
+ return true;
91
238
  },
92
239
  /**
93
240
  * @param {any} id
@@ -96,6 +243,7 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
96
243
  touch(id, { idleTimeoutMs: entryTtl } = {}) {
97
244
  const entry = entries.get(id);
98
245
  if (!entry) return;
246
+ if (entry.state !== "active") return;
99
247
  if (entryTtl !== undefined) entry.ttlMs = normalizeTtl(entryTtl, entry.ttlMs);
100
248
  entry.lastActivityAt = now();
101
249
  armTimer(id, entry);
@@ -107,16 +255,26 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
107
255
  delete(id) {
108
256
  const entry = entries.get(id);
109
257
  if (!entry) return false;
258
+ if (entry.state !== "active") return false;
110
259
  entries.delete(id);
111
260
  clearTimeout(entry.timer);
112
261
  return true;
113
262
  },
114
263
  async dispose(id) {
115
- return evict(id, "disposed");
264
+ return evictBestEffort(id, "disposed");
265
+ },
266
+ async sync(id) {
267
+ return sync(id);
268
+ },
269
+ async refresh(id) {
270
+ return refresh(id);
271
+ },
272
+ async invalidate(id) {
273
+ return invalidate(id);
116
274
  },
117
275
  async disposeAll() {
118
276
  const ids = [...entries.keys()];
119
- for (const id of ids) await evict(id, "disposed");
277
+ for (const id of ids) await evictBestEffort(id, "disposed");
120
278
  },
121
279
  size() {
122
280
  return entries.size;
@@ -136,6 +294,49 @@ export async function disposeProviderSession(providerSessionId) {
136
294
  return disposed;
137
295
  }
138
296
 
297
+ /**
298
+ * Flush provider-owned durable session state after a successful run and before
299
+ * the host commits canonical history. Providers without durable live state
300
+ * acknowledge the barrier immediately; provider sync errors propagate.
301
+ */
302
+ export async function syncProviderSession(providerSessionId) {
303
+ if (typeof providerSessionId !== "string" || !providerSessionId.trim()) return false;
304
+ let synced = false;
305
+ for (const registry of allRegistries) {
306
+ if (await registry.sync(providerSessionId)) synced = true;
307
+ }
308
+ return synced;
309
+ }
310
+
311
+ /**
312
+ * Guarantee that no provider registry can reuse process-local state for this
313
+ * id. Durable provider transcripts are preserved so the next resume reopens
314
+ * them from disk. Absence is success; cleanup failure rejects.
315
+ */
316
+ export async function refreshProviderSession(providerSessionId) {
317
+ if (typeof providerSessionId !== "string" || !providerSessionId.trim()) {
318
+ throw new TypeError("providerSessionId must be a non-empty string");
319
+ }
320
+ for (const registry of allRegistries) {
321
+ await registry.refresh(providerSessionId);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Irreversibly discard a provider session whose transcript is not represented
327
+ * by canonical host history. Unlike ordinary disposal, providers with durable
328
+ * session caches must remove the persisted transcript as well as the live
329
+ * registry entry.
330
+ */
331
+ export async function invalidateProviderSession(providerSessionId) {
332
+ if (typeof providerSessionId !== "string" || !providerSessionId.trim()) return false;
333
+ let invalidated = false;
334
+ for (const registry of allRegistries) {
335
+ if (await registry.invalidate(providerSessionId)) invalidated = true;
336
+ }
337
+ return invalidated;
338
+ }
339
+
139
340
  export async function disposeAllProviderSessions() {
140
341
  for (const registry of allRegistries) {
141
342
  await registry.disposeAll();
package/src/ai/types.js CHANGED
@@ -322,7 +322,11 @@
322
322
  * The object `createRuntime`/`createRouterRuntime` return.
323
323
  * @property {(systemPrompt: string, options: RuntimeRunOptions) => Promise<RuntimeResult>} run
324
324
  * @property {(next?: AgentRuntimeToolOptions) => void} configureTools
325
- * @property {(providerSessionId: string) => Promise<boolean|void>} disposeSession
325
+ * @property {(providerSessionId: string) => Promise<boolean>} syncSession
326
+ * @property {(providerSessionId: string) => Promise<void>} refreshSession Guarantees the id has no reusable process-local handle; rejects on failure.
327
+ * @property {(providerSessionId: string, sessionsRoot: string) => Promise<void>} retireDurableSession Permanently deletes every durable transcript with the exact id; absence is success.
328
+ * @property {(providerSessionId: string) => Promise<boolean>} disposeSession
329
+ * @property {(providerSessionId: string) => Promise<boolean>} invalidateSession
326
330
  * @property {() => Promise<void>} disposeAllSessions
327
331
  */
328
332
 
package/src/runtime.js CHANGED
@@ -30,9 +30,16 @@
30
30
 
31
31
  import { resolveRuntimeBridge } from "./ai/runtime/registry.js";
32
32
  import { createObserverHub } from "./ai/observer.js";
33
- import { disposeAllProviderSessions, disposeProviderSession } from "./ai/runtime/sessions.js";
33
+ import {
34
+ disposeAllProviderSessions,
35
+ disposeProviderSession,
36
+ invalidateProviderSession,
37
+ refreshProviderSession,
38
+ syncProviderSession,
39
+ } from "./ai/runtime/sessions.js";
34
40
  import { createToolContext, updateToolContext } from "./agent/tools/shared/tool-context.js";
35
41
  import { resolveRuntimeBrand } from "./runtime-brand.js";
42
+ import { retireDurableNativeSession } from "./ai/providers/pi-native/session-lifecycle.js";
36
43
 
37
44
  /**
38
45
  * @typedef {import('./ai/types.js').AgentRuntimeHostOptions} AgentRuntimeHostOptions
@@ -175,9 +182,21 @@ export function createRuntime(host = {}) {
175
182
  configureTools(next = {}) {
176
183
  updateToolContext(toolContext, pickPresent(next, TOOL_RUNTIME_KEYS));
177
184
  },
185
+ async syncSession(providerSessionId) {
186
+ return syncProviderSession(providerSessionId);
187
+ },
188
+ async refreshSession(providerSessionId) {
189
+ return refreshProviderSession(providerSessionId);
190
+ },
191
+ async retireDurableSession(providerSessionId, sessionsRoot) {
192
+ return retireDurableNativeSession(providerSessionId, sessionsRoot);
193
+ },
178
194
  async disposeSession(providerSessionId) {
179
195
  return disposeProviderSession(providerSessionId);
180
196
  },
197
+ async invalidateSession(providerSessionId) {
198
+ return invalidateProviderSession(providerSessionId);
199
+ },
181
200
  async disposeAllSessions() {
182
201
  return disposeAllProviderSessions();
183
202
  },
@@ -1,7 +1,7 @@
1
1
  export * from "./registry.js";
2
2
  export * from "./runtime/model-refs.js";
3
3
  export * from "./runtime/registry.js";
4
- export { createSessionRegistry, disposeAllProviderSessions, disposeProviderSession } from "./runtime/sessions.js";
4
+ export { createSessionRegistry, disposeAllProviderSessions, disposeProviderSession, invalidateProviderSession, refreshProviderSession, syncProviderSession } from "./runtime/sessions.js";
5
5
  export { createMetricsObserver, createObserverHub } from "./observer.js";
6
6
  export { generatePiNativeResponse, piNativeRuntimeBridge } from "./providers/pi-native.js";
7
7
  export { CLAUDE_SDK_CATALOG_VERSION, createClaudeSdkDiscoveryIsolation, curatedClaudeSdkModels, discoverClaudeSdkModels, normalizeClaudeSdkCatalog, normalizeClaudeSdkModelId } from "./providers/claude-sdk-discovery.js";
@@ -1,4 +1,12 @@
1
1
  export function resolveDurableNativeSessionRepo(piSessionsRoot: any): any;
2
+ /**
3
+ * Permanently retire every durable Pi transcript with this exact logical id.
4
+ * This is intentionally stronger than live-session invalidation: history
5
+ * rotation and retention can retire an epoch after its registry entry was
6
+ * already evicted or after a process restart. Absence is success; any cleanup
7
+ * or verification uncertainty rejects so canonical history remains reachable.
8
+ */
9
+ export function retireDurableNativeSession(providerSessionId: any, piSessionsRoot: any): Promise<void>;
2
10
  /**
3
11
  * Resolve the session for this run: warm registry hit, durable cold reopen,
4
12
  * create-on-miss (durable resume only), or fresh create. Mutates runState
@@ -2,12 +2,14 @@
2
2
  * @param {object} [options]
3
3
  * @param {number} [options.idleTimeoutMs]
4
4
  * @param {(value: any, reason: string) => (void | Promise<void>)} [options.onEvict]
5
+ * @param {(value: any) => (void | Promise<void>)} [options.onSync]
5
6
  * @param {() => number} [options.now]
6
7
  * @param {(value: any) => boolean} [options.isBusy]
7
8
  */
8
- export function createSessionRegistry({ idleTimeoutMs, onEvict, now, isBusy }?: {
9
+ export function createSessionRegistry({ idleTimeoutMs, onEvict, onSync, now, isBusy, }?: {
9
10
  idleTimeoutMs?: number;
10
11
  onEvict?: (value: any, reason: string) => (void | Promise<void>);
12
+ onSync?: (value: any) => (void | Promise<void>);
11
13
  now?: () => number;
12
14
  isBusy?: (value: any) => boolean;
13
15
  }): {
@@ -19,7 +21,7 @@ export function createSessionRegistry({ idleTimeoutMs, onEvict, now, isBusy }?:
19
21
  */
20
22
  set(id: any, value: any, { idleTimeoutMs: entryTtl }?: {
21
23
  idleTimeoutMs?: number;
22
- }): void;
24
+ }): boolean;
23
25
  /**
24
26
  * @param {any} id
25
27
  * @param {{idleTimeoutMs?: number}} [options]
@@ -30,9 +32,31 @@ export function createSessionRegistry({ idleTimeoutMs, onEvict, now, isBusy }?:
30
32
  has(id: any): boolean;
31
33
  /** Remove without running onEvict — for callers that already cleaned up. */
32
34
  delete(id: any): boolean;
33
- dispose(id: any): Promise<boolean>;
35
+ dispose(id: any): Promise<any>;
36
+ sync(id: any): Promise<any>;
37
+ refresh(id: any): Promise<any>;
38
+ invalidate(id: any): Promise<any>;
34
39
  disposeAll(): Promise<void>;
35
40
  size(): number;
36
41
  };
37
42
  export function disposeProviderSession(providerSessionId: any): Promise<boolean>;
43
+ /**
44
+ * Flush provider-owned durable session state after a successful run and before
45
+ * the host commits canonical history. Providers without durable live state
46
+ * acknowledge the barrier immediately; provider sync errors propagate.
47
+ */
48
+ export function syncProviderSession(providerSessionId: any): Promise<boolean>;
49
+ /**
50
+ * Guarantee that no provider registry can reuse process-local state for this
51
+ * id. Durable provider transcripts are preserved so the next resume reopens
52
+ * them from disk. Absence is success; cleanup failure rejects.
53
+ */
54
+ export function refreshProviderSession(providerSessionId: any): Promise<void>;
55
+ /**
56
+ * Irreversibly discard a provider session whose transcript is not represented
57
+ * by canonical host history. Unlike ordinary disposal, providers with durable
58
+ * session caches must remove the persisted transcript as well as the live
59
+ * registry entry.
60
+ */
61
+ export function invalidateProviderSession(providerSessionId: any): Promise<boolean>;
38
62
  export function disposeAllProviderSessions(): Promise<void>;
@@ -282,7 +282,11 @@
282
282
  * The object `createRuntime`/`createRouterRuntime` return.
283
283
  * @property {(systemPrompt: string, options: RuntimeRunOptions) => Promise<RuntimeResult>} run
284
284
  * @property {(next?: AgentRuntimeToolOptions) => void} configureTools
285
- * @property {(providerSessionId: string) => Promise<boolean|void>} disposeSession
285
+ * @property {(providerSessionId: string) => Promise<boolean>} syncSession
286
+ * @property {(providerSessionId: string) => Promise<void>} refreshSession Guarantees the id has no reusable process-local handle; rejects on failure.
287
+ * @property {(providerSessionId: string, sessionsRoot: string) => Promise<void>} retireDurableSession Permanently deletes every durable transcript with the exact id; absence is success.
288
+ * @property {(providerSessionId: string) => Promise<boolean>} disposeSession
289
+ * @property {(providerSessionId: string) => Promise<boolean>} invalidateSession
286
290
  * @property {() => Promise<void>} disposeAllSessions
287
291
  */
288
292
  export const PROVIDER_KIND_VALUES: string[];
@@ -770,6 +774,16 @@ export type AgentRuntimeToolOptions = {
770
774
  export type AgentRuntimeInstance = {
771
775
  run: (systemPrompt: string, options: RuntimeRunOptions) => Promise<RuntimeResult>;
772
776
  configureTools: (next?: AgentRuntimeToolOptions) => void;
773
- disposeSession: (providerSessionId: string) => Promise<boolean | void>;
777
+ syncSession: (providerSessionId: string) => Promise<boolean>;
778
+ /**
779
+ * Guarantees the id has no reusable process-local handle; rejects on failure.
780
+ */
781
+ refreshSession: (providerSessionId: string) => Promise<void>;
782
+ /**
783
+ * Permanently deletes every durable transcript with the exact id; absence is success.
784
+ */
785
+ retireDurableSession: (providerSessionId: string, sessionsRoot: string) => Promise<void>;
786
+ disposeSession: (providerSessionId: string) => Promise<boolean>;
787
+ invalidateSession: (providerSessionId: string) => Promise<boolean>;
774
788
  disposeAllSessions: () => Promise<void>;
775
789
  };