gentle-pi 3.3.0 → 3.4.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 (69) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +26 -2
  10. package/docs/readme-reference.md +32 -6
  11. package/extensions/ask-user-question.ts +210 -0
  12. package/extensions/gentle-agents.ts +8 -1
  13. package/extensions/gentle-ai.ts +59 -18
  14. package/extensions/gentle-shell.ts +99 -10
  15. package/extensions/quiet-tools.ts +28 -5
  16. package/extensions/startup-banner.ts +25 -10
  17. package/lib/animation-policy.ts +52 -0
  18. package/lib/background-cache-warming.ts +38 -0
  19. package/lib/command-palette-catalog.ts +1 -0
  20. package/lib/inprocess-reviewer.ts +38 -1
  21. package/lib/native-review-cli.ts +36 -10
  22. package/lib/questionnaire/questionnaire-view.ts +603 -0
  23. package/lib/questionnaire/schema.ts +82 -0
  24. package/lib/questionnaire/validate.ts +141 -0
  25. package/lib/review-candidate-view-owner.ts +20 -5
  26. package/lib/review-candidate-view.ts +9 -2
  27. package/lib/review-host-relay.ts +10 -0
  28. package/lib/review-integration-v2.ts +4 -1
  29. package/lib/shell-bar.ts +13 -0
  30. package/lib/shell-sidebar-layout.ts +10 -4
  31. package/lib/shell-usage-view.ts +5 -2
  32. package/lib/shell-usage.ts +120 -6
  33. package/package.json +1 -1
  34. package/runtime/native-review-cli.mjs +35 -9
  35. package/runtime/review-integration-v2.mjs +4 -1
  36. package/scripts/gentle-ai-installer.mjs +10 -10
  37. package/scripts/verify-package-files.mjs +2 -3
  38. package/tests/animation-policy.test.ts +42 -0
  39. package/tests/ask-user-question.test.ts +435 -0
  40. package/tests/background-cache-warming.test.ts +60 -0
  41. package/tests/background-subagents.test.ts +68 -0
  42. package/tests/command-palette.test.ts +9 -0
  43. package/tests/gentle-agents.test.ts +71 -2
  44. package/tests/gentle-ai-binary.test.ts +1 -1
  45. package/tests/gentle-ai-installer.test.ts +47 -47
  46. package/tests/gentle-ai.test.ts +56 -4
  47. package/tests/gentle-shell.test.ts +355 -2
  48. package/tests/inprocess-reviewer.test.ts +92 -0
  49. package/tests/native-review-capability-contract.test.ts +16 -1
  50. package/tests/native-review-parity.test.ts +19 -0
  51. package/tests/package-manifest.test.ts +6 -17
  52. package/tests/questionnaire-schema.test.ts +274 -0
  53. package/tests/questionnaire-view.test.ts +446 -0
  54. package/tests/rdd-status-line.test.ts +21 -4
  55. package/tests/review-candidate-owner-retry.test.ts +63 -0
  56. package/tests/review-candidate-view.test.ts +15 -0
  57. package/tests/review-controller-native-routing.test.ts +86 -0
  58. package/tests/review-host-relay.test.ts +21 -0
  59. package/tests/review-integration-v2.test.ts +30 -0
  60. package/tests/review-ledger-contract.test.ts +1 -2
  61. package/tests/review-relay-transport-agent.test.ts +107 -2
  62. package/tests/review-risk-assessment.test.ts +104 -0
  63. package/tests/shell-bar.test.ts +8 -0
  64. package/tests/shell-sidebar-layout.test.ts +60 -5
  65. package/tests/shell-usage.test.ts +129 -0
  66. package/tests/skill-collision-prefixes.test.ts +1 -1
  67. package/tests/startup-banner.test.ts +93 -2
  68. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  69. 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
  // ---------------------------------------------------------------------------
@@ -319,11 +319,26 @@ test("3.4.0 repeats 3.2.1 because the negotiated lane Pi consumes is unchanged",
319
319
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.2.1"] as Record<string, boolean>);
320
320
  });
321
321
 
322
+ test("3.5.0 repeats 3.4.0 because the negotiated lane Pi consumes is unchanged", () => {
323
+ // Published v3.4.0 and v3.5.0 provider bundles are byte-identical at
324
+ // contract 1.2.0, both with archive SHA-256:
325
+ // 547b68e172cc87aa297309d61624e5fc2c24d407a494b53eeb5a2b053904352c.
326
+ // Both binaries advertise capabilities/v2.6; only build-identity fields
327
+ // differ. No review-integration schema changed between the tags. The v2
328
+ // preflight failure identity fix changes no closed START/STATUS field
329
+ // this row negotiates, so it repeats 3.4.0. riskEvidence and hint remain
330
+ // dark because neither is proven to reach Pi's negotiated START path.
331
+ const contract = NATIVE_CLI_CONTRACTS["3.5.0"] as Record<string, boolean>;
332
+ assert.equal(contract.riskEvidence, false);
333
+ assert.equal(contract.hint, false);
334
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.4.0"] as Record<string, boolean>);
335
+ });
336
+
322
337
  test("no shipped version key was added beyond the pin bump", () => {
323
338
  // Rows are promises to consumers, so a new key only ever appears in a
324
339
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
325
340
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
326
341
  // so they get no row: a row asserts ground truth measured against a binary
327
342
  // Pi actually ran, and the table only has to be ascending, not gapless.
328
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0"]);
343
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0"]);
329
344
  });
@@ -344,6 +344,25 @@ function assertStaleConsentBinding(outcome: Record<string, unknown>, binding: un
344
344
  assert.equal(outcome.next_action, "restart-for-fresh-consent");
345
345
  }
346
346
 
347
+ test("synthetic default-ON status reaches review consent without changing RDD mode", async (t) => {
348
+ const cwd = repository(t);
349
+ const fixture = consentNative(cwd);
350
+ const operations: string[] = [];
351
+ fixture.native.reviewMode = async ({ operation }) => {
352
+ operations.push(operation);
353
+ return {
354
+ operation: "status",
355
+ scope: "clone",
356
+ status: { global: "", cloneLocal: "", effective: "on", source: "default" },
357
+ };
358
+ };
359
+ await beginConsent(parityRuntime(fixture.native), cwd);
360
+ assert.equal(fixture.starts.count, 1, "default ON must not skip native review START");
361
+ assert.ok(operations.length > 0);
362
+ assert.ok(operations.every((operation) => operation === "status"), "automation must only read RDD mode");
363
+ assert.deepEqual(fixture.answers, [], "default ON does not grant candidate consent");
364
+ });
365
+
347
366
  test("review-mode gate retains every off-source continuation and fails closed on native errors", async () => {
348
367
  for (const [source, expected] of [
349
368
  ["clone_local", /clear this clone-local override/],
@@ -142,7 +142,6 @@ test("package verification names the native review runtime boundary and packaged
142
142
 
143
143
  test("npm publication is bound to the exact package tag and triggering commit", () => {
144
144
  const workflow = readFileSync(join(PACKAGE_ROOT, ".github", "workflows", "publish.yml"), "utf8");
145
- const releaseSkill = readFileSync(join(PACKAGE_ROOT, "skills", "release", "SKILL.md"), "utf8");
146
145
  const packageJson = readPackageJson();
147
146
  const dispatchBlock = workflow.match(
148
147
  /^ {2}workflow_dispatch:\n([\s\S]*?)^\npermissions:/m,
@@ -189,16 +188,6 @@ test("npm publication is bound to the exact package tag and triggering commit",
189
188
  );
190
189
  assert.match(workflow, /npm publish --provenance --access public/);
191
190
  assert.doesNotMatch(workflow, /pnpm publish|--no-git-checks|NODE_AUTH_TOKEN/);
192
-
193
- assert.match(releaseSkill, /tag="v\$\{version\}"/);
194
- assert.match(releaseSkill, /release_sha="\$\(git rev-parse 'origin\/main\^\{commit\}'\)"/);
195
- assert.match(releaseSkill, /git rev-parse "\$\{tag\}\^\{commit\}"/);
196
- assert.match(releaseSkill, /git fetch --no-tags origin "refs\/tags\/\$\{tag\}"/);
197
- assert.match(releaseSkill, /gh release create "\$\{tag\}"[\s\S]*--verify-tag/);
198
- assert.match(releaseSkill, /--ref main/);
199
- assert.match(releaseSkill, /-f tag="\$\{tag\}"/);
200
- assert.match(releaseSkill, /trusted OIDC with provenance/);
201
- assert.doesNotMatch(releaseSkill, /--ref "\$\{tag\}"|-f dist-tag=/);
202
191
  });
203
192
 
204
193
  test("Pi delivery relay is absent from the packaged extension", () => {
@@ -309,20 +298,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
309
298
  );
310
299
  });
311
300
 
312
- test("package verification binds the published Gentle AI v3.4.0 runtime pin", () => {
301
+ test("package verification binds the published Gentle AI v3.5.0 runtime pin", () => {
313
302
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
314
303
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
315
304
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
316
305
 
317
- assert.match(installer, /INSTALLER_VERSION = "3\.4\.0"/);
306
+ assert.match(installer, /INSTALLER_VERSION = "3\.5\.0"/);
318
307
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
319
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:bQOM\+Xa3WN5\+idTagEk15rgEEQQWVhZvq8BPF\/FHtSs="/);
308
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:3y\+Nb7CtgGB3ne3bSNxkzV6j19etkOnGDXIiuv7tMp8="/);
320
309
  assert.match(installer, /GOTOOLCHAIN: "local"/);
321
310
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
322
311
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
323
312
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
324
313
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
325
- assert.match(verifier, /v3\.4\.0/);
314
+ assert.match(verifier, /v3\.5\.0/);
326
315
  });
327
316
 
328
317
 
@@ -1574,9 +1563,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1574
1563
  assert.match(wrapper, /quietToolsEnabled/);
1575
1564
  });
1576
1565
 
1577
- test("Gentle Shell v3.3.0 package and runtime stop before publication", () => {
1566
+ test("Gentle Shell v3.4.0 package and runtime stop before publication", () => {
1578
1567
  const packageJson = readPackageJson();
1579
- assert.equal(packageJson.version, "3.3.0", "the release manifest must remain explicitly pinned to v3.3.0");
1568
+ assert.equal(packageJson.version, "3.4.0", "the release manifest must remain explicitly pinned to v3.4.0");
1580
1569
  assert.equal(
1581
1570
  packageJson.scripts?.test,
1582
1571
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",