gentle-pi 2.4.0 → 2.5.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 (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -0,0 +1,741 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join, relative, resolve } from "node:path";
5
+ import test, { after, mock } from "node:test";
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
8
+ import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, legacySubagentsInstalled, type AgentsDeps } from "../extensions/gentle-agents.ts";
9
+ import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
10
+ import { emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
11
+ import { NativePointerScope } from "../lib/native-pointer-region.ts";
12
+ import { stripAnsi } from "../lib/terminal-theme.ts";
13
+ import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
14
+
15
+ // Gentle Agents extension: the subagent_* tools drive isolated pi children,
16
+ // the card above the editor follows the store, and dialogs reach the host UI.
17
+
18
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
19
+ interface Registered {
20
+ name: string;
21
+ execute(id: string, params: unknown, signal: undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ text: string }>; details: Record<string, unknown> }>;
22
+ renderCall(args: unknown, theme: unknown): { render(width: number): string[] };
23
+ }
24
+
25
+ const plainTheme = { fg: (_color: string, text: string) => text };
26
+ const fakeTui = { requestRender() {} };
27
+
28
+ type Overlay = {
29
+ render(width: number): string[];
30
+ handleInput(data: string): void;
31
+ handleMouse?(event: TuiMouseEvent): unknown;
32
+ };
33
+
34
+ function mouse(
35
+ type: TuiMouseEvent["type"],
36
+ button: TuiMouseEvent["button"],
37
+ x: number,
38
+ y: number,
39
+ width: number,
40
+ height: number,
41
+ ): TuiMouseEvent {
42
+ return { type, button, x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
43
+ }
44
+ const root = mkdtempSync(join(tmpdir(), "gentle-agents-ext-"));
45
+ after(() => rmSync(root, { recursive: true, force: true }));
46
+ const home = join(root, "home");
47
+ const cwd = join(root, "project");
48
+ mkdirSync(join(home, ".pi", "agent", "agents"), { recursive: true });
49
+ mkdirSync(cwd, { recursive: true });
50
+ writeFileSync(join(home, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\nmodel: openai-codex/gpt-5.6-terra\nthinking: high\ntools: [read, grep]\n---\nYou map things.");
51
+ writeFileSync(join(home, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2, model_profiles: { explore: { effort: "low" } } }));
52
+
53
+ function fakePi() {
54
+ const handlers = new Map<string, Handler[]>();
55
+ const tools = new Map<string, Registered>();
56
+ const shortcuts = new Map<string, { description: string; handler(ctx: ExtensionContext): Promise<void> }>();
57
+ const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
58
+ const sent: Array<{ message: Record<string, unknown>; options: Record<string, unknown> }> = [];
59
+ const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
60
+ const pi = {
61
+ sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => sent.push({ message, options }),
62
+ registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer),
63
+ on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
64
+ registerTool: (tool: Registered) => tools.set(tool.name, tool),
65
+ registerShortcut: (key: string, registration: { description: string; handler(ctx: ExtensionContext): Promise<void> }) => shortcuts.set(key, registration),
66
+ registerCommand: (name: string, registration: { handler(args: string, ctx: ExtensionContext): Promise<void> }) => commands.set(name, registration),
67
+ } as unknown as ExtensionAPI;
68
+ const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
69
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
70
+ };
71
+ return { pi, tools, shortcuts, commands, fire, sent, renderers };
72
+ }
73
+
74
+ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (title: string, message: string) => Promise<boolean> = async () => true, inputResult: (title: string, placeholder: string | undefined) => Promise<string | undefined> = async () => undefined) {
75
+ const widgets = new Map<string, (tui: unknown, theme: unknown) => { render(width: number): string[] }>();
76
+ const dialogs: string[] = [];
77
+ const overlays: Overlay[] = [];
78
+ const customCompletions: unknown[] = [];
79
+ const ctx = {
80
+ hasUI: true,
81
+ sessionManager: { getSessionId: () => "s1", getCwd: () => cwd },
82
+ ui: {
83
+ notify: (message: string) => dialogs.push(`notify:${message}`),
84
+ custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay) =>
85
+ new Promise((resolve) => {
86
+ const done = (value: unknown) => {
87
+ customCompletions.push(value);
88
+ resolve(value);
89
+ };
90
+ const component = factory({ terminal: { rows: 30 }, requestRender() {} }, plainTheme, {}, done);
91
+ overlays.push(component);
92
+ }),
93
+ setWidget(key: string, content: ((tui: unknown, theme: unknown) => { render(width: number): string[] }) | undefined) {
94
+ if (content === undefined) widgets.delete(key);
95
+ else widgets.set(key, content);
96
+ },
97
+ select: async (title: string, options: string[]) => {
98
+ dialogs.push(`select:${title}:${options.join("|")}`);
99
+ return options[0];
100
+ },
101
+ confirm: async (title: string, message: string) => {
102
+ dialogs.push(`confirm:${title}:${message}`);
103
+ return confirmResult(title, message);
104
+ },
105
+ input: async (title: string, placeholder: string | undefined) => {
106
+ dialogs.push(`input:${title}`);
107
+ return inputResult(title, placeholder);
108
+ },
109
+ editor: async () => "edited",
110
+ },
111
+ } as unknown as ExtensionContext;
112
+ const widget = () => {
113
+ const factory = widgets.get("gentle-agents");
114
+ return factory ? factory(tui, plainTheme).render(72).map(stripAnsi) : undefined;
115
+ };
116
+ return { ctx, widget, dialogs, overlays, customCompletions };
117
+ }
118
+
119
+ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: string[][] } {
120
+ const children: FakeChild[] = [];
121
+ const spawned: string[][] = [];
122
+ let clock = 1000;
123
+ return {
124
+ children,
125
+ spawned,
126
+ deps: {
127
+ spawn: (command, args) => {
128
+ spawned.push([command, ...args]);
129
+ const child = fakeChild();
130
+ children.push(child);
131
+ return child.child;
132
+ },
133
+ now: () => (clock += 500),
134
+ schedule: () => () => {},
135
+ pi: { command: "pi", args: [] },
136
+ home,
137
+ env: { PATH: "/bin" },
138
+ },
139
+ };
140
+ }
141
+
142
+ const tick = () => new Promise((resolve) => setImmediate(resolve));
143
+
144
+ test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
145
+ assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
146
+ sessions: "/profiles/pi-principal/agent/gentle-agents/sessions",
147
+ transcripts: "/profiles/pi-principal/agent/gentle-agents/transcripts",
148
+ });
149
+ assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-lab/agent"), {
150
+ sessions: "/profiles/pi-lab/agent/gentle-agents/sessions",
151
+ transcripts: "/profiles/pi-lab/agent/gentle-agents/transcripts",
152
+ });
153
+ assert.deepEqual(agentRuntimePaths("/home/x"), {
154
+ sessions: "/home/x/.pi/agent/gentle-agents/sessions",
155
+ transcripts: "/home/x/.pi/agent/gentle-agents/transcripts",
156
+ });
157
+ });
158
+
159
+ test("extension resolves each profile environment at setup time without changing HOME", async () => {
160
+ const principalHome = join(root, "pi-principal", "agent");
161
+ const labHome = join(root, "pi-lab", "agent");
162
+ for (const [agentHome, name] of [[principalHome, "principal"], [labHome, "lab"]] as const) {
163
+ mkdirSync(join(agentHome, "agents"), { recursive: true });
164
+ writeFileSync(join(agentHome, "agents", `${name}.md`), `---\ndescription: ${name}\n---\n${name}`);
165
+ }
166
+ const withoutExplicitHome = () => {
167
+ const harness = deps();
168
+ const { home: _home, env: _env, ...overrides } = harness.deps;
169
+ return overrides;
170
+ };
171
+ const principal = fakePi();
172
+ gentleAgents(principal.pi, { GENTLE_PI_AGENT_HOME: principalHome, PI_CODING_AGENT_DIR: labHome }, withoutExplicitHome());
173
+ const principalContext = fakeContext();
174
+ await principal.fire("session_start", principalContext.ctx);
175
+ assert.match((await principal.tools.get("subagent_list_agents")!.execute("p1", {}, undefined, undefined, principalContext.ctx)).content[0].text, /principal/);
176
+ const lab = fakePi();
177
+ gentleAgents(lab.pi, { PI_CODING_AGENT_DIR: labHome }, withoutExplicitHome());
178
+ const labContext = fakeContext();
179
+ await lab.fire("session_start", labContext.ctx);
180
+ assert.match((await lab.tools.get("subagent_list_agents")!.execute("l1", {}, undefined, undefined, labContext.ctx)).content[0].text, /lab/);
181
+ });
182
+
183
+ for (const [key, tilde] of [["GENTLE_PI_AGENT_HOME", false], ["PI_CODING_AGENT_DIR", false], ["GENTLE_PI_AGENT_HOME", true], ["PI_CODING_AGENT_DIR", true]] as const) {
184
+ test(`${key} ${tilde ? "tilde" : "relative"} profile shares an absolute parent and child session root`, async () => {
185
+ const agentHome = join(root, `${key}-${tilde}`, "agent");
186
+ mkdirSync(join(agentHome, "agents"), { recursive: true });
187
+ writeFileSync(join(agentHome, "agents", "relative.md"), "---\ndescription: relative profile\n---\nMap things.");
188
+ writeFileSync(join(agentHome, "subagents.json"), JSON.stringify({ default_model: "openai/profile-model" }));
189
+ const env = { [key]: tilde ? `~/${relative(homedir(), agentHome)}` : relative(process.cwd(), agentHome) };
190
+ const harness = deps();
191
+ const { home: _home, env: _env, ...overrides } = harness.deps;
192
+ const { pi, tools, fire } = fakePi();
193
+ gentleAgents(pi, env, overrides);
194
+ const { ctx } = fakeContext();
195
+ assert.notEqual(ctx.sessionManager.getCwd(), process.cwd());
196
+ await fire("session_start", ctx);
197
+ await tools.get("subagent_run")!.execute("relative", { agent: "relative", task: "Map", mode: "background" }, undefined, undefined, ctx);
198
+ await tick();
199
+ try {
200
+ const args = harness.spawned[0];
201
+ const sessionDir = args[args.indexOf("--session-dir") + 1];
202
+ const expected = join(agentHome, "gentle-agents", "sessions");
203
+ assert.equal(sessionDir, expected);
204
+ assert.equal(resolve(ctx.sessionManager.getCwd(), sessionDir), expected);
205
+ assert.equal(existsSync(expected), true, "parent created the exact child session root");
206
+ assert.match(args[args.indexOf("--model") + 1], /profile-model/);
207
+ } finally {
208
+ await fire("session_shutdown", ctx);
209
+ await tick();
210
+ }
211
+ });
212
+ }
213
+
214
+ test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
215
+ assert.equal(agentsEnabled({}), true);
216
+ assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
217
+ assert.equal(agentsEnabled({ GENTLE_PI_AGENTS_CHILD: "1" }), false);
218
+ assert.equal(agentsCollapseKey({}), "ctrl+shift+a");
219
+ assert.equal(agentsCollapseKey({ GENTLE_PI_AGENTS_KEY: "off" }), undefined);
220
+ assert.equal(agentsViewKey({}), "alt+a");
221
+ assert.equal(agentsViewKey({ GENTLE_PI_AGENTS_VIEW_KEY: "off" }), undefined);
222
+ assert.equal(agentsStopKey({}), "alt+s");
223
+ assert.equal(agentsStopKey({ GENTLE_PI_AGENTS_STOP_KEY: "" }), undefined);
224
+ assert.equal(agentsStopKey({ GENTLE_PI_AGENTS_STOP_KEY: "off" }), undefined);
225
+ const off = fakePi();
226
+ gentleAgents(off.pi, { GENTLE_PI_AGENTS: "0" });
227
+ assert.equal(off.tools.size, 0);
228
+ });
229
+
230
+ test("while pi-subagents-j0k3r is still installed the tools stay unregistered and the user is told how to switch", async () => {
231
+ const legacyHome = join(root, "legacy-home");
232
+ mkdirSync(join(legacyHome, ".pi", "agent"), { recursive: true });
233
+ writeFileSync(join(legacyHome, ".pi", "agent", "settings.json"), JSON.stringify({ packages: ["npm:pi-subagents-j0k3r", "../../work/gentle-pi"] }));
234
+ assert.equal(legacySubagentsInstalled(legacyHome), true);
235
+ assert.equal(legacySubagentsInstalled(home), false);
236
+ assert.equal(legacySubagentsInstalled(join(root, "missing")), false);
237
+ const { pi, tools, fire } = fakePi();
238
+ gentleAgents(pi, {}, { ...deps().deps, home: legacyHome });
239
+ assert.equal(tools.size, 0);
240
+ const notices: string[] = [];
241
+ const ctx = { hasUI: true, ui: { notify: (message: string, level: string) => notices.push(`${level}:${message}`) } } as unknown as ExtensionContext;
242
+ await fire("session_start", ctx);
243
+ assert.match(notices[0] ?? "", /^warning:❀ Gentle Agents is waiting: remove the old package first with "pi remove npm:pi-subagents-j0k3r"/);
244
+ });
245
+
246
+ test("subagent_list_agents and subagent_run in task mode launch a child with the resolved profile and return its answer", async () => {
247
+ const { pi, tools, fire } = fakePi();
248
+ const harness = deps();
249
+ gentleAgents(pi, {}, harness.deps);
250
+ const { ctx, widget } = fakeContext();
251
+ await fire("session_start", ctx);
252
+ assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
253
+ const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
254
+ assert.match(listed.content[0].text, /- explore \(global\): maps things/);
255
+
256
+ const running = tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/ and report every module.", label: "map lib modules", context: "Focus on agents-*.ts" }, undefined, undefined, ctx);
257
+ await tick();
258
+ const [args] = harness.spawned;
259
+ assert.equal(args[args.indexOf("--model") + 1], "openai-codex/gpt-5.6-terra:low", "the profile effort overrides the definition");
260
+ assert.equal(args[args.indexOf("--tools") + 1], "read,grep");
261
+ await tick();
262
+ assert.match(String(harness.children[0].written[1].message), /Map lib\/ and report every module\.\n\n## Context\nFocus on agents-\*\.ts/);
263
+ assert.match(widget()![0], /^╭─ ❀ Agents · 1 active ─+ \d+s ╮$/);
264
+ assert.match(widget()![1], /^│ ◐ explore map lib modules +gpt-5\.6-terra · \d+s │$/);
265
+ harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
266
+ harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
267
+ await tick();
268
+ assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · 12k · \$0\.09 · \d+s │$/);
269
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
270
+ harness.children[0].emit({ type: "agent_settled" });
271
+ const result = await running;
272
+ assert.equal(result.content[0].text, "lib has three agent files.");
273
+ assert.equal((result.details.gentleAgents as { status: string }).status, "completed");
274
+ assert.match(widget()![1], /✓ explore map lib modules/);
275
+ const orphan = tools.get("subagent_run")!.execute("c9", { agent: "explore", task: "Orphan", mode: "background" }, undefined, undefined, ctx);
276
+ await orphan;
277
+ await tick();
278
+ await fire("session_shutdown", ctx);
279
+ await tick();
280
+ assert.deepEqual(harness.children[1].killed, ["SIGTERM"], "closing pi stops the running children");
281
+ assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
282
+ });
283
+
284
+ test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
285
+ const { pi, tools, fire, sent, renderers } = fakePi();
286
+ const harness = deps();
287
+ gentleAgents(pi, {}, harness.deps);
288
+ const { ctx } = fakeContext();
289
+ await fire("session_start", ctx);
290
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
291
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
292
+ assert.match(started.content[0].text, new RegExp(`background as task ${id}`));
293
+ await tick();
294
+ assert.match((await tools.get("subagent_status")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /running · background/);
295
+ assert.match((await tools.get("subagent_result")!.execute("c3", { task_id: id }, undefined, undefined, ctx)).content[0].text, /still running/);
296
+ assert.match((await tools.get("subagent_send_message")!.execute("c4", { task_id: id, message: "Skip tests" }, undefined, undefined, ctx)).content[0].text, /queued/);
297
+ await tick();
298
+ assert.equal(harness.children[0].written.at(-1)?.message, "Skip tests");
299
+ assert.match((await tools.get("subagent_continue")!.execute("c5", { task_id: id, prompt: "more" }, undefined, undefined, ctx)).content[0].text, /cannot be continued yet/);
300
+ assert.match((await tools.get("subagent_list_tasks")!.execute("c6", {}, undefined, undefined, ctx)).content[0].text, new RegExp(`^${id} · explore · running`));
301
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "All done." }] }] });
302
+ harness.children[0].emit({ type: "agent_settled" });
303
+ await tick();
304
+ assert.equal((await tools.get("subagent_result")!.execute("c7", { task_id: id }, undefined, undefined, ctx)).content[0].text, "All done.");
305
+ assert.equal(sent.length, 1, "a background result is delivered to the model once");
306
+ assert.equal(sent[0].message.customType, "gentle-agents.result");
307
+ assert.deepEqual(sent[0].options, { deliverAs: "followUp", triggerTurn: true });
308
+ assert.match(String(sent[0].message.content), new RegExp(`^Subagent explore \\(task ${id}, "Long job"\\) finished\\.\n\nAll done\\.$`));
309
+ const card = renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(70).map(stripAnsi);
310
+ assert.match(card[0], /^╭─ ❀ Agent result · explore ─+ collapse ╮$/);
311
+ assert.match(card[1], /Subagent explore/);
312
+ assert.match(card[card.length - 2], /All done\./);
313
+ const resumed = tools.get("subagent_continue")!.execute("c8", { task_id: id, prompt: "Now summarize", mode: "task" }, undefined, undefined, ctx);
314
+ await tick();
315
+ const args = harness.spawned[1];
316
+ assert.equal(args[args.indexOf("--session") + 1], "/sessions/child.jsonl");
317
+ await tick();
318
+ harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Summary." }] }] });
319
+ harness.children[1].emit({ type: "agent_settled" });
320
+ assert.equal((await resumed).content[0].text, "Summary.");
321
+ assert.match((await tools.get("subagent_cancel")!.execute("c9", { task_id: id }, undefined, undefined, ctx)).content[0].text, /not running/);
322
+ assert.match((await tools.get("subagent_status")!.execute("c10", { task_id: "nope" }, undefined, undefined, ctx)).content[0].text, /Error: no task nope/);
323
+ assert.match((await tools.get("subagent_run")!.execute("c11", { agent: "ghost", task: "x" }, undefined, undefined, ctx)).content[0].text, /no subagent named "ghost"\. Known: explore/);
324
+ });
325
+
326
+ test("once the last task is done the card asks for one frame when its finished row expires, so an idle terminal clears it", async () => {
327
+ const { pi, tools, fire } = fakePi();
328
+ const harness = deps();
329
+ let clock = 1000;
330
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
331
+ harness.deps.now = () => clock;
332
+ harness.deps.schedule = (fn, ms) => {
333
+ const timer = { fn, ms, cancelled: false };
334
+ timers.push(timer);
335
+ return () => {
336
+ timer.cancelled = true;
337
+ };
338
+ };
339
+ gentleAgents(pi, {}, harness.deps);
340
+ let frames = 0;
341
+ const { ctx, widget } = fakeContext({ requestRender: () => (frames += 1) });
342
+ await fire("session_start", ctx);
343
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Short job", mode: "background" }, undefined, undefined, ctx);
344
+ await tick();
345
+ widget();
346
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done." }] }] });
347
+ harness.children[0].emit({ type: "agent_settled" });
348
+ await tick();
349
+ assert.match(widget()![1], /✓ explore Short job/);
350
+ const expiry = timers.filter((timer) => !timer.cancelled && timer.ms === 60_000);
351
+ assert.equal(expiry.length, 1, "exactly one timer waits for the finished row to leave the card");
352
+ clock += 60_000;
353
+ const before = frames;
354
+ expiry[0].fn();
355
+ assert.equal(frames, before + 1, "the expiry asks the terminal for a frame");
356
+ assert.deepEqual(widget(), [], "the card is gone");
357
+ assert.equal(timers.filter((timer) => !timer.cancelled && timer.ms === 60_000).length, 0, "nothing is rescheduled once the card is empty");
358
+ });
359
+
360
+ test("completionText names the outcome before the answer", () => {
361
+ const base = { id: "t1", agent: "explore", mode: "background", prompt: "p", label: "map lib", cwd: "/r", parentSessionId: "s", status: "failed" as const, createdAt: 1, startedAt: 1, endedAt: 2, model: "m", thinking: undefined, sessionPath: null, error: "pi exited with code 1", result: null, lastStep: "x", lastActivityAt: 2, turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
362
+ assert.equal(completionText(base), 'Subagent explore (task t1, "map lib") failed.\n\nSubagent explore failed: pi exited with code 1');
363
+ assert.equal(completionText({ ...base, status: "timed_out", error: "stalled for 4 min" }), 'Subagent explore (task t1, "map lib") timed out.\n\nSubagent explore timed_out: stalled for 4 min');
364
+ });
365
+
366
+ test("a task-mode child's dialog reaches the host UI and the answer goes back to the child", async () => {
367
+ const { pi, tools, fire } = fakePi();
368
+ const harness = deps();
369
+ gentleAgents(pi, {}, harness.deps);
370
+ const { ctx, dialogs, widget } = fakeContext();
371
+ await fire("session_start", ctx);
372
+ const running = tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ask me" }, undefined, undefined, ctx);
373
+ await tick();
374
+ harness.children[0].emit({ type: "extension_ui_request", id: "u1", method: "select", title: "Which file?", options: ["a.ts", "b.ts"] });
375
+ await tick();
376
+ await tick();
377
+ assert.deepEqual(dialogs, ["select:❀ Which file?:a.ts|b.ts"]);
378
+ assert.deepEqual(harness.children[0].written.at(-1), { type: "extension_ui_response", id: "u1", value: "a.ts" });
379
+ assert.match(widget()![1], /◐ explore Ask me/);
380
+ harness.children[0].emit({ type: "agent_end", messages: [] });
381
+ harness.children[0].emit({ type: "agent_settled" });
382
+ await running;
383
+ assert.deepEqual(await answerThroughUi(ctx.ui, { id: "u2", method: "confirm", title: "Sure?" }, { message: "really" }), { confirmed: true });
384
+ assert.deepEqual(await answerThroughUi(ctx.ui, { id: "u3", method: "input", title: "Name" }, {}), { cancelled: true });
385
+ assert.deepEqual(await answerThroughUi(ctx.ui, { id: "u4", method: "editor", title: "Edit" }, {}), { value: "edited" });
386
+ assert.deepEqual(await answerThroughUi(undefined, { id: "u5", method: "select", title: "x" }, {}), { cancelled: true });
387
+ });
388
+
389
+ test("AgentsView production composition observes each pointer event once and accepts only left clicks", async () => {
390
+ const { pi, tools, fire, commands } = fakePi();
391
+ const harness = deps();
392
+ gentleAgents(pi, {}, harness.deps);
393
+ const { ctx, overlays } = fakeContext();
394
+ await fire("session_start", ctx);
395
+
396
+ await tools.get("subagent_run")!.execute("b", { agent: "explore", task: "b", mode: "background" }, undefined, undefined, ctx);
397
+ await tick();
398
+ await tools.get("subagent_run")!.execute("a", { agent: "explore", task: "a", mode: "background" }, undefined, undefined, ctx);
399
+ await tick();
400
+
401
+ const originalCreateMouseObserver = NativePointerScope.prototype.createMouseObserver;
402
+ let beforeCalls = 0;
403
+ let afterCalls = 0;
404
+ const spy = mock.method(NativePointerScope.prototype, "createMouseObserver", function (
405
+ this: NativePointerScope,
406
+ requestRender?: () => void,
407
+ ) {
408
+ const observer = originalCreateMouseObserver.call(this, requestRender);
409
+ return {
410
+ beforeMouse(event: TuiMouseEvent) {
411
+ beforeCalls += 1;
412
+ observer.beforeMouse(event);
413
+ },
414
+ afterMouse(event: TuiMouseEvent) {
415
+ afterCalls += 1;
416
+ observer.afterMouse(event);
417
+ },
418
+ };
419
+ });
420
+ try {
421
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
422
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
423
+ const overlay = overlays[0];
424
+ assert.ok(overlay, "the production extension mounted its fullscreen interaction");
425
+ const lines = overlay.render(80);
426
+ const dispatch = (event: TuiMouseEvent) => {
427
+ const before = beforeCalls;
428
+ const after = afterCalls;
429
+ const result = overlay.handleMouse?.(event);
430
+ assert.deepEqual([beforeCalls - before, afterCalls - after], [1, 1], "the root owns one observer lifecycle per event");
431
+ return result;
432
+ };
433
+
434
+ assert.match(stripAnsi(overlay.render(80)[1] ?? ""), /▸/, "the first task starts selected");
435
+ assert.equal(dispatch(mouse("click", "right", 4, 2, 80, lines.length)), undefined, "right click is inert");
436
+ assert.match(stripAnsi(overlay.render(80)[1] ?? ""), /▸/, "right click cannot select another task");
437
+ assert.equal(dispatch(mouse("press", "left", 4, 2, 80, lines.length)), undefined, "press is inert");
438
+ assert.equal(dispatch(mouse("click", "middle", 4, 2, 80, lines.length)), undefined, "middle click is inert");
439
+ const leftClick = dispatch(mouse("click", "left", 4, 2, 80, lines.length));
440
+ assert.equal((leftClick as { handled?: boolean } | undefined)?.handled, true, "left click selects the task");
441
+ assert.match(stripAnsi(overlay.render(80)[2] ?? ""), /▸/, "left click selects the second task");
442
+ overlay.handleInput("\x1b");
443
+ await opened;
444
+ } finally {
445
+ spy.mock.restore();
446
+ }
447
+ });
448
+
449
+ test("AgentsView production footer uses rendered bounds and invalidates them before the next frame", async () => {
450
+ writeFileSync(join(home, ".pi", "agent", "agents", "寿司.md"), "---\ndescription: unicode footer target\nmodel: openai-codex/gpt-5.6-terra\n---\nFooter target.");
451
+ const { pi, tools, fire, commands } = fakePi();
452
+ const harness = deps();
453
+ gentleAgents(pi, {}, harness.deps);
454
+ const { ctx, overlays, customCompletions } = fakeContext();
455
+ (ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } }).sessionManager = { getSessionId: () => "footer-session", getCwd: () => cwd };
456
+ await fire("session_start", ctx);
457
+ await tools.get("subagent_run")!.execute("c1", { agent: "寿司", task: "Footer target", mode: "background" }, undefined, undefined, ctx);
458
+ await tick();
459
+
460
+ let store: TaskStore | undefined;
461
+ const subscribeSummary = TaskStore.prototype.subscribeSummary;
462
+ const captureStore = mock.method(TaskStore.prototype, "subscribeSummary", function (this: TaskStore, ...args: Parameters<TaskStore["subscribeSummary"]>) {
463
+ store ??= this;
464
+ return subscribeSummary.apply(this, args);
465
+ });
466
+ try {
467
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
468
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
469
+ const overlay = overlays[0];
470
+ assert.ok(overlay, "the production extension mounted its fullscreen interaction");
471
+ assert.ok(store, "the overlay subscribed to the production task store");
472
+ const selected = store.list().find((entry) => entry.parentSessionId === "footer-session");
473
+ assert.ok(selected?.sessionPath, "the selected unicode task initially has an openable session");
474
+ for (let index = 0; index < 12; index += 1) store.apply(selected.id, { type: TASK_EVENT.TEXT, text: `line ${index}\n` }, 2000 + index);
475
+
476
+ const buttons = (lines: string[], label: string) => {
477
+ const footer = lines.at(-2) ?? "";
478
+ const start = footer.indexOf(label);
479
+ assert.ok(start > 0, `the roomy footer exposes ${label}`);
480
+ return { x: visibleWidth(footer.slice(0, start)), y: lines.length - 2 };
481
+ };
482
+ let lines = overlay.render(160).map(stripAnsi);
483
+ let follow = buttons(lines, "[ Follow ]");
484
+ let open = buttons(lines, "[ Open session ]");
485
+ assert.match(lines[1] ?? "", /寿司/, "a unicode task remains inside the body, not the header or footer");
486
+ assert.match(lines[follow.y] ?? "", /s Stop selected.*a all sessions/, "the existing stop and scope shortcuts remain beside the footer buttons");
487
+ assert.doesNotMatch(overlay.render(44).map(stripAnsi).at(-2) ?? "", /\[ Follow \]|\[ Open session \]/, "a narrow render hides controls rather than retaining roomy bounds");
488
+ lines = overlay.render(160).map(stripAnsi);
489
+ follow = buttons(lines, "[ Follow ]");
490
+ open = buttons(lines, "[ Open session ]");
491
+ assert.equal(overlay.handleMouse?.(mouse("click", "left", follow.x, 0, 160, lines.length)), undefined, "header coordinates never route to a footer button");
492
+ assert.equal(overlay.handleMouse?.(mouse("click", "left", follow.x, follow.y - 1, 160, lines.length)), undefined, "body coordinates never route to a footer button");
493
+ assert.equal(overlay.handleMouse?.(mouse("press", "left", follow.x, follow.y, 160, lines.length)), undefined, "press is inert");
494
+ assert.equal(overlay.handleMouse?.(mouse("click", "right", follow.x, follow.y, 160, lines.length)), undefined, "right click is inert");
495
+ assert.equal(overlay.handleMouse?.(mouse("click", "middle", follow.x, follow.y, 160, lines.length)), undefined, "middle click is inert");
496
+ assert.equal((overlay.handleMouse?.(mouse("move", "none", follow.x, follow.y, 160, lines.length)) as { handled?: boolean } | undefined)?.handled, true, "hover does not activate Follow");
497
+
498
+ overlay.handleInput("\x1b[5~");
499
+ assert.equal((overlay.handleMouse?.(mouse("click", "left", follow.x, follow.y, 160, lines.length)) as { handled?: boolean } | undefined)?.handled, true, "Follow restores tail tracking");
500
+ assert.equal((overlay.handleMouse?.(mouse("click", "left", follow.x, follow.y, 160, lines.length)) as { handled?: boolean } | undefined)?.handled, true, "repeated Follow remains enabled instead of toggling off");
501
+ store.apply(selected.id, { type: TASK_EVENT.TEXT, text: "line 12\n" }, 2012);
502
+ lines = overlay.render(160).map(stripAnsi);
503
+ assert.ok(lines.some((line) => /line 12/.test(line)), "Follow keeps the stream at its tail after manual scrolling");
504
+ follow = buttons(lines, "[ Follow ]");
505
+ open = buttons(lines, "[ Open session ]");
506
+ assert.equal((overlay.handleMouse?.(mouse("click", "left", open.x, open.y, 160, lines.length)) as { handled?: boolean } | undefined)?.handled, true, "Open delegates exactly one eligible click to the existing callback");
507
+ assert.equal(customCompletions.length, 1, "Open completes the actual ui.custom callback exactly once");
508
+ const openedTask = customCompletions[0] as TaskRecord | undefined;
509
+ assert.equal(openedTask?.id, selected.id, "Open completes ui.custom with the selected task before Escape");
510
+ assert.equal(openedTask?.sessionPath, selected.sessionPath, "Open preserves the selected task's openable session in the ui.custom result");
511
+
512
+ store.update(selected.id, { sessionPath: null });
513
+ assert.equal(overlay.handleMouse?.(mouse("click", "left", follow.x, follow.y, 160, lines.length)), undefined, "a selected task update makes old footer coordinates inert until render");
514
+ lines = overlay.render(160).map(stripAnsi);
515
+ follow = buttons(lines, "[ Follow ]");
516
+ overlay.handleInput("a");
517
+ assert.equal(overlay.handleMouse?.(mouse("click", "left", follow.x, follow.y, 160, lines.length)), undefined, "a scope change makes old footer coordinates inert until render");
518
+ overlay.handleInput("\x1b");
519
+ await opened;
520
+ } finally {
521
+ captureStore.mock.restore();
522
+ }
523
+ });
524
+
525
+ test("finished tasks are written to history, come back through resolveTask, and the overlay lists them", async () => {
526
+ const { pi, tools, fire, commands, shortcuts } = fakePi();
527
+ const harness = deps();
528
+ gentleAgents(pi, {}, harness.deps);
529
+ const { ctx, overlays } = fakeContext();
530
+ await fire("session_start", ctx);
531
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Persist me", mode: "background" }, undefined, undefined, ctx);
532
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
533
+ await tick();
534
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Kept." }] }] });
535
+ harness.children[0].emit({ type: "agent_settled" });
536
+ await tick();
537
+ const tasksDir = join(home, ".pi", "agent", "gentle-agents", "tasks");
538
+ let stored = await loadHistory(tasksDir);
539
+ for (let attempt = 0; attempt < 40 && !stored.some((entry) => entry.task.id === id); attempt += 1) {
540
+ await new Promise((resolve) => setTimeout(resolve, 50));
541
+ stored = await loadHistory(tasksDir);
542
+ }
543
+ assert.ok(stored.some((entry) => entry.task.id === id && entry.task.result === "Kept."), "the finished task is on disk");
544
+
545
+ const fresh = fakePi();
546
+ gentleAgents(fresh.pi, {}, deps().deps);
547
+ const again = fakeContext();
548
+ await fresh.fire("session_start", again.ctx);
549
+ assert.equal((await fresh.tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, again.ctx)).content[0].text, "Kept.");
550
+
551
+ assert.ok(commands.has("gentle:agents") && shortcuts.has("alt+a"));
552
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
553
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
554
+ const overlay = overlays[0];
555
+ assert.ok(overlay, "the overlay component was created");
556
+ assert.match(stripAnsi(overlay.render(80)[0]), /^╭─ ❀ Agents · this session · 0 active · \d+ finished/);
557
+ assert.ok(overlay.render(80).map(stripAnsi).some((line) => /✓ explore/.test(line)), "the finished task is listed");
558
+ overlay.handleInput("\x1b");
559
+ await opened;
560
+ });
561
+
562
+ test("the overlay confirms a running task once and reports when it finishes during confirmation", async () => {
563
+ const { pi, tools, fire, commands, sent } = fakePi();
564
+ const harness = deps();
565
+ const modalHome = join(root, "modal-home");
566
+ mkdirSync(join(modalHome, ".pi", "agent", "agents"), { recursive: true });
567
+ writeFileSync(join(modalHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\nmodel: openai-codex/gpt-5.6-terra\n---\nYou map things.");
568
+ writeFileSync(join(modalHome, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2 }));
569
+ harness.deps.home = modalHome;
570
+ let answerConfirmation: (confirmed: boolean) => void = () => {};
571
+ gentleAgents(pi, {}, harness.deps);
572
+ const { ctx, dialogs, overlays } = fakeContext(fakeTui, () => new Promise((resolve) => {
573
+ answerConfirmation = resolve;
574
+ }));
575
+ await fire("session_start", ctx);
576
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Race", mode: "background" }, undefined, undefined, ctx);
577
+ await tick();
578
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
579
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
580
+ const overlay = overlays[0];
581
+ assert.ok(overlay, "the overlay component was created");
582
+ overlay.handleInput("s");
583
+ overlay.handleInput("c");
584
+ await tick();
585
+ assert.deepEqual(dialogs, ["confirm:Stop explore?:Current work may be incomplete."], "s and its compatibility alias share one confirmation");
586
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Finished first." }] }] });
587
+ harness.children[0].emit({ type: "agent_settled" });
588
+ await tick();
589
+ answerConfirmation(true);
590
+ await tick();
591
+ assert.match(dialogs.at(-1) ?? "", /^notify:Task explore already finished\.$/);
592
+ assert.equal(sent.length, 1, "a normal completion during confirmation still reaches the parent");
593
+ overlay.handleInput("\x1b");
594
+ await opened;
595
+ });
596
+
597
+ test("the overlay stops a queued selection immediately without confirmation", async () => {
598
+ const { pi, tools, fire, commands } = fakePi();
599
+ const harness = deps();
600
+ const queueHome = join(root, "queue-home");
601
+ mkdirSync(join(queueHome, ".pi", "agent", "agents"), { recursive: true });
602
+ writeFileSync(join(queueHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\n---\nYou map things.");
603
+ writeFileSync(join(queueHome, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 1 }));
604
+ harness.deps.home = queueHome;
605
+ gentleAgents(pi, {}, harness.deps);
606
+ const { ctx, dialogs, overlays } = fakeContext();
607
+ await fire("session_start", ctx);
608
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "First", mode: "background" }, undefined, undefined, ctx);
609
+ await tick();
610
+ const queued = await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Queued", mode: "background" }, undefined, undefined, ctx);
611
+ const queuedId = (queued.details.gentleAgents as { taskId: string }).taskId;
612
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
613
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
614
+ overlays[0]!.handleInput("s");
615
+ await tick();
616
+ assert.deepEqual(dialogs, ["notify:Stopped explore."], "queued work stops without confirmation");
617
+ assert.match((await tools.get("subagent_status")!.execute("c3", { task_id: queuedId }, undefined, undefined, ctx)).content[0].text, /cancelled/);
618
+ overlays[0]!.handleInput("\x1b");
619
+ await opened;
620
+ });
621
+
622
+ test("the overlay explains that stopping a waiting subagent dismisses its question", async () => {
623
+ const { pi, tools, fire, commands } = fakePi();
624
+ const harness = deps();
625
+ const waitingHome = join(root, "waiting-home");
626
+ mkdirSync(join(waitingHome, ".pi", "agent", "agents"), { recursive: true });
627
+ writeFileSync(join(waitingHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\n---\nYou map things.");
628
+ harness.deps.home = waitingHome;
629
+ let answerInput: (value: string | undefined) => void = () => {};
630
+ gentleAgents(pi, {}, harness.deps);
631
+ const { ctx, dialogs, overlays } = fakeContext(fakeTui, async () => true, () => new Promise<string | undefined>((resolve) => {
632
+ answerInput = resolve;
633
+ }));
634
+ await fire("session_start", ctx);
635
+ const running = tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ask", mode: "task" }, undefined, undefined, ctx);
636
+ await tick();
637
+ harness.children[0].emit({ type: "extension_ui_request", id: "wait", method: "input", title: "Need input" });
638
+ await tick();
639
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
640
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
641
+ overlays[0]!.handleInput("s");
642
+ await tick();
643
+ assert.ok(dialogs.includes("confirm:Stop explore?:Its pending question will be dismissed."));
644
+ assert.match((await running).content[0].text, /cancelled/);
645
+ answerInput(undefined);
646
+ overlays[0]!.handleInput("\x1b");
647
+ await opened;
648
+ });
649
+
650
+ test("restored task history cannot execute stop", async () => {
651
+ const { pi, fire, commands } = fakePi();
652
+ const harness = deps();
653
+ const historyHome = join(root, "history-home");
654
+ const historical: TaskRecord = { id: "history-running", agent: "explore", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "old", status: TASK_STATUS.RUNNING, createdAt: 1, startedAt: 1, endedAt: null, model: "m", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "working", lastActivityAt: 1, turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
655
+ await saveTask(historyDir(historyHome), historical, emptyThread());
656
+ harness.deps.home = historyHome;
657
+ gentleAgents(pi, {}, harness.deps);
658
+ const { ctx, dialogs, overlays } = fakeContext();
659
+ await fire("session_start", ctx);
660
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
661
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
662
+ assert.doesNotMatch(stripAnsi(overlays[0]!.render(80).at(-2) ?? ""), /Stop selected/);
663
+ overlays[0]!.handleInput("s");
664
+ overlays[0]!.handleInput("c");
665
+ await tick();
666
+ assert.deepEqual(dialogs, []);
667
+ overlays[0]!.handleInput("\x1b");
668
+ await opened;
669
+ });
670
+
671
+ test("Alt+S confirms a snapshot of active subagents and suppresses their follow-up delivery", async () => {
672
+ const { pi, tools, fire, shortcuts, sent } = fakePi();
673
+ const harness = deps();
674
+ let answerConfirmation: (confirmed: boolean) => void = () => {};
675
+ gentleAgents(pi, {}, harness.deps);
676
+ const { ctx, dialogs } = fakeContext(fakeTui, () => new Promise<boolean>((resolve) => {
677
+ answerConfirmation = resolve;
678
+ }));
679
+ await fire("session_start", ctx);
680
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "First", mode: "background" }, undefined, undefined, ctx);
681
+ await tick();
682
+ const shortcut = shortcuts.get("alt+s");
683
+ assert.ok(shortcut, "Alt+S is registered by default");
684
+ assert.equal(shortcut.description, "Stop active subagent(s)");
685
+ const stopping = shortcut.handler(ctx);
686
+ await tick();
687
+ await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Second", mode: "background" }, undefined, undefined, ctx);
688
+ await tick();
689
+ answerConfirmation(true);
690
+ await stopping;
691
+ assert.deepEqual(dialogs, ["confirm:Stop 1 active subagent?:Only these 1 subagent will stop. Current work may be incomplete.", "notify:Stopped 1 subagent."]);
692
+ assert.equal(sent.length, 0, "intentional cancellation does not start a follow-up turn");
693
+ assert.match((await tools.get("subagent_list_tasks")!.execute("c3", {}, undefined, undefined, ctx)).content[0].text, /running/, "a subagent started during confirmation remains active");
694
+ const secondConfirmation = shortcut.handler(ctx);
695
+ await tick();
696
+ assert.match(dialogs.at(-1) ?? "", /^confirm:Stop 1 active subagent\?/);
697
+ answerConfirmation(false);
698
+ await secondConfirmation;
699
+ await fire("session_shutdown", ctx);
700
+ });
701
+
702
+ test("the card follows the active session: after /new the earlier session's tasks leave it, and come back on /resume", async () => {
703
+ const { pi, tools, commands, fire } = fakePi();
704
+ const harness = deps();
705
+ gentleAgents(pi, {}, harness.deps);
706
+ const { ctx, widget, overlays } = fakeContext();
707
+ await fire("session_start", ctx);
708
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
709
+ await tick();
710
+ assert.match(widget()![1], /◐ explore Long job/);
711
+ const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } };
712
+ sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd };
713
+ await fire("session_start", ctx, { type: "session_start", reason: "new" });
714
+ assert.deepEqual(widget(), [], "the new session starts with an empty card");
715
+ assert.match((await tools.get("subagent_list_tasks")!.execute("c2", {}, undefined, undefined, ctx)).content[0].text, /No subagent tasks in this session/);
716
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
717
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
718
+ const overlay = overlays[0]!;
719
+ assert.match(stripAnsi(overlay.render(80)[0]), /this session · 0 active · 0 finished/, "the overlay opens on the active session");
720
+ overlay.handleInput("a");
721
+ assert.ok(overlay.render(80).map(stripAnsi).some((line) => /◐ explore/.test(line)), "all sessions still reaches the running task");
722
+ overlay.handleInput("\x1b");
723
+ await opened;
724
+ sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd };
725
+ await fire("session_start", ctx, { type: "session_start", reason: "resume" });
726
+ assert.match(widget()![1], /◐ explore Long job/, "resuming the first session shows its task again");
727
+ });
728
+
729
+ test("the card caps its rows to the terminal height and says how many tasks are hidden", async () => {
730
+ const { pi, tools, fire } = fakePi();
731
+ const harness = deps();
732
+ gentleAgents(pi, {}, harness.deps);
733
+ const { ctx, widget } = fakeContext({ requestRender() {}, terminal: { rows: 20 } } as { requestRender(): void });
734
+ await fire("session_start", ctx);
735
+ for (let index = 0; index < 6; index += 1) await tools.get("subagent_run")!.execute(`c${index}`, { agent: "explore", task: `Job ${index}`, label: `job ${index}`, mode: "background" }, undefined, undefined, ctx);
736
+ await tick();
737
+ const card = widget()!;
738
+ assert.equal(card.length, 8, "a 20-row terminal gets five card rows (four tasks and the overflow line) inside the frame, then the spacer");
739
+ assert.match(card[0], /2 active · 4 queued/);
740
+ assert.match(card[5], /^│ … 2 more · alt\+a to view +│$/);
741
+ });