gentle-pi 3.6.0 → 3.7.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/docs/gentle-shell.md +5 -4
- package/docs/readme-reference.md +11 -35
- package/extensions/gentle-agents.ts +89 -22
- package/lib/agents-runner.ts +9 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/native-review-cli.ts +4 -0
- package/lib/session-change-capture.ts +10 -1
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +4 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +22 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/gentle-agents.test.ts +362 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +50 -50
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/session-change-capture.test.ts +12 -1
|
@@ -342,7 +342,7 @@ async function main() {
|
|
|
342
342
|
});
|
|
343
343
|
|
|
344
344
|
if (driftedContracts.length > 0) {
|
|
345
|
-
console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.
|
|
345
|
+
console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.7.0 runtime's vendored Gentle AI contract artifacts:");
|
|
346
346
|
for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
|
|
347
347
|
process.exit(1);
|
|
348
348
|
}
|
|
@@ -387,7 +387,7 @@ async function main() {
|
|
|
387
387
|
process.exit(1);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.
|
|
390
|
+
console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.7.0 runtime).`);
|
|
391
391
|
}
|
|
392
392
|
|
|
393
393
|
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
@@ -400,6 +400,28 @@ for (const ending of ["cancel", "failure", "hook-error", "hook-async-error"] as
|
|
|
400
400
|
});
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
test("a queued pre-spawn denial fails only its task and keeps the runner queue moving", async () => {
|
|
404
|
+
const h = harness({ maxConcurrency: 1 });
|
|
405
|
+
const first = h.runner.run(request());
|
|
406
|
+
let allowed = true;
|
|
407
|
+
let registered = false;
|
|
408
|
+
const denied = h.runner.run(request({ beforeSpawn: () => { if (!allowed) throw new Error("grant expired"); }, onLaunch: () => { registered = true; } }));
|
|
409
|
+
const next = h.runner.run(request());
|
|
410
|
+
await tick();
|
|
411
|
+
assert.equal(h.children.length, 1);
|
|
412
|
+
allowed = false;
|
|
413
|
+
h.children[0].emit({ type: "agent_end", messages: [] });
|
|
414
|
+
h.children[0].emit({ type: "agent_settled" });
|
|
415
|
+
h.children[0].exit(0);
|
|
416
|
+
await tick();
|
|
417
|
+
assert.equal(h.store.get(denied.id)?.status, TASK_STATUS.FAILED);
|
|
418
|
+
assert.match(h.store.get(denied.id)?.error ?? "", /grant expired/);
|
|
419
|
+
assert.equal(registered, false);
|
|
420
|
+
assert.equal(h.children.length, 2, "a later valid task still starts");
|
|
421
|
+
assert.equal(h.store.get(next.id)?.status, TASK_STATUS.RUNNING);
|
|
422
|
+
assert.ok(h.store.get(first.id));
|
|
423
|
+
});
|
|
424
|
+
|
|
403
425
|
test("launch registration waits for actual spawn, including queued launches, and ignores failed spawns", async () => {
|
|
404
426
|
const launches: string[] = [];
|
|
405
427
|
const spawns: Array<() => void> = [];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts";
|
|
4
|
+
|
|
5
|
+
const target = { root: "/foreign", commonDir: "/foreign/.git" };
|
|
6
|
+
function context(confirm?: () => Promise<boolean>) {
|
|
7
|
+
let id = "session-one";
|
|
8
|
+
let calls = 0;
|
|
9
|
+
const ctx = { sessionManager: { getSessionId: () => id }, hasUI: !!confirm, ui: confirm ? { confirm: async () => { calls++; return confirm(); } } : undefined };
|
|
10
|
+
return { ctx, setId: (next: string) => { id = next; }, calls: () => calls };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("foreign grant requires interactive consent and reuses only the same live session and clone", async () => {
|
|
14
|
+
const grants = new ForeignTargetGrants();
|
|
15
|
+
const h = context(async () => true);
|
|
16
|
+
await grants.authorize(h.ctx as never, target);
|
|
17
|
+
await grants.authorize(h.ctx as never, target);
|
|
18
|
+
assert.equal(h.calls(), 1);
|
|
19
|
+
await grants.authorize(h.ctx as never, { root: "/another", commonDir: "/another/.git" });
|
|
20
|
+
assert.equal(h.calls(), 2);
|
|
21
|
+
h.setId("session-two");
|
|
22
|
+
assert.throws(() => grants.assertCurrent(h.ctx as never, target), /no longer live/);
|
|
23
|
+
await grants.authorize(h.ctx as never, target);
|
|
24
|
+
assert.equal(h.calls(), 3);
|
|
25
|
+
await new ForeignTargetGrants().authorize(h.ctx as never, target);
|
|
26
|
+
assert.equal(h.calls(), 4);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("continuation after a reload cannot recreate a lost grant by prompting", async () => {
|
|
30
|
+
const h = context(async () => true);
|
|
31
|
+
const original = new ForeignTargetGrants();
|
|
32
|
+
await original.authorize(h.ctx as never, target);
|
|
33
|
+
const reloaded = new ForeignTargetGrants();
|
|
34
|
+
await assert.rejects(reloaded.authorize(h.ctx as never, target, { continuation: true }), /grant.*lost|grant.*live/i);
|
|
35
|
+
assert.equal(h.calls(), 1, "a lost continuation grant must never prompt again");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("same-ID session-manager replacement revokes a grant without resurrecting it on continuation", async () => {
|
|
39
|
+
const h = context(async () => true);
|
|
40
|
+
const grants = new ForeignTargetGrants();
|
|
41
|
+
await grants.authorize(h.ctx as never, target);
|
|
42
|
+
const replacement = { ...h.ctx, sessionManager: { getSessionId: () => "session-one" } };
|
|
43
|
+
assert.throws(() => grants.assertCurrent(replacement as never, target), /no longer live/);
|
|
44
|
+
await assert.rejects(grants.authorize(replacement as never, target, { continuation: true }), /grant.*lost/i);
|
|
45
|
+
assert.equal(h.calls(), 1);
|
|
46
|
+
assert.throws(() => grants.assertCurrent(h.ctx as never, target), /no longer live/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("foreign grant fails closed on no UI, decline, cancellation, and identity change during consent", async () => {
|
|
50
|
+
await assert.rejects(new ForeignTargetGrants().authorize(context().ctx as never, target), /interactive/);
|
|
51
|
+
await assert.rejects(new ForeignTargetGrants().authorize(context(async () => false).ctx as never, target), /interactive/);
|
|
52
|
+
const cancelled = context(async () => { throw new Error("cancelled"); });
|
|
53
|
+
await assert.rejects(new ForeignTargetGrants().authorize(cancelled.ctx as never, target), /cancelled/);
|
|
54
|
+
const changed = context(async () => { changed.setId("replacement"); return true; });
|
|
55
|
+
const grants = new ForeignTargetGrants();
|
|
56
|
+
await assert.rejects(grants.authorize(changed.ctx as never, target), /identity changed/);
|
|
57
|
+
assert.throws(() => grants.assertCurrent(changed.ctx as never, target), /no longer live/);
|
|
58
|
+
});
|
|
@@ -7,10 +7,10 @@ import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:p
|
|
|
7
7
|
import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
|
|
8
8
|
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
|
|
9
9
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
10
|
-
import type
|
|
10
|
+
import { SessionChanges, type SessionChangeEvidence } from "../lib/session-changes.ts";
|
|
11
11
|
import test, { after, afterEach, mock } from "node:test";
|
|
12
12
|
import type { TestContext } from "node:test";
|
|
13
|
-
import type
|
|
13
|
+
import { generateUnifiedPatch, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
15
15
|
import { sidebarState } from "../lib/shell-sidebar.ts";
|
|
16
16
|
import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, createDefaultSessionTransport, legacySubagentsInstalled, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
|
|
@@ -1605,6 +1605,26 @@ async function childSessionChangeEvidence(root: string, relPath: string, toolCal
|
|
|
1605
1605
|
return result.details.gentleSessionChange;
|
|
1606
1606
|
}
|
|
1607
1607
|
|
|
1608
|
+
async function childSessionEditEvidence(root: string, relPath: string, toolCallId: string, before: string, after: string): Promise<SessionChangeEvidence> {
|
|
1609
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
1610
|
+
const childPi = {
|
|
1611
|
+
on: (key: string, fn: (event: unknown, ctx: unknown) => unknown) => handlers.set(key, fn),
|
|
1612
|
+
appendEntry: () => {},
|
|
1613
|
+
events: { on: () => () => {}, emit: () => {} },
|
|
1614
|
+
} as unknown as ExtensionAPI;
|
|
1615
|
+
const childCtx = { cwd: root, sessionManager: { getSessionId: () => "child-session", getEntries: () => [] } } as unknown as ExtensionContext;
|
|
1616
|
+
installSessionChangeCapture(childPi, { GENTLE_PI_AGENTS_CHILD: "1" }, resolveSessionWorktree);
|
|
1617
|
+
await handlers.get("session_start")?.({}, childCtx);
|
|
1618
|
+
writeFileSync(join(root, relPath), before);
|
|
1619
|
+
const event = { toolCallId, toolName: "edit", input: { path: relPath, oldText: before, newText: after } };
|
|
1620
|
+
await handlers.get("tool_call")?.(event, childCtx);
|
|
1621
|
+
writeFileSync(join(root, relPath), after);
|
|
1622
|
+
const details = { patch: generateUnifiedPatch(relPath, before, after) };
|
|
1623
|
+
const result = (await handlers.get("tool_result")?.({ ...event, isError: false, details }, childCtx)) as { details: { gentleSessionChange: SessionChangeEvidence } } | undefined;
|
|
1624
|
+
assert.ok(result?.details.gentleSessionChange, "successful child edit must carry captured evidence");
|
|
1625
|
+
return result.details.gentleSessionChange;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1608
1628
|
// C1 investigation (odd/tasks/usage-click-and-changes-attribution.md): tried
|
|
1609
1629
|
// to reproduce the live session's "16 subagent_run calls, zero relayed"
|
|
1610
1630
|
// evidence end to end — real repo, real git-backed resolver on both the
|
|
@@ -2153,6 +2173,346 @@ test("native spawn interception restores CommonJS and ESM exports after rejected
|
|
|
2153
2173
|
}
|
|
2154
2174
|
});
|
|
2155
2175
|
|
|
2176
|
+
test("foreign clone tool requires consent before queueing and never enters parent Changes", async (t) => {
|
|
2177
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-target-")));
|
|
2178
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign");
|
|
2179
|
+
const template = join(fixture, "template");
|
|
2180
|
+
mkdirSync(template);
|
|
2181
|
+
try {
|
|
2182
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2183
|
+
const configHome = join(fixture, "config");
|
|
2184
|
+
mkdirSync(configHome);
|
|
2185
|
+
writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { pinned: { explore: { model: "openai/foreign-model", thinking: "minimal" } } } }));
|
|
2186
|
+
mkdirSync(join(foreign, ".git", "gentle-ai"));
|
|
2187
|
+
writeFileSync(join(foreign, ".git", "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "pinned" }));
|
|
2188
|
+
const h = fakePi(), runtime = deps();
|
|
2189
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: configHome };
|
|
2190
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2191
|
+
const spawned: string[] = [];
|
|
2192
|
+
const spawnEnvs: Array<Record<string, string | undefined>> = [];
|
|
2193
|
+
const spawn = runtime.deps.spawn!;
|
|
2194
|
+
runtime.deps.spawn = (command, args, options) => { spawned.push(options.cwd); spawnEnvs.push(options.env); return spawn(command, args, options); };
|
|
2195
|
+
const runnerRun = t.mock.method(AgentRunner.prototype, "run");
|
|
2196
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2197
|
+
let resolveConsent!: (answer: boolean) => void;
|
|
2198
|
+
let prompts = 0;
|
|
2199
|
+
const { ctx } = fakeContext(fakeTui, () => { prompts++; return new Promise<boolean>(resolve => { resolveConsent = resolve; }); });
|
|
2200
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2201
|
+
await h.fire("session_start", ctx);
|
|
2202
|
+
const run = h.tools.get("subagent_run")!;
|
|
2203
|
+
assert.match(JSON.stringify(run.parameters.properties.repository_root), /interactive session-scoped consent/i);
|
|
2204
|
+
await assert.rejects(run.execute("wrong-selector", { agent: "explore", task: "Map", workspace_root: foreign, mode: "background" }, undefined, undefined, ctx), /same Git clone/);
|
|
2205
|
+
await assert.rejects(run.execute("both", { agent: "explore", task: "Map", workspace_root: parent, repository_root: foreign, mode: "background" }, undefined, undefined, ctx), /mutually exclusive/);
|
|
2206
|
+
await assert.rejects(run.execute("both-malformed", { agent: "explore", task: "Map", workspace_root: parent, repository_root: 123, mode: "background" }, undefined, undefined, ctx), /mutually exclusive/);
|
|
2207
|
+
const pending = run.execute("foreign", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2208
|
+
await tick();
|
|
2209
|
+
assert.equal(prompts, 1);
|
|
2210
|
+
assert.deepEqual(spawned, []);
|
|
2211
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2212
|
+
resolveConsent(true);
|
|
2213
|
+
const result = await pending;
|
|
2214
|
+
await tick();
|
|
2215
|
+
assert.deepEqual(spawned, [foreign]);
|
|
2216
|
+
assert.equal((runnerRun.mock.calls[0]?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, undefined, "foreign child must not receive parent review permission channel");
|
|
2217
|
+
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/foreign-model:minimal");
|
|
2218
|
+
assert.equal((result.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2219
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2220
|
+
const reused = await run.execute("reuse", { agent: "explore", task: "Map again", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2221
|
+
assert.equal(prompts, 1);
|
|
2222
|
+
assert.equal((reused.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2223
|
+
// Git's ambient routing must not turn an explicit foreign destination into the parent's repository.
|
|
2224
|
+
const oldGitDir = process.env.GIT_DIR;
|
|
2225
|
+
const oldGitWorkTree = process.env.GIT_WORK_TREE;
|
|
2226
|
+
try {
|
|
2227
|
+
process.env.GIT_DIR = join(parent, ".git");
|
|
2228
|
+
process.env.GIT_WORK_TREE = parent;
|
|
2229
|
+
runtime.deps.env!.GIT_DIR = join(parent, ".git");
|
|
2230
|
+
runtime.deps.env!.GIT_WORK_TREE = parent;
|
|
2231
|
+
const injected = await run.execute("injected-git", { agent: "explore", task: "Map with ambient Git routing", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2232
|
+
assert.equal((injected.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2233
|
+
assert.equal(prompts, 1);
|
|
2234
|
+
// Drain the active child so the queued injected launch reaches the actual OS spawn.
|
|
2235
|
+
runtime.children[0].emit({ type: "agent_end", messages: [] });
|
|
2236
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2237
|
+
runtime.children[0].exit(0);
|
|
2238
|
+
await tick();
|
|
2239
|
+
assert.equal(spawned.at(-1), foreign);
|
|
2240
|
+
assert.equal(spawnEnvs.at(-1)?.GIT_DIR, undefined);
|
|
2241
|
+
assert.equal(spawnEnvs.at(-1)?.GIT_WORK_TREE, undefined);
|
|
2242
|
+
} finally {
|
|
2243
|
+
delete runtime.deps.env!.GIT_DIR;
|
|
2244
|
+
delete runtime.deps.env!.GIT_WORK_TREE;
|
|
2245
|
+
if (oldGitDir === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR = oldGitDir;
|
|
2246
|
+
if (oldGitWorkTree === undefined) delete process.env.GIT_WORK_TREE; else process.env.GIT_WORK_TREE = oldGitWorkTree;
|
|
2247
|
+
}
|
|
2248
|
+
const queued = await run.execute("queued", { agent: "explore", task: "Third map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2249
|
+
assert.equal((queued.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2250
|
+
assert.equal(runtime.children.length, 3, "later launches wait in the runner queue");
|
|
2251
|
+
await run.execute("same-clone", { agent: "explore", task: "Map parent", workspace_root: parent, mode: "background" }, undefined, undefined, ctx);
|
|
2252
|
+
assert.equal(typeof (runnerRun.mock.calls.at(-1)?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, "function", "same-clone child retains parent review permission channel");
|
|
2253
|
+
const { ctx: successor } = fakeContext();
|
|
2254
|
+
successor.sessionManager.getCwd = () => parent;
|
|
2255
|
+
await h.fire("session_start", successor);
|
|
2256
|
+
assert.notEqual(successor.sessionManager, ctx.sessionManager);
|
|
2257
|
+
assert.equal(successor.sessionManager.getSessionId(), ctx.sessionManager.getSessionId(), "replacement retains the same session ID");
|
|
2258
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
2259
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2260
|
+
runtime.children[0].exit(0);
|
|
2261
|
+
await tick();
|
|
2262
|
+
assert.equal(runtime.children.length, 3, "stale queued foreign task must fail before OS spawn");
|
|
2263
|
+
await h.fire("session_shutdown", successor);
|
|
2264
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
test("foreign child Changes require successful target-bound tool evidence, never model claims or sibling writes", async () => {
|
|
2268
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-changes-")));
|
|
2269
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), sibling = join(fixture, "sibling"), template = join(fixture, "template");
|
|
2270
|
+
mkdirSync(template);
|
|
2271
|
+
try {
|
|
2272
|
+
for (const path of [parent, foreign, sibling]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2273
|
+
const h = fakePi(), runtime = deps();
|
|
2274
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2275
|
+
const spawn = runtime.deps.spawn!;
|
|
2276
|
+
let proveSpawn: (() => void) | undefined;
|
|
2277
|
+
runtime.deps.spawn = (...args) => {
|
|
2278
|
+
const child = spawn(...args);
|
|
2279
|
+
const on = child.on.bind(child);
|
|
2280
|
+
child.on = ((event: string, listener: () => void) => {
|
|
2281
|
+
if (event === "spawn") proveSpawn = listener;
|
|
2282
|
+
return on(event as "spawn", listener);
|
|
2283
|
+
}) as typeof child.on;
|
|
2284
|
+
return child;
|
|
2285
|
+
};
|
|
2286
|
+
installSessionChangeCapture(h.pi, {}, resolveSessionWorktree);
|
|
2287
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2288
|
+
const { ctx } = fakeContext();
|
|
2289
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2290
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
2291
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
2292
|
+
await h.fire("session_start", ctx);
|
|
2293
|
+
const launched = await h.tools.get("subagent_run")!.execute("foreign-evidence", { agent: "explore", task: "Write", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2294
|
+
await tick();
|
|
2295
|
+
const taskId = (launched.details.gentleAgents as { taskId: string }).taskId;
|
|
2296
|
+
const child = runtime.children[0];
|
|
2297
|
+
const send = async (id: string, root: string, path: string, error = false, forgedRoot?: string) => {
|
|
2298
|
+
const captured = await childSessionChangeEvidence(root, path, id, "agent output\n");
|
|
2299
|
+
const evidence = forgedRoot ? { ...captured, root: forgedRoot } : captured;
|
|
2300
|
+
child.emit({ type: "tool_execution_start", toolCallId: id, toolName: "write", args: { path } });
|
|
2301
|
+
child.emit({ type: "tool_execution_end", toolCallId: id, isError: error, result: { content: [], details: { gentleSessionChange: evidence } } });
|
|
2302
|
+
await tick();
|
|
2303
|
+
};
|
|
2304
|
+
child.emit({ type: "message_update", text: `I edited ${join(foreign, "claimed.md")}` });
|
|
2305
|
+
await send("unspawned", foreign, "unspawned.md");
|
|
2306
|
+
assert.equal(h.entries.some(entry => entry.customType === "gentle-pi.session-change/v1"), false, "unproven spawn cannot attribute a mutation");
|
|
2307
|
+
assert.ok(proveSpawn);
|
|
2308
|
+
proveSpawn();
|
|
2309
|
+
await tick();
|
|
2310
|
+
await send("sibling", sibling, "sibling.md");
|
|
2311
|
+
await send("forged", foreign, "forged.md", false, parent);
|
|
2312
|
+
await send("failed", foreign, "failed.md", true);
|
|
2313
|
+
assert.equal(h.entries.some(entry => entry.customType === "gentle-pi.session-change/v1"), false);
|
|
2314
|
+
await send("accepted", foreign, "accepted.md");
|
|
2315
|
+
const spacedEvidence = await childSessionChangeEvidence(foreign, "space name.md", "unicode-space", "agent output\n");
|
|
2316
|
+
child.emit({ type: "tool_execution_start", toolCallId: "unicode-space", toolName: "write", args: { path: "space\u00a0name.md" } });
|
|
2317
|
+
child.emit({ type: "tool_execution_end", toolCallId: "unicode-space", isError: false, result: { content: [], details: { gentleSessionChange: spacedEvidence } } });
|
|
2318
|
+
await tick();
|
|
2319
|
+
const editEvidence = await childSessionEditEvidence(foreign, "edited.md", "edit-accepted", "original\n", "changed\n");
|
|
2320
|
+
child.emit({ type: "tool_execution_start", toolCallId: "edit-accepted", toolName: "edit", args: { path: "edited.md" } });
|
|
2321
|
+
child.emit({ type: "tool_execution_end", toolCallId: "edit-accepted", isError: false, result: { content: [], details: { gentleSessionChange: editEvidence } } });
|
|
2322
|
+
await tick();
|
|
2323
|
+
const changes = new SessionChanges(ctx.sessionManager.getSessionId()!, h.entries);
|
|
2324
|
+
assert.deepEqual(changes.worktrees.map(tree => tree.root), [foreign]);
|
|
2325
|
+
assert.deepEqual(changes.model.files.map(file => file.path).sort(), ["accepted.md", "edited.md", "space name.md"]);
|
|
2326
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2327
|
+
assert.equal(h.events.some(event => event.name === "gentle-pi:child-session-change"), false);
|
|
2328
|
+
assert.equal(h.entries.filter(entry => entry.customType === "gentle-pi.session-change/v1").length, 3);
|
|
2329
|
+
assert.equal(changes.worktrees[0]?.model.files.find(file => file.path === "accepted.md")?.status, "added");
|
|
2330
|
+
assert.equal(changes.worktrees[0]?.model.files.find(file => file.path === "edited.md")?.status, "modified");
|
|
2331
|
+
mkdirSync(join(foreign, "nested"));
|
|
2332
|
+
const rebound = await childSessionChangeEvidence(foreign, "nested/rebound.md", "rebound", "not attributed\n");
|
|
2333
|
+
child.emit({ type: "tool_execution_start", toolCallId: "rebound", toolName: "write", args: { path: "nested/rebound.md" } });
|
|
2334
|
+
execFileSync("git", ["init", "--quiet", `--template=${template}`, join(foreign, "nested")]);
|
|
2335
|
+
child.emit({ type: "tool_execution_end", toolCallId: "rebound", isError: false, result: { content: [], details: { gentleSessionChange: rebound } } });
|
|
2336
|
+
await tick();
|
|
2337
|
+
assert.equal(h.entries.filter(entry => entry.customType === "gentle-pi.session-change/v1").length, 3, "new nested Git identity must not inherit foreign target attribution");
|
|
2338
|
+
const stale = await childSessionChangeEvidence(foreign, "stale.md", "stale", "not attributed\n");
|
|
2339
|
+
child.emit({ type: "tool_execution_start", toolCallId: "stale", toolName: "write", args: { path: "stale.md" } });
|
|
2340
|
+
const { ctx: successor } = fakeContext();
|
|
2341
|
+
successor.sessionManager.getCwd = () => parent;
|
|
2342
|
+
await h.fire("session_start", successor);
|
|
2343
|
+
child.emit({ type: "tool_execution_end", toolCallId: "stale", isError: false, result: { content: [], details: { gentleSessionChange: stale } } });
|
|
2344
|
+
await tick();
|
|
2345
|
+
assert.equal(h.entries.filter(entry => entry.customType === "gentle-pi.session-change/v1").length, 3, "session replacement during a tool cannot attribute its result");
|
|
2346
|
+
assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 0);
|
|
2347
|
+
await h.fire("session_shutdown", successor);
|
|
2348
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
test("foreign task mode waits for the child and continuation reuses its live grant", async () => {
|
|
2352
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-task-")));
|
|
2353
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2354
|
+
mkdirSync(template);
|
|
2355
|
+
try {
|
|
2356
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2357
|
+
const h = fakePi(), runtime = deps();
|
|
2358
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2359
|
+
const spawn = runtime.deps.spawn!;
|
|
2360
|
+
runtime.deps.spawn = (...args) => {
|
|
2361
|
+
const child = spawn(...args);
|
|
2362
|
+
const on = child.on.bind(child);
|
|
2363
|
+
child.on = ((event: string, listener: () => void) => {
|
|
2364
|
+
if (event === "spawn") queueMicrotask(listener);
|
|
2365
|
+
return on(event as "spawn", listener);
|
|
2366
|
+
}) as typeof child.on;
|
|
2367
|
+
return child;
|
|
2368
|
+
};
|
|
2369
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2370
|
+
let prompts = 0;
|
|
2371
|
+
const { ctx } = fakeContext(fakeTui, async () => { prompts++; return true; });
|
|
2372
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2373
|
+
await h.fire("session_start", ctx);
|
|
2374
|
+
let finished = false;
|
|
2375
|
+
const pending = h.tools.get("subagent_run")!.execute("task", { agent: "explore", task: "Map", repository_root: foreign, mode: "task" }, undefined, undefined, ctx).then(result => { finished = true; return result; });
|
|
2376
|
+
await tick();
|
|
2377
|
+
assert.equal(finished, false, "task mode waits for settlement");
|
|
2378
|
+
assert.equal(runtime.children.length, 1);
|
|
2379
|
+
assert.equal(prompts, 1);
|
|
2380
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
2381
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2382
|
+
runtime.children[0].exit(0);
|
|
2383
|
+
const first = await pending;
|
|
2384
|
+
assert.equal((first.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2385
|
+
const taskId = (first.details.gentleAgents as { taskId: string }).taskId;
|
|
2386
|
+
const continued = h.tools.get("subagent_continue")!.execute("follow-up", { task_id: taskId, prompt: "Follow up", mode: "task" }, undefined, undefined, ctx);
|
|
2387
|
+
await tick();
|
|
2388
|
+
assert.equal(runtime.children.length, 2);
|
|
2389
|
+
assert.equal(prompts, 1, "continuation cannot prompt for a second grant");
|
|
2390
|
+
runtime.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "continued" }] }] });
|
|
2391
|
+
runtime.children[1].emit({ type: "agent_settled" });
|
|
2392
|
+
runtime.children[1].exit(0);
|
|
2393
|
+
assert.equal(((await continued).details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2394
|
+
assert.equal(runtime.spawned.length, 2);
|
|
2395
|
+
await h.fire("session_shutdown", ctx);
|
|
2396
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
test("aborting during foreign consent cannot grant or queue a background child", async () => {
|
|
2400
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-abort-")));
|
|
2401
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2402
|
+
mkdirSync(template);
|
|
2403
|
+
try {
|
|
2404
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2405
|
+
const h = fakePi(), runtime = deps();
|
|
2406
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2407
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2408
|
+
let confirm!: (answer: boolean) => void;
|
|
2409
|
+
const { ctx } = fakeContext(fakeTui, () => new Promise(resolve => { confirm = resolve; }));
|
|
2410
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2411
|
+
await h.fire("session_start", ctx);
|
|
2412
|
+
const abort = new AbortController();
|
|
2413
|
+
const pending = h.tools.get("subagent_run")!.execute("abort", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, abort.signal, undefined, ctx);
|
|
2414
|
+
await tick();
|
|
2415
|
+
abort.abort("interrupted");
|
|
2416
|
+
confirm(true);
|
|
2417
|
+
await assert.rejects(pending, /abort|cancel/i);
|
|
2418
|
+
assert.equal(runtime.children.length, 0);
|
|
2419
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2420
|
+
await h.fire("session_shutdown", ctx);
|
|
2421
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
test("an interactive non-Git umbrella can target an independent repository without registering it", async () => {
|
|
2425
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-umbrella-")));
|
|
2426
|
+
const umbrella = join(fixture, "umbrella"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2427
|
+
mkdirSync(umbrella); mkdirSync(template);
|
|
2428
|
+
try {
|
|
2429
|
+
execFileSync("git", ["init", "--quiet", `--template=${template}`, foreign]);
|
|
2430
|
+
const h = fakePi(), runtime = deps();
|
|
2431
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2432
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2433
|
+
const { ctx } = fakeContext();
|
|
2434
|
+
ctx.sessionManager.getCwd = () => umbrella;
|
|
2435
|
+
await h.fire("session_start", ctx);
|
|
2436
|
+
const run = h.tools.get("subagent_run")!;
|
|
2437
|
+
const launched = await run.execute("umbrella", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2438
|
+
assert.equal((launched.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2439
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2440
|
+
await h.fire("session_shutdown", ctx);
|
|
2441
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
test("foreign selector rejects RPC even with UI and rejects SDD/remediation before consent", async () => {
|
|
2445
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-callers-")));
|
|
2446
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2447
|
+
mkdirSync(template);
|
|
2448
|
+
try {
|
|
2449
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2450
|
+
const h = fakePi(), runtime = deps();
|
|
2451
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2452
|
+
const agentHome = join(fixture, "agent-home");
|
|
2453
|
+
mkdirSync(join(agentHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2454
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: explore\ntools: [read]\n---\nExplore");
|
|
2455
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nApply");
|
|
2456
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "sdd-research.md"), "---\ndescription: research\ntools: [read]\n---\nResearch");
|
|
2457
|
+
runtime.deps.home = agentHome;
|
|
2458
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2459
|
+
const { ctx, dialogs } = fakeContext();
|
|
2460
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2461
|
+
await h.fire("session_start", ctx);
|
|
2462
|
+
ctx.mode = "rpc";
|
|
2463
|
+
const run = h.tools.get("subagent_run")!;
|
|
2464
|
+
const base = { agent: "explore", task: "Map", repository_root: foreign, mode: "background" };
|
|
2465
|
+
await assert.rejects(run.execute("rpc", base, undefined, undefined, ctx), /interactive parent session/);
|
|
2466
|
+
ctx.mode = "print";
|
|
2467
|
+
await assert.rejects(run.execute("print", { ...base, mode: "task" }, undefined, undefined, ctx), /interactive parent session/);
|
|
2468
|
+
ctx.mode = "tui";
|
|
2469
|
+
await assert.rejects(run.execute("remediate", { ...base, remediation: {} }, undefined, undefined, ctx), /interactive parent session/);
|
|
2470
|
+
await assert.rejects(run.execute("foreign-sdd", { ...base, agent: "sdd-apply", context: PARENT_CONFIRMED_SDD_CONTEXT, sdd_change: { changeName: "alpha", workspaceRoot: foreign, phase: "apply" } }, undefined, undefined, ctx), /interactive parent session/);
|
|
2471
|
+
await assert.rejects(run.execute("foreign-sdd-no-change", { ...base, agent: "sdd-research", context: PARENT_CONFIRMED_SDD_CONTEXT }, undefined, undefined, ctx), /interactive parent session/);
|
|
2472
|
+
assert.equal(existsSync(join(agentHome, ".pi", "agent", "sessions")), false, "foreign SDD rejection must precede child session directory creation");
|
|
2473
|
+
const childHost = fakePi();
|
|
2474
|
+
gentleAgents(childHost.pi, { GENTLE_PI_AGENTS_CHILD: "1" }, runtime.deps);
|
|
2475
|
+
assert.equal(childHost.tools.has("subagent_run"), false, "child-originated foreign launches have no delegation tool");
|
|
2476
|
+
assert.equal(dialogs.length, 0);
|
|
2477
|
+
assert.equal(runtime.children.length, 0);
|
|
2478
|
+
await h.fire("session_shutdown", ctx);
|
|
2479
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2480
|
+
});
|
|
2481
|
+
|
|
2482
|
+
test("foreign clone rejects aliases, absent UI, decline and changed session before any child starts", async () => {
|
|
2483
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-denials-")));
|
|
2484
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2485
|
+
mkdirSync(template);
|
|
2486
|
+
try {
|
|
2487
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2488
|
+
const h = fakePi(), runtime = deps();
|
|
2489
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2490
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2491
|
+
let decision!: (answer: boolean) => void;
|
|
2492
|
+
const { ctx, dialogs } = fakeContext(fakeTui, () => new Promise(resolve => { decision = resolve; }));
|
|
2493
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2494
|
+
let id = "original";
|
|
2495
|
+
ctx.sessionManager.getSessionId = () => id;
|
|
2496
|
+
await h.fire("session_start", ctx);
|
|
2497
|
+
const run = h.tools.get("subagent_run")!;
|
|
2498
|
+
const args = (path: string) => ({ agent: "explore", task: "Map", repository_root: path, mode: "background" });
|
|
2499
|
+
await assert.rejects(run.execute("alias", args(`${foreign}/.`), undefined, undefined, ctx), /canonical independent Git repository/);
|
|
2500
|
+
ctx.hasUI = false;
|
|
2501
|
+
await assert.rejects(run.execute("no-ui", args(foreign), undefined, undefined, ctx), /interactive/);
|
|
2502
|
+
ctx.hasUI = true;
|
|
2503
|
+
const declined = run.execute("decline", args(foreign), undefined, undefined, ctx);
|
|
2504
|
+
await tick(); decision(false);
|
|
2505
|
+
await assert.rejects(declined, /interactive/);
|
|
2506
|
+
const drift = run.execute("drift", args(foreign), undefined, undefined, ctx);
|
|
2507
|
+
await tick(); id = "replacement"; decision(true);
|
|
2508
|
+
await assert.rejects(drift, /identity changed/);
|
|
2509
|
+
assert.equal(runtime.spawned.length, 0);
|
|
2510
|
+
assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 2);
|
|
2511
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2512
|
+
await h.fire("session_shutdown", ctx);
|
|
2513
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2156
2516
|
test("explicit child roots launch and continue in the actual cwd, persist without shell, and reject other clones", async () => {
|
|
2157
2517
|
const h = fakePi();
|
|
2158
2518
|
const runtime = deps();
|
|
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
|
|
|
97
97
|
method: "go-sumdb-source-build",
|
|
98
98
|
package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
|
|
99
99
|
module: "github.com/gentleman-programming/gentle-ai/v3",
|
|
100
|
-
tag: "v3.
|
|
100
|
+
tag: "v3.7.0",
|
|
101
101
|
architecture: process.arch === "x64" ? "x64" : "arm64",
|
|
102
102
|
binarySha256: createHash("sha256").update(binary).digest("hex"),
|
|
103
103
|
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|