Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions extensions/child-capture.ts
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 5 additions & 5 deletions extensions/gentle-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions odd/tasks/fix-1688-child-agents-change-capture.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions tests/child-capture.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
13 changes: 7 additions & 6 deletions tests/gentle-agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand Down
Loading