gentle-pi 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,183 @@
1
+ // Run only in the parent's isolated snapshot with supported existing Pi deps.
2
+ // Public API fixtures, never providers or production flush/rollback workarounds.
3
+ import assert from "node:assert/strict";
4
+ import {
5
+ appendFileSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ renameSync,
11
+ rmSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { test, type TestContext } from "node:test";
16
+ import { fileURLToPath } from "node:url";
17
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
18
+ import { readSessionProfileDisk } from "../lib/session-profile-disk-reader.ts";
19
+ import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts";
20
+
21
+ const packagePath = join(
22
+ dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))),
23
+ "..",
24
+ "package.json",
25
+ );
26
+ const version: string = JSON.parse(readFileSync(packagePath, "utf8")).version;
27
+ // Fail closed rather than silently skipping tests against stale feature deps.
28
+ assert.match(version, /^\d+\.\d+\.\d+$/);
29
+ assert.ok(
30
+ Number(version.split(".")[0]) >= 1,
31
+ `Pi >=1.0.0 required, installed ${version}`,
32
+ );
33
+ console.info(
34
+ `Public SessionManager fixture API: ${version}; minimum tested baseline 1.0.0`,
35
+ );
36
+
37
+ function fixture(t: TestContext) {
38
+ const root = mkdtempSync(join(tmpdir(), "profile-reader-pi-"));
39
+ t.after(() => rmSync(root, { recursive: true, force: true }));
40
+ const sessions = join(root, "sessions");
41
+ const manager = SessionManager.create(root, sessions);
42
+ return { manager, sessions };
43
+ }
44
+ function bind(name: string) {
45
+ return {
46
+ kind: "bind",
47
+ origin: "user",
48
+ name,
49
+ modelProfiles: { worker: { model: `offline/${name}` } },
50
+ };
51
+ }
52
+ function flushFixture(manager: SessionManager) {
53
+ // An ordinary test-owned conversation message triggers Pi's normal persistence.
54
+ manager.appendMessage({
55
+ role: "user",
56
+ content: "isolated reader fixture",
57
+ timestamp: Date.now(),
58
+ });
59
+ }
60
+ function expectBound(manager: SessionManager, name: string) {
61
+ const result = readSessionProfileDisk(manager);
62
+ assert.equal(result.status, "bound");
63
+ if (result.status !== "bound")
64
+ throw new Error("Expected corroborated binding");
65
+ assert.equal(result.binding.name, name);
66
+ }
67
+
68
+ test("custom-only public session cannot restore memory; ordinary fixture flush corroborates", (t) => {
69
+ const { manager } = fixture(t);
70
+ manager.appendCustomEntry(customType, bind("initial"));
71
+ const path = manager.getSessionFile();
72
+ assert.ok(path);
73
+ assert.equal(existsSync(path), false);
74
+ assert.equal(readSessionProfileDisk(manager).status, "indeterminate");
75
+ flushFixture(manager);
76
+ expectBound(manager, "initial");
77
+ });
78
+
79
+ test("malformed line Pi skips on open does not block corroboration", (t) => {
80
+ const { manager, sessions } = fixture(t);
81
+ manager.appendCustomEntry(customType, bind("before"));
82
+ flushFixture(manager);
83
+ const path = manager.getSessionFile();
84
+ assert.ok(path);
85
+ appendFileSync(path, '{"type":"custom","id":"torn\n');
86
+ const reopened = SessionManager.open(path, sessions);
87
+ expectBound(reopened, "before");
88
+ reopened.appendCustomEntry(customType, bind("after"));
89
+ flushFixture(reopened);
90
+ expectBound(reopened, "after");
91
+ });
92
+
93
+ test("public selected branch, sibling and extracted session stay separated", (t) => {
94
+ const { manager, sessions } = fixture(t);
95
+ manager.appendCustomEntry(customType, bind("base"));
96
+ flushFixture(manager);
97
+ const base = manager.getLeafId();
98
+ assert.ok(base);
99
+ manager.appendCustomEntry(customType, bind("left"));
100
+ const left = manager.getLeafId();
101
+ assert.ok(left);
102
+ manager.branch(base);
103
+ manager.appendCustomEntry(customType, bind("right"));
104
+ expectBound(manager, "right");
105
+ manager.branch(left);
106
+ expectBound(manager, "left");
107
+ const originalFile = manager.getSessionFile();
108
+ const extracted = manager.createBranchedSession(left);
109
+ assert.ok(extracted);
110
+ assert.notEqual(extracted, originalFile);
111
+ const reopened = SessionManager.open(extracted, sessions);
112
+ expectBound(reopened, "left");
113
+ assert.equal(
114
+ reopened
115
+ .getBranch()
116
+ .some(
117
+ (row) =>
118
+ row.type === "custom" && (row.data as { name?: string })?.name === "right",
119
+ ),
120
+ false,
121
+ );
122
+ });
123
+
124
+ for (const recovery of ["bind", "clear"] as const) {
125
+ test(`real EISDIR ghost quarantined; new ${recovery} corroborates missing parent even after reopen`, (t) => {
126
+ const { manager, sessions } = fixture(t);
127
+ manager.appendCustomEntry(customType, bind("good"));
128
+ flushFixture(manager);
129
+ const file = manager.getSessionFile();
130
+ assert.ok(file);
131
+ const saved = `${file}.saved`;
132
+ renameSync(file, saved);
133
+ mkdirSync(file);
134
+ assert.throws(
135
+ () => manager.appendCustomEntry(customType, bind("failed")),
136
+ (error: unknown) => {
137
+ assert.equal((error as NodeJS.ErrnoException).code, "EISDIR");
138
+ return true;
139
+ },
140
+ );
141
+ const failedId = manager.getLeafId();
142
+ assert.ok(failedId);
143
+ assert.equal(readSessionProfileDisk(manager).status, "indeterminate");
144
+ renameSync(file, `${file}.fault-directory`);
145
+ renameSync(saved, file);
146
+ assert.equal(readSessionProfileDisk(manager).status, "indeterminate");
147
+ const knownFailedEntryIds = new Set([failedId]);
148
+ const old = readSessionProfileDisk(manager, { knownFailedEntryIds });
149
+ assert.equal(old.status, "bound");
150
+ if (old.status === "bound") assert.equal(old.binding.name, "good");
151
+ manager.appendCustomEntry(
152
+ customType,
153
+ recovery === "bind" ? bind("recovered") : { kind: "clear" },
154
+ );
155
+ const latest = manager.getBranch().at(-1);
156
+ assert.ok(latest);
157
+ assert.equal(latest.parentId, failedId);
158
+ const disk = readFileSync(file, "utf8")
159
+ .trim()
160
+ .split("\n")
161
+ .map((line) => JSON.parse(line));
162
+ assert.equal(
163
+ disk.some((row) => row.id === failedId),
164
+ false,
165
+ );
166
+ assert.deepEqual(
167
+ disk.find((row) => row.id === latest.id),
168
+ JSON.parse(JSON.stringify(latest)),
169
+ );
170
+ assert.equal(
171
+ readSessionProfileDisk(manager, { knownFailedEntryIds }).status,
172
+ recovery === "bind" ? "bound" : "cleared",
173
+ );
174
+ const reopened = SessionManager.open(file, sessions);
175
+ assert.equal(reopened.getBranch().length, 1);
176
+ assert.equal(reopened.getBranch()[0].id, latest.id);
177
+ assert.equal(
178
+ readSessionProfileDisk(reopened).status,
179
+ recovery === "bind" ? "bound" : "cleared",
180
+ );
181
+ if (recovery === "bind") expectBound(reopened, "recovered");
182
+ });
183
+ }
@@ -0,0 +1,311 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test, type TestContext } from "node:test";
6
+ import {
7
+ type DiskProfileIndeterminateReason,
8
+ readSessionProfileDisk,
9
+ } from "../lib/session-profile-disk-reader.ts";
10
+ import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts";
11
+
12
+ const header = { type: "session", id: "session", version: 3 };
13
+ function entry(id = "selected", data: unknown = { kind: "clear" }) {
14
+ return {
15
+ type: "custom",
16
+ id,
17
+ parentId: null,
18
+ timestamp: "2026-10-05T00:00:00Z",
19
+ customType,
20
+ data,
21
+ };
22
+ }
23
+ function fixture(
24
+ t: TestContext,
25
+ branch: unknown[] = [],
26
+ rows: unknown[] = [header, ...branch],
27
+ ) {
28
+ const root = mkdtempSync(join(tmpdir(), "profile-reader-"));
29
+ t.after(() => rmSync(root, { recursive: true, force: true }));
30
+ const path = join(root, "session.jsonl");
31
+ writeFileSync(
32
+ path,
33
+ rows
34
+ .map((row) => (typeof row === "string" ? row : JSON.stringify(row)))
35
+ .join("\n") + "\n",
36
+ );
37
+ const source = {
38
+ getSessionId: () => "session",
39
+ getSessionFile: (): string | undefined => path,
40
+ getBranch: () => branch,
41
+ };
42
+ return { path, source, read: () => readSessionProfileDisk(source) };
43
+ }
44
+
45
+ test("header only establishes absence", (t) => {
46
+ assert.deepEqual(fixture(t).read(), { status: "absent" });
47
+ });
48
+
49
+ test("ordinary branch without family remains absent despite disk sibling", (t) => {
50
+ const message = {
51
+ type: "message",
52
+ id: "message",
53
+ parentId: null,
54
+ timestamp: "fixture",
55
+ };
56
+ assert.deepEqual(
57
+ fixture(t, [message], [header, message, entry("sibling")]).read(),
58
+ { status: "absent" },
59
+ );
60
+ });
61
+
62
+ test("omitted data is terminal invalid or unsupported, never older resurrection", (t) => {
63
+ for (const identifier of [customType, "gentle-pi.session-profile/v9"]) {
64
+ const selected = { ...entry(), customType: identifier, data: undefined };
65
+ const result = fixture(t, [entry("older"), selected]).read();
66
+ assert.equal(
67
+ result.status,
68
+ identifier === customType ? "invalid" : "unsupported",
69
+ );
70
+ }
71
+ });
72
+
73
+ for (const [label, data, status] of [
74
+ ["clear", { kind: "clear" }, "cleared"],
75
+ [
76
+ "empty",
77
+ { kind: "bind", origin: "user", name: "chosen", modelProfiles: {} },
78
+ "bound",
79
+ ],
80
+ ["invalid", { kind: "broken" }, "invalid"],
81
+ ] as const) {
82
+ test(`corroborated ${label} retains semantic state and original positions`, (t) => {
83
+ const selected = entry("selected", data);
84
+ const message = {
85
+ type: "message",
86
+ id: "message",
87
+ parentId: null,
88
+ timestamp: "fixture",
89
+ };
90
+ const f = fixture(t, [message, selected], [header, "", message, selected]);
91
+ const result = f.read();
92
+ assert.equal(result.status, status);
93
+ assert.equal(result.entryIndex, 1);
94
+ assert.equal(result.lineNumber, 4);
95
+ });
96
+ }
97
+
98
+ test("future newest is terminal; sibling records never win", (t) => {
99
+ const older = entry("older");
100
+ const future = {
101
+ ...entry("future"),
102
+ customType: "gentle-pi.session-profile/v2",
103
+ };
104
+ const f = fixture(
105
+ t,
106
+ [older, future],
107
+ [header, older, future, entry("sibling")],
108
+ );
109
+ assert.equal(f.read().status, "unsupported");
110
+ });
111
+
112
+ test("unknown unwritten newest never falls back; trusted failed evidence excludes only its ID", (t) => {
113
+ const older = entry("older");
114
+ const ghost = entry("ghost");
115
+ const f = fixture(t, [older, ghost], [header, older]);
116
+ assert.equal(f.read().status, "indeterminate");
117
+ const options = { knownFailedEntryIds: new Set(["ghost"]) };
118
+ assert.equal(readSessionProfileDisk(f.source, options).status, "cleared");
119
+ writeFileSync(
120
+ f.path,
121
+ [header, older, ghost].map((row) => JSON.stringify(row)).join("\n"),
122
+ );
123
+ assert.equal(readSessionProfileDisk(f.source, options).entryIndex, 0);
124
+ assert.equal(
125
+ readSessionProfileDisk(f.source, {
126
+ knownFailedEntryIds: new Set(["unrelated"]),
127
+ }).entryIndex,
128
+ 1,
129
+ );
130
+ });
131
+
132
+ for (const [label, rows] of [
133
+ ["wrong session", [{ ...header, id: "other" }, entry()]],
134
+ ["wrong header", [{ ...header, type: "message" }, entry()]],
135
+ ["duplicate ID", [header, entry(), entry()]],
136
+ ["malformed", [header, "{secret"]],
137
+ ["truncated", [header, '{"type":']],
138
+ ["data mismatch", [header, entry("selected", { kind: "broken" })]],
139
+ ["parent mismatch", [header, { ...entry(), parentId: "other" }]],
140
+ ["metadata mismatch", [header, { ...entry(), timestamp: "other" }]],
141
+ ["custom type mismatch", [header, { ...entry(), customType: "other" }]],
142
+ ["extra metadata mismatch", [header, { ...entry(), extra: "changed" }]],
143
+ ["nonobject record", [header, "null"]],
144
+ ["empty file", []],
145
+ [
146
+ "missing metadata",
147
+ [
148
+ header,
149
+ { type: "custom", id: "selected", customType, data: { kind: "clear" } },
150
+ ],
151
+ ],
152
+ ] as const) {
153
+ test(`${label} is explicitly indeterminate`, (t) => {
154
+ const result = fixture(t, [entry()], [...rows]).read();
155
+ assert.equal(result.status, "indeterminate");
156
+ assert.ok(!JSON.stringify(result).includes("secret"));
157
+ });
158
+ }
159
+
160
+ test("malformed lines are skipped like Pi's session parser, keeping physical line numbers", (t) => {
161
+ const selected = entry();
162
+ for (const rows of [
163
+ [header, "{bad private contents", selected],
164
+ ["{bad private contents", header, selected],
165
+ [header, selected, '{"type":"custom","id":"torn'],
166
+ ]) {
167
+ const result = fixture(t, [selected], rows).read();
168
+ assert.equal(result.status, "cleared");
169
+ assert.equal(result.lineNumber, rows.indexOf(selected) + 1);
170
+ }
171
+ });
172
+
173
+ test("malformed selected record is missing, never admitted", (t) => {
174
+ const result = fixture(t, [entry()], [header, "{secret"]).read();
175
+ assert.deepEqual(result, {
176
+ status: "indeterminate",
177
+ reason: "selected-record-missing",
178
+ });
179
+ });
180
+
181
+ test("file with only blank or malformed lines has no header", (t) => {
182
+ for (const rows of [[], ["", "{bad"]]) {
183
+ assert.deepEqual(fixture(t, [entry()], rows).read(), {
184
+ status: "indeterminate",
185
+ reason: "missing-header",
186
+ });
187
+ }
188
+ });
189
+
190
+ test("non-string customType is ordinary, non-string kind and empty agent name are invalid", (t) => {
191
+ const ordinary = { ...entry(), customType: 7 };
192
+ assert.deepEqual(fixture(t, [ordinary]).read(), { status: "absent" });
193
+ for (const data of [
194
+ { kind: 1 },
195
+ { kind: ["clear"] },
196
+ {
197
+ kind: "bind",
198
+ origin: "user",
199
+ name: "chosen",
200
+ modelProfiles: { "": "provider/model" },
201
+ },
202
+ ]) {
203
+ const selected = entry("selected", data);
204
+ assert.equal(fixture(t, [selected]).read().status, "invalid");
205
+ }
206
+ });
207
+
208
+ test("every indeterminate reason is a named literal", () => {
209
+ const reasons = {
210
+ "missing-source": true,
211
+ "invalid-candidate-metadata": true,
212
+ "unreadable-file": true,
213
+ "source-changed": true,
214
+ "invalid-record": true,
215
+ "session-header-mismatch": true,
216
+ "duplicate-id": true,
217
+ "missing-header": true,
218
+ "selected-record-missing": true,
219
+ "selected-record-mismatch": true,
220
+ "unserializable-source": true,
221
+ } satisfies Record<DiskProfileIndeterminateReason, true>;
222
+ assert.equal(Object.keys(reasons).length, 11);
223
+ });
224
+
225
+ test("missing, undefined and unreadable paths are conservative", (t) => {
226
+ const f = fixture(t);
227
+ rmSync(f.path);
228
+ assert.equal(f.read().status, "indeterminate");
229
+ f.source.getSessionFile = () => undefined;
230
+ assert.equal(f.read().status, "indeterminate");
231
+ f.source.getSessionFile = () => f.path;
232
+ assert.equal(
233
+ readSessionProfileDisk(f.source, {
234
+ readFile: () => {
235
+ throw new Error("private");
236
+ },
237
+ }).status,
238
+ "indeterminate",
239
+ );
240
+ });
241
+
242
+ for (const change of ["id", "path", "branch"] as const) {
243
+ test(`source ${change} change during read invalidates authority`, (t) => {
244
+ const f = fixture(t, [entry()]);
245
+ const result = readSessionProfileDisk(f.source, {
246
+ readFile: (path) => {
247
+ const text = readFileSync(path, "utf8");
248
+ if (change === "id") f.source.getSessionId = () => "changed";
249
+ if (change === "path") f.source.getSessionFile = () => "changed";
250
+ if (change === "branch") f.source.getBranch().push(entry("changed"));
251
+ return text;
252
+ },
253
+ });
254
+ assert.equal(result.status, "indeterminate");
255
+ });
256
+ }
257
+
258
+ test("JSON key order and omitted undefined follow serialization; orphan parent is allowed", (t) => {
259
+ const selected = { ...entry(), parentId: "missing-failed", extra: undefined };
260
+ const reversed = Object.fromEntries(Object.entries(selected).reverse());
261
+ assert.equal(
262
+ fixture(t, [selected], [header, reversed]).read().status,
263
+ "cleared",
264
+ );
265
+ });
266
+
267
+ test("unserializable memory and malformed own candidate metadata fail closed", (t) => {
268
+ const circular: any = entry();
269
+ circular.extra = circular;
270
+ assert.equal(
271
+ fixture(t, [circular], [header, entry()]).read().status,
272
+ "indeterminate",
273
+ );
274
+ for (const field of ["id", "parentId", "timestamp"]) {
275
+ const candidate: any = entry();
276
+ delete candidate[field];
277
+ assert.equal(
278
+ fixture(t, [candidate], [header, candidate]).read().status,
279
+ "indeterminate",
280
+ );
281
+ }
282
+ });
283
+
284
+ test("all origins, legacy routes and orchestrator are detached and preserved", (t) => {
285
+ for (const origin of ["user", "local", "repo", "global"]) {
286
+ const selected = entry("selected", {
287
+ kind: "bind",
288
+ origin,
289
+ name: "chosen",
290
+ modelProfiles: {
291
+ worker: "provider/model",
292
+ orchestrator: { effort: "high" },
293
+ },
294
+ });
295
+ const f = fixture(t, [selected]);
296
+ const result = f.read();
297
+ assert.equal(result.status, "bound");
298
+ if (result.status !== "bound") throw new Error("expected binding");
299
+ assert.equal(result.binding.origin, origin);
300
+ assert.deepEqual(result.binding.modelProfiles.orchestrator, {
301
+ thinking: "high",
302
+ });
303
+ result.binding.modelProfiles.worker = { model: "changed" };
304
+ const next = f.read();
305
+ assert.equal(next.status, "bound");
306
+ if (next.status === "bound")
307
+ assert.deepEqual(next.binding.modelProfiles.worker, {
308
+ model: "provider/model",
309
+ });
310
+ }
311
+ });
@@ -282,14 +282,14 @@ test("drift notice: shown once per distinct change of the defaults", () => {
282
282
  f.pinLocal("local");
283
283
  assert.equal(
284
284
  inheritedProfileDriftNotice(opts),
285
- 'el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
285
+ 'el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
286
286
  );
287
287
  assert.equal(inheritedProfileDriftNotice(opts), undefined, "the same change is not repeated");
288
288
  f.pinLocal(undefined);
289
289
  f.setActive("team");
290
290
  assert.equal(
291
291
  inheritedProfileDriftNotice(opts),
292
- 'el Gentleman: the default profile changed to "team" (global), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
292
+ 'el Gentleman: the default profile changed to "team" (global), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
293
293
  );
294
294
  assert.equal(inheritedProfileDriftNotice(opts), undefined);
295
295
  assert.equal(readFrozenInheritedProfile("session-a")?.profile?.name, "frontier", "the notice never adopts the new default");
@@ -305,6 +305,8 @@ test("drift notice: an explicit binding learns its profile definition changed in
305
305
  const notice = inheritedProfileDriftNotice(opts);
306
306
  assert.match(notice ?? "", /the profile definition changed to "team"/);
307
307
  assert.match(notice ?? "", /this session keeps the binding from selection/);
308
+ assert.match(notice ?? "", /Open \/gentle:profiles and choose Use in this session to adopt one\.$/, "the notice names the current adoption action");
309
+ assert.doesNotMatch(notice ?? "", /Press Enter/, "the retired Enter instruction never comes back");
308
310
  assert.equal(inheritedProfileDriftNotice(opts), undefined, "the same change is not repeated");
309
311
 
310
312
  f.editProfile("team", { worker: { model: "openai/older" } });
@@ -344,14 +346,14 @@ test("drift notice covers a session frozen without a profile and a default that
344
346
  f.pinLocal("frontier");
345
347
  assert.equal(
346
348
  inheritedProfileDriftNotice(none),
347
- 'el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
349
+ 'el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Open /gentle:profiles and choose Use in this session to adopt one.',
348
350
  );
349
351
  const kept = options(f, "session-kept");
350
352
  freezeSessionProfileAtStartup(kept);
351
353
  f.pinLocal(undefined);
352
354
  assert.equal(
353
355
  inheritedProfileDriftNotice(kept),
354
- 'el Gentleman: the default profile changed to no profile, this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
356
+ 'el Gentleman: the default profile changed to no profile, this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
355
357
  );
356
358
  });
357
359
 
@@ -1,13 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import test, { type TestContext } from "node:test";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { createSessionScratchExtension, SCRATCH_COMMAND_USAGE } from "../extensions/session-scratch.ts";
9
9
  import * as scratchLib from "../lib/session-scratch.ts";
10
- import { gitArtifactTargets, LEDGER_VERSION, type ScratchLedger } from "../lib/session-scratch.ts";
10
+ import { gitArtifactTargets, LEDGER_ARTIFACTS_CAP, LEDGER_VERSION, type ScratchLedger } from "../lib/session-scratch.ts";
11
11
 
12
12
  // Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
13
13
  // the runtime owns the parent session's TMPDIR and the clones/worktrees it
@@ -943,3 +943,69 @@ test("advisories S7: the reference documents the advisory rules", () => {
943
943
  "changed in the last minute", "recorded `inherited` values", "not a `gpXXXXXX` directory",
944
944
  ]) assert.ok(section.includes(term), `scratch reference missing ${term}`);
945
945
  });
946
+
947
+ // Perf (long-session audit): the ledger file I/O must not grow with the number
948
+ // of retained ledgers or artifacts. `listLedgers` and `writeLedger` use only
949
+ // the async fs seam, and `capture` keeps at most `LEDGER_ARTIFACTS_CAP`
950
+ // artifacts, evicting the oldest first. Old pre-cap ledgers still read.
951
+
952
+ test("perf: pushing beyond the cap keeps the ledger bounded and keeps the newest artifacts", async (t) => {
953
+ const fx = fixture(t);
954
+ const s = session(fx);
955
+ await s.start();
956
+ for (let i = 0; i < LEDGER_ARTIFACTS_CAP + 6; i++) {
957
+ await s.bash(`cd ${fx.work} && git clone x d${i}`, () => mkdirSync(join(fx.work, `d${i}`)));
958
+ }
959
+ const ledger = readLedger(fx, "s-1");
960
+ assert.equal(ledger.artifacts.length, LEDGER_ARTIFACTS_CAP, "the ledger file stays bounded at the cap");
961
+ assert.equal(basename(ledger.artifacts[0].path), `d6`, "eviction is oldest-first");
962
+ assert.equal(basename(ledger.artifacts.at(-1)!.path), `d${LEDGER_ARTIFACTS_CAP + 5}`, "the newest artifacts are kept");
963
+ assert.ok(!ledger.artifacts.some((artifact) => basename(artifact.path) === "d0"));
964
+ await s.shutdown();
965
+ // An old ledger written before the cap still reads, whole, by the pre-cap reader.
966
+ writeForeignLedger(fx, { sessionId: "pre-cap", artifacts: Array.from({ length: LEDGER_ARTIFACTS_CAP + 10 }, (_, i) => ({ kind: "clone" as const, path: join(fx.work, `old${i}`), command: "git clone", recordedAt: new Date(0).toISOString() })) });
967
+ const preCap = scratchLib.readLedger(ledgerFile(fx, "pre-cap"));
968
+ assert.equal(preCap?.artifacts.length, LEDGER_ARTIFACTS_CAP + 10, "a pre-cap ledger is read without pruning");
969
+ const stored = await scratchLib.listLedgers(fx.configHome);
970
+ assert.equal(stored.find(({ ledger: found }) => found.sessionId === "pre-cap")?.ledger.artifacts.length, LEDGER_ARTIFACTS_CAP + 10);
971
+ const out = await s.run("list");
972
+ assert.match(out, /old0 \(session pre-cap, ended\)/);
973
+ });
974
+
975
+ test("perf: listLedgers and writeLedger run on the async fs seam only", async (t) => {
976
+ const fx = fixture(t);
977
+ assert.equal(scratchLib.writeLedger.constructor.name, "AsyncFunction", "writeLedger is async");
978
+ assert.equal(scratchLib.listLedgers.constructor.name, "AsyncFunction", "listLedgers is async");
979
+ const saved = { ...scratchLib.ledgerFs };
980
+ const calls: Record<string, number> = {};
981
+ t.after(() => Object.assign(scratchLib.ledgerFs, saved));
982
+ for (const name of Object.keys(scratchLib.ledgerFs) as (keyof typeof scratchLib.ledgerFs)[]) {
983
+ (scratchLib.ledgerFs as Record<string, unknown>)[name] = async (...args: unknown[]) => {
984
+ calls[name] = (calls[name] ?? 0) + 1;
985
+ return await (saved[name] as (...rest: unknown[]) => unknown)(...args);
986
+ };
987
+ }
988
+ const ledger: ScratchLedger = { version: LEDGER_VERSION, sessionId: "async", pid: 1, processStartedAt: new Date(0).toISOString(), cwd: fx.cwd, tempRoot: null, inherited: {}, scratchDir: null, artifacts: [] };
989
+ await scratchLib.writeLedger(ledgerFile(fx, "async"), ledger);
990
+ for (const name of ["mkdir", "writeFile", "rename"]) assert.ok(calls[name], `writeLedger goes through the seam: ${name}`);
991
+ const stored = await scratchLib.listLedgers(fx.configHome);
992
+ assert.equal(stored.length, 1);
993
+ assert.equal(stored[0].ledger.sessionId, "async");
994
+ for (const name of ["readdir", "readFile", "stat"]) assert.ok(calls[name], `listLedgers goes through the seam: ${name}`);
995
+ });
996
+
997
+ test("perf: a failed ledger write leaves the previous ledger file intact", async (t) => {
998
+ if (process.getuid?.() === 0) { t.skip("a write never fails for root"); return; }
999
+ const fx = fixture(t);
1000
+ const path = ledgerFile(fx, "intact");
1001
+ const ledger: ScratchLedger = { version: LEDGER_VERSION, sessionId: "intact", pid: 1, processStartedAt: new Date(0).toISOString(), cwd: fx.cwd, tempRoot: null, inherited: {}, scratchDir: null, artifacts: [{ kind: "clone", path: join(fx.work, "kept"), command: "git clone", recordedAt: new Date(0).toISOString() }] };
1002
+ await scratchLib.writeLedger(path, ledger);
1003
+ chmodSync(dirname(path), 0o500);
1004
+ try {
1005
+ await assert.rejects(() => scratchLib.writeLedger(path, { ...ledger, sessionId: "lost" }));
1006
+ assert.equal(scratchLib.readLedger(path)?.sessionId, "intact", "the previous ledger file survives the failed write");
1007
+ assert.deepEqual(readdirSync(dirname(path)).filter((name) => name.endsWith(".tmp")), [], "no temp file is left behind");
1008
+ } finally {
1009
+ chmodSync(dirname(path), 0o700);
1010
+ }
1011
+ });
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import test from "node:test";
8
8
  import { SessionManager } from "@earendil-works/pi-coding-agent";
9
- import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
9
+ import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, nativeRootSpelling, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
10
10
  import { sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
11
11
 
12
12
  // All Git and session writes belong to unique fixtures, never the live clone.
@@ -306,3 +306,15 @@ test("memoized identity still observes removal, nested repositories and expiry",
306
306
  counted(join(f.dir, "empty"), f.dir);
307
307
  assert.equal(calls, 4, "a missing repository is never memoized, so bootstrap is observed at once");
308
308
  });
309
+
310
+ test("Windows root spelling accepts forward slashes as the native separator only (gentle-shell#2314)", () => {
311
+ // Git prints C:/repo while realpath returns C:\\repo; both name the same root.
312
+ assert.equal(nativeRootSpelling("C:/path/to/sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
313
+ assert.equal(nativeRootSpelling("C:\\path\\to\\sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
314
+ // Only separators change: dot segments and trailing separators stay non-canonical.
315
+ assert.equal(nativeRootSpelling("C:/repo/.", "win32"), "C:\\repo\\.");
316
+ assert.equal(nativeRootSpelling("C:/repo/", "win32"), "C:\\repo\\");
317
+ // POSIX spelling is untouched; a backslash is an ordinary filename character there.
318
+ assert.equal(nativeRootSpelling("/srv/repo", "linux"), "/srv/repo");
319
+ assert.equal(nativeRootSpelling("/srv/re\\po", "darwin"), "/srv/re\\po");
320
+ });