diff --git a/extensions/child-capture.ts b/extensions/child-capture.ts new file mode 100644 index 000000000..e50afbde8 --- /dev/null +++ b/extensions/child-capture.ts @@ -0,0 +1,17 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { installSessionChangeCapture } from "../lib/session-change-capture.ts"; + +// gentle-shell#1688: isolated children do not load gentle-shell.ts in the +// Gentle Shell home, so child write/edit evidence capture is installed here. +// In the parent (where gentle-shell.ts already installs capture), this extension +// is inert to avoid duplicate handlers. +export function createChildCaptureExtension(env: NodeJS.ProcessEnv = process.env): (pi: ExtensionAPI) => void { + return (pi) => { + if (env.GENTLE_PI_AGENTS_CHILD !== "1") return; + installSessionChangeCapture(pi, env); + }; +} + +export default function childCaptureExtension(pi: ExtensionAPI): void { + createChildCaptureExtension()(pi); +} diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index dc6bb7f69..49a5620da 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -116,13 +116,13 @@ export interface AgentsDeps extends RunnerDeps { childExtensionPaths?: string[]; } -// gentle-shell#1587: children do not load the gentle-pi package in the -// isolated Gentle Shell home, so context filtering and destructive-command -// safety are passed to every child explicitly. Missing files are omitted; -// installations must include both entries to provide the delegated boundary. +// gentle-shell#1587, #1688: children do not load the gentle-pi package in the +// isolated Gentle Shell home, so context filtering, destructive-command safety, +// and session change capture are passed to every child explicitly. Missing files +// are omitted; installations must include all entries to provide the delegated boundary. export function childContextExtensionPaths(exists: (path: string) => boolean = existsSync): string[] { try { - return ["./child-context.ts", "./child-safety.ts"] + return ["./child-context.ts", "./child-safety.ts", "./child-capture.ts"] .map((path) => fileURLToPath(new URL(path, import.meta.url))) .filter(exists); } catch { diff --git a/odd/tasks/fix-1688-child-agents-change-capture.md b/odd/tasks/fix-1688-child-agents-change-capture.md new file mode 100644 index 000000000..74ea6487f --- /dev/null +++ b/odd/tasks/fix-1688-child-agents-change-capture.md @@ -0,0 +1,24 @@ +# Capture isolated child agent write/edit changes in /gentle:changes (#1688) + +## Objective and scope +Enable native `write` and `edit` operations performed by isolated child workers to emit verified `gentleSessionChange` evidence and populate the parent's `/gentle:changes` view. +Because isolated subagents do not load `gentle-shell.ts`, they lack the change capture hook installed in the parent. +1. Create `extensions/child-capture.ts` (inert in parent, active in children with `GENTLE_PI_AGENTS_CHILD === "1"`). +2. Include `./child-capture.ts` in `childContextExtensionPaths()` in `extensions/gentle-agents.ts`. +3. Verify with unit and integration tests that child write/edit mutations emit `gentleSessionChange` evidence and reach parent session change records. + +## Completed tasks +- [x] T1: Strict TDD test in `tests/gentle-agents.test.ts` reproducing missing child capture extension in `childContextExtensionPaths`. +- [x] T2: Create `extensions/child-capture.ts` installing `installSessionChangeCapture` for children. +- [x] T3: Add `./child-capture.ts` to `childContextExtensionPaths()` in `extensions/gentle-agents.ts`. +- [x] T4: Verify full test suite, typecheck, and package file integrity. + +## Evidence +Base: upstream/main at cf3012f7. Branch: fix/1688-child-agents-change-capture. +- TDD RED: test failed against base `main` with missing `/extensions/child-capture.ts` in `childContextExtensionPaths()`. +- TDD GREEN: 190/190 passed in `tests/gentle-agents.test.ts`. +- Child capture suite: 2/2 passed in `tests/child-capture.test.ts`. +- Session change capture suite: 6/6 passed in `tests/session-change-capture.test.ts`. +- Package manifest suite: 56/56 passed in `tests/package-manifest.test.ts`. +- Typecheck: 186 recorded baseline diagnostics, 0 regressions, 12 improved. +- Package file check: 155 files, 69 exact byte-pinned contract artifacts verified. diff --git a/tests/child-capture.test.ts b/tests/child-capture.test.ts new file mode 100644 index 000000000..25b484755 --- /dev/null +++ b/tests/child-capture.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createChildCaptureExtension } from "../extensions/child-capture.ts"; + +function harness(child: boolean) { + const registered: string[] = []; + const pi = { + on: (name: string) => { registered.push(name); }, + events: { on() { return () => {}; }, emit() {} }, + appendEntry() {}, + } as unknown as ExtensionAPI; + const env = { GENTLE_PI_AGENTS_CHILD: child ? "1" : "0" }; + createChildCaptureExtension(env)(pi); + return { registered }; +} + +test("child-only capture is inert in primary auto-discovery (#1688)", () => { + const { registered } = harness(false); + assert.equal(registered.length, 0, "must register no hooks in parent"); +}); + +test("children register session change capture lifecycle and tool hooks (#1688)", () => { + const { registered } = harness(true); + assert.ok(registered.includes("tool_call"), "child must register tool_call hook"); + assert.ok(registered.includes("tool_result"), "child must register tool_result hook"); + assert.ok(registered.includes("tool_execution_end"), "child must register tool_execution_end hook"); + assert.ok(registered.includes("session_start"), "child must register session_start hook"); + assert.ok(registered.includes("session_shutdown"), "child must register session_shutdown hook"); +}); diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 5e1838a78..d86c6d0c6 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -4826,13 +4826,14 @@ test("issue #1162: task-mode subagent_run includes question directly in waiting }); -// gentle-shell#1587: children do not load the gentle-pi package in the -// isolated Gentle Shell home, so every child receives the child-context -// extension explicitly through --extension. -test("children receive context and safety extensions, and missing files are omitted", async () => { +// gentle-shell#1587, #1688: children do not load the gentle-pi package in the +// isolated Gentle Shell home, so every child receives context, safety, and +// change-capture extensions explicitly through --extension. +test("children receive context, safety, and capture extensions, and missing files are omitted (#1688)", async () => { const expected = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-context.ts"); const safety = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-safety.ts"); - assert.deepEqual(childContextExtensionPaths(), [resolve(expected), resolve(safety)]); + const capture = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-capture.ts"); + assert.deepEqual(childContextExtensionPaths(), [resolve(expected), resolve(safety), resolve(capture)]); assert.deepEqual(childContextExtensionPaths(() => false), [], "a missing extension file fails safe to no --extension"); const extensionArguments = (args: string[]) => args.filter((_, index) => args[index - 1] === "--extension"); for (const scenario of ["present", "missing"] as const) { @@ -4846,7 +4847,7 @@ test("children receive context and safety extensions, and missing files are omit await h.tools.get("subagent_run")!.execute(`child-context-${scenario}`, { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx); await tick(); assert.equal(runtime.spawned.length, 1); - assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety)] : []); + assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety), resolve(capture)] : []); } finally { await h.fire("session_shutdown", ctx); await tick();