gentle-pi 3.2.0 → 3.3.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.
Files changed (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -5,11 +5,14 @@ import { homedir, tmpdir } from "node:os";
5
5
  import { createRequire, syncBuiltinESMExports } from "node:module";
6
6
  import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
7
7
  import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
8
- import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED } from "../lib/session-worktree-registry.ts";
8
+ import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
9
+ import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
10
+ import type { SessionChangeEvidence } from "../lib/session-changes.ts";
9
11
  import test, { after, afterEach, mock } from "node:test";
10
12
  import type { TestContext } from "node:test";
11
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
14
+ import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
15
+ import { sidebarState } from "../lib/shell-sidebar.ts";
13
16
  import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, createDefaultSessionTransport, legacySubagentsInstalled, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
14
17
  import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
15
18
  import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
@@ -462,6 +465,21 @@ const drainLifecycle = async (label: string, promise: Promise<LifecycleOutcome>)
462
465
  catch (error) { return { label, error }; }
463
466
  };
464
467
 
468
+ // The disk history read behind restoreSessionHistory is fire-and-forget from
469
+ // session_start, so nothing signals when it lands. Poll the overlay's own
470
+ // render output (backed by the live, shared TaskStore) on setImmediate ticks
471
+ // instead of sleeping a fixed guess: fast when the restore already landed,
472
+ // bounded at 2s so a genuine regression still fails instead of hanging.
473
+ async function waitForOverlayMatch(render: () => string, pattern: RegExp, timeoutMs = 2000): Promise<string> {
474
+ const deadline = Date.now() + timeoutMs;
475
+ let rendered = render();
476
+ while (!pattern.test(rendered) && Date.now() < deadline) {
477
+ await tick();
478
+ rendered = render();
479
+ }
480
+ return rendered;
481
+ }
482
+
465
483
  test("overlapping session transport startups preserve ownership and shutdown waits for every pending operation", async (t: TestContext) => {
466
484
  const h = fakePi();
467
485
  const runtime = deps();
@@ -1359,6 +1377,131 @@ for (const matching of [true, false]) {
1359
1377
  });
1360
1378
  }
1361
1379
 
1380
+ // Runs the CHILD half of installSessionChangeCapture against a real repo, the
1381
+ // same way an actual subagent process would: a tool_call/tool_result pair
1382
+ // with GENTLE_PI_AGENTS_CHILD set, using the real git-backed resolver rather
1383
+ // than a stub. Returns exactly the evidence object the child would put in
1384
+ // its tool result's details.gentleSessionChange.
1385
+ async function childSessionChangeEvidence(root: string, relPath: string, toolCallId: string, content: string): Promise<SessionChangeEvidence> {
1386
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
1387
+ const childPi = {
1388
+ on: (key: string, fn: (event: unknown, ctx: unknown) => unknown) => handlers.set(key, fn),
1389
+ appendEntry: () => {},
1390
+ events: { on: () => () => {}, emit: () => {} },
1391
+ } as unknown as ExtensionAPI;
1392
+ const childCtx = { cwd: root, sessionManager: { getSessionId: () => "child-session", getEntries: () => [] } } as unknown as ExtensionContext;
1393
+ installSessionChangeCapture(childPi, { GENTLE_PI_AGENTS_CHILD: "1" }, resolveSessionWorktree);
1394
+ await handlers.get("session_start")?.({}, childCtx);
1395
+ const event = { toolCallId, toolName: "write", input: { path: relPath, content } };
1396
+ await handlers.get("tool_call")?.(event, childCtx);
1397
+ writeFileSync(join(root, relPath), content);
1398
+ const result = (await handlers.get("tool_result")?.({ ...event, isError: false }, childCtx)) as { details: { gentleSessionChange: SessionChangeEvidence } } | undefined;
1399
+ assert.ok(result?.details.gentleSessionChange, "the child must attach session-change evidence to its tool result");
1400
+ return result.details.gentleSessionChange;
1401
+ }
1402
+
1403
+ // C1 investigation (odd/tasks/usage-click-and-changes-attribution.md): tried
1404
+ // to reproduce the live session's "16 subagent_run calls, zero relayed"
1405
+ // evidence end to end — real repo, real git-backed resolver on both the
1406
+ // child and parent sides (not the trivial path-echoing stub the other
1407
+ // fixtures in this file use), a real child tool_call/tool_result pair
1408
+ // producing the session-change evidence, and the real onSuccessfulMutation
1409
+ // guard chain in extensions/gentle-agents.ts, round-tripped through actual
1410
+ // JSON serialization the same way agents-fake-child.ts's emit() does for
1411
+ // every other test here. With every guard input constructed faithfully, the
1412
+ // relay fires correctly: parentSessionId/ownedTaskIds, root === childRoot,
1413
+ // worktrees.roots().includes(root), tool.evidence.root === root, and the
1414
+ // realpath comparison all pass.
1415
+ //
1416
+ // The one drop this file could reproduce was a test-harness gap, not a
1417
+ // product bug: SessionWorktreeRegistry.start() is never called for this
1418
+ // extension's own registry (registryFor() in extensions/gentle-agents.ts),
1419
+ // so worktrees.roots() is empty until some subagent's onLaunch callback
1420
+ // registers a root on the real child process's "spawn" event. In production
1421
+ // that event always fires before any tool call can complete, so the root is
1422
+ // registered well before any mutation; the fake child here only reproduces
1423
+ // that if the test explicitly wires "spawn" (as this test does). Making
1424
+ // registryFor() call start() eagerly was tried and reverted: it makes the
1425
+ // parent's own cwd a registered root at session start regardless of whether
1426
+ // any subagent ever actually launches into it, which breaks two existing,
1427
+ // deliberate invariants — "child mutation attribution through registered
1428
+ // subagent_run: unregistered" (a queued-but-never-spawned task must not be
1429
+ // attributed) and "queueing and returning a child handle do not register
1430
+ // roots" / "delayed child spawn retains the originating session..." (merely
1431
+ // constructing the registry must not append an entry). Deciding whether the
1432
+ // session's own root should be trusted before any subagent proves it by
1433
+ // actually spawning is a product/security question, not a guard bug fix, so
1434
+ // it was left to the parent instead of guessed at here.
1435
+ test("C1 investigation: the relay mechanism is correct when every guard input is real", async () => {
1436
+ // A real repository, spawned through the real (unstubbed) git-backed
1437
+ // resolver on both the child and parent sides — the same resolver the
1438
+ // live session used — rather than the trivial path-echoing stub the other
1439
+ // fixtures in this file use.
1440
+ const repoRoot = mkdtempSync(join(tmpdir(), "gentle-agents-c1-"));
1441
+ // Isolate every git spawn below from the developer's own environment: no
1442
+ // global/system config, no ambient $HOME gitconfig, no signing prompt, and
1443
+ // no hooks -- only the identity this fixture supplies explicitly.
1444
+ const gitHome = mkdtempSync(join(tmpdir(), "gentle-agents-c1-git-home-"));
1445
+ const gitHooksDir = mkdtempSync(join(tmpdir(), "gentle-agents-c1-git-hooks-"));
1446
+ const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", HOME: gitHome };
1447
+ const gitIdentity = ["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "-c", `core.hooksPath=${gitHooksDir}`];
1448
+ const runGit = (args: string[]) => execFileSync("git", [...gitIdentity, ...args], { env: gitEnv });
1449
+ try {
1450
+ runGit(["init", "--quiet", "-b", "main", repoRoot]);
1451
+ writeFileSync(join(repoRoot, "README.md"), "seed\n");
1452
+ runGit(["-C", repoRoot, "add", "README.md"]);
1453
+ runGit(["-C", repoRoot, "commit", "--quiet", "-m", "seed"]);
1454
+
1455
+ const h = fakePi();
1456
+ const d = deps();
1457
+ const { ctx } = fakeContext();
1458
+ ctx.sessionManager.getCwd = () => repoRoot;
1459
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1460
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1461
+ d.deps.resolveWorktree = resolveSessionWorktree;
1462
+ // Mirror a real child process: the OS "spawn" event fires as soon as the
1463
+ // process starts, which is what triggers onLaunch -> registry.register.
1464
+ const spawn = d.deps.spawn!;
1465
+ d.deps.spawn = (...args) => {
1466
+ const child = spawn(...args);
1467
+ const on = child.on.bind(child);
1468
+ child.on = ((event: string, listener: () => void) => {
1469
+ if (event === "spawn") queueMicrotask(listener);
1470
+ return on(event as "spawn", listener);
1471
+ }) as typeof child.on;
1472
+ return child;
1473
+ };
1474
+ gentleAgents(h.pi, {}, d.deps);
1475
+ await h.fire("session_start", ctx);
1476
+
1477
+ const launched = await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write a file", mode: "background" }, undefined, undefined, ctx);
1478
+ await tick();
1479
+ const gentleAgentsDetails = launched.details.gentleAgents as { taskId: string; cwd: string };
1480
+ const taskId = gentleAgentsDetails.taskId;
1481
+ // The task's cwd is whatever buildRequest resolved from the real
1482
+ // resolver: confirming this here pins down which value onSuccessfulMutation
1483
+ // will later compare against the freshly-resolved root.
1484
+ const childCwd = gentleAgentsDetails.cwd;
1485
+
1486
+ const evidence = await childSessionChangeEvidence(childCwd, "notes.md", "write", "agent output\n");
1487
+
1488
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "notes.md" } });
1489
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1490
+ await tick();
1491
+
1492
+ const relays = h.events.filter((event) => event.name === "gentle-pi:child-session-change");
1493
+ assert.equal(relays.length, 1, `expected the child mutation to relay; evidence=${JSON.stringify(evidence)} childCwd=${childCwd}`);
1494
+ assert.equal((relays[0].data as { evidence: { id: string } }).evidence.id, `${taskId}:write`);
1495
+
1496
+ await h.fire("session_shutdown", ctx);
1497
+ await tick();
1498
+ } finally {
1499
+ rmSync(repoRoot, { recursive: true, force: true });
1500
+ rmSync(gitHome, { recursive: true, force: true });
1501
+ rmSync(gitHooksDir, { recursive: true, force: true });
1502
+ }
1503
+ });
1504
+
1362
1505
  for (const scenario of ["own", "other-root", "escaped", "sibling", "session-switch", "shutdown", "unregistered"] as const) {
1363
1506
  test(`child mutation attribution through registered subagent_run: ${scenario}`, async () => {
1364
1507
  const h = fakePi();
@@ -1370,7 +1513,11 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
1370
1513
  ctx.sessionManager.getSessionId = () => sessionId;
1371
1514
  ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1372
1515
  ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1516
+ // The cheap session/ownership guards run before any worktree resolution:
1517
+ // a mutation from a switched-away session must never reach git.
1518
+ let resolutions = 0;
1373
1519
  d.deps.resolveWorktree = (path, base) => {
1520
+ resolutions++;
1374
1521
  const absolute = resolve(base, path);
1375
1522
  const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
1376
1523
  return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
@@ -1404,6 +1551,254 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
1404
1551
  });
1405
1552
  }
1406
1553
 
1554
+ // C1 diagnostics (odd/tasks/usage-click-and-changes-attribution.md): the
1555
+ // guard chain's posture is unchanged (spawn-gated registration stays, a
1556
+ // parent decision) -- these only verify each drop explains itself once, in
1557
+ // the task's own thread, naming the exact guard that fired.
1558
+ async function openTaskThread(commands: ReturnType<typeof fakePi>["commands"], ctx: ExtensionContext, overlays: Overlay[]): Promise<string> {
1559
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1560
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 10));
1561
+ const overlay = overlays[0];
1562
+ const text = overlay ? stripAnsi(overlay.render(100).join("\n")) : "";
1563
+ overlay?.handleInput("\x1b");
1564
+ await opened;
1565
+ overlays.length = 0;
1566
+ return text;
1567
+ }
1568
+
1569
+ for (const [scenario, guard] of [
1570
+ ["escaped", "root-unresolved"],
1571
+ ["sibling", "root-mismatch"],
1572
+ ["session-switch", "parent-session-mismatch"],
1573
+ ["unregistered", "root-not-registered"],
1574
+ ] as const) {
1575
+ test(`a dropped mutation explains itself in the task thread: ${scenario} -> ${guard}`, async () => {
1576
+ const h = fakePi();
1577
+ const d = deps();
1578
+ const { ctx, overlays } = fakeContext();
1579
+ let sessionId = "s1";
1580
+ const sibling = join(root, `sibling-${scenario}`);
1581
+ ctx.sessionManager.getSessionId = () => sessionId;
1582
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1583
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1584
+ // The cheap session/ownership guards run before any worktree resolution:
1585
+ // a mutation from a switched-away session must never reach git.
1586
+ let resolutions = 0;
1587
+ d.deps.resolveWorktree = (path, base) => {
1588
+ resolutions++;
1589
+ const absolute = resolve(base, path);
1590
+ const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
1591
+ return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
1592
+ };
1593
+ const spawn = d.deps.spawn!;
1594
+ d.deps.spawn = (...args) => {
1595
+ const child = spawn(...args);
1596
+ const on = child.on.bind(child);
1597
+ child.on = ((event: string, listener: () => void) => {
1598
+ if (event === "spawn" && scenario !== "unregistered") queueMicrotask(listener);
1599
+ return on(event as "spawn", listener);
1600
+ }) as typeof child.on;
1601
+ return child;
1602
+ };
1603
+ gentleAgents(h.pi, {}, d.deps);
1604
+ await h.fire("session_start", ctx);
1605
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
1606
+ await tick();
1607
+ if (scenario === "session-switch") sessionId = "s2";
1608
+ const path = scenario === "escaped" ? "../../outside.ts" : scenario === "sibling" ? join(sibling, "file.ts") : "file.ts";
1609
+ resolutions = 0; // spawn-time registration may resolve; only the mutation matters here
1610
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path } });
1611
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
1612
+ await tick();
1613
+ if (scenario === "session-switch") assert.equal(resolutions, 0, "session mismatch is decided before resolving any worktree");
1614
+ if (scenario === "session-switch") sessionId = "s1"; // back to the task's own session to inspect its thread
1615
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1616
+ assert.match(rendered, new RegExp(`changes not attributed: ${guard}\\b`), `expected the ${guard} guard to explain itself`);
1617
+ // The same drop repeated for the same task must not add a second note.
1618
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write2", toolName: "write", args: { path } });
1619
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write2", isError: false, result: { content: [] } });
1620
+ await tick();
1621
+ const renderedAgain = await openTaskThread(h.commands, ctx, overlays);
1622
+ assert.equal((renderedAgain.match(new RegExp(`changes not attributed: ${guard}`, "g")) ?? []).length, 1, "one note per (task, guard), not one per file");
1623
+ await h.fire("session_shutdown", ctx);
1624
+ await tick();
1625
+ });
1626
+ }
1627
+
1628
+ test("a mutation dropped for lacking any session-change evidence explains itself once", async () => {
1629
+ const h = fakePi();
1630
+ const d = deps();
1631
+ const { ctx, overlays } = fakeContext();
1632
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1633
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1634
+ d.deps.resolveWorktree = (path, base) => {
1635
+ const full = resolve(base, path);
1636
+ return full === cwd || full.startsWith(cwd + "/") ? { root: cwd, commonDir: "/fixture/common" } : undefined;
1637
+ };
1638
+ const spawn = d.deps.spawn!;
1639
+ d.deps.spawn = (...args) => {
1640
+ const child = spawn(...args);
1641
+ const on = child.on.bind(child);
1642
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1643
+ return child;
1644
+ };
1645
+ gentleAgents(h.pi, {}, d.deps);
1646
+ await h.fire("session_start", ctx);
1647
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
1648
+ await tick();
1649
+ // No details.gentleSessionChange at all -- the evidence never surfaced.
1650
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "file.ts" } });
1651
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
1652
+ await tick();
1653
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1654
+ assert.match(rendered, /changes not attributed: evidence-missing\b/);
1655
+ await h.fire("session_shutdown", ctx);
1656
+ await tick();
1657
+ });
1658
+
1659
+ test("a mutation dropped for evidence pointing at a different worktree root explains itself once", async () => {
1660
+ const h = fakePi();
1661
+ const d = deps();
1662
+ const { ctx, overlays } = fakeContext();
1663
+ const target = realpathSync(cwd);
1664
+ (ctx as unknown as { cwd: string }).cwd = target;
1665
+ ctx.sessionManager.getCwd = () => target;
1666
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1667
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1668
+ d.deps.resolveWorktree = (path, base) => {
1669
+ const full = resolve(base, path);
1670
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1671
+ };
1672
+ const spawn = d.deps.spawn!;
1673
+ d.deps.spawn = (...args) => {
1674
+ const child = spawn(...args);
1675
+ const on = child.on.bind(child);
1676
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1677
+ return child;
1678
+ };
1679
+ gentleAgents(h.pi, {}, d.deps);
1680
+ await h.fire("session_start", ctx);
1681
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1682
+ await tick();
1683
+ writeFileSync(join(target, "evidence-root-mismatch-test.ts"), "agent\n");
1684
+ // The evidence names a different root than the one the mutation actually
1685
+ // resolved into -- distinct from having no evidence at all.
1686
+ const evidence = { id: "write", root: join(target, "elsewhere"), path: "evidence-root-mismatch-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
1687
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "evidence-root-mismatch-test.ts" } });
1688
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1689
+ await tick();
1690
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1691
+ assert.match(rendered, /changes not attributed: evidence-root-mismatch\b/);
1692
+ await h.fire("session_shutdown", ctx);
1693
+ await tick();
1694
+ });
1695
+
1696
+ test("a mutation dropped for an unreadable evidence target explains itself once", async () => {
1697
+ const h = fakePi();
1698
+ const d = deps();
1699
+ const { ctx, overlays } = fakeContext();
1700
+ const target = realpathSync(cwd);
1701
+ (ctx as unknown as { cwd: string }).cwd = target;
1702
+ ctx.sessionManager.getCwd = () => target;
1703
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1704
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1705
+ d.deps.resolveWorktree = (path, base) => {
1706
+ const full = resolve(base, path);
1707
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1708
+ };
1709
+ const spawn = d.deps.spawn!;
1710
+ d.deps.spawn = (...args) => {
1711
+ const child = spawn(...args);
1712
+ const on = child.on.bind(child);
1713
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1714
+ return child;
1715
+ };
1716
+ gentleAgents(h.pi, {}, d.deps);
1717
+ await h.fire("session_start", ctx);
1718
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1719
+ await tick();
1720
+ // The tool's own path never lands on disk -- realpathSync must throw
1721
+ // rather than report a plain mismatch.
1722
+ const evidence = { id: "write", root: target, path: "missing-target-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
1723
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "missing-target-test.ts" } });
1724
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1725
+ await tick();
1726
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1727
+ assert.match(rendered, /changes not attributed: evidence-path-unreadable\b/);
1728
+ await h.fire("session_shutdown", ctx);
1729
+ await tick();
1730
+ });
1731
+
1732
+ test("a mutation dropped for mismatched session-change evidence explains itself once", async () => {
1733
+ const h = fakePi();
1734
+ const d = deps();
1735
+ const { ctx, overlays } = fakeContext();
1736
+ const target = realpathSync(cwd);
1737
+ (ctx as unknown as { cwd: string }).cwd = target;
1738
+ ctx.sessionManager.getCwd = () => target;
1739
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1740
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1741
+ d.deps.resolveWorktree = (path, base) => {
1742
+ const full = resolve(base, path);
1743
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1744
+ };
1745
+ const spawn = d.deps.spawn!;
1746
+ d.deps.spawn = (...args) => {
1747
+ const child = spawn(...args);
1748
+ const on = child.on.bind(child);
1749
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1750
+ return child;
1751
+ };
1752
+ gentleAgents(h.pi, {}, d.deps);
1753
+ await h.fire("session_start", ctx);
1754
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1755
+ await tick();
1756
+ writeFileSync(join(target, "evidence-mismatch-test.ts"), "agent\n");
1757
+ const evidence = { id: "write", root: target, path: "different-file.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
1758
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "evidence-mismatch-test.ts" } });
1759
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1760
+ await tick();
1761
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1762
+ assert.match(rendered, /changes not attributed: evidence-path-mismatch\b/);
1763
+ await h.fire("session_shutdown", ctx);
1764
+ await tick();
1765
+ });
1766
+
1767
+ test("a successfully attributed mutation adds no drop note", async () => {
1768
+ const h = fakePi();
1769
+ const d = deps();
1770
+ const { ctx, overlays } = fakeContext();
1771
+ const target = realpathSync(cwd);
1772
+ (ctx as unknown as { cwd: string }).cwd = target;
1773
+ ctx.sessionManager.getCwd = () => target;
1774
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1775
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1776
+ d.deps.resolveWorktree = (path, base) => {
1777
+ const full = resolve(base, path);
1778
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1779
+ };
1780
+ const spawn = d.deps.spawn!;
1781
+ d.deps.spawn = (...args) => {
1782
+ const child = spawn(...args);
1783
+ const on = child.on.bind(child);
1784
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1785
+ return child;
1786
+ };
1787
+ gentleAgents(h.pi, {}, d.deps);
1788
+ await h.fire("session_start", ctx);
1789
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1790
+ await tick();
1791
+ writeFileSync(join(target, "happy-path-test.ts"), "agent\n");
1792
+ const evidence = { id: "write", root: target, path: "happy-path-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
1793
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "happy-path-test.ts" } });
1794
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1795
+ await tick();
1796
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1797
+ assert.doesNotMatch(rendered, /changes not attributed/);
1798
+ await h.fire("session_shutdown", ctx);
1799
+ await tick();
1800
+ });
1801
+
1407
1802
  test("worktree attribution containment respects Windows path boundaries", () => {
1408
1803
  const candidate = win32.resolve("C:\\fixture", "project");
1409
1804
  assert.equal(containsResolvedPath(candidate, win32.resolve(candidate), win32), true, "the worktree root itself is contained");
@@ -1929,6 +2324,32 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
1929
2324
  assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
1930
2325
  });
1931
2326
 
2327
+ test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
2328
+ const { pi, tools, fire } = fakePi();
2329
+ const harness = deps();
2330
+ gentleAgents(pi, {}, harness.deps);
2331
+ // A terminal-bearing host is what makes sidebarPart do anything at all
2332
+ // (a host with no .terminal is already a passthrough); this is the host
2333
+ // shape the fullscreen layout actually uses.
2334
+ const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
2335
+ const { ctx, widget } = fakeContext(terminalTui);
2336
+ await fire("session_start", ctx);
2337
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/", label: "map lib modules", mode: "background" }, undefined, undefined, ctx);
2338
+ await tick();
2339
+
2340
+ // The factory only runs (and only then could register a rail part) once
2341
+ // something actually renders the widget, exactly like the real host.
2342
+ assert.match(widget()![1]!, /explore map lib modules/);
2343
+ const state = sidebarState(terminalTui as unknown as TUI);
2344
+ assert.equal(state.parts.has("agents"), false, "Agents never claims a rail slot; the above-editor widget is its only surface");
2345
+
2346
+ // Simulate the fullscreen sidebar actively owning the host, the same
2347
+ // condition sidebarPart used to suppress a registered bottom widget under.
2348
+ state.active = true;
2349
+ state.ownsHost = () => true;
2350
+ assert.match(widget()![1]!, /explore map lib modules/, "the widget keeps rendering regardless of sidebar ownership");
2351
+ });
2352
+
1932
2353
  test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
1933
2354
  const { pi, tools, fire, sent, renderers } = fakePi();
1934
2355
  const harness = deps();
@@ -2184,7 +2605,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef
2184
2605
  }
2185
2606
  });
2186
2607
 
2187
- test("finished tasks remain available through resolveTask but never reappear in the live-only overlay", async () => {
2608
+ test("a task that finishes live stays visible as history, in both scopes, and its result also resolves after a restart", async () => {
2188
2609
  const { pi, tools, fire, commands, shortcuts } = fakePi();
2189
2610
  const harness = deps();
2190
2611
  gentleAgents(pi, {}, harness.deps);
@@ -2204,21 +2625,25 @@ test("finished tasks remain available through resolveTask but never reappear in
2204
2625
  }
2205
2626
  assert.ok(stored.some((entry) => entry.task.id === id && entry.task.result === "Kept."), "the finished task is on disk");
2206
2627
 
2628
+ // A completely different session still resolves it by id on demand, with
2629
+ // nothing restored in advance.
2207
2630
  const fresh = fakePi();
2208
2631
  gentleAgents(fresh.pi, {}, deps().deps);
2209
2632
  const again = fakeContext();
2210
2633
  await fresh.fire("session_start", again.ctx);
2211
2634
  assert.equal((await fresh.tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, again.ctx)).content[0].text, "Kept.");
2212
2635
 
2636
+ // Back in the session where it actually finished, it stays listed as
2637
+ // history -- in the current-session view and under "all sessions" too.
2213
2638
  assert.ok(commands.has("gentle:agents") && shortcuts.has("alt+a"));
2214
2639
  const opened = commands.get("gentle:agents")!.handler("", ctx);
2215
2640
  for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2216
2641
  const overlay = overlays[0];
2217
2642
  assert.ok(overlay, "the overlay component was created");
2218
- assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /finished|Subagent explore|Current orchestrator/);
2643
+ assert.match(overlay.render(80).map(stripAnsi).join("\n"), /finished|Subagent explore/, "the task that finished in this session stays visible as history");
2219
2644
  overlay.handleInput("a");
2220
2645
  overlay.handleInput("\x1b[C");
2221
- assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /✓ Subagent explore/, "all sessions is not a historical-task browser");
2646
+ assert.match(overlay.render(80).map(stripAnsi).join("\n"), /Subagent explore/, "the current session's own history shows under all sessions too");
2222
2647
  overlay.handleInput("\x1b");
2223
2648
  await opened;
2224
2649
  });
@@ -2333,6 +2758,104 @@ test("restored task history cannot enter the live panel or execute stop even wit
2333
2758
  await opened;
2334
2759
  });
2335
2760
 
2761
+ // A1 (odd/tasks/usage-click-and-changes-attribution.md): resuming a session
2762
+ // brings its own finished subagents back as visible history, never another
2763
+ // session's, and a brand-new session starts with none restored.
2764
+ test("resuming a session restores its own finished tasks as history, never another session's", async () => {
2765
+ const { pi, fire, commands } = fakePi();
2766
+ const harness = deps();
2767
+ const historyHome = join(root, "resume-history-home");
2768
+ const base: TaskRecord = { id: "own-1", agent: "explore-a", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "resumed-session", status: TASK_STATUS.COMPLETED, createdAt: 1, startedAt: 1, endedAt: 100, model: "m", thinking: undefined, sessionPath: null, error: null, result: "done", lastStep: "responded", lastActivityAt: 100, turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
2769
+ const own1 = base;
2770
+ const own2: TaskRecord = { ...base, id: "own-2", agent: "explore-b", status: TASK_STATUS.FAILED, endedAt: 200, error: "boom", result: null };
2771
+ const other: TaskRecord = { ...base, id: "not-mine", agent: "explore-other", parentSessionId: "other-session" };
2772
+ await saveTask(historyDir(historyHome), own1, emptyThread());
2773
+ await saveTask(historyDir(historyHome), own2, emptyThread());
2774
+ await saveTask(historyDir(historyHome), other, emptyThread());
2775
+ harness.deps.home = historyHome;
2776
+ gentleAgents(pi, {}, harness.deps);
2777
+ const { ctx, overlays } = fakeContext();
2778
+ ctx.sessionManager.getSessionId = () => "resumed-session";
2779
+ await fire("session_start", ctx, { reason: "resume" });
2780
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
2781
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2782
+ // The disk history read behind restoreSessionHistory is fire-and-forget,
2783
+ // so poll the overlay's own render output instead of sleeping a guess.
2784
+ const rendered = await waitForOverlayMatch(() => stripAnsi(overlays[0]!.render(100).join("\n")), /Subagent explore-a/);
2785
+ assert.match(rendered, /Subagent explore-a/, "the resumed session's own finished task is restored");
2786
+ assert.match(rendered, /Subagent explore-b/, "a second finished task of the same session is restored too");
2787
+ assert.doesNotMatch(rendered, /explore-other/, "another session's finished task never restores here");
2788
+ overlays[0]!.handleInput("\x1b");
2789
+ await opened;
2790
+ await fire("session_shutdown", ctx);
2791
+
2792
+ const { pi: freshPi, fire: freshFire, commands: freshCommands } = fakePi();
2793
+ gentleAgents(freshPi, {}, harness.deps);
2794
+ const { ctx: freshCtx, overlays: freshOverlays } = fakeContext();
2795
+ freshCtx.sessionManager.getSessionId = () => "brand-new-session";
2796
+ await freshFire("session_start", freshCtx, { reason: "new" });
2797
+ await tick();
2798
+ const freshOpened = freshCommands.get("gentle:agents")!.handler("", freshCtx);
2799
+ for (let attempt = 0; attempt < 40 && freshOverlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2800
+ assert.doesNotMatch(stripAnsi(freshOverlays[0]!.render(100).join("\n")), /explore/, "a brand-new session restores nothing");
2801
+ freshOverlays[0]!.handleInput("\x1b");
2802
+ await freshOpened;
2803
+ await freshFire("session_shutdown", freshCtx);
2804
+ });
2805
+
2806
+ // A1 follow-up: Pi reports "startup" (not "resume") when the CLI is launched
2807
+ // directly into an existing session file (--continue / --resume picker,
2808
+ // agent-session.js:152) -- "resume" is only the in-session /resume switch.
2809
+ // A startup into a session that already has entries must restore its history
2810
+ // too; a "startup" with no entries (nothing pre-existing to restore) must not.
2811
+ test("starting up into an existing session also restores its own finished history; a startup with no prior entries does not", async () => {
2812
+ const historyHome = join(root, "startup-history-home");
2813
+ const finished: TaskRecord = { id: "startup-1", agent: "explore-startup", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "startup-session", status: TASK_STATUS.COMPLETED, createdAt: 1, startedAt: 1, endedAt: 100, model: "m", thinking: undefined, sessionPath: null, error: null, result: "done", lastStep: "responded", lastActivityAt: 100, turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
2814
+ await saveTask(historyDir(historyHome), finished, emptyThread());
2815
+
2816
+ {
2817
+ // Startup into a session file that already has entries: restores.
2818
+ const { pi, fire, commands } = fakePi();
2819
+ const harness = deps();
2820
+ harness.deps.home = historyHome;
2821
+ gentleAgents(pi, {}, harness.deps);
2822
+ const { ctx, overlays } = fakeContext();
2823
+ ctx.sessionManager.getSessionId = () => "startup-session";
2824
+ ctx.sessionManager.getEntries = (() => [{ type: "message" }]) as typeof ctx.sessionManager.getEntries;
2825
+ await fire("session_start", ctx, { reason: "startup" });
2826
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
2827
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2828
+ // The disk history read behind restoreSessionHistory is fire-and-forget,
2829
+ // so poll the overlay's own render output instead of sleeping a guess.
2830
+ const rendered = await waitForOverlayMatch(() => stripAnsi(overlays[0]!.render(100).join("\n")), /Subagent explore-startup/);
2831
+ assert.match(rendered, /Subagent explore-startup/, "startup into an existing session restores its own finished history");
2832
+ overlays[0]!.handleInput("\x1b");
2833
+ await opened;
2834
+ await fire("session_shutdown", ctx);
2835
+ }
2836
+ {
2837
+ // Startup reported for a session with no prior entries: nothing to
2838
+ // restore, even though the reason is "startup" and the id matches.
2839
+ const { pi, fire, commands } = fakePi();
2840
+ const harness = deps();
2841
+ harness.deps.home = historyHome;
2842
+ gentleAgents(pi, {}, harness.deps);
2843
+ const { ctx, overlays } = fakeContext();
2844
+ ctx.sessionManager.getSessionId = () => "startup-session";
2845
+ await fire("session_start", ctx, { reason: "startup" });
2846
+ // getEntries defaults to [] here, so preexisting is false and
2847
+ // restoreSessionHistory never runs -- nothing to wait for beyond
2848
+ // letting the already-fired session_start handler settle.
2849
+ await tick();
2850
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
2851
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2852
+ assert.doesNotMatch(stripAnsi(overlays[0]!.render(100).join("\n")), /explore-startup/, "a startup report with no prior entries restores nothing");
2853
+ overlays[0]!.handleInput("\x1b");
2854
+ await opened;
2855
+ await fire("session_shutdown", ctx);
2856
+ }
2857
+ });
2858
+
2336
2859
  test("Alt+S confirms a snapshot of active subagents and suppresses their follow-up delivery", async () => {
2337
2860
  const { pi, tools, fire, shortcuts, sent } = fakePi();
2338
2861
  const harness = 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.1.0",
100
+ tag: "v3.4.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,