@rosetears/aili-pi 0.1.6 → 0.1.8

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.
@@ -0,0 +1,135 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { resolvePermissionModesPackageRoot } from "../src/runtime/package-resolution.ts";
6
+
7
+ const ROOT = resolve(import.meta.dirname, "..");
8
+ const SOURCE_ROOT = fileURLToPath(resolvePermissionModesPackageRoot());
9
+ const VERIFY = process.argv.includes("--verify");
10
+ const VERSION = "2.2.0";
11
+ const REVISION = "23d65d10a53b67043cae42322acf9044d6edb196";
12
+
13
+ const EXPECTED_UPSTREAM_HASHES: Record<string, string> = {
14
+ "src/index.ts": "fd4462a3b7ba986af734c2e17ba8ea7178df56c933e87ed444ba90ba24c2fd5b",
15
+ "src/resolve.ts": "13f52a4a9c08d7a55f5f9d03f97302d864768838fb3e9fca2051cb7d94a0ae82",
16
+ LICENSE: "d87cb99b43f6bf8771e57be83485db11b977b9dfa21b6bd201b8d3d370bdce43",
17
+ };
18
+
19
+ const ADAPTATION_HEADER = [
20
+ "/**",
21
+ " * Generated AILI adaptation of pi-permission-modes@2.2.0.",
22
+ " * Source revision: 23d65d10a53b67043cae42322acf9044d6edb196.",
23
+ " * Regenerate with scripts/sync-permission-modes.ts; do not edit manually.",
24
+ " */",
25
+ "",
26
+ ].join("\n");
27
+
28
+ const sha256 = (value: string): string => createHash("sha256").update(value).digest("hex");
29
+
30
+ function replaceExactlyOnce(source: string, before: string, after: string, label: string): string {
31
+ const first = source.indexOf(before);
32
+ if (first < 0 || source.indexOf(before, first + before.length) >= 0) {
33
+ throw new Error(`expected exactly one ${label} in pi-permission-modes@${VERSION}`);
34
+ }
35
+ return source.slice(0, first) + after + source.slice(first + before.length);
36
+ }
37
+
38
+ function adaptIndex(source: string): string {
39
+ let redirected = 0;
40
+ const body = source.replace(/from "\.\/([^"]+)";/g, (full, specifier: string) => {
41
+ if (specifier === "resolve.ts") return full;
42
+ redirected += 1;
43
+ return `from "pi-permission-modes/src/${specifier}";`;
44
+ });
45
+ if (redirected !== 12) {
46
+ throw new Error(`expected 12 redirected sibling imports in pi-permission-modes@${VERSION}, found ${redirected}`);
47
+ }
48
+ const documented = replaceExactlyOnce(
49
+ body,
50
+ "The logic lives in sibling\n * modules:",
51
+ "The adapted matcher lives locally; unchanged logic stays in pinned dependency\n * modules:",
52
+ "entry module ownership comment",
53
+ );
54
+ return ADAPTATION_HEADER + documented;
55
+ }
56
+
57
+ function adaptResolve(source: string): string {
58
+ const withSchemaRedirect = replaceExactlyOnce(
59
+ source,
60
+ 'from "./schema.ts";',
61
+ 'from "pi-permission-modes/src/schema.ts";',
62
+ "schema import",
63
+ );
64
+ const withDotAll = replaceExactlyOnce(
65
+ withSchemaRedirect,
66
+ "return new RegExp(re).test(t);",
67
+ 'return new RegExp(re, "s").test(t);',
68
+ "glob RegExp compilation",
69
+ );
70
+ return ADAPTATION_HEADER + withDotAll;
71
+ }
72
+
73
+ async function expectedOutputs(): Promise<Record<string, string>> {
74
+ const packageJsonText = await readFile(resolve(SOURCE_ROOT, "package.json"), "utf8");
75
+ const packageJson = JSON.parse(packageJsonText) as { name?: string; version?: string; license?: string };
76
+ if (packageJson.name !== "pi-permission-modes" || packageJson.version !== VERSION || packageJson.license !== "MIT") {
77
+ throw new Error(`expected pi-permission-modes@${VERSION} MIT baseline`);
78
+ }
79
+
80
+ const upstream = Object.fromEntries(await Promise.all(
81
+ Object.keys(EXPECTED_UPSTREAM_HASHES).map(async (path) => [path, await readFile(resolve(SOURCE_ROOT, path), "utf8")]),
82
+ )) as Record<string, string>;
83
+ for (const [path, expected] of Object.entries(EXPECTED_UPSTREAM_HASHES)) {
84
+ const actual = sha256(upstream[path]!);
85
+ if (actual !== expected) throw new Error(`unexpected upstream drift for ${path}: ${actual}`);
86
+ }
87
+
88
+ const index = adaptIndex(upstream["src/index.ts"]!);
89
+ const matcher = adaptResolve(upstream["src/resolve.ts"]!);
90
+ const license = upstream.LICENSE!;
91
+ const adaptedFiles = {
92
+ "src/vendor/pi-permission-modes/index.ts": index,
93
+ "src/vendor/pi-permission-modes/resolve.ts": matcher,
94
+ "licenses/pi-permission-modes-MIT.txt": license,
95
+ };
96
+ const lock = {
97
+ schemaVersion: 1,
98
+ package: {
99
+ name: "pi-permission-modes",
100
+ version: VERSION,
101
+ revision: REVISION,
102
+ license: "MIT",
103
+ },
104
+ upstreamFiles: Object.entries(EXPECTED_UPSTREAM_HASHES).map(([path, hash]) => ({ path, sha256: hash })),
105
+ adaptedFiles: Object.entries(adaptedFiles).map(([path, content]) => ({ path, sha256: sha256(content) })),
106
+ localChanges: [
107
+ "Package-owned adapted entry redirects all unchanged sibling modules to the exact pi-permission-modes dependency while owning resolve.ts locally.",
108
+ "matchPattern compiles its anchored glob RegExp with dotAll so * and ? include ECMAScript line terminators.",
109
+ ],
110
+ generatedBy: "scripts/sync-permission-modes.ts",
111
+ verification: ["npm run verify:permission-modes", "tests/unit/permission-patterns.test.ts", "tests/integration/permission-modes.test.ts"],
112
+ };
113
+
114
+ return {
115
+ ...adaptedFiles,
116
+ "upstream/pi-permission-modes.lock.json": `${JSON.stringify(lock, null, 2)}\n`,
117
+ };
118
+ }
119
+
120
+ async function main(): Promise<void> {
121
+ const outputs = await expectedOutputs();
122
+ for (const [path, expected] of Object.entries(outputs)) {
123
+ const absolute = resolve(ROOT, path);
124
+ if (VERIFY) {
125
+ const actual = await readFile(absolute, "utf8").catch(() => "");
126
+ if (actual !== expected) throw new Error(`permission-mode adaptation drifted: ${path}`);
127
+ } else {
128
+ await mkdir(dirname(absolute), { recursive: true });
129
+ await writeFile(absolute, expected, "utf8");
130
+ }
131
+ }
132
+ console.log(`${VERIFY ? "PASS" : "SYNCED"}: pi-permission-modes@${VERSION} adapted matcher (${Object.keys(outputs).length} artifacts)`);
133
+ }
134
+
135
+ await main();
@@ -3,7 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  type NativeExtension = (pi: ExtensionAPI) => void | Promise<void>;
4
4
 
5
5
  export const NATIVE_INTEGRATIONS = [
6
- "pi-permission-modes@2.2.0",
6
+ "pi-permission-modes@2.2.0 (AILI multiline-glob adaptation)",
7
7
  "pi-quota-status@0.3.0",
8
8
  "pi-web-access@0.13.0",
9
9
  "pi-cache-optimizer@2.6.18",
@@ -12,7 +12,7 @@ export const NATIVE_INTEGRATIONS = [
12
12
  ] as const;
13
13
 
14
14
  const INTEGRATION_MODULES = [
15
- "pi-permission-modes/src/index.ts",
15
+ "../vendor/pi-permission-modes/index.ts",
16
16
  "pi-quota-status",
17
17
  "pi-web-access",
18
18
  "pi-cache-optimizer/index.ts",
@@ -21,9 +21,9 @@ const INTEGRATION_MODULES = [
21
21
  ] as const;
22
22
 
23
23
  /**
24
- * Initialise the pinned upstream extensions through AILI's single package entry.
25
- * They remain upstream-owned implementations; this module adds no substitute
26
- * command, tool, permission, or provider behavior.
24
+ * Initialise pinned native integrations through AILI's single package entry.
25
+ * Permission modes uses the revision-bound adapted entry that changes only
26
+ * shared multiline glob matching; the remaining integrations stay upstream-owned.
27
27
  */
28
28
  export async function registerNativeIntegrations(pi: ExtensionAPI): Promise<void> {
29
29
  for (const moduleName of INTEGRATION_MODULES) {
@@ -0,0 +1,8 @@
1
+ import { createRequire } from "node:module";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ /** Resolve the installed dependency from either a development tree or npm's hoisted scoped-package layout. */
5
+ export function resolvePermissionModesPackageRoot(from: string | URL = import.meta.url): URL {
6
+ const packageJson = createRequire(from).resolve("pi-permission-modes/package.json");
7
+ return new URL("./", pathToFileURL(packageJson));
8
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { access, readFile, readdir } from "node:fs/promises";
3
3
  import { loadRoleProfiles, validateRoleProfiles } from "./roles.ts";
4
+ import { resolvePermissionModesPackageRoot } from "./package-resolution.ts";
4
5
 
5
6
  const ROOT = new URL("../../", import.meta.url);
6
7
 
@@ -143,30 +144,158 @@ export async function validateLiveVerification(): Promise<string[]> {
143
144
  const errors: string[] = [];
144
145
  try {
145
146
  const evidence = await json<{
146
- schemaVersion?: number; platform?: string; piVersion?: string; status?: string;
147
- probes?: Array<{ id?: string; status?: string; changedFiles?: number }>;
147
+ schemaVersion?: number; platform?: string; piVersion?: string; subagentVersion?: string; status?: string;
148
+ probes?: Array<{
149
+ id?: string;
150
+ status?: string;
151
+ changedFiles?: number | null;
152
+ backendRequest?: string;
153
+ resolvedBackend?: string | null;
154
+ }>;
148
155
  implementation?: Record<string, string>;
149
156
  }>("manifests/live-verification.json");
150
- if (evidence.schemaVersion !== 1 || evidence.platform !== "linux" || evidence.piVersion !== "0.81.1" || evidence.status !== "passed") errors.push("live verification: identity is incomplete or non-pass");
151
- const genericProbe = evidence.probes?.find((probe) => probe.id === "generic-agentless-read-package");
152
- const credentialProbe = evidence.probes?.find((probe) => probe.id === "generic-credential-guard");
153
- if (evidence.probes?.length !== 3 || evidence.probes.some((probe) => probe.status !== "passed") || genericProbe?.changedFiles !== 0 || credentialProbe?.changedFiles !== 0) {
154
- errors.push("live verification: required probes are missing, non-pass, or mutated files");
157
+ if (
158
+ evidence.schemaVersion !== 2 ||
159
+ evidence.platform !== "linux" ||
160
+ evidence.piVersion !== "0.81.1" ||
161
+ evidence.subagentVersion !== "0.4.8" ||
162
+ evidence.status !== "passed"
163
+ ) {
164
+ errors.push("live verification: compatibility identity is incomplete or non-pass");
155
165
  }
166
+ const fixtureProbe = evidence.probes?.find((probe) => probe.id === "generic-subagent-fixtures");
167
+ const defaultProbe = evidence.probes?.find((probe) => probe.id === "generic-agentless-default-path");
168
+ const credentialProbe = evidence.probes?.find((probe) => probe.id === "generic-credential-guard-explicit-headless");
169
+ if (
170
+ evidence.probes?.length !== 3 ||
171
+ fixtureProbe?.status !== "passed" ||
172
+ defaultProbe?.status !== "passed" ||
173
+ defaultProbe.backendRequest !== "omitted" ||
174
+ defaultProbe.resolvedBackend !== "headless" ||
175
+ defaultProbe.changedFiles !== 0 ||
176
+ credentialProbe?.status !== "passed" ||
177
+ credentialProbe.backendRequest !== "explicit-headless" ||
178
+ credentialProbe.resolvedBackend !== "headless" ||
179
+ credentialProbe.changedFiles !== 0
180
+ ) {
181
+ errors.push("live verification: default-path/headless probes are missing, ambiguous, non-pass, or mutated files");
182
+ }
183
+ const requiredImplementation = [
184
+ "src/runtime/subagents.ts",
185
+ "tests/unit/subagents.test.ts",
186
+ "tests/integration/live-subagent.test.ts",
187
+ ];
156
188
  for (const [filePath, expected] of Object.entries(evidence.implementation ?? {})) {
157
189
  const content = await readFile(new URL(filePath, ROOT), "utf8");
158
190
  const actual = createHash("sha256").update(content).digest("hex");
159
191
  if (actual !== expected) errors.push(`live verification: implementation drift ${filePath}`);
160
192
  }
161
- if (Object.keys(evidence.implementation ?? {}).length !== 3) errors.push("live verification: implementation binding must contain exactly three files");
193
+ if (JSON.stringify(Object.keys(evidence.implementation ?? {}).sort()) !== JSON.stringify(requiredImplementation.sort())) {
194
+ errors.push("live verification: implementation binding must contain the exact default-path files");
195
+ }
162
196
  } catch (error) {
163
197
  errors.push(`live verification: ${error instanceof Error ? error.message : String(error)}`);
164
198
  }
165
199
  return errors;
166
200
  }
167
201
 
168
- export async function validateProvenance(): Promise<string[]> {
202
+ export async function validatePermissionModeAdaptation(): Promise<string[]> {
169
203
  const errors: string[] = [];
204
+ try {
205
+ const lock = await json<{
206
+ schemaVersion?: number;
207
+ package?: { name?: string; version?: string; revision?: string; license?: string };
208
+ upstreamFiles?: Array<{ path?: string; sha256?: string }>;
209
+ adaptedFiles?: Array<{ path?: string; sha256?: string }>;
210
+ localChanges?: string[];
211
+ generatedBy?: string;
212
+ verification?: string[];
213
+ }>("upstream/pi-permission-modes.lock.json");
214
+ const expectedLocalChanges = [
215
+ "Package-owned adapted entry redirects all unchanged sibling modules to the exact pi-permission-modes dependency while owning resolve.ts locally.",
216
+ "matchPattern compiles its anchored glob RegExp with dotAll so * and ? include ECMAScript line terminators.",
217
+ ];
218
+ const expectedVerification = [
219
+ "npm run verify:permission-modes",
220
+ "tests/unit/permission-patterns.test.ts",
221
+ "tests/integration/permission-modes.test.ts",
222
+ ];
223
+ if (
224
+ lock.schemaVersion !== 1 ||
225
+ lock.package?.name !== "pi-permission-modes" ||
226
+ lock.package.version !== "2.2.0" ||
227
+ lock.package.revision !== "23d65d10a53b67043cae42322acf9044d6edb196" ||
228
+ lock.package.license !== "MIT" ||
229
+ lock.upstreamFiles?.length !== 3 ||
230
+ lock.adaptedFiles?.length !== 3 ||
231
+ JSON.stringify(lock.localChanges) !== JSON.stringify(expectedLocalChanges) ||
232
+ lock.generatedBy !== "scripts/sync-permission-modes.ts" ||
233
+ JSON.stringify(lock.verification) !== JSON.stringify(expectedVerification)
234
+ ) {
235
+ errors.push("permission adaptation: lock identity or inventory is incomplete");
236
+ return errors;
237
+ }
238
+ const expectedUpstream = {
239
+ "src/index.ts": "fd4462a3b7ba986af734c2e17ba8ea7178df56c933e87ed444ba90ba24c2fd5b",
240
+ "src/resolve.ts": "13f52a4a9c08d7a55f5f9d03f97302d864768838fb3e9fca2051cb7d94a0ae82",
241
+ LICENSE: "d87cb99b43f6bf8771e57be83485db11b977b9dfa21b6bd201b8d3d370bdce43",
242
+ };
243
+ const upstreamIdentity = Object.fromEntries((lock.upstreamFiles ?? []).map((record) => [record.path, record.sha256]));
244
+ if (JSON.stringify(upstreamIdentity) !== JSON.stringify(expectedUpstream)) {
245
+ errors.push("permission adaptation: upstream baseline hashes do not match the accepted 2.2.0 revision");
246
+ }
247
+ const expectedAdapted = {
248
+ "src/vendor/pi-permission-modes/index.ts": "8bfa0364967a2b76e9900cc72b8f434ea1cfa6520899c7827488220f2e288ee8",
249
+ "src/vendor/pi-permission-modes/resolve.ts": "f71688f847495da5122724f75c5ebe3b41066b3d3cac74cbe99f66b9906404f6",
250
+ "licenses/pi-permission-modes-MIT.txt": "d87cb99b43f6bf8771e57be83485db11b977b9dfa21b6bd201b8d3d370bdce43",
251
+ };
252
+ const adaptedIdentity = Object.fromEntries((lock.adaptedFiles ?? []).map((record) => [record.path, record.sha256]));
253
+ if (JSON.stringify(adaptedIdentity) !== JSON.stringify(expectedAdapted)) {
254
+ errors.push("permission adaptation: adapted hashes or file inventory do not match the accepted generated output");
255
+ }
256
+ const permissionPackageRoot = resolvePermissionModesPackageRoot();
257
+ const installedPackage = JSON.parse(await readFile(new URL("package.json", permissionPackageRoot), "utf8")) as {
258
+ name?: string;
259
+ version?: string;
260
+ license?: string;
261
+ };
262
+ if (installedPackage.name !== "pi-permission-modes" || installedPackage.version !== "2.2.0" || installedPackage.license !== "MIT") {
263
+ errors.push("permission adaptation: resolved dependency identity is not exact pi-permission-modes@2.2.0 MIT");
264
+ }
265
+ for (const [kind, records, sourceRoot] of [
266
+ ["upstream", lock.upstreamFiles, permissionPackageRoot],
267
+ ["adapted", lock.adaptedFiles, ROOT],
268
+ ] as const) {
269
+ for (const record of records ?? []) {
270
+ const path = record.path ?? "";
271
+ if (!path || path.startsWith("/") || path.split("/").includes("..") || !/^[0-9a-f]{64}$/.test(record.sha256 ?? "")) {
272
+ errors.push(`permission adaptation: unsafe or incomplete ${kind} record ${path || "(missing)"}`);
273
+ continue;
274
+ }
275
+ const content = await readFile(new URL(path, sourceRoot), "utf8");
276
+ const actual = createHash("sha256").update(content).digest("hex");
277
+ if (actual !== record.sha256) errors.push(`permission adaptation: ${kind} drift ${path}`);
278
+ }
279
+ }
280
+ const [nativeIntegration, adaptedResolve] = await Promise.all([
281
+ readFile(new URL("src/runtime/native-integrations.ts", ROOT), "utf8"),
282
+ readFile(new URL("src/vendor/pi-permission-modes/resolve.ts", ROOT), "utf8"),
283
+ ]);
284
+ const adaptedEntryCount = nativeIntegration.match(/"\.\.\/vendor\/pi-permission-modes\/index\.ts"/g)?.length ?? 0;
285
+ if (adaptedEntryCount !== 1 || nativeIntegration.includes('"pi-permission-modes/src/index.ts"')) {
286
+ errors.push("permission adaptation: native integration is not bound exactly once and exclusively to the adapted entry");
287
+ }
288
+ if (!adaptedResolve.includes('return new RegExp(re, "s").test(t);') || adaptedResolve.includes("return new RegExp(re).test(t);")) {
289
+ errors.push("permission adaptation: line-terminator-safe matcher semantic is missing");
290
+ }
291
+ } catch (error) {
292
+ errors.push(`permission adaptation: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ return errors;
295
+ }
296
+
297
+ export async function validateProvenance(): Promise<string[]> {
298
+ const errors: string[] = [...await validatePermissionModeAdaptation()];
170
299
  try {
171
300
  const provenance = await json<{ schemaVersion: number; sources: Array<{ name: string; revision: string; license: string; status: string; repository: string; sourceFiles: string[]; symbols: string[]; localChanges: string[]; verification: string[] }> }>("manifests/provenance.json");
172
301
  const sbom = await json<{ spdxVersion?: string; packages?: Array<{ SPDXID?: string; name?: string; licenseDeclared?: string }> }>("manifests/sbom.json");
@@ -0,0 +1,26 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
3
+ const LEGACY_THEME = "rem-cyberdeck";
4
+ const ROSE_THEME = "rose-cyberdeck";
5
+
6
+ /** Return non-mutating exact-token guidance for a legacy Pi theme setting. */
7
+ export function legacyRoseThemeGuidance(theme: unknown): string | undefined {
8
+ if (typeof theme !== "string") return undefined;
9
+ const parts = theme.split("/");
10
+ if (!parts.some((part) => part === LEGACY_THEME)) return undefined;
11
+ const replacement = parts.map((part) => part === LEGACY_THEME ? ROSE_THEME : part).join("/");
12
+ return `Rose Cyberdeck renamed ${LEGACY_THEME} to ${ROSE_THEME}. Use /settings or edit settings.json: \"theme\": \"${replacement}\".`;
13
+ }
14
+
15
+ /** Read-only startup compatibility detector; malformed settings are intentionally silent. */
16
+ export function legacyRoseThemeGuidanceFromSettings(path: string): string | undefined {
17
+ try {
18
+ if (!existsSync(path)) return undefined;
19
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
20
+ return parsed !== null && typeof parsed === "object" && "theme" in parsed
21
+ ? legacyRoseThemeGuidance((parsed as { theme?: unknown }).theme)
22
+ : undefined;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
@@ -22,6 +22,10 @@ type GenericTool = {
22
22
  [key: string]: unknown;
23
23
  };
24
24
 
25
+ type SubagentCompatibilityPlan =
26
+ | { kind: "forward"; params: unknown }
27
+ | { kind: "reject"; requestedBackend: "inline" | "auto"; error: string };
28
+
25
29
  type AgentHeading = {
26
30
  label: "Agent" | "Agents";
27
31
  summary: string;
@@ -29,6 +33,10 @@ type AgentHeading = {
29
33
 
30
34
  const MAX_AGENT_NAME_LENGTH = 48;
31
35
  const MAX_AGENT_SUMMARY_LENGTH = 120;
36
+ const INLINE_COMPATIBILITY_ERROR =
37
+ "inline backend is incompatible with Pi 0.81.1 and @agwab/pi-subagent 0.4.8; use backend \"headless\"";
38
+ const MIXED_PARALLEL_COMPATIBILITY_ERROR =
39
+ "auto backend cannot safely combine visible tasks with ordinary non-visible, non-sandboxed tasks on Pi 0.81.1 and @agwab/pi-subagent 0.4.8; split the fan-out or choose one explicit compatible backend";
32
40
 
33
41
  function isRecord(value: unknown): value is UnknownRecord {
34
42
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -88,6 +96,69 @@ function withRequiredExtensions(value: unknown): unknown {
88
96
  };
89
97
  }
90
98
 
99
+ function sandboxRequested(value: unknown): boolean {
100
+ return value !== undefined && value !== null && value !== false;
101
+ }
102
+
103
+ function taskSelector(
104
+ task: UnknownRecord,
105
+ parent: UnknownRecord,
106
+ ): { visible: boolean; sandboxed: boolean; plain: boolean } {
107
+ const visible = task.visible === undefined ? parent.visible === true : task.visible === true;
108
+ const sandbox = Object.hasOwn(task, "sandbox") ? task.sandbox : parent.sandbox;
109
+ const sandboxed = sandboxRequested(sandbox);
110
+ return { visible, sandboxed, plain: !visible && !sandboxed };
111
+ }
112
+
113
+ /**
114
+ * Select a Pi-0.81.1-compatible backend without changing compatible explicit,
115
+ * visible, sandboxed, or lifecycle requests. Parallel auto calls are evaluated
116
+ * per task because task-level visible/sandbox values override their parent.
117
+ */
118
+ export function normalizeSubagentCompatibility(params: unknown): SubagentCompatibilityPlan {
119
+ if (!isRecord(params) || (params.action !== undefined && params.action !== "run")) {
120
+ return { kind: "forward", params };
121
+ }
122
+
123
+ if (params.backend === "inline") {
124
+ return { kind: "reject", requestedBackend: "inline", error: INLINE_COMPATIBILITY_ERROR };
125
+ }
126
+ if (params.backend !== undefined && params.backend !== "auto") return { kind: "forward", params };
127
+
128
+ if (Array.isArray(params.tasks)) {
129
+ if (params.tasks.length === 0 || params.tasks.some((task) => !isRecord(task))) {
130
+ return { kind: "forward", params };
131
+ }
132
+ const selectors = params.tasks.map((task) => taskSelector(task as UnknownRecord, params));
133
+ const hasPlain = selectors.some((selector) => selector.plain);
134
+ const hasVisible = selectors.some((selector) => selector.visible);
135
+ if (hasPlain && hasVisible) {
136
+ return { kind: "reject", requestedBackend: "auto", error: MIXED_PARALLEL_COMPATIBILITY_ERROR };
137
+ }
138
+ if (hasPlain) return { kind: "forward", params: { ...params, backend: "headless" } };
139
+ return { kind: "forward", params };
140
+ }
141
+
142
+ if (params.visible === true || sandboxRequested(params.sandbox)) return { kind: "forward", params };
143
+ return { kind: "forward", params: { ...params, backend: "headless" } };
144
+ }
145
+
146
+ function compatibilityFailure(plan: Extract<SubagentCompatibilityPlan, { kind: "reject" }>): UnknownRecord {
147
+ const payload = {
148
+ tool: "subagent",
149
+ ...(plan.requestedBackend === "inline" ? { backend: "inline" } : {}),
150
+ requestedBackend: plan.requestedBackend,
151
+ status: "failed",
152
+ failureKind: "validation",
153
+ error: plan.error,
154
+ };
155
+ return {
156
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
157
+ details: { compatibility: payload },
158
+ isError: true,
159
+ };
160
+ }
161
+
91
162
  /**
92
163
  * Adds the immutable child credential guard to every run path while preserving
93
164
  * the ambient AILI permission-mode extension and all caller options.
@@ -113,7 +184,9 @@ export function wrapSubagentTool(tool: GenericTool): GenericTool {
113
184
  ...(typeof renderCall === "function"
114
185
  ? {
115
186
  renderCall(args: unknown, theme: RenderTheme): Component {
116
- const upstream = renderCall(args, theme);
187
+ const plan = normalizeSubagentCompatibility(args);
188
+ const effectiveArgs = plan.kind === "forward" ? plan.params : args;
189
+ const upstream = renderCall(effectiveArgs, theme);
117
190
  const heading = requestedAgentHeading(args);
118
191
  if (!heading) return upstream;
119
192
  const container = new Container();
@@ -128,22 +201,24 @@ export function wrapSubagentTool(tool: GenericTool): GenericTool {
128
201
  }
129
202
  : {}),
130
203
  async execute(...args: unknown[]) {
131
- // Pi has supported execute(params, ...) and execute(id, params, ...).
132
- // Delegate both shapes back to the upstream implementation unchanged
133
- // except for mandatory child extensions on `action: run`.
134
- const paramsIndex = args.length > 1 && args[1] !== undefined ? 1 : 0;
204
+ // Current Pi uses execute(id, params, ...); older direct fixtures may call
205
+ // execute(params). Detect the object position rather than mistaking a
206
+ // signal/callback for params.
207
+ const paramsIndex = isRecord(args[0]) || args.length === 1 ? 0 : 1;
208
+ const plan = normalizeSubagentCompatibility(args[paramsIndex]);
209
+ if (plan.kind === "reject") return compatibilityFailure(plan);
135
210
  const protectedArgs = [...args];
136
- protectedArgs[paramsIndex] = protectSubagentParams(protectedArgs[paramsIndex]);
211
+ protectedArgs[paramsIndex] = protectSubagentParams(plan.params);
137
212
  return await execute(...protectedArgs);
138
213
  },
139
214
  };
140
215
  }
141
216
 
142
217
  /**
143
- * Registers the upstream `subagent` tool unchanged in name, schema, execution,
144
- * and lifecycle behavior. AILI adds a bounded requested-Agent heading and
145
- * injects non-removable credential protection while each child loads the
146
- * ambient AILI permission-mode extension exactly once.
218
+ * Registers the upstream `subagent` name, schema, lifecycle, and worker engine.
219
+ * AILI wraps backend selection for the pinned SDK compatibility window, adds a
220
+ * bounded requested-Agent heading, and injects non-removable credential
221
+ * protection while each child loads permission modes exactly once.
147
222
  */
148
223
  export async function registerSubagent(pi: ExtensionAPI): Promise<void> {
149
224
  let genericToolRegistered = false;