@selesai/code 0.13.32 → 0.13.34

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/extensions/capability-gateway/catalog.ts +4 -1
  3. package/dist/extensions/capability-gateway/index.test.ts +93 -4
  4. package/dist/extensions/capability-gateway/index.ts +294 -59
  5. package/dist/extensions/capability-gateway/integration.test.ts +71 -0
  6. package/dist/extensions/capability-gateway/routing.test.ts +154 -1
  7. package/dist/extensions/capability-gateway/routing.ts +227 -45
  8. package/dist/extensions/jev/decisions.test.ts +37 -0
  9. package/dist/extensions/jev/decisions.ts +148 -43
  10. package/dist/extensions/jev-ask-tool.test.ts +436 -0
  11. package/dist/extensions/jev-ask-tool.ts +587 -0
  12. package/dist/extensions/package.json +1 -0
  13. package/dist/extensions/pi-hermes-memory/README.md +11 -36
  14. package/dist/extensions/pi-hermes-memory/src/config.ts +36 -8
  15. package/dist/extensions/pi-hermes-memory/src/constants.ts +5 -5
  16. package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +60 -46
  17. package/dist/extensions/pi-hermes-memory/tests/config.test.ts +26 -2
  18. package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +7 -1
  19. package/dist/extensions/pi-intercom/index.ts +5 -1
  20. package/dist/extensions/pi-subagents/agents/worker.md +3 -2
  21. package/dist/extensions/pi-subagents/docs/agents.md +2 -0
  22. package/dist/extensions/pi-subagents/docs/extension-api.md +3 -1
  23. package/dist/extensions/pi-subagents/docs/observability.md +2 -0
  24. package/dist/extensions/pi-subagents/docs/tool-reference.md +2 -2
  25. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  26. package/dist/extensions/pi-subagents/src/extension/rpc.ts +10 -1
  27. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +3 -21
  28. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -1
  29. package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +1 -0
  30. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
  31. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +10 -3
  32. package/dist/extensions/pi-subagents/src/runs/background/workflow-terminal-proof.ts +67 -0
  33. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +6 -2
  34. package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +67 -5
  35. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +6 -0
  36. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +2 -1
  37. package/dist/extensions/pi-subagents/src/runs/shared/git-environment.ts +29 -0
  38. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +69 -0
  39. package/dist/extensions/pi-subagents/src/shared/types.ts +20 -0
  40. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +4 -238
  41. package/dist/extensions/pi-subagents/src/slash/subagent-cost.ts +280 -0
  42. package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +67 -0
  43. package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +29 -1
  44. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +6 -3
  45. package/dist/extensions/pi-subagents/test/integration/single-execution.part-2.test.ts +25 -0
  46. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +5 -5
  47. package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +21 -0
  48. package/dist/extensions/pi-subagents/test/unit/child-tool-plan-permission-system.test.ts +98 -0
  49. package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +11 -0
  50. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +12 -0
  51. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +25 -0
  52. package/dist/extensions/pi-subagents/test/unit/git-environment.test.ts +38 -0
  53. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +3 -1
  54. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +105 -1
  55. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +36 -0
  56. package/dist/extensions/pi-subagents/test/unit/structured-output-rejection.test.ts +67 -0
  57. package/dist/extensions/pi-subagents/test/unit/workflow-terminal-proof.test.ts +98 -0
  58. package/dist/extensions/rtk.test.ts +21 -13
  59. package/dist/extensions/tps.test.ts +32 -1
  60. package/dist/extensions/tps.ts +3 -1
  61. package/dist/skills/pi-subagents/references/constraints-and-recipes.md +8 -8
  62. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  63. package/dist/skills/pi-subagents/references/prompting-and-roles.md +1 -1
  64. package/docs/settings.md +46 -7
  65. package/package.json +3 -3
@@ -9,6 +9,7 @@ import { describe, it, before, after, beforeEach } from "node:test";
9
9
  import assert from "node:assert/strict";
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  import type { MockPi } from "../support/helpers.ts";
13
14
  import { createMockPi, createTempDir, makeAgent, makeAgentConfigs, removeTempDir } from "../support/helpers.ts";
14
15
  import { runSync } from "../../src/runs/foreground/execution.ts";
@@ -17,6 +18,9 @@ import { createNestedRoute } from "../../src/runs/shared/nested-events.ts";
17
18
  import { createStructuredOutputRuntime } from "../../src/runs/shared/structured-output.ts";
18
19
  import type { ForegroundChildSessionControls, SingleResult } from "../../src/shared/types.ts";
19
20
 
21
+ /** Explicit child tool surfaces also receive the scoped capability gateway controls. */
22
+ const CHILD_CAPABILITY_GATEWAY_TOOLS = ["capability_catalog", "capability_discover", "capability_skill_show"];
23
+
20
24
  async function waitFor(read: () => boolean, timeoutMs = 5_000): Promise<void> {
21
25
  const deadline = Date.now() + timeoutMs;
22
26
  while (!read()) {
@@ -71,6 +75,30 @@ describe("in-process foreground child", () => {
71
75
  }
72
76
  });
73
77
 
78
+ it("passes wrapped core tools and the Graft provider to a launched foreground child", async () => {
79
+ const wrapperPath = path.join(tempDir, "wrapped-core-tools.ts");
80
+ const graftExtensionPath = fileURLToPath(new URL("../../../pi-graft/index.ts", import.meta.url));
81
+ const tools = ["read", "grep", "graft_find_code"];
82
+ fs.writeFileSync(wrapperPath, [
83
+ "export default function register(pi) {",
84
+ " const available = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));",
85
+ " for (const name of ['read', 'grep']) {",
86
+ " const tool = available.get(name);",
87
+ " if (tool) pi.registerTool({ ...tool, description: 'wrapped ' + name + ': ' + tool.description });",
88
+ " }",
89
+ "}",
90
+ ].join("\n"));
91
+ mockPi.onCall({ output: "read, grep, and Graft tools were available" });
92
+ const result = await runSync(tempDir, [makeAgent("worker", { tools, subagentOnlyExtensions: [wrapperPath, graftExtensionPath] })], "worker", "Inspect with read, grep, and Graft", { runId: "wrapped-graft-child" });
93
+ assert.equal(result.exitCode, 0, result.error);
94
+ const launch = mockPi.sessions[0]?.launch;
95
+ const expectedTools = [...tools, ...CHILD_CAPABILITY_GATEWAY_TOOLS];
96
+ assert.deepEqual(launch?.tools, expectedTools);
97
+ assert.deepEqual(launch?.runtime.requiredTools, expectedTools);
98
+ assert.ok(launch?.extensionPaths.includes(wrapperPath));
99
+ assert.ok(launch?.extensionPaths.includes(graftExtensionPath));
100
+ });
101
+
74
102
  it("adds the fanout hook and nested route only for fanout-authorized children", async () => {
75
103
  const route = createNestedRoute("hooks-fanout");
76
104
  try {
@@ -84,7 +112,7 @@ describe("in-process foreground child", () => {
84
112
  assert.equal(session?.launch.runtime.fanoutChild, true);
85
113
  assert.deepEqual(session?.launch.runtime.nestedRoute, route);
86
114
  assert.deepEqual(session?.launch.runtime.nestedParent, { parentRunId: "hooks-fanout", parentChildIndex: 0, depth: 1, path: [{ runId: "hooks-fanout", stepIndex: 0, agent: "delegator" }] });
87
- assert.deepEqual(session?.launch.tools, ["read", "subagent"]);
115
+ assert.deepEqual(session?.launch.tools, ["read", "subagent", ...CHILD_CAPABILITY_GATEWAY_TOOLS]);
88
116
  } finally {
89
117
  fs.rmSync(path.dirname(route.eventSink), { recursive: true, force: true });
90
118
  }
@@ -23,6 +23,9 @@ import {
23
23
  tryImport,
24
24
  } from "../support/helpers.ts";
25
25
 
26
+ /** Children with an explicit tool surface also receive the scoped capability gateway controls. */
27
+ const CHILD_CAPABILITY_GATEWAY_TOOLS = "capability_catalog,capability_discover,capability_skill_show";
28
+
26
29
  interface ExecutorResult {
27
30
  content: Array<{ text?: string }>;
28
31
  isError?: boolean;
@@ -308,8 +311,8 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
308
311
 
309
312
  const isolatedArgs = await readMockCallArgs(0);
310
313
  const normalArgs = await readMockCallArgs(1);
311
- assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1], "read");
312
- assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1], "read,contact_supervisor");
314
+ assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
315
+ assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1], `read,contact_supervisor,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
313
316
  assert.equal(events.emitted.filter((entry) => entry.channel === "subagent:result-intercom").length, 1);
314
317
  assert.deepEqual(result.details?.workflow?.value, { isolated: "Isolated child output", normal: "Normal child output" });
315
318
  });
@@ -1106,7 +1109,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
1106
1109
  const args = await readMockCallArgs(0);
1107
1110
  assert.equal(args[args.indexOf("--session") + 1], sessionFile);
1108
1111
  assert.equal(args[args.indexOf("--model") + 1], "anthropic/claude-sonnet-4:high");
1109
- assert.equal(args[args.indexOf("--tools") + 1], "read");
1112
+ assert.equal(args[args.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
1110
1113
  assert.equal(args.includes("--system-prompt"), true);
1111
1114
  assert.equal(args.includes("--append-system-prompt"), false);
1112
1115
  await waitForFile(path.join(RESULTS_DIR, `${revivedId}.json`));
@@ -1623,6 +1623,31 @@ if (!fs.existsSync(${JSON.stringify(holdPath)})) { console.log('{}'); } else {
1623
1623
  if (child?.artifactPaths?.outputPath) assert.match(fs.readFileSync(child.artifactPaths.outputPath, "utf-8"), /"note": "captured"/);
1624
1624
  });
1625
1625
 
1626
+ it("reports bounded structured_output rejection evidence without leaking the submitted value", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1627
+ const secret = "PRIVATE_STRUCTURED_OUTPUT_VALUE";
1628
+ mockPi.onCall({
1629
+ stdoutRaw: [
1630
+ { type: "tool_execution_start", toolCallId: "structured-rejected", toolName: "structured_output", args: { value: { secret } } },
1631
+ { type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-rejected", toolName: "structured_output", isError: true, content: [{ type: "text", text: `Structured output validation failed: value.secret: expected boolean; received value: ${secret}` }] } },
1632
+ { type: "tool_execution_end", toolCallId: "structured-rejected", toolName: "structured_output", isError: true },
1633
+ ].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
1634
+ });
1635
+ const executor = makeExecutor([makeAgent("echo")]);
1636
+ const result = await executor.execute(
1637
+ "single-schema-rejected",
1638
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false },
1639
+ new AbortController().signal,
1640
+ undefined,
1641
+ makeMinimalCtx(tempDir),
1642
+ );
1643
+
1644
+ const child = result.details?.results?.[0];
1645
+ assert.equal(result.isError, true);
1646
+ assert.equal(child?.structuredOutputFailed, true);
1647
+ assert.match(child?.error ?? "", /Structured output validation failed: value.secret/);
1648
+ assert.doesNotMatch(child?.error ?? "", new RegExp(secret));
1649
+ });
1650
+
1626
1651
  it("routes retained workflow follow-ups to distinct outputs without overwriting the writer report", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1627
1652
  for (const relative of [false, true]) {
1628
1653
  const writerPath = path.join(tempDir, `writer-${relative}.md`);
@@ -638,16 +638,16 @@ Do work
638
638
  assert.equal(worker?.defaultContext, "fork");
639
639
  });
640
640
 
641
- it("loads packaged worker and oracle with fork defaultContext and advisor alias", () => {
641
+ it("loads packaged worker with fresh writer defaults and keeps oracle fork context", () => {
642
642
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-builtin-default-context-"));
643
643
  tempDirs.push(dir);
644
644
  const agents = discoverAgentsAll(dir).builtin;
645
645
 
646
- for (const name of ["worker", "oracle"]) {
647
- const agent = agents.find((candidate) => candidate.name === name);
648
- assert.equal(agent?.defaultContext, "fork", `${name} should default to fork context`);
649
- }
646
+ const worker = agents.find((candidate) => candidate.name === "worker");
647
+ assert.equal(worker?.defaultContext, "fresh");
648
+ assert.equal(worker?.acceptanceRole, "writer");
650
649
  const oracle = agents.find((candidate) => candidate.name === "oracle");
650
+ assert.equal(oracle?.defaultContext, "fork");
651
651
  assert.deepEqual(oracle?.aliases, ["advisor"]);
652
652
  assert.doesNotMatch(oracle?.tools?.join(",") ?? "", /contact_supervisor/);
653
653
  for (const name of ["scout", "researcher", "oracle", "reviewer"]) {
@@ -37,7 +37,19 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
37
37
  fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: pkg, version: "0.85.0", exports }));
38
38
  }
39
39
  const originalArgv1 = process.argv[1];
40
+ const inheritedGitEnv = {
41
+ GIT_DIR: "/outer/repo/.git",
42
+ GIT_WORK_TREE: "/outer/repo",
43
+ GIT_INDEX_FILE: "/outer/repo/.git/index",
44
+ GIT_CONFIG_COUNT: "1",
45
+ GIT_CONFIG_KEY_0: "core.hooksPath",
46
+ GIT_CONFIG_VALUE_0: "/outer/hooks",
47
+ GIT_AUTHOR_NAME: "Kept Author",
48
+ GIT_ENV_SENTINEL_KEEP: "kept",
49
+ };
50
+ const previousGitEnv = Object.fromEntries(Object.keys(inheritedGitEnv).map((key) => [key, process.env[key]]));
40
51
  try {
52
+ Object.assign(process.env, inheritedGitEnv);
41
53
  for (const pkg of Object.keys(hostExports)) {
42
54
  if (pkg !== server) writeHostPackage(pkg);
43
55
  }
@@ -79,6 +91,11 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
79
91
  assert.equal(spawn.mock.callCount(), scenario === "supplemental" ? 1 : scenario === "complete" ? 2 : 3);
80
92
  const [command, args, options] = spawn.mock.calls.at(-1)!.arguments;
81
93
  assert.ok(path.isAbsolute(command));
94
+ for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"]) {
95
+ assert.equal(options.env[key], undefined, key);
96
+ }
97
+ assert.equal(options.env.GIT_AUTHOR_NAME, "Kept Author");
98
+ assert.equal(options.env.GIT_ENV_SENTINEL_KEEP, "kept");
82
99
  assert.equal(options.env[SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV], host);
83
100
  const actualAliases = JSON.parse(options.env.JITI_ALIAS) as Record<string, string>;
84
101
  assert.deepEqual(Object.fromEntries(Object.entries(actualAliases).map(([key, target]) => [key, fs.realpathSync(target)])), expectedAliases);
@@ -96,6 +113,10 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
96
113
  } finally {
97
114
  t.mock.restoreAll();
98
115
  syncBuiltinESMExports();
116
+ for (const [key, value] of Object.entries(previousGitEnv)) {
117
+ if (value === undefined) delete process.env[key];
118
+ else process.env[key] = value;
119
+ }
99
120
  if (originalArgv1 === undefined) delete process.argv[1];
100
121
  else process.argv[1] = originalArgv1;
101
122
  fs.rmSync(root, { recursive: true, force: true });
@@ -11,6 +11,7 @@ import * as path from "node:path";
11
11
  import { afterEach, describe, it } from "node:test";
12
12
  import { buildInProcessChildLaunch, type BuildInProcessChildLaunchInput } from "../../src/runs/shared/child-launch.ts";
13
13
  import {
14
+ CAPABILITY_GATEWAY_EXTENSION_PATH,
14
15
  resolvePermissionSystemExtension,
15
16
  resolvePiLaunchToolPlan,
16
17
  } from "../../src/runs/shared/child-tool-plan.ts";
@@ -19,6 +20,7 @@ const originalEnv = {
19
20
  HOME: process.env.HOME,
20
21
  USERPROFILE: process.env.USERPROFILE,
21
22
  SELESAI_CODING_AGENT_DIR: process.env.SELESAI_CODING_AGENT_DIR,
23
+ SELESAI_CAPABILITY_GATEWAY: process.env.SELESAI_CAPABILITY_GATEWAY,
22
24
  };
23
25
  const tempRoots: string[] = [];
24
26
 
@@ -33,6 +35,7 @@ function createFixture() {
33
35
  process.env.HOME = home;
34
36
  process.env.USERPROFILE = home;
35
37
  process.env.SELESAI_CODING_AGENT_DIR = agentDir;
38
+ delete process.env.SELESAI_CAPABILITY_GATEWAY;
36
39
  process.chdir(projectDir);
37
40
  return { root, agentDir, projectDir };
38
41
  }
@@ -397,3 +400,98 @@ describe("child launch <active_agent> tag injection", () => {
397
400
  );
398
401
  });
399
402
  });
403
+
404
+ describe("capability gateway child runtime policy", () => {
405
+ const gatewayTools = ["capability_catalog", "capability_discover", "capability_skill_show"];
406
+
407
+ for (const host of ["parent", "runner"] as const) {
408
+ it(`adds only gateway controls for extension-permitted ${host} children`, () => {
409
+ const { agentDir } = createFixture();
410
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
411
+
412
+ const launch = buildInProcessChildLaunch(childLaunch({ host, tools: ["read", "web_explore"] }));
413
+ assert.equal(launch.toolPlan.capabilityGatewayEnabled, true);
414
+ assert.ok(launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
415
+ assert.deepEqual(launch.session.tools, ["read", "web_explore", ...gatewayTools]);
416
+ assert.ok(gatewayTools.every((tool) => launch.toolPlan.requiredChildTools.includes(tool)));
417
+ if (host === "runner") {
418
+ assert.equal(launch.session.ambientExtensions, true);
419
+ assert.ok(launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
420
+ } else {
421
+ assert.equal(launch.session.ambientExtensions, false);
422
+ assert.equal(launch.session.processEnv, undefined);
423
+ }
424
+ });
425
+ }
426
+
427
+ it("keeps omitted tools unrestricted without synthesizing an explicit allowlist", () => {
428
+ const { agentDir } = createFixture();
429
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
430
+ const launch = buildInProcessChildLaunch(childLaunch({ host: "runner" }));
431
+ assert.equal(launch.toolPlan.capabilityGatewayEnabled, true);
432
+ assert.equal(launch.session.tools, undefined);
433
+ assert.ok(launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
434
+ });
435
+
436
+ for (const host of ["parent", "runner"] as const) {
437
+ it(`skips gateway for ${host} children with an explicit empty tool set`, () => {
438
+ const { agentDir } = createFixture();
439
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
440
+
441
+ const launch = buildInProcessChildLaunch(childLaunch({ host, tools: [] }));
442
+ assert.equal(launch.toolPlan.capabilityGatewayEnabled, false);
443
+ assert.deepEqual(launch.session.tools, []);
444
+ assert.ok(!launch.toolPlan.extensionArgs.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
445
+ assert.ok(!launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
446
+ });
447
+ }
448
+
449
+ it("does not widen a capability ceiling and honors explicit extension policy", () => {
450
+ const { agentDir, projectDir } = createFixture();
451
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
452
+ const limited = resolvePiLaunchToolPlan({
453
+ tools: ["read", "web_explore"],
454
+ capabilityCeiling: { version: 1, allowedTools: ["read", "web_explore"], denyExtensions: false, sources: ["test"] },
455
+ });
456
+ assert.equal(limited.capabilityGatewayEnabled, false);
457
+ assert.deepEqual(limited.effectiveToolAllowlist, ["read", "web_explore"]);
458
+ assert.ok(!limited.extensionArgs.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
459
+
460
+ const explicitBlock = resolvePiLaunchToolPlan({
461
+ tools: ["read"],
462
+ extensions: [path.join(projectDir, "other-extension.ts")],
463
+ });
464
+ assert.equal(explicitBlock.capabilityGatewayEnabled, false);
465
+ assert.ok(!explicitBlock.extensionArgs.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
466
+
467
+ const explicitOptIn = resolvePiLaunchToolPlan({
468
+ tools: ["read"],
469
+ extensions: [],
470
+ subagentOnlyExtensions: [path.dirname(CAPABILITY_GATEWAY_EXTENSION_PATH)],
471
+ });
472
+ assert.equal(explicitOptIn.capabilityGatewayEnabled, true);
473
+ assert.ok(explicitOptIn.extensionArgs.includes(path.dirname(CAPABILITY_GATEWAY_EXTENSION_PATH)));
474
+ assert.ok(explicitOptIn.effectiveToolAllowlist.includes("capability_discover"));
475
+ });
476
+
477
+ for (const host of ["parent", "runner"] as const) {
478
+ it(`does not load or authorize the gateway for ${host} children with denyExtensions`, () => {
479
+ const { agentDir } = createFixture();
480
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
481
+
482
+ const launch = buildInProcessChildLaunch(childLaunch({
483
+ host,
484
+ tools: ["read"],
485
+ subagentOnlyExtensions: [CAPABILITY_GATEWAY_EXTENSION_PATH],
486
+ capabilityCeiling: { version: 1, allowedTools: ["read"], denyExtensions: true, sources: ["test"] },
487
+ }));
488
+ assert.equal(launch.toolPlan.capabilityGatewayEnabled, false);
489
+ assert.deepEqual(launch.session.tools, ["read"]);
490
+ assert.ok(!launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
491
+ assert.ok(!launch.toolPlan.requiredChildTools.some((tool) => tool.startsWith("capability_")));
492
+ if (host === "runner") {
493
+ assert.equal(launch.session.ambientExtensions, false);
494
+ }
495
+ });
496
+ }
497
+ });
@@ -19,6 +19,17 @@ function runtimeSnapshotHost(serverName: string): McpRuntimeSnapshotHost {
19
19
  }
20
20
 
21
21
  describe("child tool plan", () => {
22
+ it("requires fanout authorization for the child supervisor reply tool", () => {
23
+ assert.throws(() => resolvePiLaunchToolPlan({ tools: ["read", "subagent_supervisor"] }), /subagent_supervisor.*requires fanout authorization/);
24
+ assert.throws(() => resolvePiLaunchToolPlan({ tools: ["read", "subagent", "subagent_supervisor"], excludeTools: ["subagent"] }), /subagent_supervisor.*requires fanout authorization/);
25
+ assert.throws(() => resolvePiLaunchToolPlan({
26
+ tools: ["read", "subagent", "subagent_supervisor"],
27
+ capabilityCeiling: { version: 1, allowedTools: ["read", "subagent_supervisor"], sources: ["test"] },
28
+ }), /subagent_supervisor.*requires fanout authorization/);
29
+ assert.equal(resolvePiLaunchToolPlan({ tools: ["read", "subagent", "subagent_supervisor"] }).fanoutAuthorized, true);
30
+ assert.equal(resolvePiLaunchToolPlan({ tools: ["read", "subagent_supervisor"], allowNestedSubagents: true }).fanoutAuthorized, true);
31
+ });
32
+
22
33
  it("fails a launch that selects MCP tools from the adapter's runtime snapshot", () => {
23
34
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-runtime-mcp-"));
24
35
  try {
@@ -7,6 +7,7 @@ import {
7
7
  evaluateCompletionMutationGuard,
8
8
  expectsImplementationMutation,
9
9
  hasMutationToolCall,
10
+ hasMutationToolCapability,
10
11
  validateImplementationToolContract,
11
12
  } from "../../src/runs/shared/completion-guard.ts";
12
13
  import { isMutatingTool } from "../../src/runs/shared/long-running-guard.ts";
@@ -500,6 +501,17 @@ test("edit and write tool calls count as mutation attempts", () => {
500
501
  assert.equal(hasMutationToolCall([assistantToolCall("write", { path: "a.ts" })]), true);
501
502
  });
502
503
 
504
+ test("capability gateway controls never count as mutation capability", () => {
505
+ const gatewayTools = ["capability_catalog", "capability_discover", "capability_skill_show"];
506
+ assert.equal(hasMutationToolCapability(["read", "grep", ...gatewayTools], undefined), false);
507
+ assert.equal(validateImplementationToolContract({
508
+ agent: "worker",
509
+ task: "Implement the requested source fix.",
510
+ tools: ["read", "grep", "find", "ls", "contact_supervisor", ...gatewayTools],
511
+ }), "Agent 'worker' was given an implementation task, but its tool allowlist has no mutation-capable tools. Add bash, edit, write, or another mutation-capable tool to the agent, or use a read-only task/agent.");
512
+ assert.equal(hasMutationToolCapability(["read", "web_explore"], undefined), true);
513
+ });
514
+
503
515
  test("declared extension mutation tools count without weakening unknown tools", () => {
504
516
  const messages = [assistantToolCall("replace", { remove_from: "Liv" })];
505
517
  assert.equal(hasMutationToolCall(messages), false);
@@ -43,6 +43,31 @@ describe("external CLI runner", () => {
43
43
  }
44
44
  });
45
45
 
46
+ it("strips inherited Git routing vars but honors an explicit environment allowlist", async () => {
47
+ const dir = tempDir();
48
+ const inherited = { GIT_DIR: "/outer/repo/.git", GIT_AUTHOR_NAME: "Kept Author" };
49
+ const previous = Object.fromEntries(Object.keys(inherited).map((key) => [key, process.env[key]]));
50
+ Object.assign(process.env, inherited);
51
+ const script = "process.stdout.write(JSON.stringify({gitDir:process.env.GIT_DIR??null,author:process.env.GIT_AUTHOR_NAME??null}))";
52
+ try {
53
+ const defaultEnv = await runExternalCli({ command: process.execPath, args: ["-e", script], cwd: dir, prompt: "x", asyncDir: dir, stepIndex: 1 });
54
+ assert.equal(defaultEnv.exitCode, 0);
55
+ assert.deepEqual(JSON.parse(defaultEnv.output), { gitDir: null, author: "Kept Author" });
56
+
57
+ const explicitEnv = await runExternalCli({
58
+ command: process.execPath, args: ["-e", script], cwd: dir, prompt: "x", asyncDir: dir, stepIndex: 2,
59
+ environment: { allowlist: ["GIT_DIR"] },
60
+ });
61
+ assert.equal(explicitEnv.exitCode, 0);
62
+ assert.deepEqual(JSON.parse(explicitEnv.output), { gitDir: "/outer/repo/.git", author: null });
63
+ } finally {
64
+ for (const [key, value] of Object.entries(previous)) {
65
+ if (value === undefined) delete process.env[key];
66
+ else process.env[key] = value;
67
+ }
68
+ }
69
+ });
70
+
46
71
  it("delivers the combined prompt only through stdin and preserves argv", async () => {
47
72
  const dir = tempDir();
48
73
  const prompt = buildExternalCliPrompt("Follow exactly.", "Review $HOME; echo nope");
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { omitGitRoutingEnv } from "../../src/runs/shared/git-environment.ts";
4
+
5
+ const GIT_ROUTING_ENV = {
6
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: "/repo/objects",
7
+ GIT_COMMON_DIR: "/repo/common",
8
+ GIT_CONFIG: "/repo/config",
9
+ GIT_CONFIG_COUNT: "1",
10
+ GIT_CONFIG_KEY_0: "core.worktree",
11
+ GIT_CONFIG_PARAMETERS: "'core.worktree=/repo'",
12
+ GIT_CONFIG_VALUE_0: "/repo",
13
+ GIT_DIR: "/repo/.git",
14
+ GIT_GRAFT_FILE: "/repo/grafts",
15
+ GIT_IMPLICIT_WORK_TREE: "1",
16
+ GIT_INDEX_FILE: "/repo/index",
17
+ GIT_NAMESPACE: "tenant",
18
+ GIT_NO_REPLACE_OBJECTS: "1",
19
+ GIT_OBJECT_DIRECTORY: "/repo/object-dir",
20
+ GIT_PREFIX: "src/",
21
+ GIT_REPLACE_REF_BASE: "refs/replace/",
22
+ GIT_SHALLOW_FILE: "/repo/shallow",
23
+ GIT_WORK_TREE: "/repo",
24
+ };
25
+
26
+ describe("omitGitRoutingEnv", () => {
27
+ it("removes inherited repository routing and keeps unrelated values", () => {
28
+ assert.deepEqual(omitGitRoutingEnv({ ...GIT_ROUTING_ENV, KEEP_ME: "yes" }), { KEEP_ME: "yes" });
29
+ });
30
+
31
+ it("removes every indexed Git config entry", () => {
32
+ assert.deepEqual(omitGitRoutingEnv({ GIT_CONFIG_KEY_17: "user.name", GIT_CONFIG_VALUE_17: "Test", KEEP_ME: "yes" }), { KEEP_ME: "yes" });
33
+ });
34
+
35
+ it("matches names case-insensitively, as Windows and Git for Windows do", () => {
36
+ assert.deepEqual(omitGitRoutingEnv({ git_dir: "/repo/.git", Git_Work_Tree: "/repo", git_config_key_0: "core.bare", KEEP_ME: "yes" }), { KEEP_ME: "yes" });
37
+ });
38
+ });
@@ -857,7 +857,9 @@ Project prompt.
857
857
  assert.equal(result.ok, true);
858
858
  if (!result.ok) return;
859
859
  assert.deepEqual(result.contract.tools.excludeTools, ["write", "unknown_tool"]);
860
- assert.deepEqual(result.contract.tools.effectiveAllowlist, ["read"]);
860
+ // An explicit tool surface also gets the scoped capability gateway tools, which
861
+ // this launch does not exclude (see the capability-gateway child policy tests).
862
+ assert.deepEqual(result.contract.tools.effectiveAllowlist, ["read", "capability_catalog", "capability_discover", "capability_skill_show"]);
861
863
  assert.match(result.contract.launchContractDigest, /^[a-f0-9]{64}$/);
862
864
  });
863
865
 
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import { describe, it } from "node:test";
6
6
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
7
7
  import { consumeStopRequestPayload, stopRequestPath, stopRequestsDir } from "../../src/runs/background/control-channel.ts";
8
+ import { getArtifactPaths, getArtifactsDir } from "../../src/shared/artifacts.ts";
8
9
  import {
9
10
  SUBAGENT_RPC_PROTOCOL_VERSION,
10
11
  SUBAGENT_RPC_READY_EVENT,
@@ -13,7 +14,7 @@ import {
13
14
  subagentRpcReplyEvent,
14
15
  type SubagentRpcReplyEnvelope,
15
16
  } from "../../src/extension/rpc.ts";
16
- import { SUBAGENT_CHILD_STATUS_EVENT, type Details, type SubagentChildStatusEvent, type SubagentState } from "../../src/shared/types.ts";
17
+ import { DIRS, SUBAGENT_CHILD_STATUS_EVENT, type Details, type SubagentChildStatusEvent, type SubagentState } from "../../src/shared/types.ts";
17
18
 
18
19
  class FakeEvents {
19
20
  readonly emitted: Array<{ event: string; data: unknown }> = [];
@@ -117,6 +118,8 @@ describe("subagent extension RPC bridge", () => {
117
118
  (reply as { data: { capabilities?: { statusProjection?: unknown } } }).data.capabilities?.statusProjection,
118
119
  { version: 1, untargeted: "in-memory-when-ready", targeted: "executor" },
119
120
  );
121
+ assert.deepEqual((reply as { data: { capabilities?: { cost?: unknown } } }).data.capabilities?.cost, { version: 1 });
122
+ assert.ok((reply as { data: { methods?: string[] } }).data.methods?.includes("cost"));
120
123
 
121
124
  bridge.dispose();
122
125
  });
@@ -1147,4 +1150,105 @@ describe("subagent extension RPC bridge", () => {
1147
1150
  fs.rmSync(root, { recursive: true, force: true });
1148
1151
  }
1149
1152
  });
1153
+
1154
+ it("answers cost with structured parent-plus-child accounting from the session branch", async () => {
1155
+ const events = new FakeEvents();
1156
+ const childUsage = { input: 8, output: 3, cacheRead: 5, cacheWrite: 0, cost: 0.25, turns: 1 };
1157
+ const branch = [
1158
+ { type: "message", message: { role: "assistant", usage: { input: 15, output: 3, cacheRead: 30, cacheWrite: 0, cost: { total: 0.25 } } } },
1159
+ { type: "message", message: { role: "toolResult", toolName: "subagent", details: { mode: "single", results: [{ agent: "reviewer", runId: "run-a", usage: childUsage, sessionFile: "/sessions/child-a.jsonl" }] } } },
1160
+ { type: "message", message: { role: "toolResult", toolName: "bg_wait", details: { mode: "single", results: [], completions: [{ mode: "single", runId: "run-a", results: [{ agent: "reviewer", runId: "run-a", usage: childUsage }] }] } } },
1161
+ ];
1162
+ const bridge = registerSubagentRpcBridge({
1163
+ events,
1164
+ getContext: () => ({
1165
+ cwd: "/repo",
1166
+ sessionManager: { getSessionId: () => "session-123", getSessionFile: () => "/sessions/parent.jsonl", getBranch: () => branch },
1167
+ }) as any,
1168
+ execute: async () => assert.fail("cost should not call executor"),
1169
+ state: { baseCwd: "/repo" } as SubagentState,
1170
+ });
1171
+
1172
+ const reply = await request(events, "cost-1", "cost");
1173
+ assert.equal(reply.success, true);
1174
+ assert.equal(reply.method, "cost");
1175
+ const data = (reply as { data: { version: number; parent: Record<string, number>; children: Array<{ agent?: string; runId?: string }>; childTotal: Record<string, number>; total: Record<string, number>; unresolvedAsyncChildren: number } }).data;
1176
+ assert.equal(data.version, 1);
1177
+ assert.deepEqual(data.parent, { input: 15, output: 3, cacheRead: 30, cacheWrite: 0, cost: 0.25, turns: 1 });
1178
+ assert.equal(data.children.length, 1, "run identity deduplicates the bg_wait completion");
1179
+ assert.equal(data.children[0]?.agent, "reviewer");
1180
+ assert.equal(data.children[0]?.runId, "run-a");
1181
+ assert.deepEqual(data.childTotal, childUsage);
1182
+ assert.deepEqual(data.total, { input: 23, output: 6, cacheRead: 35, cacheWrite: 0, cost: 0.5, turns: 2 });
1183
+ assert.equal(data.unresolvedAsyncChildren, 0);
1184
+
1185
+ const rejected = await request(events, "cost-2", "cost", "not-an-object");
1186
+ assert.equal(rejected.success, false);
1187
+ assert.equal((rejected as { error: { code: string } }).error.code, "invalid_params");
1188
+ bridge.dispose();
1189
+ });
1190
+
1191
+ it("answers cost with receipt-recovered async usage and an unresolved lower bound", async () => {
1192
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-rpc-cost-async-"));
1193
+ const workflowRunId = `workflow-rpc-cost-${process.pid}-${Date.now()}`;
1194
+ const recoveredRunId = `child-rpc-cost-${process.pid}-${Date.now()}`;
1195
+ const unresolvedRunId = `child-rpc-cost-missing-${process.pid}-${Date.now()}`;
1196
+ const unresolvedWorkflowRunId = `workflow-rpc-cost-missing-${process.pid}-${Date.now()}`;
1197
+ const asyncDir = path.join(DIRS.async, workflowRunId);
1198
+ try {
1199
+ const sessionFile = path.join(root, "sessions", "parent.jsonl");
1200
+ const artifactsDir = getArtifactsDir(sessionFile, root, "session");
1201
+ const metadataPath = getArtifactPaths(artifactsDir, recoveredRunId, "reviewer", 0).metadataPath;
1202
+ fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
1203
+ fs.writeFileSync(sessionFile, "", "utf-8");
1204
+ fs.mkdirSync(asyncDir, { recursive: true });
1205
+ fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
1206
+ fs.writeFileSync(path.join(asyncDir, "workflow-receipt.json"), JSON.stringify({
1207
+ version: 1, workflowRunId, state: "complete", createdAt: Date.now(),
1208
+ entries: {
1209
+ recovered: { key: "recovered", agent: "reviewer", latestRunId: recoveredRunId, continuation: { runIds: [recoveredRunId] }, resumability: { state: "resumable" } },
1210
+ unresolved: { key: "unresolved", agent: "worker", latestRunId: unresolvedRunId, continuation: { runIds: [unresolvedRunId] }, resumability: { state: "resumable" } },
1211
+ nestedWorkflow: { key: "nestedWorkflow", latestRunId: unresolvedWorkflowRunId, continuation: { runIds: [unresolvedWorkflowRunId] }, resumability: { state: "resumable" } },
1212
+ },
1213
+ }, null, 2), "utf-8");
1214
+ const recoveredUsage = { input: 20, output: 4, cacheRead: 80, cacheWrite: 0, cost: 0.5, turns: 2 };
1215
+ fs.writeFileSync(metadataPath, JSON.stringify({ runId: recoveredRunId, agent: "reviewer", usage: recoveredUsage }), "utf-8");
1216
+
1217
+ const events = new FakeEvents();
1218
+ const bridge = registerSubagentRpcBridge({
1219
+ events,
1220
+ getContext: () => ({
1221
+ cwd: root,
1222
+ sessionManager: {
1223
+ getSessionId: () => "session-async-cost",
1224
+ getSessionFile: () => sessionFile,
1225
+ getBranch: () => [{ type: "message", message: { role: "toolResult", toolName: "subagent", details: { mode: "workflow", runId: workflowRunId, results: [] } } }],
1226
+ },
1227
+ }) as any,
1228
+ execute: async () => assert.fail("cost should not call executor"),
1229
+ state: { baseCwd: root, artifactDirPreference: "session" } as SubagentState,
1230
+ });
1231
+
1232
+ const reply = await request(events, "cost-async", "cost");
1233
+ assert.equal(reply.success, true);
1234
+ const data = (reply as { data: { children: Array<{ agent?: string; runId?: string }>; childTotal: Record<string, number>; total: Record<string, number>; unresolvedAsyncChildren: number } }).data;
1235
+ assert.deepEqual(data.children.map(({ agent, runId }) => ({ agent, runId })), [{ agent: "reviewer", runId: recoveredRunId }]);
1236
+ assert.deepEqual(data.childTotal, recoveredUsage);
1237
+ assert.deepEqual(data.total, recoveredUsage, "totals remain a lower bound when metadata is unavailable");
1238
+ assert.equal(data.unresolvedAsyncChildren, 2);
1239
+ bridge.dispose();
1240
+ } finally {
1241
+ fs.rmSync(asyncDir, { recursive: true, force: true });
1242
+ fs.rmSync(root, { recursive: true, force: true });
1243
+ }
1244
+ });
1245
+
1246
+ it("cost requires an active session context like every non-ping method", async () => {
1247
+ const events = new FakeEvents();
1248
+ const bridge = registerSubagentRpcBridge({ events, getContext: () => null, execute: async () => assert.fail("cost should not call executor") });
1249
+ const reply = await request(events, "cost-3", "cost");
1250
+ assert.equal(reply.success, false);
1251
+ assert.equal((reply as { error: { code: string } }).error.code, "no_active_session");
1252
+ bridge.dispose();
1253
+ });
1150
1254
  });
@@ -1630,4 +1630,40 @@ describe("async run status inspection", () => {
1630
1630
  fs.rmSync(root, { recursive: true, force: true });
1631
1631
  }
1632
1632
  });
1633
+
1634
+ it("returns a workflow terminal proof after every async child exits", () => {
1635
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-status-workflow-terminal-"));
1636
+ try {
1637
+ const asyncRoot = path.join(root, "runs");
1638
+ const asyncDir = path.join(asyncRoot, "workflow-parent");
1639
+ const childDir = path.join(asyncRoot, "child-run");
1640
+ fs.mkdirSync(asyncDir, { recursive: true });
1641
+ fs.mkdirSync(childDir);
1642
+ const childProof = {
1643
+ version: 1, state: "observed", runId: "child-run", runnerProcessInstanceId: "runner-1", observedAt: 300,
1644
+ instances: [{ kind: "runner", processInstanceId: "runner-1", closeObservedAt: 300, exitCode: 0, signal: null }],
1645
+ };
1646
+ fs.writeFileSync(path.join(childDir, "process-terminal.json"), JSON.stringify(childProof));
1647
+ fs.writeFileSync(path.join(childDir, "status.json"), JSON.stringify({
1648
+ runId: "child-run", mode: "single", state: "complete", startedAt: 100, lastUpdate: 300,
1649
+ processTerminal: { version: 1, state: "pending", runId: "child-run", runnerProcessInstanceId: "runner-1" },
1650
+ }));
1651
+ fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
1652
+ runId: "workflow-parent", mode: "workflow", state: "complete", startedAt: 100, lastUpdate: 200, endedAt: 250,
1653
+ steps: [{ agent: "worker", workflowKey: "main", runId: "child-run", async: true, status: "completed" }],
1654
+ workflowChildren: {
1655
+ version: 1, parentToolCallId: "tool-call", workflowRunId: "workflow-parent", inventoryComplete: true,
1656
+ workflowState: "completed", children: [{ childId: "main", runId: "child-run", state: "completed" }],
1657
+ },
1658
+ }, null, 2), "utf-8");
1659
+
1660
+ const result = inspectSubagentStatus({ id: "workflow-parent" }, { asyncDirRoot: asyncRoot, resultsDir: path.join(root, "results") });
1661
+ assert.deepEqual(result.details.workflowTerminalProof, {
1662
+ version: 1, kind: "workflow", runId: "workflow-parent", state: "observed", dispatchClosed: true,
1663
+ observedAt: 300, children: [childProof],
1664
+ });
1665
+ } finally {
1666
+ fs.rmSync(root, { recursive: true, force: true });
1667
+ }
1668
+ });
1633
1669
  });