gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,2168 @@
1
+ import assert from "node:assert/strict";
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { createRequire, syncBuiltinESMExports } from "node:module";
5
+ import { join, relative, resolve } from "node:path";
6
+ import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
7
+ import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED } from "../lib/session-worktree-registry.ts";
8
+ import test, { after, mock } from "node:test";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
11
+ import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, legacySubagentsInstalled, type AgentsDeps } from "../extensions/gentle-agents.ts";
12
+ import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
13
+ import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
14
+ import { emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
15
+ import { NativePointerScope } from "../lib/native-pointer-region.ts";
16
+ import { PresenceCursor, PresencePublisher, listPresence, readActivity } from "../lib/orchestrator-presence.ts";
17
+ import { stripAnsi } from "../lib/terminal-theme.ts";
18
+ import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
19
+ import { AgentRunner } from "../lib/agents-runner.ts";
20
+ import type { NativeReviewCli } from "../lib/native-review-cli.ts";
21
+ import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
22
+ import { renderSddPreflightPrompt } from "../lib/sdd-preflight.ts";
23
+
24
+ // Gentle Agents extension: the subagent_* tools drive isolated pi children,
25
+ // the card above the editor follows the store, and dialogs reach the host UI.
26
+
27
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
28
+ interface Registered {
29
+ parameters: { properties: Record<string, unknown> };
30
+ renderShell?: string;
31
+ name: string;
32
+ execute(id: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ text: string }>; details: Record<string, unknown> }>;
33
+ renderCall(args: unknown, theme: unknown): { render(width: number): string[] };
34
+ }
35
+
36
+ const plainTheme = { fg: (_color: string, text: string) => text };
37
+ const fakeTui = { requestRender() {} };
38
+ const PARENT_CONFIRMED_SDD_CONTEXT = renderSddPreflightPrompt({
39
+ executionMode: "auto",
40
+ artifactStore: "openspec",
41
+ chainedPrStrategy: "ask-on-risk",
42
+ reviewBudgetLines: 400,
43
+ engramAvailable: false,
44
+ prompted: true,
45
+ });
46
+
47
+ type Overlay = {
48
+ render(width: number): string[];
49
+ handleInput(data: string): void;
50
+ handleMouse?(event: TuiMouseEvent): unknown;
51
+ };
52
+
53
+ function mouse(
54
+ type: TuiMouseEvent["type"],
55
+ button: TuiMouseEvent["button"],
56
+ x: number,
57
+ y: number,
58
+ width: number,
59
+ height: number,
60
+ ): TuiMouseEvent {
61
+ return { type, button, x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
62
+ }
63
+ const root = mkdtempSync(join(tmpdir(), "gentle-agents-ext-"));
64
+ after(() => rmSync(root, { recursive: true, force: true }));
65
+ const home = join(root, "home");
66
+ const cwd = join(root, "project");
67
+ const nonGitCwd = join(root, "non-git-project");
68
+ mkdirSync(join(home, ".pi", "agent", "agents"), { recursive: true });
69
+ mkdirSync(cwd, { recursive: true });
70
+ mkdirSync(nonGitCwd, { recursive: true });
71
+ 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.");
72
+ writeFileSync(join(home, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2, model_profiles: { explore: { effort: "low" } } }));
73
+
74
+ function fakePi() {
75
+ const handlers = new Map<string, Handler[]>();
76
+ const tools = new Map<string, Registered>();
77
+ const shortcuts = new Map<string, { description: string; handler(ctx: ExtensionContext): Promise<void> }>();
78
+ const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
79
+ const sent: Array<{ message: Record<string, unknown>; options: Record<string, unknown> }> = [];
80
+ const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
81
+ const entryRenderers = new Map<string, (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
82
+ const entries: Array<{ type: string; customType: string; data: unknown }> = [];
83
+ const events: Array<{ name: string; data: unknown }> = [];
84
+ const listeners = new Map<string, Set<(data: unknown) => void>>();
85
+ const pi = {
86
+ appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
87
+ events: {
88
+ emit: (name: string, data: unknown) => { events.push({ name, data }); for (const listener of listeners.get(name) ?? []) listener(data); },
89
+ on: (name: string, listener: (data: unknown) => void) => {
90
+ const set = listeners.get(name) ?? new Set(); listeners.set(name, set); set.add(listener);
91
+ return () => { set.delete(listener); };
92
+ },
93
+ },
94
+ sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => sent.push({ message, options }),
95
+ registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer),
96
+ registerEntryRenderer: (type: string, renderer: (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => entryRenderers.set(type, renderer),
97
+ on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
98
+ registerTool: (tool: Registered) => tools.set(tool.name, tool),
99
+ registerShortcut: (key: string, registration: { description: string; handler(ctx: ExtensionContext): Promise<void> }) => shortcuts.set(key, registration),
100
+ registerCommand: (name: string, registration: { handler(args: string, ctx: ExtensionContext): Promise<void> }) => commands.set(name, registration),
101
+ } as unknown as ExtensionAPI;
102
+ const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
103
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
104
+ };
105
+ return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners };
106
+ }
107
+
108
+ 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, overlayTui: { terminal: { rows: number }; requestRender(): void } = { terminal: { rows: 30 }, requestRender() {} }) {
109
+ const widgets = new Map<string, (tui: unknown, theme: unknown) => { render(width: number): string[] }>();
110
+ const dialogs: string[] = [];
111
+ const overlays: Overlay[] = [];
112
+ const customCompletions: unknown[] = [];
113
+ const customOptions: unknown[] = [];
114
+ const ctx = {
115
+ cwd,
116
+ hasUI: true,
117
+ mode: "tui",
118
+ sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [] },
119
+ ui: {
120
+ notify: (message: string) => dialogs.push(`notify:${message}`),
121
+ custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay, options: unknown) =>
122
+ new Promise((resolve) => {
123
+ customOptions.push(options);
124
+ const done = (value: unknown) => {
125
+ customCompletions.push(value);
126
+ resolve(value);
127
+ };
128
+ const component = factory(overlayTui, plainTheme, {}, done);
129
+ overlays.push(component);
130
+ }),
131
+ setWidget(key: string, content: ((tui: unknown, theme: unknown) => { render(width: number): string[] }) | undefined) {
132
+ if (content === undefined) widgets.delete(key);
133
+ else widgets.set(key, content);
134
+ },
135
+ select: async (title: string, options: string[]) => {
136
+ dialogs.push(`select:${title}:${options.join("|")}`);
137
+ return options[0];
138
+ },
139
+ confirm: async (title: string, message: string) => {
140
+ dialogs.push(`confirm:${title}:${message}`);
141
+ return confirmResult(title, message);
142
+ },
143
+ input: async (title: string, placeholder: string | undefined) => {
144
+ dialogs.push(`input:${title}`);
145
+ return inputResult(title, placeholder);
146
+ },
147
+ editor: async () => "edited",
148
+ },
149
+ } as unknown as ExtensionContext;
150
+ const widget = () => {
151
+ const factory = widgets.get("gentle-agents");
152
+ return factory ? factory(tui, plainTheme).render(72).map(stripAnsi) : undefined;
153
+ };
154
+ return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
155
+ }
156
+
157
+ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: string[][] } {
158
+ const children: FakeChild[] = [];
159
+ const spawned: string[][] = [];
160
+ let clock = 1000;
161
+ return {
162
+ children,
163
+ spawned,
164
+ deps: {
165
+ runtimeMetricsPolicy: { resolve: () => { throw new Error("Policy not configured in fixture"); } },
166
+ spawn: (command, args) => {
167
+ spawned.push([command, ...args]);
168
+ const child = fakeChild();
169
+ children.push(child);
170
+ return child.child;
171
+ },
172
+ now: () => (clock += 500),
173
+ schedule: () => () => {},
174
+ pi: { command: "pi", args: [] },
175
+ home,
176
+ resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
177
+ env: { PATH: "/bin" },
178
+ },
179
+ };
180
+ }
181
+
182
+ test("all nine subagent registrations own their transcript shell", () => {
183
+ const { pi, tools } = fakePi();
184
+ gentleAgents(pi, {}, deps().deps);
185
+ assert.equal(tools.size, 9);
186
+ for (const tool of tools.values()) assert.equal(tool.renderShell, "self", tool.name);
187
+ });
188
+
189
+ test("host query delivery exposes correlation and accepts one current-session reply", async () => {
190
+ const { pi, tools, fire, sent } = fakePi();
191
+ const harness = deps();
192
+ gentleAgents(pi, {}, harness.deps);
193
+ const { ctx } = fakeContext();
194
+ await fire("session_start", ctx);
195
+ const started = await tools.get("subagent_run")!.execute("query", { agent: "explore", task: "Ask once", mode: "background" }, undefined, undefined, ctx);
196
+ const taskId = (started.details.gentleAgents as { taskId: string }).taskId;
197
+ await tick();
198
+ harness.children[0].message({ id: "q1", kind: "query", message: "Which file?" });
199
+ await tick();
200
+ assert.match(String(sent.at(-1)?.message.content), new RegExp(`Task ID: ${taskId}\\nRequest ID: q1`));
201
+ assert.equal(sent.at(-1)?.message.display, true, "an explicit child query remains visible");
202
+ (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2";
203
+ assert.match((await tools.get("subagent_reply")!.execute("stale", { task_id: taskId, request_id: "q1", message: "wrong" }, undefined, undefined, ctx)).content[0].text, /unavailable/);
204
+ (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s1";
205
+ assert.equal((await tools.get("subagent_reply")!.execute("reply", { task_id: taskId, request_id: "q1", message: "src/a.ts" }, undefined, undefined, ctx)).content[0].text, "Reply accepted for delivery.");
206
+ assert.deepEqual(harness.children[0].sent.at(-1), { id: "q1", kind: "reply", message: "src/a.ts" });
207
+ assert.match((await tools.get("subagent_reply")!.execute("duplicate", { task_id: taskId, request_id: "q1", message: "again" }, undefined, undefined, ctx)).content[0].text, /unavailable/);
208
+ });
209
+
210
+ test("first foreground query yields while its child runs and delivers one completion", async () => {
211
+ const { pi, tools, fire, sent } = fakePi();
212
+ const harness = deps();
213
+ gentleAgents(pi, {}, harness.deps);
214
+ const { ctx } = fakeContext();
215
+ await fire("session_start", ctx);
216
+ const pending = tools.get("subagent_run")!.execute("foreground", { agent: "explore", task: "Ask then finish" }, undefined, undefined, ctx);
217
+ await tick();
218
+ harness.children[0].message({ id: "q1", kind: "query", message: "Which file?" });
219
+ const yielded = await pending;
220
+ const taskId = (yielded.details.gentleAgents as { taskId: string }).taskId;
221
+ assert.equal((yielded as { terminate?: boolean }).terminate, true);
222
+ assert.deepEqual(harness.children[0].killed, []);
223
+ await tools.get("subagent_reply")!.execute("reply", { task_id: taskId, request_id: "q1", message: "src/a.ts" }, undefined, undefined, ctx);
224
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
225
+ harness.children[0].emit({ type: "agent_settled" });
226
+ await tick();
227
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1);
228
+ });
229
+
230
+ test("cancelling a yielded foreground task prevents completion follow-up", async () => {
231
+ const { pi, tools, fire, sent } = fakePi();
232
+ const harness = deps();
233
+ gentleAgents(pi, {}, harness.deps);
234
+ const { ctx } = fakeContext();
235
+ await fire("session_start", ctx);
236
+ const pending = tools.get("subagent_run")!.execute("cancel", { agent: "explore", task: "cancel after query" }, undefined, undefined, ctx);
237
+ await tick();
238
+ harness.children[0].message({ id: "q1", kind: "query", message: "q" });
239
+ const yielded = await pending;
240
+ const taskId = (yielded.details.gentleAgents as { taskId: string }).taskId;
241
+ assert.match((await tools.get("subagent_cancel")!.execute("stop", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /Cancelled task/);
242
+ await tick();
243
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "late" }], stopReason: "stop" }] });
244
+ harness.children[0].emit({ type: "agent_settled" });
245
+ await tick();
246
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
247
+ });
248
+
249
+ test("yielded foreground completion is suppressed after session replacement or cancellation", async () => {
250
+ const { pi, tools, fire, sent } = fakePi();
251
+ const harness = deps();
252
+ gentleAgents(pi, {}, harness.deps);
253
+ const { ctx } = fakeContext();
254
+ await fire("session_start", ctx);
255
+ const pending = tools.get("subagent_run")!.execute("switch", { agent: "explore", task: "switch session" }, undefined, undefined, ctx);
256
+ await tick();
257
+ harness.children[0].message({ id: "q1", kind: "query", message: "q" });
258
+ const yielded = await pending;
259
+ const taskId = (yielded.details.gentleAgents as { taskId: string }).taskId;
260
+ (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2";
261
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
262
+ harness.children[0].emit({ type: "agent_settled" });
263
+ await tick();
264
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
265
+ (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s1";
266
+ assert.match((await tools.get("subagent_cancel")!.execute("cancel", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /not running/);
267
+ });
268
+
269
+ test("first handoff failure keeps ordinary completion, later failure retains yielded completion", async () => {
270
+ const first = fakePi();
271
+ const firstHarness = deps();
272
+ gentleAgents(first.pi, {}, firstHarness.deps);
273
+ const firstContext = fakeContext();
274
+ await first.fire("session_start", firstContext.ctx);
275
+ (first.pi as unknown as { sendMessage(): void }).sendMessage = () => { throw new Error("host unavailable"); };
276
+ const ordinary = first.tools.get("subagent_run")!.execute("first", { agent: "explore", task: "fail handoff" }, undefined, undefined, firstContext.ctx);
277
+ await tick();
278
+ firstHarness.children[0].message({ id: "q1", kind: "query", message: "q" });
279
+ firstHarness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "ordinary" }], stopReason: "stop" }] });
280
+ firstHarness.children[0].emit({ type: "agent_settled" });
281
+ assert.equal((await ordinary).content[0].text, "ordinary");
282
+
283
+ const second = fakePi();
284
+ const secondHarness = deps();
285
+ gentleAgents(second.pi, {}, secondHarness.deps);
286
+ const secondContext = fakeContext();
287
+ await second.fire("session_start", secondContext.ctx);
288
+ let sends = 0;
289
+ (second.pi as unknown as { sendMessage(message: Record<string, unknown>, options: Record<string, unknown>): void }).sendMessage = (message, options) => {
290
+ sends += 1;
291
+ if (sends === 2) throw new Error("second unavailable");
292
+ second.sent.push({ message, options });
293
+ };
294
+ const pending = second.tools.get("subagent_run")!.execute("second", { agent: "explore", task: "two queries" }, undefined, undefined, secondContext.ctx);
295
+ await tick();
296
+ secondHarness.children[0].message({ id: "q1", kind: "query", message: "first" });
297
+ await pending;
298
+ secondHarness.children[0].message({ id: "q2", kind: "query", message: "second" });
299
+ secondHarness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
300
+ secondHarness.children[0].emit({ type: "agent_settled" });
301
+ await tick();
302
+ assert.equal(second.sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1);
303
+ });
304
+
305
+ test("foreground handoff survives settlement before its original await resumes", async () => {
306
+ const { pi, tools, fire, sent } = fakePi();
307
+ const harness = deps();
308
+ gentleAgents(pi, {}, harness.deps);
309
+ const { ctx } = fakeContext();
310
+ await fire("session_start", ctx);
311
+ const pending = tools.get("subagent_run")!.execute("race", { agent: "explore", task: "query then settle" }, undefined, undefined, ctx);
312
+ await tick();
313
+ harness.children[0].message({ id: "q1", kind: "query", message: "q" });
314
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "race result" }], stopReason: "stop" }] });
315
+ harness.children[0].emit({ type: "agent_settled" });
316
+ assert.equal((await pending as { terminate?: boolean }).terminate, true);
317
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1);
318
+ });
319
+
320
+ const tick = () => new Promise((resolve) => setImmediate(resolve));
321
+
322
+ test("child parent-message tooling admits notifications and the active parent preserves raw model text", async () => {
323
+ const child = fakePi();
324
+ const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
325
+ const frames: Array<Record<string, unknown>> = [];
326
+ gentleAgents(child.pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: "fixture" }, {
327
+ childIpc: {
328
+ send: (frame: Record<string, unknown>) => { frames.push(frame); return true; },
329
+ on: (event: string, listener: (value: Record<string, unknown>) => void) => listeners.set(event, [...(listeners.get(event) ?? []), listener]),
330
+ },
331
+ });
332
+ assert.deepEqual([...child.tools.keys()], ["subagent_parent_message"]);
333
+ const pending = child.tools.get("subagent_parent_message")!.execute("message", { message: "raw\u001B[2J text" }, undefined, undefined, {} as ExtensionContext);
334
+ assert.deepEqual(frames, [{ id: "n1", kind: "notification", message: "raw\u001B[2J text" }]);
335
+ for (const listener of listeners.get("message") ?? []) listener({ id: "n1", kind: "ack", accepted: true });
336
+ assert.equal((await pending).content[0].text, "Notification accepted by the parent.");
337
+
338
+ const parent = fakePi();
339
+ const runtime = deps();
340
+ gentleAgents(parent.pi, {}, runtime.deps);
341
+ const { ctx } = fakeContext();
342
+ await parent.fire("session_start", ctx);
343
+ await parent.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "notify", mode: "background" }, undefined, undefined, ctx);
344
+ await tick();
345
+ runtime.children[0].message({ id: "n1", kind: "notification", message: "raw\u001B[2J text" });
346
+ await tick();
347
+ assert.equal(parent.sent[0]?.message.content, "raw\u001B[2J text");
348
+ assert.equal(parent.sent[0]?.message.display, false, "ordinary child notifications remain model-visible but do not render in the transcript");
349
+ assert.deepEqual(parent.sent[0]?.options, { deliverAs: "followUp", triggerTurn: true });
350
+ const rendered = parent.renderers.get("gentle-agents.message")!(parent.sent[0]?.message, { expanded: true }, plainTheme).render(80).join("\n");
351
+ assert.match(rendered, /raw\\x1B\[2J text/);
352
+ (ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
353
+ await parent.fire("session_start", ctx, { type: "session_start", reason: "new" });
354
+ runtime.children[0].message({ id: "n2", kind: "notification", message: "must not reach a replacement session" });
355
+ await tick();
356
+ assert.equal(parent.sent.length, 1, "a child from the prior session delivers no notification after session replacement");
357
+ assert.deepEqual(runtime.children[0].sent, [{ id: "n1", kind: "ack", accepted: true }, { id: "n2", kind: "ack", accepted: false, error: "task parent is not the active host session" }]);
358
+ await parent.fire("session_shutdown", ctx);
359
+ });
360
+ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws", "no-spawn", "long-running"] as const) {
361
+ test(`local child composition through real extensions and RPC runner: ${boundary}`, async (t) => {
362
+ const discarded: number[] = [];
363
+ const discard = AgentRunner.prototype.discardResponseObservations;
364
+ t.mock.method(AgentRunner.prototype, "discardResponseObservations", function (this: AgentRunner, id: string) {
365
+ const live = Reflect.get(this, "live").get(id);
366
+ discarded.push(live?.observations?.responses.length ?? 0);
367
+ discard.call(this, id);
368
+ assert.equal(live?.observations, undefined, "buffer gone synchronously, without another RPC");
369
+ });
370
+ const h = fakePi();
371
+ const runtime = deps();
372
+ let catalogAttempts = 0;
373
+ if (boundary === "allowed") runtime.deps.lookupPiCatalogName = () => {
374
+ catalogAttempts++;
375
+ return new Promise(() => {});
376
+ };
377
+ const context = fakeContext();
378
+ const spawn = runtime.deps.spawn!;
379
+ runtime.deps.spawn = (...args) => {
380
+ const child = spawn(...args);
381
+ const on = child.on.bind(child);
382
+ child.on = ((event: string, listener: (...args: any[]) => void) => {
383
+ if (event === "spawn" && boundary !== "no-spawn") queueMicrotask(() => listener());
384
+ return on(event as any, listener);
385
+ }) as typeof child.on;
386
+ return child;
387
+ };
388
+ let clock = 1;
389
+ const renewalTimers = new Map<number, () => void>();
390
+ const metricsSchedule = (fn: () => void, ms: number) => {
391
+ const at = clock + ms; renewalTimers.set(at, fn); return () => { renewalTimers.delete(at); };
392
+ };
393
+ let calls = 0;
394
+ const policy = { resolve: () => "fixture", exec: async () => {
395
+ calls++;
396
+ return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true,
397
+ source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
398
+ } };
399
+ const env: NodeJS.ProcessEnv = {};
400
+ const profile = join(root, `metrics-${boundary}`);
401
+ mkdirSync(join(profile, "agents"), { recursive: true });
402
+ writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
403
+ writeFileSync(join(profile, "subagents.json"), JSON.stringify({ model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
404
+ gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile, runtimeMetricsPolicy: policy, metricsNow: () => clock, metricsSchedule });
405
+ const listenerCounts = () => [...h.listeners].map(([name, set]) => [name, set.size]);
406
+ const initialListeners = listenerCounts();
407
+ await h.fire("session_start", context.ctx);
408
+ const result = h.tools.get("subagent_run")!.execute("call", { agent: "gentle-ai-worker", task: "private task", mode: "task" }, undefined, undefined, context.ctx);
409
+ await tick();
410
+ assert.equal(runtime.children.length, 1);
411
+ if (boundary === "allowed") assert.equal(catalogAttempts, 1, "child launch does not await catalog loading");
412
+ const child = runtime.children[0];
413
+ const launchedCalls = calls;
414
+ for (let i = 0; i < 10; i++) child.emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "private streamed text" } });
415
+ assert.equal(calls, launchedCalls, "no per-chunk policy process");
416
+ if (boundary === "long-running") {
417
+ for (let i = 0; i < 6; i++) {
418
+ clock += 20_000;
419
+ for (const [at, fn] of [...renewalTimers]) if (at <= clock) { renewalTimers.delete(at); fn(); }
420
+ await tick();
421
+ child.emit({ type: "message_end", message: { role: "assistant", model: "gpt-4o", provider: "openai",
422
+ providerThinkingLevel: "low", stopReason: "stop", usage: { input: 7, output: 3 } } });
423
+ }
424
+ assert.equal(calls, launchedCalls, "no telemetry policy renewal during a two-minute child");
425
+ assert.equal(renewalTimers.size, 0, "child telemetry never schedules a lease timer");
426
+ }
427
+ if (boundary === "env") env.DO_NOT_TRACK = "yes";
428
+ if (boundary === "session" || boundary === "replacement") {
429
+ child.emit({ type: "message_end", message: { role: "assistant", model: "gpt-4o", provider: "openai",
430
+ stopReason: "stop", usage: { input: 7, output: 3 } } });
431
+ if (boundary === "replacement") {
432
+ Object.assign(context.ctx.sessionManager, { getSessionId: () => "replacement" });
433
+ await h.fire("session_start", context.ctx);
434
+ } else await h.fire("session_shutdown", context.ctx);
435
+ assert.deepEqual(discarded, [1], "idle buffered response discarded at lifecycle boundary");
436
+ assert.equal(renewalTimers.size, 0);
437
+ if (boundary === "session") {
438
+ assert.ok([...h.listeners.values()].every(set => set.size === 0), "old bus subscriptions removed");
439
+ const fresh = fakePi();
440
+ Object.assign(fresh.pi, { events: h.pi.events });
441
+ gentleAgents(fresh.pi, env, { ...runtime.deps, env, runtimeMetricsPolicy: policy, metricsSchedule });
442
+ await fresh.fire("session_start", context.ctx);
443
+ assert.deepEqual(listenerCounts(), initialListeners, "fresh instance installs one subscription set");
444
+ await fresh.fire("session_shutdown", context.ctx);
445
+ assert.ok([...h.listeners.values()].every(set => set.size === 0));
446
+ assert.equal(renewalTimers.size, 0);
447
+ }
448
+ }
449
+ if (boundary === "bus-throws") h.pi.events.on(CHILD_METRICS_EVENT, () => { throw new Error("private bus error"); });
450
+ for (const model of ["gpt-4o", "gpt-4o-mini"]) child.emit({ type: "message_end", message: {
451
+ role: "assistant", model, provider: "openai", providerThinkingLevel: "low", stopReason: "stop", usage: { input: 7, output: 3 } } });
452
+ child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
453
+ child.emit({ type: "agent_settled" });
454
+ assert.match((await result).content[0].text, boundary === "session" ? /cancelled/ : /done/);
455
+ await tick(); await tick();
456
+ const events = h.events.filter(event => event.name === CHILD_METRICS_EVENT);
457
+ const admitted = boundary === "allowed" || boundary === "bus-throws" || boundary === "long-running";
458
+ assert.equal(events.length, Number(admitted));
459
+ if (admitted) {
460
+ assert.ok(!JSON.stringify(events).includes("private"));
461
+ const event = events[0].data as import("../lib/runtime-metrics-children.ts").ChildMetricsEvent;
462
+ assert.equal(event.launch.agentClass, "worker");
463
+ assert.equal(event.launch.selectedEffort, "high");
464
+ assert.equal(event.responses.length, boundary === "long-running" ? 8 : 2);
465
+ assert.ok(event.responses.every(row => row.agentClass === "worker" && row.effort === "high"
466
+ && row.selectedProvider === "openai" && row.selectedModelId === "gpt-4o"));
467
+ assert.equal(event.agentSettled, true);
468
+ child.emit({ type: "agent_settled" });
469
+ await tick();
470
+ assert.equal(h.events.filter(event => event.name === CHILD_METRICS_EVENT).length, 1, "completion consumed once, even after bus failure");
471
+ }
472
+ assert.equal(calls, 0, "child telemetry performs no launch, renewal or pending-forward policy query");
473
+ assert.equal(renewalTimers.size, 0, "no renewal timer after the last task finishes");
474
+ assert.ok(!JSON.stringify(h.entries).includes("launch_configuration"));
475
+ await h.fire("session_shutdown", context.ctx);
476
+ });
477
+ }
478
+
479
+ async function eventually(check: () => boolean, message: string): Promise<void> {
480
+ for (let attempt = 0; attempt < 120; attempt++) {
481
+ if (check()) return;
482
+ await new Promise((resolve) => setTimeout(resolve, 25));
483
+ }
484
+ assert.fail(message);
485
+ }
486
+
487
+ function liveProfile(name: string): string {
488
+ const profile = join(realpathSync(root), name);
489
+ mkdirSync(join(profile, "agents"), { recursive: true });
490
+ for (const agent of ["local", "peer"]) {
491
+ writeFileSync(join(profile, "agents", `${agent}.md`), `---\ndescription: ${agent}\n---\nFixture agent.`);
492
+ }
493
+ return profile;
494
+ }
495
+
496
+ function liveInstance(t: test.TestContext, profile: string, sessionId: string) {
497
+ const h = fakePi();
498
+ const runtime = deps();
499
+ const context = fakeContext();
500
+ Object.assign(context.ctx.sessionManager, {
501
+ getSessionId: () => sessionId,
502
+ getSessionName: () => sessionId,
503
+ getCwd: () => join(realpathSync(root), sessionId),
504
+ });
505
+ runtime.deps.schedule = (fn, ms) => {
506
+ const timer = setTimeout(fn, ms);
507
+ timer.unref();
508
+ return () => clearTimeout(timer);
509
+ };
510
+ gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
511
+ t.after(async () => {
512
+ for (const overlay of context.overlays) overlay.handleInput("q");
513
+ await h.fire("session_shutdown", context.ctx, { reason: "quit" });
514
+ });
515
+ return { ...h, ...context, ...runtime };
516
+ }
517
+
518
+ async function liveOverlay(instance: ReturnType<typeof liveInstance>) {
519
+ const opened = instance.commands.get("gentle:agents")!.handler("", instance.ctx);
520
+ await eventually(() => instance.overlays.length > 0, "overlay must mount without waiting for an unbounded directory scan");
521
+ const overlay = instance.overlays.at(-1)!;
522
+ const frame = () => overlay.render(160).map(stripAnsi).join("\n");
523
+ return { overlay, frame, opened };
524
+ }
525
+
526
+ test("live-only extension instances discover same-profile peers across cwd boundaries without importing their tasks", async (t) => {
527
+ const profile = liveProfile("live-peer-profile");
528
+ const local = liveInstance(t, profile, "local-live");
529
+ const peer = liveInstance(t, profile, "peer-live");
530
+ assert.equal(listPresence(profile).entries.length, 0, "factory construction starts no presence resources");
531
+ await local.fire("session_start", local.ctx, { reason: "startup" });
532
+ await peer.fire("session_start", peer.ctx, { reason: "startup" });
533
+ assert.equal(listPresence(profile).entries.length, 2, "idle open orchestrators publish empty activity");
534
+ for (const header of listPresence(profile).entries) assert.deepEqual(readActivity(profile, header).activity?.tasks, []);
535
+ const panel = await liveOverlay(local);
536
+ panel.overlay.handleInput("a");
537
+ await eventually(() => /peer-live/.test(panel.frame()), "an idle peer with zero children must be discoverable");
538
+ const run = async (instance: typeof local, agent: string) => {
539
+ const result = await instance.tools.get("subagent_run")!.execute(agent, { agent, task: agent, mode: "background" }, undefined, undefined, instance.ctx);
540
+ return (result.details.gentleAgents as { taskId: string }).taskId;
541
+ };
542
+ await run(local, "local");
543
+ const peerId = await run(peer, "peer");
544
+ await tick();
545
+ peer.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "peer streamed text" } });
546
+ await eventually(() => listPresence(profile).entries.some((header) => readActivity(profile, header).activity?.tasks.some((row) => row.summary.id === peerId && row.thread.items.some((item) => item.text === "peer streamed text"))), "task deltas, not just status changes, must publish peer activity");
547
+ await eventually(() => /Subagent peer/.test(panel.frame()), "open directory refreshes peer children without reopening");
548
+ const peerPanel = await liveOverlay(peer);
549
+ peerPanel.overlay.handleInput("a");
550
+ await eventually(() => /Subagent local/.test(peerPanel.frame()), "discovery is symmetric across extension instances");
551
+ const lines = panel.overlay.render(160).map(stripAnsi);
552
+ const y = lines.findIndex((line) => line.includes("Subagent peer"));
553
+ assert.ok(y > 0);
554
+ panel.overlay.handleMouse?.(mouse("click", "left", 4, y, 160, lines.length));
555
+ assert.match(panel.frame(), /peer streamed text/, "remote inspection reads the peer's pinned activity, not a local thread");
556
+ for (const key of ["s", "c", "o", "\r"]) panel.overlay.handleInput(key);
557
+ assert.deepEqual(local.customCompletions, [], "remote rows never route Open into the local editor");
558
+ assert.deepEqual(local.children[0].killed, [], "remote Stop never cancels the local child");
559
+ assert.deepEqual(peer.children[0].killed, [], "peer rows provide no remote control channel");
560
+ assert.doesNotMatch((await local.tools.get("subagent_list_tasks")!.execute("list", {}, undefined, undefined, local.ctx)).content[0].text, /· peer ·/);
561
+ assert.match((await local.tools.get("subagent_status")!.execute("status", { task_id: peerId }, undefined, undefined, local.ctx)).content[0].text, /no task/, "peer discovery must never restore into local TaskStore");
562
+ panel.overlay.handleInput("a");
563
+ assert.doesNotMatch(panel.frame(), /Subagent peer|peer-live|Current orchestrator/);
564
+ assert.match(panel.frame(), /Subagent local/);
565
+ panel.overlay.handleInput("a");
566
+ await peer.fire("session_shutdown", peer.ctx, { reason: "resume" });
567
+ await eventually(() => !/peer-live|Subagent peer/.test(panel.frame()), "shutdown removes the peer from an already-open directory");
568
+ assert.equal(listPresence(profile).entries.length, 1);
569
+ panel.overlay.handleInput("q");
570
+ peerPanel.overlay.handleInput("q");
571
+ await Promise.all([panel.opened, peerPanel.opened]);
572
+ });
573
+
574
+ test("manual agents command warns once in RPC mode and stays quiet without UI", async (t) => {
575
+ const local = liveInstance(t, liveProfile("live-non-tui"), "non-tui");
576
+ Object.assign(local.ctx, { mode: "rpc" });
577
+ const notify = t.mock.method(local.ctx.ui, "notify");
578
+ await local.commands.get("gentle:agents")!.handler("", local.ctx);
579
+ assert.equal(notify.mock.callCount(), 1);
580
+ assert.equal(notify.mock.calls[0].arguments[1], "warning");
581
+ assert.deepEqual(local.overlays, []);
582
+ Object.assign(local.ctx, { hasUI: false });
583
+ await local.commands.get("gentle:agents")!.handler("", local.ctx);
584
+ assert.equal(notify.mock.callCount(), 1, "headless invocation adds no notification");
585
+ assert.deepEqual(local.overlays, []);
586
+ });
587
+
588
+ for (const scenario of ["recover", "shutdown", "replacement"] as const) {
589
+ test(`live presence after owned-target publication failure: ${scenario}`, async (t) => {
590
+ const profile = liveProfile(`live-io-${scenario}`);
591
+ const local = liveInstance(t, profile, "io-original");
592
+ await local.fire("session_start", local.ctx);
593
+ const original = listPresence(profile).entries[0]!;
594
+ const peer = scenario === "recover" ? liveInstance(t, profile, "io-original") : undefined;
595
+ if (peer) await peer.fire("session_start", peer.ctx);
596
+ const panel = peer ? await liveOverlay(local) : undefined;
597
+ if (panel) {
598
+ panel.overlay.handleInput("a");
599
+ await eventually(() => /io-original/.test(panel.frame()), "same-session peer is visible before publication failure");
600
+ }
601
+ const target = join(profile, "gentle-agents", "presence", `${original.sessionHash}.${original.incarnation}.activity.json`);
602
+ const fs = createRequire(import.meta.url)("node:fs") as typeof import("node:fs");
603
+ const rename = fs.renameSync;
604
+ let failures = 0;
605
+ const fault = t.mock.method(fs, "renameSync", (from, to) => {
606
+ if (to === target) {
607
+ failures++;
608
+ throw Object.assign(new Error("fixture publication failure"), { code: "EIO" });
609
+ }
610
+ return rename(from, to);
611
+ });
612
+ syncBuiltinESMExports();
613
+ try {
614
+ await local.tools.get("subagent_run")!.execute("io", { agent: "local", task: "Recover activity", mode: "background" }, undefined, undefined, local.ctx);
615
+ await eventually(() => failures > 0 && !listPresence(profile).entries.some((header) => header.incarnation === original.incarnation), "guarded flush failure must dispose the owned publication");
616
+ } finally {
617
+ fault.mock.restore();
618
+ syncBuiltinESMExports();
619
+ }
620
+ if (scenario === "shutdown") await local.fire("session_shutdown", local.ctx);
621
+ if (scenario === "replacement") {
622
+ const next = fakeContext().ctx;
623
+ next.sessionManager.getSessionId = () => "io-replacement";
624
+ await local.fire("session_start", next);
625
+ }
626
+ const replacement = listPresence(profile).entries[0];
627
+ local.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "activity after IO recovery" } });
628
+ await tick();
629
+ if (scenario === "recover") {
630
+ await eventually(() => listPresence(profile).entries.some((header) => readActivity(profile, header).activity?.tasks.some((row) => row.thread.items.some((item) => item.text === "activity after IO recovery"))), "ordinary task delta must recreate presence with current activity");
631
+ const recovered = listPresence(profile).entries;
632
+ assert.equal(recovered.length, 2, "recovery preserves the distinct same-session peer");
633
+ assert.ok(recovered.every((header) => header.sessionHash === original.sessionHash));
634
+ assert.ok(recovered.every((header) => header.incarnation !== original.incarnation));
635
+ const scans = t.mock.method(PresenceCursor.prototype, "next");
636
+ await peer!.tools.get("subagent_run")!.execute("peer", { agent: "peer", task: "Peer after recovery", mode: "background" }, undefined, undefined, peer!.ctx);
637
+ // The second scan starts only after the first post-recovery traversal is displayed.
638
+ await eventually(() => scans.mock.callCount() >= 2 && /Subagent peer/.test(panel!.frame()), "same-session peer remains visible after recovery without reopening");
639
+ assert.equal((panel!.frame().match(/Subagent local/g) ?? []).length, 1, "recovered local activity appears once, never as a read-only peer duplicate");
640
+ assert.equal((panel!.frame().match(/Subagent peer/g) ?? []).length, 1, "the actual same-session peer remains independently visible");
641
+ } else if (scenario === "shutdown") {
642
+ assert.deepEqual(listPresence(profile).entries, [], "shutdown and late child events never recreate presence");
643
+ } else {
644
+ const headers = listPresence(profile).entries;
645
+ assert.equal(headers.length, 1, "late old-session activity cannot resurrect its publisher");
646
+ assert.notEqual(headers[0].sessionHash, original.sessionHash);
647
+ assert.equal(headers[0].incarnation, replacement!.incarnation);
648
+ assert.deepEqual(readActivity(profile, headers[0]).activity?.tasks, [], "replacement must not publish old-session tasks");
649
+ }
650
+ });
651
+ }
652
+
653
+ test("live-only instances sharing a session ID remain distinct and withdraw only their own incarnation", async (t) => {
654
+ const profile = liveProfile("live-incarnation-profile");
655
+ const local = liveInstance(t, profile, "same-live");
656
+ const peer = liveInstance(t, profile, "same-live");
657
+ peer.ctx.sessionManager.getCwd = () => join(realpathSync(root), "peer-cwd");
658
+ for (const [instance, agent] of [[local, "local"], [peer, "peer"]] as const) {
659
+ await instance.fire("session_start", instance.ctx, { reason: "startup" });
660
+ await instance.tools.get("subagent_run")!.execute(agent, { agent, task: agent, mode: "background" }, undefined, undefined, instance.ctx);
661
+ }
662
+ const headers = listPresence(profile).entries;
663
+ assert.equal(headers.length, 2);
664
+ assert.equal(headers[0].sessionHash, headers[1].sessionHash);
665
+ assert.notEqual(headers[0].incarnation, headers[1].incarnation);
666
+ const panel = await liveOverlay(local);
667
+ panel.overlay.handleInput("a");
668
+ await eventually(() => /Subagent peer/.test(panel.frame()), "same-session peers remain independently visible");
669
+ assert.equal((panel.frame().match(/Subagent local/g) ?? []).length, 1, "own publication is not duplicated as a peer");
670
+ await local.fire("session_shutdown", local.ctx, { reason: "quit" });
671
+ await panel.opened;
672
+ assert.equal(listPresence(profile).entries.length, 1, "shutdown removes only this activation");
673
+ assert.deepEqual(peer.children[0].killed, []);
674
+ });
675
+
676
+ test("live-only directory traverses presence overflow, excludes expired and other-profile peers, and replaces sessions", async (t) => {
677
+ const profile = liveProfile("live-paged-profile");
678
+ const now = Date.now();
679
+ const oldClock = mock.method(Date, "now", () => now - 16_000);
680
+ let expired: PresencePublisher;
681
+ try {
682
+ expired = PresencePublisher.start({ profile, sessionId: "expired", label: "Expired peer", activity: [] });
683
+ } finally {
684
+ oldClock.mock.restore();
685
+ }
686
+ // Simulate an abruptly closed publisher: retained files, but no renewing heartbeat.
687
+ const stem = join(profile, "gentle-agents", "presence", `${expired.target.sessionHash}.${expired.target.incarnation}`);
688
+ const staleFiles = ["header", "activity"].map((kind) => ({ path: `${stem}.${kind}.json`, bytes: readFileSync(`${stem}.${kind}.json`) }));
689
+ expired.dispose();
690
+ for (const { path, bytes } of staleFiles) writeFileSync(path, bytes, { mode: 0o600 });
691
+ const isolated = PresencePublisher.start({ profile: liveProfile("live-isolated-profile"), sessionId: "isolated", label: "Isolated peer", activity: [] });
692
+ t.after(() => isolated.dispose());
693
+ for (let index = 0; index < 130; index++) {
694
+ const publisher = PresencePublisher.start({ profile, sessionId: `idle-${index}`, label: `Idle peer ${index}`, activity: [] });
695
+ t.after(() => publisher.dispose());
696
+ }
697
+ assert.equal(listPresence(profile).overflow, true, "fixture exceeds the foundation's first-page budget");
698
+ let pageReadThisTurn = false;
699
+ const next = PresenceCursor.prototype.next;
700
+ const pages = t.mock.method(PresenceCursor.prototype, "next", function (this: PresenceCursor, ...args: Parameters<PresenceCursor["next"]>) {
701
+ assert.equal(pageReadThisTurn, false, "directory pages must yield instead of blocking the UI with an unbounded loop");
702
+ pageReadThisTurn = true;
703
+ setImmediate(() => { pageReadThisTurn = false; });
704
+ return next.apply(this, args);
705
+ });
706
+ const local = liveInstance(t, profile, "directory-local");
707
+ await local.fire("session_start", local.ctx, { reason: "startup" });
708
+ const panel = await liveOverlay(local);
709
+ panel.overlay.handleInput("a");
710
+ const seen = new Set<string>();
711
+ await eventually(() => {
712
+ for (let step = 0; step < 140; step++) {
713
+ const frame = panel.frame();
714
+ assert.doesNotMatch(frame, /Expired peer|Isolated peer/);
715
+ for (const match of frame.matchAll(/Idle peer (\d+)/g)) seen.add(match[1]);
716
+ panel.overlay.handleInput("j");
717
+ }
718
+ for (let step = 0; step < 140; step++) panel.overlay.handleInput("k");
719
+ return seen.size === 130;
720
+ }, "every idle orchestrator beyond the 128-entry page must eventually be reachable");
721
+ assert.ok(pages.mock.callCount() >= 3, "the directory traversed all three fixture pages");
722
+ panel.overlay.handleInput("q");
723
+ await panel.opened;
724
+ const readsAtClose = pages.mock.callCount();
725
+ await new Promise((resolve) => setTimeout(resolve, 1100));
726
+ assert.equal(pages.mock.callCount(), readsAtClose, "closing the overlay cancels future directory scans");
727
+ pages.mock.restore();
728
+ await local.fire("session_shutdown", local.ctx, { reason: "new" });
729
+ const replacement = liveInstance(t, profile, "replacement-live");
730
+ await replacement.fire("session_start", replacement.ctx, { reason: "new" });
731
+ const cursor = new PresenceCursor(profile);
732
+ const labels: string[] = [];
733
+ try {
734
+ let page;
735
+ do {
736
+ page = cursor.next();
737
+ labels.push(...page.entries.map((entry) => entry.label));
738
+ } while (page.overflow);
739
+ } finally {
740
+ cursor.close();
741
+ }
742
+ assert.ok(labels.some((label) => label.includes("replacement-live")));
743
+ assert.ok(labels.every((label) => !label.includes("directory-local")), "session replacement withdraws the old activation");
744
+ panel.overlay.handleInput("q");
745
+ await panel.opened;
746
+ });
747
+
748
+ test("research launch transports selected grants and only matching existing extensions", async () => {
749
+ const fixtureHome = join(root, "research-home");
750
+ mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
751
+ writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-research.md"), "---\nname: sdd-research\ntools: [read, write, fetch_content, web_search, source_check, mcp, bash]\n---\nCollect research.");
752
+ const fake = fakePi(), runtime = deps(), { ctx } = fakeContext();
753
+ fake.pi.getActiveTools = () => ["fetch_content", "web_search", "mcp", "bash"];
754
+ fake.pi.getAllTools = () => fake.pi.getActiveTools().map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) as never;
755
+ const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
756
+ const artifact = { store: "openspec", worktree: cwd, changeName: "demo", retainedIntent: "preserve denied documentation questions", locators: [{ artifact: "research", path: join(cwd, "openspec/changes/demo/research.md"), revision: 1, digest: "a".repeat(64) }] };
757
+ let childEnv: NodeJS.ProcessEnv = {};
758
+ gentleAgents(fake.pi, {}, { ...runtime.deps, home: fixtureHome, spawn: (command, args, options) => {
759
+ childEnv = options.env!;
760
+ return runtime.deps.spawn!(command, args, options);
761
+ } });
762
+ const result = await fake.tools.get("subagent_run")!.execute("research", { agent: "sdd-research", task: "Research docs", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", research_selection: selection, research_artifact: artifact }, undefined, undefined, ctx);
763
+ await tick();
764
+ const argv = runtime.spawned[0];
765
+ assert.equal(argv[argv.indexOf("--tools") + 1], "read,write,fetch_content,subagent_parent_message");
766
+ assert.equal(argv[argv.indexOf("--extension") + 1], "/installed/web.ts");
767
+ assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), selection);
768
+ assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact);
769
+ assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact);
770
+ assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_selection, "fresh selection must be expressible on continuation");
771
+ assert.ok(JSON.parse(childEnv.GENTLE_PI_RESEARCH_TOOLS!).includes("subagent_parent_message"));
772
+ assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /documentation: available/);
773
+ assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /open-web: blocked/, "two reachable tools cannot admit open-web");
774
+ runtime.children[0].emit({ type: "agent_settled" });
775
+ await tick();
776
+ const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
777
+ const rejected = await fake.tools.get("subagent_continue")!.execute("broaden", { task_id: taskId, prompt: "Inspect", mode: "background", research_artifact: { ...artifact, store: "none", locators: [] } }, undefined, undefined, ctx);
778
+ assert.match(rejected.content[0].text, /scope/);
779
+ assert.equal(runtime.spawned.length, 1);
780
+ await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background", research_artifact: artifact }, undefined, undefined, ctx);
781
+ await tick();
782
+ assert.equal(runtime.spawned.length, 2);
783
+ assert.ok(!runtime.spawned[1].includes("--extension"), "no inherited research selection");
784
+ assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "read,write,subagent_parent_message");
785
+ assert.equal(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), null);
786
+ assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact, "denial intent and exact store/path survive re-entry");
787
+ fake.pi.getActiveTools = () => ["web_search"];
788
+ runtime.children[1].emit({ type: "agent_settled" });
789
+ await tick();
790
+ const denied = await fake.tools.get("subagent_continue")!.execute("missing-tool", { task_id: taskId, prompt: "Retry same scope", mode: "background", research_selection: selection, research_artifact: artifact }, undefined, undefined, ctx);
791
+ await tick();
792
+ assert.ok(!runtime.spawned[2].includes("--extension"));
793
+ runtime.children[2].emit({ type: "agent_settled" });
794
+ await tick();
795
+ fake.pi.getActiveTools = () => ["fetch_content", "web_search"];
796
+ await fake.tools.get("subagent_continue")!.execute("corrected", { task_id: (denied.details.gentleAgents as { taskId: string }).taskId, prompt: "Retry same scope", mode: "background", research_selection: selection, research_artifact: artifact }, undefined, undefined, ctx);
797
+ await tick();
798
+ assert.equal(runtime.spawned[3][runtime.spawned[3].indexOf("--extension") + 1], "/installed/web.ts");
799
+ assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact);
800
+ await fake.fire("session_shutdown", ctx);
801
+ });
802
+
803
+ test("research child inventory requires every canonical open-web tool", () => {
804
+ const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
805
+ for (const missing of [undefined, ...required]) {
806
+ const hooks = new Map<string, (event: any) => any>();
807
+ const active = required.filter(name => name !== missing);
808
+ const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => active, getAllTools: () => required.map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) } as never;
809
+ gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(required), GENTLE_PI_RESEARCH_SELECTION: JSON.stringify({ documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } }, "open-web": { tools: required, extensions: Object.fromEntries(required.map(name => [name, "/installed/web.ts"])) } }) });
810
+ const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt;
811
+ assert.match(prompt, new RegExp(`open-web: ${missing === undefined ? "available" : "blocked"}`));
812
+ assert.match(prompt, new RegExp(`documentation: ${missing === "fetch_content" ? "blocked" : "available"}`));
813
+ assert.match(prompt, /Availability is not evidence/);
814
+ }
815
+ });
816
+
817
+ test("research child rechecks local inventory and blocks gateway calls", async () => {
818
+ const hooks = new Map<string, (event: any) => any>();
819
+ const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => ["read", "mcp"], getAllTools: () => [{ name: "read" }, { name: "mcp" }] } as never;
820
+ gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: '["read","fetch_content"]' });
821
+ assert.match(hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt, /documentation: blocked/);
822
+ assert.equal(hooks.get("tool_call")!({ toolName: "mcp" }).block, true);
823
+ assert.equal(hooks.get("tool_call")!({ toolName: "fetch_content" }).block, true);
824
+ assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing artifact scope cannot authorize a read");
825
+ });
826
+
827
+ async function shutdownAndRestoreNativeSpawn(
828
+ childProcess: typeof import("node:child_process"),
829
+ originalSpawn: typeof import("node:child_process").spawn,
830
+ shutdown: () => Promise<unknown>,
831
+ ): Promise<void> {
832
+ try {
833
+ await shutdown();
834
+ } finally {
835
+ childProcess.spawn = originalSpawn;
836
+ syncBuiltinESMExports();
837
+ }
838
+ }
839
+
840
+ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-switch", "shutdown", "unregistered"] as const) {
841
+ test(`child mutation attribution through registered subagent_run: ${scenario}`, async () => {
842
+ const h = fakePi();
843
+ const d = deps();
844
+ const { ctx } = fakeContext();
845
+ let sessionId = "s1";
846
+ const sibling = join(root, "sibling");
847
+ const childRoot = scenario === "other-root" ? sibling : cwd;
848
+ ctx.sessionManager.getSessionId = () => sessionId;
849
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
850
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
851
+ d.deps.resolveWorktree = (path, base) => {
852
+ const absolute = resolve(base, path);
853
+ const worktree = [cwd, sibling].find((candidate) => absolute === candidate || absolute.startsWith(`${candidate}/`));
854
+ return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
855
+ };
856
+ const spawn = d.deps.spawn!;
857
+ d.deps.spawn = (...args) => {
858
+ const child = spawn(...args);
859
+ const on = child.on.bind(child);
860
+ child.on = ((event: string, listener: () => void) => {
861
+ if (event === "spawn" && scenario !== "unregistered") queueMicrotask(listener);
862
+ return on(event as "spawn", listener);
863
+ }) as typeof child.on;
864
+ return child;
865
+ };
866
+ gentleAgents(h.pi, {}, d.deps);
867
+ await h.fire("session_start", ctx);
868
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: childRoot }, undefined, undefined, ctx);
869
+ await tick();
870
+ assert.equal(h.entries.filter((entry) => entry.customType === REVIEW_REMINDER_RECEIPT).length, 0, "spawn alone is not ownership");
871
+ if (scenario === "session-switch") sessionId = "s2";
872
+ if (scenario === "shutdown") await h.fire("session_shutdown", ctx);
873
+ const path = scenario === "escaped" ? "../../outside.ts" : scenario === "sibling" ? join(sibling, "file.ts") : "file.ts";
874
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path } });
875
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
876
+ const accepted = scenario === "own" || scenario === "other-root";
877
+ assert.equal(h.entries.filter((entry) => entry.customType === REVIEW_REMINDER_RECEIPT).length, accepted ? 1 : 0);
878
+ assert.equal(Boolean(pendingReviewMutation(ctx.sessionManager, cwd)), scenario === "own", "another registered root never authorizes current-root STATUS");
879
+ if (scenario === "other-root") assert.ok(pendingReviewMutation(ctx.sessionManager, sibling));
880
+ await h.fire("session_shutdown", ctx);
881
+ await tick();
882
+ });
883
+ }
884
+
885
+ test("default Node spawn adapter distinguishes IPC-only and permission-capable canonical Git children", async () => {
886
+ const childProcess = createRequire(import.meta.url)("node:child_process") as typeof import("node:child_process");
887
+ const originalSpawn = childProcess.spawn;
888
+ type CapturedSpawnOptions = { cwd: string; env: NodeJS.ProcessEnv; shell?: boolean; windowsHide?: boolean; detached?: boolean; stdio?: string[] };
889
+ const captured: Array<{ command: string; args: readonly string[]; options: CapturedSpawnOptions }> = [];
890
+ const children: FakeChild[] = [];
891
+ const shutdown: Array<() => Promise<void>> = [];
892
+ const canonicalGitCwd = join(root, "canonical-git-project");
893
+ const gitBin = join(root, "canonical-git-bin");
894
+ mkdirSync(join(canonicalGitCwd, ".git"), { recursive: true });
895
+ mkdirSync(gitBin, { recursive: true });
896
+ const gitFixture = join(gitBin, "git");
897
+ writeFileSync(gitFixture, `#!/bin/sh\nif [ "$1" = "-C" ] && [ "$2" = "${canonicalGitCwd}" ] && [ "$3" = "rev-parse" ] && [ "$4" = "--git-common-dir" ]; then\n printf '.git\\n'\n exit 0\nfi\nexit 1\n`);
898
+ chmodSync(gitFixture, 0o755);
899
+ const withCanonicalGitFixture = <T>(action: () => T): T => {
900
+ const previousPath = process.env.PATH;
901
+ process.env.PATH = gitBin;
902
+ try {
903
+ return action();
904
+ } finally {
905
+ if (previousPath === undefined) delete process.env.PATH;
906
+ else process.env.PATH = previousPath;
907
+ }
908
+ };
909
+ childProcess.spawn = ((command: string, args: readonly string[], options: Record<string, unknown>) => {
910
+ captured.push({ command, args, options: options as unknown as CapturedSpawnOptions });
911
+ const child = fakeChild();
912
+ children.push(child);
913
+ return child.child;
914
+ }) as unknown as typeof childProcess.spawn;
915
+ syncBuiltinESMExports();
916
+ try {
917
+ const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
918
+ const h = fakePi();
919
+ gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined });
920
+ const { ctx } = fakeContext();
921
+ (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
922
+ await h.fire("session_start", ctx);
923
+ shutdown.push(() => h.fire("session_shutdown", ctx));
924
+ return { h, ctx, result: withCanonicalGitFixture(() => h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx)) };
925
+ };
926
+ const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });
927
+ await tick();
928
+ children[0]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "task complete" }] }] });
929
+ children[0]!.emit({ type: "agent_settled" });
930
+ await task.result;
931
+ const background = await launch("background", { PATH: "/bin", FIXTURE: "background" });
932
+ await background.result;
933
+ await tick();
934
+ const permission = await launch("task", { PATH: "/bin", FIXTURE: "permission" }, canonicalGitCwd);
935
+ await tick();
936
+ children[2]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "permission complete" }] }] });
937
+ children[2]!.emit({ type: "agent_settled" });
938
+ await permission.result;
939
+
940
+ const args = ["--host-flag", "--mode", "rpc", "--session-dir", join(home, ".pi", "agent", "gentle-agents", "sessions"), "--model", "openai-codex/gpt-5.6-terra:low", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."];
941
+ assert.equal(captured.length, 3, "the extension reaches Node's spawn boundary for IPC-only and permission-channel launches");
942
+ for (const [index, fixture] of ["task", "background", "permission"].entries()) {
943
+ const permissionChannel = index === 2;
944
+ const ownedIpc = captured[index]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
945
+ assert.match(ownedIpc ?? "", /^\d+-[a-z0-9]+$/, "the child receives an opaque owned-IPC marker");
946
+ assert.equal(captured[index]?.command, "/fixture/pi");
947
+ assert.deepEqual(captured[index]?.args, args);
948
+ assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
949
+ assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}) });
950
+ assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
951
+ assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
952
+ assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
953
+ assert.deepEqual(captured[index]?.options.stdio, permissionChannel ? ["pipe", "pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"], permissionChannel ? "canonical repository children retain an fd3 permission channel and receive messaging IPC at fd4" : "IPC-only children have no inherited permission fd");
954
+ }
955
+ await Promise.all(shutdown.map((close) => close()));
956
+ assert.deepEqual(children[1]?.killed, ["SIGTERM"], "session shutdown cleans up an active background child");
957
+ shutdown.length = 0;
958
+ } finally {
959
+ await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => Promise.all(shutdown.map((close) => close())));
960
+ }
961
+ });
962
+
963
+ test("native spawn interception restores CommonJS and ESM exports after rejected shutdown and assertion failure", async () => {
964
+ const childProcess = createRequire(import.meta.url)("node:child_process") as typeof import("node:child_process");
965
+ const originalSpawn = childProcess.spawn;
966
+ const assertRestored = async () => {
967
+ assert.equal(childProcess.spawn, originalSpawn, "CommonJS spawn is restored");
968
+ assert.equal((await import("node:child_process")).spawn, originalSpawn, "ESM spawn is restored");
969
+ };
970
+ const installMock = () => {
971
+ childProcess.spawn = (() => fakeChild().child) as unknown as typeof childProcess.spawn;
972
+ syncBuiltinESMExports();
973
+ };
974
+ const start = async (rejectShutdown: boolean) => {
975
+ const h = fakePi();
976
+ const fire = h.fire;
977
+ if (rejectShutdown) {
978
+ h.fire = async (event, ctx, payload) => {
979
+ await fire(event, ctx, payload);
980
+ if (event === "session_shutdown") throw new Error("forced shutdown rejection");
981
+ return undefined;
982
+ };
983
+ }
984
+ gentleAgents(h.pi, {}, { home, agentHome: join(home, ".pi", "agent"), env: { PATH: "/bin" }, pi: { command: "/fixture/pi", args: [] }, resolveWorktree: () => undefined });
985
+ const { ctx } = fakeContext();
986
+ await h.fire("session_start", ctx);
987
+ await h.tools.get("subagent_run")!.execute("cleanup", { agent: "explore", task: "Keep cleanup live", mode: "background" }, undefined, undefined, ctx);
988
+ await tick();
989
+ return { h, ctx };
990
+ };
991
+
992
+ let mocked = false;
993
+ installMock();
994
+ mocked = true;
995
+ try {
996
+ const rejected = await start(true);
997
+ await assert.rejects(shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => rejected.h.fire("session_shutdown", rejected.ctx)), /forced shutdown rejection/);
998
+ mocked = false;
999
+ await assertRestored();
1000
+ } finally {
1001
+ if (mocked) {
1002
+ childProcess.spawn = originalSpawn;
1003
+ syncBuiltinESMExports();
1004
+ }
1005
+ }
1006
+
1007
+ installMock();
1008
+ mocked = true;
1009
+ try {
1010
+ const asserted = await start(false);
1011
+ await assert.rejects(async () => {
1012
+ try {
1013
+ assert.fail("forced assertion failure");
1014
+ } finally {
1015
+ await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => asserted.h.fire("session_shutdown", asserted.ctx));
1016
+ }
1017
+ }, /forced assertion failure/);
1018
+ mocked = false;
1019
+ await assertRestored();
1020
+ } finally {
1021
+ if (mocked) {
1022
+ childProcess.spawn = originalSpawn;
1023
+ syncBuiltinESMExports();
1024
+ }
1025
+ }
1026
+ });
1027
+
1028
+ test("explicit child roots launch and continue in the actual cwd, persist without shell, and reject other clones", async () => {
1029
+ const h = fakePi();
1030
+ const runtime = deps();
1031
+ const childRoot = join(root, "child-worktree");
1032
+ const launched: string[] = [];
1033
+ const spawnEvents: Array<() => void> = [];
1034
+ const baseSpawn = runtime.deps.spawn!;
1035
+ runtime.deps.spawn = (command, args, options) => {
1036
+ launched.push(options.cwd);
1037
+ const child = baseSpawn(command, args, options);
1038
+ const on = child.on.bind(child);
1039
+ child.on = ((event: string, listener: () => void) => {
1040
+ if (event === "spawn") spawnEvents.push(listener);
1041
+ else on(event as "exit", listener);
1042
+ return child;
1043
+ }) as typeof child.on;
1044
+ return child;
1045
+ };
1046
+ runtime.deps.resolveWorktree = (path, base) => ({ root: resolve(base, path), commonDir: path === "/other-clone" ? "/other/git" : "/fixture/common" });
1047
+ gentleAgents(h.pi, {}, runtime.deps);
1048
+ const { ctx } = fakeContext();
1049
+ (ctx.sessionManager as unknown as { getEntries(): unknown[] }).getEntries = () => h.entries;
1050
+ await h.fire("session_start", ctx);
1051
+ const run = h.tools.get("subagent_run")!;
1052
+ await assert.rejects(run.execute("bad", { agent: "explore", task: "Map", workspace_root: "/other-clone", mode: "background" }, undefined, undefined, ctx), /same Git clone/);
1053
+ assert.deepEqual(launched, []);
1054
+ const result = await run.execute("one", { agent: "explore", task: "Map /other-clone mentioned in prose", workspace_root: childRoot, mode: "background" }, undefined, undefined, ctx);
1055
+ await tick();
1056
+ assert.deepEqual(launched, [childRoot]);
1057
+ assert.deepEqual(h.entries, [], "queueing and returning a child handle do not register roots");
1058
+ spawnEvents[0]();
1059
+ assert.deepEqual(h.entries, [{ type: "custom", customType: SESSION_WORKTREE_ENTRY, data: { sessionId: "s1", root: childRoot, evidence: "subagent:spawn" } }]);
1060
+ assert.deepEqual(h.events.filter(event => event.name === SESSION_WORKTREE_CHANGED), [{ name: SESSION_WORKTREE_CHANGED, data: { sessionId: "s1" } }]);
1061
+ const details = result.details.gentleAgents as { taskId: string; cwd: string };
1062
+ assert.equal(details.cwd, childRoot);
1063
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
1064
+ runtime.children[0].emit({ type: "agent_settled" });
1065
+ await tick();
1066
+ await h.tools.get("subagent_continue")!.execute("continue", { task_id: details.taskId, prompt: "Follow up", mode: "background" }, undefined, undefined, ctx);
1067
+ await tick();
1068
+ assert.deepEqual(launched, [childRoot, childRoot]);
1069
+ spawnEvents[1]();
1070
+ assert.equal(h.entries.length, 1, "continuation dedupes the original root");
1071
+ const status = await h.tools.get("subagent_status")!.execute("status", { task_id: details.taskId }, undefined, undefined, ctx);
1072
+ assert.match(status.content[0].text, /cwd:/);
1073
+ await h.fire("session_shutdown", ctx);
1074
+ spawnEvents[1]();
1075
+ assert.equal(h.entries.length, 1, "late process events after shutdown cannot write session state");
1076
+ await tick();
1077
+ });
1078
+
1079
+ test("SDD phase continuation requires a fresh selection and launches only that selection", async () => {
1080
+ const h = fakePi();
1081
+ const runtime = deps();
1082
+ const fixtureHome = join(root, "sdd-selection-home");
1083
+ mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
1084
+ writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nSDD apply executor");
1085
+ gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome });
1086
+ const { ctx } = fakeContext();
1087
+ await h.fire("session_start", ctx);
1088
+ const run = await h.tools.get("subagent_run")!.execute("run", {
1089
+ agent: "sdd-apply", task: "Apply alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
1090
+ sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "apply" },
1091
+ }, undefined, undefined, ctx);
1092
+ await tick();
1093
+ const taskId = (run.details.gentleAgents as { taskId: string }).taskId;
1094
+ const first = runtime.spawned[0]!;
1095
+ assert.deepEqual(JSON.parse(first[first.indexOf("--gentle-sdd-change") + 1]!), { changeName: "alpha", workspaceRoot: cwd, phase: "apply" });
1096
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
1097
+ runtime.children[0].emit({ type: "agent_settled" });
1098
+ await tick();
1099
+ const missing = await h.tools.get("subagent_continue")!.execute("missing", { task_id: taskId, prompt: "Continue", mode: "background" }, undefined, undefined, ctx);
1100
+ assert.match(missing.content[0].text, /requires a fresh sdd_change/i);
1101
+ assert.equal(runtime.spawned.length, 1);
1102
+ await h.tools.get("subagent_continue")!.execute("continue", {
1103
+ task_id: taskId, prompt: "Apply beta", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
1104
+ sdd_change: { changeName: "beta", workspaceRoot: cwd, phase: "apply" },
1105
+ }, undefined, undefined, ctx);
1106
+ await tick();
1107
+ const second = runtime.spawned[1]!;
1108
+ assert.deepEqual(JSON.parse(second[second.indexOf("--gentle-sdd-change") + 1]!), { changeName: "beta", workspaceRoot: cwd, phase: "apply" });
1109
+ await h.fire("session_shutdown", ctx);
1110
+ });
1111
+
1112
+ test("ordinary non-Git tasks still continue in their original cwd without registering a worktree", async () => {
1113
+ const h = fakePi();
1114
+ const runtime = deps();
1115
+ runtime.deps.resolveWorktree = () => undefined;
1116
+ gentleAgents(h.pi, {}, runtime.deps);
1117
+ const { ctx } = fakeContext();
1118
+ await h.fire("session_start", ctx);
1119
+ const result = await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
1120
+ await tick();
1121
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
1122
+ runtime.children[0].emit({ type: "agent_settled" });
1123
+ await tick();
1124
+ const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
1125
+ await h.tools.get("subagent_continue")!.execute("continue", { task_id: taskId, prompt: "Follow up", mode: "background" }, undefined, undefined, ctx);
1126
+ await tick();
1127
+ assert.equal(runtime.children.length, 2);
1128
+ assert.deepEqual(h.entries, []);
1129
+ await h.fire("session_shutdown", ctx);
1130
+ await tick();
1131
+ });
1132
+
1133
+ test("delayed child spawn retains the originating session and cannot append into its replacement", async () => {
1134
+ const h = fakePi();
1135
+ const runtime = deps();
1136
+ const spawnEvents: Array<() => void> = [];
1137
+ const baseSpawn = runtime.deps.spawn!;
1138
+ runtime.deps.spawn = (command, args, options) => {
1139
+ const child = baseSpawn(command, args, options);
1140
+ const on = child.on.bind(child);
1141
+ child.on = ((event: string, listener: () => void) => {
1142
+ if (event === "spawn") spawnEvents.push(listener);
1143
+ else on(event as "exit", listener);
1144
+ return child;
1145
+ }) as typeof child.on;
1146
+ return child;
1147
+ };
1148
+ gentleAgents(h.pi, {}, runtime.deps);
1149
+ const { ctx } = fakeContext();
1150
+ await h.fire("session_start", ctx);
1151
+ await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map", workspace_root: join(root, "old-root"), mode: "background" }, undefined, undefined, ctx);
1152
+ await tick();
1153
+ const next = fakeContext();
1154
+ (next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
1155
+ await h.fire("session_start", next.ctx);
1156
+ spawnEvents[0]();
1157
+ await tick();
1158
+ assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API");
1159
+ await h.fire("session_shutdown", next.ctx);
1160
+ });
1161
+
1162
+ test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
1163
+ assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
1164
+ sessions: "/profiles/pi-principal/agent/gentle-agents/sessions",
1165
+ transcripts: "/profiles/pi-principal/agent/gentle-agents/transcripts",
1166
+ });
1167
+ assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-lab/agent"), {
1168
+ sessions: "/profiles/pi-lab/agent/gentle-agents/sessions",
1169
+ transcripts: "/profiles/pi-lab/agent/gentle-agents/transcripts",
1170
+ });
1171
+ assert.deepEqual(agentRuntimePaths("/home/x"), {
1172
+ sessions: "/home/x/.pi/agent/gentle-agents/sessions",
1173
+ transcripts: "/home/x/.pi/agent/gentle-agents/transcripts",
1174
+ });
1175
+ });
1176
+
1177
+ test("extension resolves each profile environment at setup time without changing HOME", async () => {
1178
+ const principalHome = join(root, "pi-principal", "agent");
1179
+ const labHome = join(root, "pi-lab", "agent");
1180
+ for (const [agentHome, name] of [[principalHome, "principal"], [labHome, "lab"]] as const) {
1181
+ mkdirSync(join(agentHome, "agents"), { recursive: true });
1182
+ writeFileSync(join(agentHome, "agents", `${name}.md`), `---\ndescription: ${name}\n---\n${name}`);
1183
+ }
1184
+ const withoutExplicitHome = () => {
1185
+ const harness = deps();
1186
+ const { home: _home, env: _env, ...overrides } = harness.deps;
1187
+ return overrides;
1188
+ };
1189
+ const principal = fakePi();
1190
+ gentleAgents(principal.pi, { GENTLE_PI_AGENT_HOME: principalHome, PI_CODING_AGENT_DIR: labHome }, withoutExplicitHome());
1191
+ const principalContext = fakeContext();
1192
+ await principal.fire("session_start", principalContext.ctx);
1193
+ assert.match((await principal.tools.get("subagent_list_agents")!.execute("p1", {}, undefined, undefined, principalContext.ctx)).content[0].text, /principal/);
1194
+ const lab = fakePi();
1195
+ gentleAgents(lab.pi, { PI_CODING_AGENT_DIR: labHome }, withoutExplicitHome());
1196
+ const labContext = fakeContext();
1197
+ await lab.fire("session_start", labContext.ctx);
1198
+ assert.match((await lab.tools.get("subagent_list_agents")!.execute("l1", {}, undefined, undefined, labContext.ctx)).content[0].text, /lab/);
1199
+ });
1200
+
1201
+ 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) {
1202
+ test(`${key} ${tilde ? "tilde" : "relative"} profile shares an absolute parent and child session root`, async () => {
1203
+ const agentHome = join(root, `${key}-${tilde}`, "agent");
1204
+ mkdirSync(join(agentHome, "agents"), { recursive: true });
1205
+ writeFileSync(join(agentHome, "agents", "relative.md"), "---\ndescription: relative profile\n---\nMap things.");
1206
+ writeFileSync(join(agentHome, "subagents.json"), JSON.stringify({ default_model: "openai/profile-model" }));
1207
+ const env = { [key]: tilde ? `~/${relative(homedir(), agentHome)}` : relative(process.cwd(), agentHome) };
1208
+ const harness = deps();
1209
+ const { home: _home, env: _env, ...overrides } = harness.deps;
1210
+ const { pi, tools, fire } = fakePi();
1211
+ gentleAgents(pi, env, overrides);
1212
+ const { ctx } = fakeContext();
1213
+ assert.notEqual(ctx.sessionManager.getCwd(), process.cwd());
1214
+ await fire("session_start", ctx);
1215
+ await tools.get("subagent_run")!.execute("relative", { agent: "relative", task: "Map", mode: "background" }, undefined, undefined, ctx);
1216
+ await tick();
1217
+ try {
1218
+ const args = harness.spawned[0];
1219
+ const sessionDir = args[args.indexOf("--session-dir") + 1];
1220
+ const expected = join(agentHome, "gentle-agents", "sessions");
1221
+ assert.equal(sessionDir, expected);
1222
+ assert.equal(resolve(ctx.sessionManager.getCwd(), sessionDir), expected);
1223
+ assert.equal(existsSync(expected), true, "parent created the exact child session root");
1224
+ assert.match(args[args.indexOf("--model") + 1], /profile-model/);
1225
+ } finally {
1226
+ await fire("session_shutdown", ctx);
1227
+ await tick();
1228
+ }
1229
+ });
1230
+ }
1231
+
1232
+ test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
1233
+ assert.equal(agentsEnabled({}), true);
1234
+ assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
1235
+ assert.equal(agentsEnabled({ GENTLE_PI_AGENTS_CHILD: "1" }), false);
1236
+ assert.equal(agentsCollapseKey({}), "ctrl+shift+a");
1237
+ assert.equal(agentsCollapseKey({ GENTLE_PI_AGENTS_KEY: "off" }), undefined);
1238
+ assert.equal(agentsViewKey({}), "alt+a");
1239
+ assert.equal(agentsViewKey({ GENTLE_PI_AGENTS_VIEW_KEY: "off" }), undefined);
1240
+ assert.equal(agentsStopKey({}), "alt+s");
1241
+ assert.equal(agentsStopKey({ GENTLE_PI_AGENTS_STOP_KEY: "" }), undefined);
1242
+ assert.equal(agentsStopKey({ GENTLE_PI_AGENTS_STOP_KEY: "off" }), undefined);
1243
+ const off = fakePi();
1244
+ gentleAgents(off.pi, { GENTLE_PI_AGENTS: "0" });
1245
+ assert.equal(off.tools.size, 0);
1246
+ });
1247
+
1248
+ test("while pi-subagents-j0k3r is still installed the tools stay unregistered and the user is told how to switch", async () => {
1249
+ const legacyHome = join(root, "legacy-home");
1250
+ mkdirSync(join(legacyHome, ".pi", "agent"), { recursive: true });
1251
+ writeFileSync(join(legacyHome, ".pi", "agent", "settings.json"), JSON.stringify({ packages: ["npm:pi-subagents-j0k3r", "../../work/gentle-pi"] }));
1252
+ assert.equal(legacySubagentsInstalled(legacyHome), true);
1253
+ assert.equal(legacySubagentsInstalled(home), false);
1254
+ assert.equal(legacySubagentsInstalled(join(root, "missing")), false);
1255
+ const { pi, tools, fire } = fakePi();
1256
+ gentleAgents(pi, {}, { ...deps().deps, home: legacyHome });
1257
+ assert.equal(tools.size, 0);
1258
+ const notices: string[] = [];
1259
+ const ctx = { hasUI: true, ui: { notify: (message: string, level: string) => notices.push(`${level}:${message}`) } } as unknown as ExtensionContext;
1260
+ await fire("session_start", ctx);
1261
+ assert.match(notices[0] ?? "", /^warning:❀ Gentle Agents is waiting: remove the old package first with "pi remove npm:pi-subagents-j0k3r"/);
1262
+ });
1263
+
1264
+ test("subagent_list_agents and subagent_run in task mode launch a child with the resolved profile and return its answer", async () => {
1265
+ const { pi, tools, fire } = fakePi();
1266
+ const harness = deps();
1267
+ gentleAgents(pi, {}, harness.deps);
1268
+ const { ctx, widget } = fakeContext();
1269
+ await fire("session_start", ctx);
1270
+ assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
1271
+ const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
1272
+ assert.match(listed.content[0].text, /- explore \(global\): maps things/);
1273
+
1274
+ 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);
1275
+ await tick();
1276
+ const [args] = harness.spawned;
1277
+ assert.equal(args[args.indexOf("--model") + 1], "openai-codex/gpt-5.6-terra:low", "the profile effort overrides the definition");
1278
+ assert.equal(args[args.indexOf("--tools") + 1], "read,grep,subagent_parent_message");
1279
+ await tick();
1280
+ assert.match(String(harness.children[0].written[1].message), /Map lib\/ and report every module\.\n\n## Context\nFocus on agents-\*\.ts/);
1281
+ assert.match(widget()![0], /^╭─ ❀ Agents · 1 active ─+╮$/);
1282
+ assert.match(widget()![1], /^│ ◐ explore map lib modules +gpt-5\.6-terra · low · \d+s │$/);
1283
+ harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
1284
+ harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
1285
+ await tick();
1286
+ assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · low · 12k · \$0\.09 · \d+s │$/);
1287
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
1288
+ harness.children[0].emit({ type: "agent_settled" });
1289
+ const result = await running;
1290
+ assert.equal(result.content[0].text, "lib has three agent files.");
1291
+ assert.equal((result.details.gentleAgents as { status: string }).status, "completed");
1292
+ assert.match(widget()![1], /✓ explore map lib modules/);
1293
+ const orphan = tools.get("subagent_run")!.execute("c9", { agent: "explore", task: "Orphan", mode: "background" }, undefined, undefined, ctx);
1294
+ await orphan;
1295
+ await tick();
1296
+ await fire("session_shutdown", ctx);
1297
+ await tick();
1298
+ assert.deepEqual(harness.children[1].killed, ["SIGTERM"], "closing pi stops the running children");
1299
+ assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
1300
+ });
1301
+
1302
+ test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
1303
+ const { pi, tools, fire, sent, renderers } = fakePi();
1304
+ const harness = deps();
1305
+ gentleAgents(pi, {}, harness.deps);
1306
+ const { ctx } = fakeContext();
1307
+ await fire("session_start", ctx);
1308
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
1309
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
1310
+ assert.match(started.content[0].text, new RegExp(`background as task ${id}`));
1311
+ await tick();
1312
+ assert.match((await tools.get("subagent_status")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /running · background/);
1313
+ assert.match((await tools.get("subagent_result")!.execute("c3", { task_id: id }, undefined, undefined, ctx)).content[0].text, /still running/);
1314
+ assert.match((await tools.get("subagent_send_message")!.execute("c4", { task_id: id, message: "Skip tests" }, undefined, undefined, ctx)).content[0].text, /queued/);
1315
+ await tick();
1316
+ assert.equal(harness.children[0].written.at(-1)?.message, "Skip tests");
1317
+ assert.match((await tools.get("subagent_continue")!.execute("c5", { task_id: id, prompt: "more" }, undefined, undefined, ctx)).content[0].text, /cannot be continued yet/);
1318
+ assert.match((await tools.get("subagent_list_tasks")!.execute("c6", {}, undefined, undefined, ctx)).content[0].text, new RegExp(`^${id} · explore · running`));
1319
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "All done." }] }] });
1320
+ harness.children[0].emit({ type: "agent_settled" });
1321
+ await tick();
1322
+ assert.equal((await tools.get("subagent_result")!.execute("c7", { task_id: id }, undefined, undefined, ctx)).content[0].text, "All done.");
1323
+ assert.equal(sent.length, 1, "a background result is delivered to the model once");
1324
+ assert.equal(sent[0].message.customType, "gentle-agents.result");
1325
+ assert.equal(sent[0].message.display, true, "completion cards remain visible");
1326
+ // The completion path must never regress to "followUp": the host drains the
1327
+ // follow-up queue only when the parent run stops calling tools, which is the
1328
+ // hour-long #867 delay. "steer" keeps the idle wake-up (triggerTurn) while
1329
+ // bounding an active parent's wait to the current turn.
1330
+ assert.deepEqual(sent[0].options, { deliverAs: "steer", triggerTurn: true });
1331
+ assert.match(String(sent[0].message.content), new RegExp(`^Subagent explore \\(task ${id}, "Long job"\\) finished\\.\n\nAll done\\.$`));
1332
+ const card = renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(70).map(stripAnsi);
1333
+ assert.match(card[0], /^╭─ ❀ Agent result · explore ─+ collapse ╮$/);
1334
+ assert.match(card[1], /Subagent explore/);
1335
+ assert.match(card[card.length - 2], /All done\./);
1336
+ const resumed = tools.get("subagent_continue")!.execute("c8", { task_id: id, prompt: "Now summarize", mode: "task" }, undefined, undefined, ctx);
1337
+ await tick();
1338
+ const args = harness.spawned[1];
1339
+ assert.equal(args[args.indexOf("--session") + 1], "/sessions/child.jsonl");
1340
+ await tick();
1341
+ harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Summary." }] }] });
1342
+ harness.children[1].emit({ type: "agent_settled" });
1343
+ assert.equal((await resumed).content[0].text, "Summary.");
1344
+ assert.match((await tools.get("subagent_cancel")!.execute("c9", { task_id: id }, undefined, undefined, ctx)).content[0].text, /not running/);
1345
+ assert.match((await tools.get("subagent_status")!.execute("c10", { task_id: "nope" }, undefined, undefined, ctx)).content[0].text, /Error: no task nope/);
1346
+ 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/);
1347
+ });
1348
+
1349
+ 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 () => {
1350
+ const { pi, tools, fire } = fakePi();
1351
+ const harness = deps();
1352
+ let clock = 1000;
1353
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
1354
+ harness.deps.now = () => clock;
1355
+ harness.deps.schedule = (fn, ms) => {
1356
+ const timer = { fn, ms, cancelled: false };
1357
+ timers.push(timer);
1358
+ return () => {
1359
+ timer.cancelled = true;
1360
+ };
1361
+ };
1362
+ gentleAgents(pi, {}, harness.deps);
1363
+ let frames = 0;
1364
+ const { ctx, widget } = fakeContext({ requestRender: () => (frames += 1) });
1365
+ await fire("session_start", ctx);
1366
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Short job", mode: "background" }, undefined, undefined, ctx);
1367
+ await tick();
1368
+ widget();
1369
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done." }] }] });
1370
+ harness.children[0].emit({ type: "agent_settled" });
1371
+ await tick();
1372
+ assert.match(widget()![1], /✓ explore Short job/);
1373
+ const expiry = timers.filter((timer) => !timer.cancelled && timer.ms === 60_000);
1374
+ assert.equal(expiry.length, 1, "exactly one timer waits for the finished row to leave the card");
1375
+ clock += 60_000;
1376
+ const before = frames;
1377
+ expiry[0].fn();
1378
+ assert.equal(frames, before + 1, "the expiry asks the terminal for a frame");
1379
+ assert.deepEqual(widget(), [], "the card is gone");
1380
+ assert.equal(timers.filter((timer) => !timer.cancelled && timer.ms === 60_000).length, 0, "nothing is rescheduled once the card is empty");
1381
+ });
1382
+
1383
+ test("completionText names the outcome before the answer", () => {
1384
+ 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 };
1385
+ assert.equal(completionText(base), 'Subagent explore (task t1, "map lib") failed.\n\nSubagent explore failed: pi exited with code 1');
1386
+ 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');
1387
+ });
1388
+
1389
+ test("a task-mode child's dialog reaches the host UI and the answer goes back to the child", async () => {
1390
+ const { pi, tools, fire } = fakePi();
1391
+ const harness = deps();
1392
+ gentleAgents(pi, {}, harness.deps);
1393
+ const { ctx, dialogs, widget } = fakeContext();
1394
+ await fire("session_start", ctx);
1395
+ const running = tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ask me" }, undefined, undefined, ctx);
1396
+ await tick();
1397
+ harness.children[0].emit({ type: "extension_ui_request", id: "u1", method: "select", title: "Which file?", options: ["a.ts", "b.ts"] });
1398
+ await tick();
1399
+ await tick();
1400
+ assert.deepEqual(dialogs, ["select:❀ Which file?:a.ts|b.ts"]);
1401
+ assert.deepEqual(harness.children[0].written.at(-1), { type: "extension_ui_response", id: "u1", value: "a.ts" });
1402
+ assert.match(widget()![1], /◐ explore Ask me/);
1403
+ harness.children[0].emit({ type: "agent_end", messages: [] });
1404
+ harness.children[0].emit({ type: "agent_settled" });
1405
+ await running;
1406
+ assert.deepEqual(await answerThroughUi(ctx.ui, { id: "u2", method: "confirm", title: "Sure?" }, { message: "really" }), { confirmed: true });
1407
+ assert.deepEqual(await answerThroughUi(ctx.ui, { id: "u3", method: "input", title: "Name" }, {}), { cancelled: true });
1408
+ assert.deepEqual(await answerThroughUi(ctx.ui, { id: "u4", method: "editor", title: "Edit" }, {}), { value: "edited" });
1409
+ assert.deepEqual(await answerThroughUi(undefined, { id: "u5", method: "select", title: "x" }, {}), { cancelled: true });
1410
+ });
1411
+
1412
+ test("AgentsView production composition observes each pointer event once and accepts only left clicks", async () => {
1413
+ const { pi, tools, fire, commands } = fakePi();
1414
+ const harness = deps();
1415
+ gentleAgents(pi, {}, harness.deps);
1416
+ const { ctx, overlays, customCompletions } = fakeContext();
1417
+ await fire("session_start", ctx);
1418
+
1419
+ await tools.get("subagent_run")!.execute("b", { agent: "explore", task: "b", mode: "background" }, undefined, undefined, ctx);
1420
+ await tick();
1421
+ await tools.get("subagent_run")!.execute("a", { agent: "explore", task: "a", mode: "background" }, undefined, undefined, ctx);
1422
+ await tick();
1423
+
1424
+ const originalCreateMouseObserver = NativePointerScope.prototype.createMouseObserver;
1425
+ let beforeCalls = 0;
1426
+ let afterCalls = 0;
1427
+ const spy = mock.method(NativePointerScope.prototype, "createMouseObserver", function (
1428
+ this: NativePointerScope,
1429
+ requestRender?: () => void,
1430
+ ) {
1431
+ const observer = originalCreateMouseObserver.call(this, requestRender);
1432
+ return {
1433
+ beforeMouse(event: TuiMouseEvent) {
1434
+ beforeCalls += 1;
1435
+ observer.beforeMouse(event);
1436
+ },
1437
+ afterMouse(event: TuiMouseEvent) {
1438
+ afterCalls += 1;
1439
+ observer.afterMouse(event);
1440
+ },
1441
+ };
1442
+ });
1443
+ try {
1444
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1445
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1446
+ const overlay = overlays[0];
1447
+ assert.ok(overlay, "the production extension mounted its fullscreen interaction");
1448
+ overlay.handleInput("a"); // Directory headings remain non-actionable; Current has no wrapper.
1449
+ const lines = overlay.render(80);
1450
+ const dispatch = (event: TuiMouseEvent) => {
1451
+ const before = beforeCalls;
1452
+ const after = afterCalls;
1453
+ const result = overlay.handleMouse?.(event);
1454
+ assert.deepEqual([beforeCalls - before, afterCalls - after], [1, 1], "the root owns one observer lifecycle per event");
1455
+ return result;
1456
+ };
1457
+
1458
+ assert.match(stripAnsi(overlay.render(80)[1] ?? ""), /Current orchestrator/, "the parent heading is the first visible row");
1459
+ assert.doesNotMatch(stripAnsi(overlay.render(80)[1] ?? ""), /▸/, "the heading is not a selected task");
1460
+ assert.match(stripAnsi(overlay.render(80)[2] ?? ""), /▸ └ .*Subagent/, "the first child starts selected beneath its heading");
1461
+ overlay.handleInput("k");
1462
+ overlay.handleInput("s");
1463
+ overlay.handleInput("o");
1464
+ assert.deepEqual(harness.children.map((child) => child.killed), [[], []], "heading actions never stop a child");
1465
+ assert.deepEqual(customCompletions, [], "heading actions never open a child session");
1466
+ overlay.handleInput("j");
1467
+ assert.equal(dispatch(mouse("click", "right", 4, 3, 80, lines.length)), undefined, "right click is inert");
1468
+ assert.match(stripAnsi(overlay.render(80)[2] ?? ""), /▸ └ .*Subagent/, "right click cannot select another child");
1469
+ assert.equal(dispatch(mouse("press", "left", 4, 3, 80, lines.length)), undefined, "press is inert");
1470
+ assert.equal(dispatch(mouse("click", "middle", 4, 3, 80, lines.length)), undefined, "middle click is inert");
1471
+ const leftClick = dispatch(mouse("click", "left", 4, 3, 80, lines.length));
1472
+ assert.equal((leftClick as { handled?: boolean } | undefined)?.handled, true, "left click selects a child task");
1473
+ assert.match(stripAnsi(overlay.render(80)[3] ?? ""), /▸ └ .*Subagent/, "left click selects the second child");
1474
+ overlay.handleInput("\x1b");
1475
+ await opened;
1476
+ } finally {
1477
+ spy.mock.restore();
1478
+ }
1479
+ });
1480
+
1481
+ test("AgentsView production footer uses rendered bounds and invalidates them before the next frame", async () => {
1482
+ writeFileSync(join(home, ".pi", "agent", "agents", "寿司.md"), "---\ndescription: unicode footer target\nmodel: openai-codex/gpt-5.6-terra\n---\nFooter target.");
1483
+ const { pi, tools, fire, commands } = fakePi();
1484
+ const harness = deps();
1485
+ gentleAgents(pi, {}, harness.deps);
1486
+ const { ctx, overlays, customCompletions } = fakeContext();
1487
+ (ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } }).sessionManager = { getSessionId: () => "footer-session", getCwd: () => cwd };
1488
+ await fire("session_start", ctx);
1489
+ await tools.get("subagent_run")!.execute("c1", { agent: "寿司", task: "Footer target", mode: "background" }, undefined, undefined, ctx);
1490
+ await tick();
1491
+
1492
+ let store: TaskStore | undefined;
1493
+ const subscribeSummary = TaskStore.prototype.subscribeSummary;
1494
+ const captureStore = mock.method(TaskStore.prototype, "subscribeSummary", function (this: TaskStore, ...args: Parameters<TaskStore["subscribeSummary"]>) {
1495
+ store ??= this;
1496
+ return subscribeSummary.apply(this, args);
1497
+ });
1498
+ try {
1499
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1500
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1501
+ const overlay = overlays[0];
1502
+ assert.ok(overlay, "the production extension mounted its fullscreen interaction");
1503
+ assert.ok(store, "the overlay subscribed to the production task store");
1504
+ const selected = store.list().find((entry) => entry.parentSessionId === "footer-session");
1505
+ assert.ok(selected?.sessionPath, "the selected unicode task initially has an openable session");
1506
+ for (let index = 0; index < 12; index += 1) store.apply(selected.id, { type: TASK_EVENT.TEXT, text: `line ${index}\n` }, 2000 + index);
1507
+
1508
+ const buttons = (lines: string[], label: string) => {
1509
+ const footer = lines.at(-2) ?? "";
1510
+ const start = footer.indexOf(label);
1511
+ assert.ok(start > 0, `the roomy footer exposes ${label}`);
1512
+ return { x: visibleWidth(footer.slice(0, start)), y: lines.length - 2 };
1513
+ };
1514
+ let lines = overlay.render(160).map(stripAnsi);
1515
+ let follow = buttons(lines, "[ Follow ]");
1516
+ let open = buttons(lines, "[ Open session ]");
1517
+ assert.match(lines[1] ?? "", /寿司/, "a unicode task remains inside the body, not the header or footer");
1518
+ assert.match(lines[follow.y] ?? "", /s Stop selected.*a all sessions/, "the existing stop and scope shortcuts remain beside the footer buttons");
1519
+ assert.match(overlay.render(59).map(stripAnsi).at(-2) ?? "", /\[Scope\]/, "the narrow list keeps mouse-accessible scope controls");
1520
+ lines = overlay.render(160).map(stripAnsi);
1521
+ follow = buttons(lines, "[ Follow ]");
1522
+ open = buttons(lines, "[ Open session ]");
1523
+ assert.equal(overlay.handleMouse?.(mouse("click", "left", follow.x, 0, 160, lines.length)), undefined, "header coordinates never route to a footer button");
1524
+ assert.equal(overlay.handleMouse?.(mouse("click", "left", follow.x, follow.y - 1, 160, lines.length)), undefined, "body coordinates never route to a footer button");
1525
+ assert.equal(overlay.handleMouse?.(mouse("press", "left", follow.x, follow.y, 160, lines.length)), undefined, "press is inert");
1526
+ assert.equal(overlay.handleMouse?.(mouse("click", "right", follow.x, follow.y, 160, lines.length)), undefined, "right click is inert");
1527
+ assert.equal(overlay.handleMouse?.(mouse("click", "middle", follow.x, follow.y, 160, lines.length)), undefined, "middle click is inert");
1528
+ 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");
1529
+
1530
+ overlay.handleInput("\x1b[5~");
1531
+ assert.equal((overlay.handleMouse?.(mouse("click", "left", follow.x, follow.y, 160, lines.length)) as { handled?: boolean } | undefined)?.handled, true, "Follow restores tail tracking");
1532
+ 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");
1533
+ store.apply(selected.id, { type: TASK_EVENT.TEXT, text: "line 12\n" }, 2012);
1534
+ lines = overlay.render(160).map(stripAnsi);
1535
+ assert.ok(lines.some((line) => /line 12/.test(line)), "Follow keeps the stream at its tail after manual scrolling");
1536
+ follow = buttons(lines, "[ Follow ]");
1537
+ open = buttons(lines, "[ Open session ]");
1538
+ 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");
1539
+ assert.equal(customCompletions.length, 1, "Open completes the actual ui.custom callback exactly once");
1540
+ const openedTask = customCompletions[0] as TaskRecord | undefined;
1541
+ assert.equal(openedTask?.id, selected.id, "Open completes ui.custom with the selected task before Escape");
1542
+ assert.equal(openedTask?.sessionPath, selected.sessionPath, "Open preserves the selected task's openable session in the ui.custom result");
1543
+
1544
+ store.update(selected.id, { sessionPath: null });
1545
+ 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");
1546
+ lines = overlay.render(160).map(stripAnsi);
1547
+ follow = buttons(lines, "[ Follow ]");
1548
+ overlay.handleInput("a");
1549
+ 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");
1550
+ overlay.handleInput("\x1b");
1551
+ await opened;
1552
+ } finally {
1553
+ captureStore.mock.restore();
1554
+ }
1555
+ });
1556
+
1557
+ test("finished tasks remain available through resolveTask but never reappear in the live-only overlay", async () => {
1558
+ const { pi, tools, fire, commands, shortcuts } = fakePi();
1559
+ const harness = deps();
1560
+ gentleAgents(pi, {}, harness.deps);
1561
+ const { ctx, overlays } = fakeContext();
1562
+ await fire("session_start", ctx);
1563
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Persist me", mode: "background" }, undefined, undefined, ctx);
1564
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
1565
+ await tick();
1566
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Kept." }] }] });
1567
+ harness.children[0].emit({ type: "agent_settled" });
1568
+ await tick();
1569
+ const tasksDir = join(home, ".pi", "agent", "gentle-agents", "tasks");
1570
+ let stored = await loadHistory(tasksDir);
1571
+ for (let attempt = 0; attempt < 40 && !stored.some((entry) => entry.task.id === id); attempt += 1) {
1572
+ await new Promise((resolve) => setTimeout(resolve, 50));
1573
+ stored = await loadHistory(tasksDir);
1574
+ }
1575
+ assert.ok(stored.some((entry) => entry.task.id === id && entry.task.result === "Kept."), "the finished task is on disk");
1576
+
1577
+ const fresh = fakePi();
1578
+ gentleAgents(fresh.pi, {}, deps().deps);
1579
+ const again = fakeContext();
1580
+ await fresh.fire("session_start", again.ctx);
1581
+ assert.equal((await fresh.tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, again.ctx)).content[0].text, "Kept.");
1582
+
1583
+ assert.ok(commands.has("gentle:agents") && shortcuts.has("alt+a"));
1584
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1585
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1586
+ const overlay = overlays[0];
1587
+ assert.ok(overlay, "the overlay component was created");
1588
+ assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /finished|Subagent explore|Current orchestrator/);
1589
+ overlay.handleInput("a");
1590
+ overlay.handleInput("\x1b[C");
1591
+ assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /✓ Subagent explore/, "all sessions is not a historical-task browser");
1592
+ overlay.handleInput("\x1b");
1593
+ await opened;
1594
+ });
1595
+
1596
+ test("the overlay confirms a running task once and reports when it finishes during confirmation", async () => {
1597
+ const { pi, tools, fire, commands, sent } = fakePi();
1598
+ const harness = deps();
1599
+ const modalHome = join(root, "modal-home");
1600
+ mkdirSync(join(modalHome, ".pi", "agent", "agents"), { recursive: true });
1601
+ writeFileSync(join(modalHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\nmodel: openai-codex/gpt-5.6-terra\n---\nYou map things.");
1602
+ writeFileSync(join(modalHome, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2 }));
1603
+ harness.deps.home = modalHome;
1604
+ let answerConfirmation: (confirmed: boolean) => void = () => {};
1605
+ gentleAgents(pi, {}, harness.deps);
1606
+ const { ctx, dialogs, overlays } = fakeContext(fakeTui, () => new Promise((resolve) => {
1607
+ answerConfirmation = resolve;
1608
+ }));
1609
+ await fire("session_start", ctx);
1610
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Race", mode: "background" }, undefined, undefined, ctx);
1611
+ await tick();
1612
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1613
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1614
+ const overlay = overlays[0];
1615
+ assert.ok(overlay, "the overlay component was created");
1616
+ overlay.handleInput("s");
1617
+ overlay.handleInput("c");
1618
+ await tick();
1619
+ assert.deepEqual(dialogs, ["confirm:Stop explore?:Current work may be incomplete."], "s and its compatibility alias share one confirmation");
1620
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Finished first." }] }] });
1621
+ harness.children[0].emit({ type: "agent_settled" });
1622
+ await tick();
1623
+ answerConfirmation(true);
1624
+ await tick();
1625
+ assert.match(dialogs.at(-1) ?? "", /^notify:Task explore already finished\.$/);
1626
+ assert.equal(sent.length, 1, "a normal completion during confirmation still reaches the parent");
1627
+ overlay.handleInput("\x1b");
1628
+ await opened;
1629
+ });
1630
+
1631
+ test("the overlay stops a queued selection immediately without confirmation", async () => {
1632
+ const { pi, tools, fire, commands } = fakePi();
1633
+ const harness = deps();
1634
+ const queueHome = join(root, "queue-home");
1635
+ mkdirSync(join(queueHome, ".pi", "agent", "agents"), { recursive: true });
1636
+ writeFileSync(join(queueHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\n---\nYou map things.");
1637
+ writeFileSync(join(queueHome, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 1 }));
1638
+ harness.deps.home = queueHome;
1639
+ gentleAgents(pi, {}, harness.deps);
1640
+ const { ctx, dialogs, overlays } = fakeContext();
1641
+ await fire("session_start", ctx);
1642
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "First", mode: "background" }, undefined, undefined, ctx);
1643
+ await tick();
1644
+ const queued = await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Queued", mode: "background" }, undefined, undefined, ctx);
1645
+ const queuedId = (queued.details.gentleAgents as { taskId: string }).taskId;
1646
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1647
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1648
+ overlays[0]!.handleInput("s");
1649
+ await tick();
1650
+ assert.deepEqual(dialogs, ["notify:Stopped explore."], "queued work stops without confirmation");
1651
+ assert.match((await tools.get("subagent_status")!.execute("c3", { task_id: queuedId }, undefined, undefined, ctx)).content[0].text, /cancelled/);
1652
+ overlays[0]!.handleInput("\x1b");
1653
+ await opened;
1654
+ });
1655
+
1656
+ test("the overlay explains that stopping a waiting subagent dismisses its question", async () => {
1657
+ const { pi, tools, fire, commands } = fakePi();
1658
+ const harness = deps();
1659
+ const waitingHome = join(root, "waiting-home");
1660
+ mkdirSync(join(waitingHome, ".pi", "agent", "agents"), { recursive: true });
1661
+ writeFileSync(join(waitingHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\n---\nYou map things.");
1662
+ harness.deps.home = waitingHome;
1663
+ let answerInput: (value: string | undefined) => void = () => {};
1664
+ gentleAgents(pi, {}, harness.deps);
1665
+ const { ctx, dialogs, overlays } = fakeContext(fakeTui, async () => true, () => new Promise<string | undefined>((resolve) => {
1666
+ answerInput = resolve;
1667
+ }));
1668
+ await fire("session_start", ctx);
1669
+ const running = tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ask", mode: "task" }, undefined, undefined, ctx);
1670
+ await tick();
1671
+ harness.children[0].emit({ type: "extension_ui_request", id: "wait", method: "input", title: "Need input" });
1672
+ await tick();
1673
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1674
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1675
+ overlays[0]!.handleInput("s");
1676
+ await tick();
1677
+ assert.ok(dialogs.includes("confirm:Stop explore?:Its pending question will be dismissed."));
1678
+ assert.match((await running).content[0].text, /cancelled/);
1679
+ answerInput(undefined);
1680
+ overlays[0]!.handleInput("\x1b");
1681
+ await opened;
1682
+ });
1683
+
1684
+ test("restored task history cannot enter the live panel or execute stop even with the current session ID", async () => {
1685
+ const { pi, fire, commands, tools } = fakePi();
1686
+ const harness = deps();
1687
+ const historyHome = join(root, "history-home");
1688
+ const historical: TaskRecord = { id: "history-running", agent: "explore", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "s1", 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 };
1689
+ await saveTask(historyDir(historyHome), historical, emptyThread());
1690
+ harness.deps.home = historyHome;
1691
+ gentleAgents(pi, {}, harness.deps);
1692
+ const { ctx, dialogs, overlays } = fakeContext();
1693
+ await fire("session_start", ctx);
1694
+ await tools.get("subagent_status")!.execute("restore", { task_id: historical.id }, undefined, undefined, ctx);
1695
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1696
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1697
+ assert.doesNotMatch(stripAnsi(overlays[0]!.render(80).join("\n")), /Stop selected|Subagent explore/);
1698
+ overlays[0]!.handleInput("s");
1699
+ overlays[0]!.handleInput("c");
1700
+ await tick();
1701
+ assert.deepEqual(dialogs, []);
1702
+ overlays[0]!.handleInput("\x1b");
1703
+ await opened;
1704
+ });
1705
+
1706
+ test("Alt+S confirms a snapshot of active subagents and suppresses their follow-up delivery", async () => {
1707
+ const { pi, tools, fire, shortcuts, sent } = fakePi();
1708
+ const harness = deps();
1709
+ let answerConfirmation: (confirmed: boolean) => void = () => {};
1710
+ gentleAgents(pi, {}, harness.deps);
1711
+ const { ctx, dialogs } = fakeContext(fakeTui, () => new Promise<boolean>((resolve) => {
1712
+ answerConfirmation = resolve;
1713
+ }));
1714
+ await fire("session_start", ctx);
1715
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "First", mode: "background" }, undefined, undefined, ctx);
1716
+ await tick();
1717
+ const shortcut = shortcuts.get("alt+s");
1718
+ assert.ok(shortcut, "Alt+S is registered by default");
1719
+ assert.equal(shortcut.description, "Stop active subagent(s)");
1720
+ const stopping = shortcut.handler(ctx);
1721
+ await tick();
1722
+ await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Second", mode: "background" }, undefined, undefined, ctx);
1723
+ await tick();
1724
+ answerConfirmation(true);
1725
+ await stopping;
1726
+ assert.deepEqual(dialogs, ["confirm:Stop 1 active subagent?:Only these 1 subagent will stop. Current work may be incomplete.", "notify:Stopped 1 subagent."]);
1727
+ assert.equal(sent.length, 0, "intentional cancellation does not start a follow-up turn");
1728
+ assert.match((await tools.get("subagent_list_tasks")!.execute("c3", {}, undefined, undefined, ctx)).content[0].text, /running/, "a subagent started during confirmation remains active");
1729
+ const secondConfirmation = shortcut.handler(ctx);
1730
+ await tick();
1731
+ assert.match(dialogs.at(-1) ?? "", /^confirm:Stop 1 active subagent\?/);
1732
+ answerConfirmation(false);
1733
+ await secondConfirmation;
1734
+ await fire("session_shutdown", ctx);
1735
+ });
1736
+
1737
+ test("the card follows the active session: after /new the earlier session's tasks leave it, and come back on /resume", async () => {
1738
+ const { pi, tools, commands, fire } = fakePi();
1739
+ const harness = deps();
1740
+ gentleAgents(pi, {}, harness.deps);
1741
+ const { ctx, widget, overlays } = fakeContext();
1742
+ await fire("session_start", ctx);
1743
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
1744
+ await tick();
1745
+ assert.match(widget()![1], /◐ explore Long job/);
1746
+ const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } };
1747
+ sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd };
1748
+ await fire("session_start", ctx, { type: "session_start", reason: "new" });
1749
+ assert.deepEqual(widget(), [], "the new session starts with an empty card");
1750
+ assert.match((await tools.get("subagent_list_tasks")!.execute("c2", {}, undefined, undefined, ctx)).content[0].text, /No subagent tasks in this session/);
1751
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1752
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1753
+ const overlay = overlays[0]!;
1754
+ assert.match(stripAnsi(overlay.render(80)[0]), /this session · 0 active/, "the overlay opens on the active session");
1755
+ overlay.handleInput("a");
1756
+ assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /◐ Subagent explore/, "retained children of a replaced session do not imply an open orchestrator");
1757
+ overlay.handleInput("\x1b");
1758
+ await opened;
1759
+ sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd };
1760
+ await fire("session_start", ctx, { type: "session_start", reason: "resume" });
1761
+ assert.match(widget()![1], /◐ explore Long job/, "resuming the first session shows its task again");
1762
+ });
1763
+
1764
+ test("the card caps its rows to the terminal height and says how many tasks are hidden", async () => {
1765
+ const { pi, tools, fire } = fakePi();
1766
+ const harness = deps();
1767
+ gentleAgents(pi, {}, harness.deps);
1768
+ const { ctx, widget } = fakeContext({ requestRender() {}, terminal: { rows: 20 } } as { requestRender(): void });
1769
+ await fire("session_start", ctx);
1770
+ 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);
1771
+ await tick();
1772
+ const card = widget()!;
1773
+ 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");
1774
+ assert.match(card[0], /2 active · 4 queued/);
1775
+ assert.match(card[5], /^│ … 2 more · alt\+a to view +│$/);
1776
+ });
1777
+
1778
+ test("the production overlay reads terminal rows at render time without a minimum-height override", async () => {
1779
+ const { pi, fire, commands } = fakePi();
1780
+ const harness = deps();
1781
+ gentleAgents(pi, {}, harness.deps);
1782
+ let rows = 10;
1783
+ const overlayTui = { terminal: { get rows() { return rows; } }, requestRender() {} };
1784
+ const { ctx, overlays, customOptions } = fakeContext(fakeTui, async () => true, async () => undefined, overlayTui);
1785
+ await fire("session_start", ctx);
1786
+ const opened = commands.get("gentle:agents")!.handler("", ctx);
1787
+ for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
1788
+ const overlay = overlays[0]!;
1789
+ assert.deepEqual(customOptions[0], { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } });
1790
+ assert.equal(overlay.render(80).length, 10, "the overlay uses the full terminal height");
1791
+ rows = 5;
1792
+ assert.equal(overlay.render(80).length, 5, "a live terminal resize changes the production frame budget");
1793
+ rows = 2;
1794
+ assert.equal(overlay.render(80).length, 1, "tiny terminals retain bounded controls rather than forced chrome");
1795
+ overlay.handleInput("\x1b");
1796
+ await opened;
1797
+ });
1798
+
1799
+ // Issue #867: a completion settling while the parent agent run is active must
1800
+ // be held by the extension and flushed at the next turn boundary, not parked
1801
+ // in the host's followUp queue until the whole orchestrator run stops calling
1802
+ // tools.
1803
+ test("a background completion settling while the parent agent runs is delivered exactly once at the next turn end", async () => {
1804
+ const { pi, tools, fire, sent } = fakePi();
1805
+ const harness = deps();
1806
+ gentleAgents(pi, {}, harness.deps);
1807
+ const { ctx } = fakeContext();
1808
+ await fire("session_start", ctx);
1809
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Chained turns", mode: "background" }, undefined, undefined, ctx);
1810
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
1811
+ await tick();
1812
+ await fire("agent_start", ctx);
1813
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Chained done." }] }] });
1814
+ harness.children[0].emit({ type: "agent_settled" });
1815
+ await tick();
1816
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "nothing enters the conversation while the parent agent run is active");
1817
+ await fire("turn_end", ctx);
1818
+ const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
1819
+ assert.equal(results.length, 1, "the held completion is delivered exactly once at turn_end");
1820
+ assert.match(String(results[0]!.message.content), new RegExp(`task ${id}, "Chained turns"`));
1821
+ // Pins the delivery mode against the host's drain semantics: "followUp" is
1822
+ // drained by the run loop only in its stop branch, so a parent that keeps
1823
+ // calling tools would see the completion when the whole run ends. "steer" is
1824
+ // polled every turn and injected before the next LLM call, bounding the wait
1825
+ // to the current turn.
1826
+ assert.deepEqual(results[0]!.options, { deliverAs: "steer", triggerTurn: true });
1827
+ await fire("turn_end", ctx);
1828
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1, "a later turn_end never replays the completion");
1829
+ await fire("session_shutdown", ctx);
1830
+ });
1831
+
1832
+ test("a completion held past the stale window becomes transcript-only content and never re-enters the conversation", async () => {
1833
+ const { pi, tools, fire, sent, entries, entryRenderers } = fakePi();
1834
+ const harness = deps();
1835
+ let clock = 1000;
1836
+ harness.deps.now = () => clock;
1837
+ gentleAgents(pi, {}, harness.deps);
1838
+ const { ctx } = fakeContext();
1839
+ await fire("session_start", ctx);
1840
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Slow orchestrator", mode: "background" }, undefined, undefined, ctx);
1841
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
1842
+ await tick();
1843
+ await fire("agent_start", ctx);
1844
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Late answer." }] }] });
1845
+ harness.children[0].emit({ type: "agent_settled" });
1846
+ await tick();
1847
+ clock += STALE_COMPLETION_MS + 1_000;
1848
+ await fire("turn_end", ctx);
1849
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a stale completion never enters the model context");
1850
+ const stale = entries.filter((entry) => entry.customType === "gentle-agents.stale-result");
1851
+ assert.equal(stale.length, 1, "the human still sees the stale completion as durable transcript content");
1852
+ assert.match(JSON.stringify(stale[0]!.data), new RegExp(id), "the stale notice names the task");
1853
+ const rendered = entryRenderers.get("gentle-agents.stale-result")!(stale[0]!, { expanded: true }, plainTheme).render(90).map(stripAnsi).join("\n");
1854
+ assert.match(rendered, /stale/i);
1855
+ assert.match(rendered, new RegExp(id));
1856
+ assert.match(rendered, /explore/);
1857
+ assert.match(rendered, /ago/);
1858
+ await fire("session_shutdown", ctx);
1859
+ });
1860
+
1861
+ test("a completion the parent already pulled is dropped silently at the next turn end", async () => {
1862
+ const { pi, tools, fire, sent } = fakePi();
1863
+ const harness = deps();
1864
+ gentleAgents(pi, {}, harness.deps);
1865
+ const { ctx } = fakeContext();
1866
+ await fire("session_start", ctx);
1867
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Pulled early", mode: "background" }, undefined, undefined, ctx);
1868
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
1869
+ await tick();
1870
+ await fire("agent_start", ctx);
1871
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Pulled answer." }] }] });
1872
+ harness.children[0].emit({ type: "agent_settled" });
1873
+ await tick();
1874
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
1875
+ assert.match((await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /Pulled answer\./);
1876
+ await fire("turn_end", ctx);
1877
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a consumed completion is dropped instead of replayed");
1878
+ await fire("session_shutdown", ctx);
1879
+ });
1880
+
1881
+ test("a session restart never replays a completion still pending from before it", async () => {
1882
+ const { pi, tools, fire, sent } = fakePi();
1883
+ const harness = deps();
1884
+ gentleAgents(pi, {}, harness.deps);
1885
+ const { ctx } = fakeContext();
1886
+ await fire("session_start", ctx);
1887
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Restarted", mode: "background" }, undefined, undefined, ctx);
1888
+ await tick();
1889
+ await fire("agent_start", ctx);
1890
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Unclaimed answer." }] }] });
1891
+ harness.children[0].emit({ type: "agent_settled" });
1892
+ await tick();
1893
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
1894
+ await fire("session_start", ctx, { reason: "resume" });
1895
+ await fire("turn_end", ctx);
1896
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a resumed session starts with an empty completion queue");
1897
+ await fire("session_shutdown", ctx);
1898
+ });
1899
+
1900
+ test("a background completion owned by a prior session is dropped, never delivered into the current session", async () => {
1901
+ const { pi, tools, fire, sent, entries } = fakePi();
1902
+ const harness = deps();
1903
+ gentleAgents(pi, {}, harness.deps);
1904
+ const { ctx } = fakeContext();
1905
+ await fire("session_start", ctx);
1906
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Cross session", mode: "background" }, undefined, undefined, ctx);
1907
+ await tick();
1908
+ (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2";
1909
+ await fire("session_start", ctx, { type: "session_start", reason: "new" });
1910
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Cross answer." }] }] });
1911
+ harness.children[0].emit({ type: "agent_settled" });
1912
+ await tick();
1913
+ await fire("turn_end", ctx);
1914
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "the replacement session receives no completion it does not own");
1915
+ assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 0);
1916
+ await fire("session_shutdown", ctx);
1917
+ });
1918
+
1919
+ test("aborting the caller's signal cancels the subagent, records it, and says why", async () => {
1920
+ const { pi, tools, fire } = fakePi();
1921
+ const harness = deps();
1922
+ gentleAgents(pi, {}, harness.deps);
1923
+ const { ctx, dialogs } = fakeContext();
1924
+ await fire("session_start", ctx);
1925
+ const controller = new AbortController();
1926
+ const pending = tools.get("subagent_run")!.execute("abort", { agent: "explore", task: "keep working" }, controller.signal, undefined, ctx);
1927
+ await tick();
1928
+ controller.abort();
1929
+ await tick();
1930
+ const yielded = await pending;
1931
+ const details = (yielded.details as { gentleAgents?: { taskId: string; status: string } }).gentleAgents!;
1932
+ assert.equal(details.status, "cancelled", "the run is recorded as cancelled");
1933
+ assert.match((yielded as { content: Array<{ text: string }> }).content[0].text, /cancelled/);
1934
+ assert.ok(
1935
+ dialogs.some((entry) => entry.startsWith("notify:") && /cancelled/.test(entry) && /tool call was aborted/.test(entry)),
1936
+ "a warning names the abort and the cancellation",
1937
+ );
1938
+ assert.equal(harness.children[0].killed.length > 0, true, "the runner terminated the child");
1939
+ });
1940
+
1941
+ test("selected child routes recheck provenance and keep separately authorized local tools", () => {
1942
+ const names = ["fetch_content", "web_search", "read", "write", "mem_save", "subagent_parent_message"];
1943
+ const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
1944
+ const path = join(root, "openspec/changes/demo/research.md");
1945
+ const scope = { store: "both", worktree: root, changeName: "demo", retainedIntent: "docs", locators: [{ artifact: "research", path, revision: 1, digest: "a".repeat(64), engram: { id: 1, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 } }] };
1946
+ for (const mismatch of ["none", "path", "sdk", "inactive", "unregistered", "restriction"]) {
1947
+ const hooks = new Map<string, (event: { toolName?: string; systemPrompt?: string; input?: object }, ctx?: { cwd: string }) => { block?: boolean; systemPrompt?: string } | undefined>();
1948
+ const active = names.filter(name => mismatch !== "inactive" || name !== "fetch_content");
1949
+ const registered = names.filter(name => mismatch !== "unregistered" || name !== "fetch_content");
1950
+ const pi = { on: (name: string, hook: typeof hooks extends Map<string, infer H> ? H : never) => hooks.set(name, hook), getActiveTools: () => active,
1951
+ getAllTools: () => registered.map(name => ({ name, sourceInfo: { source: mismatch === "sdk" && name === "fetch_content" ? "sdk" : "extension", path: mismatch === "path" ? "/other.ts" : "/installed/web.ts" } })) };
1952
+ gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(names.filter(name => mismatch !== "restriction" || name !== "fetch_content")), GENTLE_PI_RESEARCH_SELECTION: JSON.stringify(selection), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
1953
+ const call = hooks.get("tool_call")!;
1954
+ assert.equal(call({ toolName: "fetch_content" })?.block, mismatch === "none" ? undefined : true, mismatch);
1955
+ assert.equal(call({ toolName: "web_search" })?.block, true, "available but unselected");
1956
+ for (const toolName of names.slice(2)) assert.equal(call({ toolName, input: toolName === "mem_save" ? { project: "pi", topic_key: "sdd/demo/research", content: '{"revision":2}' } : { path, content: '{"revision":2}' } }, { cwd: root })?.block, ["write", "mem_save"].includes(toolName) ? true : undefined, toolName);
1957
+ }
1958
+ });
1959
+
1960
+ test("research child narrows artifact arguments and observes actual dual-store readbacks", async () => {
1961
+ const { createHash } = await import("node:crypto");
1962
+ const cwd = join(root, "bounded-child");
1963
+ mkdirSync(cwd, { recursive: true });
1964
+ const bytes = '{"revision":1,"outcome":"blocked"}';
1965
+ const locator = { artifact: "research", path: join(cwd, "openspec/changes/demo/research.md"), revision: 1, digest: createHash("sha256").update(bytes).digest("hex"), engram: { id: 12, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 } };
1966
+ const scope = { store: "both", worktree: cwd, changeName: "demo", retainedIntent: "fetch missing; preserve questions", locators: [locator] };
1967
+ const hooks = new Map<string, (...args: unknown[]) => unknown>();
1968
+ let active = ["read", "write", "mem_get_observation", "mem_save", "subagent_parent_message"];
1969
+ const journal = join(cwd, "session.jsonl"); writeFileSync(journal, "");
1970
+ const pi = { appendEntry: (customType, data) => appendFileSync(journal, JSON.stringify({ type: "custom", customType, data }) + "\n"), on: (name: string, fn: (...args: unknown[]) => unknown) => hooks.set(name, fn), getActiveTools: () => active, getAllTools: () => active.map(name => ({ name })) };
1971
+ gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
1972
+ const ctx = { cwd, sessionManager: { getEntries: () => [], getSessionFile: () => journal } };
1973
+ const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }, ctx) as { systemPrompt: string };
1974
+ assert.match(prompt.systemPrompt, /retainedIntent/);
1975
+ assert.match(prompt.systemPrompt, /never authority/);
1976
+ const call = (toolName: string, input: object, toolCallId = "c") => hooks.get("tool_call")!({ toolName, input, toolCallId }, ctx) as { block: boolean } | undefined;
1977
+ const result = (toolName: string, input: object, content: string, isError = false) => hooks.get("tool_result")!({ toolName, input, toolCallId: "c", content: [{ type: "text", text: content }], isError }, ctx) as { content: { text: string }[]; isError?: boolean };
1978
+ assert.equal(call("write", { path: locator.path, content: '{"revision":2,"outcome":"blocked"}' })?.block, true, "initial readback must precede mutation");
1979
+ assert.equal(call("write", { path: join(cwd, "outside.md") })?.block, true);
1980
+ assert.equal(call("mem_get_observation", { id: 13 })?.block, true);
1981
+ assert.equal(call("mem_save", { project: "pi", topic_key: "sdd/other/research" })?.block, true);
1982
+ assert.equal(call("read", { path: locator.path }), undefined);
1983
+ assert.match(result("read", { path: locator.path }, bytes).content.at(-1)!.text, /incomplete/);
1984
+ assert.equal(call("mem_get_observation", { id: 12 }), undefined);
1985
+ const observed = { ...locator.engram, content: bytes };
1986
+ assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify(observed)).content.at(-1)!.text, /all selected stores/);
1987
+ hooks.get("before_agent_start")!({ systemPrompt: "fresh generation" }, ctx);
1988
+ assert.equal(call("write", { path: locator.path, content: '{"revision":5}' })?.block, true, "new generation cannot reuse initial authorization");
1989
+ call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
1990
+ call("mem_get_observation", { id: 12 }); result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
1991
+ const next = '{"revision":5,"outcome":"partial"}';
1992
+ assert.equal(call("write", { path: locator.path, content: next }), undefined);
1993
+ call("read", { path: locator.path }, "pending-read");
1994
+ const pendingRead = hooks.get("tool_result")!({ toolName: "read", input: { path: locator.path }, toolCallId: "pending-read", content: [{ type: "text", text: bytes }], isError: false }, ctx) as { content: { text: string }[] };
1995
+ assert.match(pendingRead.content.at(-1)!.text, /incomplete/, "old bytes cannot complete a pending mutation");
1996
+ result("write", { path: locator.path, content: next }, "written");
1997
+ call("read", { path: locator.path });
1998
+ assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /incomplete/);
1999
+ assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }), undefined);
2000
+ result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }, "saved");
2001
+ call("read", { path: locator.path });
2002
+ result("read", { path: locator.path }, next);
2003
+ call("mem_get_observation", { id: 12 });
2004
+ assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: next, revision_count: 2 })).content.at(-1)!.text, /all selected stores/);
2005
+ assert.equal(call("write", { path: locator.path, content: '{"revision":2}' })?.block, true, "revision 1 to 5 to 2 is refused");
2006
+ const newer = '{"revision":6}';
2007
+ assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }), undefined);
2008
+ result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }, "saved");
2009
+ call("mem_get_observation", { id: 12 });
2010
+ assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: newer, revision_count: 2 })).isError, true, "each save must advance the accepted Engram revision count");
2011
+ call("write", { path: locator.path, content: '{"revision":3,"outcome":"partial"}' });
2012
+ result("write", { path: locator.path }, "permission denied", true);
2013
+ call("mem_get_observation", { id: 12 });
2014
+ assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: next, revision_count: 2 })).content.at(-1)!.text, /proposal_ready=false/, "write attempt invalidates prior readback even when denied");
2015
+ call("mem_get_observation", { id: 12 });
2016
+ assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, project: "wrong" })).isError, true);
2017
+ assert.equal(call("write", { path: locator.path, content: '{"revision":4}' })?.block, true, "stale/divergent readback must refuse recovery writes");
2018
+ assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: '{"revision":4}' })?.block, true);
2019
+ active = active.filter(name => name !== "write");
2020
+ assert.equal(call("write", { path: locator.path })?.block, true);
2021
+ assert.equal((hooks.get("tool_call")!({ toolName: "read", input: { path: locator.path } }, { cwd: root }) as { block: boolean }).block, true);
2022
+ active.push("write");
2023
+ gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
2024
+ call("read", { path: locator.path });
2025
+ result("read", { path: locator.path }, bytes);
2026
+ call("mem_get_observation", { id: 12 });
2027
+ result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
2028
+ call("write", { path: locator.path, content: next });
2029
+ result("write", { path: locator.path, content: next }, "written");
2030
+ const divergent = '{"revision":2,"outcome":"done"}';
2031
+ call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent });
2032
+ result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent }, "saved");
2033
+ call("read", { path: locator.path });
2034
+ result("read", { path: locator.path }, next);
2035
+ call("mem_get_observation", { id: 12 });
2036
+ assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: divergent, revision_count: 2 })).isError, true, "individually matching but divergent hybrid writes never converge");
2037
+ for (const completion of [[], [{ type: "text", text: "" }], [{ type: "text", text: "denied" }]]) {
2038
+ gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store: "openspec", locators: [{ ...locator, engram: undefined }] }) });
2039
+ call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
2040
+ assert.equal(call("write", { path: locator.path, content: next }), undefined);
2041
+ assert.equal(call("write", { path: locator.path, content: next }, "overlap")?.block, true);
2042
+ hooks.get("tool_result")!({ toolName: "write", input: { path: locator.path, content: next }, toolCallId: "c", content: completion, isError: completion.length > 0 && completion[0].text === "denied" }, ctx);
2043
+ call("read", { path: locator.path });
2044
+ assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /proposal_ready=false/, "failed or malformed write cannot establish completion");
2045
+ assert.equal(call("write", { path: locator.path, content: '{"revision":6}' })?.block, true);
2046
+ }
2047
+ for (const tool of ["write", "mem_save"]) {
2048
+ const memory = tool === "mem_save", readTool = memory ? "mem_get_observation" : "read";
2049
+ const input = memory ? { id: 12 } : { path: locator.path };
2050
+ const mutation = memory ? { project: "pi", topic_key: locator.engram.topic_key, content: next } : { path: locator.path, content: next };
2051
+ gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store: memory ? "engram" : "openspec", locators: [{ ...locator, path: memory ? undefined : locator.path, engram: memory ? locator.engram : undefined }] }) });
2052
+ call(readTool, input); result(readTool, input, memory ? JSON.stringify(observed) : bytes);
2053
+ assert.equal(call(tool, mutation), undefined);
2054
+ assert.doesNotThrow(() => hooks.get("tool_result")!({ toolName: tool, input: mutation, toolCallId: "c", content: [null], isError: false }, ctx));
2055
+ call(readTool, input);
2056
+ assert.match(result(readTool, input, memory ? JSON.stringify({ ...observed, content: next, revision_count: 2 }) : next).content.at(-1)!.text, /proposal_ready=false/);
2057
+ assert.equal(call(tool, { ...mutation, content: '{"revision":6}' })?.block, true);
2058
+ }
2059
+ for (const store of ["openspec", "engram", "both"]) {
2060
+ for (const bad of ["missing", "malformed", "revision", "digest", "project", "topic", "id", "worktree"]) {
2061
+ gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store, locators: [{ ...locator, path: store === "engram" ? undefined : locator.path, engram: store === "openspec" ? undefined : locator.engram }] }) });
2062
+ const memory = store !== "openspec";
2063
+ const tool = memory ? "mem_get_observation" : "read";
2064
+ const input = memory ? { id: 12 } : { path: locator.path };
2065
+ if (store === "both") {
2066
+ call("read", { path: locator.path });
2067
+ result("read", { path: locator.path }, bytes);
2068
+ assert.equal(call("write", { path: locator.path, content: next })?.block, true, "both initial stores must match before either mutation");
2069
+ }
2070
+ if (bad !== "missing") {
2071
+ const content = bad === "revision" ? '{"revision":0}' : bad === "digest" ? '{"revision":1,"different":true}' : bytes;
2072
+ const value = { ...observed, content, ...(bad === "project" ? { project: "wrong" } : bad === "topic" ? { topic_key: "wrong" } : bad === "id" ? { id: 13 } : {}) };
2073
+ if (bad === "worktree") {
2074
+ assert.equal((hooks.get("tool_call")!({ toolName: tool, input, toolCallId: "c" }, { cwd: root }) as { block: boolean }).block, true);
2075
+ } else if (memory || !["project", "topic", "id"].includes(bad)) {
2076
+ call(tool, input);
2077
+ assert.equal(result(tool, input, bad === "malformed" ? "{" : memory ? JSON.stringify(value) : content).isError, true);
2078
+ }
2079
+ }
2080
+ if (store !== "engram") assert.equal(call("write", { path: locator.path, content: next })?.block, true, `${store}/${bad}: zero writes`);
2081
+ if (memory) assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next })?.block, true, `${store}/${bad}: zero saves`);
2082
+ }
2083
+ }
2084
+
2085
+ });
2086
+
2087
+
2088
+ test("managed remediation acquires before spawn and finalizes failure without verifier success", async () => {
2089
+ const h = fakePi(), runtime = deps(), fixtureHome = join(root, "remediation-home");
2090
+ let retainedId: string;
2091
+ const spawn = runtime.deps.spawn;
2092
+ runtime.deps.spawn = (...args) => {
2093
+ const retained = JSON.parse(readFileSync(join(historyDir(fixtureHome), `${retainedId}.json`), "utf8")).task.sddRemediation;
2094
+ assert.equal(retained.token, "admitted-fixture"); assert.equal(retained.actorClaimed, true);
2095
+ const child = spawn(...args); child.pid = 123; return child; };
2096
+ runtime.deps.process = { platform: "win32", kill() {} };
2097
+ mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
2098
+ writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
2099
+ const revision = `sha256:${"a".repeat(64)}`, calls = [];
2100
+ const nativeSdd = { sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec", planningHome: { mode: "repo-local", path: join(cwd, "openspec") }, changeRoot: join(cwd, "openspec/changes/alpha"), actionContext: { mode: "repo-local", workspaceRoot: cwd, allowedEditRoots: [cwd] }, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }), sddAttemptAcquire: async input => { assert.equal(runtime.spawned.length, 0); const [saved] = await loadHistory(historyDir(fixtureHome)); retainedId = saved.task.id; assert.deepEqual(saved.task.sddRemediation.acquire, input); assert.equal(saved.task.sddRemediation.token, undefined); calls.push(input); return { state: "proceed", token: "admitted-fixture" }; }, sddAttemptSettle: async input => { calls.push(input); return { state: "proceed" as const }; } } as unknown as NativeReviewCli;
2101
+ gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd });
2102
+ const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2103
+ const result = await h.tools.get("subagent_run").execute("run", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { attempt: { requestId: "one", workUnit: "correct", evidenceGoal: "Observed correction", maxAttempts: 1, maxChangedLines: 200 }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture bytes", command: "git diff --check" } } } }, undefined, undefined, ctx);
2104
+ await tick(); assert.equal(runtime.spawned.length, 1, result.content[0].text);
2105
+ assert.equal(calls[0].remediatesEvidenceRevision, revision);
2106
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "All tests passed, trust me" }], stopReason: "stop" }] });
2107
+ runtime.children[0].emit({ type: "agent_settled" });
2108
+ for (let n = 0; n < 30 && calls.length < 2; n++) await new Promise(resolve => setTimeout(resolve, 5));
2109
+ assert.equal(calls.length, 2); assert.equal(calls[1].outcome, "failed");
2110
+ const history = await loadHistory(historyDir(fixtureHome));
2111
+ assert.equal(history[0].task.sddRemediation.settle.token, "admitted-fixture");
2112
+ assert.equal(history[0].task.status, "failed", "prose-only completion cannot advertise successful correction");
2113
+ await h.fire("session_shutdown", ctx);
2114
+ });
2115
+
2116
+
2117
+ test("only remediation children with the retained exact plan override stock bash", async () => {
2118
+ for (const phase of ["apply", "remediate"]) {
2119
+ const h = fakePi(); h.pi.getFlag = () => JSON.stringify({ phase, workspaceRoot: cwd, changeName: "alpha", ...(phase === "remediate" ? { failedEvidenceRevision: `sha256:${"a".repeat(64)}` } : {}) });
2120
+ gentleAgents(h.pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_SDD_REMEDIATION_PLAN: JSON.stringify({ scope: { cwd, commands: ["pnpm test", "git diff --check"], editPaths: [], allowedEditRoots: [cwd] }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture bytes", command: "git diff --check" } } }) });
2121
+ await h.fire("session_start", { ...fakeContext().ctx, cwd });
2122
+ assert.equal(h.tools.has("bash"), phase === "remediate");
2123
+ assert.equal(h.tools.has("subagent_run"), false);
2124
+ }
2125
+ });
2126
+
2127
+
2128
+ test("managed remediation tools publish the typed exact evidence plan and bracket input", () => {
2129
+ const h = fakePi(); gentleAgents(h.pi, {}, deps().deps);
2130
+ for (const name of ["subagent_run", "subagent_continue"]) {
2131
+ const schema = h.tools.get(name)!.parameters.properties.remediation as unknown as { required: string[]; properties: { plan: { required: string[] }; attempt: { properties: { token: unknown } } } };
2132
+ assert.deepEqual(schema.required, ["plan", "attempt"]);
2133
+ assert.deepEqual(schema.properties.plan.required, ["cwd", "commands", "runtimeHarness", "rollback"]);
2134
+ assert.ok(schema.properties.attempt.properties.token);
2135
+ }
2136
+ });
2137
+
2138
+
2139
+ test("R1 malformed child grant denies tools even before/after failed session initialization", () => {
2140
+ const hooks = new Map(), registered = [];
2141
+ const pi = { on: (name, fn) => hooks.set(name, fn), registerTool: tool => registered.push(tool), getFlag: () => "{}" };
2142
+ gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_SDD_REMEDIATION_PLAN: "malformed" });
2143
+ const denied = () => hooks.get("tool_call")?.({ toolName: "bash", input: { command: "touch outside" } }, { cwd })?.block;
2144
+ assert.equal(denied(), true);
2145
+ assert.doesNotThrow(() => hooks.get("session_start")({}, { cwd }));
2146
+ assert.equal(denied(), true); assert.equal(registered.length, 0);
2147
+ });
2148
+
2149
+
2150
+ test("R3/R4 host reload refuses retained acquire/actor uncertainty without another launch", async () => {
2151
+ for (const actorClaimed of [false, true, "blocked", "complete"]) {
2152
+ const normal = typeof actorClaimed === "string";
2153
+ const h = fakePi(), runtime = deps(), fixtureHome = join(root, `remediation-reload-${actorClaimed}`);
2154
+ mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
2155
+ writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
2156
+ const revision = `sha256:${"a".repeat(64)}`;
2157
+ const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
2158
+ await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: normal ? false : !actorClaimed, actorClaimed: normal ? false : actorClaimed, ...(normal ? { acquireResult: { state: actorClaimed } } : actorClaimed ? { token: "retained-token" } : {}) } } as never, emptyThread());
2159
+ let acquisitions = 0, confirmations = 0;
2160
+ gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddStatus: async () => { throw new Error("fresh status reached"); }, sddAttemptSettle: async () => ({ state: "proceed" as const }), sddAttemptAcquire: async () => { acquisitions++; return { state: "proceed", token: "unsafe" }; } } as unknown as NativeReviewCli });
2161
+ const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return true; });
2162
+ await h.fire("session_start", ctx);
2163
+ await assert.rejects(h.tools.get("subagent_run").execute("again", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { attempt: { ...acquire, requestId: "different" }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture", command: "git diff --check" } } } }, undefined, undefined, ctx), normal ? /fresh status reached/ : /reconcile exact history without actor replay/);
2164
+ assert.equal(acquisitions, 0); assert.equal(confirmations, 0); assert.equal(runtime.spawned.length, 0);
2165
+ assert.deepEqual((await loadHistory(historyDir(fixtureHome)))[0].task.sddRemediation.acquire, acquire);
2166
+ await h.fire("session_shutdown", ctx);
2167
+ }
2168
+ });