gentle-pi 3.2.1 → 3.4.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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -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";
@@ -144,8 +147,10 @@ function fakePi() {
144
147
  await fire("session_shutdown", ctx, { reason: "quit" });
145
148
  };
146
149
  const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
150
+ const results: unknown[] = [];
147
151
  try {
148
- for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
152
+ for (const handler of handlers.get(event) ?? []) results.push(await handler(payload, ctx));
153
+ return results;
149
154
  } finally {
150
155
  if (event === "session_start") {
151
156
  activeSession = ctx;
@@ -234,6 +239,73 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
234
239
  };
235
240
  }
236
241
 
242
+ test("cache warming follows actual Gentle Agents ownership and completion lifecycle", async (t) => {
243
+ const h = fakePi();
244
+ const runtime = deps();
245
+ let store: TaskStore | undefined;
246
+ const list = TaskStore.prototype.list;
247
+ t.mock.method(TaskStore.prototype, "list", function (this: TaskStore, ...args: Parameters<TaskStore["list"]>) {
248
+ store = this;
249
+ return list.apply(this, args);
250
+ });
251
+ const scheduled = t.mock.fn(() => () => {});
252
+ runtime.deps.schedule = scheduled;
253
+ gentleAgents(h.pi, {}, runtime.deps);
254
+ const { ctx } = fakeContext();
255
+ let sessionId = "warming-parent";
256
+ ctx.sessionManager.getSessionId = () => sessionId;
257
+ await h.fire("session_start", ctx);
258
+ const candidate = { type: "cache_warming_decision", warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" };
259
+ const run = t.mock.method(AgentRunner.prototype, "run");
260
+ const status = t.mock.method(h.tools.get("subagent_status")!, "execute");
261
+ const result = t.mock.method(h.tools.get("subagent_result")!, "execute");
262
+ const decide = async (expected: unknown) => {
263
+ const before = [runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()];
264
+ const sent = [...h.sent], entries = [...h.entries];
265
+ const childTraffic = runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed]));
266
+ const timeout = t.mock.method(globalThis, "setTimeout");
267
+ const interval = t.mock.method(globalThis, "setInterval");
268
+ try {
269
+ assert.deepEqual(await h.fire("cache_warming_decision", ctx, candidate), [expected]);
270
+ assert.equal(timeout.mock.callCount(), 0);
271
+ assert.equal(interval.mock.callCount(), 0);
272
+ } finally { timeout.mock.restore(); interval.mock.restore(); }
273
+ assert.deepEqual([runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()], before);
274
+ assert.deepEqual(h.sent, sent, "no maintenance or duplicate completion message");
275
+ assert.deepEqual(h.entries, entries, "no maintenance context entry");
276
+ assert.deepEqual(runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed])), childTraffic, "no child inspection, steering, or cancellation");
277
+ };
278
+ await decide(undefined);
279
+ const launch = await h.tools.get("subagent_run")!.execute("warming-launch", { agent: "explore", task: "Map warming", mode: "background" }, undefined, undefined, ctx);
280
+ await tick();
281
+ const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
282
+ assert.equal(runtime.children.length, 1);
283
+ await decide({ action: "warm" });
284
+ // The same live owned child is foreign when the active session changes.
285
+ sessionId = "other-parent";
286
+ await decide(undefined);
287
+ sessionId = "warming-parent";
288
+ await decide({ action: "warm" });
289
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
290
+ await tick();
291
+ await decide({ action: "warm" });
292
+ assert.equal(h.sent.length, 0, "agent_end is not settlement");
293
+ runtime.children[0].emit({ type: "agent_settled" });
294
+ await tick();
295
+ await decide(undefined);
296
+ assert.equal(h.sent.length, 1);
297
+ assert.equal(h.sent[0].message.customType, "gentle-agents.result");
298
+ assert.match(JSON.stringify(h.sent[0].message), new RegExp(taskId));
299
+ assert.equal(h.sent[0].options.triggerTurn, true);
300
+ assert.equal(run.mock.callCount(), 1, "warming never launches equivalent work");
301
+ // Seed the actual history-restore path with a stale live-looking record:
302
+ // sharing the parent ID and running status must not confer ownership.
303
+ assert.equal(store!.restore({ ...store!.get(taskId)!, id: "warming-restored", status: TASK_STATUS.RUNNING }, emptyThread()), true);
304
+ await decide(undefined);
305
+ await h.fire("session_shutdown", ctx);
306
+ await decide(undefined);
307
+ });
308
+
237
309
  const PRINT_BACKGROUND_ERROR = "Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
238
310
 
239
311
  for (const continuation of [false, true]) {
@@ -462,6 +534,21 @@ const drainLifecycle = async (label: string, promise: Promise<LifecycleOutcome>)
462
534
  catch (error) { return { label, error }; }
463
535
  };
464
536
 
537
+ // The disk history read behind restoreSessionHistory is fire-and-forget from
538
+ // session_start, so nothing signals when it lands. Poll the overlay's own
539
+ // render output (backed by the live, shared TaskStore) on setImmediate ticks
540
+ // instead of sleeping a fixed guess: fast when the restore already landed,
541
+ // bounded at 2s so a genuine regression still fails instead of hanging.
542
+ async function waitForOverlayMatch(render: () => string, pattern: RegExp, timeoutMs = 2000): Promise<string> {
543
+ const deadline = Date.now() + timeoutMs;
544
+ let rendered = render();
545
+ while (!pattern.test(rendered) && Date.now() < deadline) {
546
+ await tick();
547
+ rendered = render();
548
+ }
549
+ return rendered;
550
+ }
551
+
465
552
  test("overlapping session transport startups preserve ownership and shutdown waits for every pending operation", async (t: TestContext) => {
466
553
  const h = fakePi();
467
554
  const runtime = deps();
@@ -1359,6 +1446,131 @@ for (const matching of [true, false]) {
1359
1446
  });
1360
1447
  }
1361
1448
 
1449
+ // Runs the CHILD half of installSessionChangeCapture against a real repo, the
1450
+ // same way an actual subagent process would: a tool_call/tool_result pair
1451
+ // with GENTLE_PI_AGENTS_CHILD set, using the real git-backed resolver rather
1452
+ // than a stub. Returns exactly the evidence object the child would put in
1453
+ // its tool result's details.gentleSessionChange.
1454
+ async function childSessionChangeEvidence(root: string, relPath: string, toolCallId: string, content: string): Promise<SessionChangeEvidence> {
1455
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
1456
+ const childPi = {
1457
+ on: (key: string, fn: (event: unknown, ctx: unknown) => unknown) => handlers.set(key, fn),
1458
+ appendEntry: () => {},
1459
+ events: { on: () => () => {}, emit: () => {} },
1460
+ } as unknown as ExtensionAPI;
1461
+ const childCtx = { cwd: root, sessionManager: { getSessionId: () => "child-session", getEntries: () => [] } } as unknown as ExtensionContext;
1462
+ installSessionChangeCapture(childPi, { GENTLE_PI_AGENTS_CHILD: "1" }, resolveSessionWorktree);
1463
+ await handlers.get("session_start")?.({}, childCtx);
1464
+ const event = { toolCallId, toolName: "write", input: { path: relPath, content } };
1465
+ await handlers.get("tool_call")?.(event, childCtx);
1466
+ writeFileSync(join(root, relPath), content);
1467
+ const result = (await handlers.get("tool_result")?.({ ...event, isError: false }, childCtx)) as { details: { gentleSessionChange: SessionChangeEvidence } } | undefined;
1468
+ assert.ok(result?.details.gentleSessionChange, "the child must attach session-change evidence to its tool result");
1469
+ return result.details.gentleSessionChange;
1470
+ }
1471
+
1472
+ // C1 investigation (odd/tasks/usage-click-and-changes-attribution.md): tried
1473
+ // to reproduce the live session's "16 subagent_run calls, zero relayed"
1474
+ // evidence end to end — real repo, real git-backed resolver on both the
1475
+ // child and parent sides (not the trivial path-echoing stub the other
1476
+ // fixtures in this file use), a real child tool_call/tool_result pair
1477
+ // producing the session-change evidence, and the real onSuccessfulMutation
1478
+ // guard chain in extensions/gentle-agents.ts, round-tripped through actual
1479
+ // JSON serialization the same way agents-fake-child.ts's emit() does for
1480
+ // every other test here. With every guard input constructed faithfully, the
1481
+ // relay fires correctly: parentSessionId/ownedTaskIds, root === childRoot,
1482
+ // worktrees.roots().includes(root), tool.evidence.root === root, and the
1483
+ // realpath comparison all pass.
1484
+ //
1485
+ // The one drop this file could reproduce was a test-harness gap, not a
1486
+ // product bug: SessionWorktreeRegistry.start() is never called for this
1487
+ // extension's own registry (registryFor() in extensions/gentle-agents.ts),
1488
+ // so worktrees.roots() is empty until some subagent's onLaunch callback
1489
+ // registers a root on the real child process's "spawn" event. In production
1490
+ // that event always fires before any tool call can complete, so the root is
1491
+ // registered well before any mutation; the fake child here only reproduces
1492
+ // that if the test explicitly wires "spawn" (as this test does). Making
1493
+ // registryFor() call start() eagerly was tried and reverted: it makes the
1494
+ // parent's own cwd a registered root at session start regardless of whether
1495
+ // any subagent ever actually launches into it, which breaks two existing,
1496
+ // deliberate invariants — "child mutation attribution through registered
1497
+ // subagent_run: unregistered" (a queued-but-never-spawned task must not be
1498
+ // attributed) and "queueing and returning a child handle do not register
1499
+ // roots" / "delayed child spawn retains the originating session..." (merely
1500
+ // constructing the registry must not append an entry). Deciding whether the
1501
+ // session's own root should be trusted before any subagent proves it by
1502
+ // actually spawning is a product/security question, not a guard bug fix, so
1503
+ // it was left to the parent instead of guessed at here.
1504
+ test("C1 investigation: the relay mechanism is correct when every guard input is real", async () => {
1505
+ // A real repository, spawned through the real (unstubbed) git-backed
1506
+ // resolver on both the child and parent sides — the same resolver the
1507
+ // live session used — rather than the trivial path-echoing stub the other
1508
+ // fixtures in this file use.
1509
+ const repoRoot = mkdtempSync(join(tmpdir(), "gentle-agents-c1-"));
1510
+ // Isolate every git spawn below from the developer's own environment: no
1511
+ // global/system config, no ambient $HOME gitconfig, no signing prompt, and
1512
+ // no hooks -- only the identity this fixture supplies explicitly.
1513
+ const gitHome = mkdtempSync(join(tmpdir(), "gentle-agents-c1-git-home-"));
1514
+ const gitHooksDir = mkdtempSync(join(tmpdir(), "gentle-agents-c1-git-hooks-"));
1515
+ const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", HOME: gitHome };
1516
+ const gitIdentity = ["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "-c", `core.hooksPath=${gitHooksDir}`];
1517
+ const runGit = (args: string[]) => execFileSync("git", [...gitIdentity, ...args], { env: gitEnv });
1518
+ try {
1519
+ runGit(["init", "--quiet", "-b", "main", repoRoot]);
1520
+ writeFileSync(join(repoRoot, "README.md"), "seed\n");
1521
+ runGit(["-C", repoRoot, "add", "README.md"]);
1522
+ runGit(["-C", repoRoot, "commit", "--quiet", "-m", "seed"]);
1523
+
1524
+ const h = fakePi();
1525
+ const d = deps();
1526
+ const { ctx } = fakeContext();
1527
+ ctx.sessionManager.getCwd = () => repoRoot;
1528
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1529
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1530
+ d.deps.resolveWorktree = resolveSessionWorktree;
1531
+ // Mirror a real child process: the OS "spawn" event fires as soon as the
1532
+ // process starts, which is what triggers onLaunch -> registry.register.
1533
+ const spawn = d.deps.spawn!;
1534
+ d.deps.spawn = (...args) => {
1535
+ const child = spawn(...args);
1536
+ const on = child.on.bind(child);
1537
+ child.on = ((event: string, listener: () => void) => {
1538
+ if (event === "spawn") queueMicrotask(listener);
1539
+ return on(event as "spawn", listener);
1540
+ }) as typeof child.on;
1541
+ return child;
1542
+ };
1543
+ gentleAgents(h.pi, {}, d.deps);
1544
+ await h.fire("session_start", ctx);
1545
+
1546
+ const launched = await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write a file", mode: "background" }, undefined, undefined, ctx);
1547
+ await tick();
1548
+ const gentleAgentsDetails = launched.details.gentleAgents as { taskId: string; cwd: string };
1549
+ const taskId = gentleAgentsDetails.taskId;
1550
+ // The task's cwd is whatever buildRequest resolved from the real
1551
+ // resolver: confirming this here pins down which value onSuccessfulMutation
1552
+ // will later compare against the freshly-resolved root.
1553
+ const childCwd = gentleAgentsDetails.cwd;
1554
+
1555
+ const evidence = await childSessionChangeEvidence(childCwd, "notes.md", "write", "agent output\n");
1556
+
1557
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "notes.md" } });
1558
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1559
+ await tick();
1560
+
1561
+ const relays = h.events.filter((event) => event.name === "gentle-pi:child-session-change");
1562
+ assert.equal(relays.length, 1, `expected the child mutation to relay; evidence=${JSON.stringify(evidence)} childCwd=${childCwd}`);
1563
+ assert.equal((relays[0].data as { evidence: { id: string } }).evidence.id, `${taskId}:write`);
1564
+
1565
+ await h.fire("session_shutdown", ctx);
1566
+ await tick();
1567
+ } finally {
1568
+ rmSync(repoRoot, { recursive: true, force: true });
1569
+ rmSync(gitHome, { recursive: true, force: true });
1570
+ rmSync(gitHooksDir, { recursive: true, force: true });
1571
+ }
1572
+ });
1573
+
1362
1574
  for (const scenario of ["own", "other-root", "escaped", "sibling", "session-switch", "shutdown", "unregistered"] as const) {
1363
1575
  test(`child mutation attribution through registered subagent_run: ${scenario}`, async () => {
1364
1576
  const h = fakePi();
@@ -1370,7 +1582,11 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
1370
1582
  ctx.sessionManager.getSessionId = () => sessionId;
1371
1583
  ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1372
1584
  ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1585
+ // The cheap session/ownership guards run before any worktree resolution:
1586
+ // a mutation from a switched-away session must never reach git.
1587
+ let resolutions = 0;
1373
1588
  d.deps.resolveWorktree = (path, base) => {
1589
+ resolutions++;
1374
1590
  const absolute = resolve(base, path);
1375
1591
  const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
1376
1592
  return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
@@ -1404,6 +1620,254 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
1404
1620
  });
1405
1621
  }
1406
1622
 
1623
+ // C1 diagnostics (odd/tasks/usage-click-and-changes-attribution.md): the
1624
+ // guard chain's posture is unchanged (spawn-gated registration stays, a
1625
+ // parent decision) -- these only verify each drop explains itself once, in
1626
+ // the task's own thread, naming the exact guard that fired.
1627
+ async function openTaskThread(commands: ReturnType<typeof fakePi>["commands"], ctx: ExtensionContext, overlays: Overlay[]): Promise<string> {
1628
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1629
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 10));
1630
+ const overlay = overlays[0];
1631
+ const text = overlay ? stripAnsi(overlay.render(100).join("\n")) : "";
1632
+ overlay?.handleInput("\x1b");
1633
+ await opened;
1634
+ overlays.length = 0;
1635
+ return text;
1636
+ }
1637
+
1638
+ for (const [scenario, guard] of [
1639
+ ["escaped", "root-unresolved"],
1640
+ ["sibling", "root-mismatch"],
1641
+ ["session-switch", "parent-session-mismatch"],
1642
+ ["unregistered", "root-not-registered"],
1643
+ ] as const) {
1644
+ test(`a dropped mutation explains itself in the task thread: ${scenario} -> ${guard}`, async () => {
1645
+ const h = fakePi();
1646
+ const d = deps();
1647
+ const { ctx, overlays } = fakeContext();
1648
+ let sessionId = "s1";
1649
+ const sibling = join(root, `sibling-${scenario}`);
1650
+ ctx.sessionManager.getSessionId = () => sessionId;
1651
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1652
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1653
+ // The cheap session/ownership guards run before any worktree resolution:
1654
+ // a mutation from a switched-away session must never reach git.
1655
+ let resolutions = 0;
1656
+ d.deps.resolveWorktree = (path, base) => {
1657
+ resolutions++;
1658
+ const absolute = resolve(base, path);
1659
+ const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
1660
+ return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
1661
+ };
1662
+ const spawn = d.deps.spawn!;
1663
+ d.deps.spawn = (...args) => {
1664
+ const child = spawn(...args);
1665
+ const on = child.on.bind(child);
1666
+ child.on = ((event: string, listener: () => void) => {
1667
+ if (event === "spawn" && scenario !== "unregistered") queueMicrotask(listener);
1668
+ return on(event as "spawn", listener);
1669
+ }) as typeof child.on;
1670
+ return child;
1671
+ };
1672
+ gentleAgents(h.pi, {}, d.deps);
1673
+ await h.fire("session_start", ctx);
1674
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
1675
+ await tick();
1676
+ if (scenario === "session-switch") sessionId = "s2";
1677
+ const path = scenario === "escaped" ? "../../outside.ts" : scenario === "sibling" ? join(sibling, "file.ts") : "file.ts";
1678
+ resolutions = 0; // spawn-time registration may resolve; only the mutation matters here
1679
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path } });
1680
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
1681
+ await tick();
1682
+ if (scenario === "session-switch") assert.equal(resolutions, 0, "session mismatch is decided before resolving any worktree");
1683
+ if (scenario === "session-switch") sessionId = "s1"; // back to the task's own session to inspect its thread
1684
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1685
+ assert.match(rendered, new RegExp(`changes not attributed: ${guard}\\b`), `expected the ${guard} guard to explain itself`);
1686
+ // The same drop repeated for the same task must not add a second note.
1687
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write2", toolName: "write", args: { path } });
1688
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write2", isError: false, result: { content: [] } });
1689
+ await tick();
1690
+ const renderedAgain = await openTaskThread(h.commands, ctx, overlays);
1691
+ assert.equal((renderedAgain.match(new RegExp(`changes not attributed: ${guard}`, "g")) ?? []).length, 1, "one note per (task, guard), not one per file");
1692
+ await h.fire("session_shutdown", ctx);
1693
+ await tick();
1694
+ });
1695
+ }
1696
+
1697
+ test("a mutation dropped for lacking any session-change evidence explains itself once", async () => {
1698
+ const h = fakePi();
1699
+ const d = deps();
1700
+ const { ctx, overlays } = fakeContext();
1701
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1702
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1703
+ d.deps.resolveWorktree = (path, base) => {
1704
+ const full = resolve(base, path);
1705
+ return full === cwd || full.startsWith(cwd + "/") ? { root: cwd, commonDir: "/fixture/common" } : undefined;
1706
+ };
1707
+ const spawn = d.deps.spawn!;
1708
+ d.deps.spawn = (...args) => {
1709
+ const child = spawn(...args);
1710
+ const on = child.on.bind(child);
1711
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1712
+ return child;
1713
+ };
1714
+ gentleAgents(h.pi, {}, d.deps);
1715
+ await h.fire("session_start", ctx);
1716
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
1717
+ await tick();
1718
+ // No details.gentleSessionChange at all -- the evidence never surfaced.
1719
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "file.ts" } });
1720
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
1721
+ await tick();
1722
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1723
+ assert.match(rendered, /changes not attributed: evidence-missing\b/);
1724
+ await h.fire("session_shutdown", ctx);
1725
+ await tick();
1726
+ });
1727
+
1728
+ test("a mutation dropped for evidence pointing at a different worktree root explains itself once", async () => {
1729
+ const h = fakePi();
1730
+ const d = deps();
1731
+ const { ctx, overlays } = fakeContext();
1732
+ const target = realpathSync(cwd);
1733
+ (ctx as unknown as { cwd: string }).cwd = target;
1734
+ ctx.sessionManager.getCwd = () => target;
1735
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1736
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1737
+ d.deps.resolveWorktree = (path, base) => {
1738
+ const full = resolve(base, path);
1739
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1740
+ };
1741
+ const spawn = d.deps.spawn!;
1742
+ d.deps.spawn = (...args) => {
1743
+ const child = spawn(...args);
1744
+ const on = child.on.bind(child);
1745
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1746
+ return child;
1747
+ };
1748
+ gentleAgents(h.pi, {}, d.deps);
1749
+ await h.fire("session_start", ctx);
1750
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1751
+ await tick();
1752
+ writeFileSync(join(target, "evidence-root-mismatch-test.ts"), "agent\n");
1753
+ // The evidence names a different root than the one the mutation actually
1754
+ // resolved into -- distinct from having no evidence at all.
1755
+ 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" } };
1756
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "evidence-root-mismatch-test.ts" } });
1757
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1758
+ await tick();
1759
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1760
+ assert.match(rendered, /changes not attributed: evidence-root-mismatch\b/);
1761
+ await h.fire("session_shutdown", ctx);
1762
+ await tick();
1763
+ });
1764
+
1765
+ test("a mutation dropped for an unreadable evidence target explains itself once", async () => {
1766
+ const h = fakePi();
1767
+ const d = deps();
1768
+ const { ctx, overlays } = fakeContext();
1769
+ const target = realpathSync(cwd);
1770
+ (ctx as unknown as { cwd: string }).cwd = target;
1771
+ ctx.sessionManager.getCwd = () => target;
1772
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1773
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1774
+ d.deps.resolveWorktree = (path, base) => {
1775
+ const full = resolve(base, path);
1776
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1777
+ };
1778
+ const spawn = d.deps.spawn!;
1779
+ d.deps.spawn = (...args) => {
1780
+ const child = spawn(...args);
1781
+ const on = child.on.bind(child);
1782
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1783
+ return child;
1784
+ };
1785
+ gentleAgents(h.pi, {}, d.deps);
1786
+ await h.fire("session_start", ctx);
1787
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1788
+ await tick();
1789
+ // The tool's own path never lands on disk -- realpathSync must throw
1790
+ // rather than report a plain mismatch.
1791
+ const evidence = { id: "write", root: target, path: "missing-target-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
1792
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "missing-target-test.ts" } });
1793
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1794
+ await tick();
1795
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1796
+ assert.match(rendered, /changes not attributed: evidence-path-unreadable\b/);
1797
+ await h.fire("session_shutdown", ctx);
1798
+ await tick();
1799
+ });
1800
+
1801
+ test("a mutation dropped for mismatched session-change evidence explains itself once", async () => {
1802
+ const h = fakePi();
1803
+ const d = deps();
1804
+ const { ctx, overlays } = fakeContext();
1805
+ const target = realpathSync(cwd);
1806
+ (ctx as unknown as { cwd: string }).cwd = target;
1807
+ ctx.sessionManager.getCwd = () => target;
1808
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1809
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1810
+ d.deps.resolveWorktree = (path, base) => {
1811
+ const full = resolve(base, path);
1812
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1813
+ };
1814
+ const spawn = d.deps.spawn!;
1815
+ d.deps.spawn = (...args) => {
1816
+ const child = spawn(...args);
1817
+ const on = child.on.bind(child);
1818
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1819
+ return child;
1820
+ };
1821
+ gentleAgents(h.pi, {}, d.deps);
1822
+ await h.fire("session_start", ctx);
1823
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1824
+ await tick();
1825
+ writeFileSync(join(target, "evidence-mismatch-test.ts"), "agent\n");
1826
+ const evidence = { id: "write", root: target, path: "different-file.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
1827
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "evidence-mismatch-test.ts" } });
1828
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1829
+ await tick();
1830
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1831
+ assert.match(rendered, /changes not attributed: evidence-path-mismatch\b/);
1832
+ await h.fire("session_shutdown", ctx);
1833
+ await tick();
1834
+ });
1835
+
1836
+ test("a successfully attributed mutation adds no drop note", async () => {
1837
+ const h = fakePi();
1838
+ const d = deps();
1839
+ const { ctx, overlays } = fakeContext();
1840
+ const target = realpathSync(cwd);
1841
+ (ctx as unknown as { cwd: string }).cwd = target;
1842
+ ctx.sessionManager.getCwd = () => target;
1843
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1844
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1845
+ d.deps.resolveWorktree = (path, base) => {
1846
+ const full = resolve(base, path);
1847
+ return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
1848
+ };
1849
+ const spawn = d.deps.spawn!;
1850
+ d.deps.spawn = (...args) => {
1851
+ const child = spawn(...args);
1852
+ const on = child.on.bind(child);
1853
+ child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
1854
+ return child;
1855
+ };
1856
+ gentleAgents(h.pi, {}, d.deps);
1857
+ await h.fire("session_start", ctx);
1858
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
1859
+ await tick();
1860
+ writeFileSync(join(target, "happy-path-test.ts"), "agent\n");
1861
+ const evidence = { id: "write", root: target, path: "happy-path-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
1862
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "happy-path-test.ts" } });
1863
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
1864
+ await tick();
1865
+ const rendered = await openTaskThread(h.commands, ctx, overlays);
1866
+ assert.doesNotMatch(rendered, /changes not attributed/);
1867
+ await h.fire("session_shutdown", ctx);
1868
+ await tick();
1869
+ });
1870
+
1407
1871
  test("worktree attribution containment respects Windows path boundaries", () => {
1408
1872
  const candidate = win32.resolve("C:\\fixture", "project");
1409
1873
  assert.equal(containsResolvedPath(candidate, win32.resolve(candidate), win32), true, "the worktree root itself is contained");
@@ -1443,7 +1907,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
1443
1907
  const { ctx } = fakeContext();
1444
1908
  (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
1445
1909
  await h.fire("session_start", ctx);
1446
- shutdown.push(() => h.fire("session_shutdown", ctx));
1910
+ shutdown.push(async () => { await h.fire("session_shutdown", ctx); });
1447
1911
  return { h, ctx, result: h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx) };
1448
1912
  };
1449
1913
  const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });
@@ -1929,6 +2393,32 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
1929
2393
  assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
1930
2394
  });
1931
2395
 
2396
+ test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
2397
+ const { pi, tools, fire } = fakePi();
2398
+ const harness = deps();
2399
+ gentleAgents(pi, {}, harness.deps);
2400
+ // A terminal-bearing host is what makes sidebarPart do anything at all
2401
+ // (a host with no .terminal is already a passthrough); this is the host
2402
+ // shape the fullscreen layout actually uses.
2403
+ const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
2404
+ const { ctx, widget } = fakeContext(terminalTui);
2405
+ await fire("session_start", ctx);
2406
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/", label: "map lib modules", mode: "background" }, undefined, undefined, ctx);
2407
+ await tick();
2408
+
2409
+ // The factory only runs (and only then could register a rail part) once
2410
+ // something actually renders the widget, exactly like the real host.
2411
+ assert.match(widget()![1]!, /explore map lib modules/);
2412
+ const state = sidebarState(terminalTui as unknown as TUI);
2413
+ assert.equal(state.parts.has("agents"), false, "Agents never claims a rail slot; the above-editor widget is its only surface");
2414
+
2415
+ // Simulate the fullscreen sidebar actively owning the host, the same
2416
+ // condition sidebarPart used to suppress a registered bottom widget under.
2417
+ state.active = true;
2418
+ state.ownsHost = () => true;
2419
+ assert.match(widget()![1]!, /explore map lib modules/, "the widget keeps rendering regardless of sidebar ownership");
2420
+ });
2421
+
1932
2422
  test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
1933
2423
  const { pi, tools, fire, sent, renderers } = fakePi();
1934
2424
  const harness = deps();
@@ -2184,7 +2674,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef
2184
2674
  }
2185
2675
  });
2186
2676
 
2187
- test("finished tasks remain available through resolveTask but never reappear in the live-only overlay", async () => {
2677
+ test("a task that finishes live stays visible as history, in both scopes, and its result also resolves after a restart", async () => {
2188
2678
  const { pi, tools, fire, commands, shortcuts } = fakePi();
2189
2679
  const harness = deps();
2190
2680
  gentleAgents(pi, {}, harness.deps);
@@ -2204,21 +2694,25 @@ test("finished tasks remain available through resolveTask but never reappear in
2204
2694
  }
2205
2695
  assert.ok(stored.some((entry) => entry.task.id === id && entry.task.result === "Kept."), "the finished task is on disk");
2206
2696
 
2697
+ // A completely different session still resolves it by id on demand, with
2698
+ // nothing restored in advance.
2207
2699
  const fresh = fakePi();
2208
2700
  gentleAgents(fresh.pi, {}, deps().deps);
2209
2701
  const again = fakeContext();
2210
2702
  await fresh.fire("session_start", again.ctx);
2211
2703
  assert.equal((await fresh.tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, again.ctx)).content[0].text, "Kept.");
2212
2704
 
2705
+ // Back in the session where it actually finished, it stays listed as
2706
+ // history -- in the current-session view and under "all sessions" too.
2213
2707
  assert.ok(commands.has("gentle:agents") && shortcuts.has("alt+a"));
2214
2708
  const opened = commands.get("gentle:agents")!.handler("", ctx);
2215
2709
  for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2216
2710
  const overlay = overlays[0];
2217
2711
  assert.ok(overlay, "the overlay component was created");
2218
- assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /finished|Subagent explore|Current orchestrator/);
2712
+ assert.match(overlay.render(80).map(stripAnsi).join("\n"), /finished|Subagent explore/, "the task that finished in this session stays visible as history");
2219
2713
  overlay.handleInput("a");
2220
2714
  overlay.handleInput("\x1b[C");
2221
- assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /✓ Subagent explore/, "all sessions is not a historical-task browser");
2715
+ assert.match(overlay.render(80).map(stripAnsi).join("\n"), /Subagent explore/, "the current session's own history shows under all sessions too");
2222
2716
  overlay.handleInput("\x1b");
2223
2717
  await opened;
2224
2718
  });
@@ -2333,6 +2827,104 @@ test("restored task history cannot enter the live panel or execute stop even wit
2333
2827
  await opened;
2334
2828
  });
2335
2829
 
2830
+ // A1 (odd/tasks/usage-click-and-changes-attribution.md): resuming a session
2831
+ // brings its own finished subagents back as visible history, never another
2832
+ // session's, and a brand-new session starts with none restored.
2833
+ test("resuming a session restores its own finished tasks as history, never another session's", async () => {
2834
+ const { pi, fire, commands } = fakePi();
2835
+ const harness = deps();
2836
+ const historyHome = join(root, "resume-history-home");
2837
+ 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 };
2838
+ const own1 = base;
2839
+ const own2: TaskRecord = { ...base, id: "own-2", agent: "explore-b", status: TASK_STATUS.FAILED, endedAt: 200, error: "boom", result: null };
2840
+ const other: TaskRecord = { ...base, id: "not-mine", agent: "explore-other", parentSessionId: "other-session" };
2841
+ await saveTask(historyDir(historyHome), own1, emptyThread());
2842
+ await saveTask(historyDir(historyHome), own2, emptyThread());
2843
+ await saveTask(historyDir(historyHome), other, emptyThread());
2844
+ harness.deps.home = historyHome;
2845
+ gentleAgents(pi, {}, harness.deps);
2846
+ const { ctx, overlays } = fakeContext();
2847
+ ctx.sessionManager.getSessionId = () => "resumed-session";
2848
+ await fire("session_start", ctx, { reason: "resume" });
2849
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
2850
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2851
+ // The disk history read behind restoreSessionHistory is fire-and-forget,
2852
+ // so poll the overlay's own render output instead of sleeping a guess.
2853
+ const rendered = await waitForOverlayMatch(() => stripAnsi(overlays[0]!.render(100).join("\n")), /Subagent explore-a/);
2854
+ assert.match(rendered, /Subagent explore-a/, "the resumed session's own finished task is restored");
2855
+ assert.match(rendered, /Subagent explore-b/, "a second finished task of the same session is restored too");
2856
+ assert.doesNotMatch(rendered, /explore-other/, "another session's finished task never restores here");
2857
+ overlays[0]!.handleInput("\x1b");
2858
+ await opened;
2859
+ await fire("session_shutdown", ctx);
2860
+
2861
+ const { pi: freshPi, fire: freshFire, commands: freshCommands } = fakePi();
2862
+ gentleAgents(freshPi, {}, harness.deps);
2863
+ const { ctx: freshCtx, overlays: freshOverlays } = fakeContext();
2864
+ freshCtx.sessionManager.getSessionId = () => "brand-new-session";
2865
+ await freshFire("session_start", freshCtx, { reason: "new" });
2866
+ await tick();
2867
+ const freshOpened = freshCommands.get("gentle:agents")!.handler("", freshCtx);
2868
+ for (let attempt = 0; attempt < 40 && freshOverlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2869
+ assert.doesNotMatch(stripAnsi(freshOverlays[0]!.render(100).join("\n")), /explore/, "a brand-new session restores nothing");
2870
+ freshOverlays[0]!.handleInput("\x1b");
2871
+ await freshOpened;
2872
+ await freshFire("session_shutdown", freshCtx);
2873
+ });
2874
+
2875
+ // A1 follow-up: Pi reports "startup" (not "resume") when the CLI is launched
2876
+ // directly into an existing session file (--continue / --resume picker,
2877
+ // agent-session.js:152) -- "resume" is only the in-session /resume switch.
2878
+ // A startup into a session that already has entries must restore its history
2879
+ // too; a "startup" with no entries (nothing pre-existing to restore) must not.
2880
+ test("starting up into an existing session also restores its own finished history; a startup with no prior entries does not", async () => {
2881
+ const historyHome = join(root, "startup-history-home");
2882
+ 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 };
2883
+ await saveTask(historyDir(historyHome), finished, emptyThread());
2884
+
2885
+ {
2886
+ // Startup into a session file that already has entries: restores.
2887
+ const { pi, fire, commands } = fakePi();
2888
+ const harness = deps();
2889
+ harness.deps.home = historyHome;
2890
+ gentleAgents(pi, {}, harness.deps);
2891
+ const { ctx, overlays } = fakeContext();
2892
+ ctx.sessionManager.getSessionId = () => "startup-session";
2893
+ ctx.sessionManager.getEntries = (() => [{ type: "message" }]) as typeof ctx.sessionManager.getEntries;
2894
+ await fire("session_start", ctx, { reason: "startup" });
2895
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
2896
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2897
+ // The disk history read behind restoreSessionHistory is fire-and-forget,
2898
+ // so poll the overlay's own render output instead of sleeping a guess.
2899
+ const rendered = await waitForOverlayMatch(() => stripAnsi(overlays[0]!.render(100).join("\n")), /Subagent explore-startup/);
2900
+ assert.match(rendered, /Subagent explore-startup/, "startup into an existing session restores its own finished history");
2901
+ overlays[0]!.handleInput("\x1b");
2902
+ await opened;
2903
+ await fire("session_shutdown", ctx);
2904
+ }
2905
+ {
2906
+ // Startup reported for a session with no prior entries: nothing to
2907
+ // restore, even though the reason is "startup" and the id matches.
2908
+ const { pi, fire, commands } = fakePi();
2909
+ const harness = deps();
2910
+ harness.deps.home = historyHome;
2911
+ gentleAgents(pi, {}, harness.deps);
2912
+ const { ctx, overlays } = fakeContext();
2913
+ ctx.sessionManager.getSessionId = () => "startup-session";
2914
+ await fire("session_start", ctx, { reason: "startup" });
2915
+ // getEntries defaults to [] here, so preexisting is false and
2916
+ // restoreSessionHistory never runs -- nothing to wait for beyond
2917
+ // letting the already-fired session_start handler settle.
2918
+ await tick();
2919
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
2920
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
2921
+ assert.doesNotMatch(stripAnsi(overlays[0]!.render(100).join("\n")), /explore-startup/, "a startup report with no prior entries restores nothing");
2922
+ overlays[0]!.handleInput("\x1b");
2923
+ await opened;
2924
+ await fire("session_shutdown", ctx);
2925
+ }
2926
+ });
2927
+
2336
2928
  test("Alt+S confirms a snapshot of active subagents and suppresses their follow-up delivery", async () => {
2337
2929
  const { pi, tools, fire, shortcuts, sent } = fakePi();
2338
2930
  const harness = deps();