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
@@ -526,6 +526,7 @@ test("models saves and clears independent provider review roles without local ar
526
526
  }
527
527
  assert.match(initial, /Pi persisted default model/);
528
528
  assert.match(initial, /Pi persisted default effort/);
529
+ panel.handleInput("j"); // Skip the snapshot-only orchestrator row.
529
530
  for (const [index, model] of ["alpha", "beta"].entries()) {
530
531
  panel.handleInput("j");
531
532
  panel.handleInput("\r");
@@ -549,6 +550,7 @@ test("models saves and clears independent provider review roles without local ar
549
550
  assertNoLocalArtifacts();
550
551
 
551
552
  fixture.onInput((panel) => {
553
+ panel.handleInput("j");
552
554
  panel.handleInput("j");
553
555
  panel.handleInput("i");
554
556
  panel.handleInput("\x13");
@@ -561,6 +563,7 @@ test("models saves and clears independent provider review roles without local ar
561
563
  assertNoLocalArtifacts();
562
564
 
563
565
  fixture.onInput((panel) => {
566
+ panel.handleInput("j");
564
567
  panel.handleInput("j");
565
568
  panel.handleInput("j");
566
569
  panel.handleInput("i");
@@ -1297,8 +1300,8 @@ test("model panel lists grow with the terminal height instead of a fixed window"
1297
1300
  .renderSddModelPanel({}, models, agents, 100, undefined, 40)
1298
1301
  .map(stripAnsi);
1299
1302
  assert.equal(agentLines.length, 40);
1300
- // 40 rows minus 15 rows of chrome: every remaining row lists an agent or "Set all".
1301
- assert.equal(agentLines.filter((line) => /(agent-\d\d|Set all agents)\s+model=/.test(line)).length, 25);
1303
+ // 40 rows minus 15 rows of chrome: agents, "Set all", and the orchestrator share the space.
1304
+ assert.equal(agentLines.filter((line) => /(agent-\d\d|Set all agents)\s+model=|orchestrator\s+leave untouched/.test(line)).length, 25);
1302
1305
  assert.ok(agentLines.some((line) => /x export/.test(line)));
1303
1306
 
1304
1307
  const pickerLines = __testing
@@ -2933,11 +2936,186 @@ test("s snapshots the live session's orchestrator over the settings defaults", a
2933
2936
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings are untouched");
2934
2937
  });
2935
2938
 
2939
+ function chooseOrchestrator(panel: RoutingConsumerPanel): void {
2940
+ panel.handleInput("g");
2941
+ panel.handleInput("j");
2942
+ assert.match(renderComponent(panel), /▸ orchestrator/);
2943
+ }
2944
+
2945
+ function chooseModel(panel: RoutingConsumerPanel, query: string): void {
2946
+ panel.handleInput("\r");
2947
+ for (const character of query) panel.handleInput(character);
2948
+ panel.handleInput("\r");
2949
+ }
2950
+
2951
+ function chooseEffort(panel: RoutingConsumerPanel, effort: string): void {
2952
+ panel.handleInput("e");
2953
+ for (let step = 0; step < 10 && !renderComponent(panel).includes(`▸ ${effort}`); step++) panel.handleInput("j");
2954
+ assert.ok(renderComponent(panel).includes(`▸ ${effort}`));
2955
+ panel.handleInput("\r");
2956
+ }
2957
+
2958
+ test("models explicit orchestrator choice wins over live/defaults and agent bulk edits", async (t) => {
2959
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2960
+ // Enter in /gentle:profiles binds the fixture session in a process-wide store.
2961
+ t.after(() => resetSessionProfileBindingsForTesting());
2962
+ writeSettings();
2963
+ const settingsBefore = readFileSync(settingsPath, "utf8");
2964
+ fixture.setLiveModel("openai", "alpha", "high");
2965
+ writeStore({ team: { orchestrator: { model: "nan/glm5.3", thinking: "max" } } }, "team");
2966
+ fixture.onInput((panel) => {
2967
+ assert.match(renderComponent(panel), /orchestrator\s+model=nan\/glm5.3, effort=max/);
2968
+ chooseOrchestrator(panel);
2969
+ chooseModel(panel, "beta");
2970
+ chooseEffort(panel, "low");
2971
+ panel.handleInput("g");
2972
+ chooseModel(panel, "alpha");
2973
+ chooseEffort(panel, "high");
2974
+ assert.match(renderComponent(panel), /orchestrator\s+model=openai\/beta, effort=low/);
2975
+ panel.handleInput("u");
2976
+ });
2977
+ await fixture.run("gentle:models");
2978
+ const snapshot = JSON.parse(readFileSync(storePath, "utf8")).profiles.team;
2979
+ assert.deepEqual(snapshot.orchestrator, { model: "openai/beta", thinking: "low" });
2980
+ assert.deepEqual(snapshot.worker, { model: "openai/alpha", thinking: "high" });
2981
+ assert.equal("orchestrator" in JSON.parse(readFileSync(fixture.globalPath, "utf8")), false);
2982
+ assert.equal("orchestrator" in JSON.parse(readFileSync(join(fixture.root, ".pi", "subagents.json"), "utf8")).model_profiles, false);
2983
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
2984
+ assert.deepEqual(fixture.liveSwitches, [], "saving never switches the live model or effort");
2985
+ assert.equal(fixture.notifications.some((entry) => entry.severity === "warning"), false);
2986
+ let profileVisits = 0;
2987
+ fixture.onInput((panel) => { panel.handleInput(profileVisits++ === 0 ? "\r" : "\x1b"); });
2988
+ await fixture.run("gentle:profiles");
2989
+ assert.deepEqual(fixture.liveSwitches, [
2990
+ { kind: "model", provider: "openai", id: "beta" },
2991
+ { kind: "thinking", level: "low" },
2992
+ ]);
2993
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
2994
+ });
2995
+
2996
+ test("models leave untouched removes stale orchestrator and preserves live model/effort on apply", async (t) => {
2997
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2998
+ // Enter in /gentle:profiles binds the fixture session in a process-wide store.
2999
+ t.after(() => resetSessionProfileBindingsForTesting());
3000
+ writeSettings();
3001
+ const settingsBefore = readFileSync(settingsPath, "utf8");
3002
+ fixture.setLiveModel("openai", "alpha", "low");
3003
+ writeStore({ team: { orchestrator: { model: "openai/beta", thinking: "high" } } }, "team");
3004
+ writeFileSync(fixture.globalPath, JSON.stringify({ orchestrator: { model: "openai/beta", thinking: "high" }, worker: { model: "openai/alpha" } }));
3005
+ fixture.answerInputs("");
3006
+ let visits = 0;
3007
+ fixture.onInput((panel) => {
3008
+ if (visits++ === 0) {
3009
+ chooseOrchestrator(panel);
3010
+ chooseModel(panel, "leave untouched");
3011
+ panel.handleInput("e");
3012
+ assert.match(renderComponent(panel), /orchestrator\s+leave untouched/);
3013
+ panel.handleInput("j");
3014
+ panel.handleInput("c");
3015
+ } else {
3016
+ assert.match(renderComponent(panel), /orchestrator\s+leave untouched/);
3017
+ panel.handleInput("u");
3018
+ }
3019
+ });
3020
+ await fixture.run("gentle:models");
3021
+ assert.equal(Object.hasOwn(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, "orchestrator"), false);
3022
+ assert.equal(Object.hasOwn(JSON.parse(readFileSync(fixture.globalPath, "utf8")), "orchestrator"), false);
3023
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3024
+ assert.deepEqual(fixture.liveSwitches, []);
3025
+ let profileVisits = 0;
3026
+ fixture.onInput((panel) => {
3027
+ assert.match(renderComponent(panel), /orchestrator\s+inherit/);
3028
+ panel.handleInput(profileVisits++ === 0 ? "\r" : "\x1b");
3029
+ });
3030
+ await fixture.run("gentle:profiles");
3031
+ assert.deepEqual(fixture.liveSwitches, [], "Enter preserves both live model and effort without the key");
3032
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3033
+ });
3034
+
3035
+ for (const [name, key] of [["ctrl+s", "\x13"], ["cancel", "\x1b"]]) {
3036
+ test(`models ${name} never snapshots or applies the chosen orchestrator`, async (t) => {
3037
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3038
+ writeSettings();
3039
+ fixture.setLiveModel("openai", "alpha", "high");
3040
+ writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" } } }, "team");
3041
+ const before = [readFileSync(storePath, "utf8"), readFileSync(settingsPath, "utf8")];
3042
+ fixture.onInput((panel) => {
3043
+ chooseOrchestrator(panel);
3044
+ chooseModel(panel, "beta");
3045
+ chooseEffort(panel, "low");
3046
+ panel.handleInput(key!);
3047
+ });
3048
+ await fixture.run("gentle:models");
3049
+ assert.deepEqual([readFileSync(storePath, "utf8"), readFileSync(settingsPath, "utf8")], before);
3050
+ assert.deepEqual(fixture.liveSwitches, []);
3051
+ if (name === "ctrl+s") assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {});
3052
+ else assert.equal(existsSync(fixture.globalPath), false);
3053
+ });
3054
+ }
3055
+
3056
+ for (const action of ["bulk inherit", "custom", "export/restore"]) {
3057
+ for (const leaveUntouched of [false, true]) {
3058
+ test(`models choice survives ${action} with ${leaveUntouched ? "no key" : "explicit pair"}`, async (t) => {
3059
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3060
+ writeSettings();
3061
+ fixture.setLiveModel("openai", "alpha", "high");
3062
+ writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" } } }, "team");
3063
+ writeFileSync(fixture.globalPath, JSON.stringify({ worker: { model: "openai/alpha" } }));
3064
+ const settingsBefore = readFileSync(settingsPath, "utf8");
3065
+ if (action === "custom") fixture.answerInputs("openai/alpha");
3066
+ let visits = 0;
3067
+ const assertChoice = (panel: RoutingConsumerPanel) => {
3068
+ assert.match(renderComponent(panel), leaveUntouched
3069
+ ? /orchestrator\s+leave untouched/
3070
+ : /orchestrator\s+model=openai\/beta, effort=low/);
3071
+ };
3072
+ fixture.onInput((panel) => {
3073
+ visits++;
3074
+ assert.ok(visits <= 3, "reopens must be bounded");
3075
+ if (visits === 1) {
3076
+ chooseOrchestrator(panel);
3077
+ if (leaveUntouched) panel.handleInput("i");
3078
+ else {
3079
+ chooseModel(panel, "beta");
3080
+ chooseEffort(panel, "low");
3081
+ }
3082
+ assertChoice(panel);
3083
+ if (action === "bulk inherit") {
3084
+ panel.handleInput("g");
3085
+ panel.handleInput("i");
3086
+ assertChoice(panel);
3087
+ panel.handleInput("u");
3088
+ } else if (action === "custom") {
3089
+ panel.handleInput("g");
3090
+ for (let step = 0; step < 4; step++) panel.handleInput("j");
3091
+ assert.match(renderComponent(panel), /▸ worker/);
3092
+ panel.handleInput("c");
3093
+ } else panel.handleInput("x");
3094
+ } else {
3095
+ assertChoice(panel);
3096
+ panel.handleInput(action === "export/restore" && visits === 2 ? "r" : "u");
3097
+ }
3098
+ });
3099
+ await fixture.run("gentle:models");
3100
+ const profile = JSON.parse(readFileSync(storePath, "utf8")).profiles.team;
3101
+ assert.equal(Object.hasOwn(profile, "orchestrator"), !leaveUntouched);
3102
+ if (!leaveUntouched) assert.deepEqual(profile.orchestrator, { model: "openai/beta", thinking: "low" });
3103
+ assert.deepEqual(profile.worker, action === "bulk inherit" ? {} : { model: "openai/alpha" });
3104
+ assert.equal(Object.hasOwn(JSON.parse(readFileSync(fixture.globalPath, "utf8")), "orchestrator"), false);
3105
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3106
+ assert.deepEqual(fixture.liveSwitches, []);
3107
+ assert.equal(fixture.notifications.some((entry) => entry.severity === "warning"), false);
3108
+ assert.equal(visits, action === "bulk inherit" ? 1 : action === "custom" ? 2 : 3);
3109
+ assert.equal(fixture.confirmCalls.length, action === "export/restore" ? 1 : 0);
3110
+ });
3111
+ }
3112
+ }
3113
+
2936
3114
  // /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
2937
- // by the snapshot `/gentle:profiles` performs with `s` on the current profile.
3115
+ // by a snapshot of saved agent routing and the explicitly chosen orchestrator.
2938
3116
  function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
2939
- // Rows: "Set all agents", the two provider review roles, then "worker".
2940
- for (let step = 0; step < 3; step += 1) panel.handleInput("j");
3117
+ // Rows: "Set all agents", "orchestrator", two provider review roles, then "worker".
3118
+ for (let step = 0; step < 4; step += 1) panel.handleInput("j");
2941
3119
  assert.match(renderComponent(panel), /▸ worker/);
2942
3120
  panel.handleInput("\r");
2943
3121
  for (const character of "alpha") panel.handleInput(character);
@@ -2945,7 +3123,7 @@ function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
2945
3123
  panel.handleInput("u");
2946
3124
  }
2947
3125
 
2948
- test("u saves global routing and captures live session orchestrator instead of defaults", async (t) => {
3126
+ test("u preserves an active profile's absent orchestrator when editing agents", async (t) => {
2949
3127
  const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2950
3128
  writeSettings();
2951
3129
  const settingsBefore = readFileSync(settingsPath, "utf8");
@@ -2956,7 +3134,8 @@ test("u saves global routing and captures live session orchestrator instead of d
2956
3134
  }, "team");
2957
3135
  fixture.onInput((panel) => {
2958
3136
  assert.match(renderComponent(panel), /Current profile: team/);
2959
- assert.match(renderComponent(panel), /u capture session in "team"/);
3137
+ assert.match(renderComponent(panel), /orchestrator\s+leave untouched/);
3138
+ assert.match(renderComponent(panel), /u save choice in "team"/);
2960
3139
  pickWorkerModelThenUpdateProfile(panel);
2961
3140
  });
2962
3141
  await fixture.run("gentle:models");
@@ -2965,8 +3144,8 @@ test("u saves global routing and captures live session orchestrator instead of d
2965
3144
  const store = JSON.parse(readFileSync(storePath, "utf8"));
2966
3145
  assert.deepEqual(store.profiles.team, {
2967
3146
  worker: { model: "openai/alpha" },
2968
- orchestrator: { model: "openai/alpha", thinking: "low" },
2969
3147
  });
3148
+ assert.deepEqual(fixture.liveSwitches, []);
2970
3149
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "global defaults remain unchanged");
2971
3150
  assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
2972
3151
  assert.equal(store.active, "team");
@@ -2981,17 +3160,19 @@ test("u saves global routing and captures live session orchestrator instead of d
2981
3160
  );
2982
3161
  });
2983
3162
 
2984
- test("u updates the pinned profile instead of the active one inside a pinned repository", async (t) => {
2985
- const { fixture, storePath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
3163
+ test("u preserves the pinned profile's stored orchestrator instead of live or active values", async (t) => {
3164
+ const { fixture, storePath, settingsPath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
2986
3165
  writeSettings();
2987
- fixture.setLiveModel("openai", "beta", "medium");
3166
+ const settingsBefore = readFileSync(settingsPath, "utf8");
3167
+ fixture.setLiveModel("openai", "alpha", "low");
2988
3168
  writeStore({
2989
3169
  team: { worker: { model: "openai/beta" } },
2990
- other: { worker: { model: "openai/beta", thinking: "high" } },
3170
+ other: { worker: { model: "openai/beta", thinking: "high" }, orchestrator: { model: "openai/beta", thinking: "high" } },
2991
3171
  }, "team");
2992
3172
  writePin(localPinPath, "other");
2993
3173
  fixture.onInput((panel) => {
2994
3174
  assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
3175
+ assert.match(renderComponent(panel), /orchestrator\s+model=openai\/beta, effort=high/);
2995
3176
  pickWorkerModelThenUpdateProfile(panel);
2996
3177
  });
2997
3178
  await fixture.run("gentle:models");
@@ -3000,8 +3181,10 @@ test("u updates the pinned profile instead of the active one inside a pinned rep
3000
3181
  const store = JSON.parse(readFileSync(storePath, "utf8"));
3001
3182
  assert.deepEqual(store.profiles.other, {
3002
3183
  worker: { model: "openai/alpha" },
3003
- orchestrator: { model: "openai/beta", thinking: "medium" },
3184
+ orchestrator: { model: "openai/beta", thinking: "high" },
3004
3185
  });
3186
+ assert.deepEqual(fixture.liveSwitches, []);
3187
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3005
3188
  assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
3006
3189
  assert.equal(store.active, "team");
3007
3190
  assert.ok(
@@ -3013,10 +3196,12 @@ test("u updates the pinned profile instead of the active one inside a pinned rep
3013
3196
  test("u keeps the global save and reports when no profile is current", async (t) => {
3014
3197
  const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
3015
3198
  writeSettings();
3199
+ fixture.setLiveModel("openai", "alpha", "low");
3016
3200
  writeStore({ team: { worker: { model: "openai/beta" } } });
3017
3201
  const before = readFileSync(storePath, "utf8");
3018
3202
  fixture.onInput((panel) => {
3019
3203
  assert.match(renderComponent(panel), /Current profile: none/);
3204
+ assert.match(renderComponent(panel), /orchestrator\s+model=openai\/alpha, effort=low/);
3020
3205
  pickWorkerModelThenUpdateProfile(panel);
3021
3206
  });
3022
3207
  await fixture.run("gentle:models");
@@ -3035,6 +3220,7 @@ test("u seeds the profiles store the way /gentle:profiles does when it is missin
3035
3220
  assert.equal(existsSync(storePath), false);
3036
3221
  fixture.onInput((panel) => {
3037
3222
  assert.match(renderComponent(panel), /Current profile: none/);
3223
+ assert.match(renderComponent(panel), /orchestrator\s+model=nan\/deepseek-v4-flash, effort=high/);
3038
3224
  pickWorkerModelThenUpdateProfile(panel);
3039
3225
  });
3040
3226
  await fixture.run("gentle:models");
@@ -3141,6 +3327,7 @@ test("the profiles panel fills the terminal, lists routing per agent, and scroll
3141
3327
  orchestrator: { model: "nan/glm5.3", thinking: "high" },
3142
3328
  worker: { model: "openai/alpha", thinking: "high" },
3143
3329
  "sdd-design": { model: "nan/glm5.3", thinking: "high" },
3330
+ "review-refuter": { model: "nan/glm5.3", thinking: "high" },
3144
3331
  },
3145
3332
  }, "team");
3146
3333
  let rendered: string | undefined;
@@ -3165,6 +3352,15 @@ test("the profiles panel fills the terminal, lists routing per agent, and scroll
3165
3352
  assert.match(text, /Current routing \(effective\)/);
3166
3353
  assert.match(text, /orchestrator\s+nan\/glm5\.3 · high/);
3167
3354
  assert.match(text, /worker\s+openai\/alpha\s+high/);
3355
+ assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high\s+← no matching agent/);
3356
+ // A matched key stays a plain row: the note is reserved for keys that name
3357
+ // no discovered agent (issue #2196).
3358
+ assert.doesNotMatch(text, /worker\s+openai\/alpha\s+high\s+←/);
3359
+ // gentle-pi#2271: review-refuter and review-validator are provider review
3360
+ // roles the relay routes by key; they never have an agent file, so the
3361
+ // panel must not flag them as unmatched.
3362
+ assert.match(text, /review-refuter\s+nan\/glm5\.3\s+high/);
3363
+ assert.doesNotMatch(text, /review-refuter[^\n]*(←|↳)/);
3168
3364
  assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high/);
3169
3365
  assert.doesNotMatch(text, /agents? → /);
3170
3366
 
@@ -3182,6 +3378,89 @@ test("the profiles panel fills the terminal, lists routing per agent, and scroll
3182
3378
  assert.match(shortLines.at(-1)!, /^╰/);
3183
3379
  });
3184
3380
 
3381
+ test("a narrow detail pane keeps the unmatched-key note visible", async (t) => {
3382
+ const { fixture, writeStore } = profilesStoreFixture(t);
3383
+ writeStore({
3384
+ team: {
3385
+ worker: { model: "openai/alpha", thinking: "high" },
3386
+ "sdd-design": { model: "nan/glm5.3", thinking: "high" },
3387
+ },
3388
+ }, "team");
3389
+ let panel: { render(width: number): string[] } | undefined;
3390
+ fixture.onInput((visited) => {
3391
+ panel = visited;
3392
+ visited.handleInput("\x1b");
3393
+ });
3394
+ fixture.tui.terminal.rows = 40;
3395
+ await fixture.run("gentle:profiles");
3396
+
3397
+ assert.ok(panel);
3398
+ // At 60 columns the routing row fills the detail pane, so the note wraps
3399
+ // onto its own line instead of being truncated away (issue #2196).
3400
+ const narrow = stripAnsi(panel.render(60).join("\n"));
3401
+ assert.match(narrow, /↳ no matching agent/);
3402
+ });
3403
+
3404
+ // Issue #2400: a model_profiles key that matches no discovered agent and has
3405
+ // no suggestion never became a "Current routing (effective)" row, because that
3406
+ // table is built per discovered agent. The panel now lists those keys under
3407
+ // their own heading; a key with a suggestion is left to the launch warning.
3408
+ test("the profiles panel lists model_profiles keys that match no agent and suggest none", async (t) => {
3409
+ const { fixture, writeStore } = profilesStoreFixture(t);
3410
+ writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
3411
+ writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({
3412
+ model_profiles: {
3413
+ worker: { model: "openai/alpha" },
3414
+ explor: { model: "openai/beta" },
3415
+ "sdd-orchestratr-x": { model: "openai/beta" },
3416
+ "gentle-ai-worker": { model: "openai/beta" },
3417
+ },
3418
+ }, null, 2)}\n`);
3419
+ let panel: { render(width: number): string[] } | undefined;
3420
+ fixture.onInput((visited) => {
3421
+ panel = visited;
3422
+ visited.handleInput("\x1b");
3423
+ });
3424
+ fixture.tui.terminal.rows = 40;
3425
+ await fixture.run("gentle:profiles");
3426
+
3427
+ assert.ok(panel);
3428
+ const wide = stripAnsi(panel.render(120).join("\n"));
3429
+ assert.match(wide, /Unmatched keys \(ignored\)/);
3430
+ assert.match(wide, /explor\s+← no matching agent/);
3431
+ assert.match(wide, /sdd-orchestratr-x\s+← no matching agent/);
3432
+ assert.doesNotMatch(wide, /gentle-ai-worker/, "a key with a suggestion is not listed as unmatched");
3433
+
3434
+ // At 60 columns the detail pane is 32 wide: the heading and the key stay
3435
+ // whole, and a note that does not fit moves to its own line.
3436
+ const narrow = stripAnsi(panel.render(60).join("\n"));
3437
+ assert.match(narrow, /Unmatched keys \(ignored\)/);
3438
+ assert.match(narrow, /explor\s+← no matching agent/);
3439
+ assert.match(narrow, /sdd-orchestratr-x[^\n]*\n[^\n]*↳ no matching agent/);
3440
+ });
3441
+
3442
+ // Issue #2400: when a pin wins, launches route from the pinned snapshot, whose
3443
+ // keys are already current rows with their own notes, so the subagents.json
3444
+ // keys are not listed as the routing in effect.
3445
+ test("the unmatched-keys list stays out of the panel while a pin wins", async (t) => {
3446
+ const { fixture, writeStore, writePin, localPinPath } = profilesStoreFixture(t);
3447
+ writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
3448
+ writePin(localPinPath, "team");
3449
+ writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explor: { model: "openai/beta" } } }, null, 2)}\n`);
3450
+ let panel: { render(width: number): string[] } | undefined;
3451
+ fixture.onInput((visited) => {
3452
+ panel = visited;
3453
+ visited.handleInput("\x1b");
3454
+ });
3455
+ fixture.tui.terminal.rows = 40;
3456
+ await fixture.run("gentle:profiles");
3457
+
3458
+ assert.ok(panel);
3459
+ const text = stripAnsi(panel.render(120).join("\n"));
3460
+ assert.match(text, /Current routing \(effective\)/);
3461
+ assert.doesNotMatch(text, /Unmatched keys/);
3462
+ });
3463
+
3185
3464
  test("j and k scroll the detail pane one line at a time, like the agents view", async (t) => {
3186
3465
  const { fixture, writeStore } = profilesStoreFixture(t);
3187
3466
  const routing: Record<string, { model: string; thinking: string }> = {};