gentle-pi 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +39 -14
  2. package/bin/engram-guard +1 -1
  3. package/bin/gentle-shell.mjs +16 -11
  4. package/docs/auto-permission-answers.md +25 -0
  5. package/docs/auto-permission-evidence.md +39 -0
  6. package/docs/bundled-install.md +8 -6
  7. package/docs/gentle-shell.md +17 -10
  8. package/docs/install-wizard.md +9 -9
  9. package/docs/permission-modes.md +50 -5
  10. package/docs/readme-reference.md +48 -12
  11. package/docs/session-profile-format.md +39 -0
  12. package/docs/yolo-mode.md +2 -1
  13. package/extensions/ask-user-choice.ts +4 -0
  14. package/extensions/ask-user-question.ts +6 -1
  15. package/extensions/gentle-agents.ts +54 -23
  16. package/extensions/gentle-ai.ts +105 -25
  17. package/extensions/gentle-shell.ts +26 -31
  18. package/extensions/gentle-todo.ts +26 -16
  19. package/lib/auto-permission-answers.ts +133 -0
  20. package/lib/auto-permission-classifier.ts +215 -36
  21. package/lib/auto-permission-evidence.ts +88 -0
  22. package/lib/auto-permission-memory.ts +69 -0
  23. package/lib/auto-permission-runtime.ts +251 -35
  24. package/lib/auto-permission-tool-context.ts +61 -0
  25. package/lib/destructive-command-guard.ts +104 -28
  26. package/lib/gentle-shell-bundled.ts +23 -5
  27. package/lib/gentle-shell-launcher.ts +1 -1
  28. package/lib/model-routing-authority.ts +6 -1
  29. package/lib/selection-engine.ts +4 -1
  30. package/lib/session-profile-freeze.ts +10 -15
  31. package/lib/session-profile-persistence.ts +187 -0
  32. package/lib/shell-changes.ts +2 -0
  33. package/lib/shell-prompt.ts +6 -4
  34. package/lib/shell-todo.ts +20 -8
  35. package/lib/vim-editor-adapter.ts +22 -4
  36. package/package.json +5 -5
  37. package/runtime/gentle-shell-bundled.mjs +23 -5
  38. package/runtime/gentle-shell-launcher.mjs +1 -1
  39. package/scripts/install-wizard-preview.mjs +2 -2
  40. package/scripts/installer-downloads.mjs +7 -7
  41. package/scripts/installer-preflight.mjs +1 -1
  42. package/scripts/pi-update.mjs +223 -0
  43. package/tests/append-system-prompt-route.test.ts +18 -1
  44. package/tests/auto-mode-human-answers.test.ts +68 -0
  45. package/tests/auto-mode-invariants.test.ts +23 -5
  46. package/tests/auto-mode-sdk.test.ts +29 -2
  47. package/tests/auto-permission-answers.test.ts +170 -0
  48. package/tests/auto-permission-classifier.test.ts +353 -2
  49. package/tests/auto-permission-compound.test.ts +24 -0
  50. package/tests/auto-permission-evidence.test.ts +88 -0
  51. package/tests/auto-permission-memory.test.ts +46 -0
  52. package/tests/auto-permission-public-regressions.test.ts +111 -0
  53. package/tests/auto-permission-runtime.test.ts +270 -12
  54. package/tests/autonomous-guard.test.ts +86 -16
  55. package/tests/bundled-install.test.ts +3 -3
  56. package/tests/engram-safety.test.ts +34 -6
  57. package/tests/gentle-agents.test.ts +162 -6
  58. package/tests/gentle-shell-bin.test.ts +17 -17
  59. package/tests/gentle-shell-bundled.test.ts +67 -33
  60. package/tests/gentle-shell-launcher.test.ts +18 -13
  61. package/tests/gentle-shell.test.ts +71 -5
  62. package/tests/gentle-todo.test.ts +79 -27
  63. package/tests/install-wizard.test.ts +2 -2
  64. package/tests/installer-posix-bootstrap.test.ts +8 -8
  65. package/tests/installer-preflight.test.ts +3 -3
  66. package/tests/installer-probes.test.ts +8 -8
  67. package/tests/installer-runner.test.ts +14 -13
  68. package/tests/installer-server.test.ts +5 -5
  69. package/tests/main-channel-upgrade.test.ts +7 -1
  70. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  71. package/tests/package-manifest.test.ts +6 -6
  72. package/tests/pi-update-launcher.test.ts +87 -0
  73. package/tests/pi-update.test.ts +186 -0
  74. package/tests/rdd-status-line.test.ts +52 -0
  75. package/tests/selection-engine.test.ts +35 -5
  76. package/tests/session-profile-freeze.test.ts +14 -1
  77. package/tests/session-profile-persistence.test.ts +334 -0
  78. package/tests/shell-changes.test.ts +18 -0
  79. package/tests/shell-todo.test.ts +15 -12
  80. package/tests/vim-editor-adapter.test.ts +41 -15
  81. package/tests/yolo-mode.test.ts +6 -1
@@ -0,0 +1,186 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { managedUpdatePlan, updateSelectedPi } from '../scripts/pi-update.mjs';
4
+ import { bundledUpdatePlan, bundledUpdateNext } from '../runtime/gentle-shell-bundled.mjs';
5
+
6
+ const PI = '@earendil-works/pi-coding-agent';
7
+ const CLI = 'dist/bundle/cli.js';
8
+
9
+ // The layouts pnpm v11 and npm really produce. pnpm links each top-level
10
+ // package from its own global group into the linked store, where Shell's
11
+ // peer is a sibling of the Shell package; a separate global Pi links to the
12
+ // same store entry. npm nests the peer under the Shell package.
13
+ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = 'both', peerAfter = '1.2.0', shellVersion = '4.1.0',
14
+ badManifest = false, failed = false, mismatch = false, linked = false, readback = true, unrelatedPeer = false, shim = ''} = {}) {
15
+ const base = windows ? 'C:/pnpm' : '/pnpm';
16
+ const storePi = (version: string) => `${base}/store/links/@earendil-works/pi-coding-agent/${version}/h/node_modules/${PI}`;
17
+ const shellModules = npm ? `${base}/npm/lib/node_modules` : `${base}/store/links/@/gentle-pi/4.1.0/h1/node_modules`;
18
+ const shell = `${shellModules}/gentle-pi`;
19
+ const nestedPeer = `${shell}/node_modules/${PI}`;
20
+ const g1 = npm ? shellModules : `${base}/global/v11/g1/node_modules`;
21
+ const g2 = npm ? shellModules : `${base}/global/v11/g2/node_modules`;
22
+ const g3 = `${base}/global/v11/g3/node_modules`;
23
+ const state = {peer: '1.1.0', group: '1.1.0'};
24
+ const peerRoot = () => npm ? nestedPeer : storePi(state.peer);
25
+ const selectedCli = `${npm ? (runtime === 'adjacent' ? nestedPeer : `${shellModules}/${PI}`) : storePi('1.1.0')}/${CLI}`;
26
+ // A package-manager shim on PATH: its text names the global Pi group's cli.js.
27
+ const shimFile = `${base}/bin/pi${windows ? '.CMD' : ''}`;
28
+ const shimText = shim === 'posix' ? `#!/bin/sh\nbasedir=$(dirname "$0")\nexec node "$basedir/../global/v11/g2/node_modules/${PI}/${CLI}" "$@"\n`
29
+ : shim === 'windows' ? `@"%~dp0\\..\\global\\v11\\g2\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js" %*\r\n`
30
+ : shim === 'two' ? `"$basedir/../a/dist/bundle/cli.js" "$basedir/../b/dist/bundle/cli.js"` : shim === 'none' ? '#!/bin/sh\nexec something\n' : '';
31
+ const mutations: string[][] = [];
32
+ const out: string[] = [];
33
+ const cwds: unknown[] = [];
34
+ const entry = (pkg: string, modules: string, extra = {}) => ({[pkg]: {path: `${modules}/${pkg}`, ...extra}});
35
+ const shellEntry = entry('gentle-pi', g1, linked ? {link: true} : {});
36
+ const piEntry = entry(PI, g2, linked ? {resolved: 'file:/src/pi'} : {});
37
+ const listing = () => {
38
+ const deps = {...(groups !== 'pi' ? shellEntry : {}), ...(groups !== 'shell' ? piEntry : {}),
39
+ ...(groups === 'shared' ? entry('unrelated-tool', g1) : {}), ...(groups === 'separate' ? entry('unrelated-tool', g3) : {})};
40
+ const group = {path: npm ? shellModules : `${base}/global/v11`, dependencies: deps};
41
+ return npm ? group : [group];
42
+ };
43
+ const exists = (n: string) => [g1, g2, g3, shellModules, shell, storePi(state.peer), storePi(state.group), storePi('1.1.0'), nestedPeer]
44
+ .some(root => n === root || n.startsWith(root + '/'));
45
+ const realpath = async (value: string) => {
46
+ const n = value.replaceAll('\\', '/');
47
+ if (n === shimFile) return n;
48
+ // Symlinked components inside a longer path resolve like the real filesystem does.
49
+ if (n.startsWith(`${g2}/${PI}/`)) return (npm ? `${g2}/${PI}` : storePi(state.group)) + n.slice(`${g2}/${PI}`.length);
50
+ if (n === `${g1}/gentle-pi` || n === `${g1}/unrelated-tool` || n === `${g3}/unrelated-tool`) return n === `${g1}/gentle-pi` ? shell : n;
51
+ if (n === `${g2}/${PI}`) return npm ? n : storePi(state.group);
52
+ if (!npm && n === `${shellModules}/${PI}`) return storePi(state.peer);
53
+ if (!npm && n === nestedPeer) throw new Error('ENOENT nested peer');
54
+ // Only joined (backslash) paths mismatch: the selected CLI itself stays canonical.
55
+ if (mismatch && n.endsWith(CLI) && value.includes('\\')) return `${base}/other/cli.js`;
56
+ if (exists(n)) return n;
57
+ throw new Error(`ENOENT ${n}`);
58
+ };
59
+ const readFile = async (value: string) => {
60
+ const n = value.replaceAll('\\', '/');
61
+ if (n === shimFile) return shimText;
62
+ if (n === `${shell}/package.json`) return JSON.stringify({name: 'gentle-pi', ...(shellVersion ? {version: shellVersion} : {})});
63
+ if (n === `${g1}/unrelated-tool/package.json`) return JSON.stringify({name: 'unrelated-tool'});
64
+ const version = n.startsWith(nestedPeer) ? state.peer : /pi-coding-agent\/([\d.]+)\/h\//.exec(n)?.[1];
65
+ if (badManifest || unrelatedPeer) return JSON.stringify({name: unrelatedPeer ? 'other' : PI, version});
66
+ return JSON.stringify({name: PI, version, bin: {pi: CLI}});
67
+ };
68
+ const run = async (_command: string, args: string[], options?: {cwd?: unknown}) => {
69
+ cwds.push(options?.cwd);
70
+ if (args.includes('list') || args.includes('ls')) return {code: failed ? 1 : 0, stdout: readback || mutations.length === 0 ? JSON.stringify(listing()) : '{'};
71
+ mutations.push(args);
72
+ if (args.some(arg => arg.startsWith('gentle-pi@'))) state.peer = peerAfter;
73
+ if (args.some(arg => arg.startsWith(PI + '@'))) state.group = peerAfter;
74
+ return {code: 0};
75
+ };
76
+ return {mutations, out, cwds, state, shell, selectedCli, input: {platform: windows ? 'win32' : 'linux', packageRoot: `${g1}/gentle-pi`, currentVersion: '1.1.0', cwd: '/neutral',
77
+ runtime: runtime === 'adjacent' ? {kind: 'bundled', command: 'node', args: [selectedCli]} : {kind: runtime, command: shim ? shimFile : selectedCli, args: []},
78
+ out: (line: string) => out.push(line),
79
+ adapters: {which: async (name: string) => name === (npm ? 'npm' : 'pnpm') ? `/fake/${name}` : null, fs: {realpath, readFile}, run}}};
80
+ }
81
+
82
+ test('prefix distribution still redirects self update and forwards only extensions for all', () => {
83
+ assert.deepEqual(bundledUpdatePlan(['update']), {self: true});
84
+ assert.deepEqual(bundledUpdateNext(bundledUpdatePlan(['update']), 0), {exitCode: 0});
85
+ assert.deepEqual(bundledUpdatePlan(['update', '--all']), {self: true, piArgs: ['update', '--extensions']});
86
+ assert.deepEqual(bundledUpdatePlan(['update', '--extensions-only']), {self: false, piArgs: ['update', '--extensions-only']});
87
+ });
88
+
89
+ for (const args of [['update', '--extensions-only'], ['update', '--extensions'], ['update', 'npm:custom'], ['update', '--models'], ['update', '--help'], ['update', '--unknown']]) test(`no ownership interception for ${args.join(' ')}`, () => {
90
+ assert.deepEqual(managedUpdatePlan(args), {self: false, piArgs: args});
91
+ });
92
+
93
+ test('all strips only self update before forwarding extension flags', () => {
94
+ assert.deepEqual(managedUpdatePlan(['update', '--all', '--approve']), {self: true, piArgs: ['update', '--extensions', '--approve']});
95
+ });
96
+
97
+ for (const windows of [false, true]) test(`an adjacent peer is refreshed by reinstalling its Shell package at the same version, never the separate global Pi (windows: ${windows})`, async () => {
98
+ const f = fixture({windows});
99
+ assert.equal(await updateSelectedPi(f.input), 0);
100
+ assert.deepEqual(f.mutations, [['add', '-g', 'gentle-pi@4.1.0', '--allow-build=gentle-pi']]);
101
+ assert.deepEqual(f.out, ['pnpm installed gentle-pi@4.1.0; the selected Pi is now 1.2.0 (was 1.1.0).']);
102
+ assert.equal(f.state.group, '1.1.0');
103
+ });
104
+
105
+ for (const runtime of ['path', 'env']) test(`a ${runtime} runtime sharing the store entry is updated through its own global Pi package`, async () => {
106
+ const f = fixture({runtime});
107
+ assert.equal(await updateSelectedPi(f.input), 0);
108
+ assert.deepEqual(f.mutations, [['add', '-g', `${PI}@latest`]]);
109
+ assert.deepEqual(f.out, [`pnpm installed ${PI}@latest; the selected Pi is now 1.2.0 (was 1.1.0).`]);
110
+ assert.equal(f.state.peer, '1.1.0');
111
+ });
112
+
113
+ test('npm nests the peer under Shell and reinstalls Shell for it', async () => {
114
+ const f = fixture({npm: true});
115
+ assert.equal(await updateSelectedPi(f.input), 0);
116
+ assert.deepEqual(f.mutations, [['install', '-g', 'gentle-pi@4.1.0']]);
117
+ assert.deepEqual(f.out, ['npm installed gentle-pi@4.1.0; the selected Pi is now 1.2.0 (was 1.1.0).']);
118
+ });
119
+
120
+ test('an npm PATH runtime updates the global Pi without its scripts', async () => {
121
+ const f = fixture({npm: true, runtime: 'path'});
122
+ assert.equal(await updateSelectedPi(f.input), 0);
123
+ assert.deepEqual(f.mutations, [['install', '-g', `${PI}@latest`, '--ignore-scripts']]);
124
+ });
125
+
126
+ test('an unchanged pnpm result names the minimum-release-age rule instead of claiming latest', async () => {
127
+ const f = fixture({peerAfter: '1.1.0'});
128
+ assert.equal(await updateSelectedPi(f.input), 0);
129
+ assert.equal(f.out[0], 'pnpm installed gentle-pi@4.1.0, and the selected Pi is still 1.1.0.');
130
+ assert.match(f.out[1], /minimumReleaseAge/);
131
+ assert.equal(f.out.length, 2);
132
+ });
133
+
134
+ test('every package manager call runs from the neutral directory, never the project cwd', async () => {
135
+ const f = fixture();
136
+ assert.equal(await updateSelectedPi(f.input), 0);
137
+ assert.deepEqual(f.cwds, ['/neutral', '/neutral', '/neutral']);
138
+ });
139
+
140
+ test('an unreadable readback is reported, not invented', async () => {
141
+ const f = fixture({readback: false});
142
+ assert.equal(await updateSelectedPi(f.input), 0);
143
+ assert.deepEqual(f.out, ['pnpm installed gentle-pi@4.1.0. The Pi version it now supplies could not be read back; run \'gentle-shell --version\' to check it.']);
144
+ });
145
+
146
+ test('separate aggregated groups do not block the Shell reinstall', async () => {
147
+ const f = fixture({groups: 'separate'});
148
+ assert.equal(await updateSelectedPi(f.input), 0);
149
+ assert.deepEqual(f.mutations, [['add', '-g', 'gentle-pi@4.1.0', '--allow-build=gentle-pi']]);
150
+ });
151
+
152
+ for (const [name, options] of Object.entries({
153
+ 'listing failed': {failed: true},
154
+ 'peer identity differs': {badManifest: true},
155
+ 'peer package is not Pi': {unrelatedPeer: true},
156
+ 'selected CLI mismatches on Windows': {windows: true, mismatch: true},
157
+ 'linked or file suppliers': {linked: true},
158
+ 'Shell shares its global group': {groups: 'shared'},
159
+ 'Shell version is missing': {shellVersion: ''},
160
+ 'only a separate global Pi is listed for an adjacent runtime': {groups: 'pi'},
161
+ 'only Shell is listed for a PATH runtime': {runtime: 'path', groups: 'shell'},
162
+ })) test(`ownership rejects without mutation when ${name}`, async () => {
163
+ const f = fixture(options);
164
+ await assert.rejects(updateSelectedPi(f.input), /ownership/);
165
+ assert.deepEqual(f.mutations, []);
166
+ assert.deepEqual(f.out, []);
167
+ });
168
+
169
+ for (const [name, options] of Object.entries({'pnpm POSIX shim': {shim: 'posix', runtime: 'path'}, 'Windows .CMD shim': {shim: 'windows', runtime: 'path', windows: true}, 'GENTLE_SHELL_PI shim override': {shim: 'posix', runtime: 'env'}})) test(`a ${name} resolves to the global Pi it runs`, async () => {
170
+ const f = fixture(options);
171
+ assert.equal(await updateSelectedPi(f.input), 0);
172
+ assert.deepEqual(f.mutations, [['add', '-g', `${PI}@latest`]]);
173
+ });
174
+
175
+ for (const [name, shim] of Object.entries({'names several targets': 'two', 'is not a Pi entry': 'none'})) test(`a shim that ${name} is refused without mutation`, async () => {
176
+ const f = fixture({shim, runtime: 'path'});
177
+ await assert.rejects(updateSelectedPi(f.input), /ownership/);
178
+ assert.deepEqual(f.mutations, []);
179
+ });
180
+
181
+ test('an unrelated selected override cannot authorize the adjacent Shell group', async () => {
182
+ const f = fixture();
183
+ f.input.runtime = {kind: 'env', command: '/unrelated/pi', args: []};
184
+ await assert.rejects(updateSelectedPi(f.input), /ownership/);
185
+ assert.deepEqual(f.mutations, []);
186
+ });
@@ -306,3 +306,55 @@ test("the on and off renders are never longer than the unknown (worst-case) rend
306
306
  }
307
307
  }
308
308
  });
309
+
310
+ // gentle-shell#1029: the rendered RDD line is part of the cached system-prompt
311
+ // prefix. A re-read after the TTL that fails or times out must not flip a
312
+ // known on/off line to "unknown" and back: the last successfully read status
313
+ // stays until the native reader reports a different status.
314
+ test("resolveRddModeStatus keeps the last known status when a later re-read fails", async () => {
315
+ clearRddStatusMemoForTesting();
316
+ const cwd = "/repo-sticky-status";
317
+ let clock = 1_000_000;
318
+ const now = () => clock;
319
+ let fail = false;
320
+ let calls = 0;
321
+ const cli: Pick<NativeReviewCli, "reviewMode"> = {
322
+ async reviewMode() {
323
+ calls += 1;
324
+ if (fail) throw new Error("native review CLI unavailable");
325
+ return modeResult("on", NATIVE_REVIEW_MODE_SOURCE.GLOBAL);
326
+ },
327
+ };
328
+ const first = await resolveRddModeStatus(cli, cwd, undefined, now);
329
+ assert.equal(first?.effective, "on");
330
+ fail = true;
331
+ clock += RDD_STATUS_MEMO_TTL_MS + 1;
332
+ const second = await resolveRddModeStatus(cli, cwd, undefined, now);
333
+ assert.equal(calls, 2, "the expired memo must still trigger a re-read");
334
+ assert.deepEqual(second, first, "a failed re-read must keep the last known status, not render unknown");
335
+ assert.equal(
336
+ await resolveRddStatusLine(cli, cwd, undefined, now),
337
+ "Receipt-driven development: on (decided by global)",
338
+ );
339
+ });
340
+
341
+ test("resolveRddModeStatus still follows a real status change after a re-read", async () => {
342
+ clearRddStatusMemoForTesting();
343
+ const cwd = "/repo-status-change";
344
+ let clock = 1_000_000;
345
+ const now = () => clock;
346
+ let effective: "on" | "off" = "on";
347
+ const cli: Pick<NativeReviewCli, "reviewMode"> = {
348
+ async reviewMode() { return modeResult(effective, NATIVE_REVIEW_MODE_SOURCE.GLOBAL); },
349
+ };
350
+ assert.equal((await resolveRddModeStatus(cli, cwd, undefined, now))?.effective, "on");
351
+ effective = "off";
352
+ clock += RDD_STATUS_MEMO_TTL_MS + 1;
353
+ assert.equal((await resolveRddModeStatus(cli, cwd, undefined, now))?.effective, "off");
354
+ });
355
+
356
+ test("resolveRddModeStatus stays unknown when no status was ever read", async () => {
357
+ clearRddStatusMemoForTesting();
358
+ const cli = fakeReviewMode(() => { throw new Error("never available"); });
359
+ assert.equal(await resolveRddModeStatus(cli, "/repo-never-known"), undefined);
360
+ });
@@ -2,11 +2,16 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
4
4
  import { GentlePromptEditor } from "../extensions/gentle-shell.ts";
5
- import { SelectionEngine } from "../lib/selection-engine.ts";
5
+ import { SelectionEngine, withReverseSpan } from "../lib/selection-engine.ts";
6
6
  import { decodePrintableKey } from "../lib/pi-tui-keys.ts";
7
- import { CURSOR_MARKER, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
7
+ import { CURSOR_MARKER, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
8
8
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
9
- import { stripAnsi } from "../lib/terminal-theme.ts";
9
+ import { withPromptHint } from "../lib/shell-prompt.ts";
10
+
11
+ function resolveFakeCursors(rows: string[], hardware = false): string[] {
12
+ const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
13
+ return host.resolveFakeCursors.call({ showHardwareCursor: hardware }, rows);
14
+ }
10
15
 
11
16
  // Native selection engine tests: drive a real CustomEditor through the
12
17
  // SelectionEngine the same way GentlePromptEditor wires it — engine.handleInput
@@ -45,6 +50,30 @@ const click = (x: number, y: number, width: number): TuiMouseEvent => ({
45
50
  type: "click", button: "left", x, y, width, height: 30, screenX: x, screenY: y, shift: false, alt: false, ctrl: false,
46
51
  });
47
52
 
53
+ test("legacy and APC cursors preserve native selection and empty hints", () => {
54
+ const legacy = `${CURSOR_MARKER}\x1b[7ma\x1b[0mbc`;
55
+ assert.equal(withReverseSpan(legacy, 0, 3), `\x1b[7m${CURSOR_MARKER}\x1b[7ma\x1b[0m\x1b[7mbc\x1b[27m`);
56
+ const apc = `${CURSOR_MARKER}\x1b_pi:fc\x07a\x1b_pi:/fc\x07bc`;
57
+ const selected = withReverseSpan(apc, 0, 3);
58
+ assert.ok(selected.includes(`${CURSOR_MARKER}\x1b_pi:fc\x07`));
59
+ for (const hardware of [false, true]) {
60
+ const resolved = resolveFakeCursors([selected], hardware)[0];
61
+ assert.ok(resolved.includes("\x1b[7mbc\x1b[27m"));
62
+ assert.equal(stripAnsi(resolved), "abc");
63
+ }
64
+ // Outside selection the host still suppresses only the focused fake cursor.
65
+ assert.equal(resolveFakeCursors([apc], true)[0], `${CURSOR_MARKER}abc`);
66
+ assert.ok(resolveFakeCursors([apc.replace(CURSOR_MARKER, "")], true)[0].includes("\x1b[7ma\x1b[27m"));
67
+ for (const cursor of ["\x1b[7m \x1b[0m", "\x1b_pi:fc\x07 \x1b_pi:/fc\x07"]) {
68
+ const row = `${CURSOR_MARKER}${cursor}${" ".repeat(30)}`;
69
+ const hinted = withPromptHint(row, "type here", (_role, text) => text);
70
+ assert.ok(hinted.startsWith(`${CURSOR_MARKER}${cursor}`));
71
+ assert.ok(hinted.includes("type here"));
72
+ assert.equal(visibleWidth(hinted), visibleWidth(row));
73
+ assert.equal(withPromptHint(row + "draft", "type here", (_role, text) => text), row + "draft");
74
+ }
75
+ });
76
+
48
77
  test("T2 float prompt native cursor, selection, replacement and mouse share geometry", () => {
49
78
  const previous = cardStyle();
50
79
  setCardStyle(CARD_STYLE.FLOAT);
@@ -56,11 +85,12 @@ test("T2 float prompt native cursor, selection, replacement and mouse share geom
56
85
  let rows = editor.render(80);
57
86
  assert.match(rows[0], /\x1b\[35m▎\x1b\[39m/);
58
87
  assert.equal(visibleWidth(rows[1].split(CURSOR_MARKER)[0]), 6);
59
- assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b[7md\x1b[0m\x1b[44m`));
88
+ assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b_pi:fc\x07d\x1b_pi:/fc\x07`));
89
+ assert.ok(resolveFakeCursors(rows)[1].includes(`${CURSOR_MARKER}\x1b[7md\x1b[27m`));
60
90
  editor.handleInput("\x1b[1;2H");
61
91
  rows = editor.render(80);
62
92
  assert.equal(stripAnsi(rows[1].replaceAll(CURSOR_MARKER, "")).trim(), "▎ abcd");
63
- assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b[7ma\x1b[0m\x1b[44m\x1b[7mbc\x1b[27md`), "native selection inversion survives the software cursor reset");
93
+ assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b_pi:fc\x07a\x1b_pi:/fc\x07\x1b[7mbc\x1b[27md`), "native selection inversion survives the deferred software cursor reset");
64
94
  assert.match(rows[2], /3 chars selected/);
65
95
  assert.doesNotMatch(rows[2], /╯/);
66
96
  assert.ok(rows[2].includes("\x1b[44m"));
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test, { after, beforeEach } from "node:test";
6
6
  import { createProfile, emptyProfilesFile, profilesFilePath, setActiveProfile, writeProfilesFileSync } from "../lib/agent-profiles.ts";
7
- import { clearProfilePinSync, localProfilePinPath, repoProfileDeclarationPath, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
7
+ import { clearProfilePinSync, localProfilePinPath, repoProfileDeclarationPath, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
8
8
  import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
9
9
  import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
10
10
  import {
@@ -120,6 +120,19 @@ test("resolveInheritedProfile falls to the global active profile in profiles.jso
120
120
  });
121
121
  });
122
122
 
123
+ test("resolveInheritedProfile asks the default worktree resolver once when no resolver is passed", (t) => {
124
+ const f = fixture();
125
+ f.setActive("local");
126
+ let lookups = 0;
127
+ setProfilePinWorktreeResolverForTesting(() => { lookups++; return f.identity; });
128
+ t.after(() => setProfilePinWorktreeResolverForTesting());
129
+ assert.equal(resolveInheritedProfile({ cwd: f.cwd, configHome: f.configHome })?.name, "local");
130
+ assert.equal(lookups, 1, "the no-pin path reuses one worktree lookup");
131
+ f.pinLocal("frontier");
132
+ assert.equal(resolveInheritedProfile({ cwd: f.cwd, configHome: f.configHome })?.origin, "local");
133
+ assert.equal(lookups, 2);
134
+ });
135
+
123
136
  test("resolveInheritedProfile returns undefined when no layer has a profile", () => {
124
137
  const f = fixture();
125
138
  assert.equal(resolveInheritedProfile({ cwd: f.cwd, configHome: f.configHome, resolveWorktree: f.resolveWorktree }), undefined);
@@ -0,0 +1,334 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ SESSION_PROFILE_CUSTOM_TYPE,
5
+ createSessionProfileBind,
6
+ createSessionProfileClear,
7
+ readSessionProfileEntry,
8
+ replaySessionProfileBranch,
9
+ } from "../lib/session-profile-persistence.ts";
10
+
11
+ import { normalizeModelConfig } from "../lib/model-routing-authority.ts";
12
+ import {
13
+ normalizeProfilesFile,
14
+ PROFILES_KIND,
15
+ PROFILES_VERSION,
16
+ } from "../lib/agent-profiles.ts";
17
+
18
+ const custom = (data: unknown, customType = SESSION_PROFILE_CUSTOM_TYPE) => ({
19
+ type: "custom",
20
+ customType,
21
+ data,
22
+ });
23
+ const bind = (modelProfiles: unknown = {}) => ({
24
+ kind: "bind",
25
+ origin: "user",
26
+ name: "work",
27
+ modelProfiles,
28
+ });
29
+
30
+ // These are supplied branch entries, not evidence of persistence on disk.
31
+ test("writer emits only the v1 user binding fields and a minimal clear", () => {
32
+ assert.equal(SESSION_PROFILE_CUSTOM_TYPE, "gentle-pi.session-profile/v1");
33
+ assert.deepEqual(createSessionProfileBind("work", {}), bind());
34
+ assert.deepEqual(createSessionProfileClear(), { kind: "clear" });
35
+ });
36
+
37
+ test("writer accepts real normalized optional fields without persisting undefined", () => {
38
+ const raw = {
39
+ worker: "provider/model",
40
+ reviewer: { effort: "high" },
41
+ orchestrator: { model: "provider/main" },
42
+ inheriting: {},
43
+ };
44
+ const routes = normalizeModelConfig(raw);
45
+ const profiles = normalizeProfilesFile({
46
+ kind: PROFILES_KIND,
47
+ version: PROFILES_VERSION,
48
+ profiles: { work: raw },
49
+ });
50
+ assert.ok(routes);
51
+ assert.ok(profiles);
52
+ assert.equal(Object.hasOwn(routes.reviewer, "model"), true);
53
+ assert.equal(Object.hasOwn(routes.reviewer, "thinking"), true);
54
+ const expected = bind({
55
+ worker: { model: "provider/model" },
56
+ reviewer: { thinking: "high" },
57
+ orchestrator: { model: "provider/main" },
58
+ inheriting: {},
59
+ });
60
+ for (const config of [routes, profiles.file.profiles.work]) {
61
+ const payload = createSessionProfileBind("work", config);
62
+ assert.deepEqual(payload, expected);
63
+ assert.deepEqual(readSessionProfileEntry(custom(payload)), {
64
+ status: "bound",
65
+ binding: expected,
66
+ });
67
+ assert.notEqual(payload.modelProfiles.reviewer, config.reviewer);
68
+ }
69
+ assert.equal(Object.hasOwn(routes.reviewer, "model"), true);
70
+ });
71
+
72
+ test("decoder stays strict about present undefined known fields", () => {
73
+ for (const route of [
74
+ { model: undefined },
75
+ { thinking: undefined },
76
+ { effort: undefined },
77
+ ]) {
78
+ assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), {
79
+ status: "invalid",
80
+ });
81
+ }
82
+ });
83
+
84
+ test("bound empty snapshot is distinct from absent and cleared", () => {
85
+ assert.deepEqual(readSessionProfileEntry(custom(bind())), {
86
+ status: "bound",
87
+ binding: bind(),
88
+ });
89
+ assert.deepEqual(replaySessionProfileBranch([]), { status: "absent" });
90
+ assert.deepEqual(readSessionProfileEntry(custom({ kind: "clear" })), {
91
+ status: "cleared",
92
+ });
93
+ });
94
+
95
+ test("reader accepts all four origins and ignores unknown payload fields", () => {
96
+ for (const origin of ["user", "local", "repo", "global"]) {
97
+ assert.deepEqual(
98
+ readSessionProfileEntry(
99
+ custom({ ...bind(), origin, extra: true, version: 99 }),
100
+ ),
101
+ {
102
+ status: "bound",
103
+ binding: { ...bind(), origin },
104
+ },
105
+ );
106
+ }
107
+ assert.deepEqual(
108
+ readSessionProfileEntry(custom({ kind: "clear", extra: true })),
109
+ { status: "cleared" },
110
+ );
111
+ });
112
+
113
+ test("legacy strings and effort normalize while orchestrator remains in the snapshot", () => {
114
+ const result = readSessionProfileEntry(
115
+ custom(
116
+ bind({
117
+ worker: " provider/model ",
118
+ reviewer: { effort: "high", ignored: true },
119
+ orchestrator: { model: "provider/main", thinking: "low", effort: "max" },
120
+ inheriting: {},
121
+ }),
122
+ ),
123
+ );
124
+ assert.deepEqual(result, {
125
+ status: "bound",
126
+ binding: bind({
127
+ worker: { model: "provider/model" },
128
+ reviewer: { thinking: "high" },
129
+ orchestrator: { model: "provider/main", thinking: "low" },
130
+ inheriting: {},
131
+ }),
132
+ });
133
+ });
134
+
135
+ test("missing required fields and invalid known meanings fail closed", () => {
136
+ const invalid: unknown[] = [
137
+ null,
138
+ [],
139
+ {},
140
+ { kind: "other" },
141
+ { kind: "bind", name: "work", modelProfiles: {} },
142
+ { ...bind(), origin: "future" },
143
+ { ...bind(), name: "../work" },
144
+ { ...bind(), name: "constructor" },
145
+ { ...bind(), modelProfiles: undefined },
146
+ { ...bind(), modelProfiles: [] },
147
+ ];
148
+ for (const data of invalid) {
149
+ assert.deepEqual(readSessionProfileEntry(custom(data)), {
150
+ status: "invalid",
151
+ });
152
+ }
153
+ });
154
+
155
+ test("one invalid route invalidates the whole snapshot instead of silently dropping it", () => {
156
+ const routes: unknown[] = [
157
+ null,
158
+ [],
159
+ 3,
160
+ "bad model",
161
+ { model: "bad model", thinking: "low" },
162
+ { model: "provider/good", thinking: "extreme" },
163
+ { effort: "extreme" },
164
+ { thinking: "low", effort: "extreme" },
165
+ { model: null },
166
+ ];
167
+ for (const route of routes) {
168
+ assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), {
169
+ status: "invalid",
170
+ });
171
+ assert.deepEqual(
172
+ readSessionProfileEntry(
173
+ custom(bind({ good: "provider/good", worker: route })),
174
+ ),
175
+ { status: "invalid" },
176
+ );
177
+ }
178
+ assert.deepEqual(readSessionProfileEntry(custom(bind({ "bad role": {} }))), {
179
+ status: "invalid",
180
+ });
181
+ });
182
+
183
+ test("unknown route fields are ignored, including an unknown-only route", () => {
184
+ assert.deepEqual(
185
+ readSessionProfileEntry(custom(bind({ worker: { future: true } }))),
186
+ {
187
+ status: "bound",
188
+ binding: bind({ worker: {} }),
189
+ },
190
+ );
191
+ });
192
+
193
+ test("hostile record keys remain own data without changing prototypes", () => {
194
+ const routes = JSON.parse(
195
+ '{"__proto__":{"model":"provider/safe"},"constructor":{},"prototype":{}}',
196
+ );
197
+ const result = readSessionProfileEntry(custom(bind(routes)));
198
+ assert.equal(result.status, "bound");
199
+ if (result.status !== "bound") return;
200
+ assert.equal(
201
+ Object.getPrototypeOf(result.binding.modelProfiles),
202
+ Object.prototype,
203
+ );
204
+ assert.equal(Object.hasOwn(result.binding.modelProfiles, "__proto__"), true);
205
+ assert.deepEqual(result.binding.modelProfiles["__proto__"], {
206
+ model: "provider/safe",
207
+ });
208
+ for (const key of ["constructor", "prototype"]) {
209
+ assert.equal(Object.hasOwn(result.binding.modelProfiles, key), true);
210
+ assert.deepEqual(result.binding.modelProfiles[key], {});
211
+ }
212
+ });
213
+
214
+ test("required fields cannot be supplied by prototypes", () => {
215
+ assert.deepEqual(readSessionProfileEntry(custom(Object.create(bind()))), {
216
+ status: "invalid",
217
+ });
218
+ });
219
+
220
+ test("writer rejects invalid input rather than manufacturing an empty snapshot", () => {
221
+ assert.throws(() => createSessionProfileBind("../bad", {}));
222
+ assert.throws(() =>
223
+ createSessionProfileBind("work", { worker: { model: "bad model" } }),
224
+ );
225
+ for (const route of [
226
+ { model: null },
227
+ { model: "bad model", thinking: undefined },
228
+ { model: undefined, thinking: "extreme" },
229
+ { effort: undefined },
230
+ ]) {
231
+ assert.throws(() =>
232
+ createSessionProfileBind("work", {
233
+ good: { model: "provider/good" },
234
+ worker: route,
235
+ } as Parameters<typeof createSessionProfileBind>[1]),
236
+ );
237
+ }
238
+ });
239
+
240
+ test("writer and reader return defensive snapshots", () => {
241
+ const source = { worker: { model: "provider/first" } };
242
+ const payload = createSessionProfileBind("work", source);
243
+ source.worker.model = "provider/second";
244
+ assert.equal(payload.modelProfiles.worker.model, "provider/first");
245
+ const entry = custom(payload);
246
+ const first = readSessionProfileEntry(entry);
247
+ assert.equal(first.status, "bound");
248
+ if (first.status !== "bound") return;
249
+ first.binding.modelProfiles.worker.model = "provider/third";
250
+ const second = readSessionProfileEntry(entry);
251
+ assert.equal(second.status, "bound");
252
+ if (second.status === "bound")
253
+ assert.equal(second.binding.modelProfiles.worker.model, "provider/first");
254
+ });
255
+
256
+ test("noncustom and unrelated custom entries are absent even with matching payloads", () => {
257
+ for (const entry of [
258
+ { type: "message", customType: SESSION_PROFILE_CUSTOM_TYPE, data: bind() },
259
+ { type: "message", message: { role: "user", content: "hello" } },
260
+ custom(bind(), "other/v1"),
261
+ ])
262
+ assert.deepEqual(readSessionProfileEntry(entry), { status: "absent" });
263
+ });
264
+
265
+ test("newest family entry is terminal and retains its actual branch index", () => {
266
+ const old = custom(bind({ worker: "provider/old" }));
267
+ const message = {
268
+ type: "message",
269
+ message: { role: "user", content: "hello" },
270
+ };
271
+ for (const [entry, status] of [
272
+ [custom({ kind: "clear" }), "cleared"],
273
+ [custom({ kind: "bind" }), "invalid"],
274
+ [custom(bind(), "gentle-pi.session-profile/v2"), "unsupported"],
275
+ ] as const) {
276
+ assert.deepEqual(
277
+ replaySessionProfileBranch([old, message, entry, custom({}, "other/v1")]),
278
+ {
279
+ status,
280
+ entryIndex: 2,
281
+ },
282
+ );
283
+ }
284
+ });
285
+
286
+ test("family identifier, not payload version, determines support", () => {
287
+ for (const customType of [
288
+ "gentle-pi.session-profile/v0",
289
+ "gentle-pi.session-profile/v99",
290
+ "gentle-pi.session-profile/future",
291
+ ]) {
292
+ assert.deepEqual(readSessionProfileEntry(custom(null, customType)), {
293
+ status: "unsupported",
294
+ });
295
+ }
296
+ assert.deepEqual(
297
+ readSessionProfileEntry(custom(bind(), "gentle-pi.session-profile-other/v1")),
298
+ { status: "absent" },
299
+ );
300
+ });
301
+
302
+ test("branch order selects latest bind; clear and bad entries can be explicitly superseded", () => {
303
+ const latest = custom(bind({ worker: "provider/new" }));
304
+ for (const earlier of [
305
+ custom({ kind: "clear" }),
306
+ custom(null),
307
+ custom(null, "gentle-pi.session-profile/v2"),
308
+ ]) {
309
+ assert.deepEqual(replaySessionProfileBranch([earlier, latest]), {
310
+ status: "bound",
311
+ entryIndex: 1,
312
+ binding: bind({ worker: { model: "provider/new" } }),
313
+ });
314
+ }
315
+ });
316
+
317
+ test("replay sees only caller-supplied active branch, not siblings or later paths", () => {
318
+ const ancestor = custom(bind());
319
+ const sibling = custom({ kind: "clear" });
320
+ const future = custom(null, "gentle-pi.session-profile/v2");
321
+ assert.equal(
322
+ replaySessionProfileBranch([ancestor, sibling]).status,
323
+ "cleared",
324
+ );
325
+ assert.equal(
326
+ replaySessionProfileBranch([ancestor, future]).status,
327
+ "unsupported",
328
+ );
329
+ assert.deepEqual(replaySessionProfileBranch([ancestor, { type: "message" }]), {
330
+ status: "bound",
331
+ entryIndex: 0,
332
+ binding: bind(),
333
+ });
334
+ });