@mono-agent/agent-runtime 0.15.4 → 0.17.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/MIGRATION.md CHANGED
@@ -42,8 +42,8 @@ the configuration schema.
42
42
  switch to `listPiBuiltinModels`, `getPiBuiltinModel`,
43
43
  `reasoningLevelsForPiModel`, `resolvePiOAuthApiKey`, and `loginPiOAuth` from
44
44
  `@mono-agent/agent-runtime/ai`. The runtime keeps Pi AI and Pi Agent Core
45
- exact-pinned at `0.80.6`; the façade returns cloned model and credential
46
- snapshots rather than exposing mutable upstream registries.
45
+ exact-pinned at `0.83.0`; the façade returns cloned model and credential
46
+ snapshots rather than exposing upstream provider objects.
47
47
  - **Claude test seam:** downstream tests should pass
48
48
  `RuntimeRunOptions.claudeAgentQuery` instead of mocking
49
49
  `@anthropic-ai/claude-agent-sdk` by package name. Normal runs omit this option
@@ -63,12 +63,35 @@ the configuration schema.
63
63
  authoritative and the runtime emits a bounded
64
64
  `live_input_callback_failed` warning.
65
65
 
66
- ## 0.15.x baseline
66
+ ## 0.17.x baseline
67
67
 
68
- This is the current published baseline for the detailed pre-1.0 reference
69
- below. It includes the explicit exports map, the five-bridge lazy registry,
70
- typed runtime policies, runtime-owned provider dependencies, and the
71
- public-surface cleanup described in this guide.
68
+ This is the current published baseline. It carries the 0.16.x contract forward
69
+ and adds a host-only, request-scoped `toolEnvironment` boundary. Hosts may pass
70
+ validated values and PATH prefixes through the request, harness, and runtime;
71
+ the runtime applies them only when Bash, Exec, or a nested subagent process is
72
+ spawned. It does not mutate `process.env` or persist the values in prompts,
73
+ metadata, history, traces, or long-lived tool context.
74
+
75
+ ## 0.16.x
76
+
77
+ This baseline carries the whole 0.15.x contract forward and adds:
78
+
79
+ - `skills` and `skillsRoot` on the run options. `skills` is the disclosed
80
+ `{name, description}` set for a run; a non-empty value makes `supports_skills`
81
+ a routing requirement, so a chain entry lacking it is skipped. `skillsRoot`
82
+ names the directory holding `<name>/SKILL.md` and is required alongside
83
+ `skills` for `ReadSkill` to exist. A subagent run now inherits both from its
84
+ parent unless a host-supplied `run` withholds them, so a child no longer has
85
+ to rediscover by trial and error what its parent could look up.
86
+ - Provider failover detail reaches whoever is watching the run rather than being
87
+ flattened at the boundary.
88
+ - Pi SDK 0.83.0.
89
+
90
+ ## 0.15.x
91
+
92
+ - The explicit exports map, the five-bridge lazy registry, typed runtime
93
+ policies, runtime-owned provider dependencies, and the public-surface cleanup
94
+ described in this guide.
72
95
 
73
96
  ## 0.12.x
74
97
 
@@ -313,9 +336,22 @@ falls back to its own env vars, exactly as returning `undefined` from the old ho
313
336
  did). **No host action needed** — `resolvePiApiKey` behaves as before.
314
337
 
315
338
  Dependency bump: **`@earendil-works/pi-ai` and `@earendil-works/pi-agent-core` are
316
- now `0.80.6`** (the initial Pi 0.80 migration landed at `0.80.5`, from
317
- `^0.79.1`). Compaction is driven natively (section 3). The `0.80.6` refresh also
318
- preserves model-native `max` reasoning and Pi's request-wide pricing tiers.
339
+ now `0.83.0`** (the initial Pi 0.80 migration landed at `0.80.5`, from
340
+ `^0.79.1`, and ran at `0.80.6` until the 0.83 upgrade). Compaction is driven
341
+ natively (section 3), and model-native `max` reasoning plus Pi's request-wide
342
+ pricing tiers are preserved.
343
+
344
+ The 0.83 upgrade carries two upstream removals, both absorbed inside the runtime
345
+ so hosts need no action:
346
+
347
+ - `@earendil-works/pi-ai/oauth` became a type-only entry point. The generic
348
+ registry (`getOAuthApiKey`, `getOAuthProvider`, `getOAuthProviders`) is gone,
349
+ and the per-provider flows are not importable. `src/ai/pi-oauth-compat.js`
350
+ rebuilds the same contracts over `provider.auth.oauth`, so `resolvePiApiKey`,
351
+ `resolvePiOAuthApiKey`, and `loginPiOAuth` keep their existing signatures and
352
+ behaviour, including the refresh-on-expiry trigger.
353
+ - `AgentHarnessOptions.env` was removed in favour of a per-turn `toolContext`.
354
+ The runtime passes neither: it uses none of Pi's built-in file/shell tools.
319
355
 
320
356
  ### 11. Exports map: wildcards removed (explicit deep-path map)
321
357
 
@@ -366,7 +402,7 @@ a compatibility subpath.
366
402
 
367
403
  ## Version
368
404
 
369
- This guide describes the published `0.15.x` package contract. Keep
405
+ This guide describes the published `0.17.x` package contract. Keep
370
406
  `@mono-agent/agent-runtime`, `@mono-agent/runtime-adapter`, and other
371
407
  `@mono-agent/*` packages on the same lockstep version when upgrading. The paired
372
408
  runtime adapter no longer exposes `piReasoningSummary` in its run-options type.
@@ -387,14 +423,14 @@ Worklab's runtime fork:
387
423
  `@earendil-works/pi-ai`, its separate Pi version constraint, and local copies
388
424
  of provider bridge code. Move tests off Pi's faux-provider helpers too; until
389
425
  that is complete, isolate the fixture or pin its development-only Pi
390
- dependency to exact `0.80.6` rather than a floating range. Do not restore the
426
+ dependency to exact `0.83.0` rather than a floating range. Do not restore the
391
427
  removed `pi-sdk.js` subpath.
392
428
  3. **Use the public Pi surfaces.** Run models through
393
429
  `generatePiNativeResponse` or the runtime registry. Use
394
430
  `listPiBuiltinModels`, `getPiBuiltinModel`,
395
431
  `reasoningLevelsForPiModel`, `resolvePiOAuthApiKey`, and `loginPiOAuth` for
396
- catalog and OAuth integration. Those façades keep Pi mutable state and the
397
- exact `0.80.6` compatibility pin inside the runtime. OAuth login adapters
432
+ catalog and OAuth integration. Those façades keep Pi provider objects and the
433
+ exact `0.83.0` compatibility pin inside the runtime. OAuth login adapters
398
434
  must supply `onAuth`, `onDeviceCode`, `onPrompt`, and `onSelect`; the façade
399
435
  rejects an incomplete callback contract before starting provider login.
400
436
  4. **Inject Claude tests.** Replace package-level mocks of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.15.4",
3
+ "version": "0.17.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",
@@ -133,8 +133,8 @@
133
133
  "dependencies": {
134
134
  "@anthropic-ai/claude-agent-sdk": "0.3.206",
135
135
  "@anthropic-ai/sdk": "^0.110.0",
136
- "@earendil-works/pi-agent-core": "0.80.6",
137
- "@earendil-works/pi-ai": "0.80.6",
136
+ "@earendil-works/pi-agent-core": "0.83.0",
137
+ "@earendil-works/pi-ai": "0.83.0",
138
138
  "@modelcontextprotocol/sdk": "^1.29.0",
139
139
  "@mozilla/readability": "0.6.0",
140
140
  "@opencode-ai/sdk": "^1.15.13",
@@ -93,16 +93,38 @@ function toolDescription(subagents, definitions, ceiling) {
93
93
  const named = definitions.length === 0
94
94
  ? ""
95
95
  : `\n\nAvailable subagents:\n${definitions.map((d) => `- ${d.name}: ${d.description}`).join("\n")}\n- ${GENERAL_PURPOSE_SUBAGENT}: read-only researcher inheriting the main model. Used when \`name\` is omitted.`;
96
+ // With authoring on, `name` is a free string rather than an enum, so it is
97
+ // the model's only signal for which of the two shapes it is writing. Left
98
+ // implicit, a caller that wants a configured profile AND a descriptive label
99
+ // splits those across two fields — label into `name`, profile into an
100
+ // invented one — and the closed schema rejects the whole call before any of
101
+ // the handler's precise errors can run.
102
+ const shapes = ceiling === null
103
+ ? ""
104
+ : `\n\nExactly two ways to call this, and \`name\` carries the agent's identity in both:\n- Use a configured one: set \`name\` to a name from the list above. Nothing else.\n- Build one for this task: set \`name\` to a NEW kebab-case name AND \`systemPrompt\` to its full instructions (optionally \`tools\`, \`effort\`). Do that when no configured one fits — a dedicated prompt beats stuffing constraints into \`prompt\`.\n\n\`description\` is the short label shown in the activity log, never the agent's name. There is no separate field for choosing a configured agent.`;
96
105
  // The ceiling is listed because the model has no other way to discover it: a
97
106
  // tool it cannot see is indistinguishable from one it forgot to ask for.
98
107
  const inline = ceiling === null
99
108
  ? ""
100
- : `\n\nYou can also build a specialist on the spot instead of picking a profile: pass \`systemPrompt\` with its full instructions, a kebab-case \`name\`, and the \`tools\` it needs. Do that when no profile fits the task — a dedicated prompt beats stuffing constraints into \`prompt\`. Tools you may grant: ${ceiling.join(", ")}. Anything else is dropped. Omit \`tools\` for a read-only helper.`;
101
- return `${DESCRIPTION_BASE}${parallel}${named}${inline}`;
109
+ : `\n\nTools you may grant a subagent you build: ${ceiling.join(", ")}. Anything else is dropped. Omit \`tools\` for a read-only helper.`;
110
+ return `${DESCRIPTION_BASE}${parallel}${named}${shapes}${inline}`;
111
+ }
112
+
113
+ /** @param {*} value @returns {number} */
114
+ function numberOrZero(value) {
115
+ const numeric = Number(value);
116
+ return Number.isFinite(numeric) ? numeric : 0;
117
+ }
118
+
119
+ /** @returns {{costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}} */
120
+ function emptyUsage() {
121
+ return { costUsd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
102
122
  }
103
123
 
104
124
  /**
105
- * Per-logical-run call and byte budget, shared across router attempts.
125
+ * Per-logical-run call, byte and subagent-usage budget, shared across router
126
+ * attempts. `usage` rides the same entry so delegated spend inherits the
127
+ * existing per-run keying and eviction instead of needing a second store.
106
128
  * @param {*} subagents The run-scoped options object, stable across attempts.
107
129
  * @param {string|undefined} parentRunId
108
130
  */
@@ -120,11 +142,58 @@ function budgetForRun(subagents, parentRunId) {
120
142
  if (oldest.done) break;
121
143
  store.delete(oldest.value);
122
144
  }
123
- const fresh = { total: 0, bytes: 0, warnedQueued: false };
145
+ const fresh = { total: 0, bytes: 0, warnedQueued: false, usage: emptyUsage() };
124
146
  store.set(key, fresh);
125
147
  return fresh;
126
148
  }
127
149
 
150
+ /**
151
+ * How many subagents this logical run actually spawned.
152
+ *
153
+ * A read-only accessor so a provider can report `subagent_invoked` truthfully
154
+ * without reaching into `__budgets`, which is a deliberately private,
155
+ * non-enumerable implementation detail. Returns 0 when nothing was ever
156
+ * registered — a run with no `Agent` tool never creates a budget entry, and that
157
+ * is indistinguishable from one that had the tool and never used it, which is
158
+ * exactly what "no subagent was invoked" means for this signal.
159
+ *
160
+ * @param {*} subagents The run-scoped options object, or undefined.
161
+ * @param {string|undefined} parentRunId
162
+ * @returns {number}
163
+ */
164
+ export function subagentInvocationCount(subagents, parentRunId) {
165
+ const store = subagents?.__budgets;
166
+ if (!(store instanceof Map)) return 0;
167
+ const entry = store.get(parentRunId ?? "unkeyed");
168
+ return Number.isInteger(entry?.total) ? entry.total : 0;
169
+ }
170
+
171
+ /**
172
+ * What this logical run's subagents spent, summed across every delegation.
173
+ *
174
+ * A delegation is work the run asked for, so its cost belongs to the run's
175
+ * total — a provider folds this into its own usage before reporting, which is
176
+ * what makes the console's cost, the TUI status bar and the exported metrics
177
+ * agree with the bill. Same read-only-accessor contract as
178
+ * `subagentInvocationCount`: `__budgets` stays private. All zeroes when nothing
179
+ * delegated, which is the truthful answer for a run that never used the tool.
180
+ *
181
+ * @param {*} subagents The run-scoped options object, or undefined.
182
+ * @param {string|undefined} parentRunId
183
+ * @returns {{costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}}
184
+ */
185
+ export function subagentUsageForRun(subagents, parentRunId) {
186
+ const store = subagents?.__budgets;
187
+ const usage = store instanceof Map ? store.get(parentRunId ?? "unkeyed")?.usage : undefined;
188
+ return {
189
+ costUsd: numberOrZero(usage?.costUsd),
190
+ input: numberOrZero(usage?.input),
191
+ output: numberOrZero(usage?.output),
192
+ cacheRead: numberOrZero(usage?.cacheRead),
193
+ cacheWrite: numberOrZero(usage?.cacheWrite),
194
+ };
195
+ }
196
+
128
197
  /** @param {*} value @param {number} fallback @returns {number} */
129
198
  function positiveInt(value, fallback) {
130
199
  return Number.isInteger(value) && value > 0 ? value : fallback;
@@ -134,7 +203,7 @@ function positiveInt(value, fallback) {
134
203
  * Build the `Agent` tool, or null when subagents are unavailable for this run.
135
204
  *
136
205
  * @param {RuntimeSubagentsOptions|null|undefined} subagents
137
- * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, onEvent?: (event: *) => void}} [context]
206
+ * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, toolEnvironment?: *, onEvent?: (event: *) => void}} [context]
138
207
  * @returns {*|null}
139
208
  */
140
209
  export function createAgentTool(subagents, context = {}) {
@@ -306,6 +375,15 @@ export function createAgentTool(subagents, context = {}) {
306
375
  callIndex,
307
376
  ...(params.description === undefined ? {} : { label: params.description }),
308
377
  ...(context.onEvent === undefined ? {} : { emit: context.onEvent }),
378
+ // Summed, not replaced: a turn can delegate a dozen times and the run
379
+ // owns all of it. Lives on the run budget so router attempts share it.
380
+ recordUsage: (spent) => {
381
+ budget.usage.costUsd += spent.costUsd;
382
+ budget.usage.input += spent.input;
383
+ budget.usage.output += spent.output;
384
+ budget.usage.cacheRead += spent.cacheRead;
385
+ budget.usage.cacheWrite += spent.cacheWrite;
386
+ },
309
387
  });
310
388
  collector.started();
311
389
  const startedAt = Date.now();
@@ -326,6 +404,12 @@ export function createAgentTool(subagents, context = {}) {
326
404
  // Inherited, never widened: a profile cannot loosen confinement.
327
405
  ...(context.sandboxPolicy === undefined ? {} : { sandboxPolicy: context.sandboxPolicy }),
328
406
  ...(context.sandboxEngine === undefined ? {} : { sandboxEngine: context.sandboxEngine }),
407
+ // The parent's disclosed skills. Offered, not imposed — the host's
408
+ // `run` decides whether this child may have them, since only it knows
409
+ // the child's resolved route and deny lists.
410
+ ...(context.skills === undefined ? {} : { skills: context.skills }),
411
+ ...(context.skillsRoot === undefined ? {} : { skillsRoot: context.skillsRoot }),
412
+ ...(context.toolEnvironment === undefined ? {} : { toolEnvironment: context.toolEnvironment }),
329
413
  abortSignal: controller.signal,
330
414
  maxTurns,
331
415
  callId: toolCallId,
@@ -518,13 +602,15 @@ const WIRE_CONTENT_MAX_CHARS = 2_000;
518
602
  * answer body, so forwarding them would splice a subagent's prose into the
519
603
  * main agent's reply. Its text reaches the parent through the tool result.
520
604
  *
521
- * @param {{callId: string, profileName: string, callIndex: number, label?: string, emit?: (event: *) => void}} options
605
+ * @param {{callId: string, profileName: string, callIndex: number, label?: string, emit?: (event: *) => void, recordUsage?: (usage: {costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}) => void}} options
522
606
  */
523
- function createActivityCollector({ callId, profileName, callIndex, label, emit }) {
607
+ function createActivityCollector({ callId, profileName, callIndex, label, emit, recordUsage }) {
524
608
  /** @type {Map<string, {name: string, args: unknown, startedAt: number, ms?: number}>} */
525
609
  const open = new Map();
526
610
  /** @type {Array<{name: string, args: unknown, ms?: number, isError: boolean}>} */
527
611
  const done = [];
612
+ /** What the child reported spending, so the parent run can own it. */
613
+ const usage = emptyUsage();
528
614
  const subagent = { id: callId, name: profileName, callIndex, ...(label === undefined ? {} : { label }) };
529
615
 
530
616
  /** @param {*} event */
@@ -550,10 +636,17 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit }
550
636
  },
551
637
  /** @param {{status: string, durationMs: number}} outcome */
552
638
  finished({ status, durationMs }) {
639
+ // Before the bookend, so the run's own usage report can already include
640
+ // it, and so an abandoned child still hands over whatever it spent.
641
+ recordUsage?.(usage);
553
642
  publish({
554
643
  phase: "agent_completed",
555
644
  id: `agent:${callId}`,
556
645
  name: `Agent(${profileName})`,
646
+ // The one place the child's price is knowable per delegation: operator
647
+ // surfaces show it on the row so an expensive one is identifiable, not
648
+ // just visible in the run total it disappears into.
649
+ ...(usage.costUsd > 0 ? { subagent: { ...subagent, costUsd: usage.costUsd } } : {}),
557
650
  isError: status !== "ok",
558
651
  executionMs: durationMs,
559
652
  content: `${status} · ${done.length} tool call${done.length === 1 ? "" : "s"}`,
@@ -618,8 +711,25 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit }
618
711
  }
619
712
  return;
620
713
  }
714
+ if (type === "cost_accumulated") {
715
+ // Read as a running total, not a delta — the same rule
716
+ // `ai/observer.js` applies to the parent's own events, because a bridge
717
+ // emits one of these per completed provider run carrying that run's
718
+ // totals. Keeping one rule means a child that failed over undercounts
719
+ // exactly as its parent does today rather than inventing a second.
720
+ // Not republished on the parent stream: consumers treat `usage_update`
721
+ // as the run's cumulative figure, and a child's smaller total arriving
722
+ // last would read as the run getting cheaper.
723
+ const tokens = event.tokens && typeof event.tokens === "object" ? event.tokens : {};
724
+ usage.costUsd = numberOrZero(event.cumulativeUsd);
725
+ usage.input = numberOrZero(tokens.input);
726
+ usage.output = numberOrZero(tokens.output);
727
+ usage.cacheRead = numberOrZero(tokens.cacheReadTokens);
728
+ usage.cacheWrite = numberOrZero(tokens.cacheCreationTokens);
729
+ return;
730
+ }
621
731
  // Child warnings are worth surfacing; everything else (context usage,
622
- // partial tool output, cost) stays inside the subagent for now.
732
+ // partial tool output) stays inside the subagent for now.
623
733
  if (type === "runtime_warning" && emit !== undefined) {
624
734
  try {
625
735
  emit({ ...event, subagentId: callId });
@@ -15,7 +15,7 @@ import {
15
15
  runPreparedProcess,
16
16
  } from "./shared/process-runner.js";
17
17
  import { readToolRuntime } from "./shared/runtime-context.js";
18
- import { resolveSandboxPolicy } from "./shared/tool-context.js";
18
+ import { requestToolProcessEnvironment, resolveSandboxPolicy } from "./shared/tool-context.js";
19
19
 
20
20
  const DEFAULT_BASH_TIMEOUT_MS = 120_000;
21
21
  const BASH_STARTUP_ENV_KEYS = new Set([
@@ -120,7 +120,7 @@ export async function bashToolRun(
120
120
  command: "/bin/bash",
121
121
  args: ["--noprofile", "--norc", "-c", command],
122
122
  cwd,
123
- env: cleanBashEnvironment(),
123
+ env: requestToolProcessEnvironment(resolvedCtx, cleanBashEnvironment()),
124
124
  },
125
125
  });
126
126
  } catch (error) {
@@ -16,7 +16,7 @@ import {
16
16
  runPreparedProcess,
17
17
  } from "./shared/process-runner.js";
18
18
  import { readToolRuntime } from "./shared/runtime-context.js";
19
- import { resolveSandboxPolicy } from "./shared/tool-context.js";
19
+ import { requestToolProcessEnvironment, resolveSandboxPolicy } from "./shared/tool-context.js";
20
20
 
21
21
  const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
22
22
  const MAX_EXEC_ARGS = 256;
@@ -75,6 +75,7 @@ export async function execToolRun(
75
75
  const maxChars = positiveInteger(max_output_chars, DEFAULT_MAX_BASH_OUTPUT_CHARS);
76
76
  let prepared;
77
77
  try {
78
+ const requestEnvironment = requestToolProcessEnvironment(resolvedCtx);
78
79
  prepared = await sandbox.prepareCommand({
79
80
  policy,
80
81
  engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
@@ -82,6 +83,7 @@ export async function execToolRun(
82
83
  command: executable,
83
84
  args: [...args],
84
85
  cwd,
86
+ ...(requestEnvironment === undefined ? {} : { env: requestEnvironment }),
85
87
  },
86
88
  });
87
89
  } catch (error) {
@@ -38,6 +38,8 @@
38
38
 
39
39
  // @ts-check
40
40
 
41
+ import { delimiter } from "node:path";
42
+
41
43
  import { passthroughSandbox } from "../../sandbox-seam.js";
42
44
  import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-brand.js";
43
45
 
@@ -58,6 +60,7 @@ import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-bra
58
60
  * @property {RuntimeSandboxEngine} [sandboxEngine]
59
61
  * @property {RuntimeSandbox} sandbox
60
62
  * @property {RuntimeBrand} runtimeBrand
63
+ * @property {{schema: 1, values: Readonly<Record<string, string>>, pathPrepend?: readonly string[]}} [toolEnvironment]
61
64
  */
62
65
 
63
66
  // The data keys (everything except the always-resolved runtimeBrand). A fixed
@@ -155,3 +158,22 @@ export function resolveSandboxPolicy(ctx, requestPolicy = undefined) {
155
158
  const merged = sandbox.mergePolicies(ctx?.sandboxPolicy ?? undefined, requestPolicy ?? undefined);
156
159
  return merged && merged.mode !== "off" ? merged : undefined;
157
160
  }
161
+
162
+ /**
163
+ * Compose the process environment overlay for Bash/Exec. The caller-supplied
164
+ * base carries each tool's own hardening values; request values are applied
165
+ * only at this final process boundary. No global environment is mutated.
166
+ *
167
+ * @param {ToolContext|undefined} ctx
168
+ * @param {Record<string, string|undefined>} [base]
169
+ * @returns {Record<string, string|undefined>|undefined}
170
+ */
171
+ export function requestToolProcessEnvironment(ctx, base = undefined) {
172
+ const request = ctx?.toolEnvironment;
173
+ if (request === undefined) return base;
174
+ const env = { ...(base ?? {}), ...request.values };
175
+ if (Array.isArray(request.pathPrepend) && request.pathPrepend.length > 0) {
176
+ env.PATH = [...request.pathPrepend, process.env.PATH].filter(Boolean).join(delimiter);
177
+ }
178
+ return env;
179
+ }
@@ -3,7 +3,7 @@
3
3
  // directly so the runtime's known-good Pi version remains authoritative.
4
4
 
5
5
  import { getBuiltinModel, getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
6
- import { getOAuthApiKey, getOAuthProvider } from "@earendil-works/pi-ai/oauth";
6
+ import { getPiOAuthAuth, resolveOAuthApiKey, toAuthInteraction } from "./pi-oauth-compat.js";
7
7
  import { reasoningLevelsForPiModel as resolveReasoningLevels } from "./providers/pi-models.js";
8
8
 
9
9
  /**
@@ -122,7 +122,7 @@ export function reasoningLevelsForPiModel(model) {
122
122
  * @returns {Promise<{apiKey: string, newCredentials: PiOAuthCredentialsSnapshot}|null>}
123
123
  */
124
124
  export async function resolvePiOAuthApiKey(providerId, credentials) {
125
- const result = await getOAuthApiKey(
125
+ const result = await resolveOAuthApiKey(
126
126
  providerId,
127
127
  /** @type {any} */ (cloneInteropValue(credentials)),
128
128
  );
@@ -142,8 +142,8 @@ export async function resolvePiOAuthApiKey(providerId, credentials) {
142
142
  * @returns {Promise<PiOAuthCredentialsSnapshot>}
143
143
  */
144
144
  export async function loginPiOAuth(providerId, callbacks) {
145
- const provider = getOAuthProvider(providerId);
146
- if (!provider || typeof provider.login !== "function") {
145
+ const oauth = getPiOAuthAuth(providerId);
146
+ if (!oauth || typeof oauth.login !== "function") {
147
147
  throw new Error(`Pi OAuth provider is unavailable: ${providerId}`);
148
148
  }
149
149
  for (const callbackName of ["onAuth", "onDeviceCode", "onPrompt", "onSelect"]) {
@@ -151,6 +151,8 @@ export async function loginPiOAuth(providerId, callbacks) {
151
151
  throw new TypeError(`loginPiOAuth requires callbacks.${callbackName}()`);
152
152
  }
153
153
  }
154
- const credentials = await provider.login(/** @type {any} */ ({ ...callbacks }));
154
+ const credentials = await oauth.login(
155
+ toAuthInteraction(/** @type {any} */ ({ ...callbacks })),
156
+ );
155
157
  return cloneInteropValue(credentials);
156
158
  }
@@ -0,0 +1,193 @@
1
+ // @ts-check
2
+
3
+ // Adapter over pi-ai's provider-owned OAuth surface.
4
+ //
5
+ // pi-ai 0.83.0 removed the generic OAuth registry — `getOAuthApiKey`,
6
+ // `getOAuthProvider` and `getOAuthProviders` are gone, and the
7
+ // `@earendil-works/pi-ai/oauth` entry point is now type-only (`export {}` at
8
+ // runtime). The per-provider implementations (`anthropicOAuth`, …) live under
9
+ // `dist/auth/oauth/*`, which has no entry in the package's `exports` map, so
10
+ // they cannot be imported. The supported surface is `provider.auth.oauth`,
11
+ // reached through the provider factories.
12
+ //
13
+ // mono-agent resolves providers dynamically from `pi:<provider>:<model>`, so it
14
+ // needs a lookup by id — this module rebuilds that over `builtinProviders()` and
15
+ // preserves the old call contracts exactly, keeping the migration confined here.
16
+ //
17
+ // Deliberately NOT using `createModels({credentials})`: that hands credential
18
+ // locking and persistence to pi, while mono-agent already owns `auth.json`
19
+ // through `pi-auth.js` (serialized writes, atomic 0600 rename). `refresh()` and
20
+ // `toAuth()` are callable directly, so the pure caller-persists contract stays.
21
+
22
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
23
+
24
+ /**
25
+ * @typedef {import("@earendil-works/pi-ai").OAuthAuth} OAuthAuth
26
+ * @typedef {import("@earendil-works/pi-ai").OAuthCredential} OAuthCredential
27
+ * @typedef {import("@earendil-works/pi-ai").AuthInteraction} AuthInteraction
28
+ * @typedef {import("@earendil-works/pi-ai").AuthPrompt} AuthPrompt
29
+ * @typedef {import("@earendil-works/pi-ai").AuthEvent} AuthEvent
30
+ * @typedef {import("@earendil-works/pi-ai/oauth").OAuthLoginCallbacks} OAuthLoginCallbacks
31
+ */
32
+
33
+ /** @type {Map<string, import("@earendil-works/pi-ai").Provider>|undefined} */
34
+ let providerIndexCache;
35
+
36
+ /**
37
+ * `builtinProviders()` freshly constructs every provider (~37 objects) on each
38
+ * call, and `pi-auth.js` sits on the per-request credential path. The catalog is
39
+ * static for the process lifetime, so index it once.
40
+ *
41
+ * @returns {Map<string, import("@earendil-works/pi-ai").Provider>}
42
+ */
43
+ function providerIndex() {
44
+ if (providerIndexCache === undefined) {
45
+ providerIndexCache = new Map(builtinProviders().map((provider) => [provider.id, provider]));
46
+ }
47
+ return providerIndexCache;
48
+ }
49
+
50
+ /** @internal Exported only so tests can force a rebuild of the memoized index. */
51
+ export function resetPiProviderIndexForTests() {
52
+ providerIndexCache = undefined;
53
+ }
54
+
55
+ /**
56
+ * The OAuth implementation for a Pi provider id, or undefined when the provider
57
+ * is unknown or supports only API-key auth (e.g. `opencode-go`).
58
+ *
59
+ * @param {string} providerId
60
+ * @returns {OAuthAuth|undefined}
61
+ */
62
+ export function getPiOAuthAuth(providerId) {
63
+ if (typeof providerId !== "string" || providerId.length === 0) return undefined;
64
+ return providerIndex().get(providerId)?.auth?.oauth;
65
+ }
66
+
67
+ /**
68
+ * Every Pi provider id that supports OAuth. Replaces
69
+ * `getOAuthProviders().map((provider) => provider.id)`.
70
+ *
71
+ * @returns {string[]}
72
+ */
73
+ export function getPiOAuthProviderIds() {
74
+ const ids = [];
75
+ for (const [id, provider] of providerIndex()) {
76
+ if (provider.auth?.oauth !== undefined) ids.push(id);
77
+ }
78
+ return ids;
79
+ }
80
+
81
+ /**
82
+ * Resolve an API key from stored OAuth credentials, refreshing first when the
83
+ * token has expired.
84
+ *
85
+ * Reproduces pi-ai 0.80.6's `getOAuthApiKey(providerId, credentials)` contract
86
+ * so its call sites keep their shape: takes the whole provider-keyed credential
87
+ * map, returns `null` when this provider has no stored credential, and is
88
+ * *pure* — the refreshed credential comes back as `newCredentials` for the
89
+ * caller to persist rather than being written here.
90
+ *
91
+ * The refresh trigger is deliberately the old exact-expiry check. pi's own
92
+ * `Models.getAuth()` refreshes five minutes ahead of expiry; matching that would
93
+ * change live token rotation timing, which this migration does not intend.
94
+ *
95
+ * @param {string} providerId
96
+ * @param {Record<string, *>|undefined} credentials Provider-keyed credential map.
97
+ * @returns {Promise<{newCredentials: OAuthCredential, apiKey: string|undefined}|null>}
98
+ */
99
+ export async function resolveOAuthApiKey(providerId, credentials) {
100
+ const oauth = getPiOAuthAuth(providerId);
101
+ if (oauth === undefined) {
102
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
103
+ }
104
+
105
+ const stored = credentials?.[providerId];
106
+ if (stored === undefined || stored === null) return null;
107
+
108
+ // 0.83.0's OAuthCredential carries a `type: "oauth"` discriminant that the
109
+ // 0.80.6 shape did not. Stored entries written by mono-agent already have it;
110
+ // tag defensively so hand-edited or externally written files still work.
111
+ let credential = /** @type {OAuthCredential} */ ({ ...stored, type: "oauth" });
112
+
113
+ if (Date.now() >= credential.expires) {
114
+ try {
115
+ credential = await oauth.refresh(credential);
116
+ } catch {
117
+ throw new Error(`Failed to refresh OAuth token for ${providerId}`);
118
+ }
119
+ }
120
+
121
+ // `toAuth()` also derives a per-credential baseUrl (GitHub Copilot's
122
+ // per-account proxy). 0.80.6 discarded it and callers here have no field for
123
+ // it, so it stays dropped — behaviour preserved, worth revisiting separately.
124
+ const auth = await oauth.toAuth(credential);
125
+ return { newCredentials: credential, apiKey: auth?.apiKey };
126
+ }
127
+
128
+ /**
129
+ * Bridge the legacy six-callback OAuth surface onto 0.83.0's single
130
+ * `prompt`/`notify` pair.
131
+ *
132
+ * `manual_code` must stay wired to `onManualCodeInput`: Anthropic races its
133
+ * localhost callback against a pasted redirect URL, and that path is the reason
134
+ * `agent-app`'s `runPiOAuthLogin` exists at all.
135
+ *
136
+ * @param {OAuthLoginCallbacks} callbacks
137
+ * @returns {AuthInteraction}
138
+ */
139
+ export function toAuthInteraction(callbacks) {
140
+ if (callbacks === null || typeof callbacks !== "object") {
141
+ throw new TypeError("toAuthInteraction requires an OAuthLoginCallbacks object");
142
+ }
143
+
144
+ return {
145
+ ...(callbacks.signal === undefined ? {} : { signal: callbacks.signal }),
146
+
147
+ /** @param {AuthPrompt} prompt */
148
+ async prompt(prompt) {
149
+ if (prompt.type === "select") {
150
+ const selected = await callbacks.onSelect({
151
+ message: prompt.message,
152
+ options: prompt.options.map((option) => ({ id: option.id, label: option.label })),
153
+ });
154
+ // The legacy callback resolves undefined when the user cancels; the new
155
+ // contract requires a string and rejects on cancel.
156
+ if (typeof selected !== "string") {
157
+ throw new Error("OAuth provider selection was cancelled.");
158
+ }
159
+ return selected;
160
+ }
161
+
162
+ if (prompt.type === "manual_code" && typeof callbacks.onManualCodeInput === "function") {
163
+ return await callbacks.onManualCodeInput();
164
+ }
165
+
166
+ return await callbacks.onPrompt({
167
+ message: prompt.message,
168
+ ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
169
+ });
170
+ },
171
+
172
+ /** @param {AuthEvent} event */
173
+ notify(event) {
174
+ if (event.type === "auth_url") {
175
+ callbacks.onAuth({
176
+ url: event.url,
177
+ ...(event.instructions === undefined ? {} : { instructions: event.instructions }),
178
+ });
179
+ return;
180
+ }
181
+ if (event.type === "device_code") {
182
+ callbacks.onDeviceCode({
183
+ userCode: event.userCode,
184
+ verificationUri: event.verificationUri,
185
+ ...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }),
186
+ ...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }),
187
+ });
188
+ return;
189
+ }
190
+ callbacks.onProgress?.(event.message);
191
+ },
192
+ };
193
+ }