@erichll/pi-sandbox 0.6.1 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-08-17
4
+
5
+ - **Builtin `subagent` model fail-fast.** An explicit `model` on `start`/
6
+ `handoff` is now validated against the host model registry before a child Pi
7
+ spawns. Unknown models fail before any session/broker/process is created;
8
+ ambiguous bare ids are rejected with a `provider/model` hint; a trailing
9
+ `:thinking` suffix is preserved; `status`/`wait`/`stop`/`follow_up` are never
10
+ intercepted.
11
+
12
+ - **FleetView external-worker display.** Declare `pi-subagents >=0.50.0` as an
13
+ **optional** peer dependency (no forced install for builtin/off users) and
14
+ mirror pi-sandbox's isolated external workers into pi-subagents' FleetView
15
+ (caller-owned `external-runs`) whenever `subagents.provider ===
16
+ "pi-subagents"` AND `externalWorkerIsolation === "enforce"`. Loading uses a
17
+ dynamic import only; import/registry failures disable the display path
18
+ without weakening fail-closed isolation or network approval. v1 records are
19
+ `running`-only and cleaned up on worker unregister, session shutdown, and
20
+ supervisor replacement.
21
+
22
+ - **`pi-subagents` capability baseline raised to `0.50.0`** (test/CI only;
23
+ does not affect the published runtime). The dev dependency is pinned exactly
24
+ to `0.50.0`, the capability boundary is redocumented, the compatibility-gate
25
+ entry resolves through the package `exports["."]` via `import.meta.resolve`,
26
+ and the external-isolation probe is generalized. Verified by
27
+ `gate:external-isolation` and the model-backed `gate:pi-subagents`
28
+ (`PASS`, `piSubagents: 0.50.0`).
29
+
30
+ ## 0.6.2 - 2026-08-13
31
+
32
+ - Bump `@erichll/pi-auto-review` to `0.3.5` so consumers inherit the raised
33
+ default model-review timeout (90s) and retries (2) through the broker.
34
+ - Update the documented `pi-subagents` capability boundary to `0.45.2` and relax
35
+ the dev dependency to `^0.47.1` (test/CI only; does not affect the published
36
+ runtime).
37
+
3
38
  ## 0.6.1 - 2026-08-09
4
39
 
5
40
  - Upgrade `@anthropic-ai/sandbox-runtime` from `0.0.67` to `0.0.71` (path
package/README.md CHANGED
@@ -82,12 +82,16 @@ Supported modes:
82
82
  itself is not wrapped while `externalWorkerIsolation` is `off`.
83
83
  - `off`: protect Bash only.
84
84
 
85
- ### `pi-subagents` 0.44.0 capability boundary
85
+ ### `pi-subagents` 0.50.0 capability boundary
86
86
 
87
87
  `pi-sandbox` verifies the following combination in its test suite and release
88
- gate:
88
+ gate. This version boundary is pinned exactly (see the package
89
+ `devDependencies`) and additionally exercised against the latest published
90
+ `pi-subagents` on a nightly CI schedule. The `PI_SUBAGENT_PI_BINARY` seam
91
+ (`externalWorkerIsolation: "enforce"`) is re-checked deterministically by the
92
+ `gate:external-isolation` probe on the pinned and latest dependency.
89
93
 
90
- | Capability | `builtin` | `pi-subagents` 0.44.0 |
94
+ | Capability | `builtin` | `pi-subagents` 0.50.0 |
91
95
  | --- | --- | --- |
92
96
  | Outer worker sandbox | Yes | Opt-in (`externalWorkerIsolation: "enforce"`) |
93
97
  | Bash sandbox | Yes | Yes |
@@ -95,6 +99,11 @@ gate:
95
99
  | `workflowScript`, missions, schedules | No | Yes |
96
100
  | permission-system parent forwarding | Not required | Verified by compatibility gate |
97
101
 
102
+ Upgrading across a capability boundary (for example `0.49.0` → `0.50.0`)
103
+ requires re-running `npm run gate:external-isolation` and, before a release,
104
+ the model-backed `npm run gate:pi-subagents`. See `docs/compat-notes.md` for
105
+ the recorded compatibility seams and how each is verified on upgrade.
106
+
98
107
  For the external provider, `off` leaves the worker process outside the outer
99
108
  Sandbox Runtime boundary. Do not treat inherited Bash sandboxing as complete
100
109
  worker isolation unless the trusted global configuration explicitly enables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-sandbox",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Sandbox Runtime-backed Linux and macOS sandbox with pi-auto-review approval",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -51,16 +51,22 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/sandbox-runtime": "0.0.71",
54
- "@erichll/pi-auto-review": "0.3.4"
54
+ "@erichll/pi-auto-review": "0.3.5"
55
55
  },
56
56
  "peerDependencies": {
57
- "@earendil-works/pi-coding-agent": ">=0.82.1",
57
+ "@earendil-works/pi-coding-agent": "^0.84.1",
58
+ "pi-subagents": ">=0.50.0",
58
59
  "typebox": ">=1.0.0"
59
60
  },
61
+ "peerDependenciesMeta": {
62
+ "pi-subagents": {
63
+ "optional": true
64
+ }
65
+ },
60
66
  "devDependencies": {
61
- "@earendil-works/pi-coding-agent": "0.83.0",
62
- "@types/node": "^22.15.3",
63
- "pi-subagents": "0.44.0",
67
+ "@earendil-works/pi-coding-agent": "^0.84.1",
68
+ "@types/node": "^26.2.0",
69
+ "pi-subagents": "0.50.0",
64
70
  "typescript": "^6.0.3"
65
71
  }
66
72
  }
@@ -0,0 +1,173 @@
1
+ // Optional FleetView integration for pi-sandbox's isolated external workers.
2
+ //
3
+ // When the host is running the external `pi-subagents` provider with
4
+ // `externalWorkerIsolation: "enforce"`, pi-sandbox supervises the external
5
+ // worker process trees itself. This module mirrors those supervised workers
6
+ // into pi-subagents' caller-owned external-run registry so they show up in the
7
+ // FleetView (pi-subagents >= 0.50.0).
8
+ //
9
+ // This is a pure observability enhancement: it participates in NO sandbox
10
+ // allow/deny decision, and nothing here can downgrade an isolated worker into
11
+ // host execution. `pi-subagents/external-runs` is loaded via a DYNAMIC import
12
+ // only (never a top-level static import), because pi-subagents is an OPTIONAL
13
+ // peer dependency: builtin/off deployments may not have it installed.
14
+ //
15
+ // Deliberate scope of v1: records are "running" only. No completed/failed/
16
+ // endedAt are fabricated because neither the launcher nor the supervisor
17
+ // protocol currently reports process exit evidence to this extension (the
18
+ // record would therefore be a lie). See docs/compat-notes.md seam 5.
19
+
20
+ type ExternalRunState = "running";
21
+
22
+ /** Structural view of the specific pi-subagents API we consume. */
23
+ type ExternalRunsApi = {
24
+ registerExternalRun(input: {
25
+ id: string;
26
+ sessionId: string;
27
+ source: string;
28
+ label: string;
29
+ state: ExternalRunState;
30
+ startedAt: number;
31
+ preview?: string;
32
+ }): unknown;
33
+ unregisterExternalRun(sessionId: string, id: string): boolean;
34
+ };
35
+
36
+ export type ExternalRunsView = {
37
+ /** Whether the pi-subagents runtime is reachable (dynamic import succeeded). */
38
+ readonly enabled: boolean;
39
+ /** Diagnostic string when the runtime is disabled. */
40
+ readonly error?: string;
41
+ /** Mirror a supervisor lifecycle.registered event into the registry. */
42
+ registered(worker: { id: string; cwd: string; startedAt: number }): void;
43
+ /** Mirror a supervisor lifecycle.unregistered event out of the registry. */
44
+ unregistered(workerId: string): void;
45
+ /** Unregister every run this view registered (session shutdown / replace). */
46
+ close(): void;
47
+ };
48
+
49
+ const VIEW_LABEL = "isolated pi-subagents worker";
50
+ const VIEW_SOURCE = "pi-sandbox";
51
+
52
+ /**
53
+ * Whether the FleetView integration should be enabled for a given provider /
54
+ * isolation combination. It is enabled ONLY when the external pi-subagents
55
+ * provider owns orchestration AND outer worker isolation is enforced -- the
56
+ * sole case where pi-sandbox actually supervises external workers and can
57
+ * truthfully vouch for a "running" record it owns.
58
+ */
59
+ export function externalRunsViewEnabledWhen(
60
+ provider: string,
61
+ externalWorkerIsolation: string,
62
+ ): boolean {
63
+ return provider === "pi-subagents" && externalWorkerIsolation === "enforce";
64
+ }
65
+
66
+ function ident(value: string): { ok: true; value: string } | { ok: false; error: string } {
67
+ const trimmed = value.trim();
68
+ if (!trimmed || trimmed !== value) {
69
+ return { ok: false, error: "must be a non-empty trimmed string" };
70
+ }
71
+ return { ok: true, value: trimmed };
72
+ }
73
+
74
+ export function createExternalRunsView(
75
+ sessionIdInput: string,
76
+ load: () => Promise<unknown> = () => import("pi-subagents/external-runs"),
77
+ ): Promise<ExternalRunsView> {
78
+ return (async () => {
79
+ const sessionId = ident(sessionIdInput);
80
+ if (!sessionId.ok) {
81
+ return disabledView(`invalid session id: ${sessionId.error}`);
82
+ }
83
+ let api: ExternalRunsApi | undefined;
84
+ let loadError: string | undefined;
85
+ try {
86
+ const mod = (await load()) as Record<string, unknown>;
87
+ const registerExternalRun = mod.registerExternalRun;
88
+ const unregisterExternalRun = mod.unregisterExternalRun;
89
+ if (typeof registerExternalRun !== "function" || typeof unregisterExternalRun !== "function") {
90
+ throw new Error("pi-subagents/external-runs does not expose registerExternalRun/unregisterExternalRun");
91
+ }
92
+ api = { registerExternalRun, unregisterExternalRun } as unknown as ExternalRunsApi;
93
+ } catch (error) {
94
+ loadError = `FleetView external-run registration unavailable: ${error instanceof Error ? error.message : String(error)}`;
95
+ // Never thrown: disable only the display path. Sandbox fail-closed and
96
+ // network approval for the supervisor are untouched.
97
+ return disabledView(loadError);
98
+ }
99
+
100
+ // (sessionId, workerId) pairs this extension registered, so we never
101
+ // duplicate-register and never unregister records owned by other sources.
102
+ const registered = new Map<string, number>();
103
+
104
+ return {
105
+ get enabled() {
106
+ return Boolean(api);
107
+ },
108
+ error: loadError,
109
+ registered(worker) {
110
+ const fullId = ident(worker.id);
111
+ const cwd = ident(worker.cwd);
112
+ if (!api || !fullId.ok || !cwd.ok) return;
113
+ if (registered.has(fullId.value)) return; // avoid duplicate registration
114
+ try {
115
+ api!.registerExternalRun({
116
+ id: fullId.value,
117
+ sessionId: sessionId.value,
118
+ source: VIEW_SOURCE,
119
+ label: VIEW_LABEL,
120
+ state: "running",
121
+ startedAt:
122
+ Number.isSafeInteger(worker.startedAt) && worker.startedAt > 0
123
+ ? worker.startedAt
124
+ : Date.now(),
125
+ preview: cwd.value,
126
+ });
127
+ registered.set(fullId.value, Date.now());
128
+ } catch (error) {
129
+ // Registry full / malformed record / unknown fields: log, do not emit.
130
+ console.error(
131
+ `pi-sandbox: could not register external run for worker '${fullId.value}': ${error instanceof Error ? error.message : String(error)}`,
132
+ );
133
+ }
134
+ },
135
+ unregistered(workerId) {
136
+ const fullId = ident(workerId);
137
+ if (!fullId.ok) return;
138
+ if (!registered.has(fullId.value)) return; // we did not register it
139
+ registered.delete(fullId.value);
140
+ if (api) {
141
+ try {
142
+ api.unregisterExternalRun(sessionId.value, fullId.value);
143
+ } catch {
144
+ // Best-effort; nothing else to fall back to.
145
+ }
146
+ }
147
+ },
148
+ close() {
149
+ for (const workerId of registered.keys()) {
150
+ if (api) {
151
+ try {
152
+ api.unregisterExternalRun(sessionId.value, workerId);
153
+ } catch {
154
+ // best-effort
155
+ }
156
+ }
157
+ }
158
+ registered.clear();
159
+ },
160
+ };
161
+ })();
162
+ }
163
+
164
+ function disabledView(error: string): ExternalRunsView {
165
+ console.error(`pi-sandbox: ${error}`);
166
+ return {
167
+ enabled: false,
168
+ error,
169
+ registered() {},
170
+ unregistered() {},
171
+ close() {},
172
+ };
173
+ }
@@ -20,6 +20,13 @@ type Request = {
20
20
  port?: unknown;
21
21
  };
22
22
 
23
+ export type ExternalWorkerSupervisorLifecycle = {
24
+ /** Called immediately after a worker finishes the register protocol. */
25
+ registered?(worker: { id: string; cwd: string; startedAt: number }): void;
26
+ /** Called immediately after a worker completes the unregister protocol. */
27
+ unregistered?(worker: { id: string; cwd: string }): void;
28
+ };
29
+
23
30
  export type ExternalWorkerSupervisor = {
24
31
  socketPath: string;
25
32
  capability: string;
@@ -35,6 +42,7 @@ export type ExternalWorkerSupervisor = {
35
42
 
36
43
  export async function createExternalWorkerSupervisor(
37
44
  approvalContext: (worker: { id: string; cwd: string }) => TrapApprovalContext,
45
+ lifecycle: ExternalWorkerSupervisorLifecycle = {},
38
46
  ): Promise<ExternalWorkerSupervisor> {
39
47
  const directory = await mkdtemp(join(tmpdir(), "pi-sandbox-external-"));
40
48
  await chmod(directory, 0o700);
@@ -94,6 +102,11 @@ export async function createExternalWorkerSupervisor(
94
102
  requests: 0,
95
103
  });
96
104
  socket.write(`${JSON.stringify({ id: request.id, action: "allow" })}\n`);
105
+ lifecycle.registered?.({
106
+ id: request.workerId as string,
107
+ cwd: request.cwd as string,
108
+ startedAt: workers.get(request.workerId as string)!.startedAt,
109
+ });
97
110
  return;
98
111
  }
99
112
  const knownWorker =
@@ -106,6 +119,10 @@ export async function createExternalWorkerSupervisor(
106
119
  seen.add(request.id as string);
107
120
  knownWorker.state = "exited";
108
121
  socket.write(`${JSON.stringify({ id: request.id, action: "allow" })}\n`);
122
+ lifecycle.unregistered?.({
123
+ id: request.workerId as string,
124
+ cwd: request.cwd as string,
125
+ });
109
126
  return;
110
127
  }
111
128
  const workerCwd =
package/src/index.ts CHANGED
@@ -31,11 +31,17 @@ import type {
31
31
  ProcessBackedSubagentManager,
32
32
  ProcessBackedSubagentSession,
33
33
  } from "./subagent.ts";
34
+ import { validateSubagentModel } from "./subagent.ts";
34
35
  import { Type } from "typebox";
35
36
  import {
36
37
  createExternalWorkerSupervisor,
37
38
  type ExternalWorkerSupervisor,
38
39
  } from "./external-supervisor.ts";
40
+ import {
41
+ createExternalRunsView,
42
+ externalRunsViewEnabledWhen,
43
+ type ExternalRunsView,
44
+ } from "./external-runs-view.ts";
39
45
 
40
46
  const EXTENSION_NAME = "pi-sandbox";
41
47
  const registrations = new WeakMap<ExtensionAPI, Promise<void>>();
@@ -254,6 +260,10 @@ async function performRegistration(
254
260
  const subagentProvider =
255
261
  options.subagentProvider ?? config.subagents.provider;
256
262
  const externalWorkerIsolation = config.subagents.externalWorkerIsolation;
263
+ const externalRunsViewEnabled = externalRunsViewEnabledWhen(
264
+ subagentProvider,
265
+ externalWorkerIsolation,
266
+ );
257
267
  const additionalAllowRead = config.filesystem.additionalAllowRead;
258
268
  const hostIPC = options.hostIPC ?? config.hostIPC;
259
269
  const subagents =
@@ -268,6 +278,7 @@ async function performRegistration(
268
278
  const cwd = process.cwd();
269
279
  let isolation: ExternalWorkerIsolation | undefined;
270
280
  let supervisor: ExternalWorkerSupervisor | undefined;
281
+ let fleetView: ExternalRunsView | undefined;
271
282
  let externalIsolationFailure: string | undefined;
272
283
  const localBash = createBashToolDefinition(cwd);
273
284
 
@@ -474,6 +485,21 @@ async function performRegistration(
474
485
  : requireTask();
475
486
  const parentId =
476
487
  action === "handoff" ? requireSession().id : undefined;
488
+ // Fail fast on an invalid explicit model before spawning a child Pi.
489
+ // Only process-spawning actions (start/handoff) reach this block, so
490
+ // status/wait/stop/follow_up are never intercepted by model validation.
491
+ // When no model is specified the child inherits host behavior (no check).
492
+ const modelSpec = params.model?.trim();
493
+ if (modelSpec) {
494
+ const modelCheck = validateSubagentModel(
495
+ modelSpec,
496
+ ctx.modelRegistry?.getAvailable() ?? [],
497
+ ctx.model?.provider,
498
+ );
499
+ if (!modelCheck.ok) {
500
+ throw new Error(modelCheck.error);
501
+ }
502
+ }
477
503
  const session = await subagents.start(sessionOptions(task, parentId));
478
504
  const unsubscribe = update(session);
479
505
  try {
@@ -548,6 +574,10 @@ async function performRegistration(
548
574
  });
549
575
 
550
576
  pi.on("session_start", async (_event, ctx) => {
577
+ // A new session may replace a prior supervisor ownership; drop the old
578
+ // session's FleetView registrations before reconstructing.
579
+ fleetView?.close();
580
+ fleetView = undefined;
551
581
  if (
552
582
  subagentProvider === "pi-subagents" &&
553
583
  externalWorkerIsolation === "enforce"
@@ -555,15 +585,28 @@ async function performRegistration(
555
585
  try {
556
586
  isolation?.restore();
557
587
  await supervisor?.close();
558
- supervisor = await createExternalWorkerSupervisor((worker) => ({
559
- broker: getBoundaryBroker(),
560
- command: "external pi-subagents worker network request",
561
- cwd: worker.cwd,
562
- sessionId: sessionId(ctx),
563
- scopeKey: `${sessionId(ctx)}:turn:${currentTurn}`,
564
- agentName: `pi-subagents-worker:${worker.id}`,
565
- humanApproval: humanApproval(ctx),
566
- }));
588
+ supervisor = undefined;
589
+ if (externalRunsViewEnabled) {
590
+ fleetView = await createExternalRunsView(sessionId(ctx));
591
+ }
592
+ supervisor = await createExternalWorkerSupervisor(
593
+ (worker) => ({
594
+ broker: getBoundaryBroker(),
595
+ command: "external pi-subagents worker network request",
596
+ cwd: worker.cwd,
597
+ sessionId: sessionId(ctx),
598
+ scopeKey: `${sessionId(ctx)}:turn:${currentTurn}`,
599
+ agentName: `pi-subagents-worker:${worker.id}`,
600
+ humanApproval: humanApproval(ctx),
601
+ }),
602
+ {
603
+ // Observability-only: never influences allow/deny, and a failure
604
+ // here must not stop the supervisor from answering approval RPCs
605
+ // or make the worker launch on the host.
606
+ registered: (worker) => fleetView?.registered(worker),
607
+ unregistered: (worker) => fleetView?.unregistered(worker.id),
608
+ },
609
+ );
567
610
  isolation = enableExternalWorkerIsolation(supervisor);
568
611
  externalIsolationFailure = undefined;
569
612
  } catch (error) {
@@ -573,6 +616,8 @@ async function performRegistration(
573
616
  isolation = undefined;
574
617
  await supervisor?.close();
575
618
  supervisor = undefined;
619
+ fleetView?.close();
620
+ fleetView = undefined;
576
621
  console.error(message);
577
622
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
578
623
  }
@@ -622,6 +667,8 @@ async function performRegistration(
622
667
  pi.on("session_shutdown", async () => {
623
668
  isolation?.restore();
624
669
  isolation = undefined;
670
+ fleetView?.close();
671
+ fleetView = undefined;
625
672
  await supervisor?.close();
626
673
  supervisor = undefined;
627
674
  await subagents?.shutdown();
package/src/subagent.ts CHANGED
@@ -104,6 +104,103 @@ function shellQuote(value: string): string {
104
104
  return `'${value.replace(/'/g, `'\\''`)}'`;
105
105
  }
106
106
 
107
+ /**
108
+ * Minimal structural shape of a registry model used for validation. Kept as
109
+ * a structural type so this pure helper does not depend on the host coding
110
+ * agent's `Model` type.
111
+ */
112
+ export interface SubagentModelLike {
113
+ /** Bare model id, e.g. `claude-3-5-sonnet` (not provider-prefixed). */
114
+ id: string;
115
+ /** Provider id, e.g. `anthropic`. */
116
+ provider: string;
117
+ }
118
+
119
+ export type SubagentModelValidation =
120
+ | { ok: true; mode: "inherit" }
121
+ | { ok: true; mode: "explicit" }
122
+ | { ok: false; error: string };
123
+
124
+ // Mirrors pi's valid thinking levels (cli/args.ts VALID_THINKING_LEVELS).
125
+ const VALID_THINKING_LEVELS = new Set([
126
+ "off",
127
+ "minimal",
128
+ "low",
129
+ "medium",
130
+ "high",
131
+ "xhigh",
132
+ "max",
133
+ ]);
134
+
135
+ /**
136
+ * Split an optional trailing `:thinking` suffix off a model pattern. A suffix
137
+ * that is not a valid thinking level is treated as part of the model id (pi
138
+ * supports model ids containing colons, e.g. OpenRouter's `:exacto`).
139
+ */
140
+ export function splitModelThinking(
141
+ modelSpec: string,
142
+ ): { base: string; thinking?: string } {
143
+ const colon = modelSpec.lastIndexOf(":");
144
+ if (colon === -1) return { base: modelSpec };
145
+ const suffix = modelSpec.slice(colon + 1);
146
+ if (VALID_THINKING_LEVELS.has(suffix)) {
147
+ return { base: modelSpec.slice(0, colon), thinking: suffix };
148
+ }
149
+ return { base: modelSpec };
150
+ }
151
+
152
+ /**
153
+ * Fail-fast validation of an explicit subagent model before a child Pi spawns.
154
+ *
155
+ * Rules (see docs/compat-notes.md / Plan.md phase C2):
156
+ * - `undefined`/empty -> inherit host behavior, never validated.
157
+ * - `provider/model` -> requires an exact match on `${provider}/${modelId}`.
158
+ * - bare model id -> preferred provider first; otherwise accept only when
159
+ * globally unique; otherwise reject as ambiguous and ask for the full id.
160
+ * - a valid trailing `:thinking` suffix is stripped for base validation and
161
+ * preserved in the original pattern passed to the child.
162
+ *
163
+ * Only `start`/`handoff` (process-spawning actions) should call this.
164
+ */
165
+ export function validateSubagentModel(
166
+ modelSpec: string | undefined,
167
+ available: readonly SubagentModelLike[],
168
+ preferredProvider: string | undefined,
169
+ ): SubagentModelValidation {
170
+ const spec = (modelSpec ?? "").trim();
171
+ if (!spec) return { ok: true, mode: "inherit" };
172
+ const { base } = splitModelThinking(spec);
173
+
174
+ // Full `provider/model` reference: require an exact registry match.
175
+ const fullMatch = available.find((m) => `${m.provider}/${m.id}` === base);
176
+ if (fullMatch) return { ok: true, mode: "explicit" };
177
+
178
+ // Contains a `/`, so it was intended as a full provider/model reference that
179
+ // did not match any registered model. Do not fall back to bare matching.
180
+ if (base.includes("/")) {
181
+ return {
182
+ ok: false,
183
+ error: `subagent start requires an available model; unknown model '${base}'`, // eslint-disable-line
184
+ };
185
+ }
186
+
187
+ // Bare model id.
188
+ const matches = available.filter((m) => m.id === base);
189
+ if (matches.length === 0) {
190
+ return {
191
+ ok: false,
192
+ error: `subagent start requires an available model; unknown model '${base}'`, // eslint-disable-line
193
+ };
194
+ }
195
+ const preferred = matches.find((m) => m.provider === preferredProvider);
196
+ if (preferred) return { ok: true, mode: "explicit" };
197
+ if (matches.length === 1) return { ok: true, mode: "explicit" };
198
+ return {
199
+ ok: false,
200
+ error: `subagent start requires an unambiguous model; '${base}' is available under multiple providers (${matches.map((m) => m.provider).join(", ")}). Use provider/model or set PI_MODEL to disambiguate.`,
201
+ };
202
+ }
203
+
107
204
  export function resolvePiInvocation(): ProcessInvocation {
108
205
  const currentEntry = process.argv[1];
109
206
  if (