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
@@ -1054,6 +1054,55 @@ test("the one-launch walk applies the single-path walk, per path, with its own f
1054
1054
  assert.doesNotMatch(many, /Invoke-Expression|\biex\b|ScriptBlock\]::Create|EncodedCommand/);
1055
1055
  });
1056
1056
 
1057
+ test("storage metadata public adapters preserve verdicts and unexpected failures", () => {
1058
+ const path = "D:\\metadata fixture\\tool.exe";
1059
+ const env = { SystemRoot: "C:\\Windows" };
1060
+ let calls = 0;
1061
+ const single = (output: string) => verifyWindowsStorage(path, env, (_command: string, _args: string[], data: Record<string, string>) => {
1062
+ calls++; assert.equal(data.GENTLE_WINDOWS_CHECK, path); return output;
1063
+ });
1064
+ if (process.platform === "win32") {
1065
+ assert.equal(single("safe"), undefined);
1066
+ for (const check of ["target-reparse", "parent-owner", "ancestor-acl-mask"]) {
1067
+ assert.throws(() => single(`unsafe:${check}|${encoded(`${path}|||`)}`), (error: Error & { check?: string; detail?: { at: string } }) => error.check === check && error.detail?.at === path);
1068
+ }
1069
+ assert.throws(() => single("malformed"), /Windows ACL evidence rejected/);
1070
+ assert.equal(calls, 5);
1071
+ const failure = Object.assign(new Error("metadata read failed"), { code: "EACCES" });
1072
+ assert.throws(() => verifyWindowsStorage(path, env, () => { throw failure; }), (error: unknown) => error === failure);
1073
+ } else {
1074
+ assert.throws(() => single("safe"), /Native Windows storage verification unavailable/);
1075
+ assert.equal(calls, 0, "unsupported platforms reject before invoking the runner");
1076
+ }
1077
+ const paths = [path, "D:\\metadata fixture\\next.cmd"];
1078
+ assert.deepEqual(windowsModule.verifyWindowsStorageMany(paths, env, { platform: "win32", processAdapter: (_command: string, _args: string[], data: Record<string, string>) => {
1079
+ assert.equal(data.GENTLE_WINDOWS_CHECKS, paths.join("|")); return "unknown\nsafe";
1080
+ } }), [{ check: "unchecked", at: path }, null]);
1081
+ });
1082
+
1083
+ // Inspect scripts through the public process seam; never execute PowerShell.
1084
+ for (const mode of ["single", "batch"] as const) {
1085
+ test(`storage metadata ${mode} uses throwing static attributes before ACL reads`, { skip: mode === "single" && process.platform !== "win32" ? "single-path script requires the real Windows platform" : false }, () => {
1086
+ let script = "";
1087
+ const processAdapter = (_command: string, args: string[], env: Record<string, string>) => {
1088
+ script = args[4];
1089
+ assert.equal(env[mode === "single" ? "GENTLE_WINDOWS_CHECK" : "GENTLE_WINDOWS_CHECKS"], "C:\\metadata\\node.exe");
1090
+ return "safe";
1091
+ };
1092
+ if (mode === "single") verifyWindowsStorage("C:\\metadata\\node.exe", { SystemRoot: "C:\\Windows" }, processAdapter);
1093
+ else windowsModule.verifyWindowsStorageMany(["C:\\metadata\\node.exe"], { SystemRoot: "C:\\Windows" }, { platform: "win32", processAdapter });
1094
+ assert.ok(script.includes("$attributes = [IO.File]::GetAttributes($path);"));
1095
+ const reparse = "if ($attributes -band [IO.FileAttributes]::ReparsePoint) { $detail = $path + '|||'; throw ($role + '-reparse') };";
1096
+ const branch = "if ($attributes -band [IO.FileAttributes]::Directory) { $acl = [IO.Directory]::GetAccessControl($path) } else { $acl = [IO.File]::GetAccessControl($path) };";
1097
+ assert.ok(script.includes(reparse));
1098
+ assert.ok(script.includes(branch));
1099
+ assert.ok(script.indexOf("GetAttributes($path)") < script.indexOf(reparse));
1100
+ assert.ok(script.indexOf(reparse) < script.indexOf(branch));
1101
+ assert.doesNotMatch(script, /Get-Item|\[IO\.(?:File|Directory)\]::Exists|FileInfo|DirectoryInfo|Refresh/);
1102
+ assert.ok(script.includes(mode === "single" ? "} else { throw } }" : "} else { 'unknown' } }"));
1103
+ });
1104
+ }
1105
+
1057
1106
  // S6 decision fixtures: an in-memory folder tree and an injected walk verdict per
1058
1107
  // path, the same way the helper tests inject storage. `weak` maps a walked path
1059
1108
  // to the check its walk fails; every other walk passes.
@@ -1197,8 +1246,9 @@ test("production and fixture PowerShell never depend on autoloading Microsoft.Po
1197
1246
  assert.match(source, /\[IO\.Directory\]::SetAccessControl\(/);
1198
1247
  }
1199
1248
  // Walks that may reach a file read it as a file, never through a directory API.
1200
- for (const source of [productionAclSources()[1], productionAclSources()[2]]) {
1201
- assert.match(source, /if \(\$item\.PSIsContainer\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
1249
+ assert.match(productionAclSources()[1], /if \(\$item\.PSIsContainer\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
1250
+ for (const source of [productionAclSources()[2], manyCheck()]) {
1251
+ assert.match(source, /if \(\$attributes -band \[IO\.FileAttributes\]::Directory\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
1202
1252
  }
1203
1253
  });
1204
1254
  const psRecordLine = ' "$record = [Diagnostics.Process]::GetCurrentProcess(); [IO.File]::AppendAllText($env:GENTLE_FIXTURE_RECORDS,([string]$record.Id + [char]124 + [string]$record.StartTime.ToUniversalTime().Ticks + [Environment]::NewLine));" ^';
@@ -1317,9 +1367,17 @@ if (Test-Path -LiteralPath $env:GENTLE_FIXTURE_RECORDS) {
1317
1367
  if ($reaped) { [Console]::WriteLine('fixture guard reaped residual owned process') };
1318
1368
  exit 0;
1319
1369
  `;
1370
+ const GUARD_TIMEOUT_MS = 5000;
1320
1371
  function cleanNativeProcesses(root: string) {
1321
1372
  const ps = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe");
1322
- const result = spawnSync(ps, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", guardCommand], { env: nativeEnv(root), timeout: 5000, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 65536 });
1373
+ const spawnGuard = (timeoutMs: number) => spawnSync(ps, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", guardCommand], { env: nativeEnv(root), timeout: timeoutMs, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 65536 });
1374
+ // A hosted Windows runner can cold-start PowerShell slower than any fixed
1375
+ // bound under load (run 40000806368: ETIMEDOUT at 5s, same commit green on
1376
+ // rerun). The guard's job is cleanup, not a load measurement, and the guard
1377
+ // is idempotent — it kills only verified owned processes and exits 0 — so a
1378
+ // timeout is retried once with a wider bound before failing the fixture.
1379
+ let result = spawnGuard(GUARD_TIMEOUT_MS);
1380
+ if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") result = spawnGuard(GUARD_TIMEOUT_MS * 4);
1323
1381
  assert.equal(result.error, undefined, "fixture guard must be bounded and available");
1324
1382
  assert.equal(result.status, 0, `fixture guard must only clean verified owned processes: ${(result.stderr ?? "").slice(0, 4000)}`);
1325
1383
  assert.ok(["", "fixture guard reaped residual owned process"].includes(result.stdout.trim()), "unexpected fixture guard output");
@@ -0,0 +1,111 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { basename, dirname, join, parse, resolve } from 'node:path';
5
+ import { getSystemErrorName } from 'node:util';
6
+ import { windowsProcessCheck, windowsBootstrapReason } from '../scripts/installer-windows.mjs';
7
+
8
+ // Only owned inert Node children. Never invoke PowerShell, ACL/storage or setup.
9
+ const base = resolve('odd/checks/issue-1921-windows-prerequisite-diagnostics');
10
+ mkdirSync(base, { recursive: true });
11
+ const root = mkdtempSync(join(base, 'inert-'));
12
+ const fixture = join(root, 'child.mjs');
13
+ writeFileSync(fixture, `import { writeSync } from 'node:fs';
14
+ const mode = process.argv[2];
15
+ if (mode === 'cap') { writeSync(2, 'overflow fixture\\n'); writeSync(1, 'x'.repeat(2*1024*1024)); }
16
+ else if (mode === 'success') { writeSync(1, ' ' + JSON.stringify({cwd:process.cwd()}) + ' \\n'); }
17
+ else { writeSync(1, process.argv[3]); writeSync(2, process.argv[4] ?? ''); process.exit(7); }
18
+ `);
19
+ const env = { SystemRoot: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows',
20
+ PATH: dirname(process.execPath), DIAGNOSTIC_SECRET: 'must-not-retain-env' };
21
+ function failure(command: string, args: string[]): any {
22
+ let caught: any;
23
+ try { windowsProcessCheck(command, args, env); } catch (error) { caught = error; }
24
+ assert.equal(caught?.message, 'Windows prerequisite process failed');
25
+ assert.equal(windowsBootstrapReason(caught, 'tools-storage'), 'process-failed (tools-storage)');
26
+ return caught;
27
+ }
28
+ function causeOf(error: any): any {
29
+ assert.ok(error.cause instanceof Error, 'standard cause Error must survive existing cause.stack observer');
30
+ const cause = error.cause;
31
+ assert.deepEqual(Object.keys(cause).sort(), ['error', 'signal', 'status', 'stderr', 'stdout']);
32
+ assert.ok(cause.stack.length <= 8192);
33
+ assert.ok(cause.stdout.length <= 1024 && cause.stderr.length <= 1024);
34
+ assert.ok(!cause.stack.includes(root) && !cause.stack.includes('installer-windows.mjs'));
35
+ assert.ok(!cause.error?.message?.includes(root));
36
+ assert.ok(!JSON.stringify(cause).includes(env.DIAGNOSTIC_SECRET));
37
+ return cause;
38
+ }
39
+
40
+ test('metadata: nonzero exit keeps bounded streams and usable cause.stack', () => {
41
+ const args = [fixture, 'text', 'controlled stdout\n', 'controlled stderr\n', 'private-argv-sentinel'];
42
+ const before = structuredClone({ args, env });
43
+ const error = failure(process.execPath, args), cause = causeOf(error);
44
+ assert.equal(cause.status, 7); assert.equal(cause.signal, null); assert.equal(cause.error, null);
45
+ assert.equal(cause.stdout, 'controlled stdout\n'); assert.equal(cause.stderr, 'controlled stderr\n');
46
+ assert.match(error.cause?.stack, /"status":7/);
47
+ assert.match(error.cause?.stack, /controlled stderr/);
48
+ assert.ok(!JSON.stringify(cause).includes('private-argv-sentinel'));
49
+ assert.deepEqual({ args, env }, before);
50
+ });
51
+
52
+ test('metadata: missing synthetic executable preserves ENOENT without raw spawn error', () => {
53
+ const cause = causeOf(failure(join(root, 'missing.exe'), ['private-argv-sentinel']));
54
+ assert.equal(cause.status, null); assert.equal(cause.signal, null);
55
+ assert.deepEqual(Object.keys(cause.error).sort(), ['code', 'errno', 'message', 'name']);
56
+ assert.equal(cause.error.code, 'ENOENT'); assert.equal(cause.error.name, 'Error');
57
+ assert.equal(getSystemErrorName(cause.error.errno), 'ENOENT');
58
+ assert.ok(cause.error.message.length <= 256);
59
+ assert.equal(cause.stdout, ''); assert.equal(cause.stderr, '');
60
+ assert.match(cause.stack, /ENOENT/);
61
+ });
62
+
63
+ test('metadata: output overflow preserves ENOBUFS and actual kill signal without waiting15s', () => {
64
+ const cause = causeOf(failure(process.execPath, [fixture, 'cap']));
65
+ assert.equal(cause.status, null); assert.equal(cause.signal, 'SIGKILL');
66
+ assert.equal(cause.error.code, 'ENOBUFS'); assert.equal(cause.error.name, 'Error');
67
+ assert.equal(getSystemErrorName(cause.error.errno), 'ENOBUFS');
68
+ assert.equal(cause.stderr, 'overflow fixture\n');
69
+ assert.match(cause.stack, /ENOBUFS/);
70
+ });
71
+
72
+ test('privacy: private paths, SID/encoded ACL detail and sensitive output are suppressed; caps hold', () => {
73
+ const privateLines = ['visible', 'C:\\Users\\Synthetic Person\\private.txt', '/home/synthetic/private.txt',
74
+ 'S-1-5-21-111-222-333-1001', 'DOMAIN\\synthetic', 'unsafe:target-owner|YWJjZA=='];
75
+ const cause = causeOf(failure(process.execPath, [fixture, 'text', privateLines.join('\n'),
76
+ 'Authorization: Bearer synthetic-secret\ncontinuation-must-not-survive']));
77
+ assert.match(cause.stdout, /visible/);
78
+ for (const value of privateLines.slice(1)) assert.ok(!cause.stdout.includes(value));
79
+ assert.ok(!cause.stack.includes('synthetic-secret') && !cause.stack.includes('continuation-must-not-survive'));
80
+ const bounded = causeOf(failure(process.execPath, [fixture, 'text', 'safe line\n'.repeat(300), 'safe error\n'.repeat(300)]));
81
+ assert.equal(bounded.stdout.length, 1024); assert.equal(bounded.stderr.length, 1024);
82
+ });
83
+
84
+ test('PR privacy: credential markers recognize underscore separators without changing API_KEY handling', () => {
85
+ for (const marker of ['GITHUB_TOKEN', 'PREFIX_API_KEY_SUFFIX', 'AWS_ACCESS_KEY_ID', 'API_KEY', 'access_key']) {
86
+ const payload = `${marker}=synthetic-value-1921\ncontinuation-value-1921`;
87
+ assert.equal(payload.includes('\\'), false, 'actual argv bytes must not trigger the path filter');
88
+ const cause = causeOf(failure(process.execPath,
89
+ ['-e', 'process.stderr.write(process.argv[1]);process.exit(7)', payload]));
90
+ assert.equal(cause.status, 7);
91
+ assert.equal(cause.stderr, '[redacted]');
92
+ assert.ok(!cause.stack.includes('synthetic-value-1921') && !cause.stack.includes('continuation-value-1921'));
93
+ }
94
+ });
95
+
96
+ test('PR privacy: missing bare executable identity never enters cause message or stack', () => {
97
+ const command = `fixture-private-executable-1921-${basename(root)}`;
98
+ assert.equal(parse(command).root, '', 'a path-based probe would miss the bare-name leak');
99
+ const cause = causeOf(failure(command, ['private-argv-sentinel']));
100
+ assert.equal(cause.status, null); assert.equal(cause.signal, null);
101
+ assert.equal(cause.error.code, 'ENOENT'); assert.equal(cause.error.name, 'Error');
102
+ assert.equal(getSystemErrorName(cause.error.errno), 'ENOENT');
103
+ assert.ok(!JSON.stringify(cause).includes(command) && !cause.stack.includes(command));
104
+ assert.match(cause.error.message, /ENOENT/);
105
+ });
106
+
107
+ test('legacy: neutral cwd, trimmed success and pre-spawn CMD/BAT rejection stay exact', () => {
108
+ assert.equal(windowsProcessCheck(process.execPath, [fixture, 'success'], env), JSON.stringify({ cwd: parse(process.execPath).root }));
109
+ for (const suffix of ['.CMD', '.bat']) assert.throws(() => windowsProcessCheck(join(root, `absent${suffix}`), [], env),
110
+ error => error instanceof Error && error.message === 'Unproven command interpreter target' && error.cause === undefined);
111
+ });
@@ -0,0 +1,138 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { after, before } from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { JOBS_GLYPH, renderJobsCard, widgetJobs } from "../lib/jobs-widget.ts";
5
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ // The Jobs sidebar card replaces the Jobs group the Status card used to print.
9
+ // Rendering is pure, so it verifies without a TUI: newest job first, one row
10
+ // per running job with its elapsed time, collapsible like Agents and Todos.
11
+
12
+ // Width math counts every painted character, so these assertions use a plain
13
+ // theme; colored roles are covered by the widget's paint calls, not here.
14
+ const plainTheme = {
15
+ fg(_color: string, text: string) {
16
+ return text;
17
+ },
18
+ bold(text: string) {
19
+ return text;
20
+ },
21
+ };
22
+
23
+ const jobs = [
24
+ { id: "job-2", label: "Watcher CI ronda 2", startedAt: 40_000 },
25
+ { id: "job-1", label: "Build runtime", startedAt: 10_000 },
26
+ ];
27
+
28
+ // The card style defaults to float; these assertions pin the outlined (neon)
29
+ // panels unless a test switches the style itself.
30
+ const initialCardStyle = cardStyle();
31
+ before(() => setCardStyle(CARD_STYLE.NEON));
32
+ after(() => setCardStyle(initialCardStyle));
33
+
34
+ test("the jobs card lists running jobs newest first with elapsed time", () => {
35
+ const lines = renderJobsCard(jobs, plainTheme, 50, 100_000, { collapsed: false, collapseControl: true });
36
+ const text = lines.join("\n");
37
+ assert.match(text, new RegExp(`${JOBS_GLYPH} Jobs`));
38
+ assert.match(text, /2 running/);
39
+ const watcher = text.indexOf("Watcher CI ronda 2");
40
+ const build = text.indexOf("Build runtime");
41
+ assert.ok(watcher >= 0, "the newest job paints");
42
+ assert.ok(build > watcher, "newest first, matching the /gentle:jobs running group");
43
+ assert.match(text, /1m 0s/, "the 60s job shows its elapsed time");
44
+ assert.match(text, /1m 30s/, "the 90s job shows its elapsed time");
45
+ assert.match(text, /▾ Collapse/);
46
+ for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`);
47
+ });
48
+
49
+ test("an empty job list paints nothing", () => {
50
+ assert.deepEqual(renderJobsCard([], plainTheme, 50, 100_000, { collapsed: false }), []);
51
+ });
52
+
53
+ test("a collapsed jobs card keeps the newest job and folds the rest", () => {
54
+ // Wide enough for the header to also fit the "ctrl+shift+j expand" hint.
55
+ const lines = renderJobsCard(jobs, plainTheme, 64, 100_000, { collapsed: true, collapseKey: "ctrl+shift+j", collapseControl: true });
56
+ const text = lines.join("\n");
57
+ assert.match(text, /Watcher CI ronda 2/);
58
+ assert.doesNotMatch(text, /Build runtime/);
59
+ assert.match(text, /… 1 more/);
60
+ assert.match(text, /ctrl\+shift\+j expand/);
61
+ assert.match(text, /▸ Expand/);
62
+ });
63
+
64
+ test("the collapse control is opt-in and the sort never mutates the snapshot", () => {
65
+ const snapshot = [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }];
66
+ const lines = renderJobsCard(snapshot, plainTheme, 50, 100_000, { collapsed: false });
67
+ assert.doesNotMatch(lines.join("\n"), /Collapse|Expand/);
68
+ const sorted = widgetJobs(snapshot, 100_000);
69
+ assert.deepEqual(snapshot, [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }]);
70
+ assert.equal(sorted[0]?.id, "job-1");
71
+ });
72
+
73
+ // Finished tails mirror the Agents card's results: an exit lingers for a
74
+ // minute with the /gentle:jobs outcome glyph, its elapsed frozen at the end.
75
+
76
+ const exitedJob = { id: "job-1", label: "Build runtime", startedAt: 10_000, status: "exited" as const, endedAt: 70_000, exitCode: 0 as number | null };
77
+
78
+ function finishedJobs(): Array<typeof exitedJob> {
79
+ return [1, 2, 3, 4].map((n) => ({ ...exitedJob, id: `job-${n}`, label: `Job ${n}`, startedAt: n * 1_000, endedAt: 100_000 + n * 1_000 }));
80
+ }
81
+
82
+ test("a finished job lingers with a ✓ row and its elapsed frozen at the end", () => {
83
+ const lines = renderJobsCard([exitedJob], plainTheme, 50, 100_000, { collapsed: false });
84
+ const text = lines.join("\n");
85
+ assert.match(text, /✓\s+Build runtime/);
86
+ assert.match(text, /1m 0s/, "elapsed freezes at endedAt instead of ticking to 1m 30s");
87
+ assert.match(text, /1 finished/);
88
+ for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`);
89
+ });
90
+
91
+ test("a nonzero exit paints ✗ and a stopped job paints –", () => {
92
+ const lines = renderJobsCard([
93
+ { ...exitedJob, exitCode: 1 },
94
+ { id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 },
95
+ ], plainTheme, 50, 100_000, { collapsed: false });
96
+ const text = lines.join("\n");
97
+ assert.match(text, /✗\s+Build runtime/);
98
+ assert.match(text, /–\s+Watcher CI/);
99
+ assert.match(text, /2 finished/);
100
+ });
101
+
102
+ test("a failed job rides the tail with the ✗ glyph", () => {
103
+ const lines = renderJobsCard([{ ...exitedJob, status: "failed" as const }], plainTheme, 50, 100_000, { collapsed: false });
104
+ const text = lines.join("\n");
105
+ assert.match(text, /✗\s+Build runtime/);
106
+ assert.match(text, /1m 0s/, "elapsed freezes at endedAt like the other outcomes");
107
+ assert.match(text, /1 finished/);
108
+ });
109
+
110
+ test("finished jobs past the TTL paint nothing", () => {
111
+ // now - endedAt lands exactly on the TTL: not fresh anymore.
112
+ assert.deepEqual(renderJobsCard([{ ...exitedJob, endedAt: 40_000 }], plainTheme, 50, 100_000, { collapsed: false }), []);
113
+ });
114
+
115
+ test("at most the three newest finished jobs survive", () => {
116
+ const jobs = finishedJobs();
117
+ const shown = widgetJobs(jobs, 105_000);
118
+ assert.deepEqual(shown.map((job) => job.id), ["job-4", "job-3", "job-2"]);
119
+ assert.deepEqual(jobs, finishedJobs(), "the TTL filter never mutates the snapshot");
120
+ });
121
+
122
+ test("running entries sort before the finished tail and never expire", () => {
123
+ const snapshot = [
124
+ { ...exitedJob, id: "done", label: "Old build", startedAt: 1_000 },
125
+ { id: "old", label: "Old run", startedAt: 1_000 },
126
+ { id: "new", label: "New run", startedAt: 50_000 },
127
+ ];
128
+ const shown = widgetJobs(snapshot, 100_000);
129
+ assert.deepEqual(shown.map((job) => job.id), ["new", "old", "done"]);
130
+ });
131
+
132
+ test("the subtitle mixes running and finished counts", () => {
133
+ const lines = renderJobsCard([
134
+ { id: "job-1", label: "New run", startedAt: 50_000 },
135
+ { id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 },
136
+ ], plainTheme, 50, 100_000, { collapsed: false });
137
+ assert.match(lines.join("\n"), /1 running · 1 finished/);
138
+ });
@@ -4,7 +4,7 @@ import * as fsPromises from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
- import { CHANNEL_SCHEMA, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
7
+ import { CHANNEL_SCHEMA, GENTLE_AI_MODULE, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
8
8
  import { requirements } from "../scripts/installer-preflight.mjs";
9
9
 
10
10
  const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
@@ -19,7 +19,8 @@ type Invocation = { command: string; prefix: string[] } | null;
19
19
  * `which` finds `<bin>/<name><suffix>`; `invocation`, when given, is the host's adapter. */
20
20
  function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell: SHELL_SHA }, tools = ["go", "pnpm", "npm"], platform = "darwin",
21
21
  bin = "/usr/bin", suffix = "", invocation = undefined as ((name: string) => Promise<Invocation>) | undefined, fs = fsPromises as typeof fsPromises,
22
- migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined } = {}) {
22
+ migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined,
23
+ failGoInstall = false, failShellInstall = false } = {}) {
23
24
  // owner "linked": `npm link` of a source checkout, so npm's global entry points outside npm's root.
24
25
  const root = realpathSync(mkdtempSync(join(tmpdir(), "upgrade-")));
25
26
  const home = join(root, "home");
@@ -50,13 +51,22 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
50
51
  }
51
52
  calls.push({ command, argv });
52
53
  if (argv[0] === "install" && argv[1]?.includes("gentle-ai/v4/cmd/gentle-ai@")) {
54
+ if (failGoInstall) return { code: 1, stdout: "" };
53
55
  mkdirSync(options.env!.GOBIN, { recursive: true });
54
56
  for (const name of ["gentle-ai", "gentle-ai.exe"]) writeFileSync(join(options.env!.GOBIN, name), "#!/bin/sh\n", { mode: 0o755 });
55
57
  return { code: 0, stdout: "" };
56
58
  }
57
59
  if (argv[0] === "version" && command === `${bin}/go`) {
60
+ if (argv[1] === "-m") {
61
+ const commit = String(argv[2] ?? "").split("/").at(-2) ?? "";
62
+ return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\tv4.0.1-0.20261008202137-${commit.slice(0, 12)}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
63
+ }
58
64
  return { code: 0, stdout: `go version go${requirements.go} ${platform}/amd64\n` };
59
65
  }
66
+ if (argv[0] === "list" && command === `${bin}/go`) {
67
+ const commit = String(argv[3] ?? "").slice(`${GENTLE_AI_MODULE}@`.length);
68
+ return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: `v4.0.1-0.20261008202137-${commit.slice(0, 12)}`, Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: commit } }) };
69
+ }
60
70
  if (argv[0] === "version") {
61
71
  const sha = command.split("/").at(-2) ?? "";
62
72
  return { code: 0, stdout: `gentle-ai 4.0.1-0.20261008202137-${sha.slice(0, 12)}\n` };
@@ -65,6 +75,7 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
65
75
  writeFileSync(join(argv[argv.indexOf("-C") + 1], "package.json"), JSON.stringify({ name: "gentle-pi", version: "4.1.0", scripts: { prepack: "x" } }));
66
76
  return { code: 0, stdout: "" };
67
77
  }
78
+ if (failShellInstall && argv.includes("-g") && argv.some((arg) => arg.endsWith(".tgz"))) return { code: 1, stdout: "" };
68
79
  if (argv.includes("pack")) {
69
80
  const version = JSON.parse(readFileSync(join(options.cwd!, "package.json"), "utf8")).version;
70
81
  writeFileSync(join(argv[argv.indexOf("--pack-destination") + 1], `gentle-pi-${version}.tgz`), "tgz");
@@ -186,6 +197,225 @@ test("a main install rebuilds only what moved on main and records the new commit
186
197
  } finally { w.cleanup(); }
187
198
  });
188
199
 
200
+ // #2356: a Gentle AI step that fails still installs a main Shell that moved, so the
201
+ // retry runs the new Shell's code; the channel is recorded only when both succeed.
202
+ const GO_INSTALL_FAILED = `main-gentle-ai-build-failed: go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@${NEW_AI} failed`;
203
+ const PRIOR_REGISTRATION = JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: "/prior/main/gentle-ai" });
204
+ const NEW_SHELL_TGZ = (home: string) => join(home, ".pi", "gentle-ai", "main", "packages", "gentle-pi-4.1.0-main.333333333333.tgz");
205
+ async function onPriorMain(w: ReturnType<typeof world>) {
206
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
207
+ writeFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), PRIOR_REGISTRATION);
208
+ return () => ({ channel: readFileSync(join(w.home, ".pi", "gentle-ai", "channel.json"), "utf8"), registration: readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8") });
209
+ }
210
+
211
+ test("a failed Gentle AI build still installs the main Shell that moved, records no channel and names the retry", posixOnly, async () => {
212
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true });
213
+ try {
214
+ const files = await onPriorMain(w);
215
+ const before = files();
216
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
217
+ assert.equal(error.code, "main-gentle-ai-build-failed");
218
+ assert.equal(error.message, `${GO_INSTALL_FAILED}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
219
+ return true;
220
+ });
221
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
222
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
223
+ assert.deepEqual(files(), before);
224
+ assert.deepEqual(JSON.parse(before.registration), JSON.parse(PRIOR_REGISTRATION));
225
+ } finally { w.cleanup(); }
226
+ });
227
+
228
+ test("a Go that cannot be obtained for the Gentle AI build still installs the main Shell that moved", posixOnly, async () => {
229
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, tools: ["pnpm", "npm"] });
230
+ try {
231
+ const files = await onPriorMain(w);
232
+ const before = files();
233
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
234
+ assert.equal(error.code, "main-requires-tools");
235
+ assert.match(error.message, /^main-requires-tools: the main channel builds Gentle AI with Go .*Unexpected Go acquisition in sandboxed upgrade fixture.*; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish$/);
236
+ return true;
237
+ });
238
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
239
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
240
+ assert.deepEqual(files(), before);
241
+ } finally { w.cleanup(); }
242
+ });
243
+
244
+ test("after a fail-forward, the retry from the new Shell builds Gentle AI and records both commits", posixOnly, async () => {
245
+ // The first copy of the built binary fails (as a rejected build would); the retry's succeeds.
246
+ let copies = 0;
247
+ const fs = { ...fsPromises, copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => {
248
+ if (copies++ === 0) throw new MainChannelError("main-gentle-ai-unverified", "first attempt rejected");
249
+ return fsPromises.copyFile(...args);
250
+ } } as typeof fsPromises;
251
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs });
252
+ try {
253
+ const files = await onPriorMain(w);
254
+ const before = files();
255
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => error.code === "main-gentle-ai-unverified");
256
+ assert.deepEqual(files(), before);
257
+ // The retry is simulated as the new Shell's run: the same runUpgrade with the new
258
+ // version (the installed package itself is not executed). No channel was recorded,
259
+ // so it rebuilds Gentle AI and repacks the Shell before recording both commits.
260
+ w.lines.length = 0;
261
+ assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
262
+ assert.deepEqual(w.lines, ["Updated to the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
263
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`, `/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
264
+ assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "main", shellCommit: NEW_SHELL, gentleAiCommit: NEW_AI });
265
+ assert.equal(JSON.parse(readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")).path,
266
+ join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", NEW_AI, "gentle-ai"));
267
+ // A third run is a no-op: both commits are current.
268
+ w.lines.length = 0;
269
+ assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
270
+ assert.deepEqual(w.lines, ["gentle-shell is already at the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
271
+ } finally { w.cleanup(); }
272
+ });
273
+
274
+ test("a failed Gentle AI build with the main Shell already current fails as before: nothing installed or recorded", posixOnly, async () => {
275
+ const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, failGoInstall: true });
276
+ try {
277
+ const files = await onPriorMain(w);
278
+ const before = files();
279
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
280
+ assert.equal(error.code, "main-gentle-ai-build-failed");
281
+ assert.equal(error.message, GO_INSTALL_FAILED);
282
+ return true;
283
+ });
284
+ assert.deepEqual(w.installs(), []);
285
+ assert.deepEqual(w.lines, []);
286
+ assert.deepEqual(files(), before);
287
+ } finally { w.cleanup(); }
288
+ });
289
+
290
+ test("when the Gentle AI build and the Shell install both fail, the Shell failure is reported with the Gentle AI one", posixOnly, async () => {
291
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true, failShellInstall: true });
292
+ try {
293
+ const files = await onPriorMain(w);
294
+ const before = files();
295
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
296
+ assert.equal(error.code, "upgrade-install-failed");
297
+ assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (${GO_INSTALL_FAILED})`);
298
+ return true;
299
+ });
300
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
301
+ assert.deepEqual(w.lines, []);
302
+ assert.deepEqual(files(), before);
303
+ } finally { w.cleanup(); }
304
+ });
305
+
306
+ // An ordinary failure that is not a MainChannelError (EACCES) keeps the retry advice and both errors.
307
+ const eacces = (what: string) => Object.assign(new Error(`EACCES: permission denied, ${what}`), { code: "EACCES" });
308
+ const COPY_DENIED = eacces("copyfile");
309
+ const MANIFEST_DENIED = eacces("open package.json");
310
+ const deniedFs = ({ copy = false, manifest = false }) => ({ ...fsPromises,
311
+ copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => { if (copy) throw COPY_DENIED; return fsPromises.copyFile(...args); },
312
+ readFile: (async (path: string, options?: unknown) => {
313
+ if (manifest && String(path).endsWith(join(".source", "src", "package.json"))) throw MANIFEST_DENIED;
314
+ return fsPromises.readFile(path, options as never);
315
+ }) as typeof fsPromises.readFile }) as typeof fsPromises;
316
+
317
+ test("an ordinary error in the Gentle AI step still installs the main Shell that moved and names the retry", posixOnly, async () => {
318
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true }) });
319
+ try {
320
+ const files = await onPriorMain(w);
321
+ const before = files();
322
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
323
+ assert.ok(error instanceof MainChannelError);
324
+ assert.equal(error.code, "main-upgrade-incomplete");
325
+ assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, copyfile; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
326
+ assert.equal(error.cause, COPY_DENIED);
327
+ return true;
328
+ });
329
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
330
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
331
+ assert.deepEqual(files(), before);
332
+ } finally { w.cleanup(); }
333
+ });
334
+
335
+ test("ordinary errors in both steps report the Shell error with the Gentle AI one", posixOnly, async () => {
336
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true, manifest: true }) });
337
+ try {
338
+ const files = await onPriorMain(w);
339
+ const before = files();
340
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
341
+ assert.ok(error instanceof MainChannelError);
342
+ assert.equal(error.code, "main-upgrade-incomplete");
343
+ assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, open package.json; Gentle AI was not updated either (EACCES: permission denied, copyfile)");
344
+ assert.equal(error.cause, MANIFEST_DENIED);
345
+ return true;
346
+ });
347
+ assert.deepEqual(w.installs(), []);
348
+ assert.deepEqual(w.lines, []);
349
+ assert.deepEqual(files(), before);
350
+ } finally { w.cleanup(); }
351
+ });
352
+
353
+ test("an ordinary error in the Gentle AI step with the main Shell already current is rethrown unchanged", posixOnly, async () => {
354
+ const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, fs: deniedFs({ copy: true }) });
355
+ try {
356
+ const files = await onPriorMain(w);
357
+ const before = files();
358
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: Error) => error === COPY_DENIED);
359
+ assert.deepEqual(w.installs(), []);
360
+ assert.deepEqual(w.lines, []);
361
+ assert.deepEqual(files(), before);
362
+ } finally { w.cleanup(); }
363
+ });
364
+
365
+ // A rejection that is not an Error, even a falsy one, is still a failed Gentle AI step.
366
+ const rejectingCopy = (value: unknown) => ({ ...fsPromises, copyFile: async () => { throw value; } }) as typeof fsPromises;
367
+
368
+ test("a falsy rejection in the Gentle AI step still records no channel and names the retry", posixOnly, async () => {
369
+ for (const value of [undefined, null, 0, false]) {
370
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(value) });
371
+ try {
372
+ const files = await onPriorMain(w);
373
+ const before = files();
374
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
375
+ assert.ok(error instanceof MainChannelError);
376
+ assert.equal(error.code, "main-upgrade-incomplete");
377
+ assert.equal(error.message, `main-upgrade-incomplete: ${String(value)}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
378
+ return true;
379
+ });
380
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
381
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
382
+ assert.deepEqual(files(), before);
383
+ } finally { w.cleanup(); }
384
+ }
385
+ });
386
+
387
+ test("a rejection that is not an Error but has a message keeps that message", posixOnly, async () => {
388
+ for (const failShellInstall of [false, true]) {
389
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy({ message: "EACCES: copy denied", code: "EACCES" }), failShellInstall });
390
+ try {
391
+ const files = await onPriorMain(w);
392
+ const before = files();
393
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
394
+ assert.equal(error.message, failShellInstall
395
+ ? `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (EACCES: copy denied)`
396
+ : "main-upgrade-incomplete: EACCES: copy denied; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
397
+ return true;
398
+ });
399
+ assert.deepEqual(files(), before);
400
+ } finally { w.cleanup(); }
401
+ }
402
+ });
403
+
404
+ test("a null rejection in the Gentle AI step is kept when the Shell install fails too", posixOnly, async () => {
405
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(null), failShellInstall: true });
406
+ try {
407
+ const files = await onPriorMain(w);
408
+ const before = files();
409
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
410
+ assert.equal(error.code, "upgrade-install-failed");
411
+ assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (null)`);
412
+ return true;
413
+ });
414
+ assert.deepEqual(w.lines, []);
415
+ assert.deepEqual(files(), before);
416
+ } finally { w.cleanup(); }
417
+ });
418
+
189
419
  test("a main install whose Gentle AI did not move rebuilds only Gentle Shell", posixOnly, async () => {
190
420
  const w = world({ commits: { ai: AI_SHA, shell: NEW_SHELL } });
191
421
  try {