gentle-pi 3.3.0 → 3.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 (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -7,6 +7,7 @@ import test from "node:test";
7
7
  import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
8
8
  import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
9
9
  import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
10
+ import { USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
10
11
  import { CHANGE_STATUS } from "../lib/shell-changes.ts";
11
12
  import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
12
13
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
@@ -129,7 +130,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
129
130
  for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
130
131
  }
131
132
 
132
- function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
133
+ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
133
134
  const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
134
135
  let resolveOverlay: () => void;
135
136
  const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
@@ -150,6 +151,9 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
150
151
  getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
151
152
  ui: {
152
153
  theme: plainTheme,
154
+ // Added only when requested: an absent select keeps the no-menu fallback
155
+ // that every pre-existing test relies on.
156
+ ...(options.select ? { select: options.select } : {}),
153
157
  setFooter(factory: unknown) {
154
158
  ui.footerFactory = factory;
155
159
  },
@@ -475,7 +479,122 @@ test("gentleShell shows working while the agent runs and queued when messages wa
475
479
  editor.dispose();
476
480
  });
477
481
 
482
+ test("animations command reports without writing and switches the live pulse", async (t) => {
483
+ const configHome = scopedDoubleEscCancelConfigHome(t);
484
+ const delays: number[] = [];
485
+ let active = 0;
486
+ t.mock.method(globalThis, "setInterval", (_callback: () => void, delay: number) => {
487
+ delays.push(delay); active++; return { unref() {} };
488
+ });
489
+ t.mock.method(globalThis, "clearInterval", () => { active--; });
490
+ const { pi, handlers, commands } = fakePi();
491
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
492
+ const { ctx, ui } = fakeContext();
493
+ const editor = installedPrompt(ctx, ui, handlers);
494
+ const command = commands.get("gentle:animations");
495
+ assert.ok(command);
496
+ await command.handler("", ctx);
497
+ assert.match(ui.notices.at(-1)!, /animations: quality/);
498
+ assert.equal(existsSync(join(configHome, "animations.json")), false);
499
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
500
+ assert.deepEqual(delays, [80]);
501
+ await command.handler("performance", ctx);
502
+ assert.deepEqual(delays, [80, 1000]);
503
+ assert.equal(active, 1);
504
+ await command.handler("potato", ctx);
505
+ assert.equal(active, 0);
506
+ assert.match(stripAnsi(editor.render(60)[0]), /working/);
507
+ assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
508
+ await command.handler("invalid", ctx);
509
+ assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
510
+ await command.handler("quality", ctx);
511
+ assert.deepEqual(delays, [80, 1000, 80]);
512
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
513
+ assert.equal(active, 0);
514
+ editor.dispose();
515
+ });
516
+
517
+ test("potato repaints start/settle and shows queued state on the host's next render without intervals", async (t) => {
518
+ const configHome = scopedDoubleEscCancelConfigHome(t);
519
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"potato"}');
520
+ const intervals = t.mock.method(globalThis, "setInterval", () => { throw new Error("potato must not animate"); });
521
+ const renders = t.mock.method(fakeTui, "requestRender", () => {});
522
+ const { pi, handlers, commands } = fakePi();
523
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
524
+ const { ctx, ui } = fakeContext();
525
+ const pending = { value: false };
526
+ (ctx as unknown as { hasPendingMessages(): boolean }).hasPendingMessages = () => pending.value;
527
+ const editor = installedPrompt(ctx, ui, handlers);
528
+ let before = renders.mock.callCount();
529
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
530
+ assert.ok(renders.mock.callCount() > before);
531
+ assert.match(stripAnsi(editor.render(60)[0]), /✿ working/);
532
+ before = renders.mock.callCount();
533
+ pending.value = true;
534
+ assert.equal(renders.mock.callCount(), before, "changing the queue flag is not a Gentle repaint event");
535
+ // Pi owns enqueue and its repaint scheduling; this fake context exposes only
536
+ // hasPendingMessages, not Pi's enqueue path. Simulate the host's render request:
537
+ // this proves next-render visibility, not real Pi enqueue-to-paint latency.
538
+ fakeTui.requestRender();
539
+ assert.equal(renders.mock.callCount(), before + 1);
540
+ assert.match(stripAnsi(editor.render(60)[0]), /✿ queued/);
541
+ before = renders.mock.callCount();
542
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
543
+ assert.ok(renders.mock.callCount() > before);
544
+ assert.doesNotMatch(stripAnsi(editor.render(60)[0]), /working|queued/);
545
+ await commands.get("gentle:animations")!.handler("status", ctx);
546
+ assert.match(ui.notices.at(-1)!, /animations: potato/);
547
+ for (const handler of handlers.get("session_shutdown") ?? []) handler({}, ctx);
548
+ assert.equal(intervals.mock.callCount(), 0);
549
+ });
550
+
551
+ test("animations status attributes malformed files and reports a failed write", async (t) => {
552
+ const configHome = scopedDoubleEscCancelConfigHome(t);
553
+ const path = join(configHome, "animations.json");
554
+ writeFileSync(path, "broken");
555
+ const { pi, commands } = fakePi();
556
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
557
+ const { ctx, ui } = fakeContext();
558
+ await commands.get("gentle:animations")!.handler("", ctx);
559
+ assert.match(ui.notices.at(-1)!, /animations: quality.*global file.*malformed/);
560
+ assert.equal(readFileSync(path, "utf8"), "broken");
561
+ rmSync(path);
562
+ mkdirSync(path);
563
+ await commands.get("gentle:animations")!.handler("potato", ctx);
564
+ assert.match(ui.notices.at(-1)!, /EISDIR|ENOTEMPTY|EPERM/);
565
+ });
566
+
567
+ test("animations with no argument opens a selectable menu and applies the chosen policy", async (t) => {
568
+ const configHome = scopedDoubleEscCancelConfigHome(t);
569
+ const path = join(configHome, "animations.json");
570
+ const { pi, commands } = fakePi();
571
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
572
+ // No editor/prompt is installed: the handler's `prompt?.setAnimationPolicy`
573
+ // optional chain must tolerate the interactive menu without one.
574
+ const chosen = fakeContext({
575
+ select: async (title, options) => {
576
+ assert.match(title, /Gentle animations/);
577
+ assert.deepEqual(options, ["quality", "performance", "potato", "status"]);
578
+ return "potato";
579
+ },
580
+ });
581
+ await commands.get("gentle:animations")!.handler("", chosen.ctx);
582
+ assert.equal(JSON.parse(readFileSync(path, "utf8")).policy, "potato");
583
+ assert.match(chosen.ui.notices.at(-1)!, /animations: potato/);
584
+
585
+ // A dismissed menu (undefined selection) reports nothing and writes nothing.
586
+ const dismissHome = scopedDoubleEscCancelConfigHome(t);
587
+ const { pi: dismissPi, commands: dismissCommands } = fakePi();
588
+ gentleShell(dismissPi, { GENTLE_PI_CONFIG_HOME: dismissHome });
589
+ const dismissed = fakeContext({ select: async () => undefined });
590
+ await dismissCommands.get("gentle:animations")!.handler("", dismissed.ctx);
591
+ assert.equal(dismissed.ui.notices.length, 0);
592
+ assert.equal(existsSync(join(dismissHome, "animations.json")), false);
593
+ });
594
+
478
595
  test("prompt uses the compact banner cadence and releases its unref timer at settlement", (t) => {
596
+ const configHome = scopedDoubleEscCancelConfigHome(t);
597
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
479
598
  const delays: number[] = [];
480
599
  let active = 0;
481
600
  let unrefs = 0;
@@ -486,7 +605,7 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
486
605
  });
487
606
  t.mock.method(globalThis, "clearInterval", () => { active--; });
488
607
  const { pi, handlers } = fakePi();
489
- gentleShell(pi, {});
608
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
490
609
  const { ctx, ui } = fakeContext();
491
610
  const editor = installedPrompt(ctx, ui, handlers);
492
611
  assert.equal(active, 0);
@@ -1594,6 +1713,240 @@ test("a provider switch refreshes the new provider inside the same window", asyn
1594
1713
  assert.equal(calls[1].url, "https://cloud-api.nan.builders/api/usage/quota");
1595
1714
  });
1596
1715
 
1716
+ // A generic hook: any extension can register a usage source for its own
1717
+ // provider on `pi.events`, and gentle-shell dispatches to it exactly like it
1718
+ // dispatches to the built-in Codex/NaN fetchers, without knowing the
1719
+ // provider's name ahead of time.
1720
+
1721
+ function acmeSource(onFetch: (apiKey: string | undefined) => void, plan = "Acme cloud"): unknown {
1722
+ return {
1723
+ schema: USAGE_SOURCE_SCHEMA,
1724
+ provider: "acme-cloud",
1725
+ fetch: async (apiKey: string | undefined) => {
1726
+ onFetch(apiKey);
1727
+ return {
1728
+ provider: "acme-cloud",
1729
+ plan,
1730
+ limits: [{ name: "acme-cloud", windows: [{ label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null }], limitReached: false }],
1731
+ fetchedAt: 0,
1732
+ };
1733
+ },
1734
+ };
1735
+ }
1736
+
1737
+ test("gentleShell fetches usage through a source registered before session start", async () => {
1738
+ const { pi, handlers } = fakePi();
1739
+ const seen: Array<string | undefined> = [];
1740
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1741
+ pi.events.emit(USAGE_SOURCE_EVENT, acmeSource((apiKey) => seen.push(apiKey)));
1742
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1743
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1744
+ await fire(handlers, "session_start", ctx);
1745
+ await new Promise((resolve) => setTimeout(resolve, 0));
1746
+ assert.deepEqual(seen, ["acme-token"], "the registered fetch gets the api key modelRegistry resolves for its provider");
1747
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/);
1748
+ });
1749
+
1750
+ test("gentleShell forces one refresh when a usage source registers after session start for the active provider", async () => {
1751
+ const { pi, handlers } = fakePi();
1752
+ let calls = 0;
1753
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1754
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1755
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1756
+ await fire(handlers, "session_start", ctx);
1757
+ await new Promise((resolve) => setTimeout(resolve, 0));
1758
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "no source registered yet, nothing to show");
1759
+
1760
+ pi.events.emit(USAGE_SOURCE_EVENT, acmeSource(() => { calls += 1; }));
1761
+ await new Promise((resolve) => setTimeout(resolve, 0));
1762
+ assert.equal(calls, 1, "late registration for the active provider triggers one forced refresh, not a wait for the next turn");
1763
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/);
1764
+ });
1765
+
1766
+ test("a usage source registered for a different provider does not force a refresh", async () => {
1767
+ const { pi, handlers } = fakePi();
1768
+ let calls = 0;
1769
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1770
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1771
+ (ctx as unknown as { model: { provider: string } }).model.provider = "openai-codex";
1772
+ await fire(handlers, "session_start", ctx);
1773
+ await new Promise((resolve) => setTimeout(resolve, 0));
1774
+
1775
+ pi.events.emit(USAGE_SOURCE_EVENT, acmeSource(() => { calls += 1; }));
1776
+ await new Promise((resolve) => setTimeout(resolve, 0));
1777
+ assert.equal(calls, 0, "the active provider is codex, so an acme-cloud registration fetches nothing yet");
1778
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/);
1779
+ });
1780
+
1781
+ test("gentleShell shows the unsupported note for a provider with no built-in or registered source", async () => {
1782
+ const { pi, handlers, commands } = fakePi();
1783
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1784
+ const { ctx, ui } = fakeContext({ token: undefined });
1785
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1786
+ await fire(handlers, "session_start", ctx);
1787
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
1788
+ await new Promise((resolve) => setTimeout(resolve, 0));
1789
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1790
+ assert.match(plain[1], /✿ acme-cloud · no subscription usage for this provider/);
1791
+ ui.closeOverlay?.();
1792
+ await opened;
1793
+ });
1794
+
1795
+ test("gentleShell ignores a malformed usage-source registration payload", async () => {
1796
+ const { pi, handlers } = fakePi();
1797
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1798
+ pi.events.emit(USAGE_SOURCE_EVENT, { schema: "wrong-schema", provider: "acme-cloud", fetch: async () => undefined });
1799
+ pi.events.emit(USAGE_SOURCE_EVENT, { schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: "not-a-function" });
1800
+ pi.events.emit(USAGE_SOURCE_EVENT, "not-an-object");
1801
+ pi.events.emit(USAGE_SOURCE_EVENT, undefined);
1802
+ const { ctx, ui } = fakeContext({ token: undefined });
1803
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1804
+ await fire(handlers, "session_start", ctx);
1805
+ await new Promise((resolve) => setTimeout(resolve, 0));
1806
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "no malformed payload registered a usable source");
1807
+ });
1808
+
1809
+ // A registered source is foreign code running inside a fire-and-forget
1810
+ // refresh (`void refreshUsage(...)`, both at session_start and on a late
1811
+ // registration): if its fetch rejects and nothing catches it, the rejection
1812
+ // is unhandled and, under Node's default policy, takes the whole process
1813
+ // down. The doc promises a foreign source is "ignored rather than crashing
1814
+ // the shell", so a throw must degrade exactly like a built-in fetcher's.
1815
+ test("a registered source's rejecting fetch never crashes the shell or poisons the store", async () => {
1816
+ const { pi, handlers, commands } = fakePi();
1817
+ let calls = 0;
1818
+ const rejectingSource = () => ({
1819
+ schema: USAGE_SOURCE_SCHEMA,
1820
+ provider: "acme-cloud",
1821
+ fetch: async () => {
1822
+ calls += 1;
1823
+ throw new Error("acme is down");
1824
+ },
1825
+ });
1826
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1827
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1828
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1829
+
1830
+ const unhandled: unknown[] = [];
1831
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
1832
+ process.on("unhandledRejection", onUnhandled);
1833
+ try {
1834
+ // Registered before session start: session_start's own fire-and-forget
1835
+ // refresh dispatches straight to the rejecting fetch.
1836
+ pi.events.emit(USAGE_SOURCE_EVENT, rejectingSource());
1837
+ await fire(handlers, "session_start", ctx);
1838
+ await new Promise((resolve) => setTimeout(resolve, 0));
1839
+ assert.equal(calls, 1);
1840
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a rejecting fetch must never be recorded as a snapshot");
1841
+
1842
+ // A second, late registration for the already-active provider dispatches
1843
+ // through the USAGE_SOURCE_EVENT handler's own fire-and-forget refresh.
1844
+ pi.events.emit(USAGE_SOURCE_EVENT, rejectingSource());
1845
+ await new Promise((resolve) => setTimeout(resolve, 0));
1846
+ assert.equal(calls, 2);
1847
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/);
1848
+ } finally {
1849
+ process.off("unhandledRejection", onUnhandled);
1850
+ }
1851
+ assert.deepEqual(unhandled, [], "a foreign source's rejection must never surface as an unhandled rejection");
1852
+
1853
+ // The panel still explains itself with the pending note, never a crash,
1854
+ // even through the awaited refresh openUsage runs on open.
1855
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
1856
+ await new Promise((resolve) => setTimeout(resolve, 0));
1857
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1858
+ assert.match(plain[1], /✿ acme-cloud · no usage yet · r to fetch/);
1859
+ ui.closeOverlay?.();
1860
+ await opened;
1861
+ });
1862
+
1863
+ // A registered source's resolved value is foreign code's own object: it must
1864
+ // be validated like any other parsed payload (never trusted to name the
1865
+ // provider it actually is, never trusted to be well-formed), and a source
1866
+ // that gets replaced mid-flight must never let its late, stale answer land
1867
+ // after the replacement already recorded its own.
1868
+
1869
+ test("a registered source resolving usage for another provider is rejected without overwriting that provider's snapshot", async () => {
1870
+ const { pi, handlers } = fakePi();
1871
+ const { fetchFn } = fakeFetch(NAN_QUOTA_PAYLOAD);
1872
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
1873
+ const { ctx, ui } = fakeContext({ token: "sk-nan-secret" });
1874
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
1875
+ await fire(handlers, "session_start", ctx);
1876
+ await new Promise((resolve) => setTimeout(resolve, 0));
1877
+ assert.match(renderFooter(ui), /nan total ▰+▱+/, "the real nan snapshot recorded first");
1878
+
1879
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1880
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1881
+ schema: USAGE_SOURCE_SCHEMA,
1882
+ provider: "acme-cloud",
1883
+ fetch: async () => ({ provider: "nan", plan: undefined, limits: [], fetchedAt: 0 }),
1884
+ });
1885
+ await new Promise((resolve) => setTimeout(resolve, 0));
1886
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a resolution naming another provider must not surface as the active one");
1887
+
1888
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
1889
+ assert.match(renderFooter(ui), /nan total ▰+▱+/, "the real nan snapshot must survive a mismatched acme-cloud resolution untouched");
1890
+ });
1891
+
1892
+ test("gentleShell leaves the pending note when a registered source resolves a malformed usage", async () => {
1893
+ const { pi, handlers, commands } = fakePi();
1894
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1895
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1896
+ schema: USAGE_SOURCE_SCHEMA,
1897
+ provider: "acme-cloud",
1898
+ fetch: async () => ({ provider: "acme-cloud", plan: "Acme", fetchedAt: 0, limits: "nope" }),
1899
+ });
1900
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1901
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1902
+ await fire(handlers, "session_start", ctx);
1903
+ await new Promise((resolve) => setTimeout(resolve, 0));
1904
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a malformed result must never be recorded");
1905
+
1906
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
1907
+ await new Promise((resolve) => setTimeout(resolve, 0));
1908
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1909
+ assert.match(plain[1], /✿ acme-cloud · no usage yet · r to fetch/);
1910
+ ui.closeOverlay?.();
1911
+ await opened;
1912
+ });
1913
+
1914
+ test("a slow fetch from a replaced source never records after its replacement resolves", async () => {
1915
+ const { pi, handlers } = fakePi();
1916
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1917
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1918
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1919
+
1920
+ let resolveSlow!: (value: unknown) => void;
1921
+ const slow = new Promise((resolve) => { resolveSlow = resolve; });
1922
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1923
+ schema: USAGE_SOURCE_SCHEMA,
1924
+ provider: "acme-cloud",
1925
+ fetch: async () => {
1926
+ await slow;
1927
+ return { provider: "acme-cloud", plan: "Stale", fetchedAt: 0, limits: [{ name: "acme-cloud", limitReached: false, windows: [{ label: "week", usedPercent: 10, windowSeconds: 604_800, resetAt: null }] }] };
1928
+ },
1929
+ });
1930
+ await fire(handlers, "session_start", ctx);
1931
+ await new Promise((resolve) => setTimeout(resolve, 0));
1932
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "the slow fetch has not resolved yet");
1933
+
1934
+ // Replace the source before the slow fetch resolves; the late registration
1935
+ // forces its own refresh, which resolves immediately.
1936
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1937
+ schema: USAGE_SOURCE_SCHEMA,
1938
+ provider: "acme-cloud",
1939
+ fetch: async () => ({ provider: "acme-cloud", plan: "Fresh", fetchedAt: 0, limits: [{ name: "acme-cloud", limitReached: false, windows: [{ label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null }] }] }),
1940
+ });
1941
+ await new Promise((resolve) => setTimeout(resolve, 0));
1942
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/, "the replacement's own refresh recorded first");
1943
+
1944
+ // Now let the stale fetch resolve; it must never overwrite the fresh record.
1945
+ resolveSlow(undefined);
1946
+ await new Promise((resolve) => setTimeout(resolve, 0));
1947
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/, "the stale refresh must never record after being replaced");
1948
+ });
1949
+
1597
1950
  test("gentleShell records SSE rate-limit headers from provider responses", async () => {
1598
1951
  const { pi, handlers } = fakePi();
1599
1952
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
@@ -6,6 +6,7 @@ import {
6
6
  INPROCESS_REVIEWER_FAILURE,
7
7
  INPROCESS_REVIEWER_OUTPUT_MAX_BYTES,
8
8
  runInProcessReviewer,
9
+ openCodeSessionAttributionHeaders,
9
10
  type InProcessReviewerOutcome,
10
11
  type InProcessReviewerRegistry,
11
12
  type InProcessReviewerRequest,
@@ -345,6 +346,97 @@ test("omits reasoning for a non-reasoning model even with a valid label", async
345
346
  assert.ok(!("reasoning" in (calls[0]!.options ?? {})));
346
347
  });
347
348
 
349
+ // ---------------------------------------------------------------------------
350
+ // OpenCode session attribution headers
351
+ //
352
+ // Pi adds OpenCode attribution headers inside the main agent loop
353
+ // (provider-attribution.js#getSessionHeaders). This completion is an extension
354
+ // side-call that bypasses that loop, so it must add the same headers itself:
355
+ // provider opencode / opencode-go, or a baseUrl whose host is opencode.ai,
356
+ // carrying the live session id — and nothing for any other provider.
357
+ // ---------------------------------------------------------------------------
358
+
359
+ const OPENCODE_ATTRIBUTION_TESTS: Array<{ readonly name: string; readonly model: Partial<Model<Api>>; readonly selection: string }> = [
360
+ { name: "an opencode provider model", model: { provider: "opencode", id: "sonnet-4", baseUrl: "https://opencode.ai" }, selection: "opencode/sonnet-4" },
361
+ { name: "an opencode-go provider model", model: { provider: "opencode-go", id: "gpt-5", baseUrl: "https://opencode.ai" }, selection: "opencode-go/gpt-5" },
362
+ { name: "a custom provider whose baseUrl host is opencode.ai", model: { provider: "custom", id: "relay-model", baseUrl: "https://opencode.ai/v1" }, selection: "custom/relay-model" },
363
+ ];
364
+
365
+ for (const { name, model, selection } of OPENCODE_ATTRIBUTION_TESTS) {
366
+ test(`${name} receives both attribution headers carrying the live session id`, async () => {
367
+ const { complete, calls } = capturingComplete(assistantText("ok"));
368
+ const outcome = await runInProcessReviewer(baseRequest({ selection, sessionId: "ses-live-1" }), {
369
+ registry: fakeRegistry([fakeModel(model)]),
370
+ complete,
371
+ });
372
+ expectText(outcome);
373
+ assert.equal(calls.length, 1);
374
+ assert.equal(calls[0]!.options?.headers?.["x-opencode-session"], "ses-live-1");
375
+ assert.equal(calls[0]!.options?.headers?.["x-opencode-client"], "pi");
376
+ });
377
+ }
378
+
379
+ for (const { name, model, selection } of OPENCODE_ATTRIBUTION_TESTS) {
380
+ test(`${name} without a session id adds no attribution header and still completes`, async () => {
381
+ const { complete, calls } = capturingComplete(assistantText("ok"));
382
+ const outcome = await runInProcessReviewer(baseRequest({ selection }), {
383
+ registry: fakeRegistry([fakeModel(model)]),
384
+ complete,
385
+ });
386
+ expectText(outcome);
387
+ assert.equal(calls.length, 1);
388
+ assert.ok(!("headers" in (calls[0]!.options ?? {})), "a missing session id must never invent a header");
389
+ });
390
+ }
391
+
392
+ test("a non-OpenCode model adds no attribution headers and leaves the options unchanged", async () => {
393
+ const { complete, calls } = capturingComplete(assistantText("ok"));
394
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "anthropic/claude-sonnet-4", sessionId: "ses-live-1" }), {
395
+ registry: fakeRegistry([fakeModel({ provider: "anthropic", id: "claude-sonnet-4", baseUrl: "https://api.anthropic.com" })]),
396
+ complete,
397
+ });
398
+ expectText(outcome);
399
+ assert.equal(calls.length, 1);
400
+ assert.ok(!("headers" in (calls[0]!.options ?? {})));
401
+ });
402
+
403
+ test("registry auth headers win over the attribution defaults and both survive the merge", async () => {
404
+ const { complete, calls } = capturingComplete(assistantText("ok"));
405
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "opencode/sonnet-4", sessionId: "ses-live-1" }), {
406
+ registry: fakeRegistry([fakeModel({ provider: "opencode", id: "sonnet-4" })], async () => ({
407
+ ok: true,
408
+ headers: { "x-api-key": "registry-key", "x-opencode-session": "registry-session" },
409
+ })),
410
+ complete,
411
+ });
412
+ expectText(outcome);
413
+ const headers = calls[0]!.options?.headers;
414
+ assert.equal(headers?.["x-api-key"], "registry-key", "registry auth headers must survive");
415
+ assert.equal(headers?.["x-opencode-session"], "registry-session", "explicit registry headers must not be clobbered by the attribution default");
416
+ assert.equal(headers?.["x-opencode-client"], "pi");
417
+ });
418
+
419
+ test("an unparseable baseUrl never throws: attribution follows the provider condition only", async () => {
420
+ const { complete, calls } = capturingComplete(assistantText("ok"));
421
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "custom/relay-model", sessionId: "ses-live-1" }), {
422
+ registry: fakeRegistry([fakeModel({ provider: "custom", id: "relay-model", baseUrl: "not-a-url" })]),
423
+ complete,
424
+ });
425
+ expectText(outcome);
426
+ assert.ok(!("headers" in (calls[0]!.options ?? {})));
427
+ });
428
+
429
+ test("openCodeSessionAttributionHeaders mirrors pi's condition exactly", () => {
430
+ const opencode = fakeModel({ provider: "opencode", baseUrl: "https://example.com" });
431
+ assert.deepEqual(openCodeSessionAttributionHeaders(opencode, "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
432
+ assert.deepEqual(openCodeSessionAttributionHeaders(fakeModel({ provider: "opencode-go" }), "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
433
+ assert.deepEqual(openCodeSessionAttributionHeaders(fakeModel({ provider: "custom", baseUrl: "https://opencode.ai/v1" }), "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
434
+ assert.equal(openCodeSessionAttributionHeaders(fakeModel({ provider: "custom", baseUrl: "https://api.opencode.ai" }), "ses-1"), undefined, "a subdomain host is not opencode.ai");
435
+ assert.equal(openCodeSessionAttributionHeaders(fakeModel(), "ses-1"), undefined);
436
+ assert.equal(openCodeSessionAttributionHeaders(opencode, undefined), undefined, "no session id, no header");
437
+ assert.equal(openCodeSessionAttributionHeaders(opencode, ""), undefined, "an empty session id is no session id");
438
+ });
439
+
348
440
  // ---------------------------------------------------------------------------
349
441
  // Text concatenation ignoring thinking parts
350
442
  // ---------------------------------------------------------------------------
@@ -0,0 +1,99 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+
8
+ // isPiManagedInstall gates the POSTINSTALL entry point only (scripts/install-gentle-ai.mjs):
9
+ // it decides whether the running gentle-pi package sits under a directory
10
+ // pattern Pi's own package manager creates (npm-backed or git-backed), so an
11
+ // `npm install -g gentle-pi`, a plain development git checkout, or an npx
12
+ // cache directory never writes to the user's global Pi settings. It is
13
+ // deliberately unrelated to installTuiModeSetting's own ownership check
14
+ // (which compares the package location against one specific resolved agent
15
+ // home) and to installIsolatedTuiModeSetting (gentle-shell's own bootstrap of
16
+ // a directory it just created), covered in the other two test files.
17
+
18
+ const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
19
+ const { isPiManagedInstall } = await import(helperUrl.href);
20
+
21
+ test("isPiManagedInstall recognizes the user-scope npm-managed layout", () => {
22
+ assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "npm", "node_modules", "gentle-pi")), true);
23
+ });
24
+
25
+ test("isPiManagedInstall recognizes the project-scope npm-managed layout", () => {
26
+ assert.equal(isPiManagedInstall(join("/repo", ".pi", "npm", "node_modules", "gentle-pi")), true);
27
+ });
28
+
29
+ test("isPiManagedInstall recognizes Pi's git-managed layout", () => {
30
+ assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "git", "github.com", "Gentleman-Programming", "gentle-pi")), true);
31
+ });
32
+
33
+ test("isPiManagedInstall rejects a global npm -g install", () => {
34
+ assert.equal(isPiManagedInstall("/usr/local/lib/node_modules/gentle-pi"), false);
35
+ });
36
+
37
+ test("isPiManagedInstall rejects a plain development git checkout", () => {
38
+ assert.equal(isPiManagedInstall("/Users/alan/work/gentle-pi"), false);
39
+ });
40
+
41
+ test("isPiManagedInstall rejects an npx cache directory", () => {
42
+ assert.equal(isPiManagedInstall(join("/home/alan", ".npm", "_npx", "abc123", "node_modules", "gentle-pi")), false);
43
+ });
44
+
45
+ test("isPiManagedInstall rejects a pnpm content-addressable store", () => {
46
+ assert.equal(isPiManagedInstall(join("/home/alan", ".pnpm", "gentle-pi@1.0.0", "node_modules", "gentle-pi")), false);
47
+ });
48
+
49
+ test("isPiManagedInstall resolves a relative path before checking", () => {
50
+ const cwd = process.cwd();
51
+ try {
52
+ process.chdir(join("/tmp"));
53
+ assert.equal(isPiManagedInstall(join(".", "npm", "node_modules", "gentle-pi")), true);
54
+ } finally {
55
+ process.chdir(cwd);
56
+ }
57
+ });
58
+
59
+ // --- postinstall entry integration --------------------------------------------
60
+
61
+ function fixture(t: test.TestContext, packageSegments: readonly string[]) {
62
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-guard-"));
63
+ t.after(() => rmSync(root, { recursive: true, force: true }));
64
+ const home = join(root, "agent");
65
+ const packageRoot = join(home, ...packageSegments);
66
+ const scripts = join(packageRoot, "scripts");
67
+ mkdirSync(scripts, { recursive: true });
68
+ copyFileSync(helperUrl, join(scripts, "install-tui-mode-setting.mjs"));
69
+ copyFileSync(new URL("../scripts/install-gentle-ai.mjs", import.meta.url), join(scripts, "install-gentle-ai.mjs"));
70
+ writeFileSync(
71
+ join(scripts, "gentle-ai-installer.mjs"),
72
+ 'export const INSTALLER_VERSION = "test"; export async function installGentleAi() { return { installed: true, binaryPath: "fixture" }; }',
73
+ );
74
+ const settings = join(home, "settings.json");
75
+ const env = { ...process.env, GENTLE_PI_AGENT_HOME: home, GENTLE_PI_SKIP_GENTLE_AI_INSTALL: "0" };
76
+ return { root, home, packageRoot, scripts, settings, env };
77
+ }
78
+
79
+ test("postinstall skips the global settings write outside a pi-managed install", (t) => {
80
+ const f = fixture(t, ["node_modules", "gentle-pi"]);
81
+ const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
82
+ assert.equal(result.status, 0, result.stderr);
83
+ assert.match(`${result.stdout}${result.stderr}`, /skip/i);
84
+ assert.equal(existsSync(f.settings), false);
85
+ });
86
+
87
+ test("postinstall still writes fullscreen for a pi-managed npm install", (t) => {
88
+ const f = fixture(t, ["npm", "node_modules", "gentle-pi"]);
89
+ const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
90
+ assert.equal(result.status, 0, result.stderr);
91
+ assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
92
+ });
93
+
94
+ test("postinstall still writes fullscreen for a pi-managed git install", (t) => {
95
+ const f = fixture(t, ["git", "github.com", "Gentleman-Programming", "gentle-pi"]);
96
+ const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
97
+ assert.equal(result.status, 0, result.stderr);
98
+ assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
99
+ });
@@ -7,7 +7,7 @@ import { join } from "node:path";
7
7
  import { test } from "node:test";
8
8
 
9
9
  const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
10
- const { installTuiModeSetting } = await import(helperUrl.href);
10
+ const { installTuiModeSetting, installIsolatedTuiModeSetting } = await import(helperUrl.href);
11
11
 
12
12
  function fixture(t: { after(fn: () => void): void }, packagePath: readonly string[] = ["npm", "node_modules", "gentle-pi"]) {
13
13
  const root = mkdtempSync(join(tmpdir(), "gentle-tui-test-"));
@@ -269,6 +269,44 @@ for (const fault of ["write", "rename", "concurrent-change"]) {
269
269
  });
270
270
  }
271
271
 
272
+ // --- installIsolatedTuiModeSetting -------------------------------------------
273
+ // gentle-shell's own isolated-home bootstrap (T2): unlike installTuiModeSetting,
274
+ // there is no "physically installed under this home's npm/node_modules"
275
+ // ownership check to satisfy, because gentle-shell just created `dir` itself
276
+ // moments ago; a plain agent-home-shaped temp directory is enough.
277
+
278
+ test("installIsolatedTuiModeSetting writes fullscreen into a freshly created directory", async (t) => {
279
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
280
+ t.after(() => rmSync(root, { recursive: true, force: true }));
281
+ const dir = join(root, "gentle-shell", "agent");
282
+ mkdirSync(dir, { recursive: true });
283
+ assert.deepEqual(await installIsolatedTuiModeSetting(dir), { changed: true, recognized: true });
284
+ assert.deepEqual(JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")), { tuiMode: "fullscreen" });
285
+ });
286
+
287
+ test("installIsolatedTuiModeSetting preserves other settings fields already present", async (t) => {
288
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
289
+ t.after(() => rmSync(root, { recursive: true, force: true }));
290
+ writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "regular", theme: "rose" }));
291
+ assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: true, recognized: true });
292
+ assert.deepEqual(JSON.parse(readFileSync(join(root, "settings.json"), "utf8")), { tuiMode: "fullscreen", theme: "rose" });
293
+ });
294
+
295
+ test("installIsolatedTuiModeSetting is a no-op when already fullscreen", async (t) => {
296
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
297
+ t.after(() => rmSync(root, { recursive: true, force: true }));
298
+ writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
299
+ const before = lstatSync(join(root, "settings.json"));
300
+ assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: false, recognized: true });
301
+ assert.equal(lstatSync(join(root, "settings.json")).ino, before.ino);
302
+ });
303
+
304
+ test("installIsolatedTuiModeSetting rejects a missing directory", async (t) => {
305
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
306
+ t.after(() => rmSync(root, { recursive: true, force: true }));
307
+ await assert.rejects(installIsolatedTuiModeSetting(join(root, "absent")));
308
+ });
309
+
272
310
  type PostinstallRoute = "skip" | "success" | "failure";
273
311
 
274
312
  function writePostinstallFixture(f: ReturnType<typeof fixture>, route: PostinstallRoute) {