gentle-pi 3.2.1 → 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 (71) hide show
  1. package/docs/gentle-shell.md +27 -14
  2. package/docs/readme-reference.md +41 -7
  3. package/docs/review-integration.md +22 -17
  4. package/extensions/gentle-agents.ts +85 -17
  5. package/extensions/gentle-ai.ts +123 -21
  6. package/extensions/gentle-shell.ts +379 -31
  7. package/extensions/gentle-todo.ts +19 -1
  8. package/lib/agents-view.ts +41 -14
  9. package/lib/agents-widget.ts +84 -13
  10. package/lib/command-palette-catalog.ts +1 -0
  11. package/lib/double-esc-cancel-policy.ts +138 -0
  12. package/lib/inprocess-reviewer.ts +260 -0
  13. package/lib/native-review-cli.ts +14 -0
  14. package/lib/odd-runtime-delegation-gate.ts +88 -0
  15. package/lib/review-host-relay.ts +246 -171
  16. package/lib/review-integration-v2.ts +110 -26
  17. package/lib/shell-bar.ts +150 -75
  18. package/lib/shell-card.ts +19 -9
  19. package/lib/shell-changes-view.ts +43 -5
  20. package/lib/shell-changes.ts +92 -5
  21. package/lib/shell-hover.ts +39 -0
  22. package/lib/shell-prompt.ts +10 -1
  23. package/lib/shell-sidebar-layout.ts +111 -15
  24. package/lib/shell-sidebar.ts +16 -0
  25. package/lib/shell-todo.ts +7 -1
  26. package/lib/shell-usage-view.ts +98 -10
  27. package/package.json +1 -1
  28. package/runtime/native-review-cli.mjs +14 -0
  29. package/runtime/review-integration-v2.mjs +110 -26
  30. package/scripts/gentle-ai-installer.mjs +10 -10
  31. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  32. package/scripts/verify-package-files.mjs +3 -3
  33. package/tests/agents-grouping.test.ts +75 -18
  34. package/tests/agents-view.test.ts +28 -18
  35. package/tests/agents-widget.test.ts +100 -12
  36. package/tests/command-palette.test.ts +1 -0
  37. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  38. package/tests/double-esc-cancel-policy.test.ts +194 -0
  39. package/tests/gentle-agents.test.ts +528 -5
  40. package/tests/gentle-ai-binary.test.ts +1 -1
  41. package/tests/gentle-ai-installer.test.ts +47 -47
  42. package/tests/gentle-ai.test.ts +69 -5
  43. package/tests/gentle-shell.test.ts +795 -23
  44. package/tests/gentle-todo.test.ts +17 -4
  45. package/tests/inprocess-reviewer.test.ts +368 -0
  46. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  47. package/tests/native-review-capability-contract.test.ts +19 -1
  48. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  49. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  50. package/tests/package-manifest.test.ts +6 -6
  51. package/tests/review-host-relay-routing.test.ts +77 -0
  52. package/tests/review-host-relay.test.ts +277 -300
  53. package/tests/review-integration-v2-forward.test.ts +61 -0
  54. package/tests/review-integration-v2.test.ts +116 -1
  55. package/tests/review-relay-transport-agent.test.ts +22 -24
  56. package/tests/runtime-harness.mjs +11 -0
  57. package/tests/session-changes-shell.test.ts +27 -0
  58. package/tests/session-worktree-registry.test.ts +41 -0
  59. package/tests/shell-bar.test.ts +192 -124
  60. package/tests/shell-card.test.ts +5 -3
  61. package/tests/shell-changes-view.test.ts +47 -0
  62. package/tests/shell-changes.test.ts +177 -0
  63. package/tests/shell-hover.test.ts +19 -0
  64. package/tests/shell-prompt.test.ts +20 -0
  65. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  66. package/tests/shell-sidebar-layout.test.ts +243 -5
  67. package/tests/shell-sidebar.test.ts +25 -1
  68. package/tests/shell-todo.test.ts +36 -0
  69. package/tests/shell-usage-view.test.ts +120 -1
  70. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  71. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -9,10 +9,11 @@
9
9
  // `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests;
10
10
  // the CLI is the entry point the separate verifier uses for the real organic
11
11
  // positive journey after a user-visible forecast.
12
+ import { randomUUID } from "node:crypto";
12
13
  import { spawn, spawnSync } from "node:child_process";
13
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { delimiter, isAbsolute, join, resolve } from "node:path";
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
+ import { delimiter, isAbsolute, resolve } from "node:path";
16
+ import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat";
16
17
  import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, classifyReviewHostRelayRefusal, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
17
18
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts";
18
19
  export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1";
@@ -88,13 +89,17 @@ const exactKeys = (obj, allowed, label) => {
88
89
  // the completing form is provably bindable before any process launches.
89
90
  export function validateDescriptor(value) {
90
91
  if (!isObj(value)) fail("descriptor must be a JSON object");
91
- exactKeys(value, new Set(["schema", "gentleAiExecutable", "piExecutable", "cases"]), "descriptor");
92
+ exactKeys(value, new Set(["schema", "gentleAiExecutable", "reviewerSelection", "cases"]), "descriptor");
92
93
  if (value.schema !== DESCRIPTOR_SCHEMA) fail(`descriptor.schema must be exactly "${DESCRIPTOR_SCHEMA}"`);
93
94
  if (!isStr(value.gentleAiExecutable) || !isAbsolute(value.gentleAiExecutable)) fail("descriptor.gentleAiExecutable must be a non-empty absolute path; the runner never re-resolves the production binary");
94
- if (!isStr(value.piExecutable)) fail("descriptor.piExecutable must be a non-empty string");
95
+ // gentle-pi#311 P4: the positive-lens journey used to need a declared real
96
+ // `pi` binary; lens captures now run in-process, so it instead needs a
97
+ // "provider/id" reviewer selection the armed run resolves through a
98
+ // scripted-but-real completion (never a fake pi child).
99
+ if (!isStr(value.reviewerSelection)) fail("descriptor.reviewerSelection must be a non-empty string");
95
100
  if (!Array.isArray(value.cases) || value.cases.length === 0) fail("descriptor.cases must be a non-empty array");
96
101
  const seen = new Set();
97
- return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, piExecutable: value.piExecutable, cases: value.cases.map((entry, index) => {
102
+ return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, reviewerSelection: value.reviewerSelection, cases: value.cases.map((entry, index) => {
98
103
  if (!isObj(entry)) fail(`descriptor.cases[${index}] must be an object`);
99
104
  if (!isStr(entry.name)) fail(`descriptor.cases[${index}].name must be a non-empty string`);
100
105
  if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`);
@@ -205,13 +210,77 @@ export function resolveDeclaredExecutable(executable) {
205
210
  return null;
206
211
  }
207
212
  const missingReason = (executable, label) => `${label} "${executable}" is not an existing executable; arm the descriptor with a real binary, or set GENTLE_PI_REQUIRE_MAINTAINER=1 to fail instead of block.`;
208
- // A guaranteed-nonexistent pi path inside a fresh private directory. mkdtemp
209
- // picks an unpredictable name and 0700 keeps it ours, so nothing can race a
210
- // file into the slot between the mkdtemp and the spawn.
211
- function unlaunchablePi() {
212
- const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-no-pi-"));
213
- chmodSync(directory, 0o700);
214
- return { directory, executable: join(directory, "pi-must-never-launch") };
213
+ // A reviewer registry whose `find` always misses. Mirrors the pre-in-process
214
+ // "unlaunchable pi" trick this replaced: the maintainer-declared `kind` is
215
+ // not evidence that the declared gentle-ai binary is actually incapable, so
216
+ // a `relay-unavailable` negative control against a mis-declared CAPABLE
217
+ // binary must still never complete a reviewer or submit anything. Resolving
218
+ // nothing guarantees a typed MODEL_NOT_FOUND refusal before any completion
219
+ // or submission, so the control cannot become a hidden real mutation even
220
+ // against a binary that turns out to be capable (gentle-pi#311 P4).
221
+ function unresolvableReviewerRegistry() {
222
+ return {
223
+ find: () => undefined,
224
+ getApiKeyAndHeaders: async () => ({ ok: false, error: "the relay-unavailable negative control never resolves a reviewer" }),
225
+ };
226
+ }
227
+ // The armed, organic positive-lens journey needs the real gentle-ai binary's
228
+ // materialize/submit protocol exercised end-to-end, but this harness cannot
229
+ // assume a maintainer's real provider credentials. Instead of a fake pi
230
+ // child, this registers pi-ai's own faux provider -- the SAME api-registry
231
+ // the real `completeSimple` dispatches through in production -- so the real
232
+ // `runInProcessReviewer` -> real `completeSimple` path runs with no network.
233
+ // The scripted response reads the frozen prompt's `GENTLE_AI_REVIEW_BINDING`
234
+ // line for the subject_hash and its `GENTLE_AI_REVIEW_CONTEXT` line for the
235
+ // changed-path manifest, and answers with every field the reviewer result
236
+ // contract requires -- subject_hash, a completed inspection over exactly the
237
+ // frozen paths, findings, evidence -- so the real Go admission accepts it
238
+ // (gentle-pi#311 P4). Not unregistered: each
239
+ // call uses a fresh random api id, and this runs at most once per short-lived
240
+ // process (the CLI's single `main()` or one test), so a leaked entry in the
241
+ // process-global api-registry is harmless.
242
+ // Reads the one-line JSON that follows a frozen prompt marker (the binding
243
+ // and context lines Go writes at the top of every reviewer task). Returns
244
+ // undefined when the marker is absent or its JSON is malformed, so the faux
245
+ // reviewer answers with an incomplete result that real admission refuses
246
+ // instead of throwing inside the provider and masking the contract drift.
247
+ function frozenPromptMarkerJson(promptText, marker) {
248
+ const prefix = `${marker} `;
249
+ for (const line of promptText.split("\n")) {
250
+ if (!line.startsWith(prefix)) continue;
251
+ try { return JSON.parse(line.slice(prefix.length)); } catch { return undefined; }
252
+ }
253
+ return undefined;
254
+ }
255
+ function armedFauxReviewerRegistry(reviewerSelection) {
256
+ const separatorIndex = reviewerSelection.indexOf("/");
257
+ if (separatorIndex <= 0 || separatorIndex === reviewerSelection.length - 1) {
258
+ throw new DescriptorValidationError(`descriptor.reviewerSelection "${reviewerSelection}" must have the "provider/id" shape`);
259
+ }
260
+ const provider = reviewerSelection.slice(0, separatorIndex);
261
+ const modelId = reviewerSelection.slice(separatorIndex + 1);
262
+ const api = `gentle-pi-maintainer-faux-reviewer-${randomUUID()}`;
263
+ const faux = registerFauxProvider({ api, provider, models: [{ id: modelId }] });
264
+ faux.setResponses([(context) => {
265
+ const content = context.messages[0]?.content;
266
+ const textPart = Array.isArray(content) ? content.find((part) => part.type === "text") : undefined;
267
+ const promptText = textPart?.text ?? "";
268
+ const binding = frozenPromptMarkerJson(promptText, "GENTLE_AI_REVIEW_BINDING");
269
+ const frozenContext = frozenPromptMarkerJson(promptText, "GENTLE_AI_REVIEW_CONTEXT");
270
+ const manifest = Array.isArray(frozenContext?.changed_path_manifest) ? frozenContext.changed_path_manifest : [];
271
+ const paths = manifest.map((entry) => entry?.path).filter((path) => typeof path === "string" && path.length > 0);
272
+ return fauxAssistantMessage(JSON.stringify({
273
+ subject_hash: binding?.subject_hash,
274
+ inspection: { status: "completed", paths },
275
+ findings: [],
276
+ evidence: ["armed maintainer faux reviewer inspected every frozen candidate path"],
277
+ }));
278
+ }]);
279
+ const model = faux.getModel();
280
+ return {
281
+ find: () => model,
282
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux-key" }),
283
+ };
215
284
  }
216
285
  function terminateRoleProcessTree(child) {
217
286
  if (child.pid === undefined) return false;
@@ -317,39 +386,41 @@ export async function runMatrix(descriptor, options = {}) {
317
386
  }
318
387
  continue;
319
388
  }
320
- // The positive lens needs a real pi AND an explicit arm; the negative
321
- // control never launches pi (fails closed at materialize). The precheck
322
- // resolves the declared Pi executable ONCE and reuses that exact
323
- // concrete path for launch; a bare declaration is never re-resolved
324
- // between precheck and spawn, so the relay launches exactly the
325
- // executable the harness checked (issue #324).
326
- let piPath = null;
327
- if (caseEntry.kind === "positive-lens") {
328
- piPath = resolveDeclaredExecutable(descriptor.piExecutable);
329
- if (piPath === null) {
330
- verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.piExecutable, "piExecutable"), command: POSITIVE_JOURNEY_COMMAND });
331
- continue;
332
- }
333
- if (!positiveArmed) {
334
- verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
335
- continue;
336
- }
389
+ // The positive lens needs an explicit arm; the negative control never
390
+ // resolves a reviewer (fails closed at the reviewer stage or earlier).
391
+ if (caseEntry.kind === "positive-lens" && !positiveArmed) {
392
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
393
+ continue;
337
394
  }
338
- // `relay-unavailable` is a NEGATIVE CONTROL: it must never launch pi and
339
- // never submit. The maintainer-declared `kind` is not evidence that the
340
- // declared binary is actually incapable, so the arm gate cannot key off
341
- // the declaration alone. Pointed at a mis-declared CAPABLE binary, a
342
- // declaration-only gate materializes, runs a REAL pi model, and executes
343
- // a REAL `capture-result --input=...` submission, and only then reports
344
- // `fail` — the mutation already happened. So give the negative control a
345
- // pi that cannot exist: an incapable binary still classifies
346
- // relay-unavailable at materialize (the control stays genuine, since it
347
- // never reached pi anyway), while a capable one fails closed at the pi
348
- // stage as kind=pi-launch-failed stage=pi mutationOutcome=none, with
349
- // zero pi launch and zero submission.
350
- const negativeControl = caseEntry.kind === "relay-unavailable" ? unlaunchablePi() : null;
351
- const request = { captureArgumentTokens: caseEntry.captureArgumentTokens, submission: caseEntry.submission, gentleAiExecutable: gentleAiPath, piExecutable: negativeControl === null ? piPath : negativeControl.executable, ...(options.signal === undefined ? {} : { signal: options.signal }) };
395
+ // `relay-unavailable` is a NEGATIVE CONTROL: it must never complete a
396
+ // reviewer and never submit. The maintainer-declared `kind` is not
397
+ // evidence that the declared binary is actually incapable, so the arm
398
+ // gate cannot key off the declaration alone. Pointed at a mis-declared
399
+ // CAPABLE binary, a declaration-only gate materializes, runs a REAL
400
+ // reviewer completion, and executes a REAL `capture-result --input=...`
401
+ // submission, and only then reports `fail` — the mutation already
402
+ // happened. So give the negative control a reviewer registry that can
403
+ // never resolve: an incapable binary still classifies relay-unavailable
404
+ // at materialize (the control stays genuine, since it never reached the
405
+ // reviewer stage anyway), while a capable one fails closed at the
406
+ // reviewer stage as kind=reviewer-model-not-found stage=pi
407
+ // mutationOutcome=none, with zero completion and zero submission
408
+ // (gentle-pi#311 P4, superseding the pre-in-process "unlaunchable pi").
352
409
  try {
410
+ // A malformed `reviewerSelection` (caught here rather than at
411
+ // descriptor-validation time, since it is only ever exercised by an
412
+ // armed positive-lens case) must fail this case the same way a real
413
+ // relay error would, never crash the whole matrix.
414
+ const reviewerRegistry = caseEntry.kind === "relay-unavailable" ? unresolvableReviewerRegistry() : armedFauxReviewerRegistry(descriptor.reviewerSelection);
415
+ const request = {
416
+ captureArgumentTokens: caseEntry.captureArgumentTokens,
417
+ submission: caseEntry.submission,
418
+ gentleAiExecutable: gentleAiPath,
419
+ reviewerRegistry,
420
+ selection: descriptor.reviewerSelection,
421
+ routingKey: "review-relay-matrix",
422
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
423
+ };
353
424
  const result = await relay(request);
354
425
  if (caseEntry.kind === "relay-unavailable") {
355
426
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: "expected relay-unavailable (runtime without --materialize) but the relay succeeded; the descriptor's binary is unexpectedly capable" });
@@ -357,7 +428,9 @@ export async function runMatrix(descriptor, options = {}) {
357
428
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submissionByteLength: result.submission.length });
358
429
  }
359
430
  } catch (error) {
360
- if (error instanceof ReviewHostRelayError) {
431
+ if (error instanceof DescriptorValidationError) {
432
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: error.message });
433
+ } else if (error instanceof ReviewHostRelayError) {
361
434
  if (caseEntry.kind === "relay-unavailable") {
362
435
  if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE && error.stage === "materialize" && error.mutationOutcome === "none") {
363
436
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE });
@@ -372,8 +445,6 @@ export async function runMatrix(descriptor, options = {}) {
372
445
  } else {
373
446
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-relay error: ${error instanceof Error ? error.message : String(error)}` });
374
447
  }
375
- } finally {
376
- if (negativeControl !== null) rmSync(negativeControl.directory, { recursive: true, force: true });
377
448
  }
378
449
  }
379
450
  return verdicts;
@@ -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": "9868cd52ae8f15cc2e6a6757014d5b12ff0904e06725d1a4cf29e9bcc3a7bae2",
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.2.1 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.2.1 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", () => {