@mono-agent/agent-runtime 0.20.10 → 0.20.14

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 (101) hide show
  1. package/MIGRATION.md +264 -25
  2. package/README.md +139 -448
  3. package/package.json +7 -43
  4. package/src/agent/tools/agent-tool.js +5 -5
  5. package/src/agent/tools/bash.js +1 -24
  6. package/src/agent/tools/codex-subscription-search.js +1 -1
  7. package/src/agent/tools/index.js +7 -0
  8. package/src/agent/tools/monitor.js +140 -0
  9. package/src/agent/tools/pi-bridge.js +92 -7
  10. package/src/agent/tools/shared/bash-environment.js +31 -0
  11. package/src/agent/tools/shared/monitors.js +274 -0
  12. package/src/agent/tools/shared/process-runner.js +23 -5
  13. package/src/ai/cost.js +13 -68
  14. package/src/ai/index.js +4 -17
  15. package/src/ai/pi-interop.js +65 -1
  16. package/src/ai/pi-oauth-compat.js +1 -1
  17. package/src/ai/providers/codex/app-server-client.js +592 -0
  18. package/src/ai/providers/pi-models.js +18 -10
  19. package/src/ai/providers/pi-native/compaction-driver.js +49 -21
  20. package/src/ai/providers/pi-native/harness-adapter.js +338 -0
  21. package/src/ai/providers/pi-native/result-builder.js +10 -10
  22. package/src/ai/providers/pi-native/session-lifecycle.js +95 -40
  23. package/src/ai/providers/pi-native/stream-subscriber.js +23 -5
  24. package/src/ai/providers/pi-native/turn-runner.js +34 -15
  25. package/src/ai/providers/pi-native.js +48 -22
  26. package/src/ai/runtime/capabilities.js +11 -56
  27. package/src/ai/runtime/model-refs.js +118 -153
  28. package/src/ai/runtime/registry.js +22 -56
  29. package/src/ai/runtime/router.js +48 -408
  30. package/src/ai/runtime/session-liveness.js +3 -4
  31. package/src/ai/runtime/sessions.js +4 -5
  32. package/src/ai/runtime/tool-policy.js +0 -2
  33. package/src/ai/types.js +12 -108
  34. package/src/index.js +0 -6
  35. package/src/runtime.js +8 -14
  36. package/types/agent/tools/agent-tool.d.ts +1 -2
  37. package/types/agent/tools/codex-subscription-search.d.ts +1 -1
  38. package/types/agent/tools/index.d.ts +1 -0
  39. package/types/agent/tools/monitor.d.ts +44 -0
  40. package/types/agent/tools/pi-bridge.d.ts +3 -2
  41. package/types/agent/tools/shared/bash-environment.d.ts +4 -0
  42. package/types/agent/tools/shared/monitors.d.ts +83 -0
  43. package/types/agent/tools/shared/process-runner.d.ts +11 -2
  44. package/types/ai/cost.d.ts +1 -2
  45. package/types/ai/index.d.ts +1 -4
  46. package/types/ai/pi-interop.d.ts +20 -0
  47. package/types/ai/providers/codex/app-server-client.d.ts +37 -0
  48. package/types/ai/providers/pi-native/harness-adapter.d.ts +56 -0
  49. package/types/ai/providers/pi-native/result-builder.d.ts +3 -3
  50. package/types/ai/providers/pi-native/session-lifecycle.d.ts +2 -1
  51. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -2
  52. package/types/ai/providers/pi-native/turn-runner.d.ts +33 -6
  53. package/types/ai/providers/pi-native.d.ts +21 -4
  54. package/types/ai/runtime/capabilities.d.ts +21 -70
  55. package/types/ai/runtime/model-refs.d.ts +0 -24
  56. package/types/ai/runtime/router.d.ts +3 -10
  57. package/types/ai/runtime/tool-policy.d.ts +0 -2
  58. package/types/ai/types.d.ts +28 -250
  59. package/types/index.d.ts +0 -1
  60. package/src/ai/providers/acp-client.js +0 -1149
  61. package/src/ai/providers/acp-privacy.js +0 -124
  62. package/src/ai/providers/acp-public.js +0 -21
  63. package/src/ai/providers/acp-session-tokens.js +0 -282
  64. package/src/ai/providers/acp-transport.js +0 -356
  65. package/src/ai/providers/acp.js +0 -543
  66. package/src/ai/providers/claude-cli.js +0 -883
  67. package/src/ai/providers/claude-sandbox.js +0 -71
  68. package/src/ai/providers/claude-sdk-discovery-worker.js +0 -53
  69. package/src/ai/providers/claude-sdk-discovery.js +0 -352
  70. package/src/ai/providers/claude-sdk.js +0 -1127
  71. package/src/ai/providers/claude-subagent-activity.js +0 -719
  72. package/src/ai/providers/claude-subagents.js +0 -88
  73. package/src/ai/providers/codex-app.js +0 -2946
  74. package/src/ai/providers/opencode-app.js +0 -1109
  75. package/src/ai/providers/opencode-discovery.js +0 -39
  76. package/src/ai/providers/opencode-server.js +0 -508
  77. package/src/ai/runtime/context-windows.js +0 -46
  78. package/src/ai/runtime/fast-mode.js +0 -8
  79. package/src/ai/streaming/codex-events.js +0 -146
  80. package/src/ai/streaming/opencode-events.js +0 -59
  81. package/types/ai/providers/acp-client.d.ts +0 -227
  82. package/types/ai/providers/acp-privacy.d.ts +0 -25
  83. package/types/ai/providers/acp-public.d.ts +0 -7
  84. package/types/ai/providers/acp-session-tokens.d.ts +0 -41
  85. package/types/ai/providers/acp-transport.d.ts +0 -45
  86. package/types/ai/providers/acp.d.ts +0 -93
  87. package/types/ai/providers/claude-cli.d.ts +0 -305
  88. package/types/ai/providers/claude-sandbox.d.ts +0 -79
  89. package/types/ai/providers/claude-sdk-discovery-worker.d.ts +0 -1
  90. package/types/ai/providers/claude-sdk-discovery.d.ts +0 -97
  91. package/types/ai/providers/claude-sdk.d.ts +0 -138
  92. package/types/ai/providers/claude-subagent-activity.d.ts +0 -53
  93. package/types/ai/providers/claude-subagents.d.ts +0 -18
  94. package/types/ai/providers/codex-app.d.ts +0 -151
  95. package/types/ai/providers/opencode-app.d.ts +0 -96
  96. package/types/ai/providers/opencode-discovery.d.ts +0 -4
  97. package/types/ai/providers/opencode-server.d.ts +0 -20
  98. package/types/ai/runtime/context-windows.d.ts +0 -9
  99. package/types/ai/runtime/fast-mode.d.ts +0 -2
  100. package/types/ai/streaming/codex-events.d.ts +0 -40
  101. package/types/ai/streaming/opencode-events.d.ts +0 -42
@@ -10,12 +10,13 @@
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 } from "@earendil-works/pi-agent-core";
14
14
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
- import { open } from "node:fs/promises";
15
+ import { access, open } from "node:fs/promises";
16
16
  import { dirname, resolve } from "node:path";
17
17
  import { createSessionRegistry } from "../../runtime/sessions.js";
18
18
  import { createSessionLiveness } from "../../runtime/session-liveness.js";
19
+ import { createPiSessionAdapter, PI_CONTEXT } from "./harness-adapter.js";
19
20
 
20
21
  async function syncPath(path) {
21
22
  const handle = await open(path, "r");
@@ -40,31 +41,61 @@ async function syncDurableTranscript(entry) {
40
41
  }
41
42
 
42
43
  async function invalidateNativeSession(entry) {
43
- await entry.repo.delete(entry.metadata);
44
44
  if (entry.durable) {
45
45
  const path = entry.metadata?.path;
46
46
  if (typeof path !== "string" || !path) {
47
47
  throw new Error("Durable Pi session metadata is missing its JSONL path");
48
48
  }
49
+ // Explicit invalidation is idempotent. A preceding failed sync may have
50
+ // observed that the transcript was already removed outside this process.
51
+ try {
52
+ await access(path);
53
+ } catch (error) {
54
+ if (error?.code === "ENOENT") return;
55
+ throw error;
56
+ }
57
+ }
58
+ await entry.repo.delete(entry.metadata, PI_CONTEXT);
59
+ if (entry.durable) {
60
+ const path = entry.metadata.path;
49
61
  // Make the unlink durable before the registry forgets the busy marker.
50
62
  await syncPath(dirname(path));
51
63
  }
52
64
  }
53
65
 
66
+ /**
67
+ * Release a Pi session handle and remove its repository record as independent
68
+ * best-effort operations. A close failure must not prevent deletion of a fresh
69
+ * or otherwise poisoned transcript.
70
+ * @param {any} session
71
+ * @param {any} repo
72
+ * @param {any} [knownMetadata]
73
+ */
74
+ async function closeAndDeleteSession(session, repo, knownMetadata) {
75
+ let metadata = knownMetadata;
76
+ if (!metadata) {
77
+ try { metadata = await session.getMetadata(); } catch { /* best-effort */ }
78
+ }
79
+ try { await session.close(); } catch { /* best-effort */ }
80
+ if (metadata) {
81
+ try { await repo.delete(metadata, PI_CONTEXT); } catch { /* best-effort */ }
82
+ }
83
+ }
84
+
54
85
  // 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
86
+ // { metadata, repo, durable, busy }. Pi 0.85 sessions are closed after every
87
+ // turn so their repository record can be reopened safely; the registry owns
88
+ // only liveness and metadata. In-memory transcripts are freed when the registry
89
+ // evicts them; durable (jsonl) transcripts survive eviction. Registering here gives
59
90
  // runtime.disposeSession / disposeProviderSession + idle-TTL eviction the same
60
91
  // reach over native pi sessions that the legacy bridge had.
61
- const nativeSessionRepo = new InMemorySessionRepo();
92
+ const nativeSessionRepo = new MemorySessionRepo();
62
93
  const nativeSessions = createSessionRegistry({
63
94
  isBusy: (entry) => entry.busy === true,
64
95
  onSync: syncDurableTranscript,
65
96
  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
97
+ // Ordinary disposal/TTL only drops registry metadata so durable sessions
98
+ // can reopen later. Explicit invalidation means the host rejected the
68
99
  // turn before canonical history commit; that poisoned transcript must be
69
100
  // deleted too or it could silently reappear on the next stable-id resume.
70
101
  if (reason === "invalidated") {
@@ -75,7 +106,7 @@ const nativeSessions = createSessionRegistry({
75
106
  return;
76
107
  }
77
108
  if (entry.durable) return;
78
- await entry.repo.delete(entry.metadata);
109
+ await entry.repo.delete(entry.metadata, PI_CONTEXT);
79
110
  },
80
111
  });
81
112
  const liveness = createSessionLiveness(nativeSessions);
@@ -88,7 +119,7 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
88
119
  let repo = durableNativeSessionRepos.get(root);
89
120
  if (!repo) {
90
121
  repo = new JsonlSessionRepo({
91
- fs: new NodeExecutionEnv({ cwd: process.cwd() }),
122
+ fileSystem: new NodeExecutionEnv({ cwd: process.cwd() }),
92
123
  sessionsRoot: root,
93
124
  });
94
125
  durableNativeSessionRepos.set(root, repo);
@@ -118,19 +149,19 @@ export async function retireDurableNativeSession(providerSessionId, piSessionsRo
118
149
 
119
150
  const repo = resolveDurableNativeSessionRepo(piSessionsRoot);
120
151
  if (!repo) throw new Error("Durable Pi session repository is unavailable");
121
- const matches = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
152
+ const matches = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
122
153
  const changedDirectories = new Set();
123
154
  for (const metadata of matches) {
124
155
  if (typeof metadata?.path !== "string" || !metadata.path) {
125
156
  throw new Error(`Durable Pi session ${providerSessionId} has invalid metadata`);
126
157
  }
127
- await repo.delete(metadata);
158
+ await repo.delete(metadata, PI_CONTEXT);
128
159
  changedDirectories.add(dirname(metadata.path));
129
160
  }
130
161
  for (const directory of changedDirectories) await syncPath(directory);
131
162
  if (changedDirectories.size > 0) await syncPath(resolve(piSessionsRoot));
132
163
 
133
- const remaining = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
164
+ const remaining = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
134
165
  if (remaining.length > 0) {
135
166
  throw new Error(`Durable Pi session ${providerSessionId} could not be retired completely`);
136
167
  }
@@ -158,10 +189,9 @@ function isSafeSessionId(id) {
158
189
 
159
190
  async function reopenDurableNativeSession(repo, sessionId) {
160
191
  try {
161
- const metadata = (await repo.list()).find((entry) => entry?.id === sessionId);
192
+ const metadata = (await repo.list(undefined, PI_CONTEXT)).find((entry) => entry?.id === sessionId);
162
193
  if (!metadata) return null;
163
- const session = await repo.open(metadata);
164
- return { session, metadata, repo, durable: true, busy: false };
194
+ return { metadata, repo, durable: true, busy: false };
165
195
  } catch {
166
196
  return null;
167
197
  }
@@ -185,9 +215,9 @@ function sessionUnavailableResult({
185
215
  usage: {},
186
216
  durationMs: Date.now() - start,
187
217
  numTurns: 0,
188
- model: resolved?.reference || resolved?.model || null,
218
+ model: resolved?.reference || (resolved?.provider && resolved?.model ? `${resolved.provider}:${resolved.model}` : null),
189
219
  effort: options.effort || null,
190
- sdk: resolved?.sdk || "pi",
220
+ sdk: "pi",
191
221
  cancelled: false,
192
222
  error: errorMessage,
193
223
  failureKind,
@@ -290,11 +320,14 @@ export async function resolveSession(runState, {
290
320
  } else {
291
321
  // Reserved the id with a busy placeholder BEFORE the create await so a
292
322
  // 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.
323
+ // The keep-alive success path overwrites this placeholder with an
324
+ // entry that remains busy until its harness closes; drop/abort/catch
325
+ // paths release it. Keyed by requestedSessionId === providerSessionId.
296
326
  runState.reservation = reservation;
297
- runState.session = await durableRepo.create({ id: providerSessionId, cwd: cwd || process.cwd() });
327
+ runState.session = createPiSessionAdapter(await durableRepo.create(
328
+ { id: providerSessionId, cwd: cwd || process.cwd() },
329
+ PI_CONTEXT,
330
+ ));
298
331
  runState.createdOnMiss = true;
299
332
  }
300
333
  } else {
@@ -353,14 +386,28 @@ export async function resolveSession(runState, {
353
386
  };
354
387
  }
355
388
  runState.sessionEntry = claimed.entry;
356
- runState.session = claimed.entry.session;
389
+ try {
390
+ runState.session = createPiSessionAdapter(await claimed.entry.repo.open(
391
+ claimed.entry.metadata,
392
+ PI_CONTEXT,
393
+ ));
394
+ } catch (error) {
395
+ // The claim made this registry entry busy. An open failure means the
396
+ // entry cannot be driven, so remove its liveness record before
397
+ // propagating; otherwise every later resume retries the same broken
398
+ // entry forever. Preserve the durable transcript for a later cold
399
+ // reopen/recovery attempt.
400
+ liveness.release(requestedSessionId);
401
+ runState.sessionEntry = null;
402
+ throw error;
403
+ }
357
404
  }
358
405
  } else {
359
406
  // Fresh runs persist into the durable jsonl repo when piSessionsRoot is
360
407
  // set, so a kept-alive session can be reopened from disk after the live
361
408
  // entry is evicted; otherwise the in-memory repo is used.
362
- runState.session = await (durableRepo || nativeSessionRepo)
363
- .create({ id: providerSessionId, cwd: cwd || process.cwd() });
409
+ runState.session = createPiSessionAdapter(await (durableRepo || nativeSessionRepo)
410
+ .create({ id: providerSessionId, cwd: cwd || process.cwd() }, PI_CONTEXT));
364
411
  }
365
412
  return { done: false };
366
413
  }
@@ -379,7 +426,10 @@ export async function discardUncommittedSession(runState, { durableRepo }) {
379
426
  // transcript was appended yet (prompt never ran), so the live session is
380
427
  // already at its pre-turn leaf and needs no rollback.
381
428
  if (runState.session && !runState.sessionEntry) {
382
- try { await (durableRepo || nativeSessionRepo).delete(await runState.session.getMetadata()); } catch { /* best-effort */ }
429
+ await closeAndDeleteSession(
430
+ runState.session,
431
+ durableRepo || nativeSessionRepo,
432
+ );
383
433
  }
384
434
  // Drop the create-on-miss BUSY reservation too, else the busy placeholder
385
435
  // leaks and every future resume of this conversation's stable id returns
@@ -419,16 +469,16 @@ export async function commitSession(runState, {
419
469
  } else {
420
470
  const metadata = await session.getMetadata();
421
471
  const entry = {
422
- session,
423
472
  metadata,
424
473
  repo: durableRepo || nativeSessionRepo,
425
474
  durable: !!durableRepo,
426
- busy: false,
475
+ busy: true,
427
476
  };
428
477
  // A create-on-miss reservation is overwritten by its commit (same id);
429
478
  // a plain fresh keep-alive run registers directly.
430
479
  if (reservation) reservation.commit(entry);
431
480
  else nativeSessions.set(providerSessionId, entry, { idleTimeoutMs: sessionTtlMs });
481
+ runState.registeredSessionEntry = entry;
432
482
  }
433
483
  } catch (err) {
434
484
  // Session persistence must never fail the run; drop the (now
@@ -442,7 +492,7 @@ export async function commitSession(runState, {
442
492
  if (requestedSessionId) nativeSessions.delete(requestedSessionId);
443
493
  const broken = sessionEntry;
444
494
  if (broken) {
445
- try { await broken.repo.delete(broken.metadata); } catch { /* best-effort */ }
495
+ await closeAndDeleteSession(session, broken.repo, broken.metadata);
446
496
  }
447
497
  }
448
498
  } else if (sessionEntry) {
@@ -463,9 +513,7 @@ export async function commitSession(runState, {
463
513
  // (the success keep-alive path overwrites it with the finalized entry, so
464
514
  // it is only this drop branch that must clean it up).
465
515
  if (reservation) reservation.release();
466
- try {
467
- await (durableRepo || nativeSessionRepo).delete(await session.getMetadata());
468
- } catch { /* best-effort */ }
516
+ await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
469
517
  }
470
518
  }
471
519
 
@@ -487,7 +535,7 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
487
535
  nativeSessions.delete(requestedSessionId);
488
536
  } else {
489
537
  nativeSessions.delete(providerSessionId);
490
- try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
538
+ await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
491
539
  }
492
540
  }
493
541
 
@@ -495,7 +543,8 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
495
543
  * Outer-catch session cleanup: drop a just-created fresh durable session, drop a
496
544
  * create-on-miss reservation placeholder, and roll a resumed session back to its
497
545
  * pre-turn leaf for host/runtime-side throws that landed after the harness
498
- * already mutated the live session.
546
+ * already mutated the live session. Resumed handles are always closed here so
547
+ * setup failures before a harness is returned cannot leave the repo wedged.
499
548
  * @param {any} runState
500
549
  * @param {{durableRepo: any}} params
501
550
  */
@@ -509,7 +558,7 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
509
558
  // resumed user session here would be data loss) and never when the throw
510
559
  // preceded session create.
511
560
  if (session && !sessionEntry) {
512
- try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
561
+ await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
513
562
  }
514
563
  // Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
515
564
  // during/after the reservation — including a throw inside the create await
@@ -522,8 +571,14 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
522
571
  // harness already mutated the live session. Mirrors the success-path
523
572
  // rollback: move the live session back to the pre-turn leaf so the failed
524
573
  // 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 */ }
574
+ // baselineLeafId` so rollback only fires for resumes that captured a baseline.
575
+ // Closing is independently gated on the resumed session existing: a failure
576
+ // may land before the baseline was readable, but that handle must still be
577
+ // released without deleting the user-owned transcript.
578
+ if (sessionEntry && session) {
579
+ if (baselineLeafId) {
580
+ try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
581
+ }
582
+ try { await session.close(); } catch { /* best-effort */ }
528
583
  }
529
584
  }
@@ -76,8 +76,8 @@ function toolResultOutcome(result) {
76
76
 
77
77
  /**
78
78
  * Build the harness subscribe handler. `harness` is passed for the maxTurns
79
- * abort; it is already constructed when this is wired (subscribe follows the
80
- * AgentHarness constructor).
79
+ * abort; it is already constructed when this is wired (subscribe follows
80
+ * AgentHarness.create()).
81
81
  * @param {StreamSubscriberState} runState
82
82
  * @param {{onEvent: (event: any) => void, options: any, toolLimits: any, harness: any, sdk: string, model: string}} deps
83
83
  * @returns {(event: any) => void}
@@ -107,7 +107,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
107
107
  onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.content }] } });
108
108
  }
109
109
  }
110
- } else if (event.type === "message_end") {
110
+ } else if (event.type === "message_end" && event.message?.role === "assistant") {
111
111
  const contextUsage = contextUsageFromAssistantMessage(event.message);
112
112
  if (contextUsage) {
113
113
  const contextWindow = Number(harness?.getModel?.()?.contextWindow) || 0;
@@ -124,6 +124,24 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
124
124
  tokens: contextUsage,
125
125
  });
126
126
  }
127
+ const hasVisibleContent = Array.isArray(event.message?.content)
128
+ && event.message.content.some((block) => block
129
+ && typeof block === "object"
130
+ && ((block.type === "text"
131
+ && typeof block.text === "string"
132
+ && block.text.length > 0)
133
+ || (block.type === "thinking"
134
+ && ((typeof block.thinking === "string" && block.thinking.length > 0)
135
+ || (typeof block.text === "string" && block.text.length > 0)))));
136
+ if (hasVisibleContent) {
137
+ const messageId = typeof event.message?.id === "string" && event.message.id.trim().length > 0
138
+ ? event.message.id
139
+ : undefined;
140
+ onEvent({
141
+ type: "assistant_message_boundary",
142
+ ...(messageId === undefined ? {} : { messageId }),
143
+ });
144
+ }
127
145
  } else if (event.type === "tool_execution_start") {
128
146
  if (event.toolName) runState.lastToolName = event.toolName;
129
147
  if (event.toolCallId) runState.toolStartTimes.set(event.toolCallId, Date.now());
@@ -198,7 +216,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
198
216
  });
199
217
  } else if (event.type === "turn_end") {
200
218
  runState.turnCount += 1;
201
- // NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.80.3).
219
+ // NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.85.0).
202
220
  // pi's only after-turn stop hook is `shouldStopAfterTurn` on the LOW-LEVEL
203
221
  // `AgentLoopConfig` (dist/types.d.ts) — the config passed to the raw
204
222
  // `agentLoop`. It is NOT surfaced on `AgentHarnessOptions`
@@ -217,7 +235,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
217
235
  && runState.turnCount >= Number(options.maxTurns)
218
236
  && event.message?.stopReason === "toolUse") {
219
237
  runState.maxTurnsHit = true;
220
- harness.abort();
238
+ void Promise.resolve(harness.abort()).catch(() => {});
221
239
  }
222
240
  }
223
241
  };
@@ -7,7 +7,6 @@
7
7
  // state; run state (harness, removeAbortHandler, externalAbort) lives on the
8
8
  // caller-owned runState.
9
9
 
10
- import { AgentHarness } from "@earendil-works/pi-agent-core";
11
10
  import {
12
11
  createStructuredOutputTool,
13
12
  getPiBuiltinTools,
@@ -17,6 +16,7 @@ import { createNodeReplController } from "../../../agent/tools/node-repl.js";
17
16
  import { createWebToolController } from "../../../agent/tools/web-controller.js";
18
17
  import { readToolRuntime } from "../../../agent/tools/shared/runtime-context.js";
19
18
  import { formatLiveInputGuidance } from "../../live-input-prompt.js";
19
+ import { createPiHarnessAdapter } from "./harness-adapter.js";
20
20
  import { appendStructuredOutputInstruction } from "./structured-output.js";
21
21
  import { createStreamSubscriber } from "./stream-subscriber.js";
22
22
 
@@ -121,13 +121,13 @@ export async function buildTurnTools(runState, {
121
121
  nodeReplController,
122
122
  webController,
123
123
  processJobsController: options.processJobs,
124
+ monitorsController: options.monitors,
124
125
  toolExecutionMode,
125
126
  subagents: options.subagents,
126
127
  // The child inherits the parent's route and workspace unless its profile
127
128
  // pins a model; the tool closure reads these to build each child request.
128
129
  subagentContext: {
129
130
  model: options.model,
130
- executionMode: options.executionMode,
131
131
  cwd: options.cwd,
132
132
  parentRunId: runCtx?.runId,
133
133
  // Same policy + engine this turn's own tools are confined by, so a
@@ -251,7 +251,7 @@ export function toolResultErrorOverride(details) {
251
251
  return failed ? { isError: true } : undefined;
252
252
  }
253
253
 
254
- export function buildTurnHarness(runState, {
254
+ export async function buildTurnHarness(runState, {
255
255
  session,
256
256
  piModels,
257
257
  model,
@@ -263,18 +263,9 @@ export function buildTurnHarness(runState, {
263
263
  maxRetries,
264
264
  maxRetryDelayMs,
265
265
  steeringMode,
266
- onEvent,
267
266
  options,
268
- toolLimits,
269
- sdk,
270
- reference,
271
267
  }) {
272
- // pi-agent-core 0.83.0 removed `env` from AgentHarnessOptions: an
273
- // ExecutionEnv now reaches tools through the generic per-turn `toolContext`
274
- // instead. mono-agent needs neither — it uses none of pi's built-in
275
- // file/shell tools, and its own tools close over what they need — so the
276
- // option is dropped rather than migrated.
277
- const harness = new AgentHarness({
268
+ const harness = await createPiHarnessAdapter(session, {
278
269
  session,
279
270
  models: piModels,
280
271
  model,
@@ -297,8 +288,37 @@ export function buildTurnHarness(runState, {
297
288
  // every resolved execute(), so without this the model would be told a failed,
298
289
  // timed-out, or empty delegation succeeded.
299
290
  harness.on("tool_result", (event) => toolResultErrorOverride(event?.details));
291
+ // Pi 0.85 records in-flight work durably. mono-agent tools do not yet use the
292
+ // new replay-memo contract, so abort an interrupted operation instead of
293
+ // risking a duplicate external effect. Do this before subscribing so stale
294
+ // recovery events cannot contaminate the new run's transcript counters.
295
+ try {
296
+ await harness.abortOpenOperations();
297
+ } catch (error) {
298
+ try { await harness.close(); } catch { /* best-effort */ }
299
+ throw error;
300
+ }
301
+
300
302
  runState.harness = harness;
301
303
 
304
+ return harness;
305
+ }
306
+
307
+ /**
308
+ * Subscribe only after prior history has been seeded. Pi emits message
309
+ * lifecycle events for manual appendMessage() calls; subscribing earlier makes
310
+ * restored assistant messages look like fresh output boundaries.
311
+ * @param {any} runState
312
+ * @param {{harness: any, onEvent: (event: any) => void, options: any, toolLimits: any, sdk: string, reference: string}} deps
313
+ */
314
+ export function activateTurnHarness(runState, {
315
+ harness,
316
+ onEvent,
317
+ options,
318
+ toolLimits,
319
+ sdk,
320
+ reference,
321
+ }) {
302
322
  harness.subscribe(createStreamSubscriber(runState, {
303
323
  onEvent,
304
324
  options,
@@ -310,13 +330,12 @@ export function buildTurnHarness(runState, {
310
330
 
311
331
  const abortHandler = () => {
312
332
  runState.externalAbort = true;
313
- harness.abort();
333
+ void harness.abort().catch(() => {});
314
334
  };
315
335
  if (options.abortSignal) {
316
336
  options.abortSignal.addEventListener("abort", abortHandler, { once: true });
317
337
  runState.removeAbortHandler = () => options.abortSignal.removeEventListener?.("abort", abortHandler);
318
338
  }
319
- return harness;
320
339
  }
321
340
 
322
341
  /**
@@ -74,6 +74,7 @@ import {
74
74
  runReactiveCompaction,
75
75
  } from "./pi-native/compaction-driver.js";
76
76
  import {
77
+ activateTurnHarness,
77
78
  buildTurnHarness,
78
79
  buildTurnTools,
79
80
  runHarnessPrompt,
@@ -83,9 +84,10 @@ import {
83
84
  import { resolvePiTransport } from "./pi-native/transport.js";
84
85
 
85
86
  /**
86
- * Pi 0.80.6 exposes per-tool execution markers but not AgentHarness's global
87
- * toolExecution option. Resolve mono-agent's programmatic mode once per run;
88
- * individual tool builders then mark stateful/mutating tools sequential.
87
+ * Resolve mono-agent's programmatic mode once per run. Tool builders mark
88
+ * stateful/mutating tools sequential; the Pi 0.85 harness adapter promotes any
89
+ * such marker to its global toolExecution setting because mixed scheduling is
90
+ * no longer available upstream.
89
91
  */
90
92
  export function resolvePiToolExecutionMode(options = {}) {
91
93
  const warnings = [];
@@ -258,6 +260,14 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
258
260
  // an intermediate layer flattens the Error to its message.
259
261
  installTransportErrorProbe();
260
262
  const resolved = options.model;
263
+ if (
264
+ typeof resolved?.provider !== "string"
265
+ || resolved.provider.length === 0
266
+ || typeof resolved.model !== "string"
267
+ || resolved.model.length === 0
268
+ ) {
269
+ throw new Error("invalid pi model reference: provider and model are required");
270
+ }
261
271
  const start = Date.now();
262
272
  const events = [];
263
273
  const runtimeWarnings = [];
@@ -304,6 +314,10 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
304
314
  },
305
315
  session: null,
306
316
  sessionEntry: null,
317
+ // Fresh keep-alive sessions are registered busy until the harness closes;
318
+ // this prevents another turn from reopening the same repo record while its
319
+ // first handle is still live.
320
+ registeredSessionEntry: null,
307
321
  // True when a requestedSessionId had no live entry AND no durable transcript,
308
322
  // so a fresh durable session was created under that id (cross-restart resume,
309
323
  // first turn for the conversation). Distinct from a true resume (sessionEntry
@@ -352,13 +366,17 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
352
366
  }
353
367
  emitCaptured(events, options.onEvent, event);
354
368
  };
369
+ // Exec and Monitor both run a command a host configured Bash's risk tier for.
370
+ // Leaving either unmapped would let the same shell command take a lower
371
+ // approval tier simply by being started through a different tool.
372
+ const bashRiskTier = options.toolRiskTiers?.Bash;
355
373
  const approvalRiskTiers = {
356
374
  ...(options.toolRiskTiers || {}),
357
- ...(
358
- options.toolRiskTiers?.Exec === undefined && options.toolRiskTiers?.Bash !== undefined
359
- ? { Exec: options.toolRiskTiers.Bash }
360
- : {}
361
- ),
375
+ ...(bashRiskTier !== undefined
376
+ ? Object.fromEntries(["Exec", "Monitor"]
377
+ .filter((name) => options.toolRiskTiers?.[name] === undefined)
378
+ .map((name) => [name, bashRiskTier]))
379
+ : {}),
362
380
  };
363
381
  const approvalManager = options.onToolApprovalRequest
364
382
  ? createApprovalManager({
@@ -422,8 +440,7 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
422
440
  : resolvePiRuntimeModel(resolved, options);
423
441
  const capabilities = runtime.capabilities || {};
424
442
  const effectiveThinkingLevel = thinkingLevelForEffort(options.effort || "medium", capabilities);
425
- const reference = resolved.reference
426
- || (resolved.sdk === "pi" ? `pi:${resolved.provider}:${resolved.model}` : `${resolved.sdk}:${resolved.model}`);
443
+ const reference = resolved.reference || `${resolved.provider}:${resolved.model}`;
427
444
 
428
445
  // Resolve the typed `toolLimits` / `compaction` policy objects against the
429
446
  // deprecated `settings` fallback (per-group precedence: a present typed
@@ -486,15 +503,17 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
486
503
  ? Number(options.maxRetryDelayMs)
487
504
  : 60_000;
488
505
  // Steering controls user follow-up delivery, not tool scheduling. Keep it
489
- // independent from piToolExecutionMode; tools carry their own executionMode.
506
+ // independent from piToolExecutionMode; the adapter derives Pi 0.85's
507
+ // global scheduling mode from the tools' executionMode markers.
490
508
  const toolSteeringMode = "one-at-a-time";
491
509
 
492
510
  const piModels = buildRunModels(runtime, options, runtimeWarnings);
493
511
 
494
- // Construct the harness, subscribe the stream normalizer, and wire the
495
- // external abort handler (which sets runState.externalAbort and aborts the
496
- // harness). Sets runState.harness + runState.removeAbortHandler.
497
- harness = buildTurnHarness(runState, {
512
+ // Construct the harness and recover any interrupted durable operation.
513
+ // Stream subscription and the external abort handler are activated only
514
+ // after prior transcript seeding, so manual append lifecycle events cannot
515
+ // surface as output from this run.
516
+ harness = await buildTurnHarness(runState, {
498
517
  session: runState.session,
499
518
  piModels,
500
519
  model: runtime.model,
@@ -506,11 +525,7 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
506
525
  maxRetries,
507
526
  maxRetryDelayMs,
508
527
  steeringMode: toolSteeringMode,
509
- onEvent,
510
528
  options,
511
- toolLimits,
512
- sdk: resolved.sdk,
513
- reference,
514
529
  });
515
530
 
516
531
  // Seed prior transcript (everything before the trailing user turn) into the
@@ -536,6 +551,15 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
536
551
  try { runState.baselineLeafId = await runState.session.getLeafId(); } catch { /* best-effort */ }
537
552
  }
538
553
 
554
+ activateTurnHarness(runState, {
555
+ harness,
556
+ onEvent,
557
+ options,
558
+ toolLimits,
559
+ sdk: "pi",
560
+ reference,
561
+ });
562
+
539
563
  // Live steering: consume follow-up messages and steer the harness mid-run.
540
564
  // The consumer is tied to run completion so it stops steering once the run
541
565
  // finishes and does not swallow messages meant for a later turn.
@@ -584,7 +608,7 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
584
608
 
585
609
  onEvent({
586
610
  type: "provider_request_started",
587
- sdk: resolved.sdk,
611
+ sdk: "pi",
588
612
  model: reference,
589
613
  runtime: "pi",
590
614
  timestamp: Date.now(),
@@ -765,7 +789,7 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
765
789
  // runtime.compaction.enabled. See docs/reference/feature-registry.md runtime.context-compaction.
766
790
  contextCompactionApplied: runState.compaction.policy?.enabled ? runState.compaction.applied : null,
767
791
  });
768
- emitCapabilitiesResolved(onEvent, { sdk: resolved.sdk, model: reference, capabilitiesUsed });
792
+ emitCapabilitiesResolved(onEvent, { sdk: "pi", model: reference, capabilitiesUsed });
769
793
 
770
794
  // Re-check the abort signal: a cancel can land during the post-run work above
771
795
  // (live-input teardown, structured-output finalization retry) after the line
@@ -861,7 +885,9 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
861
885
  piTransport,
862
886
  });
863
887
  } finally {
888
+ try { await harness?.close?.(); } catch { /* best-effort */ }
864
889
  if (runState.sessionEntry) runState.sessionEntry.busy = false;
890
+ if (runState.registeredSessionEntry) runState.registeredSessionEntry.busy = false;
865
891
  runState.removeAbortHandler?.();
866
892
  try {
867
893
  await closeRunTools();
@@ -875,6 +901,6 @@ export const piNativeRuntimeBridge = {
875
901
  id: "pi",
876
902
  kind: "pi",
877
903
  capabilities: runtimeCapabilities("pi"),
878
- supports: (ref) => ref?.sdk === "pi",
904
+ supports: (ref) => Boolean(ref?.provider && ref?.model),
879
905
  execute: generatePiNativeResponse,
880
906
  };