@cr1ms0n/pi-subagent 0.8.9 → 0.9.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/src/schema.ts CHANGED
@@ -45,21 +45,21 @@ const Action = Type.Union([
45
45
 
46
46
  /** Shared optional task configuration fields. */
47
47
  export const TaskFields = {
48
- backend: Type.Optional({ ...Backend, description: "Agent CLI powering the child. pi (default) supports every feature. codex uses codex exec with an OS-level read-only sandbox, but reports no cost (max_cost is refused) and cannot be steered. claude uses the claude CLI, reports cost, and cannot be steered. Unsupported combinations are refused with an explanation rather than silently ignored." }),
48
+ backend: Type.Optional({ ...Backend, description: "New Jev-routed work supports pi only. codex/claude are recognized for an actionable rejection; existing-run management stays available." }),
49
49
  agent: Type.Optional(Type.String({ minLength: 1, description: "Named agent to use (from .pi/agents/<name>.md). Supplies persona system prompt and defaults; explicit params still override." })),
50
50
  description: Type.Optional(Type.String({ description: "Short human label (3-5 words) shown in UIs and result indexes." })),
51
51
  system_prompt: Type.Optional(Type.String({ description: "Extra system prompt appended to the child's prompt (does not replace it)." })),
52
- model: Type.Optional(Type.String({ description: "**REQUIRED for every spawn call (task/tasks).** Exact model id from modelPolicy, in provider/model-id form. Calls without an explicit model are rejected; agent-file model, taskDefaults, and parent-session inheritance are ignored. Management actions (status/wait/cancel/steer/diff/apply/discard) do not need it." })),
53
- thinking: Type.Optional({ ...ThinkingLevel, description: "Opaque Pi thinking level for the child. Values such as max are passed through unchanged; Pi/model support decides validity. Defaults to agent thinking, profile taskDefaults.thinking, modelPolicy route thinking, then the parent's level." }),
54
- tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist. explore/review profiles reject project-writing tools; Pi context-management tools remain available for context continuity." })),
55
- profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review cannot write project files but retain Pi context-management tools; general inherits the parent's active tools and may write." }),
52
+ model: Type.Optional(Type.String({ description: "Legacy field: omit on new work. Jev chooses from the dedicated configured model list; explicit model is rejected rather than bypassing routing." })),
53
+ thinking: Type.Optional({ ...ThinkingLevel, description: "Opaque Pi thinking level for the child. Values such as max are passed through unchanged; Pi/model support decides validity. Defaults to agent thinking, profile taskDefaults.thinking, selected candidate thinking, then the parent's level." }),
54
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Optional candidate ceiling for Jev. Default candidates are all available locally permitted tools. Jev chooses individually; available Pi continuity controls are added locally even with tools:[]." })),
55
+ profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review cannot write project files but retain Pi context-management tools; general permits Jev to choose from the full locally available catalog and may write." }),
56
56
  cwd: Type.Optional(Type.String({ description: "Working directory for the child process." })),
57
- timeout_ms: Type.Optional(Type.Number({ minimum: 1, maximum: 24 * 60 * 60_000, description: "Total budget in milliseconds including queue time. Timed-out runs report which phase timed out." })),
57
+ timeout_ms: Type.Optional(Type.Number({ minimum: 1, maximum: 24 * 60 * 60_000, description: "Total budget in milliseconds including local preflight, Jev selection, setup, queue and retries. Timed-out runs report which phase timed out." })),
58
58
  max_turns: Type.Optional(Type.Number({ minimum: 1, maximum: 500, description: "Budget: at this many turns the child is steered to wrap up and given grace turns for a final answer; ends as 'partial' with output preserved." })),
59
- max_cost: Type.Optional(Type.Number({ minimum: 0, description: "Soft cost ceiling in dollars; checked after each turn, triggers the same wrap-up flow as max_turns." })),
59
+ max_cost: Type.Optional(Type.Number({ minimum: 0, description: "Soft provider-reported execution cost ceiling in dollars, checked after each turn. TypeSafe routing currency is unreported and NOT capped by max_cost." })),
60
60
  grace_turns: Type.Optional(Type.Number({ minimum: 0, maximum: 20, description: "Wrap-up turns allowed after a budget breach before hard stop. 0 = immediate stop. Default from config (2)." })),
61
- fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Ordered backup models tried automatically on transient failures (provider error, stall, queue timeout)." })),
62
- max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "Extra attempts on transient failures. Defaults to config maxRetries (1). Task-quality failures never retry." })),
61
+ fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Legacy field: omit on new work, including an empty list. Jev routing has no emergency or fallback model." })),
62
+ max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "Extra same-model attempts on transient child failures; never reselects Jev or broadens tools. Defaults to config maxRetries (1). Task-quality failures never retry." })),
63
63
  context: Type.Optional(
64
64
  Type.Union([Type.Literal("fresh"), Type.Literal("fork")], {
65
65
  description: "fork starts the child from a branched copy of the parent conversation (needs a persisted parent session); fresh (default) starts clean. Fork is single-task only.",
@@ -113,7 +113,7 @@ export const ParallelTaskItem = Type.Object(
113
113
  export const SubagentParamsSchema = Type.Object(
114
114
  {
115
115
  // Management actions
116
- action: Type.Optional({ ...Action, description: "Management action on an existing run: status, wait, cancel, steer (inject guidance into a running child), diff/apply/discard (worktree results). action:\"plan\" dry-runs validation + preflight with task/tasks no spawn." }),
116
+ action: Type.Optional({ ...Action, description: "Management action on an existing run: status, wait, cancel, steer (inject guidance into a running child), diff/apply/discard (worktree results). action:\"plan\" calls Jev and local preflights, returning a resolved route without spawning a child. Selector fees apply; later dispatch selects again." }),
117
117
  id: Type.Optional(Type.String({ minLength: 1, description: "Run id (or unique prefix) for management actions." })),
118
118
  message: Type.Optional(Type.String({ minLength: 1, description: "Steering message injected into the running child (action: steer)." })),
119
119
  index: Type.Optional(Type.Number({ minimum: 0, description: "Task index within a parallel run for steer/diff/apply/discard. Defaults to the only eligible task." })),
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Pi child startup-handshake contract for Jev-routed subagent tasks.
3
+ *
4
+ * A routed dispatch must prove *before* the real task prompt reaches the child that the
5
+ * child actually loaded the expected execution model and exactly the finalized tool set.
6
+ * Parent-side catalog knowledge is not proof: Pi silently drops unknown `--tools` names,
7
+ * and a child may load a different (possibly older) copy of this package, whose nested
8
+ * `subagent` tool would bypass routing entirely.
9
+ *
10
+ * This module is the single source of truth for the bounded, non-secret handshake:
11
+ *
12
+ * - manifest + acknowledgement schemas and the private command naming scheme,
13
+ * - the pure command resolver and acknowledgement verifier,
14
+ * - the host-version gate for the one baseline that was actually verified.
15
+ *
16
+ * It is imported by `src/backends/pi.ts` (writes the temporary manifest and loads the
17
+ * private extension), `src/child-preflight.ts` (runs inside the child and answers), and
18
+ * `src/runner.ts` (drives and verifies). It carries no HTTP, config, policy or process
19
+ * dependencies, so it also loads cleanly from an isolated offline harness.
20
+ *
21
+ * Nothing here is a sandbox. It is a capability check against stale installs, dropped
22
+ * tools and misconfigured children — not a defence against a malicious local extension.
23
+ */
24
+
25
+ import { Buffer } from "node:buffer";
26
+ import * as crypto from "node:crypto";
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ /** Custom-message type of the child-side acknowledgement. */
32
+ export const PREFLIGHT_ACK_TYPE = "pi-subagent-preflight-ack";
33
+ /** Schema tag inside the acknowledgement payload. */
34
+ export const PREFLIGHT_ACK_SCHEMA = "pi-subagent-preflight-ack/1";
35
+ /** Schema tag inside the temporary expectation manifest. */
36
+ export const PREFLIGHT_MANIFEST_SCHEMA = "pi-subagent-preflight-manifest/1";
37
+ /** Private command prefix; the nonce makes each invocation name unique per run. */
38
+ export const PREFLIGHT_COMMAND_PREFIX = "pi_subagent_preflight_";
39
+ /** Env var carrying the *path* to the temporary manifest (never its contents in argv). */
40
+ export const PREFLIGHT_MANIFEST_ENV = "PI_SUBAGENT_PREFLIGHT_MANIFEST";
41
+
42
+ /**
43
+ * Lowest Pi host version whose startup contract was actually exercised offline.
44
+ * A *known* older host is refused rather than silently degraded; an unknown version is
45
+ * accepted only because the behavioural handshake (command + acknowledgement) already
46
+ * proved the contract exists.
47
+ */
48
+ export const MIN_VERIFIED_HOST_VERSION = "0.86.0";
49
+
50
+ /** Stop reason a failed startup check produces. Deliberately not in the transient set. */
51
+ export const PREFLIGHT_FAILURE_STOP_REASON = "capability_mismatch";
52
+
53
+ /** Local bounds; all handshake input is untrusted child output. */
54
+ const MAX_MANIFEST_BYTES = 64 * 1024;
55
+ const MAX_ACK_TOOLS = 512;
56
+ const MAX_ACK_NESTED = 512;
57
+ const MAX_NAME_LENGTH = 256;
58
+ const MAX_MODEL_LENGTH = 512;
59
+ const MAX_PROBLEMS_IN_MESSAGE = 8;
60
+ const NONCE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
61
+
62
+ /** Bounded, non-secret expectation the backend writes and the child reads back. */
63
+ export interface PreflightManifest {
64
+ readonly schema: typeof PREFLIGHT_MANIFEST_SCHEMA;
65
+ readonly nonce: string;
66
+ /** Exact `provider/modelId` the child must have active. */
67
+ readonly model: string;
68
+ /** Exact finalized active tool set (Jev selection + mandatory local controls). */
69
+ readonly tools: readonly string[];
70
+ /** Nested dispatch tools whose loaded source must be this package's extension entry. */
71
+ readonly nestedTools?: readonly string[];
72
+ }
73
+
74
+ /** What the control side verifies the acknowledgement against. */
75
+ export interface PreflightExpectation {
76
+ readonly nonce: string;
77
+ readonly model: string;
78
+ readonly tools: readonly string[];
79
+ readonly nestedTools?: readonly string[];
80
+ /** Expected own extension entry paths; defaults to the current package's entries. */
81
+ readonly ownEntryPaths?: readonly string[];
82
+ /** Expected source path of the private preflight command; defaults to this package's. */
83
+ readonly preflightCommandPath?: string | null;
84
+ }
85
+
86
+ /** One provenance row for a nested tool, echoed by the child in its acknowledgement. */
87
+ export interface PreflightToolSource {
88
+ readonly name: string;
89
+ readonly path?: string | null;
90
+ readonly source?: string | null;
91
+ }
92
+
93
+ export interface PreflightAckPayload {
94
+ readonly schema?: unknown;
95
+ readonly nonce?: unknown;
96
+ readonly model?: { provider?: unknown; id?: unknown } | null;
97
+ readonly tools?: unknown;
98
+ readonly nestedToolsWithSource?: unknown;
99
+ readonly host?: { version?: unknown } | null;
100
+ }
101
+
102
+ /** Result-message prefix for a refused routed startup, stable for callers/TUI matching. */
103
+ export const STARTUP_FAILURE_RESULT_PREFIX = "Subagent startup check failed";
104
+
105
+ /**
106
+ * Marker for a startup capability failure carried by a *plain* `Error`.
107
+ *
108
+ * This repository forbids custom `Error` subclasses, so a pre-spawn/startup refusal is a
109
+ * plain Error whose message carries a stable, bounded marker plus an owned code; the
110
+ * runner narrows it with `readStartupFailure` and maps it to a non-transient
111
+ * `PREFLIGHT_FAILURE_STOP_REASON` result instead of rethrowing for control flow.
112
+ */
113
+ export const STARTUP_FAILURE_MARKER = "subagent-startup-check-failed";
114
+
115
+ /** Build a plain Error carrying an owned startup failure code. */
116
+ export function startupFailure(code: string, detail: string): Error {
117
+ const error = new Error(`${STARTUP_FAILURE_MARKER}: ${code}: ${detail}`);
118
+ (error as Error & { startupCode?: string }).startupCode = code;
119
+ return error;
120
+ }
121
+
122
+ /** Narrow any thrown value to an owned startup failure, or undefined. */
123
+ export function readStartupFailure(error: unknown): { code: string; detail: string } | undefined {
124
+ if (!error || typeof error !== "object") return undefined;
125
+ const code = (error as { startupCode?: unknown }).startupCode;
126
+ if (typeof code === "string" && code.length > 0) {
127
+ const raw = (error as { message?: unknown }).message;
128
+ const message = typeof raw === "string" ? raw : "";
129
+ const prefix = `${STARTUP_FAILURE_MARKER}: ${code}: `;
130
+ const detail = message.startsWith(prefix) ? message.slice(prefix.length) : message;
131
+ return { code, detail: detail || "startup check failed" };
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ /** Bounded startup timeout used when a routed child never answers the handshake. */
137
+ export function startupTimeoutDetail(budgetMs: number): string {
138
+ return `The child did not complete startup verification within ${Math.max(0, Math.round(budgetMs))} ms.`;
139
+ }
140
+
141
+ export function isValidPreflightNonce(value: unknown): value is string {
142
+ return typeof value === "string" && NONCE_PATTERN.test(value);
143
+ }
144
+
145
+ export function createPreflightNonce(): string {
146
+ return crypto.randomBytes(12).toString("hex");
147
+ }
148
+
149
+ export function preflightCommandBase(nonce: string): string {
150
+ return `${PREFLIGHT_COMMAND_PREFIX}${nonce}`;
151
+ }
152
+
153
+ /**
154
+ * Normalize a filesystem path for provenance comparison. Symlinked installs and Windows
155
+ * drive-case differences must not turn an identical copy into a false mismatch.
156
+ */
157
+ export function normalizeFsPath(value: unknown): string | null {
158
+ if (typeof value !== "string" || value.length === 0) return null;
159
+ let resolved: string;
160
+ try {
161
+ resolved = path.resolve(value);
162
+ } catch {
163
+ resolved = value;
164
+ }
165
+ try {
166
+ resolved = fs.realpathSync(resolved);
167
+ } catch {
168
+ /* path may not exist from this process's view; keep the lexical form */
169
+ }
170
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
171
+ }
172
+
173
+ /**
174
+ * Absolute path of this package's private preflight extension, used both as the explicit
175
+ * `-e` target and as the expected `sourceInfo.path` of the startup command.
176
+ */
177
+ export function ownPreflightExtensionPath(baseUrl: string = import.meta.url): string | null {
178
+ try {
179
+ const candidate = path.join(path.dirname(fileURLToPath(baseUrl)), "child-preflight.ts");
180
+ return fs.existsSync(candidate) ? normalizeFsPath(candidate) : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Package extension entries that legitimately host the nested `subagent` / `subagent_wait`
188
+ * tools. Derived from this module's own location so a child that loaded a different
189
+ * installed copy is detected rather than trusted.
190
+ */
191
+ export function ownExtensionEntryCandidates(baseUrl: string = import.meta.url): string[] {
192
+ let srcDir: string;
193
+ try {
194
+ srcDir = path.dirname(fileURLToPath(baseUrl));
195
+ } catch {
196
+ return [];
197
+ }
198
+ const root = path.resolve(srcDir, "..");
199
+ const candidates: string[] = [];
200
+ for (const relative of ["extensions/subagent.ts", "src/extension.ts"]) {
201
+ const absolute = path.join(root, relative);
202
+ if (!fs.existsSync(absolute)) continue;
203
+ const normalized = normalizeFsPath(absolute);
204
+ if (normalized) candidates.push(normalized);
205
+ }
206
+ return candidates;
207
+ }
208
+
209
+ export type PreflightManifestParse =
210
+ | { readonly ok: true; readonly manifest: PreflightManifest }
211
+ | { readonly ok: false; readonly code: string; readonly message: string };
212
+
213
+ /** Validate untrusted manifest text read from the temporary file. */
214
+ export function parsePreflightManifest(raw: unknown): PreflightManifestParse {
215
+ if (typeof raw !== "string") {
216
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest was not readable text." };
217
+ }
218
+ if (Buffer.byteLength(raw, "utf8") > MAX_MANIFEST_BYTES) {
219
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest exceeded the local size bound." };
220
+ }
221
+ let parsed: any;
222
+ try {
223
+ parsed = JSON.parse(raw);
224
+ } catch {
225
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest was not valid JSON." };
226
+ }
227
+ if (!parsed || typeof parsed !== "object" || parsed.schema !== PREFLIGHT_MANIFEST_SCHEMA) {
228
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest schema tag did not match." };
229
+ }
230
+ if (!isValidPreflightNonce(parsed.nonce)) {
231
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried an unusable correlation nonce." };
232
+ }
233
+ if (typeof parsed.model !== "string" || parsed.model.length === 0 || parsed.model.length > MAX_MODEL_LENGTH) {
234
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried no usable model expectation." };
235
+ }
236
+ const tools = readNameList(parsed.tools, MAX_ACK_TOOLS);
237
+ if (!tools) {
238
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried no usable tool list." };
239
+ }
240
+ let nestedTools: string[] | undefined;
241
+ if (parsed.nestedTools !== undefined) {
242
+ nestedTools = readNameList(parsed.nestedTools, MAX_ACK_NESTED) ?? undefined;
243
+ if (parsed.nestedTools !== undefined && nestedTools === undefined) {
244
+ return { ok: false, code: "preflight_manifest_unreadable", message: "The preflight manifest carried an unusable nested-tool list." };
245
+ }
246
+ }
247
+ return {
248
+ ok: true,
249
+ manifest: { schema: PREFLIGHT_MANIFEST_SCHEMA, nonce: parsed.nonce, model: parsed.model, tools, nestedTools },
250
+ };
251
+ }
252
+
253
+ function readNameList(value: unknown, maxCount: number): string[] | null {
254
+ if (!Array.isArray(value) || value.length > maxCount) return null;
255
+ const out: string[] = [];
256
+ for (const entry of value) {
257
+ if (typeof entry !== "string" || entry.length === 0 || entry.length > MAX_NAME_LENGTH) return null;
258
+ out.push(entry);
259
+ }
260
+ return out;
261
+ }
262
+
263
+ export interface ResolvedPreflightCommand {
264
+ readonly name: string;
265
+ readonly description?: string;
266
+ readonly source?: string;
267
+ readonly path?: string;
268
+ readonly sourceInfoSource?: string;
269
+ readonly scope?: string;
270
+ readonly origin?: string;
271
+ }
272
+
273
+ export type PreflightCommandResolution =
274
+ | { readonly ok: true; readonly code: "verified"; readonly invocableName: string; readonly entry: ResolvedPreflightCommand }
275
+ | {
276
+ readonly ok: false;
277
+ readonly code:
278
+ | "command-absent"
279
+ | "command-path-mismatch"
280
+ | "command-ambiguous"
281
+ | "command-source-not-extension";
282
+ readonly invocableName: null;
283
+ readonly candidates: ReadonlyArray<{ name: string; source?: string; path: string | null }>;
284
+ };
285
+
286
+ /**
287
+ * Resolve the invocable startup command from a raw `get_commands` payload.
288
+ *
289
+ * Requires BOTH the nonce-specific name AND an expected source file path: Pi suffixes
290
+ * duplicate command names (`name:1`, `name:2`), so name-only matching could select a
291
+ * different extension's copy — and an unverified slash command would be treated as an
292
+ * ordinary model prompt.
293
+ */
294
+ export function resolvePreflightCommand(
295
+ commands: unknown,
296
+ baseName: string,
297
+ expectedPaths: string | readonly string[],
298
+ allowedSources: readonly string[] = ["extension"],
299
+ ): PreflightCommandResolution {
300
+ const expected = (Array.isArray(expectedPaths) ? expectedPaths : [expectedPaths])
301
+ .map((candidate) => normalizeFsPath(candidate))
302
+ .filter((candidate): candidate is string => candidate !== null);
303
+
304
+ const list = Array.isArray(commands) ? commands : [];
305
+ const byName = list.filter(
306
+ (entry: any) =>
307
+ entry &&
308
+ typeof entry === "object" &&
309
+ typeof entry.name === "string" &&
310
+ (entry.name === baseName || entry.name.startsWith(`${baseName}:`)),
311
+ );
312
+ const describe = (entry: any) => ({
313
+ name: String(entry?.name ?? ""),
314
+ source: typeof entry?.source === "string" ? entry.source : undefined,
315
+ path: typeof entry?.sourceInfo?.path === "string" ? entry.sourceInfo.path : null,
316
+ });
317
+
318
+ if (byName.length === 0) {
319
+ return { ok: false, code: "command-absent", invocableName: null, candidates: [] };
320
+ }
321
+ const byPath = byName.filter((entry: any) => {
322
+ const observed = normalizeFsPath(entry?.sourceInfo?.path);
323
+ return observed !== null && expected.includes(observed);
324
+ });
325
+ if (byPath.length === 0) {
326
+ return { ok: false, code: "command-path-mismatch", invocableName: null, candidates: byName.map(describe) };
327
+ }
328
+ if (byPath.length > 1) {
329
+ return { ok: false, code: "command-ambiguous", invocableName: null, candidates: byPath.map(describe) };
330
+ }
331
+ const entry: any = byPath[0];
332
+ if (typeof entry.source !== "string" || !allowedSources.includes(entry.source)) {
333
+ return { ok: false, code: "command-source-not-extension", invocableName: null, candidates: [describe(entry)] };
334
+ }
335
+ return {
336
+ ok: true,
337
+ code: "verified",
338
+ invocableName: entry.name,
339
+ entry: {
340
+ name: entry.name,
341
+ description: typeof entry.description === "string" ? entry.description : undefined,
342
+ source: entry.source,
343
+ path: typeof entry.sourceInfo?.path === "string" ? entry.sourceInfo.path : undefined,
344
+ sourceInfoSource: typeof entry.sourceInfo?.source === "string" ? entry.sourceInfo.source : undefined,
345
+ scope: typeof entry.sourceInfo?.scope === "string" ? entry.sourceInfo.scope : undefined,
346
+ origin: typeof entry.sourceInfo?.origin === "string" ? entry.sourceInfo.origin : undefined,
347
+ },
348
+ };
349
+ }
350
+
351
+ /** Parse the child's custom-message content. Never throws; returns null when unusable. */
352
+ export function parsePreflightAckContent(content: unknown): PreflightAckPayload | null {
353
+ if (typeof content !== "string" || content.length === 0) return null;
354
+ if (Buffer.byteLength(content, "utf8") > MAX_MANIFEST_BYTES) return null;
355
+ try {
356
+ const parsed = JSON.parse(content);
357
+ return parsed && typeof parsed === "object" ? (parsed as PreflightAckPayload) : null;
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+
363
+ /** `major.minor.patch` compare; unparseable input is "unknown", never "older". */
364
+ export function compareHostVersion(version: unknown): "ok" | "unsupported" | "unknown" {
365
+ if (typeof version !== "string") return "unknown";
366
+ const parse = (value: string): number[] | null => {
367
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
368
+ if (!match) return null;
369
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
370
+ };
371
+ const actual = parse(version);
372
+ const floor = parse(MIN_VERIFIED_HOST_VERSION);
373
+ if (!actual || !floor) return "unknown";
374
+ for (let index = 0; index < 3; index += 1) {
375
+ if (actual[index] > floor[index]) return "ok";
376
+ if (actual[index] < floor[index]) return "unsupported";
377
+ }
378
+ return "ok";
379
+ }
380
+
381
+ /**
382
+ * Verify an acknowledgement against the local expectation. Returns problem codes; an empty
383
+ * list means the child provably loaded the expected model and the exact expected tools.
384
+ *
385
+ * The child's own claims are never trusted for authority: every required tool must be
386
+ * reported active, and any extra active tool is a failure (a silently broadened child is
387
+ * exactly what this check exists to catch).
388
+ */
389
+ export function verifyPreflightAck(ack: unknown, expectation: PreflightExpectation): string[] {
390
+ const problems: string[] = [];
391
+ if (!ack || typeof ack !== "object") return ["ack-malformed"];
392
+ const payload = ack as PreflightAckPayload;
393
+ if (payload.schema !== PREFLIGHT_ACK_SCHEMA) problems.push("ack-schema-mismatch");
394
+ if (payload.nonce !== expectation.nonce) problems.push("nonce-mismatch");
395
+
396
+ const hostVersion = payload.host?.version;
397
+ if (compareHostVersion(hostVersion) === "unsupported") {
398
+ problems.push(`host-version-unsupported:${String(hostVersion).slice(0, 32)}`);
399
+ }
400
+
401
+ const model = payload.model;
402
+ if (!model || typeof model !== "object") {
403
+ problems.push("model-absent");
404
+ } else {
405
+ const provider = typeof model.provider === "string" ? model.provider : "";
406
+ const id = typeof model.id === "string" ? model.id : "";
407
+ if (!provider || !id || `${provider}/${id}` !== expectation.model) problems.push("model-mismatch");
408
+ }
409
+
410
+ if (!Array.isArray(payload.tools)) {
411
+ problems.push("tools-not-array");
412
+ } else if (payload.tools.length > MAX_ACK_TOOLS) {
413
+ problems.push("ack-too-large");
414
+ } else {
415
+ const active = new Set<string>();
416
+ for (const entry of payload.tools) {
417
+ if (typeof entry !== "string" || entry.length === 0 || entry.length > MAX_NAME_LENGTH) {
418
+ problems.push("tool-name-invalid");
419
+ continue;
420
+ }
421
+ if (active.has(entry)) problems.push(`tool-duplicate:${entry}`);
422
+ active.add(entry);
423
+ }
424
+ for (const required of expectation.tools) {
425
+ if (!active.has(required)) problems.push(`missing-tool:${required}`);
426
+ }
427
+ for (const observed of active) {
428
+ if (!expectation.tools.includes(observed)) problems.push(`unexpected-tool:${observed}`);
429
+ }
430
+ }
431
+
432
+ const nested = expectation.nestedTools ?? [];
433
+ if (nested.length > 0) {
434
+ const expectedEntries = expectation.ownEntryPaths ?? ownExtensionEntryCandidates();
435
+ const rows = Array.isArray(payload.nestedToolsWithSource) ? payload.nestedToolsWithSource : [];
436
+ if (rows.length > MAX_ACK_NESTED) problems.push("ack-too-large");
437
+ const byName = new Map<string, PreflightToolSource>();
438
+ for (const row of rows) {
439
+ if (!row || typeof row !== "object") continue;
440
+ const candidate = row as PreflightToolSource;
441
+ if (typeof candidate.name !== "string") continue;
442
+ if (!byName.has(candidate.name)) byName.set(candidate.name, candidate);
443
+ }
444
+ for (const tool of nested) {
445
+ const row = byName.get(tool);
446
+ if (!row) {
447
+ problems.push(`nested-tool-source-missing:${tool}`);
448
+ continue;
449
+ }
450
+ const source = typeof row.source === "string" ? row.source : "";
451
+ if (!source || source === "builtin" || source === "sdk") {
452
+ problems.push(`nested-tool-source-not-extension:${tool}`);
453
+ continue;
454
+ }
455
+ const observed = normalizeFsPath(row.path);
456
+ if (!observed || expectedEntries.length === 0 || !expectedEntries.includes(observed)) {
457
+ problems.push(`nested-tool-source-mismatch:${tool}`);
458
+ }
459
+ }
460
+ }
461
+
462
+ return problems;
463
+ }
464
+
465
+ /** Bounded, safe diagnostic text for a verification failure. */
466
+ export function summarizePreflightProblems(problems: readonly string[]): string {
467
+ if (problems.length === 0) return "startup acknowledgement rejected";
468
+ const shown = problems.slice(0, MAX_PROBLEMS_IN_MESSAGE).map((problem) => problem.slice(0, 96));
469
+ const suffix = problems.length > shown.length ? ` (+${problems.length - shown.length} more)` : "";
470
+ return shown.join("; ") + suffix;
471
+ }
472
+
473
+ /** Bounded command-resolution diagnostic for a failure detail. */
474
+ export function summarizeCommandResolution(resolution: PreflightCommandResolution): string {
475
+ if (resolution.ok) return `verified /${resolution.invocableName}`;
476
+ const candidates = resolution.candidates
477
+ .slice(0, 3)
478
+ .map((candidate) => `${candidate.name || "<unnamed>"}@${candidate.path ?? candidate.source ?? "unknown"}`)
479
+ .join(", ");
480
+ return `${resolution.code}${candidates ? ` [${candidates.slice(0, 300)}]` : ""}`;
481
+ }