gentle-pi 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -33,7 +33,7 @@ const EXPECTED_BANNER_COMMANDS = [
|
|
|
33
33
|
const EXPECTED_COMMANDS = [
|
|
34
34
|
"gentle:install-delegation",
|
|
35
35
|
"gentle:install-review",
|
|
36
|
-
"gentle:
|
|
36
|
+
"gentle:profiles",
|
|
37
37
|
"gentle:persona",
|
|
38
38
|
"gentle:status",
|
|
39
39
|
"gentle:doctor",
|
|
@@ -43,6 +43,8 @@ const EXPECTED_COMMANDS = [
|
|
|
43
43
|
|
|
44
44
|
const FORBIDDEN_COMPAT_COMMANDS = [
|
|
45
45
|
"gentle:install-assets",
|
|
46
|
+
// Retired: /gentle:profiles opens the same models screen.
|
|
47
|
+
"gentle:models",
|
|
46
48
|
"gentle-ai:models",
|
|
47
49
|
"gentleman:models",
|
|
48
50
|
"gentle-ai:persona",
|
|
@@ -1301,16 +1303,16 @@ async function run() {
|
|
|
1301
1303
|
assert.doesNotMatch(invalidGlobalSkippedAgent, /model: legacy\/provider-model/);
|
|
1302
1304
|
assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
|
|
1303
1305
|
assert.match(legacyCtx.ui.notifications.at(-1).message, /skipped model config/);
|
|
1306
|
+
// An unreadable models.json does not block the models screen (Make default
|
|
1307
|
+
// discloses it), and closing the screen leaves the file untouched.
|
|
1304
1308
|
let modelPanelOpened = false;
|
|
1305
1309
|
legacyCtx.ui.custom = () => {
|
|
1306
1310
|
modelPanelOpened = true;
|
|
1307
|
-
return Promise.resolve({ type: "
|
|
1311
|
+
return Promise.resolve({ type: "close" });
|
|
1308
1312
|
};
|
|
1309
|
-
await commands.get("gentle:
|
|
1310
|
-
assert.equal(modelPanelOpened,
|
|
1313
|
+
await commands.get("gentle:profiles").handler("", legacyCtx);
|
|
1314
|
+
assert.equal(modelPanelOpened, true);
|
|
1311
1315
|
assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
|
|
1312
|
-
assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
|
|
1313
|
-
assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
|
|
1314
1316
|
await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
|
|
1315
1317
|
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
1316
1318
|
const emptyGlobalPreservesAgent = await readFile(
|
|
@@ -1629,61 +1631,50 @@ async function run() {
|
|
|
1629
1631
|
{ provider: "safe", id: "model" },
|
|
1630
1632
|
{ provider: "evil\u001b]52;c;Zm9v\u0007", id: "model" },
|
|
1631
1633
|
];
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
initialLines
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
initialLines.join("\n"),
|
|
1654
|
-
/\
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
"
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
"
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
assert.ok(
|
|
1677
|
-
bottomLines.length === 24,
|
|
1678
|
-
"bottom model agent list should keep filling the terminal",
|
|
1679
|
-
);
|
|
1680
|
-
assert.ok(
|
|
1681
|
-
plainBottomLines.some((line) => line.includes("▸ ← Back")),
|
|
1682
|
-
"G should jump to the Back action",
|
|
1683
|
-
);
|
|
1684
|
-
return Promise.resolve({ type: "cancel" });
|
|
1685
|
-
};
|
|
1686
|
-
await commands.get("gentle:models").handler("", ctx);
|
|
1634
|
+
// /gentle:profiles opens the models screen on the profile this repository
|
|
1635
|
+
// resolves: one table, profiles as tabs. Its render contract: a bordered
|
|
1636
|
+
// card filling the terminal, scroll indicators for long agent lists, and
|
|
1637
|
+
// sanitized labels everywhere, including the model card.
|
|
1638
|
+
let panelVisits = 0;
|
|
1639
|
+
ctx.ui.custom = (factory) =>
|
|
1640
|
+
new Promise((resolve) => {
|
|
1641
|
+
panelVisits += 1;
|
|
1642
|
+
const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
|
|
1643
|
+
const initialLines = panel.render(120);
|
|
1644
|
+
const plainInitialLines = initialLines.map(stripAnsi);
|
|
1645
|
+
assert.ok(
|
|
1646
|
+
plainInitialLines[0].startsWith("╭") && plainInitialLines.at(-1).startsWith("╰"),
|
|
1647
|
+
"the models screen should render inside a bordered card",
|
|
1648
|
+
);
|
|
1649
|
+
assert.equal(initialLines.length, 24, "the models screen should fill the 24-row terminal");
|
|
1650
|
+
assert.match(plainInitialLines[1], /current/, "the tab line names the profile on screen");
|
|
1651
|
+
assert.ok(
|
|
1652
|
+
plainInitialLines.some((line) => /↓ \d+ more/.test(line)),
|
|
1653
|
+
"a long agent list should render a down-scroll indicator",
|
|
1654
|
+
);
|
|
1655
|
+
assert.doesNotMatch(initialLines.join("\n"), /\u001b\]|\u0007/, "labels must not carry unsafe control sequences");
|
|
1656
|
+
assert.doesNotMatch(plainInitialLines.join("\n"), /\]52|\[31m/, "labels must not carry user-provided escapes");
|
|
1657
|
+
for (let i = 0; i < 20; i++) panel.handleInput("j");
|
|
1658
|
+
const plainScrolledLines = panel.render(120).map(stripAnsi);
|
|
1659
|
+
assert.equal(plainScrolledLines.length, 24, "the scrolled list keeps filling the terminal");
|
|
1660
|
+
assert.ok(
|
|
1661
|
+
plainScrolledLines.some((line) => /↑ \d+ more/.test(line)),
|
|
1662
|
+
"navigation down should render an up-scroll indicator",
|
|
1663
|
+
);
|
|
1664
|
+
for (let i = 0; i < 40; i++) panel.handleInput("j");
|
|
1665
|
+
assert.ok(
|
|
1666
|
+
panel.render(120).map(stripAnsi).some((line) => /▸\s+worker\b/.test(line)),
|
|
1667
|
+
"moving past the end lands on the last agent row",
|
|
1668
|
+
);
|
|
1669
|
+
panel.handleInput("\r"); // the model card lists the registry, sanitized
|
|
1670
|
+
const card = panel.render(120);
|
|
1671
|
+
assert.match(card.map(stripAnsi).join("\n"), /Model for worker/);
|
|
1672
|
+
assert.doesNotMatch(card.join("\n"), /\u001b\]|\u0007/, "registry labels are sanitized in the model card");
|
|
1673
|
+
panel.handleInput("\u001b"); // close the card
|
|
1674
|
+
panel.handleInput("\u001b"); // close the screen: nothing changed, one esc is enough
|
|
1675
|
+
});
|
|
1676
|
+
await commands.get("gentle:profiles").handler("", ctx);
|
|
1677
|
+
assert.equal(panelVisits, 1, "closing an unchanged screen ends the command");
|
|
1687
1678
|
|
|
1688
1679
|
await hooks.get("session_start")[0]({ reason: "startup" }, ctx);
|
|
1689
1680
|
const legacyAppliedAgent = await readFile(
|
|
@@ -1693,154 +1684,140 @@ async function run() {
|
|
|
1693
1684
|
assert.match(legacyAppliedAgent, /model: openai\/gpt-5/);
|
|
1694
1685
|
assert.doesNotMatch(legacyAppliedAgent, /thinking:/);
|
|
1695
1686
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
worker: { model: "openai/gpt-5-mini", thinking: "low" },
|
|
1702
|
-
researcher: { model: "openai/gpt-5-mini", thinking: "low" },
|
|
1703
|
-
"project-special": { model: "openai/gpt-5-mini", thinking: "low" },
|
|
1704
|
-
"global-special": { model: "openai/gpt-5-mini", thinking: "low" },
|
|
1705
|
-
},
|
|
1706
|
-
});
|
|
1707
|
-
await commands.get("gentle:models").handler("", ctx);
|
|
1708
|
-
assert.doesNotMatch(
|
|
1709
|
-
ctx.ui.notifications.at(-1).message,
|
|
1710
|
-
/[\u001b\u0007]/,
|
|
1711
|
-
"model save notification must strip terminal control sequences from discovered agent names",
|
|
1712
|
-
);
|
|
1713
|
-
|
|
1714
|
-
const savedConfig = JSON.parse(
|
|
1715
|
-
await readFile(globalModelsPath, "utf8"),
|
|
1716
|
-
);
|
|
1717
|
-
assert.deepEqual(savedConfig["sdd-apply"], {
|
|
1718
|
-
model: "openai/gpt-5",
|
|
1719
|
-
thinking: "high",
|
|
1720
|
-
});
|
|
1721
|
-
assert.equal(
|
|
1722
|
-
existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
|
|
1723
|
-
false,
|
|
1724
|
-
"/gentle:models must save model routing globally, not per project",
|
|
1725
|
-
);
|
|
1726
|
-
|
|
1727
|
-
const applyAgent = await readFile(
|
|
1728
|
-
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
1729
|
-
"utf8",
|
|
1730
|
-
);
|
|
1731
|
-
assert.match(applyAgent, /model: openai\/gpt-5/);
|
|
1732
|
-
assert.match(applyAgent, /thinking: high/);
|
|
1733
|
-
|
|
1734
|
-
const projectSubagents = JSON.parse(
|
|
1735
|
-
await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
|
|
1736
|
-
);
|
|
1737
|
-
assert.equal(
|
|
1738
|
-
projectSubagents.model_profiles["sdd-apply"].model,
|
|
1739
|
-
"openai/gpt-5",
|
|
1740
|
-
);
|
|
1741
|
-
assert.equal(projectSubagents.model_profiles["sdd-apply"].effort, "high");
|
|
1742
|
-
assert.equal(
|
|
1743
|
-
projectSubagents.model_profiles.worker.model,
|
|
1744
|
-
"openai/gpt-5-mini",
|
|
1745
|
-
);
|
|
1746
|
-
assert.equal(projectSubagents.model_profiles.worker.effort, "low");
|
|
1747
|
-
const routedProjectSubagentWorker = await readFile(
|
|
1748
|
-
join(modelsCwd, ".pi", "subagents", "worker.md"),
|
|
1687
|
+
// Save writes the table into the profile store only (kind
|
|
1688
|
+
// gentle-pi.agent_model_profiles). Global models.json, subagents.json, and
|
|
1689
|
+
// agent frontmatter stay untouched — Make default is the guarded global apply.
|
|
1690
|
+
const projectSubagentsBefore = await readFile(
|
|
1691
|
+
join(modelsCwd, ".pi", "subagents.json"),
|
|
1749
1692
|
"utf8",
|
|
1750
1693
|
);
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
const shadowedProjectAgentWorker = await readFile(
|
|
1754
|
-
join(modelsCwd, ".pi", "agents", "worker.md"),
|
|
1694
|
+
const agentFrontmatterBefore = await readFile(
|
|
1695
|
+
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
1755
1696
|
"utf8",
|
|
1756
1697
|
);
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
"
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1698
|
+
const pickEffort = (panel, steps) => {
|
|
1699
|
+
panel.handleInput("e");
|
|
1700
|
+
for (let i = 0; i < 10; i++) panel.handleInput("\u001b[D"); // back to inherit
|
|
1701
|
+
for (let i = 0; i < steps; i++) panel.handleInput("\u001b[C");
|
|
1702
|
+
panel.handleInput("\r");
|
|
1703
|
+
};
|
|
1704
|
+
let saveVisits = 0;
|
|
1705
|
+
ctx.ui.custom = (factory) =>
|
|
1706
|
+
new Promise((resolve) => {
|
|
1707
|
+
saveVisits += 1;
|
|
1708
|
+
const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
|
|
1709
|
+
if (saveVisits === 1) {
|
|
1710
|
+
panel.handleInput("j"); // the Model ▾ / Effort ▾ header row: every agent
|
|
1711
|
+
panel.handleInput("\r");
|
|
1712
|
+
for (const character of "safe") panel.handleInput(character); // type-to-search
|
|
1713
|
+
panel.handleInput("\r"); // safe/model for every agent
|
|
1714
|
+
pickEffort(panel, 5); // inherit off minimal low medium [high]
|
|
1715
|
+
panel.handleInput("\u0013"); // ctrl+s
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
panel.handleInput("\u001b");
|
|
1719
|
+
});
|
|
1720
|
+
await commands.get("gentle:profiles").handler("", ctx);
|
|
1721
|
+
assert.equal(saveVisits, 2, "Save reopens the screen once before it closes");
|
|
1722
|
+
|
|
1723
|
+
const profilesPath = join(globalConfigHome, "profiles.json");
|
|
1724
|
+
const savedStore = JSON.parse(await readFile(profilesPath, "utf8"));
|
|
1725
|
+
assert.equal(savedStore.kind, "gentle-pi.agent_model_profiles");
|
|
1726
|
+
assert.equal(savedStore.version, 1);
|
|
1727
|
+
assert.deepEqual(savedStore.profiles.current["sdd-apply"], { model: "safe/model", thinking: "high" });
|
|
1728
|
+
assert.deepEqual(savedStore.profiles.current["project-special"], { model: "safe/model", thinking: "high" });
|
|
1765
1729
|
assert.equal(
|
|
1766
|
-
|
|
1767
|
-
"openai/gpt-5
|
|
1730
|
+
await readFile(globalModelsPath, "utf8"),
|
|
1731
|
+
JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
|
|
1732
|
+
"Save must keep routing edits in the profile store, not write global models.json",
|
|
1768
1733
|
);
|
|
1769
|
-
assert.equal(globalSubagents.model_profiles.researcher.effort, "low");
|
|
1770
1734
|
assert.equal(
|
|
1771
|
-
|
|
1772
|
-
|
|
1735
|
+
existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
|
|
1736
|
+
false,
|
|
1737
|
+
"Save must keep routing edits in the profile store, not per project",
|
|
1773
1738
|
);
|
|
1774
|
-
assert.equal(
|
|
1739
|
+
assert.equal(await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"), projectSubagentsBefore);
|
|
1740
|
+
const untouchedAgent = await readFile(join(modelsCwd, ".pi", "agents", "sdd-apply.md"), "utf8");
|
|
1741
|
+
assert.equal(untouchedAgent, agentFrontmatterBefore);
|
|
1742
|
+
assert.doesNotMatch(untouchedAgent, /thinking:/);
|
|
1775
1743
|
assert.equal(existsSync(join(modelsCwd, ".pi", "settings.json")), false);
|
|
1776
1744
|
|
|
1777
1745
|
const kittyE = "\x1b[101u";
|
|
1778
1746
|
assert.notEqual(kittyE, "e");
|
|
1779
1747
|
assert.equal(matchesKey(kittyE, "e"), true);
|
|
1780
1748
|
|
|
1781
|
-
|
|
1749
|
+
// Custom model ids: Custom model id… in the model card hands the draft back,
|
|
1750
|
+
// ctx.ui.input asks for the id, the screen reopens with it, and Save stores it.
|
|
1751
|
+
const chooseCustom = (panel) => {
|
|
1752
|
+
panel.handleInput("\r");
|
|
1753
|
+
for (const character of "zzzz") panel.handleInput(character); // nothing matches: only Custom model id… is left
|
|
1754
|
+
panel.handleInput("\r");
|
|
1755
|
+
};
|
|
1756
|
+
let customVisits = 0;
|
|
1782
1757
|
ctx.ui.input = async () => "custom/provider-model";
|
|
1783
1758
|
ctx.ui.custom = (factory) =>
|
|
1784
1759
|
new Promise((resolve) => {
|
|
1785
|
-
|
|
1786
|
-
const panel = factory(
|
|
1787
|
-
if (
|
|
1788
|
-
panel.handleInput(
|
|
1789
|
-
|
|
1760
|
+
customVisits += 1;
|
|
1761
|
+
const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
|
|
1762
|
+
if (customVisits === 1) {
|
|
1763
|
+
panel.handleInput("j"); // every agent
|
|
1764
|
+
panel.handleInput(kittyE); // the effort row (a kitty "e" still matches)
|
|
1765
|
+
for (let i = 0; i < 10; i++) panel.handleInput("\u001b[D");
|
|
1766
|
+
for (let i = 0; i < 4; i++) panel.handleInput("\u001b[C"); // medium
|
|
1790
1767
|
panel.handleInput("\r");
|
|
1791
|
-
panel
|
|
1768
|
+
chooseCustom(panel); // custom model for every agent, same unsaved draft
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (customVisits === 2) {
|
|
1772
|
+
panel.handleInput("\u0013"); // ctrl+s saves the draft reopened after the custom id
|
|
1792
1773
|
return;
|
|
1793
1774
|
}
|
|
1794
|
-
panel.handleInput("\
|
|
1775
|
+
panel.handleInput("\u001b");
|
|
1795
1776
|
});
|
|
1796
|
-
await commands.get("gentle:
|
|
1797
|
-
|
|
1798
|
-
const
|
|
1799
|
-
|
|
1800
|
-
);
|
|
1801
|
-
assert.deepEqual(customSavedConfig["sdd-apply"], {
|
|
1777
|
+
await commands.get("gentle:profiles").handler("", ctx);
|
|
1778
|
+
assert.equal(customVisits, 3);
|
|
1779
|
+
const customSavedStore = JSON.parse(await readFile(profilesPath, "utf8"));
|
|
1780
|
+
assert.deepEqual(customSavedStore.profiles.current["sdd-apply"], {
|
|
1802
1781
|
model: "custom/provider-model",
|
|
1803
1782
|
thinking: "medium",
|
|
1804
1783
|
});
|
|
1805
1784
|
|
|
1806
|
-
|
|
1785
|
+
// An invalid custom id is refused on the screen and changes nothing.
|
|
1786
|
+
let invalidCustomVisits = 0;
|
|
1807
1787
|
ctx.ui.input = async () => "bad\nmodel: injected";
|
|
1808
1788
|
ctx.ui.custom = (factory) =>
|
|
1809
1789
|
new Promise((resolve) => {
|
|
1810
|
-
|
|
1811
|
-
const panel = factory(
|
|
1812
|
-
if (
|
|
1813
|
-
panel.handleInput("
|
|
1790
|
+
invalidCustomVisits += 1;
|
|
1791
|
+
const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
|
|
1792
|
+
if (invalidCustomVisits === 1) {
|
|
1793
|
+
panel.handleInput("j");
|
|
1794
|
+
chooseCustom(panel);
|
|
1814
1795
|
return;
|
|
1815
1796
|
}
|
|
1816
|
-
|
|
1797
|
+
if (invalidCustomVisits === 2) {
|
|
1798
|
+
assert.match(panel.render(120).map(stripAnsi).join(" "), /Custom model id must be one provider\/model line/);
|
|
1799
|
+
}
|
|
1800
|
+
panel.handleInput("\u001b"); // nothing changed: one esc closes
|
|
1817
1801
|
});
|
|
1818
|
-
await commands.get("gentle:
|
|
1819
|
-
assert.
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
);
|
|
1823
|
-
const rejectedCustomConfig = JSON.parse(
|
|
1824
|
-
await readFile(globalModelsPath, "utf8"),
|
|
1825
|
-
);
|
|
1826
|
-
assert.deepEqual(rejectedCustomConfig["sdd-apply"], {
|
|
1802
|
+
await commands.get("gentle:profiles").handler("", ctx);
|
|
1803
|
+
assert.equal(invalidCustomVisits, 2);
|
|
1804
|
+
const rejectedCustomStore = JSON.parse(await readFile(profilesPath, "utf8"));
|
|
1805
|
+
assert.deepEqual(rejectedCustomStore.profiles.current["sdd-apply"], {
|
|
1827
1806
|
model: "custom/provider-model",
|
|
1828
1807
|
thinking: "medium",
|
|
1829
1808
|
});
|
|
1830
1809
|
|
|
1831
|
-
|
|
1810
|
+
// models.json export and restore are subcommands; neither opens the screen.
|
|
1811
|
+
let screenOpened = false;
|
|
1832
1812
|
ctx.ui.custom = () => {
|
|
1833
|
-
|
|
1834
|
-
return Promise.resolve(
|
|
1813
|
+
screenOpened = true;
|
|
1814
|
+
return Promise.resolve({ type: "close" });
|
|
1835
1815
|
};
|
|
1836
|
-
await commands.get("gentle:
|
|
1816
|
+
await commands.get("gentle:profiles").handler("export-routing", ctx);
|
|
1837
1817
|
const exported = JSON.parse(await readFile(join(globalConfigHome, "models.export.json"), "utf8"));
|
|
1838
1818
|
assert.equal(exported.kind, "gentle-pi.agent_model_routing");
|
|
1839
1819
|
assert.equal(exported.version, 1);
|
|
1840
|
-
assert.deepEqual(exported.agents["sdd-apply"], {
|
|
1841
|
-
model: "custom/provider-model",
|
|
1842
|
-
thinking: "medium",
|
|
1843
|
-
});
|
|
1820
|
+
assert.deepEqual(exported.agents["sdd-apply"], { model: "openai/gpt-5" }, "export reads the saved global routing");
|
|
1844
1821
|
|
|
1845
1822
|
await writeFile(
|
|
1846
1823
|
join(globalConfigHome, "models.export.json"),
|
|
@@ -1850,59 +1827,47 @@ async function run() {
|
|
|
1850
1827
|
agents: { "sdd-apply": { model: "restore/provider", thinking: "high" } },
|
|
1851
1828
|
}, null, 2),
|
|
1852
1829
|
);
|
|
1853
|
-
let restorePanelCalls = 0;
|
|
1854
1830
|
ctx.ui.confirm = async () => true;
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
return Promise.resolve(restorePanelCalls === 1 ? { type: "restore", config: {} } : { type: "cancel" });
|
|
1858
|
-
};
|
|
1859
|
-
await commands.get("gentle:models").handler("", ctx);
|
|
1831
|
+
await commands.get("gentle:profiles").handler("restore-routing", ctx);
|
|
1832
|
+
assert.equal(screenOpened, false, "export and restore never open the screen");
|
|
1860
1833
|
const restoredConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
|
|
1861
|
-
assert.deepEqual(restoredConfig["sdd-apply"], {
|
|
1862
|
-
model: "restore/provider",
|
|
1863
|
-
thinking: "high",
|
|
1864
|
-
});
|
|
1834
|
+
assert.deepEqual(restoredConfig["sdd-apply"], { model: "restore/provider", thinking: "high" });
|
|
1865
1835
|
const restoredAgent = await readFile(join(modelsCwd, ".pi", "agents", "sdd-apply.md"), "utf8");
|
|
1866
1836
|
assert.match(restoredAgent, /model: restore\/provider/);
|
|
1867
1837
|
assert.match(restoredAgent, /thinking: high/);
|
|
1838
|
+
// Restore is global-only: the profile store keeps its own routing.
|
|
1839
|
+
const afterRestoreStore = JSON.parse(await readFile(profilesPath, "utf8"));
|
|
1840
|
+
assert.equal(afterRestoreStore.profiles.current["sdd-apply"].model, "custom/provider-model");
|
|
1868
1841
|
|
|
1869
|
-
// issue #286: `thinking: "max"` must survive save normalization
|
|
1870
|
-
//
|
|
1871
|
-
|
|
1872
|
-
Promise.resolve({
|
|
1873
|
-
type: "save",
|
|
1874
|
-
config: { "sdd-apply": { model: "openai/gpt-5", thinking: "max" } },
|
|
1875
|
-
});
|
|
1876
|
-
await commands.get("gentle:models").handler("", ctx);
|
|
1877
|
-
const maxSavedConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
|
|
1878
|
-
assert.equal(maxSavedConfig["sdd-apply"].thinking, "max");
|
|
1879
|
-
const maxSubagents = JSON.parse(
|
|
1880
|
-
await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
|
|
1881
|
-
);
|
|
1882
|
-
assert.equal(maxSubagents.model_profiles["sdd-apply"].effort, "max");
|
|
1883
|
-
const maxApplyAgent = await readFile(
|
|
1884
|
-
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
1885
|
-
"utf8",
|
|
1886
|
-
);
|
|
1887
|
-
assert.match(maxApplyAgent, /thinking: max/);
|
|
1888
|
-
|
|
1889
|
-
// issue #286: effort picker must offer `max` after `xhigh` and save it.
|
|
1890
|
-
let maxPickerCalls = 0;
|
|
1842
|
+
// issue #286: `thinking: "max"` must survive save normalization into the
|
|
1843
|
+
// profile store, and the effort row must offer `max` after `xhigh`.
|
|
1844
|
+
let maxVisits = 0;
|
|
1891
1845
|
ctx.ui.custom = (factory) =>
|
|
1892
1846
|
new Promise((resolve) => {
|
|
1893
|
-
|
|
1894
|
-
const panel = factory(
|
|
1895
|
-
if (
|
|
1896
|
-
panel.handleInput(
|
|
1897
|
-
|
|
1847
|
+
maxVisits += 1;
|
|
1848
|
+
const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
|
|
1849
|
+
if (maxVisits === 1) {
|
|
1850
|
+
panel.handleInput("j"); // every agent
|
|
1851
|
+
panel.handleInput("e");
|
|
1852
|
+
const strip = panel.render(120).map(stripAnsi).join("\n");
|
|
1853
|
+
const xhighIndex = strip.indexOf("xhigh");
|
|
1854
|
+
assert.ok(xhighIndex >= 0 && strip.indexOf("max", xhighIndex + 5) > xhighIndex, "the effort row must offer max after xhigh");
|
|
1855
|
+
for (let i = 0; i < 10; i++) panel.handleInput("\u001b[C"); // to the last level: max
|
|
1898
1856
|
panel.handleInput("\r");
|
|
1899
|
-
panel.handleInput("\u0013");
|
|
1857
|
+
panel.handleInput("\u0013");
|
|
1900
1858
|
return;
|
|
1901
1859
|
}
|
|
1860
|
+
panel.handleInput("\u001b");
|
|
1902
1861
|
});
|
|
1903
|
-
await commands.get("gentle:
|
|
1904
|
-
|
|
1905
|
-
|
|
1862
|
+
await commands.get("gentle:profiles").handler("", ctx);
|
|
1863
|
+
assert.equal(maxVisits, 2);
|
|
1864
|
+
const maxStore = JSON.parse(await readFile(profilesPath, "utf8"));
|
|
1865
|
+
assert.equal(maxStore.profiles.current["sdd-apply"].thinking, "max");
|
|
1866
|
+
assert.equal(
|
|
1867
|
+
maxStore.profiles.current["sdd-apply"].model,
|
|
1868
|
+
"custom/provider-model",
|
|
1869
|
+
"an effort-only save keeps the profile's saved model",
|
|
1870
|
+
);
|
|
1906
1871
|
} finally {
|
|
1907
1872
|
await rm(modelsCwd, { recursive: true, force: true });
|
|
1908
1873
|
await rm(globalModelsPath, { force: true });
|
|
@@ -1919,7 +1884,19 @@ async function run() {
|
|
|
1919
1884
|
}
|
|
1920
1885
|
}
|
|
1921
1886
|
|
|
1922
|
-
run(
|
|
1887
|
+
// A run that awaits a promise nobody settles (a mocked panel that never
|
|
1888
|
+
// finishes) lets the event loop drain, and Node would exit 0 with every later
|
|
1889
|
+
// section skipped. Treat an unfinished run as a failure instead.
|
|
1890
|
+
let runCompleted = false;
|
|
1891
|
+
process.on("exit", () => {
|
|
1892
|
+
if (runCompleted || process.exitCode) return;
|
|
1893
|
+
console.error("runtime harness exited before run() completed: a section awaited a promise that never settled");
|
|
1894
|
+
process.exitCode = 1;
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
run().then(() => {
|
|
1898
|
+
runCompleted = true;
|
|
1899
|
+
}).catch((error) => {
|
|
1923
1900
|
console.error(error);
|
|
1924
1901
|
process.exitCode = 1;
|
|
1925
1902
|
}).finally(async () => {
|
|
@@ -4,6 +4,7 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { SessionChanges, readChangeSnapshot, sessionChangeRoots, SESSION_CHANGE_ENTRY, MAX_CHANGE_BYTES } from "../lib/session-changes.ts";
|
|
7
|
+
import { emptyChanges } from "../lib/shell-changes.ts";
|
|
7
8
|
import { SESSION_WORKTREE_ENTRY } from "../lib/session-worktree-registry.ts";
|
|
8
9
|
|
|
9
10
|
const text = (text: string) => ({ kind: "text" as const, text });
|
|
@@ -12,7 +13,7 @@ const evidence = (id = "a", root = "/repo", path = "file.ts", before = text("old
|
|
|
12
13
|
|
|
13
14
|
test("session changes starts empty without reading the worktree or its untracked contents", () => {
|
|
14
15
|
const changes = new SessionChanges("session");
|
|
15
|
-
assert.deepEqual(changes.model, {
|
|
16
|
+
assert.deepEqual(changes.model, { ...emptyChanges(), digest: emptyChanges().digest });
|
|
16
17
|
assert.deepEqual(changes.worktrees, []);
|
|
17
18
|
});
|
|
18
19
|
|