gentle-pi 3.7.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import {
|
|
4
4
|
chmodSync,
|
|
5
5
|
existsSync,
|
|
6
|
+
lstatSync,
|
|
6
7
|
mkdirSync,
|
|
7
8
|
mkdtempSync,
|
|
8
9
|
readdirSync,
|
|
@@ -29,6 +30,62 @@ const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url);
|
|
|
29
30
|
const binPath = fileURLToPath(binUrl);
|
|
30
31
|
const packageRoot = dirname(dirname(binPath));
|
|
31
32
|
|
|
33
|
+
test("real adjacent Pi resolves through its public entry without PATH or a runtime override", (t) => {
|
|
34
|
+
const f = fixture(t);
|
|
35
|
+
const result = spawnSync(process.execPath, [binPath, "--version"], {
|
|
36
|
+
env: { HOME: f.home, USERPROFILE: f.home, PATH: "", GENTLE_SHELL_NO_AUTO_SETUP: "1" },
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
});
|
|
39
|
+
assert.equal(result.status, 0, result.stderr);
|
|
40
|
+
// The adjacent peer is the release the open development range resolved,
|
|
41
|
+
// not the manifest specifier.
|
|
42
|
+
const installed: string = JSON.parse(readFileSync(join(packageRoot, "node_modules", "@earendil-works", "pi-coding-agent", "package.json"), "utf8")).version;
|
|
43
|
+
assert.match(result.stdout, new RegExp(`pi ${installed.replace(/\./g, "\\.")}\\b`));
|
|
44
|
+
assert.equal(existsSync(join(f.home, ".gentle-shell", "agent")), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A private copy places the launcher's resolution root outside this checkout,
|
|
48
|
+
// so absent-peer fallback is exercised without moving any real dependency.
|
|
49
|
+
function standaloneLauncher(t: test.TestContext) {
|
|
50
|
+
const f = fixture(t);
|
|
51
|
+
const root = join(f.root, "standalone");
|
|
52
|
+
mkdirSync(join(root, "bin"), { recursive: true });
|
|
53
|
+
writeFileSync(join(root, "bin", "gentle-shell.mjs"), readFileSync(binPath));
|
|
54
|
+
writeFileSync(join(root, "package.json"), readFileSync(join(packageRoot, "package.json")));
|
|
55
|
+
for (const dir of ["runtime", "scripts"]) symlinkSync(join(packageRoot, dir), join(root, dir), "junction");
|
|
56
|
+
const env = { HOME: f.home, USERPROFILE: f.home, PATH: `${root}${delimiter}${dirname(process.execPath)}`, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
|
|
57
|
+
return { ...f, root, env, launcher: join(root, "bin", "gentle-shell.mjs") };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("a genuinely absent adjacent peer falls back to PATH", (t) => {
|
|
61
|
+
const f = standaloneLauncher(t);
|
|
62
|
+
writePiScript(join(f.root, "pi"), "0.99.2");
|
|
63
|
+
const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" });
|
|
64
|
+
assert.equal(result.status, 0, result.stderr);
|
|
65
|
+
assert.match(result.stdout, /pi 0\.99\.2/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("malformed adjacent metadata never silently falls back to PATH; env override still wins", (t) => {
|
|
69
|
+
const f = standaloneLauncher(t);
|
|
70
|
+
const peer = join(f.root, "node_modules", "@earendil-works", "pi-coding-agent");
|
|
71
|
+
mkdirSync(join(peer, "dist", "bundle"), { recursive: true });
|
|
72
|
+
writeFileSync(join(peer, "dist", "index.js"), "");
|
|
73
|
+
writePiScript(join(peer, "dist", "bundle", "cli.js"), "0.99.1");
|
|
74
|
+
const fallback = join(f.root, "pi");
|
|
75
|
+
writePiScript(fallback, "0.99.2");
|
|
76
|
+
const base = { name: "@earendil-works/pi-coding-agent", type: "module", exports: { ".": { import: "./dist/index.js" } }, bin: { pi: "dist/bundle/cli.js" } };
|
|
77
|
+
for (const metadata of [{ ...base, name: "impostor" }, { ...base, bin: { pi: "../outside.js" } }, { ...base, bin: {} }, "malformed"]) {
|
|
78
|
+
writeFileSync(join(peer, "package.json"), metadata === "malformed" ? "{" : JSON.stringify(metadata));
|
|
79
|
+
const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" });
|
|
80
|
+
assert.equal(result.status, 1);
|
|
81
|
+
assert.equal(result.stdout, "");
|
|
82
|
+
}
|
|
83
|
+
writeFileSync(join(peer, "package.json"), JSON.stringify({ ...base, name: "impostor" }));
|
|
84
|
+
const override = spawnSync(process.execPath, [f.launcher, "--version"], { env: { ...f.env, GENTLE_SHELL_PI: fallback }, encoding: "utf8" });
|
|
85
|
+
assert.equal(override.status, 0, override.stderr);
|
|
86
|
+
assert.match(override.stdout, /pi 0\.99\.2/);
|
|
87
|
+
});
|
|
88
|
+
|
|
32
89
|
// The launcher's own gentle-pi version, exactly as `ownPackageVersion()` in
|
|
33
90
|
// bin/gentle-shell.mjs reads it (package.json at packageRoot) — used to
|
|
34
91
|
// assert the post-install gentle-pi removal message and the provisioning
|
|
@@ -50,7 +107,7 @@ function fixture(t: test.TestContext) {
|
|
|
50
107
|
const home = join(root, "home");
|
|
51
108
|
mkdirSync(home, { recursive: true });
|
|
52
109
|
const piScript = join(root, "fake-pi.mjs");
|
|
53
|
-
writePiScript(piScript, "0.
|
|
110
|
+
writePiScript(piScript, "0.99.1");
|
|
54
111
|
const gentleShellHome = join(root, "gentle-shell-home");
|
|
55
112
|
const env: NodeJS.ProcessEnv = {
|
|
56
113
|
...process.env,
|
|
@@ -94,6 +151,7 @@ function writePiScript(path: string, version: string, removeExitCode = 0) {
|
|
|
94
151
|
" args,",
|
|
95
152
|
" PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
|
|
96
153
|
" GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
|
|
154
|
+
" GENTLE_SHELL_USER_PI_HOME: process.env.GENTLE_SHELL_USER_PI_HOME,",
|
|
97
155
|
"}));",
|
|
98
156
|
"process.exit(0);",
|
|
99
157
|
"",
|
|
@@ -106,6 +164,133 @@ function run(env: NodeJS.ProcessEnv, args: string[], options: { cwd?: string } =
|
|
|
106
164
|
return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env, ...options });
|
|
107
165
|
}
|
|
108
166
|
|
|
167
|
+
// Herdr tests model terminal/socket metadata only: no socket is opened and the
|
|
168
|
+
// real managed bridge is never imported. The fake pi records launch resources.
|
|
169
|
+
function herdrFixture(t: test.TestContext) {
|
|
170
|
+
const f = fixture(t);
|
|
171
|
+
const bridge = join(f.home, ".pi", "agent", "extensions", "herdr-agent-state.ts");
|
|
172
|
+
mkdirSync(dirname(bridge), { recursive: true });
|
|
173
|
+
writeFileSync(bridge, "export default function () {}\n");
|
|
174
|
+
const socket = join(f.root, "mock-herdr.sock");
|
|
175
|
+
const preload = join(f.root, "terminal-and-socket.cjs");
|
|
176
|
+
writeFileSync(preload, [
|
|
177
|
+
"const fs = require('node:fs');",
|
|
178
|
+
"const { syncBuiltinESMExports } = require('node:module');",
|
|
179
|
+
"const originalStat = fs.statSync;",
|
|
180
|
+
"fs.statSync = (path, ...args) => path === process.env.HERDR_SOCKET_PATH",
|
|
181
|
+
" ? { isSocket: () => process.env.TEST_SOCKET_VALID === '1' } : originalStat(path, ...args);",
|
|
182
|
+
"process.stdin.isTTY = process.env.TEST_STDIN_TTY === '1';",
|
|
183
|
+
"process.stdout.isTTY = process.env.TEST_STDOUT_TTY === '1';",
|
|
184
|
+
"require('node:net').createConnection = () => { throw new Error('Live socket forbidden'); };",
|
|
185
|
+
"syncBuiltinESMExports();",
|
|
186
|
+
].join("\n"));
|
|
187
|
+
const env: NodeJS.ProcessEnv = {
|
|
188
|
+
...f.env,
|
|
189
|
+
HERDR_ENV: "1", HERDR_SOCKET_PATH: socket, HERDR_PANE_ID: "mock-pane",
|
|
190
|
+
TEST_SOCKET_VALID: "1", TEST_STDIN_TTY: "1", TEST_STDOUT_TTY: "1",
|
|
191
|
+
};
|
|
192
|
+
delete env.GENTLE_PI_AGENTS_CHILD;
|
|
193
|
+
// Keep parent agent-home inheritance out of every portable test fixture.
|
|
194
|
+
delete env.PI_CODING_AGENT_DIR;
|
|
195
|
+
const launch = (args: string[] = [], overrides: NodeJS.ProcessEnv = {}) => {
|
|
196
|
+
const result = spawnSync(process.execPath, ["--require", preload, binPath, ...args], {
|
|
197
|
+
encoding: "utf8", env: { ...env, ...overrides }, cwd: f.root,
|
|
198
|
+
});
|
|
199
|
+
assert.equal(result.status, 0, result.stderr);
|
|
200
|
+
return JSON.parse(result.stdout).args as string[];
|
|
201
|
+
};
|
|
202
|
+
return { ...f, bridge, env, launch };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function extensionPaths(args: string[]): string[] {
|
|
206
|
+
return args.flatMap((arg, i) => arg === "-e" || arg === "--extension" ? [args[i + 1]] : []);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test("Herdr isolated TUI launch explicitly loads the canonical managed bridge", (t) => {
|
|
210
|
+
const f = herdrFixture(t);
|
|
211
|
+
assert.ok(extensionPaths(f.launch()).includes(realpathSync(f.bridge)));
|
|
212
|
+
assert.equal(readFileSync(f.bridge, "utf8"), "export default function () {}\n");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("Herdr prefers the incoming agent-home bridge and falls back to the conventional home", (t) => {
|
|
216
|
+
const f = herdrFixture(t);
|
|
217
|
+
const previousHome = join(f.root, "previous-agent");
|
|
218
|
+
const previousBridge = join(previousHome, "extensions", "herdr-agent-state.ts");
|
|
219
|
+
mkdirSync(dirname(previousBridge), { recursive: true });
|
|
220
|
+
writeFileSync(previousBridge, "export default function () {}\n");
|
|
221
|
+
const previousPaths = extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: previousHome }));
|
|
222
|
+
assert.ok(previousPaths.includes(realpathSync(previousBridge)));
|
|
223
|
+
assert.ok(!previousPaths.includes(realpathSync(f.bridge)));
|
|
224
|
+
assert.ok(extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: f.gentleShellHome })).includes(realpathSync(f.bridge)));
|
|
225
|
+
const selectedBridge = join(f.gentleShellHome, "extensions", "herdr-agent-state.ts");
|
|
226
|
+
mkdirSync(dirname(selectedBridge), { recursive: true });
|
|
227
|
+
writeFileSync(selectedBridge, "export default function () {}\n");
|
|
228
|
+
const selectedPaths = extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: previousHome }));
|
|
229
|
+
assert.ok(selectedPaths.includes(realpathSync(selectedBridge)));
|
|
230
|
+
assert.ok(!selectedPaths.includes(realpathSync(previousBridge)));
|
|
231
|
+
assert.ok(!selectedPaths.includes(realpathSync(f.bridge)));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("Herdr bridge injection respects disabled extensions, headless modes and child launches", (t) => {
|
|
235
|
+
const f = herdrFixture(t);
|
|
236
|
+
for (const args of [["--no-extensions"], ["-ne"], ["-p", "prompt"], ["--print", "prompt"],
|
|
237
|
+
["--mode", "rpc"], ["--mode", "json"], ["--export", "session.jsonl"], ["--list-models"],
|
|
238
|
+
["--link"], ["--home", f.gentleShellHome], ["list"]]) {
|
|
239
|
+
assert.ok(!extensionPaths(f.launch(args)).includes(realpathSync(f.bridge)), JSON.stringify(args));
|
|
240
|
+
}
|
|
241
|
+
for (const overrides of [{ HERDR_ENV: "0" }, { HERDR_ENV: undefined }, { HERDR_PANE_ID: "" },
|
|
242
|
+
{ HERDR_SOCKET_PATH: "" }, { TEST_SOCKET_VALID: "0" }, { TEST_STDIN_TTY: "0" },
|
|
243
|
+
{ TEST_STDOUT_TTY: "0" }, { GENTLE_PI_AGENTS_CHILD: "1" }]) {
|
|
244
|
+
assert.ok(!extensionPaths(f.launch([], overrides)).includes(realpathSync(f.bridge)), JSON.stringify(overrides));
|
|
245
|
+
}
|
|
246
|
+
assert.ok(extensionPaths(f.launch(["--mode", "text"])).includes(realpathSync(f.bridge)));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("Herdr absent bridge is nonfatal and explicit resources are preserved", (t) => {
|
|
250
|
+
const f = herdrFixture(t);
|
|
251
|
+
const absentHome = join(f.root, "empty-home");
|
|
252
|
+
mkdirSync(absentHome);
|
|
253
|
+
assert.ok(!extensionPaths(f.launch([], { HOME: absentHome, USERPROFILE: absentHome })).includes(realpathSync(f.bridge)));
|
|
254
|
+
const alias = join(f.root, "bridge-alias.ts");
|
|
255
|
+
symlinkSync(f.bridge, alias);
|
|
256
|
+
const args = f.launch(["-e", alias]);
|
|
257
|
+
assert.ok(extensionPaths(args).includes(alias));
|
|
258
|
+
assert.ok(extensionPaths(args).includes(realpathSync(f.bridge)));
|
|
259
|
+
const disabled = f.launch(["--no-extensions", "-e", alias]);
|
|
260
|
+
assert.ok(extensionPaths(disabled).includes(alias));
|
|
261
|
+
assert.ok(!extensionPaths(disabled).includes(realpathSync(f.bridge)));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("Pi real resource loader deduplicates the bridge across discovery, explicit files and package directories", async (t) => {
|
|
265
|
+
const { DefaultResourceLoader, SettingsManager, createEventBus } = await import("@earendil-works/pi-coding-agent");
|
|
266
|
+
for (const scenario of ["discovered", "explicit", "directory"] as const) {
|
|
267
|
+
const f = fixture(t);
|
|
268
|
+
const agentDir = join(f.home, ".pi", "agent");
|
|
269
|
+
const bridge = join(agentDir, "extensions", "herdr-agent-state.ts");
|
|
270
|
+
mkdirSync(dirname(bridge), { recursive: true });
|
|
271
|
+
writeFileSync(bridge, "export default function (pi) { pi.events.emit('mock:bridge-loaded', {}); }\n");
|
|
272
|
+
const canonical = realpathSync(bridge);
|
|
273
|
+
const alias = join(f.root, "alias.ts");
|
|
274
|
+
symlinkSync(bridge, alias);
|
|
275
|
+
writeFileSync(join(agentDir, "package.json"), JSON.stringify({ pi: { extensions: ["./extensions/herdr-agent-state.ts"] } }));
|
|
276
|
+
const eventBus = createEventBus();
|
|
277
|
+
let factoryCalls = 0;
|
|
278
|
+
eventBus.on("mock:bridge-loaded", () => { factoryCalls++; });
|
|
279
|
+
const additionalExtensionPaths = scenario === "explicit" ? [alias, canonical, canonical]
|
|
280
|
+
: scenario === "directory" ? [agentDir, canonical] : [canonical];
|
|
281
|
+
const loader = new DefaultResourceLoader({ cwd: f.root, agentDir, eventBus,
|
|
282
|
+
settingsManager: SettingsManager.inMemory({ extensions: ["-builtin:mcp", "-builtin:llama.cpp", "-builtin:codemode", "-builtin:tool-search"] }),
|
|
283
|
+
additionalExtensionPaths, noExtensions: scenario !== "discovered", noSkills: true,
|
|
284
|
+
noPromptTemplates: true, noThemes: true, noContextFiles: true,
|
|
285
|
+
});
|
|
286
|
+
await loader.reload();
|
|
287
|
+
const loaded = loader.getExtensions();
|
|
288
|
+
assert.deepEqual(loaded.errors, [], scenario);
|
|
289
|
+
assert.equal(loaded.extensions.length, 1, scenario);
|
|
290
|
+
assert.equal(factoryCalls, 1, scenario);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
109
294
|
// Test/development-only stub for the setup subcommand's gentle-ai binary:
|
|
110
295
|
// records its argv and the environment gentle-shell sets around it, instead
|
|
111
296
|
// of running the real package-local gentle-ai (whose supply-chain integrity
|
|
@@ -276,7 +461,7 @@ function writeGentleAiScriptDeclaringGentlePi(path: string, extraSources: string
|
|
|
276
461
|
// the invocation), this models what a real `pi remove` does to settings.json
|
|
277
462
|
// closely enough to prove a later plain launch sees the declaration gone and
|
|
278
463
|
// resumes the launcher's own injection.
|
|
279
|
-
function writePiScriptEditingSettingsOnRemove(path: string, version = "0.
|
|
464
|
+
function writePiScriptEditingSettingsOnRemove(path: string, version = "0.99.1") {
|
|
280
465
|
writeFileSync(
|
|
281
466
|
path,
|
|
282
467
|
[
|
|
@@ -449,12 +634,6 @@ test("forwarded args reach pi after the injected extension flags, in order", (t)
|
|
|
449
634
|
assert.deepEqual(payload.args, [
|
|
450
635
|
"-e",
|
|
451
636
|
packageRoot,
|
|
452
|
-
"--theme",
|
|
453
|
-
join(packageRoot, "themes"),
|
|
454
|
-
"--skill",
|
|
455
|
-
join(packageRoot, "skills"),
|
|
456
|
-
"--prompt-template",
|
|
457
|
-
join(packageRoot, "prompts"),
|
|
458
637
|
"--mode",
|
|
459
638
|
"rpc",
|
|
460
639
|
"-p",
|
|
@@ -463,6 +642,34 @@ test("forwarded args reach pi after the injected extension flags, in order", (t)
|
|
|
463
642
|
assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
|
|
464
643
|
});
|
|
465
644
|
|
|
645
|
+
test("an isolated launch keeps its own agent home and carries the user's original Pi home, even when nested", (t) => {
|
|
646
|
+
const f = fixture(t);
|
|
647
|
+
const base = { ...f.env };
|
|
648
|
+
delete base.PI_CODING_AGENT_DIR;
|
|
649
|
+
delete base.GENTLE_SHELL_USER_PI_HOME;
|
|
650
|
+
const launch = (overrides: NodeJS.ProcessEnv, args: string[] = []) => {
|
|
651
|
+
const result = run({ ...base, ...overrides }, [...args, "--mode", "rpc"]);
|
|
652
|
+
assert.equal(result.status, 0, result.stderr);
|
|
653
|
+
return JSON.parse(result.stdout);
|
|
654
|
+
};
|
|
655
|
+
const conventional = launch({});
|
|
656
|
+
assert.equal(conventional.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
657
|
+
assert.equal(conventional.GENTLE_SHELL_USER_PI_HOME, join(f.home, ".pi", "agent"));
|
|
658
|
+
const customHome = join(f.root, "custom-pi");
|
|
659
|
+
const custom = launch({ PI_CODING_AGENT_DIR: customHome });
|
|
660
|
+
assert.equal(custom.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
661
|
+
assert.equal(custom.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
|
|
662
|
+
assert.equal(custom.GENTLE_SHELL_USER_PI_HOME, customHome);
|
|
663
|
+
// A gentle-shell started from inside a Gentle Shell session inherits both variables.
|
|
664
|
+
const nested = launch({ PI_CODING_AGENT_DIR: custom.PI_CODING_AGENT_DIR, GENTLE_SHELL_USER_PI_HOME: custom.GENTLE_SHELL_USER_PI_HOME });
|
|
665
|
+
assert.equal(nested.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
666
|
+
assert.equal(nested.GENTLE_SHELL_USER_PI_HOME, customHome);
|
|
667
|
+
const linked = launch({ PI_CODING_AGENT_DIR: customHome }, ["--link"]);
|
|
668
|
+
assert.equal(linked.PI_CODING_AGENT_DIR, customHome);
|
|
669
|
+
assert.equal(linked.GENTLE_SHELL_USER_PI_HOME, customHome);
|
|
670
|
+
assert.equal(existsSync(join(customHome, "sessions")), false);
|
|
671
|
+
});
|
|
672
|
+
|
|
466
673
|
test("--link skips injection and leaves settings.json byte-identical when it already declares gentle-pi", (t) => {
|
|
467
674
|
const f = fixture(t);
|
|
468
675
|
const piAgentDir = join(f.root, "pi-agent");
|
|
@@ -511,12 +718,6 @@ test("an isolated home whose settings.json does not declare gentle-pi keeps the
|
|
|
511
718
|
assert.deepEqual(payload.args, [
|
|
512
719
|
"-e",
|
|
513
720
|
packageRoot,
|
|
514
|
-
"--theme",
|
|
515
|
-
join(packageRoot, "themes"),
|
|
516
|
-
"--skill",
|
|
517
|
-
join(packageRoot, "skills"),
|
|
518
|
-
"--prompt-template",
|
|
519
|
-
join(packageRoot, "prompts"),
|
|
520
721
|
"--mode",
|
|
521
722
|
"rpc",
|
|
522
723
|
]);
|
|
@@ -538,12 +739,6 @@ test("an isolated home whose settings.json declares a path-based gentle-pi takes
|
|
|
538
739
|
"--no-extensions",
|
|
539
740
|
"-e",
|
|
540
741
|
packageRoot,
|
|
541
|
-
"--theme",
|
|
542
|
-
join(packageRoot, "themes"),
|
|
543
|
-
"--skill",
|
|
544
|
-
join(packageRoot, "skills"),
|
|
545
|
-
"--prompt-template",
|
|
546
|
-
join(packageRoot, "prompts"),
|
|
547
742
|
"--mode",
|
|
548
743
|
"rpc",
|
|
549
744
|
]);
|
|
@@ -573,7 +768,7 @@ test("a too-old pi exits 1 naming both versions", (t) => {
|
|
|
573
768
|
const result = run(f.env, []);
|
|
574
769
|
assert.equal(result.status, 1);
|
|
575
770
|
assert.match(result.stderr, /0\.80\.0/);
|
|
576
|
-
assert.match(result.stderr, /0\.
|
|
771
|
+
assert.match(result.stderr, /0\.99\.1/);
|
|
577
772
|
});
|
|
578
773
|
|
|
579
774
|
test("--version prints three lines", (t) => {
|
|
@@ -583,7 +778,7 @@ test("--version prints three lines", (t) => {
|
|
|
583
778
|
const lines = result.stdout.trim().split("\n");
|
|
584
779
|
assert.equal(lines.length, 3);
|
|
585
780
|
assert.match(lines[0], /^gentle-shell /);
|
|
586
|
-
assert.match(lines[1], /^pi 0\.
|
|
781
|
+
assert.match(lines[1], /^pi 0\.99\.1$/);
|
|
587
782
|
assert.match(lines[2], /^home isolated /);
|
|
588
783
|
});
|
|
589
784
|
|
|
@@ -904,12 +1099,6 @@ test("a later normal launch injects the launcher's own package root once gentle-
|
|
|
904
1099
|
assert.deepEqual(payload.args, [
|
|
905
1100
|
"-e",
|
|
906
1101
|
packageRoot,
|
|
907
|
-
"--theme",
|
|
908
|
-
join(packageRoot, "themes"),
|
|
909
|
-
"--skill",
|
|
910
|
-
join(packageRoot, "skills"),
|
|
911
|
-
"--prompt-template",
|
|
912
|
-
join(packageRoot, "prompts"),
|
|
913
1102
|
"--mode",
|
|
914
1103
|
"rpc",
|
|
915
1104
|
]);
|
|
@@ -920,7 +1109,7 @@ test("gentle-shell setup propagates a non-zero pi remove exit code with an actio
|
|
|
920
1109
|
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
921
1110
|
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
922
1111
|
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
923
|
-
writePiScript(failingPiScript, "0.
|
|
1112
|
+
writePiScript(failingPiScript, "0.99.1", 7);
|
|
924
1113
|
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
925
1114
|
|
|
926
1115
|
const result = run(env, ["setup"]);
|
|
@@ -941,7 +1130,7 @@ test("gentle-shell setup --home <dir> includes --home <dir> in the failing-remov
|
|
|
941
1130
|
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
942
1131
|
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
943
1132
|
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
944
|
-
writePiScript(failingPiScript, "0.
|
|
1133
|
+
writePiScript(failingPiScript, "0.99.1", 7);
|
|
945
1134
|
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
946
1135
|
|
|
947
1136
|
const result = run(env, ["--home", target, "setup"]);
|
|
@@ -965,7 +1154,7 @@ test("gentle-shell setup shell-quotes a --home path containing a space in the fa
|
|
|
965
1154
|
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
966
1155
|
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
967
1156
|
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
968
|
-
writePiScript(failingPiScript, "0.
|
|
1157
|
+
writePiScript(failingPiScript, "0.99.1", 7);
|
|
969
1158
|
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
970
1159
|
|
|
971
1160
|
const result = run(env, ["--home", target, "setup"]);
|
|
@@ -1493,7 +1682,8 @@ test("a --home equal to pi's own default agent home is never auto-provisioned, e
|
|
|
1493
1682
|
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1494
1683
|
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1495
1684
|
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1496
|
-
|
|
1685
|
+
// Exercise the default Pi home, not an ambient PI_CODING_AGENT_DIR override.
|
|
1686
|
+
const env = enableAutoProvision({ ...f.env, PI_CODING_AGENT_DIR: undefined, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1497
1687
|
|
|
1498
1688
|
const result = run(env, ["--home", defaultPiHome, "--mode", "rpc"]);
|
|
1499
1689
|
assert.equal(result.status, 0, result.stderr);
|
|
@@ -1737,7 +1927,7 @@ test("a hung 'pi remove' during post-install cleanup is killed after the timeout
|
|
|
1737
1927
|
[
|
|
1738
1928
|
"#!/usr/bin/env node",
|
|
1739
1929
|
"const args = process.argv.slice(2);",
|
|
1740
|
-
'if (args.includes("--version")) { console.log("0.
|
|
1930
|
+
'if (args.includes("--version")) { console.log("0.99.1"); process.exit(0); }',
|
|
1741
1931
|
"if (args[0] === 'remove') { await new Promise((resolve) => setTimeout(resolve, 60000)); process.exit(0); }",
|
|
1742
1932
|
"console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
|
|
1743
1933
|
"process.exit(0);",
|
|
@@ -1882,6 +2072,253 @@ test("automatic setup restores the home's own theme when gentle-ai overwrites it
|
|
|
1882
2072
|
assert.equal(settings.theme, "dracula");
|
|
1883
2073
|
});
|
|
1884
2074
|
|
|
2075
|
+
// --- builtin codemode exclusion ----------------------------------------------
|
|
2076
|
+
//
|
|
2077
|
+
// gentle-pi registers its own decorated codemode tool, so Pi's replaceable
|
|
2078
|
+
// builtin codemode always loses to it and Pi prints a startup warning. The
|
|
2079
|
+
// only per-builtin opt-out Pi supports is a `-builtin:codemode` entry in the
|
|
2080
|
+
// settings `extensions` array, which every normal launch ensures in a home
|
|
2081
|
+
// gentle-shell owns — never in --link, a foreign --home, pi's own default
|
|
2082
|
+
// agent home, a pi subcommand, or `setup --dry-run`.
|
|
2083
|
+
|
|
2084
|
+
const CODEMODE_EXCLUSION = "-builtin:codemode";
|
|
2085
|
+
|
|
2086
|
+
function settingsText(dir: string): string {
|
|
2087
|
+
return readFileSync(join(dir, "settings.json"), "utf8");
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
test("a fresh isolated home excludes Pi's builtin codemode, and a second launch changes nothing", (t) => {
|
|
2091
|
+
const f = fixture(t);
|
|
2092
|
+
const first = run(f.env, ["--mode", "rpc"]);
|
|
2093
|
+
assert.equal(first.status, 0, first.stderr);
|
|
2094
|
+
const settings = JSON.parse(settingsText(f.gentleShellHome));
|
|
2095
|
+
assert.deepEqual(settings.extensions, [CODEMODE_EXCLUSION]);
|
|
2096
|
+
assert.equal(settings.tuiMode, "fullscreen");
|
|
2097
|
+
assert.equal(settings.theme, "Gentleman-Cute");
|
|
2098
|
+
|
|
2099
|
+
const before = settingsText(f.gentleShellHome);
|
|
2100
|
+
const second = run(f.env, ["--mode", "rpc"]);
|
|
2101
|
+
assert.equal(second.status, 0, second.stderr);
|
|
2102
|
+
assert.equal(settingsText(f.gentleShellHome), before);
|
|
2103
|
+
assert.doesNotMatch(second.stderr, /builtin codemode/);
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
test("an existing isolated home gains the exclusion after its own extension entries, keeping every other key and its formatting", (t) => {
|
|
2107
|
+
for (const [indent, newline] of [[4, "\n"], [undefined, ""]] as const) {
|
|
2108
|
+
const f = fixture(t);
|
|
2109
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
2110
|
+
const original = { tuiMode: "fullscreen", theme: "rose", extensions: ["./ext/a.ts", "!./ext/b.ts"], packages: ["npm:pi-btw"] };
|
|
2111
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), `${JSON.stringify(original, null, indent)}${newline}`);
|
|
2112
|
+
|
|
2113
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
2114
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2115
|
+
const expected = { ...original, extensions: [...original.extensions, CODEMODE_EXCLUSION] };
|
|
2116
|
+
assert.equal(settingsText(f.gentleShellHome), `${JSON.stringify(expected, null, indent)}${newline}`);
|
|
2117
|
+
assert.match(result.stderr, /builtin codemode/);
|
|
2118
|
+
}
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
test("an explicit user entry for builtin:codemode is left byte-identical", (t) => {
|
|
2122
|
+
for (const entry of ["+builtin:codemode", "!builtin:codemode", "builtin:codemode", CODEMODE_EXCLUSION]) {
|
|
2123
|
+
const f = fixture(t);
|
|
2124
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
2125
|
+
const text = JSON.stringify({ tuiMode: "fullscreen", extensions: ["./ext/a.ts", entry] });
|
|
2126
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), text);
|
|
2127
|
+
|
|
2128
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
2129
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2130
|
+
assert.equal(settingsText(f.gentleShellHome), text, entry);
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test("a malformed or unexpected settings.json is never overwritten and pi still starts", (t) => {
|
|
2135
|
+
for (const text of ["{ not json", "[]", '{"extensions":"oops"}']) {
|
|
2136
|
+
const f = fixture(t);
|
|
2137
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
2138
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), text);
|
|
2139
|
+
|
|
2140
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
2141
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2142
|
+
assert.deepEqual(JSON.parse(result.stdout).args.slice(-2), ["--mode", "rpc"]);
|
|
2143
|
+
assert.equal(settingsText(f.gentleShellHome), text);
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
test("an owned home without settings.json is not given one", (t) => {
|
|
2148
|
+
const f = fixture(t);
|
|
2149
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
2150
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
2151
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2152
|
+
assert.equal(existsSync(join(f.gentleShellHome, "settings.json")), false);
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
test("--link never gains the exclusion", (t) => {
|
|
2156
|
+
const f = fixture(t);
|
|
2157
|
+
const piAgentDir = join(f.root, "pi-agent-link");
|
|
2158
|
+
mkdirSync(piAgentDir, { recursive: true });
|
|
2159
|
+
const text = JSON.stringify({ tuiMode: "fullscreen" });
|
|
2160
|
+
writeFileSync(join(piAgentDir, "settings.json"), text);
|
|
2161
|
+
|
|
2162
|
+
const result = run({ ...f.env, PI_CODING_AGENT_DIR: piAgentDir }, ["--link", "--mode", "rpc"]);
|
|
2163
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2164
|
+
assert.equal(settingsText(piAgentDir), text);
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
test("a foreign --home and pi's own default agent home are never edited, while a bootstrapped --home is", (t) => {
|
|
2168
|
+
const f = fixture(t);
|
|
2169
|
+
const foreign = join(f.root, "existing-pi-home");
|
|
2170
|
+
const defaultPiHome = join(f.home, ".pi", "agent");
|
|
2171
|
+
const text = JSON.stringify({ tuiMode: "fullscreen" });
|
|
2172
|
+
for (const dir of [foreign, defaultPiHome]) {
|
|
2173
|
+
mkdirSync(dir, { recursive: true });
|
|
2174
|
+
writeFileSync(join(dir, "settings.json"), text);
|
|
2175
|
+
const result = run({ ...f.env, PI_CODING_AGENT_DIR: undefined }, ["--home", dir, "--mode", "rpc"]);
|
|
2176
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2177
|
+
assert.equal(settingsText(dir), text, dir);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
const bootstrapped = join(f.root, "brand-new-home");
|
|
2181
|
+
const result = run(f.env, ["--home", bootstrapped, "--mode", "rpc"]);
|
|
2182
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2183
|
+
assert.deepEqual(JSON.parse(settingsText(bootstrapped)).extensions, [CODEMODE_EXCLUSION]);
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
test("a pi subcommand and setup --dry-run never edit the home's settings", (t) => {
|
|
2187
|
+
const f = fixture(t);
|
|
2188
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
2189
|
+
const text = JSON.stringify({ tuiMode: "fullscreen" });
|
|
2190
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), text);
|
|
2191
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
2192
|
+
writeGentleAiScript(gentleAiScript);
|
|
2193
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
2194
|
+
|
|
2195
|
+
for (const args of [["list"], ["setup", "--dry-run"]]) {
|
|
2196
|
+
const result = run(env, args);
|
|
2197
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2198
|
+
assert.equal(settingsText(f.gentleShellHome), text, args.join(" "));
|
|
2199
|
+
}
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
test("the exclusion is ensured after automatic setup rewrites settings.json", (t) => {
|
|
2203
|
+
const f = fixture(t);
|
|
2204
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-drops-extensions.mjs");
|
|
2205
|
+
writeFileSync(
|
|
2206
|
+
gentleAiScript,
|
|
2207
|
+
[
|
|
2208
|
+
"#!/usr/bin/env node",
|
|
2209
|
+
"import { readFileSync, writeFileSync } from 'node:fs';",
|
|
2210
|
+
"import { join } from 'node:path';",
|
|
2211
|
+
"const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
|
|
2212
|
+
"const { extensions, ...settings } = JSON.parse(readFileSync(settingsPath, 'utf8'));",
|
|
2213
|
+
"writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\\n');",
|
|
2214
|
+
"process.exit(0);",
|
|
2215
|
+
"",
|
|
2216
|
+
].join("\n"),
|
|
2217
|
+
);
|
|
2218
|
+
chmodSync(gentleAiScript, 0o755);
|
|
2219
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
2220
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), `${JSON.stringify({ tuiMode: "fullscreen", extensions: [CODEMODE_EXCLUSION] }, null, 2)}\n`);
|
|
2221
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
2222
|
+
|
|
2223
|
+
const result = run(env, ["--mode", "rpc"]);
|
|
2224
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2225
|
+
assert.deepEqual(JSON.parse(settingsText(f.gentleShellHome)).extensions, [CODEMODE_EXCLUSION]);
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
// --- atomic writes keep symlinked config files intact ------------------------
|
|
2229
|
+
//
|
|
2230
|
+
// Dotfile managers (Nix home-manager, stow, ...) ship settings.json and
|
|
2231
|
+
// config.json as symlinks. Every atomic launcher write must rename onto the
|
|
2232
|
+
// link's real target, so the link survives and the target gets the new
|
|
2233
|
+
// bytes with its own permission bits.
|
|
2234
|
+
|
|
2235
|
+
// File symlinks need extra privileges on Windows; skip where unavailable.
|
|
2236
|
+
const fileSymlinkSkip = (() => {
|
|
2237
|
+
const dir = mkdtempSync(join(tmpdir(), "gentle-shell-symlink-probe-"));
|
|
2238
|
+
try {
|
|
2239
|
+
writeFileSync(join(dir, "target"), "");
|
|
2240
|
+
symlinkSync(join(dir, "target"), join(dir, "link"), "file");
|
|
2241
|
+
return false;
|
|
2242
|
+
} catch {
|
|
2243
|
+
return "file symlinks are unavailable here";
|
|
2244
|
+
} finally {
|
|
2245
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2246
|
+
}
|
|
2247
|
+
})();
|
|
2248
|
+
|
|
2249
|
+
function symlinkedFile(linkPath: string, targetPath: string, text: string, mode: number) {
|
|
2250
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
2251
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
2252
|
+
writeFileSync(targetPath, text);
|
|
2253
|
+
chmodSync(targetPath, mode);
|
|
2254
|
+
symlinkSync(targetPath, linkPath, "file");
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function assertStillLinkedTo(linkPath: string, targetPath: string, mode: number) {
|
|
2258
|
+
assert.ok(lstatSync(linkPath).isSymbolicLink(), `${linkPath} is still a symlink`);
|
|
2259
|
+
assert.equal(realpathSync(linkPath), realpathSync(targetPath));
|
|
2260
|
+
if (process.platform !== "win32") assert.equal(statSync(targetPath).mode & 0o777, mode);
|
|
2261
|
+
for (const dir of [dirname(linkPath), dirname(targetPath)]) {
|
|
2262
|
+
assert.deepEqual(readdirSync(dir).filter((name) => name.endsWith(".tmp")), [], `no temp file left in ${dir}`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
test("the builtin codemode exclusion writes through a symlinked settings.json", { skip: fileSymlinkSkip }, (t) => {
|
|
2267
|
+
const f = fixture(t);
|
|
2268
|
+
const linkPath = join(f.gentleShellHome, "settings.json");
|
|
2269
|
+
const targetPath = join(f.root, "dotfiles", "settings.json");
|
|
2270
|
+
symlinkedFile(linkPath, targetPath, JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }), 0o640);
|
|
2271
|
+
|
|
2272
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
2273
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2274
|
+
assertStillLinkedTo(linkPath, targetPath, 0o640);
|
|
2275
|
+
assert.deepEqual(JSON.parse(readFileSync(targetPath, "utf8")), { tuiMode: "fullscreen", theme: "rose", extensions: [CODEMODE_EXCLUSION] });
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
test("the automatic setup theme restore writes through a symlinked settings.json", { skip: fileSymlinkSkip }, (t) => {
|
|
2279
|
+
const f = fixture(t);
|
|
2280
|
+
const target = join(f.root, "linked-themed-home");
|
|
2281
|
+
const linkPath = join(target, "settings.json");
|
|
2282
|
+
const targetPath = join(f.root, "dotfiles", "themed-settings.json");
|
|
2283
|
+
symlinkedFile(linkPath, targetPath, JSON.stringify({ tuiMode: "fullscreen", theme: "dracula" }), 0o600);
|
|
2284
|
+
writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
|
|
2285
|
+
|
|
2286
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
|
|
2287
|
+
writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
|
|
2288
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
2289
|
+
|
|
2290
|
+
const result = run(env, ["--home", target, "--mode", "rpc"]);
|
|
2291
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2292
|
+
assertStillLinkedTo(linkPath, targetPath, 0o600);
|
|
2293
|
+
assert.equal(JSON.parse(readFileSync(targetPath, "utf8")).theme, "dracula");
|
|
2294
|
+
});
|
|
2295
|
+
|
|
2296
|
+
test("'home' persistence writes through a symlinked config.json", { skip: fileSymlinkSkip }, (t) => {
|
|
2297
|
+
const f = fixture(t);
|
|
2298
|
+
const linkPath = join(f.root, "config", "config.json");
|
|
2299
|
+
const targetPath = join(f.root, "dotfiles", "config.json");
|
|
2300
|
+
symlinkedFile(linkPath, targetPath, "{}\n", 0o600);
|
|
2301
|
+
|
|
2302
|
+
const result = run({ ...f.env, GENTLE_SHELL_CONFIG: linkPath }, ["home", "link"]);
|
|
2303
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2304
|
+
assertStillLinkedTo(linkPath, targetPath, 0o600);
|
|
2305
|
+
assert.deepEqual(JSON.parse(readFileSync(targetPath, "utf8")), { home: "link" });
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
test("a dangling config.json symlink is replaced in place, never followed to create its target", { skip: fileSymlinkSkip }, (t) => {
|
|
2309
|
+
const f = fixture(t);
|
|
2310
|
+
const linkPath = join(f.root, "config", "config.json");
|
|
2311
|
+
const missingTarget = join(f.root, "missing", "config.json");
|
|
2312
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
2313
|
+
symlinkSync(missingTarget, linkPath, "file");
|
|
2314
|
+
|
|
2315
|
+
const result = run({ ...f.env, GENTLE_SHELL_CONFIG: linkPath }, ["home", "link"]);
|
|
2316
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2317
|
+
assert.equal(lstatSync(linkPath).isSymbolicLink(), false);
|
|
2318
|
+
assert.deepEqual(JSON.parse(readFileSync(linkPath, "utf8")), { home: "link" });
|
|
2319
|
+
assert.equal(existsSync(dirname(missingTarget)), false);
|
|
2320
|
+
});
|
|
2321
|
+
|
|
1885
2322
|
// --- --package-root silently ignored in a declared non-link home ----------
|
|
1886
2323
|
|
|
1887
2324
|
test("--package-root warns and is ignored when an isolated home already declares gentle-pi", (t) => {
|
|
@@ -1963,12 +2400,6 @@ test("--link takes over a path-declared conflicting gentle-pi: --no-extensions,
|
|
|
1963
2400
|
join(piAgentDir, "npm", "node_modules", "some-other"),
|
|
1964
2401
|
"-e",
|
|
1965
2402
|
packageRoot,
|
|
1966
|
-
"--theme",
|
|
1967
|
-
join(packageRoot, "themes"),
|
|
1968
|
-
"--skill",
|
|
1969
|
-
join(packageRoot, "skills"),
|
|
1970
|
-
"--prompt-template",
|
|
1971
|
-
join(packageRoot, "prompts"),
|
|
1972
2403
|
"--mode",
|
|
1973
2404
|
"rpc",
|
|
1974
2405
|
]);
|
|
@@ -2019,7 +2450,7 @@ test("--link does not take over a git-sourced other package: it is skipped with
|
|
|
2019
2450
|
assert.match(result.stderr, /skipping git-sourced package/);
|
|
2020
2451
|
|
|
2021
2452
|
const payload = JSON.parse(result.stdout);
|
|
2022
|
-
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot
|
|
2453
|
+
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
|
|
2023
2454
|
});
|
|
2024
2455
|
|
|
2025
2456
|
test("--package-root forces a takeover even when settings already declare a matching npm:gentle-pi", (t) => {
|
|
@@ -2039,17 +2470,7 @@ test("--package-root forces a takeover even when settings already declare a matc
|
|
|
2039
2470
|
assert.match(result.stderr, /taking over gentle-pi from npm:gentle-pi/);
|
|
2040
2471
|
|
|
2041
2472
|
const payload = JSON.parse(result.stdout);
|
|
2042
|
-
assert.deepEqual(payload.args, [
|
|
2043
|
-
"--no-extensions",
|
|
2044
|
-
"-e",
|
|
2045
|
-
forcedRoot,
|
|
2046
|
-
"--theme",
|
|
2047
|
-
join(forcedRoot, "themes"),
|
|
2048
|
-
"--skill",
|
|
2049
|
-
join(forcedRoot, "skills"),
|
|
2050
|
-
"--prompt-template",
|
|
2051
|
-
join(forcedRoot, "prompts"),
|
|
2052
|
-
]);
|
|
2473
|
+
assert.deepEqual(payload.args, ["--no-extensions", "-e", forcedRoot]);
|
|
2053
2474
|
assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
|
|
2054
2475
|
});
|
|
2055
2476
|
|
|
@@ -2079,12 +2500,6 @@ test("--package-root forces a takeover even with no gentle-pi declaration at all
|
|
|
2079
2500
|
join(piAgentDir, "npm", "node_modules", "some-other"),
|
|
2080
2501
|
"-e",
|
|
2081
2502
|
forcedRoot,
|
|
2082
|
-
"--theme",
|
|
2083
|
-
join(forcedRoot, "themes"),
|
|
2084
|
-
"--skill",
|
|
2085
|
-
join(forcedRoot, "skills"),
|
|
2086
|
-
"--prompt-template",
|
|
2087
|
-
join(forcedRoot, "prompts"),
|
|
2088
2503
|
]);
|
|
2089
2504
|
assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
|
|
2090
2505
|
});
|
|
@@ -2117,12 +2532,6 @@ test("--link take-over with --package-root injects exactly one -e when a setting
|
|
|
2117
2532
|
"--no-extensions",
|
|
2118
2533
|
"-e",
|
|
2119
2534
|
realOtherDir,
|
|
2120
|
-
"--theme",
|
|
2121
|
-
join(realOtherDir, "themes"),
|
|
2122
|
-
"--skill",
|
|
2123
|
-
join(realOtherDir, "skills"),
|
|
2124
|
-
"--prompt-template",
|
|
2125
|
-
join(realOtherDir, "prompts"),
|
|
2126
2535
|
]);
|
|
2127
2536
|
});
|
|
2128
2537
|
|
|
@@ -2189,12 +2598,6 @@ test("--link take-over re-injects loose extension files, one -e per discovered f
|
|
|
2189
2598
|
join(resolvedLooseProjectExtensions, "project-ext.mjs"),
|
|
2190
2599
|
"-e",
|
|
2191
2600
|
packageRoot,
|
|
2192
|
-
"--theme",
|
|
2193
|
-
join(packageRoot, "themes"),
|
|
2194
|
-
"--skill",
|
|
2195
|
-
join(packageRoot, "skills"),
|
|
2196
|
-
"--prompt-template",
|
|
2197
|
-
join(packageRoot, "prompts"),
|
|
2198
2601
|
"--mode",
|
|
2199
2602
|
"rpc",
|
|
2200
2603
|
]);
|
|
@@ -2234,12 +2637,6 @@ test("--link take-over injects a root-level index.ts as its own loose file entry
|
|
|
2234
2637
|
join(looseAgentExtensions, "index.ts"),
|
|
2235
2638
|
"-e",
|
|
2236
2639
|
packageRoot,
|
|
2237
|
-
"--theme",
|
|
2238
|
-
join(packageRoot, "themes"),
|
|
2239
|
-
"--skill",
|
|
2240
|
-
join(packageRoot, "skills"),
|
|
2241
|
-
"--prompt-template",
|
|
2242
|
-
join(packageRoot, "prompts"),
|
|
2243
2640
|
]);
|
|
2244
2641
|
});
|
|
2245
2642
|
|
|
@@ -2270,12 +2667,6 @@ test("--link take-over injects a subdirectory's own index.ts entry point even wh
|
|
|
2270
2667
|
join(subDir, "index.ts"),
|
|
2271
2668
|
"-e",
|
|
2272
2669
|
packageRoot,
|
|
2273
|
-
"--theme",
|
|
2274
|
-
join(packageRoot, "themes"),
|
|
2275
|
-
"--skill",
|
|
2276
|
-
join(packageRoot, "skills"),
|
|
2277
|
-
"--prompt-template",
|
|
2278
|
-
join(packageRoot, "prompts"),
|
|
2279
2670
|
]);
|
|
2280
2671
|
});
|
|
2281
2672
|
|
|
@@ -2295,7 +2686,7 @@ test("--link take-over omits -e flags for loose extension dirs that do not exist
|
|
|
2295
2686
|
assert.equal(result.status, 0, result.stderr);
|
|
2296
2687
|
|
|
2297
2688
|
const payload = JSON.parse(result.stdout);
|
|
2298
|
-
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot
|
|
2689
|
+
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
|
|
2299
2690
|
});
|
|
2300
2691
|
|
|
2301
2692
|
// --- R3-001/R4-takeover-injects-unverified-package-dirs -------------------
|
|
@@ -2342,12 +2733,6 @@ test("--link take-over skips a declared package directory that is not installed,
|
|
|
2342
2733
|
installedOtherDir,
|
|
2343
2734
|
"-e",
|
|
2344
2735
|
packageRoot,
|
|
2345
|
-
"--theme",
|
|
2346
|
-
join(packageRoot, "themes"),
|
|
2347
|
-
"--skill",
|
|
2348
|
-
join(packageRoot, "skills"),
|
|
2349
|
-
"--prompt-template",
|
|
2350
|
-
join(packageRoot, "prompts"),
|
|
2351
2736
|
"--mode",
|
|
2352
2737
|
"rpc",
|
|
2353
2738
|
]);
|
|
@@ -2380,12 +2765,6 @@ test("--link take-over injects the launcher's own package root once when --packa
|
|
|
2380
2765
|
"--no-extensions",
|
|
2381
2766
|
"-e",
|
|
2382
2767
|
forcedRoot,
|
|
2383
|
-
"--theme",
|
|
2384
|
-
join(forcedRoot, "themes"),
|
|
2385
|
-
"--skill",
|
|
2386
|
-
join(forcedRoot, "skills"),
|
|
2387
|
-
"--prompt-template",
|
|
2388
|
-
join(forcedRoot, "prompts"),
|
|
2389
2768
|
]);
|
|
2390
2769
|
});
|
|
2391
2770
|
|
|
@@ -2402,12 +2781,6 @@ test("--isolated --package-root produces the plain injection (no --no-extensions
|
|
|
2402
2781
|
assert.deepEqual(payload.args, [
|
|
2403
2782
|
"-e",
|
|
2404
2783
|
forcedRoot,
|
|
2405
|
-
"--theme",
|
|
2406
|
-
join(forcedRoot, "themes"),
|
|
2407
|
-
"--skill",
|
|
2408
|
-
join(forcedRoot, "skills"),
|
|
2409
|
-
"--prompt-template",
|
|
2410
|
-
join(forcedRoot, "prompts"),
|
|
2411
2784
|
"--mode",
|
|
2412
2785
|
"rpc",
|
|
2413
2786
|
]);
|
|
@@ -2453,7 +2826,7 @@ test("--link take-over warns once when a loose extensions directory cannot be re
|
|
|
2453
2826
|
assert.match(result.stderr, /extensions/);
|
|
2454
2827
|
|
|
2455
2828
|
const payload = JSON.parse(result.stdout);
|
|
2456
|
-
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot
|
|
2829
|
+
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
|
|
2457
2830
|
});
|
|
2458
2831
|
|
|
2459
2832
|
// --- R3-004: loose-extensions manifest branch coverage ----------------------
|
|
@@ -2485,12 +2858,6 @@ test("--link take-over falls through to per-file discovery when a loose dir's pa
|
|
|
2485
2858
|
join(looseAgentExtensions, "a.ts"),
|
|
2486
2859
|
"-e",
|
|
2487
2860
|
packageRoot,
|
|
2488
|
-
"--theme",
|
|
2489
|
-
join(packageRoot, "themes"),
|
|
2490
|
-
"--skill",
|
|
2491
|
-
join(packageRoot, "skills"),
|
|
2492
|
-
"--prompt-template",
|
|
2493
|
-
join(packageRoot, "prompts"),
|
|
2494
2861
|
]);
|
|
2495
2862
|
});
|
|
2496
2863
|
|
|
@@ -2521,12 +2888,6 @@ test("--link take-over treats a malformed package.json as no manifest and falls
|
|
|
2521
2888
|
join(looseAgentExtensions, "a.ts"),
|
|
2522
2889
|
"-e",
|
|
2523
2890
|
packageRoot,
|
|
2524
|
-
"--theme",
|
|
2525
|
-
join(packageRoot, "themes"),
|
|
2526
|
-
"--skill",
|
|
2527
|
-
join(packageRoot, "skills"),
|
|
2528
|
-
"--prompt-template",
|
|
2529
|
-
join(packageRoot, "prompts"),
|
|
2530
2891
|
]);
|
|
2531
2892
|
});
|
|
2532
2893
|
|
|
@@ -2567,5 +2928,181 @@ test("--link take-over treats a file named `extensions` as not a loose extension
|
|
|
2567
2928
|
assert.equal(result.status, 0, result.stderr);
|
|
2568
2929
|
|
|
2569
2930
|
const payload = JSON.parse(result.stdout);
|
|
2570
|
-
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot
|
|
2931
|
+
assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]);
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
// --- resume-hint handoff (lib/gentle-shell-resume-hint.ts) ----------------------
|
|
2935
|
+
|
|
2936
|
+
// A stand-in pi that writes a resume handoff like extensions/resume-hint.ts
|
|
2937
|
+
// does, prints pi's own exit hint, and exits with the given code.
|
|
2938
|
+
function writeHandoffPiScript(path: string, exitCode = 0) {
|
|
2939
|
+
writeFileSync(
|
|
2940
|
+
path,
|
|
2941
|
+
[
|
|
2942
|
+
"#!/usr/bin/env node",
|
|
2943
|
+
"const { writeFileSync } = require('node:fs');",
|
|
2944
|
+
"const args = process.argv.slice(2);",
|
|
2945
|
+
"if (args.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
|
|
2946
|
+
"const handoff = process.env.GENTLE_SHELL_RESUME_HANDOFF;",
|
|
2947
|
+
"if (handoff) writeFileSync(handoff, JSON.stringify({ sessionId: 'abc' }));",
|
|
2948
|
+
"if (process.env.PI_STUB_PRINT_ENV) console.log(JSON.stringify({ args, handoff }));",
|
|
2949
|
+
"process.stdout.write('To resume this session: pi --session abc\\n');",
|
|
2950
|
+
`process.exit(${exitCode});`,
|
|
2951
|
+
"",
|
|
2952
|
+
].join("\n"),
|
|
2953
|
+
);
|
|
2954
|
+
chmodSync(path, 0o755);
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
test("interactive launch hands pi a private resume handoff and cleans it up", (t) => {
|
|
2958
|
+
const f = fixture(t);
|
|
2959
|
+
const piScript = join(f.root, "handoff-pi.cjs");
|
|
2960
|
+
writeHandoffPiScript(piScript);
|
|
2961
|
+
const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, []);
|
|
2962
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2963
|
+
const lines = result.stdout.trim().split("\n");
|
|
2964
|
+
const { handoff } = JSON.parse(lines[0]);
|
|
2965
|
+
assert.equal(typeof handoff, "string");
|
|
2966
|
+
assert.match(handoff, /gentle-shell-resume-/);
|
|
2967
|
+
assert.equal(existsSync(dirname(handoff)), false, "handoff dir must be removed after pi exits");
|
|
2968
|
+
// Not a TTY: like pi's own hint, the gentle-shell line is not printed.
|
|
2969
|
+
assert.deepEqual(lines.slice(1), ["To resume this session: pi --session abc"]);
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
test("pi subcommands get no resume handoff", (t) => {
|
|
2973
|
+
const f = fixture(t);
|
|
2974
|
+
const piScript = join(f.root, "handoff-pi.cjs");
|
|
2975
|
+
writeHandoffPiScript(piScript);
|
|
2976
|
+
const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, ["list"]);
|
|
2977
|
+
assert.equal(result.status, 0, result.stderr);
|
|
2978
|
+
assert.equal(JSON.parse(result.stdout.trim().split("\n")[0]).handoff, undefined);
|
|
2979
|
+
});
|
|
2980
|
+
|
|
2981
|
+
// The hint is only printed to a real TTY, so drive the launcher through a
|
|
2982
|
+
// pseudo-terminal. Python's pty module is the portable POSIX way to get one
|
|
2983
|
+
// without a native dependency; skipped where it is unavailable.
|
|
2984
|
+
const hasPythonPty = process.platform !== "win32" && spawnSync("python3", ["-c", "import pty"], { stdio: "ignore" }).status === 0;
|
|
2985
|
+
const PTY_RUNNER = [
|
|
2986
|
+
"import fcntl, os, pty, struct, subprocess, sys, termios",
|
|
2987
|
+
"m, s = pty.openpty()",
|
|
2988
|
+
"fcntl.ioctl(s, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 120, 0, 0))",
|
|
2989
|
+
"p = subprocess.Popen(sys.argv[1:], stdin=s, stdout=s, stderr=s)",
|
|
2990
|
+
"os.close(s)",
|
|
2991
|
+
"out = b''",
|
|
2992
|
+
// PTY_SIGNAL_AFTER: once this text appears, send PTY_SIGNAL to the launcher.
|
|
2993
|
+
"after = os.environ.get('PTY_SIGNAL_AFTER', '').encode()",
|
|
2994
|
+
"while True:",
|
|
2995
|
+
" try: d = os.read(m, 4096)",
|
|
2996
|
+
" except OSError: break",
|
|
2997
|
+
" if not d: break",
|
|
2998
|
+
" out += d",
|
|
2999
|
+
" if after and after in out:",
|
|
3000
|
+
" after = b''",
|
|
3001
|
+
" os.kill(p.pid, int(os.environ['PTY_SIGNAL']))",
|
|
3002
|
+
"sys.stdout.write(out.decode())",
|
|
3003
|
+
"sys.exit(p.wait())",
|
|
3004
|
+
].join("\n");
|
|
3005
|
+
|
|
3006
|
+
function runInPty(env: NodeJS.ProcessEnv, args: string[], expectedStatus = 0): string {
|
|
3007
|
+
// Pin the color environment so the launcher's hasColors() check does not
|
|
3008
|
+
// depend on the machine running the tests; a test can still override it.
|
|
3009
|
+
const { NO_COLOR, FORCE_COLOR, NODE_DISABLE_COLORS, ...rest } = env;
|
|
3010
|
+
const colorEnv = { ...rest, TERM: "xterm-256color", ...(env.PTY_NO_COLOR ? { NO_COLOR: "1" } : {}) };
|
|
3011
|
+
// Bounded so a stand-in pi that never exits fails the test instead of hanging CI.
|
|
3012
|
+
const result = spawnSync("python3", ["-c", PTY_RUNNER, process.execPath, binPath, ...args], { encoding: "utf8", env: colorEnv, timeout: 30_000 });
|
|
3013
|
+
assert.equal(result.error, undefined, String(result.error));
|
|
3014
|
+
assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
|
|
3015
|
+
return result.stdout;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const PI_HINT = "To resume this session: pi --session abc\r\n";
|
|
3019
|
+
const GENTLE_HINT = "\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session abc\r\n";
|
|
3020
|
+
|
|
3021
|
+
test("on a TTY the launcher appends a gentle-shell resume line below pi's hint", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
|
|
3022
|
+
const f = fixture(t);
|
|
3023
|
+
const piScript = join(f.root, "tty-pi.cjs");
|
|
3024
|
+
writeHandoffPiScript(piScript);
|
|
3025
|
+
const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]);
|
|
3026
|
+
assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out));
|
|
3027
|
+
});
|
|
3028
|
+
|
|
3029
|
+
test("on a TTY the gentle-shell line still follows a non-zero pi exit, keeping the code", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
|
|
3030
|
+
const f = fixture(t);
|
|
3031
|
+
const piScript = join(f.root, "tty-pi.cjs");
|
|
3032
|
+
writeHandoffPiScript(piScript, 3);
|
|
3033
|
+
const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"], 3);
|
|
3034
|
+
assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out));
|
|
3035
|
+
});
|
|
3036
|
+
|
|
3037
|
+
// A stand-in pi that writes the handoff and then waits: it quits with its
|
|
3038
|
+
// own hint on SIGHUP/SIGTERM, but survives SIGINT (like an interrupted turn)
|
|
3039
|
+
// and quits only on the next line of input.
|
|
3040
|
+
function writeWaitingPiScript(path: string) {
|
|
3041
|
+
writeFileSync(
|
|
3042
|
+
path,
|
|
3043
|
+
[
|
|
3044
|
+
"#!/usr/bin/env node",
|
|
3045
|
+
"const { writeFileSync } = require('node:fs');",
|
|
3046
|
+
"if (process.argv.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
|
|
3047
|
+
"writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc' }));",
|
|
3048
|
+
"const quit = () => { process.stdout.write('To resume this session: pi --session abc\\n'); process.exit(0); };",
|
|
3049
|
+
"process.on('SIGHUP', quit);",
|
|
3050
|
+
"process.on('SIGTERM', quit);",
|
|
3051
|
+
"process.on('SIGINT', () => { process.stdout.write('interrupted\\n'); setTimeout(quit, 50); });",
|
|
3052
|
+
"process.stdout.write('ready\\n');",
|
|
3053
|
+
"setInterval(() => {}, 1000);",
|
|
3054
|
+
"",
|
|
3055
|
+
].join("\n"),
|
|
3056
|
+
);
|
|
3057
|
+
chmodSync(path, 0o755);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
test("on a TTY the launcher prints nothing extra after the terminal hangs up", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
|
|
3061
|
+
const f = fixture(t);
|
|
3062
|
+
const piScript = join(f.root, "tty-pi.cjs");
|
|
3063
|
+
writeWaitingPiScript(piScript);
|
|
3064
|
+
const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "1" }, ["--link"]);
|
|
3065
|
+
// pi quit cleanly with its own hint; only the gentle-shell line is withheld.
|
|
3066
|
+
assert.ok(out.endsWith(PI_HINT), JSON.stringify(out));
|
|
3067
|
+
assert.equal(out.includes("gentle-shell --"), false, JSON.stringify(out));
|
|
3068
|
+
});
|
|
3069
|
+
|
|
3070
|
+
test("on a TTY a forwarded SIGINT that pi survives does not silence the gentle-shell line", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
|
|
3071
|
+
const f = fixture(t);
|
|
3072
|
+
const piScript = join(f.root, "tty-pi.cjs");
|
|
3073
|
+
writeWaitingPiScript(piScript);
|
|
3074
|
+
const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "2" }, ["--link"]);
|
|
3075
|
+
assert.ok(out.includes("interrupted"), JSON.stringify(out));
|
|
3076
|
+
assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out));
|
|
3077
|
+
});
|
|
3078
|
+
|
|
3079
|
+
test("on a TTY a cross-project session resumes by its session file", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
|
|
3080
|
+
const f = fixture(t);
|
|
3081
|
+
const sessionFile = join(f.root, "other project", "session.jsonl");
|
|
3082
|
+
const piScript = join(f.root, "tty-pi.cjs");
|
|
3083
|
+
writeFileSync(
|
|
3084
|
+
piScript,
|
|
3085
|
+
[
|
|
3086
|
+
"#!/usr/bin/env node",
|
|
3087
|
+
"const { writeFileSync } = require('node:fs');",
|
|
3088
|
+
"if (process.argv.includes('--version')) { console.log('0.99.1'); process.exit(0); }",
|
|
3089
|
+
`writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc', sessionFile: ${JSON.stringify(sessionFile)} }));`,
|
|
3090
|
+
"process.stdout.write('To resume this session: pi --session abc\\n');",
|
|
3091
|
+
"",
|
|
3092
|
+
].join("\n"),
|
|
3093
|
+
);
|
|
3094
|
+
chmodSync(piScript, 0o755);
|
|
3095
|
+
const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]);
|
|
3096
|
+
assert.ok(
|
|
3097
|
+
out.endsWith(`${PI_HINT}\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session '${sessionFile}'\r\n`),
|
|
3098
|
+
JSON.stringify(out),
|
|
3099
|
+
);
|
|
3100
|
+
});
|
|
3101
|
+
|
|
3102
|
+
test("on a TTY without colors the gentle-shell line has no ANSI styling", { skip: !hasPythonPty && "needs python3 pty" }, (t) => {
|
|
3103
|
+
const f = fixture(t);
|
|
3104
|
+
const piScript = join(f.root, "tty-pi.cjs");
|
|
3105
|
+
writeHandoffPiScript(piScript);
|
|
3106
|
+
const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_NO_COLOR: "1" }, ["--link"]);
|
|
3107
|
+
assert.ok(out.endsWith(`${PI_HINT}To resume in gentle-shell: gentle-shell --link --session abc\r\n`), JSON.stringify(out));
|
|
2571
3108
|
});
|