@erichll/pi-sandbox 0.6.2 → 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 +27 -0
- package/README.md +12 -3
- package/package.json +11 -5
- package/src/external-runs-view.ts +173 -0
- package/src/external-supervisor.ts +17 -0
- package/src/index.ts +56 -9
- package/src/subagent.ts +97 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
3
30
|
## 0.6.2 - 2026-08-13
|
|
4
31
|
|
|
5
32
|
- Bump `@erichll/pi-auto-review` to `0.3.5` so consumers inherit the raised
|
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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -54,13 +54,19 @@
|
|
|
54
54
|
"@erichll/pi-auto-review": "0.3.5"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@earendil-works/pi-coding-agent": "
|
|
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.
|
|
62
|
-
"@types/node": "^
|
|
63
|
-
"pi-subagents": "
|
|
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 =
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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 (
|