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
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // ODD routing drift ratchet — canonical fixture regeneration.
3
+ //
4
+ // gentle-pi hand-mirrors the always-on ODD routing block rendered by gentle-ai
5
+ // `internal/components/agentguidance/routing.go` (RenderRouting). There is no
6
+ // automated sync, so a canonical change leaves the pi mirror stale with nothing
7
+ // catching it. This script renders the canonical block from a LOCAL gentle-ai
8
+ // checkout and vendors it into `fixtures/odd-routing-canonical.md` with managed
9
+ // provenance, where `tests/odd-routing-canonical-ratchet.test.ts` turns drift
10
+ // into a failing test.
11
+ //
12
+ // This script NEVER fetches anything from the network (GOPROXY=off), NEVER
13
+ // modifies the gentle-ai checkout beyond a transient temporary Go entrypoint
14
+ // that it always deletes, and NEVER touches the installer release pin
15
+ // (scripts/gentle-ai-installer.mjs INSTALLER_VERSION). Regeneration writes ONLY
16
+ // the fixture; it never auto-rewrites the pi mirror assets.
17
+ //
18
+ // Usage:
19
+ // node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import { createHash } from "node:crypto";
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { fileURLToPath, pathToFileURL } from "node:url";
26
+
27
+ export const ODD_ROUTING_FIXTURE_RELATIVE = "fixtures/odd-routing-canonical.md";
28
+ export const ODD_ROUTING_SOURCE_REPO = "https://github.com/Gentleman-Programming/gentle-ai";
29
+ export const ODD_ROUTING_SOURCE_PATH = "internal/components/agentguidance/routing.go";
30
+ export const ODD_ROUTING_HEADER_OPEN = "<!-- gentle-pi:managed-odd-routing-canonical\n";
31
+ export const ODD_ROUTING_HEADER_CLOSE = "\n-->\n";
32
+ export const ODD_ROUTING_TEMP_DIR = "tmp-odd-routing-dump";
33
+ export const ODD_ROUTING_BLOCK_PREFIX = "## Implementation Routing";
34
+
35
+ const GO_ENTRYPOINT = `package main
36
+
37
+ import (
38
+ "fmt"
39
+ "os"
40
+
41
+ "github.com/gentleman-programming/gentle-ai/v3/internal/components/agentguidance"
42
+ "github.com/gentleman-programming/gentle-ai/v3/internal/model"
43
+ )
44
+
45
+ func main() {
46
+ rendered, err := agentguidance.RenderRouting(model.AgentPi)
47
+ if err != nil {
48
+ fmt.Fprintln(os.Stderr, err)
49
+ os.Exit(1)
50
+ }
51
+ fmt.Print(rendered)
52
+ }
53
+ `;
54
+
55
+ export function sha256Hex(value) {
56
+ return createHash("sha256").update(value).digest("hex");
57
+ }
58
+
59
+ export function parseOddRoutingFixture(raw) {
60
+ if (!raw.startsWith(ODD_ROUTING_HEADER_OPEN)) {
61
+ throw new Error(`fixture does not start with the managed header ${JSON.stringify(ODD_ROUTING_HEADER_OPEN)}`);
62
+ }
63
+ const headerEnd = raw.indexOf(ODD_ROUTING_HEADER_CLOSE, ODD_ROUTING_HEADER_OPEN.length);
64
+ if (headerEnd === -1) {
65
+ throw new Error(`fixture managed header is not terminated by ${JSON.stringify(ODD_ROUTING_HEADER_CLOSE)}`);
66
+ }
67
+ const headerText = raw.slice(ODD_ROUTING_HEADER_OPEN.length, headerEnd);
68
+ const body = raw.slice(headerEnd + ODD_ROUTING_HEADER_CLOSE.length);
69
+ const header = {};
70
+ for (const line of headerText.split("\n")) {
71
+ const separator = line.indexOf(": ");
72
+ if (separator === -1) continue;
73
+ header[line.slice(0, separator)] = line.slice(separator + 2);
74
+ }
75
+ return { header, body };
76
+ }
77
+
78
+ export function renderOddRoutingFixture(block, { sourceCommit, generatedAt }) {
79
+ const body = block.endsWith("\n") ? block : `${block}\n`;
80
+ const header = [
81
+ ODD_ROUTING_HEADER_OPEN.trimEnd(),
82
+ `source_repo: ${ODD_ROUTING_SOURCE_REPO}`,
83
+ `source_path: ${ODD_ROUTING_SOURCE_PATH}`,
84
+ `source_commit: ${sourceCommit}`,
85
+ `generated_at: ${generatedAt}`,
86
+ `block_sha256: ${sha256Hex(body)}`,
87
+ "-->",
88
+ ].join("\n");
89
+ return `${header}\n${body}`;
90
+ }
91
+
92
+ // `git status --porcelain --untracked-files=no` emits one `XY PATH` line per
93
+ // modified tracked file. Extract just the repository-relative path so the
94
+ // fail-closed error can name what would make provenance unverifiable.
95
+ export function parsePorcelainPaths(porcelainOutput) {
96
+ return porcelainOutput
97
+ .split("\n")
98
+ .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
99
+ .filter((line) => line.length > 0)
100
+ .map((line) => line.slice(3).trim())
101
+ .filter((path) => path.length > 0);
102
+ }
103
+
104
+ // Fail closed when the checkout carries uncommitted changes to tracked files.
105
+ // The fixture header asserts the rendered block came from the committed tree;
106
+ // rendering a dirty tree under that claim would make the provenance a lie.
107
+ // Untracked files never reach this guard because the git call excludes them.
108
+ export function assertCleanGentleAiCheckout(porcelainOutput) {
109
+ const dirtyPaths = parsePorcelainPaths(porcelainOutput);
110
+ if (dirtyPaths.length > 0) {
111
+ throw new Error(
112
+ `gentle-ai checkout is dirty; provenance would be unverifiable: ${dirtyPaths.join(", ")}. Commit or stash first.`,
113
+ );
114
+ }
115
+ }
116
+
117
+ function parseArguments(argv) {
118
+ const values = { gentleAi: undefined };
119
+ for (let index = 0; index < argv.length; index += 2) {
120
+ const flag = argv[index];
121
+ const value = argv[index + 1];
122
+ if (flag !== "--gentle-ai" || value === undefined) {
123
+ throw new Error("usage: node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]");
124
+ }
125
+ if (values.gentleAi !== undefined) throw new Error("duplicate --gentle-ai argument");
126
+ values.gentleAi = value;
127
+ }
128
+ return values;
129
+ }
130
+
131
+ function requireGentleAiCheckout(gentleAiRoot) {
132
+ if (!existsSync(gentleAiRoot)) {
133
+ throw new Error(
134
+ `no gentle-ai checkout at ${gentleAiRoot}; pass --gentle-ai <path> or clone ${ODD_ROUTING_SOURCE_REPO} as a sibling of gentle-pi`,
135
+ );
136
+ }
137
+ const routingPath = join(gentleAiRoot, ...ODD_ROUTING_SOURCE_PATH.split("/"));
138
+ if (!existsSync(routingPath)) {
139
+ throw new Error(`not a gentle-ai checkout: missing ${routingPath}`);
140
+ }
141
+ }
142
+
143
+ export function renderCanonicalRouting(gentleAiRoot) {
144
+ const tempRoot = join(gentleAiRoot, ODD_ROUTING_TEMP_DIR);
145
+ // Clear any stale temp entrypoint from a previous crash so the render is clean.
146
+ rmSync(tempRoot, { recursive: true, force: true });
147
+ mkdirSync(tempRoot, { recursive: true });
148
+ try {
149
+ writeFileSync(join(tempRoot, "main.go"), GO_ENTRYPOINT, { encoding: "utf8" });
150
+ const rendered = execFileSync("go", ["run", `./${ODD_ROUTING_TEMP_DIR}`], {
151
+ cwd: gentleAiRoot,
152
+ encoding: "utf8",
153
+ // GOPROXY=off fails closed instead of downloading: this script must
154
+ // never touch the network.
155
+ env: { ...process.env, GOPROXY: "off" },
156
+ stdio: ["ignore", "pipe", "inherit"],
157
+ });
158
+ if (!rendered.startsWith(ODD_ROUTING_BLOCK_PREFIX)) {
159
+ throw new Error(
160
+ `RenderRouting(model.AgentPi) did not return the expected routing block (starts with ${JSON.stringify(ODD_ROUTING_BLOCK_PREFIX)})`,
161
+ );
162
+ }
163
+ return rendered;
164
+ } finally {
165
+ rmSync(tempRoot, { recursive: true, force: true });
166
+ }
167
+ }
168
+
169
+ // Resolve fixture provenance from the committed gentle-ai tree. `generatedAt`
170
+ // is the committer date, not wall-clock time, so rendering the same commit
171
+ // twice yields byte-identical fixture files. `execGit` is injectable for tests.
172
+ export function resolveOddRoutingProvenance(gentleAiRoot, execGit) {
173
+ const git = execGit ?? ((args) => execFileSync("git", args, { cwd: gentleAiRoot, encoding: "utf8" }));
174
+ assertCleanGentleAiCheckout(git(["status", "--porcelain", "--untracked-files=no"]));
175
+ return {
176
+ sourceCommit: git(["rev-parse", "HEAD"]).trim(),
177
+ generatedAt: git(["show", "-s", "--format=%cI", "HEAD"]).trim(),
178
+ };
179
+ }
180
+
181
+ export function mirrorOddRouting(packageRoot, gentleAiRoot) {
182
+ requireGentleAiCheckout(gentleAiRoot);
183
+ // Resolve (and fail closed on dirty) provenance BEFORE rendering: the
184
+ // transient Go entrypoint must not run against a tree whose commit hash the
185
+ // fixture would misrepresent.
186
+ const provenance = resolveOddRoutingProvenance(gentleAiRoot);
187
+ const block = renderCanonicalRouting(gentleAiRoot);
188
+ const contents = renderOddRoutingFixture(block, provenance);
189
+
190
+ const fixturePath = join(resolve(packageRoot), ...ODD_ROUTING_FIXTURE_RELATIVE.split("/"));
191
+ const previous = readPreviousFixture(fixturePath);
192
+ mkdirSync(dirname(fixturePath), { recursive: true });
193
+
194
+ // Atomic overwrite: write a sibling temp file, then rename it into place.
195
+ const stagingPath = `${fixturePath}.tmp`;
196
+ try {
197
+ writeFileSync(stagingPath, contents, { encoding: "utf8" });
198
+ renameSync(stagingPath, fixturePath);
199
+ } finally {
200
+ rmSync(stagingPath, { force: true });
201
+ }
202
+
203
+ const next = parseOddRoutingFixture(contents);
204
+ return {
205
+ fixturePath,
206
+ sourceCommit: next.header.source_commit,
207
+ blockSha256: next.header.block_sha256,
208
+ changed: previous === undefined || previous.body !== next.body || previous.header.source_commit !== next.header.source_commit,
209
+ previous,
210
+ };
211
+ }
212
+
213
+ function readPreviousFixture(fixturePath) {
214
+ if (!existsSync(fixturePath)) return undefined;
215
+ const raw = readFileSync(fixturePath, "utf8");
216
+ // Verify the existing fixture before clobbering it: an unparseable file is a
217
+ // signal to inspect, not to silently overwrite.
218
+ const parsed = parseOddRoutingFixture(raw);
219
+ const recorded = parsed.header.block_sha256;
220
+ const actual = sha256Hex(parsed.body);
221
+ if (recorded !== undefined && recorded !== actual) {
222
+ console.warn(`warning: existing fixture body does not match its recorded digest (${recorded}); regenerating from canonical`);
223
+ }
224
+ return parsed;
225
+ }
226
+
227
+ async function main() {
228
+ const packageRoot = join(fileURLToPath(new URL("..", import.meta.url)));
229
+ const { gentleAi } = parseArguments(process.argv.slice(2));
230
+ const gentleAiRoot = resolve(gentleAi ?? join(packageRoot, "..", "gentle-ai"));
231
+ const result = mirrorOddRouting(packageRoot, gentleAiRoot);
232
+ console.log(`${result.changed ? "Wrote" : "Refreshed"} ${result.fixturePath}`);
233
+ console.log(`source commit ${result.sourceCommit}; block sha256 ${result.blockSha256}`);
234
+ if (result.previous !== undefined) {
235
+ console.log(`previous commit ${result.previous.header.source_commit}; previous block sha256 ${result.previous.header.block_sha256}`);
236
+ }
237
+ }
238
+
239
+ const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
240
+ if (isMainModule) {
241
+ await main();
242
+ }
@@ -181,7 +181,7 @@ const contractHashes = {
181
181
  "contracts/review-integration/v2/schemas/start.schema.json": "2991e3fcca672d9257d61b6a336fb34e58b15a8e03f8a09a7adf892cae6a8085",
182
182
  "contracts/review-integration/v2/schemas/status.schema.json": "c4dcc736cfc6300560a3c4262d2d982368529d5c49d58d499552a3b0beef9212",
183
183
  "contracts/telemetry/runtime-aggregate-v1.schema.json": "eb0f2993d9271f55cb42eca343e6fbb601a733fb90bd40daeebc92ee60ae1ba9",
184
- "docs/review-integration.md": "95a3df92785bc4d9f3b99e702aaf817ae0440bd16c83218d2c3f2aca67c280fb",
184
+ "docs/review-integration.md": "9b60fa2775129c468f7eb3510905014553cee5f3cbcb78a608f08baf8bbd7336",
185
185
  };
186
186
 
187
187
  requiredPaths.push(...Object.keys(contractHashes));
@@ -339,7 +339,7 @@ async function main() {
339
339
  });
340
340
 
341
341
  if (driftedContracts.length > 0) {
342
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.1.0 runtime's vendored Gentle AI contract artifacts:");
342
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.4.0 runtime's vendored Gentle AI contract artifacts:");
343
343
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
344
344
  process.exit(1);
345
345
  }
@@ -384,7 +384,7 @@ async function main() {
384
384
  process.exit(1);
385
385
  }
386
386
 
387
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.1.0 runtime).`);
387
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.4.0 runtime).`);
388
388
  }
389
389
 
390
390
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { type TuiMouseEvent } from "@earendil-works/pi-tui";
4
4
  import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
5
- import { AgentsView, SESSION_FINISHED_TTL_MS } from "../lib/agents-view.ts";
5
+ import { AgentsView } from "../lib/agents-view.ts";
6
6
  import { stripAnsi } from "../lib/terminal-theme.ts";
7
7
 
8
8
  const plainTheme = { fg: (_color: string, text: string) => text };
@@ -111,30 +111,25 @@ test("AgentsView keeps headings non-actionable and clears a hidden selected chil
111
111
  view.dispose();
112
112
  });
113
113
 
114
- test("AgentsView removes terminal children immediately without deleting retained threads", () => {
114
+ test("AgentsView keeps finished children listed as history instead of dropping them", () => {
115
115
  let now = 10_000;
116
116
  const { store, view } = harness(12, () => now);
117
117
  store.add(task("live", "current-session-id", { agent: "retained" }));
118
118
  store.add(task("other", "other-session", { agent: "excluded" }));
119
119
  store.apply("live", { type: "text", text: "kept thread" }, now);
120
120
  store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: now });
121
- for (const elapsed of [0, SESSION_FINISHED_TTL_MS - 1]) {
122
- now = 10_000 + elapsed;
123
- const output = view.render(100).map(stripAnsi).join("\n");
124
- assert.doesNotMatch(output, /Subagent retained|kept thread/);
125
- assert.equal(store.thread("live").items.length, 1, "retained thread remains available outside the panel");
126
- assert.doesNotMatch(output, /excluded/);
127
- assert.equal(view.selectedTask(), undefined);
128
- }
129
- now = 10_000 + SESSION_FINISHED_TTL_MS;
130
- assert.doesNotMatch(view.render(100).join("\n"), /Subagent retained/);
131
- assert.equal(view.selectedTask(), undefined);
121
+ now += 60_000;
122
+ const output = view.render(100).map(stripAnsi).join("\n");
123
+ assert.match(output, /Subagent retained/, "a finished task of this session stays listed, long after it ended");
124
+ assert.equal(store.thread("live").items.length, 1, "its retained thread remains available");
125
+ assert.doesNotMatch(output, /excluded/, "another session's task never shows");
126
+ now += 60_000;
132
127
  store.add(task("archive", "current-session-id", { agent: "archived", status: TASK_STATUS.COMPLETED, endedAt: now }));
133
- assert.doesNotMatch(view.render(100).join("\n"), /Subagent archived/, "absent groups lose implicit expansion");
128
+ assert.match(view.render(100).join("\n"), /Subagent archived/, "a later finished task joins the history too");
134
129
  view.dispose();
135
130
  });
136
131
 
137
- test("AgentsView retains an idle open heading and explicit collapse after the final child finishes", () => {
132
+ test("AgentsView keeps an explicit collapse across a finish, and reveals the finished child as history on re-expand", () => {
138
133
  const { store, view } = harness();
139
134
  store.add(task("live", "current-session-id", { agent: "hidden" }));
140
135
  view.handleInput("a");
@@ -142,10 +137,12 @@ test("AgentsView retains an idle open heading and explicit collapse after the fi
142
137
  view.handleInput("k");
143
138
  view.handleInput("\x1b[D");
144
139
  store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: 10_000 });
145
- assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/);
140
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/, "an explicit collapse persists across the child finishing");
146
141
  view.handleInput("\x1b[C");
147
- assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/);
148
- assert.match(view.render(100).join("\n"), /Current orchestrator.*0/);
142
+ const revealed = view.render(100).join("\n");
143
+ assert.match(revealed, /Subagent hidden/, "re-expanding reveals the finished child as history, not nothing");
144
+ assert.match(revealed, /Current orchestrator · 1 Su/, "the group's own count now includes the finished child");
145
+ assert.match(revealed, /0 active · 1 finished/, "the panel header reflects the finished child too");
149
146
  store.add(task("replacement", "current-session-id", { agent: "replacement" }));
150
147
  assert.match(view.render(100).join("\n"), /Subagent replacement/);
151
148
  view.dispose();
@@ -177,3 +174,63 @@ test("AgentsView keeps its selected listener stable while another listener recei
177
174
  unsubscribe();
178
175
  view.dispose();
179
176
  });
177
+
178
+ // A1 (odd/tasks/usage-click-and-changes-attribution.md): finished subagents
179
+ // stay listed as history instead of vanishing, ordered active first and then
180
+ // newest-ended-first, with the panel header reading "N active . M finished",
181
+ // and never cancellable or reorderable by later activity.
182
+
183
+ test("AgentsView lists active children before finished ones, newest ended first", () => {
184
+ const { store, view } = harness();
185
+ store.add(task("old-finished", "current-session-id", { agent: "old-finished", status: TASK_STATUS.COMPLETED, endedAt: 1000, createdAt: 100 }));
186
+ store.add(task("new-finished", "current-session-id", { agent: "new-finished", status: TASK_STATUS.FAILED, endedAt: 3000, createdAt: 100 }));
187
+ store.add(task("active", "current-session-id", { agent: "active-one", createdAt: 50 }));
188
+ const output = view.render(100).join("\n");
189
+ const activeIndex = output.indexOf("Subagent active-one");
190
+ const newFinishedIndex = output.indexOf("Subagent new-finished");
191
+ const oldFinishedIndex = output.indexOf("Subagent old-finished");
192
+ assert.ok(activeIndex >= 0 && newFinishedIndex >= 0 && oldFinishedIndex >= 0, "every task is listed");
193
+ assert.ok(activeIndex < newFinishedIndex, "active tasks list before finished ones");
194
+ assert.ok(newFinishedIndex < oldFinishedIndex, "finished tasks order newest ended first, regardless of creation order");
195
+ view.dispose();
196
+ });
197
+
198
+ test("the panel header counts active and finished separately", () => {
199
+ const { store, view } = harness();
200
+ store.add(task("active-1", "current-session-id", { agent: "active-1" }));
201
+ store.add(task("done-1", "current-session-id", { agent: "done-1", status: TASK_STATUS.COMPLETED, endedAt: 1000 }));
202
+ store.add(task("done-2", "current-session-id", { agent: "done-2", status: TASK_STATUS.FAILED, endedAt: 2000 }));
203
+ assert.match(view.render(100).join("\n"), /❀ Agents · this session · 1 active · 2 finished/);
204
+ view.dispose();
205
+ });
206
+
207
+ test("a finished task's row offers no stop control and ignores the stop key", () => {
208
+ const { store, view } = harness();
209
+ store.add(task("done", "current-session-id", { agent: "done", status: TASK_STATUS.COMPLETED, endedAt: 1000 }));
210
+ let cancelled: string | undefined;
211
+ view.dispose();
212
+ const cancelView = new AgentsView({
213
+ theme: plainTheme,
214
+ rows: 12,
215
+ store,
216
+ sessionId: "current-session-id",
217
+ now: () => 10_000,
218
+ onCancel: (task) => { cancelled = task.id; },
219
+ onOpen: () => {},
220
+ onClose: () => {},
221
+ requestRender: () => {},
222
+ });
223
+ assert.doesNotMatch(cancelView.render(100).join("\n"), /Stop selected/, "the footer never offers to stop a finished task");
224
+ cancelView.handleInput("s");
225
+ assert.equal(cancelled, undefined, "the stop key is a no-op on a finished task's row");
226
+ cancelView.dispose();
227
+ });
228
+
229
+ test("history retention caps finished tasks at 200 per session", () => {
230
+ const { store, view } = harness();
231
+ for (let i = 0; i < 202; i += 1) {
232
+ store.add(task(`f${i}`, "current-session-id", { agent: `f${i}`, status: TASK_STATUS.COMPLETED, endedAt: i, createdAt: i }));
233
+ }
234
+ assert.match(view.render(100).join("\n"), /0 active · 200 finished/, "the oldest finished tasks beyond the cap are dropped");
235
+ view.dispose();
236
+ });
@@ -163,15 +163,15 @@ test("activating a visible child preserves the manual list offset through Back a
163
163
  }
164
164
  });
165
165
 
166
- test("resizing a narrow child list to desktop keeps Fullscreen local and completion returns to the list", () => {
166
+ test("a task finishing while its details are open in narrow mode keeps its thread visible instead of kicking back to the list", () => {
167
167
  const { store, view, events } = harness(8);
168
168
  store.add(task("a"));
169
169
  view.render(40);
170
170
  clickLabel(view, "Fullscreen", 80);
171
171
  assert.deepEqual(events, []);
172
172
  store.update("a", { status: TASK_STATUS.COMPLETED, endedAt: 5000 });
173
- assert.equal(view.selectedTask(), undefined);
174
- assert.doesNotMatch(view.render(80).join("\n"), /Back|Subagent explore/);
173
+ assert.equal(view.selectedTask()?.id, "a", "the finished task stays selected as history instead of disappearing");
174
+ assert.match(view.render(80).join("\n"), /Back|Subagent explore/, "narrow details mode stays open on the now-finished task");
175
175
  assert.deepEqual(events, []);
176
176
  view.dispose();
177
177
  });
@@ -254,11 +254,12 @@ test("AgentsView renders the frame with the task list and the selected thread's
254
254
  for (const line of lines) assert.equal(visibleWidth(line), 90, `"${stripAnsi(line)}" is not 90 wide`);
255
255
  const plain = lines.map(stripAnsi);
256
256
  assert.equal(plain.length, 8);
257
- assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
257
+ assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active · 1 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
258
258
  assert.match(plain[1], /▸ └ ◐ Subagent explore.*explore · running · gpt-5\.6-terra · 34k · \$0\.27 · 1m00s/);
259
259
  assert.match(plain[2], /line 3/, "the thread window follows the tail");
260
260
  assert.match(plain[3], /line 4/);
261
- assert.doesNotMatch(plain.join("\n"), /Subagent worker|orchestrator/i);
261
+ assert.match(plain.join("\n"), /Subagent worker/, "the session's own finished task now stays listed as history");
262
+ assert.doesNotMatch(plain.join("\n"), /orchestrator/i);
262
263
  assert.match(plain[4], /line 5/);
263
264
  assert.match(plain[6], /\[ Follow \].*\[ Open session \].*\[Stop\]/);
264
265
  assert.match(plain[7], /^╰─+╯$/);
@@ -306,7 +307,7 @@ test("AgentsView subscribes only to the selected task and survives an empty stor
306
307
  assert.equal(renders(), before + 1, "disposed views stay quiet");
307
308
  });
308
309
 
309
- test("live-only current panel exposes active children directly at desktop and narrow widths", () => {
310
+ test("current panel exposes active children directly at desktop and narrow widths, and keeps its own finished history", () => {
310
311
  for (const width of [40, 80]) {
311
312
  const { store, view, events } = harness(12, "s");
312
313
  try {
@@ -318,8 +319,12 @@ test("live-only current panel exposes active children directly at desktop and na
318
319
  }
319
320
  store.add(task("foreign", { agent: "foreign", parentSessionId: "other" }));
320
321
  const frame = view.render(width).map(stripAnsi).join("\n");
321
- assert.doesNotMatch(frame, /orchestrator|history_|foreign|finished/i);
322
+ assert.doesNotMatch(frame, /orchestrator|foreign/i);
322
323
  for (const status of ["running", "queued", "waiting"]) assert.match(frame, new RegExp(`Subagent ${status}`));
324
+ // The narrow list column truncates each "history_<status>" label to a
325
+ // common "history_…" prefix, so count occurrences instead of matching
326
+ // each one by its full name.
327
+ assert.equal((frame.match(/Subagent history_/g) ?? []).length, 4, "all four finished tasks of this session stay listed as history");
323
328
  assert.match(stripAnsi(view.render(width)[1]), /Subagent queued/, "equal creation times sort by ID, with no parent wrapper");
324
329
  assert.equal(view.selectedTask()?.id, "running", "adding earlier-sorted children preserves the existing selection");
325
330
  view.handleInput("k");
@@ -332,22 +337,27 @@ test("live-only current panel exposes active children directly at desktop and na
332
337
  view.handleInput("s");
333
338
  assert.deepEqual(events, [`cancel:${selected.id}`]);
334
339
  store.update(selected.id, { status: TASK_STATUS.COMPLETED, endedAt: 61_000 });
335
- assert.notEqual(view.selectedTask()?.id, selected.id, "completion removes the selected live row immediately");
340
+ assert.equal(view.selectedTask()?.id, selected.id, "completion keeps the task selected, now as finished history");
336
341
  } finally {
337
342
  view.dispose();
338
343
  }
339
344
  }
340
345
  });
341
346
 
342
- test("live-only current panel never promotes retained foreign or unknown tasks into open sessions", () => {
347
+ test("current panel never promotes retained foreign or unknown tasks into open sessions, but keeps its own finished history", () => {
343
348
  const { store, view } = harness(12, "s");
344
349
  try {
345
350
  store.add(task("foreign", { agent: "foreign", parentSessionId: "closed" }));
346
351
  store.add(task("unknown", { agent: "unknown", parentSessionId: "" }));
347
352
  store.add(task("finished", { agent: "finished", status: TASK_STATUS.COMPLETED, endedAt: 61_000 }));
348
- assert.equal(view.selectedTask(), undefined);
353
+ assert.equal(view.selectedTask()?.id, "finished", "the session's own finished task is selectable even with nothing active");
349
354
  view.handleInput("a");
350
- assert.doesNotMatch(view.render(80).map(stripAnsi).join("\n"), /Subagent (foreign|unknown|finished)|Orchestrator closed|Unknown session/);
355
+ // The "Current orchestrator" heading starts collapsed here: it has no
356
+ // active task to auto-expand it, only finished history.
357
+ view.handleInput("\x1b[C");
358
+ const rendered = view.render(80).map(stripAnsi).join("\n");
359
+ assert.doesNotMatch(rendered, /Subagent (foreign|unknown)|Orchestrator closed|Unknown session/);
360
+ assert.match(rendered, /Subagent finished/, "the session's own finished task shows under all sessions too, once its heading is expanded");
351
361
  assert.equal(store.list().length, 3, "panel filtering never deletes retained history");
352
362
  } finally {
353
363
  view.dispose();
@@ -553,7 +563,7 @@ test("AgentsView scrolls the task list so the selection stays visible when there
553
563
  assert.match(listed()[2], /Subagent agent3/);
554
564
  });
555
565
 
556
- test("AgentsView scopes to live children and toggles only the open-session directory", () => {
566
+ test("AgentsView scopes to this session's own children (active and finished) and toggles only the open-session directory", () => {
557
567
  const { store, view } = harness(12, "s");
558
568
  store.add(task("mine", { agent: "mine" }));
559
569
  store.add(task("theirs", { agent: "theirs", parentSessionId: "other", createdAt: 900, lastActivityAt: 900 }));
@@ -561,19 +571,19 @@ test("AgentsView scopes to live children and toggles only the open-session direc
561
571
  store.add(task("stale", { agent: "stale", status: TASK_STATUS.COMPLETED, endedAt: 61_000 - 16 * 60_000, createdAt: 800, lastActivityAt: 800 }));
562
572
  const names = () => view.render(80).map(stripAnsi).filter((line) => /[◐✓] Subagent /.test(line)).map((line) => line.match(/[◐✓] Subagent (\w+)/)?.[1]);
563
573
  let plain = view.render(80).map(stripAnsi);
564
- assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
565
- assert.deepEqual(names(), ["mine"], "all terminal tasks and foreign retained tasks stay out");
574
+ assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active · 2 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
575
+ assert.deepEqual(names(), ["mine", "fresh", "stale"], "the session's own history stays, newest ended first, after the active child; the foreign task stays out");
566
576
  assert.match(plain.at(-2) ?? "", /\[Scope\]/);
567
577
  view.handleInput("a");
568
578
  plain = view.render(80).map(stripAnsi);
569
- assert.match(plain[0], /^╭─ ❀ Agents · all sessions · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
570
- assert.deepEqual(names(), ["mine"], "retained foreign tasks cannot manufacture open orchestrators");
579
+ assert.match(plain[0], /^╭─ ❀ Agents · all sessions · 1 active · 2 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
580
+ assert.deepEqual(names(), ["mine", "fresh", "stale"], "retained foreign tasks cannot manufacture open orchestrators");
571
581
  assert.match(plain.at(-2) ?? "", /\[Scope\]/);
572
582
  view.handleInput("j");
573
- assert.equal(view.selectedTask()?.id, "mine", "navigation stops at the only live child");
583
+ assert.equal(view.selectedTask()?.id, "fresh", "navigation moves to the session's own next-newest history entry");
574
584
  view.handleInput("a");
575
585
  assert.equal(view.selectedTask()?.id, "mine", "a new scope reads from the top");
576
- assert.deepEqual(names(), ["mine"]);
586
+ assert.deepEqual(names(), ["mine", "fresh", "stale"]);
577
587
  });
578
588
 
579
589
  test("AgentsView footer buttons follow, open only a session-backed selection, and clear stale geometry", () => {