gentle-pi 2.7.0 → 3.0.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 (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
@@ -1,14 +1,18 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { homedir, tmpdir } from "node:os";
4
5
  import { createRequire, syncBuiltinESMExports } from "node:module";
5
- import { join, relative, resolve } from "node:path";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
6
7
  import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
7
8
  import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED } from "../lib/session-worktree-registry.ts";
8
- import test, { after, mock } from "node:test";
9
+ import test, { after, afterEach, mock } from "node:test";
10
+ import type { TestContext } from "node:test";
9
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
12
  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";
13
+ import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, createDefaultSessionTransport, legacySubagentsInstalled, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
14
+ import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
15
+ import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
12
16
  import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
13
17
  import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
14
18
  import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
@@ -44,6 +48,21 @@ const PARENT_CONFIRMED_SDD_CONTEXT = renderSddPreflightPrompt({
44
48
  prompted: true,
45
49
  });
46
50
 
51
+ const inertSessionTransport: SessionTransportFactory = {
52
+ createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }),
53
+ createListener: (registry) => ({ registry, start: async () => {}, close: async () => {} }),
54
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("inert session transport must not send notifications"); } }),
55
+ };
56
+
57
+ function containsResolvedPath(
58
+ candidate: string,
59
+ path: string,
60
+ paths: Pick<typeof win32, "isAbsolute" | "relative" | "sep"> = { isAbsolute, relative, sep },
61
+ ): boolean {
62
+ const fromCandidate = paths.relative(candidate, path);
63
+ return fromCandidate === "" || (!paths.isAbsolute(fromCandidate) && fromCandidate !== ".." && !fromCandidate.startsWith(`..${paths.sep}`));
64
+ }
65
+
47
66
  type Overlay = {
48
67
  render(width: number): string[];
49
68
  handleInput(data: string): void;
@@ -60,8 +79,14 @@ function mouse(
60
79
  ): TuiMouseEvent {
61
80
  return { type, button, x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
62
81
  }
63
- const root = mkdtempSync(join(tmpdir(), "gentle-agents-ext-"));
64
- after(() => rmSync(root, { recursive: true, force: true }));
82
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
83
+ const activeSessionTeardowns = new Set<() => Promise<void>>();
84
+ const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
85
+ afterEach(stopActiveSessions);
86
+ after(async () => {
87
+ try { await stopActiveSessions(); }
88
+ finally { rmSync(root, { recursive: true, force: true }); }
89
+ });
65
90
  const home = join(root, "home");
66
91
  const cwd = join(root, "project");
67
92
  const nonGitCwd = join(root, "non-git-project");
@@ -99,8 +124,24 @@ function fakePi() {
99
124
  registerShortcut: (key: string, registration: { description: string; handler(ctx: ExtensionContext): Promise<void> }) => shortcuts.set(key, registration),
100
125
  registerCommand: (name: string, registration: { handler(args: string, ctx: ExtensionContext): Promise<void> }) => commands.set(name, registration),
101
126
  } as unknown as ExtensionAPI;
127
+ let activeSession: ExtensionContext | undefined;
128
+ const teardown = async () => {
129
+ const ctx = activeSession;
130
+ if (ctx === undefined) return;
131
+ await fire("session_shutdown", ctx, { reason: "quit" });
132
+ };
102
133
  const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
103
- for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
134
+ try {
135
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
136
+ } finally {
137
+ if (event === "session_start") {
138
+ activeSession = ctx;
139
+ activeSessionTeardowns.add(teardown);
140
+ } else if (event === "session_shutdown" && activeSession === ctx) {
141
+ activeSession = undefined;
142
+ activeSessionTeardowns.delete(teardown);
143
+ }
144
+ }
104
145
  };
105
146
  return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners };
106
147
  }
@@ -175,16 +216,91 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
175
216
  home,
176
217
  resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
177
218
  env: { PATH: "/bin" },
219
+ sessionTransport: inertSessionTransport,
178
220
  },
179
221
  };
180
222
  }
181
223
 
182
- test("all ten subagent registrations own their transcript shell", () => {
224
+ const PRINT_BACKGROUND_ERROR = "Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
225
+
226
+ for (const continuation of [false, true]) {
227
+ test(`print mode rejects background ${continuation ? "continuation" : "launch"} before allocating a task`, async (t) => {
228
+ const h = fakePi();
229
+ const runtime = deps();
230
+ gentleAgents(h.pi, {}, runtime.deps);
231
+ const { ctx } = fakeContext();
232
+ Object.assign(ctx, { mode: "print", hasUI: false });
233
+ await h.fire("session_start", ctx);
234
+ let taskId: string | undefined;
235
+ if (continuation) {
236
+ const pending = h.tools.get("subagent_run")!.execute("seed", { agent: "explore", task: "Map", mode: "task" }, undefined, undefined, ctx);
237
+ await tick();
238
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
239
+ runtime.children[0].emit({ type: "agent_settled" });
240
+ const result = await pending;
241
+ taskId = (result.details.gentleAgents as { taskId: string }).taskId;
242
+ }
243
+ const run = t.mock.method(AgentRunner.prototype, "run");
244
+ const spawnedBefore = runtime.spawned.length;
245
+ const entriesBefore = [...h.entries];
246
+ const historyBefore = await loadHistory(home);
247
+ const listBefore = await h.tools.get("subagent_list_tasks")!.execute("before", {}, undefined, undefined, ctx);
248
+ const tool = h.tools.get(continuation ? "subagent_continue" : "subagent_run")!;
249
+ await assert.rejects(tool.execute("denied", continuation
250
+ ? { task_id: taskId, prompt: "Follow up", mode: "background" }
251
+ : { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx), { message: PRINT_BACKGROUND_ERROR });
252
+ await tick();
253
+ assert.equal(run.mock.callCount(), 0, "rejection must precede runner task ID allocation");
254
+ assert.equal(runtime.spawned.length, spawnedBefore, "no child spawned");
255
+ assert.deepEqual(await h.tools.get("subagent_list_tasks")!.execute("after", {}, undefined, undefined, ctx), listBefore, "no new task record");
256
+ assert.deepEqual(await loadHistory(home), historyBefore, "no history write");
257
+ assert.deepEqual(h.entries, entriesBefore, "no worktree registration");
258
+ });
259
+ }
260
+
261
+ for (const mode of ["print", "tui", "rpc"] as const) {
262
+ test(`${mode} preserves ${mode === "print" ? "bounded task" : "background"} execution`, async () => {
263
+ const h = fakePi();
264
+ const runtime = deps();
265
+ gentleAgents(h.pi, {}, runtime.deps);
266
+ const { ctx } = fakeContext();
267
+ Object.assign(ctx, { mode, hasUI: mode === "tui" });
268
+ await h.fire("session_start", ctx);
269
+ let resolved = false;
270
+ const pending = h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: mode === "print" ? "task" : "background" }, undefined, undefined, ctx).then(result => { resolved = true; return result; });
271
+ await tick();
272
+ assert.equal(runtime.spawned.length, 1);
273
+ assert.equal(resolved, mode !== "print", "only task mode waits for completion");
274
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
275
+ runtime.children[0].emit({ type: "agent_settled" });
276
+ const result = await pending;
277
+ if (mode === "print") assert.match(result.content[0].text, /mapped/);
278
+ const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
279
+ assert.ok(taskId);
280
+ if (mode !== "print") {
281
+ await tick();
282
+ assert.match((await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /completed · background/);
283
+ assert.equal(h.sent.length, 1, "settlement delivers exactly one completion");
284
+ assert.equal(h.sent[0].message.customType, "gentle-agents.result");
285
+ assert.equal(h.sent[0].message.content, `Subagent explore (task ${taskId}, "Map") finished.\n\nmapped`);
286
+ assert.equal(h.sent[0].message.display, true);
287
+ assert.deepEqual(h.sent[0].options, { deliverAs: "steer", triggerTurn: true });
288
+ await h.fire("turn_end", ctx);
289
+ await h.fire("turn_end", ctx);
290
+ await tick();
291
+ assert.equal(h.sent.length, 1, "later turns must not redeliver the completion");
292
+ assert.equal((await h.tools.get("subagent_result")!.execute("result", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, "mapped");
293
+ }
294
+ });
295
+ }
296
+
297
+ test("all nine subagent registrations own their transcript shell", () => {
183
298
  const { pi, tools } = fakePi();
184
299
  gentleAgents(pi, {}, deps().deps);
185
- assert.equal(tools.size, 10);
186
- assert.deepEqual(tools.get("subagent_reconcile")?.parameters, { type: "object", additionalProperties: false, required: ["task_id"], properties: { task_id: { type: "string" } } });
187
- for (const tool of tools.values()) assert.equal(tool.renderShell, "self", tool.name);
300
+ const subagentTools = [...tools.values()].filter((tool) => tool.name.startsWith("subagent_"));
301
+ assert.equal(subagentTools.length, 9);
302
+ assert.equal(tools.has("subagent_reconcile"), false);
303
+ for (const tool of subagentTools) assert.equal(tool.renderShell, "self", tool.name);
188
304
  });
189
305
 
190
306
  test("host query delivery exposes correlation and accepts one current-session reply", async () => {
@@ -319,6 +435,359 @@ test("foreground handoff survives settlement before its original await resumes",
319
435
  });
320
436
 
321
437
  const tick = () => new Promise((resolve) => setImmediate(resolve));
438
+ type LifecycleOutcome = { status: "fulfilled" } | { status: "rejected"; error: unknown };
439
+ type LifecycleState = { status: "pending" | "fulfilled" | "rejected"; outcome?: LifecycleOutcome };
440
+ const observeLifecycle = <T>(promise: Promise<T>, state: LifecycleState): Promise<LifecycleOutcome> => promise.then(() => { const outcome = { status: "fulfilled" as const }; state.status = outcome.status; state.outcome = outcome; return outcome; }, (error) => { const outcome = { status: "rejected" as const, error }; state.status = outcome.status; state.outcome = outcome; return outcome; });
441
+ const boundedLifecycle = async <T>(promise: Promise<T>, label: string) => {
442
+ let timer: ReturnType<typeof setTimeout> | undefined;
443
+ try { return await Promise.race([promise, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 2000); })]); }
444
+ finally { if (timer) clearTimeout(timer); }
445
+ };
446
+
447
+ const drainLifecycle = async (label: string, promise: Promise<LifecycleOutcome>) => {
448
+ try { return { label, outcome: await boundedLifecycle(promise, label) }; }
449
+ catch (error) { return { label, error }; }
450
+ };
451
+
452
+ test("overlapping session transport startups preserve ownership and shutdown waits for every pending operation", async (t: TestContext) => {
453
+ const h = fakePi();
454
+ const runtime = deps();
455
+ let sessionId = "old";
456
+ const registryGates: Array<() => void> = [];
457
+ const listenerStarts: string[] = [], listenerCloses: string[] = [], clientCloses: string[] = [], registryCloseCalls: string[] = [], registryCloseEffects: string[] = [];
458
+ const registries = new Map<string, { closed: boolean; close(): Promise<void> }>();
459
+ const transport: SessionTransportFactory = {
460
+ createRegistry: async () => {
461
+ const id = sessionId;
462
+ await new Promise<void>((resolve) => registryGates.push(resolve));
463
+ const registry = { sessionId: id, closed: false, list: async () => [], listActivations: async () => [], close: async () => { registryCloseCalls.push(id); if (!registry.closed) { registry.closed = true; registryCloseEffects.push(id); } } };
464
+ registries.set(id, registry);
465
+ return registry;
466
+ },
467
+ createListener: (registry, id) => ({ registry, closesRegistry: true, start: async () => { listenerStarts.push(id); }, close: async () => { listenerCloses.push(id); await registry.close?.(); } }),
468
+ createClient: (_registry, id) => ({ close: () => { clientCloses.push(id); }, sendNotification: async () => ({ id: "unused", accepted: true }) }),
469
+ };
470
+ runtime.deps.sessionTransport = transport;
471
+ gentleAgents(h.pi, {}, runtime.deps);
472
+ const { ctx } = fakeContext();
473
+ (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => sessionId;
474
+ const launched: Array<{ state: LifecycleState; outcome: Promise<LifecycleOutcome> }> = [];
475
+ const launch = <T>(promise: Promise<T>) => { const state: LifecycleState = { status: "pending" }; const outcome = observeLifecycle(promise, state); launched.push({ state, outcome }); return { state, outcome }; };
476
+ let shutdownRecord: ReturnType<typeof launch> | undefined;
477
+ let primary: unknown;
478
+ try {
479
+ const first = h.fire("session_start", ctx, { reason: "startup" });
480
+ launch(first);
481
+ await tick();
482
+ sessionId = "new";
483
+ const second = h.fire("session_start", ctx, { reason: "new" });
484
+ const secondRecord = launch(second);
485
+ await tick();
486
+ assert.equal(registryGates.length, 2, "both starts must own pending registry creation");
487
+ registryGates[1]!();
488
+ await eventually(() => listenerStarts.includes("new"), "current startup must publish after its registry is ready");
489
+ const shutdown = h.fire("session_shutdown", ctx, { reason: "quit" });
490
+ shutdownRecord = launch(shutdown);
491
+ await tick();
492
+ assert.equal(secondRecord.state.status, "fulfilled", "the current startup settles before shutdown begins");
493
+ assert.equal(shutdownRecord?.state.status, "pending", "shutdown must wait for the older pending startup, not only the current startup");
494
+ registryGates[0]!();
495
+ assert.equal(registries.has("old"), false, "the released old registry gate has not yet completed acquisition");
496
+ assert.equal(registries.get("old")?.closed, undefined, "the retained old registry cannot be closed before acquisition completes");
497
+ await eventually(() => launched.every(({ state }) => state.status !== "pending"), "all launched lifecycle operations must settle after gate release");
498
+ const outcomes = await Promise.all(launched.map(({ outcome }) => outcome));
499
+ assert.ok(outcomes.every((outcome) => outcome.status === "fulfilled"), "all launched lifecycle operations must fulfill");
500
+ assert.deepEqual(listenerStarts, ["new"], "the stale startup must never publish");
501
+ assert.deepEqual(clientCloses, ["new"], "the current client closes exactly once");
502
+ assert.deepEqual(listenerCloses, ["new"], "the current listener closes exactly once");
503
+ assert.deepEqual(registryCloseCalls.sort(), ["new", "old"], "each owned registry close is invoked exactly once");
504
+ assert.deepEqual(registryCloseEffects.sort(), ["new", "old"], "each owned registry closes exactly once");
505
+ assert.equal(registries.get("old")?.closed, true);
506
+ assert.equal(registries.get("new")?.closed, true);
507
+ } catch (error) {
508
+ primary = error;
509
+ } finally {
510
+ for (const release of registryGates) release();
511
+ if (shutdownRecord === undefined) shutdownRecord = launch(h.fire("session_shutdown", ctx, { reason: "cleanup" }));
512
+ if (launched.length > 0) {
513
+ try {
514
+ await eventually(() => launched.every(({ state }) => state.status !== "pending"), "all launched lifecycle operations must settle during cleanup");
515
+ const outcomes = await Promise.all(launched.map(({ outcome }) => outcome));
516
+ const cleanupFailure = outcomes.find((outcome) => outcome.status === "rejected");
517
+ if (cleanupFailure?.status === "rejected") {
518
+ if (primary === undefined) primary = cleanupFailure.error;
519
+ else t.diagnostic(`Lifecycle cleanup secondary failure: ${cleanupFailure.error instanceof Error ? cleanupFailure.error.message : String(cleanupFailure.error)}`);
520
+ }
521
+ } catch (error) {
522
+ if (primary === undefined) primary = error;
523
+ else t.diagnostic(`Lifecycle cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
524
+ }
525
+ }
526
+ }
527
+ if (primary !== undefined) throw primary;
528
+ });
529
+
530
+ test("session_start does not block a subsequently registered handler on registry creation", async (t: TestContext) => {
531
+ const h = fakePi();
532
+ const runtime = deps();
533
+ let registryEntered = false;
534
+ let listenerCreated = false;
535
+ let clientCreated = false;
536
+ let listenerClosed = 0;
537
+ let clientClosed = 0;
538
+ let releaseRegistry!: () => void;
539
+ const registryGate = new Promise<void>((resolve) => { releaseRegistry = resolve; });
540
+ const transport: SessionTransportFactory = {
541
+ createRegistry: async () => {
542
+ registryEntered = true;
543
+ await registryGate;
544
+ return { list: async () => [], listActivations: async () => [] };
545
+ },
546
+ createListener: (registry) => { listenerCreated = true; return { registry, start: async () => {}, close: async () => { listenerClosed++; } }; },
547
+ createClient: () => { clientCreated = true; return { close() { clientClosed++; }, sendNotification: async () => ({ id: "unused", accepted: true }) }; },
548
+ };
549
+ runtime.deps.sessionTransport = transport;
550
+ gentleAgents(h.pi, {}, runtime.deps);
551
+ let laterFinished = false;
552
+ h.pi.on("session_start", async () => { laterFinished = true; });
553
+ const { ctx } = fakeContext();
554
+ let outcome: Promise<LifecycleOutcome> | undefined;
555
+ let lifecycleState: LifecycleState | undefined;
556
+ let primary: unknown;
557
+ try {
558
+ const started = h.fire("session_start", ctx, { reason: "startup" });
559
+ lifecycleState = { status: "pending" };
560
+ outcome = observeLifecycle(started, lifecycleState);
561
+ await eventually(() => registryEntered, "registry gate must be entered before the bounded handler assertion");
562
+ await eventually(() => laterFinished, "a subsequently registered session_start handler must finish while registry creation is gated");
563
+ releaseRegistry();
564
+ assert.ok(outcome);
565
+ assert.equal((await outcome).status, "fulfilled");
566
+ } catch (error) {
567
+ primary = error;
568
+ } finally {
569
+ releaseRegistry();
570
+ try {
571
+ if (outcome !== undefined) {
572
+ await eventually(() => listenerCreated && clientCreated, "registry-gated startup must acquire owned resources before cleanup shutdown");
573
+ const shutdownState: LifecycleState = { status: "pending" };
574
+ const shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), shutdownState);
575
+ await eventually(() => lifecycleState?.status !== "pending" && shutdownState.status !== "pending", "registry-gated startup cleanup must settle all launched operations");
576
+ const cleanupOutcomes = await Promise.all([outcome, shutdownOutcome]);
577
+ assert.equal(listenerClosed, 1, "cleanup closes the owned listener");
578
+ assert.equal(clientClosed, 1, "cleanup closes the owned client");
579
+ const cleanupFailure = cleanupOutcomes.find((value) => value.status === "rejected");
580
+ if (cleanupFailure?.status === "rejected") {
581
+ if (primary === undefined) primary = cleanupFailure.error;
582
+ else t.diagnostic(`Registry-gate cleanup secondary failure: ${cleanupFailure.error instanceof Error ? cleanupFailure.error.message : String(cleanupFailure.error)}`);
583
+ }
584
+ }
585
+ } catch (error) {
586
+ if (primary === undefined) primary = error;
587
+ else t.diagnostic(`Registry-gate cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
588
+ }
589
+ }
590
+ if (primary !== undefined) throw primary;
591
+ });
592
+
593
+ test("session_start does not block a subsequently registered handler on listener publication", async (t: TestContext) => {
594
+ const h = fakePi();
595
+ const runtime = deps();
596
+ let listenerEntered = false;
597
+ let listenerClosed = 0;
598
+ let clientClosed = 0;
599
+ let releaseListener!: () => void;
600
+ const listenerGate = new Promise<void>((resolve) => { releaseListener = resolve; });
601
+ const registry = { list: async () => [], listActivations: async () => [] };
602
+ const transport: SessionTransportFactory = {
603
+ createRegistry: async () => registry,
604
+ createListener: (ownedRegistry) => ({
605
+ registry: ownedRegistry,
606
+ start: async () => { listenerEntered = true; await listenerGate; },
607
+ close: async () => { listenerClosed++; },
608
+ }),
609
+ createClient: () => ({ close() { clientClosed++; }, sendNotification: async () => ({ id: "unused", accepted: true }) }),
610
+ };
611
+ runtime.deps.sessionTransport = transport;
612
+ gentleAgents(h.pi, {}, runtime.deps);
613
+ let laterFinished = false;
614
+ h.pi.on("session_start", async () => { laterFinished = true; });
615
+ const { ctx } = fakeContext();
616
+ let outcome: Promise<LifecycleOutcome> | undefined;
617
+ let lifecycleState: LifecycleState | undefined;
618
+ let primary: unknown;
619
+ try {
620
+ const started = h.fire("session_start", ctx, { reason: "startup" });
621
+ lifecycleState = { status: "pending" };
622
+ outcome = observeLifecycle(started, lifecycleState);
623
+ await eventually(() => listenerEntered, "listener gate must be entered after registry creation");
624
+ await eventually(() => laterFinished, "a subsequently registered session_start handler must finish while listener publication is gated");
625
+ releaseListener();
626
+ assert.ok(outcome);
627
+ assert.equal((await outcome).status, "fulfilled");
628
+ } catch (error) {
629
+ primary = error;
630
+ } finally {
631
+ releaseListener();
632
+ try {
633
+ if (outcome !== undefined) {
634
+ const shutdownState: LifecycleState = { status: "pending" };
635
+ const shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), shutdownState);
636
+ await eventually(() => lifecycleState?.status !== "pending" && shutdownState.status !== "pending", "listener-gated startup cleanup must settle all launched operations");
637
+ const cleanupOutcomes = await Promise.all([outcome, shutdownOutcome]);
638
+ assert.equal(listenerClosed, 1, "cleanup closes the owned listener");
639
+ assert.equal(clientClosed, 1, "cleanup closes the owned client");
640
+ const cleanupFailure = cleanupOutcomes.find((value) => value.status === "rejected");
641
+ if (cleanupFailure?.status === "rejected") {
642
+ if (primary === undefined) primary = cleanupFailure.error;
643
+ else t.diagnostic(`Listener-gate cleanup secondary failure: ${cleanupFailure.error instanceof Error ? cleanupFailure.error.message : String(cleanupFailure.error)}`);
644
+ }
645
+ }
646
+ } catch (error) {
647
+ if (primary === undefined) primary = error;
648
+ else t.diagnostic(`Listener-gate cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
649
+ }
650
+ }
651
+ if (primary !== undefined) throw primary;
652
+ });
653
+
654
+ test("replacement closes a gated stale listener once and leaves the successor owned until shutdown", async (t: TestContext) => {
655
+ const h = fakePi();
656
+ const runtime = deps();
657
+ let sessionId = "alpha";
658
+ let releaseAlpha!: () => void;
659
+ const alphaGate = new Promise<void>((resolve) => { releaseAlpha = resolve; });
660
+ let alphaEntered = false, betaStarted = false, betaClientCreated = false;
661
+ let alphaCallback: ((notification: { id: string; senderSessionId: string; message: string }) => Promise<void>) | undefined;
662
+ let alphaCloses = 0, betaCloses = 0, alphaClientCloses = 0, betaClientCloses = 0;
663
+ const registryCloseCalls: string[] = [], registryCloseEffects: string[] = [];
664
+ const transport: SessionTransportFactory = {
665
+ createRegistry: async () => {
666
+ const id = sessionId;
667
+ const ownedRegistry = { list: async () => [], listActivations: async () => [], closed: false, close: async () => { registryCloseCalls.push(id); if (!ownedRegistry.closed) { ownedRegistry.closed = true; registryCloseEffects.push(id); } } };
668
+ return ownedRegistry;
669
+ },
670
+ createListener: (ownedRegistry, id, callback) => {
671
+ if (id === "alpha") alphaCallback = callback;
672
+ return { registry: ownedRegistry, closesRegistry: true, start: async () => { if (id === "alpha") { alphaEntered = true; await alphaGate; } else betaStarted = true; }, close: async () => { if (id === "alpha") alphaCloses++; else betaCloses++; await ownedRegistry.close?.(); } };
673
+ },
674
+ createClient: (_registry, id) => { if (id === "beta") betaClientCreated = true; return { close: () => { if (id === "alpha") alphaClientCloses++; else betaClientCloses++; }, sendNotification: async () => ({ id: "unused", accepted: true }) }; },
675
+ };
676
+ runtime.deps.sessionTransport = transport;
677
+ gentleAgents(h.pi, {}, runtime.deps);
678
+ const { ctx } = fakeContext();
679
+ (ctx.sessionManager as { getSessionId(): string }).getSessionId = () => sessionId;
680
+ let primary: unknown;
681
+ let alphaOutcome: Promise<LifecycleOutcome> | undefined;
682
+ let betaOutcome: Promise<LifecycleOutcome> | undefined;
683
+ let shutdownOutcome: Promise<LifecycleOutcome> | undefined;
684
+ let shutdownState: LifecycleState | undefined;
685
+ try {
686
+ alphaOutcome = observeLifecycle(h.fire("session_start", ctx, { reason: "alpha" }), { status: "pending" });
687
+ await eventually(() => alphaEntered, "replacement test must enter the alpha listener gate");
688
+ sessionId = "beta";
689
+ betaOutcome = observeLifecycle(h.fire("session_start", ctx, { reason: "beta" }), { status: "pending" });
690
+ assert.ok(betaOutcome);
691
+ assert.equal((await boundedLifecycle(betaOutcome, "successor startup")).status, "fulfilled");
692
+ await eventually(() => betaClientCreated && betaStarted, "replacement must acquire and publish the successor before ownership assertions");
693
+ assert.equal(betaClientCloses, 0, "replacement must not close the successor client");
694
+ assert.equal(betaCloses, 0, "replacement must not close the successor listener");
695
+ assert.ok(alphaCallback, "the gated listener registered its callback before start");
696
+ await boundedLifecycle(assert.rejects(alphaCallback!({ id: "late", senderSessionId: "peer", message: "late" }), /stale session transport/), "stale callback rejection");
697
+ shutdownState = { status: "pending" };
698
+ shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "quit" }), shutdownState);
699
+ await boundedLifecycle(tick(), "replacement shutdown scheduling");
700
+ assert.equal(shutdownState.status, "pending", "shutdown waits while the replaced listener startup remains gated");
701
+ releaseAlpha();
702
+ assert.ok(alphaOutcome);
703
+ assert.equal((await boundedLifecycle(alphaOutcome, "stale alpha startup")).status, "fulfilled");
704
+ assert.equal(alphaClientCloses, 1, "stale alpha client closes once after its gate releases");
705
+ assert.equal(alphaCloses, 1, "stale alpha listener closes once after its gate releases");
706
+ assert.ok(shutdownOutcome);
707
+ assert.equal((await boundedLifecycle(shutdownOutcome, "successor shutdown")).status, "fulfilled");
708
+ assert.equal(betaClientCloses, 1, "shutdown closes the successor client once");
709
+ assert.equal(betaCloses, 1, "shutdown closes the successor listener once");
710
+ assert.deepEqual(registryCloseCalls.sort(), ["alpha", "beta"], "each owned registry close is invoked once");
711
+ assert.deepEqual(registryCloseEffects.sort(), ["alpha", "beta"], "each owned registry closes once");
712
+ } catch (error) {
713
+ primary = error;
714
+ } finally {
715
+ releaseAlpha();
716
+ try {
717
+ if (shutdownOutcome === undefined) shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), { status: "pending" });
718
+ const cleanup = await Promise.all([
719
+ ...(alphaOutcome === undefined ? [] : [drainLifecycle("alpha cleanup", alphaOutcome)]),
720
+ ...(betaOutcome === undefined ? [] : [drainLifecycle("beta cleanup", betaOutcome)]),
721
+ ...(shutdownOutcome === undefined ? [] : [drainLifecycle("shutdown cleanup", shutdownOutcome)]),
722
+ ]);
723
+ for (const result of cleanup) {
724
+ if ("error" in result) {
725
+ if (primary === undefined) primary = result.error;
726
+ else t.diagnostic(`${result.label} secondary failure: ${result.error instanceof Error ? result.error.message : String(result.error)}`);
727
+ } else if (result.outcome.status === "rejected") {
728
+ if (primary === undefined) primary = result.outcome.error;
729
+ else t.diagnostic(`${result.label} secondary rejection: ${result.outcome.error instanceof Error ? result.outcome.error.message : String(result.outcome.error)}`);
730
+ }
731
+ }
732
+ } catch (error) {
733
+ if (primary === undefined) primary = error;
734
+ else t.diagnostic(`replacement cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
735
+ }
736
+ }
737
+ if (primary !== undefined) throw primary;
738
+ });
739
+
740
+ test("startup cleanup contains client-close failure and still closes the remaining owned resources", async (t: TestContext) => {
741
+ const h = fakePi();
742
+ const runtime = deps();
743
+ const startupError = new Error("listener unavailable");
744
+ const clientError = new Error("client close failed");
745
+ let listenerCloses = 0, registryCloses = 0, clientCloseAttempts = 0;
746
+ const registry = { list: async () => [], listActivations: async () => [], close: async () => { registryCloses++; } };
747
+ runtime.deps.sessionTransport = {
748
+ createRegistry: async () => registry,
749
+ createListener: () => ({ registry, start: async () => { throw startupError; }, close: async () => { listenerCloses++; } }),
750
+ createClient: () => ({ close: () => { clientCloseAttempts++; throw clientError; }, sendNotification: async () => ({ id: "unused", accepted: true }) }),
751
+ };
752
+ gentleAgents(h.pi, {}, runtime.deps);
753
+ const { ctx } = fakeContext();
754
+ const outcome = observeLifecycle(h.fire("session_start", ctx, { reason: "startup" }), { status: "pending" });
755
+ let shutdownOutcome: Promise<LifecycleOutcome> | undefined;
756
+ let primary: unknown;
757
+ try {
758
+ assert.equal((await boundedLifecycle(outcome, "startup failure settlement")).status, "fulfilled", "startup failure remains contained");
759
+ await eventually(() => clientCloseAttempts === 1 && listenerCloses === 1 && registryCloses === 1, "startup cleanup attempts all owned resources before counter assertions");
760
+ assert.equal(clientCloseAttempts, 1, "startup cleanup attempts the owned client once");
761
+ assert.equal(listenerCloses, 1, "startup cleanup attempts the owned listener once");
762
+ assert.equal(registryCloses, 1, "startup cleanup attempts the owned registry once");
763
+ shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "verified-cleanup" }), { status: "pending" });
764
+ assert.equal((await boundedLifecycle(shutdownOutcome, "verified startup shutdown")).status, "fulfilled");
765
+ } catch (error) {
766
+ primary = error;
767
+ } finally {
768
+ try {
769
+ if (shutdownOutcome === undefined) shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), { status: "pending" });
770
+ assert.ok(shutdownOutcome);
771
+ const cleanup = await Promise.all([
772
+ drainLifecycle("startup cleanup", outcome),
773
+ drainLifecycle("startup shutdown", shutdownOutcome),
774
+ ]);
775
+ for (const result of cleanup) {
776
+ if ("error" in result) {
777
+ if (primary === undefined) primary = result.error;
778
+ else t.diagnostic(`${result.label} secondary failure: ${result.error instanceof Error ? result.error.message : String(result.error)}`);
779
+ } else if (result.outcome.status === "rejected") {
780
+ if (primary === undefined) primary = result.outcome.error;
781
+ else t.diagnostic(`${result.label} secondary rejection: ${result.outcome.error instanceof Error ? result.outcome.error.message : String(result.outcome.error)}`);
782
+ }
783
+ }
784
+ } catch (error) {
785
+ if (primary === undefined) primary = error;
786
+ else t.diagnostic(`startup cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
787
+ }
788
+ }
789
+ if (primary !== undefined) throw primary;
790
+ });
322
791
 
323
792
  test("child parent-message tooling admits notifications and the active parent preserves raw model text", async () => {
324
793
  const child = fakePi();
@@ -748,20 +1217,18 @@ test("research launch transports selected grants and only matching existing exte
748
1217
  fake.pi.getActiveTools = () => ["fetch_content", "web_search", "mcp", "bash"];
749
1218
  fake.pi.getAllTools = () => fake.pi.getActiveTools().map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) as never;
750
1219
  const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
751
- 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) }] };
752
1220
  let childEnv: NodeJS.ProcessEnv = {};
753
1221
  gentleAgents(fake.pi, {}, { ...runtime.deps, home: fixtureHome, spawn: (command, args, options) => {
754
1222
  childEnv = options.env!;
755
1223
  return runtime.deps.spawn!(command, args, options);
756
1224
  } });
757
- 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);
1225
+ 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 }, undefined, undefined, ctx);
758
1226
  await tick();
759
1227
  const argv = runtime.spawned[0];
760
- assert.equal(argv[argv.indexOf("--tools") + 1], "read,write,fetch_content,subagent_parent_message");
1228
+ assert.equal(argv[argv.indexOf("--tools") + 1], "fetch_content,subagent_parent_message");
761
1229
  assert.equal(argv[argv.indexOf("--extension") + 1], "/installed/web.ts");
762
1230
  assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), selection);
763
- assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact);
764
- assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact);
1231
+ assert.equal(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact, undefined);
765
1232
  assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_selection, "fresh selection must be expressible on continuation");
766
1233
  assert.ok(JSON.parse(childEnv.GENTLE_PI_RESEARCH_TOOLS!).includes("subagent_parent_message"));
767
1234
  assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /documentation: available/);
@@ -769,33 +1236,46 @@ test("research launch transports selected grants and only matching existing exte
769
1236
  runtime.children[0].emit({ type: "agent_settled" });
770
1237
  await tick();
771
1238
  const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
772
- 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);
773
- assert.match(rejected.content[0].text, /scope/);
774
- assert.equal(runtime.spawned.length, 1);
775
- await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background", research_artifact: artifact }, undefined, undefined, ctx);
1239
+ await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background" }, undefined, undefined, ctx);
776
1240
  await tick();
777
1241
  assert.equal(runtime.spawned.length, 2);
778
1242
  assert.ok(!runtime.spawned[1].includes("--extension"), "no inherited research selection");
779
- assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "read,write,subagent_parent_message");
1243
+ assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "subagent_parent_message");
780
1244
  assert.equal(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), null);
781
- assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact, "denial intent and exact store/path survive re-entry");
782
1245
  fake.pi.getActiveTools = () => ["web_search"];
783
1246
  runtime.children[1].emit({ type: "agent_settled" });
784
1247
  await tick();
785
- 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);
1248
+ const denied = await fake.tools.get("subagent_continue")!.execute("missing-tool", { task_id: taskId, prompt: "Retry same scope", mode: "background", research_selection: selection }, undefined, undefined, ctx);
786
1249
  await tick();
787
1250
  assert.ok(!runtime.spawned[2].includes("--extension"));
788
1251
  runtime.children[2].emit({ type: "agent_settled" });
789
1252
  await tick();
790
1253
  fake.pi.getActiveTools = () => ["fetch_content", "web_search"];
791
- 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);
1254
+ 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 }, undefined, undefined, ctx);
792
1255
  await tick();
793
1256
  assert.equal(runtime.spawned[3][runtime.spawned[3].indexOf("--extension") + 1], "/installed/web.ts");
794
- assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact);
795
1257
  await fake.fire("session_shutdown", ctx);
796
1258
  });
797
1259
 
798
- test("research child inventory requires every canonical open-web tool", () => {
1260
+ test("research registered continuation needs no prior artifact identity", async () => {
1261
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
1262
+ const home = join(root, "optional-research-home");
1263
+ mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
1264
+ writeFileSync(join(home, ".pi/agent/agents/sdd-research.md"), "---\nname: sdd-research\ntools: [read]\n---\nExplore a question.");
1265
+ gentleAgents(h.pi, {}, { ...runtime.deps, home });
1266
+ await h.fire("session_start", ctx);
1267
+ const first = await h.tools.get("subagent_run")!.execute("first", { agent: "sdd-research", task: "Inspect a question", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
1268
+ await tick();
1269
+ runtime.children[0].emit({ type: "agent_settled" });
1270
+ await tick();
1271
+ const taskId = (first.details.gentleAgents as { taskId: string }).taskId;
1272
+ const next = await h.tools.get("subagent_continue")!.execute("next", { task_id: taskId, prompt: "Investigate the remaining question", mode: "background" }, undefined, undefined, ctx);
1273
+ await tick();
1274
+ assert.equal(runtime.spawned.length, 2, JSON.stringify(next));
1275
+ await h.fire("session_shutdown", ctx);
1276
+ });
1277
+
1278
+ test("research child inventory exposes remaining authorized tools", () => {
799
1279
  const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
800
1280
  for (const missing of [undefined, ...required]) {
801
1281
  const hooks = new Map<string, (event: any) => any>();
@@ -803,7 +1283,7 @@ test("research child inventory requires every canonical open-web tool", () => {
803
1283
  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;
804
1284
  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"])) } }) });
805
1285
  const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt;
806
- assert.match(prompt, new RegExp(`open-web: ${missing === undefined ? "available" : "blocked"}`));
1286
+ assert.match(prompt, /open-web: available/);
807
1287
  assert.match(prompt, new RegExp(`documentation: ${missing === "fetch_content" ? "blocked" : "available"}`));
808
1288
  assert.match(prompt, /Availability is not evidence/);
809
1289
  }
@@ -816,7 +1296,7 @@ test("research child rechecks local inventory and blocks gateway calls", async (
816
1296
  assert.match(hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt, /documentation: blocked/);
817
1297
  assert.equal(hooks.get("tool_call")!({ toolName: "mcp" }).block, true);
818
1298
  assert.equal(hooks.get("tool_call")!({ toolName: "fetch_content" }).block, true);
819
- assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing artifact scope cannot authorize a read");
1299
+ assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing path context cannot authorize a read");
820
1300
  });
821
1301
 
822
1302
  async function shutdownAndRestoreNativeSpawn(
@@ -879,7 +1359,7 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
879
1359
  ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
880
1360
  d.deps.resolveWorktree = (path, base) => {
881
1361
  const absolute = resolve(base, path);
882
- const worktree = [cwd, sibling].find((candidate) => absolute === candidate || absolute.startsWith(`${candidate}/`));
1362
+ const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
883
1363
  return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
884
1364
  };
885
1365
  const spawn = d.deps.spawn!;
@@ -911,6 +1391,14 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
911
1391
  });
912
1392
  }
913
1393
 
1394
+ test("worktree attribution containment respects Windows path boundaries", () => {
1395
+ const candidate = win32.resolve("C:\\fixture", "project");
1396
+ assert.equal(containsResolvedPath(candidate, win32.resolve(candidate), win32), true, "the worktree root itself is contained");
1397
+ assert.equal(containsResolvedPath(candidate, win32.resolve(candidate, "nested", "file.ts"), win32), true, "Windows descendants are contained");
1398
+ assert.equal(containsResolvedPath(candidate, win32.resolve(candidate, ".."), win32), false, "the parent is excluded");
1399
+ assert.equal(containsResolvedPath(candidate, win32.resolve("C:\\fixture", "project-sibling", "file.ts"), win32), false, "a sibling prefix is excluded");
1400
+ });
1401
+
914
1402
  test("default Node spawn adapter distinguishes IPC-only and permission-capable canonical Git children", async () => {
915
1403
  const childProcess = createRequire(import.meta.url)("node:child_process") as typeof import("node:child_process");
916
1404
  const originalSpawn = childProcess.spawn;
@@ -918,23 +1406,16 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
918
1406
  const captured: Array<{ command: string; args: readonly string[]; options: CapturedSpawnOptions }> = [];
919
1407
  const children: FakeChild[] = [];
920
1408
  const shutdown: Array<() => Promise<void>> = [];
921
- const canonicalGitCwd = join(root, "canonical-git-project");
922
- const gitBin = join(root, "canonical-git-bin");
923
- mkdirSync(join(canonicalGitCwd, ".git"), { recursive: true });
924
- mkdirSync(gitBin, { recursive: true });
925
- const gitFixture = join(gitBin, "git");
926
- 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`);
927
- chmodSync(gitFixture, 0o755);
928
- const withCanonicalGitFixture = <T>(action: () => T): T => {
929
- const previousPath = process.env.PATH;
930
- process.env.PATH = gitBin;
931
- try {
932
- return action();
933
- } finally {
934
- if (previousPath === undefined) delete process.env.PATH;
935
- else process.env.PATH = previousPath;
936
- }
937
- };
1409
+ const canonicalGitFixture = mkdtempSync(join(tmpdir(), "gentle-agents-canonical-git-"));
1410
+ const canonicalGitCwd = join(canonicalGitFixture, "project");
1411
+ const gitTemplate = join(canonicalGitFixture, "template");
1412
+ try {
1413
+ mkdirSync(gitTemplate);
1414
+ execFileSync("git", ["init", "--quiet", `--template=${gitTemplate}`, canonicalGitCwd]);
1415
+ } catch (error) {
1416
+ rmSync(canonicalGitFixture, { recursive: true, force: true });
1417
+ throw error;
1418
+ }
938
1419
  childProcess.spawn = ((command: string, args: readonly string[], options: Record<string, unknown>) => {
939
1420
  captured.push({ command, args, options: options as unknown as CapturedSpawnOptions });
940
1421
  const child = fakeChild();
@@ -945,12 +1426,12 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
945
1426
  try {
946
1427
  const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
947
1428
  const h = fakePi();
948
- gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined });
1429
+ gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
949
1430
  const { ctx } = fakeContext();
950
1431
  (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
951
1432
  await h.fire("session_start", ctx);
952
1433
  shutdown.push(() => h.fire("session_shutdown", ctx));
953
- return { h, ctx, result: withCanonicalGitFixture(() => h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx)) };
1434
+ return { h, ctx, result: h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx) };
954
1435
  };
955
1436
  const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });
956
1437
  await tick();
@@ -968,6 +1449,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
968
1449
 
969
1450
  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."];
970
1451
  assert.equal(captured.length, 3, "the extension reaches Node's spawn boundary for IPC-only and permission-channel launches");
1452
+ const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
971
1453
  for (const [index, fixture] of ["task", "background", "permission"].entries()) {
972
1454
  const permissionChannel = index === 2;
973
1455
  const ownedIpc = captured[index]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
@@ -979,13 +1461,17 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
979
1461
  assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
980
1462
  assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
981
1463
  assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
982
- 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");
1464
+ assert.deepEqual(captured[index]?.options.stdio, permissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "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");
983
1465
  }
984
1466
  await Promise.all(shutdown.map((close) => close()));
985
1467
  assert.deepEqual(children[1]?.killed, ["SIGTERM"], "session shutdown cleans up an active background child");
986
1468
  shutdown.length = 0;
987
1469
  } finally {
988
- await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => Promise.all(shutdown.map((close) => close())));
1470
+ try {
1471
+ await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => Promise.all(shutdown.map((close) => close())));
1472
+ } finally {
1473
+ rmSync(canonicalGitFixture, { recursive: true, force: true });
1474
+ }
989
1475
  }
990
1476
  });
991
1477
 
@@ -1010,7 +1496,7 @@ test("native spawn interception restores CommonJS and ESM exports after rejected
1010
1496
  return undefined;
1011
1497
  };
1012
1498
  }
1013
- gentleAgents(h.pi, {}, { home, agentHome: join(home, ".pi", "agent"), env: { PATH: "/bin" }, pi: { command: "/fixture/pi", args: [] }, resolveWorktree: () => undefined });
1499
+ gentleAgents(h.pi, {}, { home, agentHome: join(home, ".pi", "agent"), env: { PATH: "/bin" }, pi: { command: "/fixture/pi", args: [] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
1014
1500
  const { ctx } = fakeContext();
1015
1501
  await h.fire("session_start", ctx);
1016
1502
  await h.tools.get("subagent_run")!.execute("cleanup", { agent: "explore", task: "Keep cleanup live", mode: "background" }, undefined, undefined, ctx);
@@ -1190,16 +1676,16 @@ test("delayed child spawn retains the originating session and cannot append into
1190
1676
 
1191
1677
  test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
1192
1678
  assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
1193
- sessions: "/profiles/pi-principal/agent/gentle-agents/sessions",
1194
- transcripts: "/profiles/pi-principal/agent/gentle-agents/transcripts",
1679
+ sessions: join("/profiles/pi-principal/agent", "gentle-agents", "sessions"),
1680
+ transcripts: join("/profiles/pi-principal/agent", "gentle-agents", "transcripts"),
1195
1681
  });
1196
1682
  assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-lab/agent"), {
1197
- sessions: "/profiles/pi-lab/agent/gentle-agents/sessions",
1198
- transcripts: "/profiles/pi-lab/agent/gentle-agents/transcripts",
1683
+ sessions: join("/profiles/pi-lab/agent", "gentle-agents", "sessions"),
1684
+ transcripts: join("/profiles/pi-lab/agent", "gentle-agents", "transcripts"),
1199
1685
  });
1200
1686
  assert.deepEqual(agentRuntimePaths("/home/x"), {
1201
- sessions: "/home/x/.pi/agent/gentle-agents/sessions",
1202
- transcripts: "/home/x/.pi/agent/gentle-agents/transcripts",
1687
+ sessions: join("/home/x", ".pi", "agent", "gentle-agents", "sessions"),
1688
+ transcripts: join("/home/x", ".pi", "agent", "gentle-agents", "transcripts"),
1203
1689
  });
1204
1690
  });
1205
1691
 
@@ -1258,6 +1744,108 @@ for (const [key, tilde] of [["GENTLE_PI_AGENT_HOME", false], ["PI_CODING_AGENT_D
1258
1744
  });
1259
1745
  }
1260
1746
 
1747
+ // A per-repository profile pin is resolved at launch against the global profiles
1748
+ // store. The fixture writes both layers into a sandbox instead of a real clone and
1749
+ // binds them to the launch through the worktree resolver the launch already uses,
1750
+ // so these tests never depend on the ambient Git state.
1751
+ function pinFixture(name: string) {
1752
+ const base = join(root, `pin-${name}`);
1753
+ const worktreeRoot = join(base, "worktree");
1754
+ const commonDir = join(base, "git-common");
1755
+ const configHome = join(base, "config");
1756
+ for (const dir of [worktreeRoot, commonDir, configHome]) mkdirSync(dir, { recursive: true });
1757
+ const writePinText = (path: string, profile: string) => {
1758
+ mkdirSync(dirname(path), { recursive: true });
1759
+ writeFileSync(path, `${JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }, null, 2)}\n`);
1760
+ };
1761
+ const localPinPath = join(commonDir, "gentle-ai", "profile-pin.json");
1762
+ const declarationPath = join(worktreeRoot, ".pi", "gentle-ai", "profile.json");
1763
+ return {
1764
+ root: worktreeRoot,
1765
+ commonDir,
1766
+ configHome,
1767
+ localPinPath,
1768
+ declarationPath,
1769
+ writePin: (profile: string) => writePinText(localPinPath, profile),
1770
+ writeDeclaration: (profile: string) => writePinText(declarationPath, profile),
1771
+ writeStore: (profiles: Record<string, unknown>) => {
1772
+ writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`);
1773
+ },
1774
+ };
1775
+ }
1776
+
1777
+ // The model the child was actually spawned with, resolved for the repository the
1778
+ // pin fixture binds to the launch.
1779
+ async function launchPinned(base: ReturnType<typeof pinFixture>): Promise<string> {
1780
+ const harness = deps();
1781
+ harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
1782
+ harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome };
1783
+ const { pi, tools, fire } = fakePi();
1784
+ gentleAgents(pi, {}, harness.deps);
1785
+ const { ctx } = fakeContext();
1786
+ await fire("session_start", ctx);
1787
+ try {
1788
+ await tools.get("subagent_run")!.execute("pin", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
1789
+ await tick();
1790
+ const args = harness.spawned[0];
1791
+ return args[args.indexOf("--model") + 1];
1792
+ } finally {
1793
+ await fire("session_shutdown", ctx);
1794
+ await tick();
1795
+ }
1796
+ }
1797
+
1798
+ test("a local pin routes the repository's subagent launches through the pinned profile", async () => {
1799
+ const base = pinFixture("local");
1800
+ base.writeStore({
1801
+ pinned: {
1802
+ // The reserved orchestrator key travels inside the profile but is never
1803
+ // subagent routing.
1804
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
1805
+ explore: { model: "openai/alpha", thinking: "minimal" },
1806
+ },
1807
+ });
1808
+ base.writePin("pinned");
1809
+ assert.equal(await launchPinned(base), "openai/alpha:minimal");
1810
+ });
1811
+
1812
+ test("a pinned profile replaces global subagent routing instead of merging it", async () => {
1813
+ const base = pinFixture("replace");
1814
+ // The global subagents.json routes explore at a lower effort and the pinned
1815
+ // profile does not mention explore at all: wholesale replacement returns it to
1816
+ // its definition routing instead of inheriting the global profile.
1817
+ base.writeStore({ pinned: { helper: { model: "openai/beta" } } });
1818
+ base.writePin("pinned");
1819
+ assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:high");
1820
+ });
1821
+
1822
+ test("a committed repository declaration pins the worktree when no local pin exists", async () => {
1823
+ const base = pinFixture("declaration");
1824
+ base.writeStore({ declared: { explore: { model: "openai/alpha" } } });
1825
+ base.writeDeclaration("declared");
1826
+ assert.equal(await launchPinned(base), "openai/alpha:high");
1827
+ });
1828
+
1829
+ test("a local pin takes precedence over the worktree's repository declaration", async () => {
1830
+ const base = pinFixture("precedence");
1831
+ base.writeStore({
1832
+ declared: { explore: { model: "openai/alpha" } },
1833
+ local: { explore: { model: "openai/beta" } },
1834
+ });
1835
+ base.writeDeclaration("declared");
1836
+ base.writePin("local");
1837
+ assert.equal(await launchPinned(base), "openai/beta:high");
1838
+ });
1839
+
1840
+ test("a stale or unreadable pin degrades to the global routing instead of failing the launch", async () => {
1841
+ const base = pinFixture("stale");
1842
+ base.writeStore({ other: { explore: { model: "openai/alpha" } } });
1843
+ base.writePin("deleted-profile");
1844
+ assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:low");
1845
+ writeFileSync(base.localPinPath, "{ not json\n");
1846
+ assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:low");
1847
+ });
1848
+
1261
1849
  test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
1262
1850
  assert.equal(agentsEnabled({}), true);
1263
1851
  assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
@@ -1296,7 +1884,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
1296
1884
  gentleAgents(pi, {}, harness.deps);
1297
1885
  const { ctx, widget } = fakeContext();
1298
1886
  await fire("session_start", ctx);
1299
- assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reconcile", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
1887
+ assert.deepEqual([...tools.keys()].sort(), ["orchestrator_list", "orchestrator_send_message", "orchestrator_session_id", "subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
1300
1888
  const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
1301
1889
  assert.match(listed.content[0].text, /- explore \(global\): maps things/);
1302
1890
 
@@ -1825,6 +2413,153 @@ test("the production overlay reads terminal rows at render time without a minimu
1825
2413
  await opened;
1826
2414
  });
1827
2415
 
2416
+
2417
+ test("default session transport selects Windows or POSIX classes without mutating the process platform", () => {
2418
+ const registry = {} as never;
2419
+ const onNotification = async () => {};
2420
+ const windows = createDefaultSessionTransport("win32");
2421
+ assert.ok(windows.createListener(registry, "s1", onNotification) instanceof WindowsActiveSessionListener);
2422
+ assert.ok(windows.createClient(registry, "s1") instanceof WindowsActiveSessionClient);
2423
+ const posix = createDefaultSessionTransport("linux");
2424
+ assert.ok(posix.createListener(registry, "s1", onNotification) instanceof ActiveSessionListener);
2425
+ assert.ok(posix.createClient(registry, "s1") instanceof ActiveSessionClient);
2426
+ assert.equal(posix.createRegistry, createDefaultSessionTransport("darwin").createRegistry);
2427
+ assert.equal(windows.createRegistry, createDefaultSessionTransport("win32").createRegistry);
2428
+ assert.notEqual(windows.createRegistry, posix.createRegistry);
2429
+ });
2430
+
2431
+ test("session transport startup failure cleans the constructed Windows-capable transport and stays unavailable", async () => {
2432
+ const h = fakePi();
2433
+ const runtime = deps();
2434
+ let registryCloses = 0;
2435
+ let listenerCloses = 0;
2436
+ let clientCloses = 0;
2437
+ const registry = {
2438
+ list: async () => [],
2439
+ listActivations: async () => [],
2440
+ close: async () => { registryCloses += 1; },
2441
+ };
2442
+ const transport: SessionTransportFactory = {
2443
+ createRegistry: async () => registry,
2444
+ createListener: () => ({
2445
+ registry,
2446
+ start: async () => { throw new Error("unavailable"); },
2447
+ close: async () => { listenerCloses += 1; },
2448
+ }),
2449
+ createClient: () => ({
2450
+ close: () => { clientCloses += 1; },
2451
+ sendNotification: async () => ({ id: "unused", accepted: true }),
2452
+ }),
2453
+ };
2454
+ runtime.deps.sessionTransport = transport;
2455
+ gentleAgents(h.pi, {}, runtime.deps);
2456
+ const { ctx } = fakeContext();
2457
+ await h.fire("session_start", ctx);
2458
+ await eventually(() => clientCloses === 1 && listenerCloses === 1 && registryCloses === 1, "startup failure cleanup completes");
2459
+ assert.equal(clientCloses, 1);
2460
+ assert.equal(listenerCloses, 1);
2461
+ assert.equal(registryCloses, 1);
2462
+ assert.match((await h.tools.get("orchestrator_session_id")!.execute("id", {}, undefined, undefined, ctx)).content[0].text, /not ready/);
2463
+ });
2464
+
2465
+ test("session transport adds host tools, forwards notifications, and closes on shutdown", async () => {
2466
+ const h = fakePi();
2467
+ const runtime = deps();
2468
+ let callback: ((notification: { id: string; senderSessionId: string; message: string }) => Promise<void>) | undefined;
2469
+ let listenerStarts = 0;
2470
+ let listenerCloses = 0;
2471
+ let clientCloses = 0;
2472
+ const registry = { list: async () => [{ sessionId: "peer", reachability: "unknown" }], listActivations: async () => [] };
2473
+ runtime.deps.sessionTransport = {
2474
+ createRegistry: async () => registry,
2475
+ createListener: (_registry, _sessionId, received) => {
2476
+ callback = received;
2477
+ return { registry, start: async () => { listenerStarts += 1; }, close: async () => { listenerCloses += 1; } };
2478
+ },
2479
+ createClient: () => ({ close: () => { clientCloses += 1; } }),
2480
+ } as never;
2481
+ gentleAgents(h.pi, {}, runtime.deps);
2482
+ const { ctx } = fakeContext();
2483
+ await h.fire("session_start", ctx);
2484
+ await eventually(() => listenerStarts === 1, "session transport listener starts");
2485
+ assert.equal(listenerStarts, 1);
2486
+ assert.ok(h.tools.has("orchestrator_session_id"));
2487
+ assert.ok(h.tools.has("orchestrator_list"));
2488
+ assert.ok(h.tools.has("orchestrator_send_message"));
2489
+ assert.match((await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx)).content[0].text, /peer/);
2490
+ assert.ok(callback, "listener receives the inbound callback");
2491
+ await callback!({ id: "message-1", senderSessionId: "peer", message: "\u001b[31mraw model content" });
2492
+ assert.equal(h.sent.at(-1)?.message.customType, "gentle-agents.orchestrator-message");
2493
+ assert.match(String(h.sent.at(-1)?.message.content), /\u001b\[31mraw model content/);
2494
+ assert.deepEqual(h.sent.at(-1)?.options, { deliverAs: "followUp", triggerTurn: true });
2495
+ await h.fire("session_shutdown", ctx);
2496
+ assert.equal(clientCloses, 1);
2497
+ assert.equal(listenerCloses, 1);
2498
+ });
2499
+
2500
+ test("session transport accepts a notification while listener publication is still starting", async () => {
2501
+ const h = fakePi();
2502
+ const runtime = deps();
2503
+ let callback: ((notification: { id: string; senderSessionId: string; message: string }) => Promise<void>) | undefined;
2504
+ const registry = { list: async () => [], listActivations: async () => [] };
2505
+ runtime.deps.sessionTransport = {
2506
+ createRegistry: async () => registry,
2507
+ createListener: (_registry, _sessionId, received) => {
2508
+ callback = received;
2509
+ return { registry, start: async () => { await callback!({ id: "published", senderSessionId: "peer", message: "during publication" }); }, close: async () => {} };
2510
+ },
2511
+ createClient: () => ({ close: () => {} }),
2512
+ } as never;
2513
+ gentleAgents(h.pi, {}, runtime.deps);
2514
+ const { ctx } = fakeContext();
2515
+ await h.fire("session_start", ctx);
2516
+ await eventually(() => h.sent.at(-1)?.message.customType === "gentle-agents.orchestrator-message", "publication callback completes");
2517
+ assert.equal(h.sent.at(-1)?.message.customType, "gentle-agents.orchestrator-message");
2518
+ assert.match(String(h.sent.at(-1)?.message.content), /during publication/);
2519
+ });
2520
+
2521
+ test("session transport selects a peer for outbound delivery and rejects stale callbacks after replacement", async () => {
2522
+ const h = fakePi();
2523
+ const runtime = deps();
2524
+ const callbacks: Array<(notification: { id: string; senderSessionId: string; message: string }) => Promise<void>> = [];
2525
+ const sent: Array<{ recipient: string; message: string; expectedActivation?: unknown }> = [];
2526
+ let closed = 0;
2527
+ const records = [
2528
+ { version: 1, sessionId: "alpha", endpoint: "/alpha.sock", createdAt: 1 },
2529
+ { version: 1, sessionId: "beta", endpoint: "/beta.sock", createdAt: 2 },
2530
+ ];
2531
+ const registry = { list: async () => [], listActivations: async () => records };
2532
+ runtime.deps.sessionTransport = {
2533
+ createRegistry: async () => registry,
2534
+ createListener: (_registry, _sessionId, received) => {
2535
+ callbacks.push(received);
2536
+ return { registry, start: async () => {}, close: async () => { closed += 1; } };
2537
+ },
2538
+ createClient: () => ({
2539
+ close: () => { closed += 1; },
2540
+ sendNotification: async (recipient: string, message: string, options: { expectedActivation?: unknown }) => {
2541
+ sent.push({ recipient, message, expectedActivation: options.expectedActivation });
2542
+ return { id: "accepted-1", accepted: true };
2543
+ },
2544
+ }),
2545
+ } as never;
2546
+ gentleAgents(h.pi, {}, runtime.deps);
2547
+ const { ctx, dialogs } = fakeContext();
2548
+ await h.fire("session_start", ctx);
2549
+ await eventually(() => callbacks.length === 1, "initial transport callback registration");
2550
+ const result = await h.tools.get("orchestrator_send_message")!.execute("send", { message: "hello peer" }, undefined, undefined, ctx);
2551
+ assert.deepEqual(dialogs, ["select:Select recipient orchestrator:Orchestrator alpha|Orchestrator beta"]);
2552
+ assert.deepEqual(sent, [{ recipient: "alpha", message: "hello peer", expectedActivation: records[0] }]);
2553
+ assert.match(result.content[0].text, /accepted for delivery; it is not a delivery or read receipt/);
2554
+ const original = callbacks[0]!;
2555
+ (ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
2556
+ await h.fire("session_start", ctx);
2557
+ await eventually(() => closed === 2, "replacement closes the old client and listener");
2558
+ assert.equal(closed, 2, "replacement closes the old client and listener before activating its successor");
2559
+ await assert.rejects(original({ id: "late", senderSessionId: "alpha", message: "late callback" }), /stale session transport/);
2560
+ await h.fire("session_shutdown", ctx);
2561
+ });
2562
+
1828
2563
  // Issue #867: a completion settling while the parent agent run is active must
1829
2564
  // be held by the extension and flushed at the next turn boundary, not parked
1830
2565
  // in the host's followUp queue until the whole orchestrator run stops calling
@@ -1965,9 +2700,10 @@ test("aborting the caller's signal cancels the subagent, records it, and says wh
1965
2700
  "a warning names the abort and the cancellation",
1966
2701
  );
1967
2702
  assert.equal(harness.children[0].killed.length > 0, true, "the runner terminated the child");
2703
+
1968
2704
  });
1969
2705
 
1970
- test("selected child routes recheck provenance and keep separately authorized local tools", () => {
2706
+ test("selected child routes recheck provenance and deny research local tools", () => {
1971
2707
  const names = ["fetch_content", "web_search", "read", "write", "mem_save", "subagent_parent_message"];
1972
2708
  const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
1973
2709
  const path = join(root, "openspec/changes/demo/research.md");
@@ -1982,313 +2718,70 @@ test("selected child routes recheck provenance and keep separately authorized lo
1982
2718
  const call = hooks.get("tool_call")!;
1983
2719
  assert.equal(call({ toolName: "fetch_content" })?.block, mismatch === "none" ? undefined : true, mismatch);
1984
2720
  assert.equal(call({ toolName: "web_search" })?.block, true, "available but unselected");
1985
- 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);
1986
- }
1987
- });
1988
-
1989
- test("research child narrows artifact arguments and observes actual dual-store readbacks", async () => {
1990
- const { createHash } = await import("node:crypto");
1991
- const cwd = join(root, "bounded-child");
1992
- mkdirSync(cwd, { recursive: true });
1993
- const bytes = '{"revision":1,"outcome":"blocked"}';
1994
- 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 } };
1995
- const scope = { store: "both", worktree: cwd, changeName: "demo", retainedIntent: "fetch missing; preserve questions", locators: [locator] };
1996
- const hooks = new Map<string, (...args: unknown[]) => unknown>();
1997
- let active = ["read", "write", "mem_get_observation", "mem_save", "subagent_parent_message"];
1998
- const journal = join(cwd, "session.jsonl"); writeFileSync(journal, "");
1999
- 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 })) };
2000
- gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
2001
- const ctx = { cwd, sessionManager: { getEntries: () => [], getSessionFile: () => journal } };
2002
- const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }, ctx) as { systemPrompt: string };
2003
- assert.match(prompt.systemPrompt, /retainedIntent/);
2004
- assert.match(prompt.systemPrompt, /never authority/);
2005
- const call = (toolName: string, input: object, toolCallId = "c") => hooks.get("tool_call")!({ toolName, input, toolCallId }, ctx) as { block: boolean } | undefined;
2006
- 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 };
2007
- assert.equal(call("write", { path: locator.path, content: '{"revision":2,"outcome":"blocked"}' })?.block, true, "initial readback must precede mutation");
2008
- assert.equal(call("write", { path: join(cwd, "outside.md") })?.block, true);
2009
- assert.equal(call("mem_get_observation", { id: 13 })?.block, true);
2010
- assert.equal(call("mem_save", { project: "pi", topic_key: "sdd/other/research" })?.block, true);
2011
- assert.equal(call("read", { path: locator.path }), undefined);
2012
- assert.match(result("read", { path: locator.path }, bytes).content.at(-1)!.text, /incomplete/);
2013
- assert.equal(call("mem_get_observation", { id: 12 }), undefined);
2014
- const observed = { ...locator.engram, content: bytes };
2015
- assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify(observed)).content.at(-1)!.text, /all selected stores/);
2016
- hooks.get("before_agent_start")!({ systemPrompt: "fresh generation" }, ctx);
2017
- assert.equal(call("write", { path: locator.path, content: '{"revision":5}' })?.block, true, "new generation cannot reuse initial authorization");
2018
- call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
2019
- call("mem_get_observation", { id: 12 }); result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
2020
- const next = '{"revision":5,"outcome":"partial"}';
2021
- assert.equal(call("write", { path: locator.path, content: next }), undefined);
2022
- call("read", { path: locator.path }, "pending-read");
2023
- 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 }[] };
2024
- assert.match(pendingRead.content.at(-1)!.text, /incomplete/, "old bytes cannot complete a pending mutation");
2025
- result("write", { path: locator.path, content: next }, "written");
2026
- call("read", { path: locator.path });
2027
- assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /incomplete/);
2028
- assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }), undefined);
2029
- result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }, "saved");
2030
- call("read", { path: locator.path });
2031
- result("read", { path: locator.path }, next);
2032
- call("mem_get_observation", { id: 12 });
2033
- assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: next, revision_count: 2 })).content.at(-1)!.text, /all selected stores/);
2034
- assert.equal(call("write", { path: locator.path, content: '{"revision":2}' })?.block, true, "revision 1 to 5 to 2 is refused");
2035
- const newer = '{"revision":6}';
2036
- assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }), undefined);
2037
- result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }, "saved");
2038
- call("mem_get_observation", { id: 12 });
2039
- 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");
2040
- call("write", { path: locator.path, content: '{"revision":3,"outcome":"partial"}' });
2041
- result("write", { path: locator.path }, "permission denied", true);
2042
- call("mem_get_observation", { id: 12 });
2043
- 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");
2044
- call("mem_get_observation", { id: 12 });
2045
- assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, project: "wrong" })).isError, true);
2046
- assert.equal(call("write", { path: locator.path, content: '{"revision":4}' })?.block, true, "stale/divergent readback must refuse recovery writes");
2047
- assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: '{"revision":4}' })?.block, true);
2048
- active = active.filter(name => name !== "write");
2049
- assert.equal(call("write", { path: locator.path })?.block, true);
2050
- assert.equal((hooks.get("tool_call")!({ toolName: "read", input: { path: locator.path } }, { cwd: root }) as { block: boolean }).block, true);
2051
- active.push("write");
2052
- gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
2053
- call("read", { path: locator.path });
2054
- result("read", { path: locator.path }, bytes);
2055
- call("mem_get_observation", { id: 12 });
2056
- result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
2057
- call("write", { path: locator.path, content: next });
2058
- result("write", { path: locator.path, content: next }, "written");
2059
- const divergent = '{"revision":2,"outcome":"done"}';
2060
- call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent });
2061
- result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent }, "saved");
2062
- call("read", { path: locator.path });
2063
- result("read", { path: locator.path }, next);
2064
- call("mem_get_observation", { id: 12 });
2065
- 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");
2066
- for (const completion of [[], [{ type: "text", text: "" }], [{ type: "text", text: "denied" }]]) {
2067
- 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 }] }) });
2068
- call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
2069
- assert.equal(call("write", { path: locator.path, content: next }), undefined);
2070
- assert.equal(call("write", { path: locator.path, content: next }, "overlap")?.block, true);
2071
- 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);
2072
- call("read", { path: locator.path });
2073
- assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /proposal_ready=false/, "failed or malformed write cannot establish completion");
2074
- assert.equal(call("write", { path: locator.path, content: '{"revision":6}' })?.block, true);
2721
+ 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, toolName === "subagent_parent_message" ? undefined : true, toolName);
2075
2722
  }
2076
- for (const tool of ["write", "mem_save"]) {
2077
- const memory = tool === "mem_save", readTool = memory ? "mem_get_observation" : "read";
2078
- const input = memory ? { id: 12 } : { path: locator.path };
2079
- const mutation = memory ? { project: "pi", topic_key: locator.engram.topic_key, content: next } : { path: locator.path, content: next };
2080
- 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 }] }) });
2081
- call(readTool, input); result(readTool, input, memory ? JSON.stringify(observed) : bytes);
2082
- assert.equal(call(tool, mutation), undefined);
2083
- assert.doesNotThrow(() => hooks.get("tool_result")!({ toolName: tool, input: mutation, toolCallId: "c", content: [null], isError: false }, ctx));
2084
- call(readTool, input);
2085
- assert.match(result(readTool, input, memory ? JSON.stringify({ ...observed, content: next, revision_count: 2 }) : next).content.at(-1)!.text, /proposal_ready=false/);
2086
- assert.equal(call(tool, { ...mutation, content: '{"revision":6}' })?.block, true);
2087
- }
2088
- for (const store of ["openspec", "engram", "both"]) {
2089
- for (const bad of ["missing", "malformed", "revision", "digest", "project", "topic", "id", "worktree"]) {
2090
- 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 }] }) });
2091
- const memory = store !== "openspec";
2092
- const tool = memory ? "mem_get_observation" : "read";
2093
- const input = memory ? { id: 12 } : { path: locator.path };
2094
- if (store === "both") {
2095
- call("read", { path: locator.path });
2096
- result("read", { path: locator.path }, bytes);
2097
- assert.equal(call("write", { path: locator.path, content: next })?.block, true, "both initial stores must match before either mutation");
2098
- }
2099
- if (bad !== "missing") {
2100
- const content = bad === "revision" ? '{"revision":0}' : bad === "digest" ? '{"revision":1,"different":true}' : bytes;
2101
- const value = { ...observed, content, ...(bad === "project" ? { project: "wrong" } : bad === "topic" ? { topic_key: "wrong" } : bad === "id" ? { id: 13 } : {}) };
2102
- if (bad === "worktree") {
2103
- assert.equal((hooks.get("tool_call")!({ toolName: tool, input, toolCallId: "c" }, { cwd: root }) as { block: boolean }).block, true);
2104
- } else if (memory || !["project", "topic", "id"].includes(bad)) {
2105
- call(tool, input);
2106
- assert.equal(result(tool, input, bad === "malformed" ? "{" : memory ? JSON.stringify(value) : content).isError, true);
2107
- }
2108
- }
2109
- if (store !== "engram") assert.equal(call("write", { path: locator.path, content: next })?.block, true, `${store}/${bad}: zero writes`);
2110
- if (memory) assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next })?.block, true, `${store}/${bad}: zero saves`);
2111
- }
2112
- }
2113
-
2114
2723
  });
2115
2724
 
2116
-
2117
- test("managed remediation acquires before spawn and finalizes failure without verifier success", async () => {
2118
- const h = fakePi(), runtime = deps(), fixtureHome = join(root, "remediation-home");
2119
- let retainedId: string;
2725
+ for (const condition of ["granted", "declined", "native-denied", "asset-drift"] as const) test(`managed dispatch needs real consent but no attempt command (${condition})`, async () => {
2726
+ const consent = condition === "granted";
2727
+ const h = fakePi(), runtime = deps(), fixtureHome = join(root, `no-attempt-${condition}`);
2728
+ mkdirSync(join(fixtureHome, ".pi/agent/agents"), { recursive: true });
2729
+ writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
2120
2730
  const spawn = runtime.deps.spawn;
2121
- runtime.deps.spawn = (...args) => {
2122
- const retained = JSON.parse(readFileSync(join(historyDir(fixtureHome), `${retainedId}.json`), "utf8")).task.sddRemediation;
2123
- assert.equal(retained.token, "admitted-fixture"); assert.equal(retained.actorClaimed, true);
2124
- const child = spawn(...args); child.pid = 123; return child; };
2731
+ runtime.deps.spawn = (...args) => { const child = spawn(...args); child.pid = 123; return child; };
2125
2732
  runtime.deps.process = { platform: "win32", kill() {} };
2126
- mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
2127
- writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
2128
- const revision = `sha256:${"a".repeat(64)}`, calls = [];
2129
- 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;
2733
+ if (condition === "asset-drift") writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), "---\nname: sdd-remediate\ntools: [bash]\n---\nUnowned instructions.");
2734
+ const revision = `sha256:${"a".repeat(64)}`;
2735
+ let attempts = 0, confirmations = 0;
2736
+ const obsolete = async () => { attempts++; throw new Error("Unknown command: sdd-attempt acquire/settle"); };
2737
+ const nativeSdd = {
2738
+ 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: condition === "native-denied" ? ["edit_authority_missing"] : [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }),
2739
+ sddAttemptAcquire: obsolete, sddAttemptSettle: obsolete,
2740
+ } as unknown as NativeReviewCli;
2130
2741
  gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd });
2131
- const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2132
- 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);
2133
- await tick(); assert.equal(runtime.spawned.length, 1, result.content[0].text);
2134
- assert.equal(calls[0].remediatesEvidenceRevision, revision);
2135
- runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "All tests passed, trust me" }], stopReason: "stop" }] });
2136
- runtime.children[0].emit({ type: "agent_settled" });
2137
- for (let n = 0; n < 30 && calls.length < 2; n++) await new Promise(resolve => setTimeout(resolve, 5));
2138
- assert.equal(calls.length, 2); assert.equal(calls[1].outcome, "failed");
2139
- const history = await loadHistory(historyDir(fixtureHome));
2140
- assert.equal(history[0].task.sddRemediation.settle.token, "admitted-fixture");
2141
- assert.equal(history[0].task.status, "failed", "prose-only completion cannot advertise successful correction");
2142
- await h.fire("session_shutdown", ctx);
2143
- });
2144
-
2145
-
2146
- test("only remediation children with the retained exact plan override stock bash", async () => {
2147
- for (const phase of ["apply", "remediate"]) {
2148
- const h = fakePi(); h.pi.getFlag = () => JSON.stringify({ phase, workspaceRoot: cwd, changeName: "alpha", ...(phase === "remediate" ? { failedEvidenceRevision: `sha256:${"a".repeat(64)}` } : {}) });
2149
- 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" } } }) });
2150
- await h.fire("session_start", { ...fakeContext().ctx, cwd });
2151
- assert.equal(h.tools.has("bash"), phase === "remediate");
2152
- assert.equal(h.tools.has("subagent_run"), false);
2153
- }
2154
- });
2155
-
2156
-
2157
- test("managed remediation tools publish the typed exact evidence plan and bracket input", () => {
2158
- const h = fakePi(); gentleAgents(h.pi, {}, deps().deps);
2159
- for (const name of ["subagent_run", "subagent_continue"]) {
2160
- const schema = h.tools.get(name)!.parameters.properties.remediation as unknown as { required: string[]; properties: { plan: { required: string[] }; attempt: { properties: { token: unknown } } } };
2161
- assert.deepEqual(schema.required, ["plan", "attempt"]);
2162
- assert.deepEqual(schema.properties.plan.required, ["cwd", "commands", "runtimeHarness", "rollback"]);
2163
- assert.ok(schema.properties.attempt.properties.token);
2742
+ const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return consent; });
2743
+ await h.fire("session_start", ctx);
2744
+ const launch = 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: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
2745
+ let result: Awaited<typeof launch> | undefined;
2746
+ if (consent) result = await launch;
2747
+ else await assert.rejects(launch, condition === "native-denied" ? /Stale remediation selection/ : condition === "asset-drift" ? /Unsupported remediation actor content/ : /fresh human authorization/);
2748
+ await tick();
2749
+ assert.equal(confirmations, ["native-denied", "asset-drift"].includes(condition) ? 0 : 1);
2750
+ assert.equal(runtime.spawned.length, consent ? 1 : 0);
2751
+ assert.equal(attempts, 0);
2752
+ assert.equal(h.tools.has("subagent_reconcile"), false);
2753
+ if (consent) {
2754
+ assert.ok(runtime.children[0].written.some((command) => command.type === "prompt"), "the registered actor receives its prompt");
2755
+ const repeat = h.tools.get("subagent_run")!.execute("repeat", { agent: "sdd-remediate", task: "Correct alpha again", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
2756
+ await assert.rejects(repeat, /Remediation already queued or running/);
2757
+ assert.equal(confirmations, 2, "independent human consent does not permit overlapping managed actors");
2758
+ assert.equal(runtime.spawned.length, 1);
2759
+
2760
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Work stopped; no verification success is claimed." }], stopReason: "stop" }] });
2761
+ runtime.children[0].emit({ type: "agent_settled" });
2762
+ await tick();
2763
+ const id = (result!.details.gentleAgents as { taskId: string }).taskId;
2764
+ const status = await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
2765
+ assert.equal((status.details.gentleAgents as { status: string }).status, "completed", "ordinary task completion is not native verification approval");
2164
2766
  }
2165
- });
2166
-
2167
-
2168
- test("R1 malformed child grant denies tools even before/after failed session initialization", () => {
2169
- const hooks = new Map(), registered = [];
2170
- const pi = { on: (name, fn) => hooks.set(name, fn), registerTool: tool => registered.push(tool), getFlag: () => "{}" };
2171
- gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_SDD_REMEDIATION_PLAN: "malformed" });
2172
- const denied = () => hooks.get("tool_call")?.({ toolName: "bash", input: { command: "touch outside" } }, { cwd })?.block;
2173
- assert.equal(denied(), true);
2174
- assert.doesNotThrow(() => hooks.get("session_start")({}, { cwd }));
2175
- assert.equal(denied(), true); assert.equal(registered.length, 0);
2176
- });
2177
-
2178
-
2179
- test("public reconciliation replays retained authority, persists closure, and never exposes or starts the actor", async () => {
2180
- const fixtureHome = join(root, "remediation-reconcile");
2181
- const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained-acquire", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: `sha256:${"a".repeat(64)}` };
2182
- await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
2183
- const h = fakePi(), runtime = deps(), calls = [];
2184
- gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: {
2185
- sddAttemptAcquire: async input => { calls.push(["acquire", structuredClone(input)]); return { state: "proceed", token: "private-token" }; },
2186
- sddAttemptSettle: async input => { calls.push(["settle", structuredClone(input)]); return { state: "complete" }; },
2187
- } as unknown as NativeReviewCli });
2188
- const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2189
- const output = await h.tools.get("subagent_reconcile").execute("reconcile", { task_id: "retained" }, undefined, undefined, ctx);
2190
- assert.match(output.content[0].text, /reconciled/i);
2191
- assert.equal(JSON.stringify(output).includes("private-token"), false);
2192
- assert.deepEqual(calls[0], ["acquire", acquire]); assert.equal(calls[1][0], "settle");
2193
- assert.equal(runtime.spawned.length, 0);
2194
- const retained = (await loadHistory(historyDir(fixtureHome)))[0].task;
2195
- assert.equal(retained.sddRemediation.acquireUncertain, undefined);
2196
- assert.deepEqual(retained.sddRemediation.settlement, { state: "complete" });
2197
- });
2198
-
2199
- test("durable reconciliation locks serialize independent extension instances sharing one tasksDir", async () => {
2200
- const fixtureHome = join(root, "remediation-reconcile-independent");
2201
- const id = "retained-independent";
2202
- const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: id, workUnit: "correct", evidenceGoal: "Observed correction" };
2203
- await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
2204
- const first = fakePi(), second = fakePi(), runtime = deps();
2205
- let calls = 0, release!: (result: { state: "blocked" }) => void;
2206
- const pending = new Promise<{ state: "blocked" }>(resolve => { release = resolve; });
2207
- const native = { sddAttemptAcquire: async () => { calls++; return calls === 1 ? pending : { state: "blocked" }; } } as unknown as NativeReviewCli;
2208
- gentleAgents(first.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: native });
2209
- gentleAgents(second.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: native });
2210
- const firstContext = fakeContext(), secondContext = fakeContext();
2211
- await first.fire("session_start", firstContext.ctx); await second.fire("session_start", secondContext.ctx);
2212
- const running = first.tools.get("subagent_reconcile")!.execute("first", { task_id: id }, undefined, undefined, firstContext.ctx);
2213
- await eventually(() => calls === 1, "the first instance must reach native acquire while holding the lock");
2214
- await assert.rejects(second.tools.get("subagent_reconcile")!.execute("second", { task_id: id }, undefined, undefined, secondContext.ctx), /busy|active|already being reconciled/i);
2215
- assert.equal(calls, 1, "a busy filesystem lock fails before native acquire");
2216
- release({ state: "blocked" }); await running;
2217
- await first.fire("session_shutdown", firstContext.ctx); await second.fire("session_shutdown", secondContext.ctx);
2218
- });
2219
-
2220
- test("reconciliation reloads a stale local task and preserves the retained disk thread", async () => {
2221
- const fixtureHome = join(root, "remediation-reconcile-reload");
2222
- const id = "retained-reload";
2223
- const oldAcquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "old", workUnit: "old", evidenceGoal: "old" };
2224
- const freshAcquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "fresh", workUnit: "fresh", evidenceGoal: "fresh" };
2225
- await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire: oldAcquire, acquireUncertain: true } } as never, applyTaskEvent(emptyThread(), { type: TASK_EVENT.NOTE, text: "old thread" }));
2226
- const h = fakePi(), runtime = deps(), seen: unknown[] = [];
2227
- gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddAttemptAcquire: async input => { seen.push(structuredClone(input)); return { state: "blocked" }; } } as unknown as NativeReviewCli });
2228
- const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2229
- await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
2230
- const freshThread = applyTaskEvent(emptyThread(), { type: TASK_EVENT.NOTE, text: "fresh thread" });
2231
- await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire: freshAcquire, acquireUncertain: true } } as never, freshThread);
2232
- await h.tools.get("subagent_reconcile")!.execute("reconcile", { task_id: id }, undefined, undefined, ctx);
2233
- assert.deepEqual(seen, [freshAcquire], "native receives the force-reloaded retained request");
2234
- const stored = (await loadHistory(historyDir(fixtureHome))).find(entry => entry.task.id === id)!;
2235
- assert.deepEqual(stored.thread.items, freshThread.items, "persistence retains the exact disk thread, not the stale store thread");
2236
- await h.fire("session_shutdown", ctx);
2237
- });
2238
-
2239
- test("reconciliation releases the durable lock after native failure", async () => {
2240
- const fixtureHome = join(root, "remediation-reconcile-failure");
2241
- const id = "retained-failure";
2242
- const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: id, workUnit: "correct", evidenceGoal: "Observed correction" };
2243
- await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
2244
- const h = fakePi(), runtime = deps(); let calls = 0;
2245
- gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddAttemptAcquire: async () => { calls++; if (calls === 1) throw new TypeError("native failure"); return { state: "blocked" }; } } as unknown as NativeReviewCli });
2246
- const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2247
- await assert.rejects(h.tools.get("subagent_reconcile")!.execute("failed", { task_id: id }, undefined, undefined, ctx), /native failure/);
2248
- const recovered = await h.tools.get("subagent_reconcile")!.execute("retry", { task_id: id }, undefined, undefined, ctx);
2249
- assert.match(recovered.content[0].text, /reconciled/i);
2250
- assert.equal(calls, 2, "the second attempt acquires after finally released the first lock");
2251
2767
  await h.fire("session_shutdown", ctx);
2768
+ assert.equal(attempts, 0, "terminal cleanup must not invoke settlement");
2252
2769
  });
2253
2770
 
2254
- test("R3/R4 host reload refuses retained acquire/actor uncertainty without another launch", async () => {
2255
- for (const actorClaimed of [false, true, "blocked", "complete"]) {
2256
- const normal = typeof actorClaimed === "string";
2257
- const h = fakePi(), runtime = deps(), fixtureHome = join(root, `remediation-reload-${actorClaimed}`);
2258
- mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
2259
- writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
2260
- const revision = `sha256:${"a".repeat(64)}`;
2261
- const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
2262
- 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());
2263
- let acquisitions = 0, confirmations = 0;
2264
- 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 });
2265
- const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return true; });
2266
- await h.fire("session_start", ctx);
2267
- 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/);
2268
- assert.equal(acquisitions, 0); assert.equal(confirmations, 0); assert.equal(runtime.spawned.length, 0);
2269
- assert.deepEqual((await loadHistory(historyDir(fixtureHome)))[0].task.sddRemediation.acquire, acquire);
2270
- await h.fire("session_shutdown", ctx);
2271
- }
2272
- });
2273
2771
 
2274
- test("R3/R4 a known native-blocked settlement lets native admission decide the next attempt; an uncertain one still refuses locally", async () => {
2275
- for (const uncertain of [false, true]) {
2276
- const h = fakePi(), runtime = deps(), fixtureHome = join(root, `remediation-settlement-${uncertain}`);
2277
- mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
2278
- writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
2279
- const revision = `sha256:${"a".repeat(64)}`;
2280
- const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
2281
- await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: uncertain
2282
- ? { acquire, settlementUncertain: true, settle: { requestId: "exact" } }
2283
- : { acquire, settlement: { state: "blocked", reason: "maintainer_decision" } } } as never, emptyThread());
2284
- let acquisitions = 0, confirmations = 0;
2285
- gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, 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 } }), sddAttemptSettle: async () => ({ state: "proceed" as const }), sddAttemptAcquire: async () => { acquisitions++; return { state: "blocked" }; } } as unknown as NativeReviewCli });
2286
- const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return true; });
2287
- await h.fire("session_start", ctx);
2288
- 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), uncertain ? /reconcile exact history without actor replay/ : /no actor started/);
2289
- assert.equal(acquisitions, uncertain ? 0 : 1, "native admission is consulted once local unresolved history is not uncertain");
2290
- assert.equal(confirmations, uncertain ? 0 : 1);
2291
- assert.equal(runtime.spawned.length, 0);
2292
- await h.fire("session_shutdown", ctx);
2293
- }
2772
+ test("registered task actor receives ordinary checkbox and configured TDD guidance", async () => {
2773
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
2774
+ const home = join(root, "task-truth-home");
2775
+ mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
2776
+ writeFileSync(join(home, ".pi/agent/agents/sdd-tasks.md"), readFileSync("assets/agents/sdd-tasks.md"));
2777
+ gentleAgents(h.pi, {}, { ...runtime.deps, home });
2778
+ await h.fire("session_start", ctx);
2779
+ await h.tools.get("subagent_run")!.execute("tasks", { agent: "sdd-tasks", task: "Plan ordinary tasks", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
2780
+ await tick();
2781
+ assert.equal(runtime.spawned.length, 1);
2782
+ const args = runtime.spawned[0], instructions = args[args.indexOf("--append-system-prompt") + 1];
2783
+ assert.match(instructions, /Only when configured strict TDD is active/);
2784
+ assert.match(instructions, /- \[ \] 1\. Implement and verify the behavior\./);
2785
+ assert.doesNotMatch(instructions, /<!-- sdd-owner:/);
2786
+ await h.fire("session_shutdown", ctx);
2294
2787
  });