gentle-pi 4.3.0 → 4.4.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 (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -13,11 +13,11 @@ import test, { after, afterEach, before, mock } from "node:test";
13
13
  import type { TestContext } from "node:test";
14
14
  import { generateUnifiedPatch, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
16
- import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
16
+ import { visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
17
17
  import { sidebarState } from "../lib/shell-sidebar.ts";
18
18
  import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection } from "../lib/child-package-injection.ts";
19
19
  import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, agentResultPreview, answerThroughUi, childContextExtensionPaths, completionText, createDefaultSessionTransport, legacySubagentsInstalled, PARENT_WAKE_GRACE_MS, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
20
- import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
20
+ import { ActiveSessionClient, ActiveSessionListener, SessionPresenceError, SessionPresenceRegistry, type PresenceRecord } from "../lib/agents-session-transport.ts";
21
21
  import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
22
22
  import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
23
23
  import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
@@ -243,7 +243,8 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
243
243
  const factory = widgets.get("gentle-agents");
244
244
  return factory ? factory(tui, plainTheme).render(72).map(stripAnsi) : undefined;
245
245
  };
246
- return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
246
+ const widgetComponent = () => widgets.get("gentle-agents")?.(tui, plainTheme) as Component | undefined;
247
+ return { ctx, widget, widgetComponent, dialogs, overlays, customCompletions, customOptions };
247
248
  }
248
249
 
249
250
  for (const scenario of ["background", "task", "append-failure", "overflow", "bytes", "replacement", "task-replacement", "unsafe-id", "no-transport"] as const) {
@@ -580,7 +581,9 @@ test("gentle-agents notifies once, deduplicated, when the RPC activity publisher
580
581
  const runtime = deps();
581
582
  const scheduler = fakeScheduler();
582
583
  runtime.deps.schedule = scheduler.schedule;
583
- runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" };
584
+ // The launch reads the session profile through this env, so it must keep the
585
+ // scratch config home instead of reaching the runner's real one.
586
+ runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
584
587
  gentleAgents(h.pi, {}, runtime.deps);
585
588
  const { ctx } = fakeContext();
586
589
  Object.assign(ctx, { mode: "rpc", hasUI: true });
@@ -1415,6 +1418,152 @@ test("live-only extension instances discover same-profile peers across cwd bound
1415
1418
  await Promise.all([panel.opened, peerPanel.opened]);
1416
1419
  });
1417
1420
 
1421
+ test("rapid child deltas coalesce presence publication to a leading publish plus one trailing publish", async (t) => {
1422
+ const profile = liveProfile("live-coalesce-profile");
1423
+ const h = fakePi();
1424
+ const runtime = deps();
1425
+ const timers = recordTimers(runtime.deps);
1426
+ const { ctx } = fakeContext();
1427
+ Object.assign(ctx.sessionManager, {
1428
+ getSessionId: () => "coalesce-live",
1429
+ getSessionName: () => "coalesce-live",
1430
+ getCwd: () => join(realpathSync(root), "coalesce-live"),
1431
+ });
1432
+ gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
1433
+ const update = t.mock.method(PresencePublisher.prototype, "update");
1434
+ await h.fire("session_start", ctx);
1435
+ timers.run(400); // expire session_start's own publication window
1436
+ update.mock.resetCalls(); // session_start's own publication is out of scope
1437
+
1438
+ const launch = await h.tools.get("subagent_run")!.execute("coalesce", { agent: "local", task: "Stream", mode: "background" }, undefined, undefined, ctx);
1439
+ await tick();
1440
+ const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
1441
+ assert.ok(taskId);
1442
+ assert.equal(runtime.children.length, 1);
1443
+ // The admission burst publishes its discrete semantic events immediately:
1444
+ // the admission itself plus the child's worktree registration. Per-delta
1445
+ // events below still collapse into one trailing publish.
1446
+ assert.equal(update.mock.callCount(), 2, "the admission burst publishes its semantic events on the leading edge");
1447
+ update.mock.resetCalls();
1448
+
1449
+ for (let delta = 0; delta < 20; delta++) {
1450
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `delta ${delta}` } });
1451
+ }
1452
+ assert.equal(update.mock.callCount(), 0, "per-delta events must not publish while the coalescing window is open");
1453
+
1454
+ timers.run(400);
1455
+ assert.equal(update.mock.callCount(), 1, "exactly one trailing publish after the burst");
1456
+ update.mock.resetCalls();
1457
+
1458
+ // After the window closes, a new burst publishes on the leading edge again.
1459
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "after window" } });
1460
+ assert.equal(update.mock.callCount(), 1, "a call after the window publishes immediately again");
1461
+ });
1462
+
1463
+ test("the trailing publication reuses cached scope facts: a served metadata query sees no Git probes", async (t) => {
1464
+ // gentle-shell#2522 regression: the coalesced trailing publish ran while a
1465
+ // peer metadata query (orchestrator_consult/orchestrator_list) was being
1466
+ // served, and its scope projection cache-missed into a synchronous Git
1467
+ // resolve. A metadata query adds no caller Git probes, so the trailing
1468
+ // publication reuses the snapshot the last event-time publish projected —
1469
+ // never probing, never dropping — until an event-time publish refreshes.
1470
+ const profile = liveProfile("live-coalesce-scope-profile");
1471
+ const h = fakePi();
1472
+ const runtime = deps();
1473
+ const timers = recordTimers(runtime.deps);
1474
+ let releaseActivations: () => void = () => {};
1475
+ // A recorded listener makes publishActivityNow project the discovery scope;
1476
+ // the registry holds the consult's activation read until the test releases it.
1477
+ runtime.deps.sessionTransport = {
1478
+ ...inertSessionTransport,
1479
+ createRegistry: async () => ({ list: async () => [], listActivations: async () => await new Promise<readonly PresenceRecord[]>(resolve => { releaseActivations = () => resolve([]); }) }),
1480
+ createListener: registry => ({ registry, record: { version: 1, sessionId: "coalesce-scope", endpoint: "/fixture/coalesce-scope", createdAt: 1 }, start: async () => {}, close: async () => {} }),
1481
+ };
1482
+ const probes: string[] = [];
1483
+ // Count only scope-projection probes: admission and launch also resolve
1484
+ // worktrees outside the projection, and only the projection can leak into a
1485
+ // peer metadata query.
1486
+ const realProject = OrchestratorScopeCache.prototype.project;
1487
+ let projecting = false;
1488
+ OrchestratorScopeCache.prototype.project = function (...args: Parameters<typeof realProject>) {
1489
+ projecting = true;
1490
+ try { return realProject.apply(this, args); } finally { projecting = false; }
1491
+ };
1492
+ t.after(() => { OrchestratorScopeCache.prototype.project = realProject; });
1493
+ runtime.deps.resolveWorktree = (path, base) => {
1494
+ if (projecting) probes.push(resolve(base, path));
1495
+ return { root: resolve(base, path), commonDir: "/fixture/common" };
1496
+ };
1497
+ const { ctx } = fakeContext();
1498
+ Object.assign(ctx.sessionManager, {
1499
+ getSessionId: () => "coalesce-scope",
1500
+ getSessionName: () => "coalesce-scope",
1501
+ getCwd: () => join(realpathSync(root), "coalesce-scope"),
1502
+ });
1503
+ gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
1504
+ const update = t.mock.method(PresencePublisher.prototype, "update");
1505
+ await h.fire("session_start", ctx);
1506
+ assert.ok(probes.length > 0, "the semantic startup publish resolves its facts eagerly");
1507
+ // Wait for the asynchronous session transport to bind its record without
1508
+ // publishing; the metadata tool answers "not ready" until it has.
1509
+ for (let i = 0; i < 100; i++) {
1510
+ const result = await h.tools.get("orchestrator_session_id")!.execute("id", {}, undefined, undefined, ctx);
1511
+ if ((result.details?.gentleAgents as { senderSessionId?: string } | undefined)?.senderSessionId) break;
1512
+ assert.ok(i < 99, "session transport binds within bounded ticks");
1513
+ await tick();
1514
+ }
1515
+ timers.run(400); // expire startup's own publication window
1516
+ update.mock.resetCalls();
1517
+ probes.length = 0;
1518
+
1519
+ const launch = await h.tools.get("subagent_run")!.execute("coalesce", { agent: "local", task: "Stream", mode: "background" }, undefined, undefined, ctx);
1520
+ await tick();
1521
+ const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
1522
+ assert.ok(taskId);
1523
+ assert.ok(probes.length > 0, "the semantic admission publish resolves new membership facts eagerly");
1524
+ // Let a settle publish absorb any late membership change before the measured window.
1525
+ timers.run(400);
1526
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "settle" } });
1527
+ await tick();
1528
+ timers.run(400);
1529
+ update.mock.resetCalls();
1530
+ probes.length = 0;
1531
+
1532
+ runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "delta" } });
1533
+ assert.equal(update.mock.callCount(), 1, "the delta publishes on the leading edge");
1534
+ assert.equal(probes.length, 0, "an unchanged membership never re-resolves facts");
1535
+
1536
+ // Membership changes mid-window (the child settles, leaving the coalesced
1537
+ // path to queue the publish) while a peer metadata query starts being
1538
+ // served — the exact collision of the regression.
1539
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
1540
+ runtime.children[0].emit({ type: "agent_settled" });
1541
+ for (let i = 0; i < 100; i++) {
1542
+ const status = await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx);
1543
+ if (/completed/.test(status.content[0]?.text ?? "")) break;
1544
+ assert.ok(i < 99, "child completes within bounded ticks");
1545
+ await tick();
1546
+ }
1547
+ assert.equal(update.mock.callCount(), 1, "the membership change stays behind the trailing edge");
1548
+ assert.equal(probes.length, 0, "queued events must not publish");
1549
+ const pendingConsult = h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer123" }, undefined, undefined, ctx);
1550
+ await tick(); // the consult suspends on its activation read, holding the window open
1551
+
1552
+ timers.run(400); // the trailing publish comes due while the query is being served
1553
+ assert.equal(update.mock.callCount(), 2, "the trailing publication still runs, never dropped");
1554
+ assert.equal(probes.length, 0, "a served metadata query adds no caller Git probes");
1555
+
1556
+ releaseActivations();
1557
+ await pendingConsult;
1558
+ assert.equal(probes.length, 0, "the whole served query stayed probe-free");
1559
+
1560
+ // After the window, an event-time publish refreshes the facts the trailing
1561
+ // reuse deferred: the changed membership resolves eagerly again.
1562
+ h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "coalesce-scope" });
1563
+ assert.ok(probes.length > 0, "an event-time publish refreshes the deferred facts");
1564
+ assert.equal(update.mock.callCount(), 3, "the refresh publishes immediately");
1565
+ });
1566
+
1418
1567
  test("manual agents command warns once in RPC mode and stays quiet without UI", async (t) => {
1419
1568
  const local = liveInstance(t, liveProfile("live-non-tui"), "non-tui");
1420
1569
  Object.assign(local.ctx, { mode: "rpc" });
@@ -2718,11 +2867,16 @@ test("foreign clone rejects aliases, absent UI, decline and changed session befo
2718
2867
  const declined = run.execute("decline", args(foreign), undefined, undefined, ctx);
2719
2868
  await tick(); decision(false);
2720
2869
  await assert.rejects(declined, /interactive/);
2870
+ // gentle-shell#2314: Git prints Windows roots with forward slashes; that spelling
2871
+ // must pass the canonical-root guard and reach consent like the native one.
2872
+ const forwardSlashes = run.execute("forward-slashes", args(foreign.replaceAll("\\", "/")), undefined, undefined, ctx);
2873
+ await tick(); decision(false);
2874
+ await assert.rejects(forwardSlashes, /interactive/);
2721
2875
  const drift = run.execute("drift", args(foreign), undefined, undefined, ctx);
2722
2876
  await tick(); id = "replacement"; decision(true);
2723
2877
  await assert.rejects(drift, /identity changed/);
2724
2878
  assert.equal(runtime.spawned.length, 0);
2725
- assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 2);
2879
+ assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 3);
2726
2880
  assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
2727
2881
  await h.fire("session_shutdown", ctx);
2728
2882
  } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
@@ -3622,7 +3776,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
3622
3776
  assert.equal(args[args.indexOf("--tools") + 1], "read,grep,subagent_parent_message");
3623
3777
  await tick();
3624
3778
  assert.match(String(harness.children[0].written[1].message), /Map lib\/ and report every module\.\n\n## Context\nFocus on agents-\*\.ts/);
3625
- assert.match(widget()![0], /^╭─ ❀ Agents · 1 active ─+╮$/);
3779
+ assert.match(widget()![0], /^╭─ ❀ Agents ▾ Collapse · 1 active ─+╮$/);
3626
3780
  assert.match(widget()![1], /^│ ◐ explore map lib modules +gpt-5\.6-terra · low · \d+s │$/);
3627
3781
  harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
3628
3782
  harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
@@ -3672,7 +3826,7 @@ test("running subagent_result polls hide only their tool chrome, not the model r
3672
3826
  assert.match(renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(80).join("\n"), /Agent result.*All done\./s, "background completion card remains visible");
3673
3827
  });
3674
3828
 
3675
- test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
3829
+ test("the Agents card moves to the fullscreen rail like Todos and its header toggles collapse on left click", async () => {
3676
3830
  const { pi, tools, fire } = fakePi();
3677
3831
  const harness = deps();
3678
3832
  gentleAgents(pi, {}, harness.deps);
@@ -3680,22 +3834,54 @@ test("the Agents widget never registers a sidebar rail part and stays visible ev
3680
3834
  // (a host with no .terminal is already a passthrough); this is the host
3681
3835
  // shape the fullscreen layout actually uses.
3682
3836
  const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
3683
- const { ctx, widget } = fakeContext(terminalTui);
3837
+ const { ctx, widgetComponent } = fakeContext(terminalTui);
3684
3838
  await fire("session_start", ctx);
3685
3839
  await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/", label: "map lib modules", mode: "background" }, undefined, undefined, ctx);
3840
+ await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Map tests/", label: "map test suites", mode: "background" }, undefined, undefined, ctx);
3686
3841
  await tick();
3687
3842
 
3688
- // The factory only runs (and only then could register a rail part) once
3689
- // something actually renders the widget, exactly like the real host.
3690
- assert.match(widget()![1]!, /explore map lib modules/);
3843
+ const component = widgetComponent()!;
3844
+ const bottom = component.render(72).map(stripAnsi);
3845
+ assert.match(bottom[0]!, /Agents ▾ Collapse/, "the header carries the same clickable control as Todos");
3846
+ assert.match(bottom.join("\n"), /explore map lib modules/);
3847
+ assert.match(bottom.join("\n"), /explore map test suites/);
3848
+
3691
3849
  const state = sidebarState(terminalTui as unknown as TUI);
3692
- assert.equal(state.parts.has("agents"), false, "Agents never claims a rail slot; the above-editor widget is its only surface");
3850
+ const rail = state.parts.get("agents");
3851
+ assert.ok(rail, "Agents claims the rail slot, so a wide fullscreen terminal shows it at the right");
3852
+ assert.equal(rail.render(46).filter((line) => /explore/.test(stripAnsi(line))).length, 2, "the rail lists every task");
3853
+ assert.ok(rail.digest?.(), "the rail declares a digest so its clock and counters repaint");
3693
3854
 
3694
- // Simulate the fullscreen sidebar actively owning the host, the same
3695
- // condition sidebarPart used to suppress a registered bottom widget under.
3855
+ // While the fullscreen sidebar owns the host, the bottom copy steps aside.
3696
3856
  state.active = true;
3697
3857
  state.ownsHost = () => true;
3698
- assert.match(widget()![1]!, /explore map lib modules/, "the widget keeps rendering regardless of sidebar ownership");
3858
+ assert.deepEqual(component.render(72), [], "the bottom card paints nothing while the rail shows it");
3859
+ state.active = false;
3860
+
3861
+ const event = (type: "press" | "click", button: "left" | "right", y = 0) => ({
3862
+ type, button, x: 1, y, screenX: 1, screenY: y, width: 72, height: bottom.length, shift: false, alt: false, ctrl: false,
3863
+ });
3864
+ assert.equal(component.handleMouse?.(event("click", "right")), undefined);
3865
+ assert.equal(component.handleMouse?.(event("click", "left", 1)), undefined);
3866
+ assert.match(stripAnsi(component.render(72)[0]!), /Agents ▾ Collapse/, "only a left click on the header toggles");
3867
+ assert.equal(component.handleMouse?.(event("click", "left"))?.handled, true);
3868
+ const collapsed = component.render(72).map(stripAnsi);
3869
+ assert.match(collapsed[0]!, /Agents ▸ Expand/);
3870
+ assert.doesNotMatch(collapsed.join("\n"), /map test suites/, "the collapsed card keeps one row");
3871
+ assert.equal(rail.render(46).filter((line) => /explore/.test(stripAnsi(line))).length, 1, "both surfaces share the collapsed state");
3872
+ });
3873
+
3874
+ test("the Agents rail digest stays stable while no task is running, so an idle card never rebuilds the rail every second", async () => {
3875
+ const { pi, fire } = fakePi();
3876
+ gentleAgents(pi, {}, deps().deps);
3877
+ const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
3878
+ const { ctx, widgetComponent } = fakeContext(terminalTui);
3879
+ await fire("session_start", ctx);
3880
+ widgetComponent();
3881
+ const rail = sidebarState(terminalTui as unknown as TUI).parts.get("agents")!;
3882
+ // The harness clock advances on every read, so three digests span more than a second.
3883
+ const digests = [rail.digest!(), rail.digest!(), rail.digest!()];
3884
+ assert.deepEqual(new Set(digests).size, 1, "the clock only enters the digest while a task runs");
3699
3885
  });
3700
3886
 
3701
3887
  test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
@@ -4438,6 +4624,7 @@ test("session transport startup failure cleans the constructed Windows-capable t
4438
4624
 
4439
4625
  test("registered task aliases persist independently of human names and curated state", async () => {
4440
4626
  const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
4627
+ const timers = recordTimers(runtime.deps);
4441
4628
  const profile = realpathSync(mkdtempSync(join(root, "aliases-runtime-")));
4442
4629
  runtime.deps.agentHome = profile;
4443
4630
  const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/aliases.sock", createdAt: 1 };
@@ -4454,8 +4641,15 @@ test("registered task aliases persist independently of human names and curated s
4454
4641
  gentleAgents(h.pi, {}, runtime.deps);
4455
4642
  await h.fire("session_start", ctx);
4456
4643
  const tool = h.tools.get("orchestrator_session_id")!;
4457
- const declare = (params: unknown = {}, context = ctx) => tool.execute("aliases", params, undefined, undefined, context);
4458
- const aliases = () => readDiscovery(profile, listPresence(profile).entries[0])?.aliases;
4644
+ const declare = async (params: unknown = {}, context = ctx) => {
4645
+ const result = await tool.execute("aliases", params, undefined, undefined, context);
4646
+ timers.run(400); // the activity publication is coalesced; close the window
4647
+ return result;
4648
+ };
4649
+ const aliases = () => {
4650
+ timers.run(400);
4651
+ return readDiscovery(profile, listPresence(profile).entries[0])?.aliases;
4652
+ };
4459
4653
  const first = await declare({ subject: "Task A", state: { progress: "Starting", work: { area: "Auth" } } });
4460
4654
  const initial = h.entries.at(-1)!;
4461
4655
  assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task A" });
@@ -4505,6 +4699,7 @@ test("registered task aliases persist independently of human names and curated s
4505
4699
  test("registered session identity declares subjects and refreshes canonical idle renames", async (t) => {
4506
4700
  const h = fakePi();
4507
4701
  const runtime = deps();
4702
+ const timers = recordTimers(runtime.deps);
4508
4703
  const profile = realpathSync(mkdtempSync(join(root, "subject-runtime-")));
4509
4704
  runtime.deps.agentHome = profile;
4510
4705
  let ready = false;
@@ -4532,7 +4727,11 @@ test("registered session identity declares subjects and refreshes canonical idle
4532
4727
  await h.fire("session_start", ctx);
4533
4728
  await eventually(() => ready, "subject transport ready");
4534
4729
  const tool = h.tools.get("orchestrator_session_id")!;
4535
- const declare = (subject?: unknown, context = ctx) => tool.execute("id", { subject }, undefined, undefined, context);
4730
+ const declare = async (subject?: unknown, context = ctx) => {
4731
+ const result = await tool.execute("id", { subject }, undefined, undefined, context);
4732
+ timers.run(400); // the activity publication is coalesced; close the window
4733
+ return result;
4734
+ };
4536
4735
  const initialWrites = h.entries.length;
4537
4736
  await assert.rejects(() => tool.execute("invalid-work", { subject: "No effect", state: { work: { topic: "Login" } } }, undefined, undefined, ctx), /invalid/);
4538
4737
  assert.equal(name, "");
@@ -4550,6 +4749,7 @@ test("registered session identity declares subjects and refreshes canonical idle
4550
4749
  { kind: "issue", repository: "github.com/Owner/Repo", id: "12" },
4551
4750
  ] };
4552
4751
  await tool.execute("publish", { state: { objective: "Verify auth", decisions: "Advisory only", work } }, undefined, undefined, ctx);
4752
+ timers.run(400);
4553
4753
  assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
4554
4754
  assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.work, work);
4555
4755
  const published = h.entries.at(-1)!;
@@ -4571,6 +4771,7 @@ test("registered session identity declares subjects and refreshes canonical idle
4571
4771
  assert.equal(readDiscovery(profile, renamed)?.scope?.host.resolvedAt, readDiscovery(profile, before)?.scope?.host.resolvedAt);
4572
4772
  registrations.push({ ...registrations[0], data: { ...registrations[0].data, root: join(cwd, "registered") } });
4573
4773
  h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "s1" });
4774
+ timers.run(400);
4574
4775
  assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.registered.map(fact => fact.root), [cwd, join(cwd, "registered")]);
4575
4776
  await declare("Do not overwrite");
4576
4777
  assert.deepEqual(names, ["Fix auth"]);
@@ -4581,10 +4782,12 @@ test("registered session identity declares subjects and refreshes canonical idle
4581
4782
  await declare("😀".repeat(130));
4582
4783
  assert.equal(Array.from(name).length, 120);
4583
4784
  await tool.execute("withdraw", { state: null }, undefined, undefined, ctx);
4785
+ timers.run(400);
4584
4786
  await h.fire("session_start", ctx, { reason: "reload" });
4585
4787
  await eventually(() => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state === null, "withdrawal reload");
4586
4788
  Object.assign(ctx.sessionManager, { getBranch: () => [published] });
4587
4789
  await h.fire("session_tree", ctx);
4790
+ timers.run(400);
4588
4791
  assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
4589
4792
  const sameId = fakeContext().ctx;
4590
4793
  Object.assign(sameId.sessionManager, { getSessionName: () => name, getBranch: () => [] });
@@ -4593,6 +4796,7 @@ test("registered session identity declares subjects and refreshes canonical idle
4593
4796
  const replacement = fakeContext().ctx;
4594
4797
  Object.assign(replacement.sessionManager, { getSessionId: () => "s2", getSessionName: () => "Replacement" });
4595
4798
  await h.fire("session_start", replacement, { reason: "new" });
4799
+ timers.run(400); // the post-transport-start publication is coalesced; close the window
4596
4800
  heartbeats[0]();
4597
4801
  assert.equal(listPresence(profile).entries.length, 1);
4598
4802
  assert.equal(listPresence(profile).entries[0].label, "Replacement");
@@ -4728,6 +4932,33 @@ test("registered orchestrator_list joins peer metadata without child launches or
4728
4932
  } finally { publisher.dispose(); }
4729
4933
  });
4730
4934
 
4935
+ test("orchestrator_list surfaces the real session-presence failure code", async () => {
4936
+ const h = fakePi();
4937
+ const runtime = deps();
4938
+ const profile = realpathSync(mkdtempSync(join(root, "discovery-failure-")));
4939
+ let ready = false;
4940
+ const registry = { list: async () => [], listActivations: async () => { throw new SessionPresenceError("busy", "presence registry is busy"); } };
4941
+ runtime.deps.agentHome = profile;
4942
+ runtime.deps.sessionTransport = {
4943
+ createRegistry: async () => registry,
4944
+ createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
4945
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
4946
+ };
4947
+ gentleAgents(h.pi, {}, runtime.deps);
4948
+ const { ctx } = fakeContext();
4949
+ try {
4950
+ await h.fire("session_start", ctx);
4951
+ await eventually(() => ready, "discovery transport ready");
4952
+ const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
4953
+ assert.match(result.content[0].text, /session discovery is unavailable \(busy\)/);
4954
+ assert.equal((result.details as { error?: string }).error, "unavailable");
4955
+ assert.equal((result.details as { reason?: string }).reason, "busy");
4956
+ } finally {
4957
+ await h.fire("session_shutdown", ctx);
4958
+ rmSync(profile, { recursive: true, force: true });
4959
+ }
4960
+ });
4961
+
4731
4962
  test("orchestrator_list labels a live peer that predates discovery publication (#2305)", async () => {
4732
4963
  const h = fakePi();
4733
4964
  const runtime = deps();
@@ -6704,6 +6935,7 @@ test("a human stop of a monitor from /gentle:jobs delivers its pending lines, th
6704
6935
  // orchestrator_session_id, and never replaces the owner's curated fields.
6705
6936
  test("todo progress publishes merged into the owner's curated state, only for this session", async () => {
6706
6937
  const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
6938
+ const timers = recordTimers(runtime.deps);
6707
6939
  const profile = realpathSync(mkdtempSync(join(root, "todo-progress-")));
6708
6940
  runtime.deps.agentHome = profile;
6709
6941
  const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress.sock", createdAt: 1 };
@@ -6717,7 +6949,10 @@ test("todo progress publishes merged into the owner's curated state, only for th
6717
6949
  Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
6718
6950
  gentleAgents(h.pi, {}, runtime.deps);
6719
6951
  await h.fire("session_start", ctx);
6720
- const published = () => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
6952
+ const published = () => {
6953
+ timers.run(400); // the activity publication is coalesced; close the window
6954
+ return readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
6955
+ };
6721
6956
  h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 2, current: "Write parser" });
6722
6957
  // PR #2290 review: a todo title is private work text; peers see counts only.
6723
6958
  assert.deepEqual(published(), { progress: "0/2 done" }, "progress publishes before any curated state, without the todo title");
@@ -6745,6 +6980,7 @@ test("todo progress publishes merged into the owner's curated state, only for th
6745
6980
  // progress line, so peers lost it until the next todo write.
6746
6981
  test("an owner publication without progress keeps the automatic progress; explicit progress and null still win", async () => {
6747
6982
  const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
6983
+ const timers = recordTimers(runtime.deps);
6748
6984
  const profile = realpathSync(mkdtempSync(join(root, "todo-progress-merge-")));
6749
6985
  runtime.deps.agentHome = profile;
6750
6986
  const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress-merge.sock", createdAt: 1 };
@@ -6758,7 +6994,10 @@ test("an owner publication without progress keeps the automatic progress; explic
6758
6994
  Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
6759
6995
  gentleAgents(h.pi, {}, runtime.deps);
6760
6996
  await h.fire("session_start", ctx);
6761
- const published = () => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
6997
+ const published = () => {
6998
+ timers.run(400); // the activity publication is coalesced; close the window
6999
+ return readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
7000
+ };
6762
7001
  const own = (state: unknown) => h.tools.get("orchestrator_session_id")!.execute("own", { state }, undefined, undefined, ctx);
6763
7002
  h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Parser" });
6764
7003
  await own({ objective: "Ship the ledger" });
@@ -6779,3 +7018,109 @@ test("an owner publication without progress keeps the automatic progress; explic
6779
7018
  await own({ objective: full });
6780
7019
  assert.deepEqual(published(), { objective: full }, "a merge over the state budget publishes the owner's state as given");
6781
7020
  });
7021
+
7022
+ // Issue #2196 (PR #2233 maintainer decision): at launch, a model_profiles key
7023
+ // that names no discovered agent but suggests the launched one is reported
7024
+ // instead of being silently ignored while the child inherits the orchestrator
7025
+ // model. An omitted or matched role keeps today's inheritance untouched.
7026
+ test("subagent_run warns when a model profile key suggests the launched agent", async () => {
7027
+ const child = fakePi();
7028
+ const runtime = deps();
7029
+ gentleAgents(child.pi, {}, runtime.deps);
7030
+ const { ctx, dialogs } = fakeContext();
7031
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
7032
+ writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { "gentle-ai-explore": { model: "openai/alpha" } } })}\n`);
7033
+ await child.fire("session_start", ctx);
7034
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "warn", mode: "background" }, undefined, undefined, ctx);
7035
+ await tick();
7036
+ assert.equal(runtime.children.length, 1, "the launch itself still proceeds");
7037
+ assert.equal(dialogs.filter((message) => /"gentle-ai-explore" did not match any agent, did you mean explore\?/.test(message)).length, 1);
7038
+
7039
+ // A key that matches the agent exactly never warns, and the fixture's own
7040
+ // global effort profile (explore) stays matched.
7041
+ dialogs.length = 0;
7042
+ writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explore: { model: "openai/alpha" } } })}\n`);
7043
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "quiet", mode: "background" }, undefined, undefined, ctx);
7044
+ await tick();
7045
+ assert.equal(dialogs.length, 0, "a matched key is silent");
7046
+ });
7047
+
7048
+ // CodeRabbit follow-up on PR #2397: a key that names another discovered agent
7049
+ // is valid, so launching a different agent whose name it merely resembles
7050
+ // (explore vs gentle-ai-explore) must not label it unmatched.
7051
+ test("subagent_run does not warn about a key that matches another discovered agent", async (t) => {
7052
+ const child = fakePi();
7053
+ const runtime = deps();
7054
+ gentleAgents(child.pi, {}, runtime.deps);
7055
+ const { ctx, dialogs } = fakeContext();
7056
+ mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
7057
+ const projectAgent = join(cwd, ".pi", "agents", "gentle-ai-explore.md");
7058
+ const subagents = join(cwd, ".pi", "subagents.json");
7059
+ t.after(() => rmSync(projectAgent, { force: true }));
7060
+ t.after(() => rmSync(subagents, { force: true }));
7061
+ writeFileSync(projectAgent, "---\ndescription: packaged explore\ntools: [read]\n---\nYou map things.");
7062
+ writeFileSync(subagents, `${JSON.stringify({ model_profiles: { "gentle-ai-explore": { model: "openai/alpha" } } })}\n`);
7063
+ await child.fire("session_start", ctx);
7064
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "quiet", mode: "background" }, undefined, undefined, ctx);
7065
+ await tick();
7066
+ assert.equal(runtime.children.length, 1, "the launch proceeds");
7067
+ assert.equal(dialogs.filter((message) => /did not match any agent/.test(message)).length, 0, "a key naming a discovered agent is never unmatched");
7068
+ });
7069
+
7070
+ // Issue #2400: a model_profiles key that matches no discovered agent and has no
7071
+ // did-you-mean suggestion (a typo such as `explor`) was still silent, because
7072
+ // the launch warning only named keys suggesting the launched agent. The first
7073
+ // launch of a session now lists every such key once; later launches in the
7074
+ // same session stay quiet, and the key is never routed.
7075
+ test("subagent_run warns once per session about model profile keys with no suggestion", async (t) => {
7076
+ const child = fakePi();
7077
+ const runtime = deps();
7078
+ const captures = captureLaunches(t);
7079
+ gentleAgents(child.pi, {}, runtime.deps);
7080
+ const { ctx, dialogs } = fakeContext();
7081
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
7082
+ const subagents = join(cwd, ".pi", "subagents.json");
7083
+ t.after(() => rmSync(subagents, { force: true }));
7084
+ writeFileSync(subagents, `${JSON.stringify({ model_profiles: { explor: { model: "openai/alpha" }, "sdd-desing": { model: "openai/beta" } } })}\n`);
7085
+ await child.fire("session_start", ctx);
7086
+ const unmatched = () => dialogs.filter((message) => /did not match any agent and was ignored/.test(message));
7087
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "first", mode: "background" }, undefined, undefined, ctx);
7088
+ await tick();
7089
+ assert.equal(unmatched().length, 1, "the first launch names the unmatched keys");
7090
+ assert.match(unmatched()[0]!, /"explor" did not match any agent and was ignored\./);
7091
+ assert.match(unmatched()[0]!, /"sdd-desing" did not match any agent and was ignored\./);
7092
+ assert.equal(dialogs.filter((message) => /did you mean/.test(message)).length, 0, "a key without a suggestion never claims one");
7093
+ assert.equal(captures[0]!.request.modelSource === "profile", false, "an unmatched key is never routed");
7094
+
7095
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "second", mode: "background" }, undefined, undefined, ctx);
7096
+ await tick();
7097
+ assert.equal(unmatched().length, 1, "later launches in the same session do not repeat it");
7098
+
7099
+ // A different parent session is told again.
7100
+ const other = fakeContext();
7101
+ other.ctx.sessionManager.getSessionId = () => "s2";
7102
+ await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "other", mode: "background" }, undefined, undefined, other.ctx);
7103
+ await tick();
7104
+ assert.equal(other.dialogs.filter((message) => /"explor" did not match any agent and was ignored\./.test(message)).length, 1);
7105
+ });
7106
+
7107
+ // Cortex QA adversarial follow-up (end-to-end pin for the #2196 fix): a key
7108
+ // that matches a discovered agent must not only stay silent, it must actually
7109
+ // route: the task request carries the profile model and thinking, so the child
7110
+ // launches with --model exactly as the issue's expected behavior describes.
7111
+ test("a matched model_profiles key routes the task request model end to end", async (t) => {
7112
+ const h = fakePi();
7113
+ const runtime = deps();
7114
+ const captures = captureLaunches(t);
7115
+ gentleAgents(h.pi, {}, runtime.deps);
7116
+ const { ctx } = fakeContext();
7117
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
7118
+ writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explore: { model: "openai/alpha", effort: "high" } } })}\n`);
7119
+ await h.fire("session_start", ctx);
7120
+ await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "route", mode: "background" }, undefined, undefined, ctx);
7121
+ await tick();
7122
+ assert.equal(captures.length, 1);
7123
+ assert.deepEqual(captures[0]!.request.model, { provider: "openai", id: "alpha" }, "the matched key routes the configured model into the launch");
7124
+ assert.equal(captures[0]!.request.thinking, "high");
7125
+ assert.equal(captures[0]!.request.modelSource, "profile");
7126
+ });
@@ -98,7 +98,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
98
98
  method: "go-sumdb-source-build",
99
99
  package: "github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai",
100
100
  module: "github.com/gentleman-programming/gentle-ai/v4",
101
- tag: "v4.2.0",
101
+ tag: "v4.3.1",
102
102
  architecture: process.arch === "x64" ? "x64" : "arm64",
103
103
  binarySha256: createHash("sha256").update(binary).digest("hex"),
104
104
  moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,