@mono-agent/agent-runtime 0.4.0 → 0.4.1

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
@@ -0,0 +1,438 @@
1
+ // @ts-check
2
+ // Session lifecycle for the pi-native bridge.
3
+ //
4
+ // Pure moves out of pi-native.js: the durable-repo resolve/reopen, the safe-id
5
+ // gate (R4), the resume / create-on-miss / claim flow (I1/I2/I3/I4/I5, R8/F4),
6
+ // the keep-alive commit + rollback + drop paths, and sessionUnavailableResult.
7
+ // The process-level session storage (the registry + repos) legitimately stays
8
+ // module-level here (it must persist across runs); per-RUN state lives on the
9
+ // caller-owned runState. Concurrency claims go through the synchronous
10
+ // createSessionLiveness primitives so the await-free spans are enforced by
11
+ // construction rather than by inline sequencing.
12
+
13
+ import { InMemorySessionRepo, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
14
+ import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
+ import { createSessionRegistry } from "../../runtime/sessions.js";
16
+ import { createSessionLiveness } from "../../runtime/session-liveness.js";
17
+
18
+ // Live pi-native sessions, keyed by provider session id. Entries are
19
+ // { session, metadata, repo, durable, busy } — identical shape and lifecycle
20
+ // policy to the (now-retired) pi-sdk bridge: in-memory transcripts are freed
21
+ // when the registry evicts them; durable (jsonl) transcripts survive eviction
22
+ // so a later resume can reopen them from disk. Registering here gives
23
+ // runtime.disposeSession / disposeProviderSession + idle-TTL eviction the same
24
+ // reach over native pi sessions that the legacy bridge had.
25
+ const nativeSessionRepo = new InMemorySessionRepo();
26
+ const nativeSessions = createSessionRegistry({
27
+ isBusy: (entry) => entry.busy === true,
28
+ onEvict: async (entry) => {
29
+ if (entry.durable) return;
30
+ try {
31
+ await entry.repo.delete(entry.metadata);
32
+ } catch { /* best-effort */ }
33
+ },
34
+ });
35
+ const liveness = createSessionLiveness(nativeSessions);
36
+
37
+ const durableNativeSessionRepos = new Map();
38
+
39
+ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
40
+ if (typeof piSessionsRoot !== "string" || !piSessionsRoot.trim()) return null;
41
+ const root = piSessionsRoot.trim();
42
+ let repo = durableNativeSessionRepos.get(root);
43
+ if (!repo) {
44
+ repo = new JsonlSessionRepo({
45
+ fs: new NodeExecutionEnv({ cwd: process.cwd() }),
46
+ sessionsRoot: root,
47
+ });
48
+ durableNativeSessionRepos.set(root, repo);
49
+ }
50
+ return repo;
51
+ }
52
+
53
+ // Defense in depth (R4): create-on-miss passes the caller-controlled session id
54
+ // straight to durableRepo.create({ id }), and JsonlSessionRepo writes
55
+ // `${createdAt}_${id}.jsonl` — so an id like "../../../../tmp/pwn" would escape
56
+ // piSessionsRoot and name a file anywhere on disk. The harness-derived id is a
57
+ // sha256 hex (always safe), but the public runtime API is caller-controlled.
58
+ // Only an id that is a single safe filename component may CREATE a session;
59
+ // anything else falls through to the existing session_not_found fast-fail, so a
60
+ // malicious id can never name a file. (A genuinely on-disk session reopened by
61
+ // reopenDurableNativeSession is matched by `.id`, never used to build a path, so
62
+ // this gate is confined to the create path.)
63
+ const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
64
+
65
+ function isSafeSessionId(id) {
66
+ return typeof id === "string"
67
+ && SAFE_SESSION_ID.test(id)
68
+ && !id.includes("..")
69
+ && !id.includes("/")
70
+ && !id.includes("\\");
71
+ }
72
+
73
+ async function reopenDurableNativeSession(repo, sessionId) {
74
+ try {
75
+ const metadata = (await repo.list()).find((entry) => entry?.id === sessionId);
76
+ if (!metadata) return null;
77
+ const session = await repo.open(metadata);
78
+ return { session, metadata, repo, durable: true, busy: false };
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function sessionUnavailableResult({
85
+ resolved,
86
+ options,
87
+ events,
88
+ runtimeWarnings,
89
+ start,
90
+ sessionId,
91
+ errorMessage,
92
+ failureKind,
93
+ piErrorCode,
94
+ }) {
95
+ return {
96
+ text: null,
97
+ events,
98
+ usage: {},
99
+ durationMs: Date.now() - start,
100
+ numTurns: 0,
101
+ model: resolved?.reference || resolved?.model || null,
102
+ effort: options.effort || null,
103
+ sdk: resolved?.sdk || "pi",
104
+ cancelled: false,
105
+ error: errorMessage,
106
+ failureKind,
107
+ providerSessionId: sessionId,
108
+ runtimeWarnings,
109
+ diagnostics: {
110
+ provider_session_id: sessionId,
111
+ pi_error_code: piErrorCode,
112
+ pi_engine: "native",
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Resolve the session for this run: warm registry hit, durable cold reopen,
119
+ * create-on-miss (durable resume only), or fresh create. Mutates runState
120
+ * (session / sessionEntry / createdOnMiss / reservation). Returns
121
+ * `{ done: true, result }` for a fast-fail early return (session_not_found /
122
+ * session_busy), else `{ done: false }` to proceed.
123
+ * @param {any} runState
124
+ * @param {any} params
125
+ * @returns {Promise<{done: true, result: any} | {done: false}>}
126
+ */
127
+ export async function resolveSession(runState, {
128
+ requestedSessionId,
129
+ providerSessionId,
130
+ durableRepo,
131
+ sessionTtlMs,
132
+ cwd,
133
+ resolved,
134
+ options,
135
+ events,
136
+ runtimeWarnings,
137
+ start,
138
+ }) {
139
+ // Resume check first: a session miss must stay cheap (no tool/MCP/harness
140
+ // init). This mirrors the legacy bridge's fail-fast contract.
141
+ if (requestedSessionId) {
142
+ let entry = liveness.adoptIfPresent(requestedSessionId);
143
+ if (!entry && durableRepo) {
144
+ entry = await reopenDurableNativeSession(durableRepo, requestedSessionId);
145
+ if (entry) {
146
+ // TOCTOU guard: the reopen above is an AWAIT, so a second concurrent
147
+ // cold resume could have reopened+inserted its own entry in this
148
+ // window. Re-read the registry and adopt any entry already present so
149
+ // the busy-claim below collapses back to the warm path's synchronous
150
+ // semantics (the loser sees the winner's shared entry with busy===true
151
+ // and returns session_busy). The discarded reopen is just an in-memory
152
+ // jsonl handle (no subprocess/socket), so dropping it is safe.
153
+ const concurrent = liveness.adoptIfPresent(requestedSessionId);
154
+ if (concurrent) {
155
+ entry = concurrent;
156
+ } else {
157
+ nativeSessions.set(requestedSessionId, entry, { idleTimeoutMs: sessionTtlMs });
158
+ }
159
+ }
160
+ }
161
+ if (!entry) {
162
+ if (durableRepo && isSafeSessionId(providerSessionId)) {
163
+ // Create-on-miss (durable resume only): the requested id has no live
164
+ // registry entry AND no JSONL on disk under piSessionsRoot. This is
165
+ // the cross-restart resume case — the harness derives a stable id from
166
+ // the conversationId and passes it before any session exists on a
167
+ // fresh process. Rather than fail with session_not_found (which would
168
+ // make the harness re-send full history into yet another fresh,
169
+ // randomly-named session and orphan future resumes), create a durable
170
+ // session UNDER the requested id so this and every later turn for the
171
+ // conversation resolve to the same on-disk transcript. sessionEntry
172
+ // stays null so this proceeds exactly like a fresh run (prior messages
173
+ // are seeded, the keep-alive success path registers + persists it).
174
+ // The IN-MEMORY resume miss (no durableRepo) — and a create-on-miss
175
+ // with an UNSAFE id (R4) — keep fast-failing below, preserving the
176
+ // existing per-process session_not_found contract.
177
+ //
178
+ // Concurrent-first-turn race (R8): two concurrent first turns for the
179
+ // same durable id would BOTH miss here and BOTH create, producing two
180
+ // transcripts for one logical id (JsonlSessionRepo names files by
181
+ // `${createdAt}_${id}`, so there is no fs-level dedup). Mirror the
182
+ // cold-reopen-race defense: synchronously (NO await) re-check the
183
+ // registry, then reserve the id with a BUSY placeholder before the
184
+ // create await. The get→check→set span MUST stay await-free, so the
185
+ // loser observes the busy placeholder and returns session_busy via the
186
+ // same busy-claim path below — exactly one create per durable id.
187
+ const reservation = liveness.reserve(requestedSessionId, {
188
+ session: null,
189
+ metadata: null,
190
+ repo: durableRepo,
191
+ durable: true,
192
+ busy: true,
193
+ }, sessionTtlMs);
194
+ if (!reservation.ok) {
195
+ // A concurrent caller already reserved/created this id in the window
196
+ // since the miss above. Adopt its entry and fall into the busy-claim
197
+ // logic (session_busy if its turn is in flight, else resume). Cast:
198
+ // @ts-check does not narrow the ReserveResult typedef union on
199
+ // `!reservation.ok`, though the loser branch always carries `entry`.
200
+ entry = /** @type {{entry: any}} */ (reservation).entry;
201
+ } else {
202
+ // Reserved the id with a busy placeholder BEFORE the create await so a
203
+ // second concurrent first turn observes busy and returns session_busy.
204
+ // The keep-alive success path (reservation.commit with busy:false)
205
+ // overwrites this placeholder on success; the drop/abort/catch paths
206
+ // release it. Keyed by requestedSessionId === providerSessionId.
207
+ runState.reservation = reservation;
208
+ runState.session = await durableRepo.create({ id: providerSessionId, cwd: cwd || process.cwd() });
209
+ runState.createdOnMiss = true;
210
+ }
211
+ } else {
212
+ return {
213
+ done: true,
214
+ result: sessionUnavailableResult({
215
+ resolved,
216
+ options,
217
+ events,
218
+ runtimeWarnings,
219
+ start,
220
+ sessionId: requestedSessionId,
221
+ errorMessage: `Pi session ${requestedSessionId} is not live`,
222
+ failureKind: "session_not_found",
223
+ piErrorCode: "pi_session_not_found",
224
+ }),
225
+ };
226
+ }
227
+ }
228
+ if (entry && !runState.createdOnMiss) {
229
+ // The busy claim MUST stay await-free between the registry adoption
230
+ // above and `entry.busy = true` inside claim(): adopt/reserve/set + this
231
+ // claim are all synchronous, which is what makes the cold-resume race
232
+ // (F4) safe. Do not introduce any await in this span or the TOCTOU window
233
+ // reopens. `claim` re-reads the same registry entry and sets busy in one
234
+ // await-free step; a busy entry loses and returns session_busy.
235
+ const claimed = liveness.claim(requestedSessionId);
236
+ if (!claimed.ok) {
237
+ // claim() can lose two ways: "busy" (the entry adopted above is
238
+ // mid-turn) or "missing" (no live entry). "missing" is UNREACHABLE on
239
+ // this path today — the entry was adopted/reserved synchronously in the
240
+ // await-free span just above, so it is always present here — but branch
241
+ // on it anyway so a future refactor that could drop the entry in this
242
+ // window self-defends with session_not_found instead of a misleading
243
+ // "busy" message. The busy branch is byte-identical to before. Cast:
244
+ // @ts-check does not narrow the ClaimResult union on `!claimed.ok`,
245
+ // though the loser branch always carries `reason`.
246
+ const missing = /** @type {{reason: string}} */ (claimed).reason === "missing";
247
+ return {
248
+ done: true,
249
+ result: sessionUnavailableResult({
250
+ resolved,
251
+ options,
252
+ events,
253
+ runtimeWarnings,
254
+ start,
255
+ sessionId: requestedSessionId,
256
+ errorMessage: missing
257
+ ? `Pi session ${requestedSessionId} is not live`
258
+ : `Pi session ${requestedSessionId} is busy with another turn`,
259
+ failureKind: missing ? "session_not_found" : "session_busy",
260
+ piErrorCode: missing ? "pi_session_not_found" : "pi_session_busy",
261
+ }),
262
+ };
263
+ }
264
+ runState.sessionEntry = claimed.entry;
265
+ runState.session = claimed.entry.session;
266
+ }
267
+ } else {
268
+ // Fresh runs persist into the durable jsonl repo when piSessionsRoot is
269
+ // set, so a kept-alive session can be reopened from disk after the live
270
+ // entry is evicted; otherwise the in-memory repo is used.
271
+ runState.session = await (durableRepo || nativeSessionRepo)
272
+ .create({ id: providerSessionId, cwd: cwd || process.cwd() });
273
+ }
274
+ return { done: false };
275
+ }
276
+
277
+ /**
278
+ * Drop an uncommitted fresh session (and any create-on-miss reservation) on the
279
+ * pre-request abort path. A resumed (user-owned) session is NEVER deleted
280
+ * (guarded `session && !sessionEntry`).
281
+ * @param {any} runState
282
+ * @param {{durableRepo: any}} params
283
+ */
284
+ export async function discardUncommittedSession(runState, { durableRepo }) {
285
+ // Drop a freshly-created non-keep-alive session so an aborted-before-run turn
286
+ // does not leave an orphan jsonl on disk. Guarded `session && !sessionEntry`
287
+ // so a resumed (user-owned) session is NEVER deleted. For a resume no
288
+ // transcript was appended yet (prompt never ran), so the live session is
289
+ // already at its pre-turn leaf and needs no rollback.
290
+ if (runState.session && !runState.sessionEntry) {
291
+ try { await (durableRepo || nativeSessionRepo).delete(await runState.session.getMetadata()); } catch { /* best-effort */ }
292
+ }
293
+ // Drop the create-on-miss BUSY reservation too, else the busy placeholder
294
+ // leaks and every future resume of this conversation's stable id returns
295
+ // session_busy forever (busy entries are never idle-evicted).
296
+ if (runState.reservation) runState.reservation.release();
297
+ }
298
+
299
+ /**
300
+ * Session lifecycle commit: keep-alive registration, resumed-turn rollback, or
301
+ * fresh/non-keep-alive drop. The harness already durably persisted the
302
+ * transcript; this tracks LIVENESS so disposeProviderSession / idle-TTL
303
+ * eviction can reach native sessions, and rolls a failed/aborted resumed turn
304
+ * back to its pre-turn leaf.
305
+ * @param {any} runState
306
+ * @param {any} params
307
+ */
308
+ export async function commitSession(runState, {
309
+ options,
310
+ requestedSessionId,
311
+ providerSessionId,
312
+ durableRepo,
313
+ sessionTtlMs,
314
+ externalAbort,
315
+ errorMessage,
316
+ onEvent,
317
+ }) {
318
+ const { session, sessionEntry, baselineLeafId, reservation } = runState;
319
+ if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
320
+ try {
321
+ if (sessionEntry) {
322
+ // Resumed run: the harness appended this run's turns onto the live
323
+ // session; just re-arm the idle window.
324
+ nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
325
+ // Surface a write failure the harness swallowed: a session that can
326
+ // no longer persist must not pretend to be resumable.
327
+ await session.buildContext();
328
+ } else {
329
+ const metadata = await session.getMetadata();
330
+ const entry = {
331
+ session,
332
+ metadata,
333
+ repo: durableRepo || nativeSessionRepo,
334
+ durable: !!durableRepo,
335
+ busy: false,
336
+ };
337
+ // A create-on-miss reservation is overwritten by its commit (same id);
338
+ // a plain fresh keep-alive run registers directly.
339
+ if (reservation) reservation.commit(entry);
340
+ else nativeSessions.set(providerSessionId, entry, { idleTimeoutMs: sessionTtlMs });
341
+ }
342
+ } catch (err) {
343
+ // Session persistence must never fail the run; drop the (now
344
+ // inconsistent) session instead of resuming from a broken transcript.
345
+ onEvent({
346
+ type: "runtime_warning",
347
+ warning_kind: "pi_session_persist_failed",
348
+ message: err?.message || String(err),
349
+ });
350
+ nativeSessions.delete(providerSessionId);
351
+ if (requestedSessionId) nativeSessions.delete(requestedSessionId);
352
+ const broken = sessionEntry;
353
+ if (broken) {
354
+ try { await broken.repo.delete(broken.metadata); } catch { /* best-effort */ }
355
+ }
356
+ }
357
+ } else if (sessionEntry) {
358
+ // Resumed run that errored (or was aborted): roll the live session back to
359
+ // the leaf captured before this turn so the failed turn never leaks into a
360
+ // later resume. The next resume then sees the last good transcript. The
361
+ // entry stays live (busy is cleared in finally) and its idle TTL re-arms.
362
+ if (baselineLeafId && (errorMessage || externalAbort)) {
363
+ try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
364
+ }
365
+ nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
366
+ } else {
367
+ // Fresh, non-keep-alive (or failed first) run: never leave a live session
368
+ // behind. A durable jsonl transcript on disk is dropped too, matching the
369
+ // legacy default contract that a non-keep-alive run is not resumable.
370
+ // A create-on-miss BUSY reservation (R8) is released here too so a
371
+ // non-keep-alive / errored / aborted first turn never leaks a busy entry
372
+ // (the success keep-alive path overwrites it with the finalized entry, so
373
+ // it is only this drop branch that must clean it up).
374
+ if (reservation) reservation.release();
375
+ try {
376
+ await (durableRepo || nativeSessionRepo).delete(await session.getMetadata());
377
+ } catch { /* best-effort */ }
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Final abort guard (durable cancel TOCTOU) rollback actions: a resumed session
383
+ * moves to its baseline leaf and drops its live entry; a fresh durable session
384
+ * deletes its jsonl. The orchestrator keeps the abort re-check + return inline
385
+ * so no await sits between the re-check and the return (I10); this only runs the
386
+ * rollback body when the guard fires.
387
+ * @param {any} runState
388
+ * @param {{requestedSessionId: string|null, providerSessionId: string, durableRepo: any}} params
389
+ */
390
+ export async function rollbackAbortedTurn(runState, { requestedSessionId, providerSessionId, durableRepo }) {
391
+ const { session, sessionEntry, baselineLeafId } = runState;
392
+ if (sessionEntry) {
393
+ if (baselineLeafId) {
394
+ try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
395
+ }
396
+ nativeSessions.delete(requestedSessionId);
397
+ } else {
398
+ nativeSessions.delete(providerSessionId);
399
+ try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Outer-catch session cleanup: drop a just-created fresh durable session, drop a
405
+ * create-on-miss reservation placeholder, and roll a resumed session back to its
406
+ * pre-turn leaf for host/runtime-side throws that landed after the harness
407
+ * already mutated the live session.
408
+ * @param {any} runState
409
+ * @param {{durableRepo: any}} params
410
+ */
411
+ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
412
+ const { session, sessionEntry, reservation, baselineLeafId } = runState;
413
+ // Drop a just-created FRESH durable session so a setup/run failure does not
414
+ // leave a resumable orphan jsonl on disk (the success path drops it via the
415
+ // fresh-run branch; the catch must mirror that). Guarded: `session &&
416
+ // !sessionEntry` fires only for fresh runs that actually created a session —
417
+ // NEVER for resumes (sessionEntry is non-null only on resume; deleting a
418
+ // resumed user session here would be data loss) and never when the throw
419
+ // preceded session create.
420
+ if (session && !sessionEntry) {
421
+ try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
422
+ }
423
+ // Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
424
+ // during/after the reservation — including a throw inside the create await
425
+ // itself, where `session` is still null so the jsonl-delete above is skipped.
426
+ // Never set on a resume (sessionEntry would be non-null), so this never
427
+ // deletes a live user session.
428
+ if (reservation && !sessionEntry) reservation.release();
429
+ // Resumed-session rollback for host/runtime-side throws (e.g. a throwing
430
+ // custom pricing resolver / bridge event callback) that land here AFTER the
431
+ // harness already mutated the live session. Mirrors the success-path
432
+ // rollback: move the live session back to the pre-turn leaf so the failed
433
+ // turn never leaks into a later resume. Gated on `sessionEntry &&
434
+ // baselineLeafId` so it only fires for resumes that captured a baseline.
435
+ if (sessionEntry && baselineLeafId) {
436
+ try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
437
+ }
438
+ }
@@ -0,0 +1,148 @@
1
+ // @ts-check
2
+ // Harness event → runtime-event normalization for the pi-native bridge.
3
+ //
4
+ // Pure move of the `harness.subscribe` handler out of pi-native.js: text /
5
+ // thinking delta+end dedup, tool start / update / end / timing events, and the
6
+ // turn-counting + maxTurns stop. All mutable counters and dedup keys live on the
7
+ // caller-owned `runState`; no module-level mutable state here.
8
+
9
+ import { toolResultContent } from "../pi-messages.js";
10
+ import {
11
+ compactToolRawResult,
12
+ eventToolArgs,
13
+ jsonSerializable,
14
+ streamContentKey,
15
+ } from "../pi-events.js";
16
+
17
+ /**
18
+ * The tool-start progress line surfaced as a thinking block, or null for a
19
+ * blank/invalid tool name.
20
+ * @param {unknown} toolName
21
+ * @returns {string|null}
22
+ */
23
+ export function toolStartProgressText(toolName) {
24
+ if (typeof toolName !== "string" || toolName.trim().length === 0) return null;
25
+ return `Running ${toolName}...`;
26
+ }
27
+
28
+ /**
29
+ * The slice of run state the stream subscriber reads and mutates. A structural
30
+ * subset of the orchestrator's runState.
31
+ * @typedef {object} StreamSubscriberState
32
+ * @property {string[]} assistantTexts
33
+ * @property {string[]} assistantThinking
34
+ * @property {Set<unknown>} textDeltaIndexes
35
+ * @property {Set<unknown>} thinkingDeltaIndexes
36
+ * @property {Map<string, number>} toolStartTimes
37
+ * @property {number} turnCount
38
+ * @property {number} toolResultsSeen
39
+ * @property {string|null} lastToolName
40
+ * @property {boolean} maxTurnsHit
41
+ */
42
+
43
+ /**
44
+ * Build the harness subscribe handler. `harness` is passed for the maxTurns
45
+ * abort; it is already constructed when this is wired (subscribe follows the
46
+ * AgentHarness constructor).
47
+ * @param {StreamSubscriberState} runState
48
+ * @param {{onEvent: (event: any) => void, options: any, toolLimits: any, harness: any}} deps
49
+ * @returns {(event: any) => void}
50
+ */
51
+ export function createStreamSubscriber(runState, { onEvent, options, toolLimits, harness }) {
52
+ return (event) => {
53
+ if (event.type === "message_update") {
54
+ const streamEvent = event.assistantMessageEvent;
55
+ if (streamEvent?.type === "text_delta" && streamEvent.delta) {
56
+ runState.textDeltaIndexes.add(streamContentKey(streamEvent, "text"));
57
+ runState.assistantTexts.push(streamEvent.delta);
58
+ onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.delta }] } });
59
+ } else if (streamEvent?.type === "text_end" && streamEvent.content) {
60
+ const key = streamContentKey(streamEvent, "text");
61
+ if (!runState.textDeltaIndexes.has(key)) {
62
+ runState.assistantTexts.push(streamEvent.content);
63
+ onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.content }] } });
64
+ }
65
+ } else if (streamEvent?.type === "thinking_delta" && streamEvent.delta) {
66
+ runState.thinkingDeltaIndexes.add(streamContentKey(streamEvent, "thinking"));
67
+ runState.assistantThinking.push(streamEvent.delta);
68
+ onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.delta }] } });
69
+ } else if (streamEvent?.type === "thinking_end" && streamEvent.content) {
70
+ const key = streamContentKey(streamEvent, "thinking");
71
+ if (!runState.thinkingDeltaIndexes.has(key)) {
72
+ runState.assistantThinking.push(streamEvent.content);
73
+ onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.content }] } });
74
+ }
75
+ }
76
+ } else if (event.type === "tool_execution_start") {
77
+ if (event.toolName) runState.lastToolName = event.toolName;
78
+ if (event.toolCallId) runState.toolStartTimes.set(event.toolCallId, Date.now());
79
+ const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
80
+ const progressText = toolStartProgressText(event.toolName);
81
+ if (progressText) {
82
+ onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: progressText }] } });
83
+ }
84
+ onEvent({
85
+ type: "assistant",
86
+ message: { content: [{ type: "tool_use", id: event.toolCallId, name: event.toolName, input }] },
87
+ });
88
+ } else if (event.type === "tool_execution_update") {
89
+ const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
90
+ onEvent({
91
+ type: "tool_update",
92
+ tool_use_id: event.toolCallId,
93
+ name: event.toolName,
94
+ input,
95
+ partial_result: jsonSerializable(event.partialResult, String(event.partialResult ?? "")),
96
+ });
97
+ } else if (event.type === "tool_execution_end") {
98
+ const resultContent = toolResultContent(event.result);
99
+ if (!event.isError) runState.toolResultsSeen += 1;
100
+ const startedAt = runState.toolStartTimes.get(event.toolCallId);
101
+ if (startedAt !== undefined) {
102
+ runState.toolStartTimes.delete(event.toolCallId);
103
+ onEvent({
104
+ type: "tool_timing",
105
+ tool_use_id: event.toolCallId,
106
+ name: event.toolName,
107
+ execution_ms: Date.now() - startedAt,
108
+ is_error: !!event.isError,
109
+ });
110
+ }
111
+ onEvent({
112
+ type: "user",
113
+ message: {
114
+ content: [{
115
+ type: "tool_result",
116
+ tool_use_id: event.toolCallId,
117
+ content: resultContent,
118
+ raw_result: compactToolRawResult(jsonSerializable(event.result, resultContent), resultContent),
119
+ is_error: !!event.isError,
120
+ }],
121
+ },
122
+ });
123
+ } else if (event.type === "turn_end") {
124
+ runState.turnCount += 1;
125
+ // NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.80.3).
126
+ // pi's only after-turn stop hook is `shouldStopAfterTurn` on the LOW-LEVEL
127
+ // `AgentLoopConfig` (dist/types.d.ts) — the config passed to the raw
128
+ // `agentLoop`. It is NOT surfaced on `AgentHarnessOptions`
129
+ // (dist/harness/types.d.ts) and `AgentHarness` (dist/harness/agent-harness.d.ts)
130
+ // exposes no maxTurns / maxSteps / loop-config passthrough. This bridge is
131
+ // built on AgentHarness (for its session tree, compaction, steering, and
132
+ // event stream); reaching `shouldStopAfterTurn` would mean abandoning the
133
+ // harness for the low-level loop and reimplementing all of that. So the
134
+ // maxTurns ceiling stays enforced HERE: we count `turn_end`s and abort on
135
+ // the one that crosses the ceiling, but only when the turn ended to run
136
+ // MORE tools (stopReason "toolUse") — a turn that already produced a final
137
+ // answer must not be clipped. Delegate to a harness-native option only if
138
+ // pi lifts shouldStopAfterTurn (or an equivalent) onto AgentHarnessOptions.
139
+ if (Number.isFinite(Number(options.maxTurns))
140
+ && Number(options.maxTurns) > 0
141
+ && runState.turnCount >= Number(options.maxTurns)
142
+ && event.message?.stopReason === "toolUse") {
143
+ runState.maxTurnsHit = true;
144
+ harness.abort();
145
+ }
146
+ }
147
+ };
148
+ }