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
@@ -0,0 +1,141 @@
1
+ import {
2
+ CUSTOM_ROW_LABEL,
3
+ MAX_HEADER_LENGTH,
4
+ MAX_LABEL_LENGTH,
5
+ MAX_OPTIONS,
6
+ MAX_QUESTIONS,
7
+ MIN_OPTIONS,
8
+ type QuestionParams,
9
+ } from "./schema.ts";
10
+
11
+ /**
12
+ * A single questionnaire validation violation, keyed by `code` so callers can
13
+ * branch without parsing the human-readable message.
14
+ */
15
+ export type QuestionnaireError =
16
+ | { code: "no_questions"; message: string }
17
+ | { code: "empty_options"; message: string; questionIndex: number }
18
+ | { code: "option_count"; message: string; questionIndex: number; count: number }
19
+ | { code: "too_many_questions"; message: string; count: number }
20
+ | { code: "duplicate_question"; message: string; questionIndex: number }
21
+ | { code: "duplicate_option_label"; message: string; questionIndex: number; optionIndex: number; label: string }
22
+ | { code: "label_too_long"; message: string; questionIndex: number; optionIndex: number; length: number }
23
+ | { code: "header_too_long"; message: string; questionIndex: number; length: number }
24
+ | { code: "reserved_label"; message: string; questionIndex: number; optionIndex: number; label: string };
25
+
26
+ /**
27
+ * Validate questionnaire parameters and return the first violation in a fixed
28
+ * precedence order, or `undefined` when the parameters are valid.
29
+ *
30
+ * Precedence: no questions, empty options, option count, question count,
31
+ * duplicate question text, duplicate option label, label length, header
32
+ * length, then reserved custom-row labels.
33
+ */
34
+ export function validateQuestionnaire(params: QuestionParams): QuestionnaireError | undefined {
35
+ const questions = Array.isArray(params?.questions) ? params.questions : [];
36
+
37
+ if (questions.length === 0) {
38
+ return { code: "no_questions", message: "At least one question is required." };
39
+ }
40
+
41
+ for (const [questionIndex, question] of questions.entries()) {
42
+ if (!Array.isArray(question?.options) || question.options.length === 0) {
43
+ return {
44
+ code: "empty_options",
45
+ message: `Question ${questionIndex + 1} must declare at least one option.`,
46
+ questionIndex,
47
+ };
48
+ }
49
+ }
50
+
51
+ for (const [questionIndex, question] of questions.entries()) {
52
+ const count = question.options.length;
53
+ if (count < MIN_OPTIONS || count > MAX_OPTIONS) {
54
+ return {
55
+ code: "option_count",
56
+ message: `Question ${questionIndex + 1} must have between ${MIN_OPTIONS} and ${MAX_OPTIONS} options, received ${count}.`,
57
+ questionIndex,
58
+ count,
59
+ };
60
+ }
61
+ }
62
+
63
+ if (questions.length > MAX_QUESTIONS) {
64
+ return {
65
+ code: "too_many_questions",
66
+ message: `A questionnaire accepts at most ${MAX_QUESTIONS} questions, received ${questions.length}.`,
67
+ count: questions.length,
68
+ };
69
+ }
70
+
71
+ const seenQuestions = new Set<string>();
72
+ for (const [questionIndex, question] of questions.entries()) {
73
+ if (seenQuestions.has(question.question)) {
74
+ return {
75
+ code: "duplicate_question",
76
+ message: `Question ${questionIndex + 1} duplicates an earlier question text.`,
77
+ questionIndex,
78
+ };
79
+ }
80
+ seenQuestions.add(question.question);
81
+ }
82
+
83
+ // Checked after duplicate questions so a repeated question points at the
84
+ // duplicate itself rather than one of its option labels.
85
+ for (const [questionIndex, question] of questions.entries()) {
86
+ const seenLabels = new Set<string>();
87
+ for (const [optionIndex, option] of question.options.entries()) {
88
+ if (seenLabels.has(option.label)) {
89
+ return {
90
+ code: "duplicate_option_label",
91
+ message: `Question ${questionIndex + 1} option ${optionIndex + 1} duplicates the label "${option.label}".`,
92
+ questionIndex,
93
+ optionIndex,
94
+ label: option.label,
95
+ };
96
+ }
97
+ seenLabels.add(option.label);
98
+ }
99
+ }
100
+
101
+ for (const [questionIndex, question] of questions.entries()) {
102
+ for (const [optionIndex, option] of question.options.entries()) {
103
+ if (option.label.length > MAX_LABEL_LENGTH) {
104
+ return {
105
+ code: "label_too_long",
106
+ message: `Question ${questionIndex + 1} option ${optionIndex + 1} label exceeds ${MAX_LABEL_LENGTH} characters.`,
107
+ questionIndex,
108
+ optionIndex,
109
+ length: option.label.length,
110
+ };
111
+ }
112
+ }
113
+ }
114
+
115
+ for (const [questionIndex, question] of questions.entries()) {
116
+ if (question.header.length > MAX_HEADER_LENGTH) {
117
+ return {
118
+ code: "header_too_long",
119
+ message: `Question ${questionIndex + 1} header exceeds ${MAX_HEADER_LENGTH} characters.`,
120
+ questionIndex,
121
+ length: question.header.length,
122
+ };
123
+ }
124
+ }
125
+
126
+ for (const [questionIndex, question] of questions.entries()) {
127
+ for (const [optionIndex, option] of question.options.entries()) {
128
+ if (option.label === "Other" || option.label === CUSTOM_ROW_LABEL) {
129
+ return {
130
+ code: "reserved_label",
131
+ message: `Question ${questionIndex + 1} option ${optionIndex + 1} uses the reserved label "${option.label}".`,
132
+ questionIndex,
133
+ optionIndex,
134
+ label: option.label,
135
+ };
136
+ }
137
+ }
138
+ }
139
+
140
+ return undefined;
141
+ }
@@ -27,6 +27,21 @@ const WINDOWS_SYSTEM = "S-1-5-18";
27
27
  const WINDOWS_ADMINISTRATORS = "S-1-5-32-544";
28
28
  const WINDOWS_SYSTEM_DIRECTORY = "\\\\?\\GLOBALROOT\\SystemRoot\\System32";
29
29
 
30
+ // Cold Windows runners occasionally exceed the bounded 5-second probe timeout
31
+ // on first process start (whoami/PowerShell/icacls). One bounded retry of an
32
+ // idempotent system probe recovers that transient; authority validation is
33
+ // never retried or weakened because it happens after the probe returns.
34
+ const TRANSIENT_WINDOWS_PROBE_CODES = new Set(["ETIMEDOUT", "EAGAIN", "EBUSY"]);
35
+
36
+ export function withWindowsSystemProbeRetry<T>(probe: () => T): T {
37
+ try {
38
+ return probe();
39
+ } catch (error) {
40
+ if (!TRANSIENT_WINDOWS_PROBE_CODES.has((error as NodeJS.ErrnoException | undefined)?.code ?? "")) throw error;
41
+ return probe();
42
+ }
43
+ }
44
+
30
45
  function windowsSystemExecutable(name: "whoami.exe" | "icacls.exe" | "WindowsPowerShell\\v1.0\\powershell.exe"): string {
31
46
  try {
32
47
  return realpathSync.native(join(WINDOWS_SYSTEM_DIRECTORY, name));
@@ -36,7 +51,7 @@ function windowsSystemExecutable(name: "whoami.exe" | "icacls.exe" | "WindowsPow
36
51
  }
37
52
 
38
53
  function windowsUserSid(): string {
39
- const output = execFileSync(windowsSystemExecutable("whoami.exe"), ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
54
+ const output = withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("whoami.exe"), ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
40
55
  const matches = output.match(/S-\d+(?:-\d+)+/gi) ?? [];
41
56
  if (matches.length !== 1) throw new Error("Windows user SID is unavailable");
42
57
  return matches[0]!.toUpperCase();
@@ -45,7 +60,7 @@ function windowsUserSid(): string {
45
60
  function windowsLocalAdministratorSid(): string {
46
61
  const script = "$ErrorActionPreference='Stop';$descriptor=New-Object System.Security.AccessControl.RawSecurityDescriptor 'D:(A;;FA;;;LA)';$descriptor.DiscretionaryAcl[0].SecurityIdentifier.Value";
47
62
  const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
48
- const output = execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot } });
63
+ const output = withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot } }));
49
64
  const matches = output.match(/S-\d+(?:-\d+)+/gi) ?? [];
50
65
  if (matches.length !== 1 || !isWindowsSid(matches[0]!)) throw new Error("Windows local Administrator SID is unavailable");
51
66
  return matches[0]!.toUpperCase();
@@ -55,7 +70,7 @@ function windowsDacl(path: string): string {
55
70
  const archive = `.gentle-ai-acl-${randomUUID()}.txt`;
56
71
  const archivePath = join(dirname(path), archive);
57
72
  try {
58
- execFileSync(windowsSystemExecutable("icacls.exe"), [path, "/save", archive, "/c"], { cwd: dirname(path), encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
73
+ withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("icacls.exe"), [path, "/save", archive, "/c"], { cwd: dirname(path), encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
59
74
  const sddl = readFileSync(archivePath, "utf16le").match(/D:[^\r\n]+/)?.[0];
60
75
  if (sddl === undefined) throw new Error("Windows DACL is unavailable");
61
76
  return sddl;
@@ -151,7 +166,7 @@ function windowsOwnerSid(path: string, kind: WindowsObjectKind): string {
151
166
  const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
152
167
  let output: string;
153
168
  try {
154
- output = execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_OWNER_PATH: path } });
169
+ output = withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_OWNER_PATH: path } }));
155
170
  } catch {
156
171
  throw new WindowsOwnerValidationError();
157
172
  }
@@ -175,7 +190,7 @@ function enforcePrivateWindowsDacl(path: string, identity: WindowsAclIdentity =
175
190
  const sddl = `D:P(A;OICI;FA;;;${user})(A;OICI;FA;;;${WINDOWS_SYSTEM})(A;OICI;FA;;;${WINDOWS_ADMINISTRATORS})`;
176
191
  const script = "$ErrorActionPreference='Stop';$acl=New-Object System.Security.AccessControl.DirectorySecurity;$acl.SetSecurityDescriptorSddlForm($env:GENTLE_PI_CANDIDATE_ACL_SDDL,[System.Security.AccessControl.AccessControlSections]::Access);[System.IO.Directory]::SetAccessControl($env:GENTLE_PI_CANDIDATE_ACL_PATH,$acl)";
177
192
  const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
178
- execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_ACL_PATH: path, GENTLE_PI_CANDIDATE_ACL_SDDL: sddl } });
193
+ withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_ACL_PATH: path, GENTLE_PI_CANDIDATE_ACL_SDDL: sddl } }));
179
194
  assertPrivateWindowsDacl(path, "directory", true, identity);
180
195
  }
181
196
 
@@ -634,6 +634,13 @@ function makeWritableForCleanup(path: string): void {
634
634
  chmodSync(path, 0o644);
635
635
  }
636
636
 
637
+ function candidateOwnerPreparationError(error: unknown): CandidateViewError {
638
+ // Surface the bounded errno of the underlying failure directly in the
639
+ // message so CI logs are self-diagnosing without TAP printing the cause.
640
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
641
+ return new CandidateViewError(`candidate view owner preparation failed${typeof code === "string" && code ? ` (${code})` : ""}`, "candidate-owner-preparation-failed", undefined, { cause: error });
642
+ }
643
+
637
644
  function candidateViewParent(commonDir: string, platform: NodeJS.Platform): string {
638
645
  const control = join(commonDir, "gentle-ai");
639
646
  mkdirSync(control, { recursive: true, mode: 0o700 });
@@ -646,7 +653,7 @@ function candidateViewParent(commonDir: string, platform: NodeJS.Platform): stri
646
653
  try {
647
654
  return prepareCandidateOwnerParent(commonDir, platform);
648
655
  } catch (error) {
649
- throw new CandidateViewError("candidate view owner preparation failed", "candidate-owner-preparation-failed", undefined, { cause: error });
656
+ throw candidateOwnerPreparationError(error);
650
657
  }
651
658
  }
652
659
 
@@ -916,7 +923,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
916
923
  try {
917
924
  owner = createCandidateOwner(canonicalCommonDir, root, platform);
918
925
  } catch (error) {
919
- throw new CandidateViewError("candidate view owner preparation failed", "candidate-owner-preparation-failed", undefined, { cause: error });
926
+ throw candidateOwnerPreparationError(error);
920
927
  }
921
928
  // The worktree is created under the same try/catch cleanup boundary as
922
929
  // the read-tree materialization that follows. addUnbornWorktree's
@@ -386,6 +386,14 @@ export interface ReviewHostRelayRequest {
386
386
  readonly selection?: string;
387
387
  /** The routing entry's thinking label, forwarded verbatim to the completion. */
388
388
  readonly thinking?: string;
389
+ /**
390
+ * The caller's live pi session id, forwarded into the in-process completion
391
+ * request so an OpenCode-routed reviewer model carries its
392
+ * `x-opencode-session` attribution header (a side-call bypasses pi's main
393
+ * agent loop, where pi adds those headers itself). Absent forwards nothing:
394
+ * no invented header, no error.
395
+ */
396
+ readonly reviewerSessionId?: string;
389
397
  /** Names the routing config key (e.g. "review-risk") in refusal messages; defaults to a generic label when absent. */
390
398
  readonly routingKey?: string;
391
399
  /**
@@ -608,6 +616,7 @@ function validateReviewerSelectionConfiguration(request: ReviewHostRelayRequest)
608
616
  reviewerRegistry: request.reviewerRegistry,
609
617
  selection: request.selection,
610
618
  ...(request.thinking === undefined ? {} : { thinking: request.thinking }),
619
+ ...(request.reviewerSessionId === undefined ? {} : { reviewerSessionId: request.reviewerSessionId }),
611
620
  routingKey,
612
621
  };
613
622
  }
@@ -728,6 +737,7 @@ export async function prepareReviewHostRelaySlot(
728
737
  {
729
738
  selection: preparedRequest.selection!,
730
739
  ...(preparedRequest.thinking === undefined ? {} : { thinking: preparedRequest.thinking }),
740
+ ...(preparedRequest.reviewerSessionId === undefined ? {} : { sessionId: preparedRequest.reviewerSessionId }),
731
741
  prompt: promptBytes,
732
742
  timeoutMs: piTimeoutMs,
733
743
  ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
@@ -310,6 +310,7 @@ export interface ChangedPathEntry {
310
310
  readonly typeChanged: boolean;
311
311
  readonly modeOnly: boolean;
312
312
  readonly intendedUntracked: boolean;
313
+ readonly generated?: true;
313
314
  }
314
315
 
315
316
  export interface ReviewArtifactSubjectV2 {
@@ -1150,7 +1151,8 @@ export function decodeReviewArtifactSubjectV2(value: unknown): ReviewArtifactSub
1150
1151
  }
1151
1152
 
1152
1153
  function decodeChangedPathEntry(value: unknown, label: string): ChangedPathEntry {
1153
- const body = exactRecord(value, label, ["path", "status", "old_mode", "new_mode", "deleted", "type_changed", "mode_only", "intended_untracked"]);
1154
+ const body = exactRecord(value, label, ["path", "status", "old_mode", "new_mode", "deleted", "type_changed", "mode_only", "intended_untracked"], ["generated"]);
1155
+ if (body.generated !== undefined && body.generated !== true) throw new TypeError(`${label}.generated must be true when present`);
1154
1156
  return {
1155
1157
  path: nonempty(body.path, `${label}.path`),
1156
1158
  status: enumeration(body.status, ["A", "D", "M", "T"] as const, `${label}.status`),
@@ -1160,6 +1162,7 @@ function decodeChangedPathEntry(value: unknown, label: string): ChangedPathEntry
1160
1162
  typeChanged: boolean(body.type_changed, `${label}.type_changed`),
1161
1163
  modeOnly: boolean(body.mode_only, `${label}.mode_only`),
1162
1164
  intendedUntracked: boolean(body.intended_untracked, `${label}.intended_untracked`),
1165
+ ...(body.generated === undefined ? {} : { generated: true }),
1163
1166
  };
1164
1167
  }
1165
1168
 
@@ -0,0 +1,36 @@
1
+ // Interactive RPC host signal: the desktop app sets this environment variable
2
+ // on the pi process it spawns directly (`--mode rpc`), letting Gentle answer
3
+ // ask-user tools through pi's RPC dialogs and publish live subagent state.
4
+ // `lib/agents-runner.ts` strips it from every subagent child env so headless
5
+ // RPC children never see it and stay unaffected by this feature.
6
+
7
+ /** Environment variable the desktop app sets on its own interactive pi process. */
8
+ export const INTERACTIVE_HOST_ENV = "GENTLE_SHELL_INTERACTIVE_HOST";
9
+
10
+ /**
11
+ * True only when running under `--mode rpc` with the interactive host
12
+ * variable set to exactly `"1"`. Any other value, or its absence, keeps RPC
13
+ * headless (the existing subagent behaviour).
14
+ */
15
+ export function isInteractiveRpcHost(mode: string, env: NodeJS.ProcessEnv = process.env): boolean {
16
+ return mode === "rpc" && env[INTERACTIVE_HOST_ENV] === "1";
17
+ }
18
+
19
+ /**
20
+ * True for the interactive TUI, or for an interactive RPC host. Extensions
21
+ * use this to offer dialog-capable behaviour without special-casing RPC.
22
+ */
23
+ export function isInteractiveMode(mode: string, env: NodeJS.ProcessEnv = process.env): boolean {
24
+ return mode === "tui" || isInteractiveRpcHost(mode, env);
25
+ }
26
+
27
+ /**
28
+ * Copy of `env` with the interactive host variable removed. Used when
29
+ * assembling a subagent child's environment so it never inherits the
30
+ * parent's interactive-host signal, even when the parent itself is one.
31
+ */
32
+ export function withoutInteractiveHost(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
33
+ const next = { ...env };
34
+ delete next[INTERACTIVE_HOST_ENV];
35
+ return next;
36
+ }
package/lib/shell-bar.ts CHANGED
@@ -305,6 +305,19 @@ export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarThe
305
305
  return { text: visibleWidth(brand) <= targetWidth ? brand : "" };
306
306
  }
307
307
 
308
+ // The rule row painted directly under the header bar: one full-width horizontal
309
+ // line in the same theme role as the editor frame (PROMPT_FRAME_ROLE in
310
+ // extensions/gentle-shell.ts), so the status row and the prompt read as one
311
+ // panel. It exists only while the fullscreen sidebar is active — when the
312
+ // sidebar is not shown the header rail never renders and the rule goes away
313
+ // with it.
314
+ const HEADER_RULE_CHAR = "─";
315
+ const HEADER_RULE_ROLE = "border";
316
+
317
+ export function renderShellHeaderRule(theme: ShellBarTheme, width: number): string {
318
+ return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(Math.max(0, Math.floor(width))));
319
+ }
320
+
308
321
  export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
309
322
  let segments = buildSegments(model, theme);
310
323
  const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
@@ -68,6 +68,7 @@ function railDigest(rail: SidebarRail): string | undefined {
68
68
  }
69
69
  }
70
70
 
71
+ /** Installs the fullscreen rail: wraps the host layout root with the [rail, transcript] hstack and returns a disposer restoring the original layout. */
71
72
  export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
72
73
  if (!tui.terminal) return () => {};
73
74
  const host = tui as Host;
@@ -96,8 +97,10 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
96
97
  for (const part of state.parts.values()) part.invalidate();
97
98
  },
98
99
  };
99
- // The header row: a plain leaf component, one line tall, painted above the
100
- // hstack when a "header" part is registered and has something to show.
100
+ // The header row: a plain leaf component measured from its rendered lines
101
+ // (one line with just the status bar; two once the rule row joins it),
102
+ // painted full-width above the hstack when a "header" part is registered
103
+ // and has something to show.
101
104
  // The header is not inside the rail's ScrollView, so it never goes through
102
105
  // dispatchPartMouse: it is its own leaf in the layout tree (no [NODE]),
103
106
  // and pi-tui's mouse dispatch (tui-alt-screen.js dispatchMouseToLayout)
@@ -113,7 +116,10 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
113
116
  follow: "none",
114
117
  primary: false,
115
118
  overscroll: "contain",
116
- scrollbar: "always",
119
+ // "always" re-slices the scrollbar column of every rail line on every
120
+ // render pass (grapheme measurement per row). "auto" keeps the rail
121
+ // scrollbar transient like pi's own fullscreen scrollbar.
122
+ scrollbar: "auto",
117
123
  scrollbarTrackStyle: (text) => theme.fg("border", text),
118
124
  scrollbarThumbStyle: (text) => theme.fg("accent", text),
119
125
  });
@@ -277,7 +283,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
277
283
  if (current.presentation?.scrollTop === scroll.scrollTop) return current.presentation.output;
278
284
  const output: LayoutNode = current.headerActive
279
285
  ? { type: "vstack", gap: 0, align: "stretch", entries: [
280
- { component: header, basis: 1, grow: 0, shrink: 0, minSize: 1 },
286
+ { component: header, basis: "auto", grow: 0, shrink: 0, minSize: 1 },
281
287
  { component: hstackHost, basis: 0, grow: 1, shrink: 1, minSize: 1 },
282
288
  ] }
283
289
  : hstackHost[NODE]();
@@ -1,5 +1,5 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
- import { renderUsagePanel, type ActiveProvider, type UsageStore, type UsageTheme } from "./shell-usage.ts";
2
+ import { renderUsagePanel, type ActiveProvider, type UsageSourceRegistry, type UsageStore, type UsageTheme } from "./shell-usage.ts";
3
3
  import { paintHoverable } from "./shell-hover.ts";
4
4
 
5
5
  // Gentle Shell subscriptions overlay: a framed panel over the usage store.
@@ -9,6 +9,9 @@ export interface UsageViewDeps {
9
9
  theme: UsageTheme;
10
10
  now(): number;
11
11
  active(): ActiveProvider | undefined;
12
+ // Optional: lets the panel resolve the pending note of a provider whose
13
+ // usage source was registered at runtime instead of built in.
14
+ registry?(): UsageSourceRegistry | undefined;
12
15
  onRefresh(): Promise<void>;
13
16
  onClose(): void;
14
17
  requestRender(): void;
@@ -98,7 +101,7 @@ export class UsageView {
98
101
  const inner = width - 2;
99
102
  const title = this.refreshing ? REFRESHING : TITLE;
100
103
  const top = theme.fg(FRAME_ROLE, "╭─ ") + theme.fg(TITLE_ROLE, title) + theme.fg(FRAME_ROLE, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
101
- const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active()).map(
104
+ const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.()).map(
102
105
  (line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
103
106
  );
104
107
  const hints = KEYS.map(([key, label]) => ({ key, label, text: `${key} ${label}`, action: (key === "r" ? "refresh" : "close") as HintAction }));
@@ -117,9 +117,10 @@ const ROLE = {
117
117
  } as const;
118
118
  export const USAGE_EMPTY_MESSAGE = "No subscription usage yet. Usage arrives with the next response, or press r to fetch it.";
119
119
  export const SUPPORTED_USAGE_PROVIDERS: readonly string[] = [CODEX_PROVIDER, ANTHROPIC_PROVIDER, NAN_PROVIDER];
120
+ const DEFAULT_PENDING_NOTE = "no usage yet · r to fetch";
120
121
  const PENDING_NOTE: Record<string, string> = {
121
- [CODEX_PROVIDER]: "no usage yet · r to fetch",
122
- [NAN_PROVIDER]: "no usage yet · r to fetch",
122
+ [CODEX_PROVIDER]: DEFAULT_PENDING_NOTE,
123
+ [NAN_PROVIDER]: DEFAULT_PENDING_NOTE,
123
124
  [ANTHROPIC_PROVIDER]: "usage arrives with the first response",
124
125
  };
125
126
  const UNSUPPORTED_NOTE = "no subscription usage for this provider";
@@ -129,8 +130,121 @@ export interface ActiveProvider {
129
130
  provider: string;
130
131
  }
131
132
 
132
- export function providerNote(provider: string): string {
133
- return PENDING_NOTE[provider] ?? UNSUPPORTED_NOTE;
133
+ // A generic hook so an extension holding its own provider (its own API token,
134
+ // its own usage endpoint) can plug a usage source into the shell without the
135
+ // shell ever knowing that provider's name. Emitted on `pi.events` as payload
136
+ // under `USAGE_SOURCE_EVENT`; gentle-shell validates the shape below and
137
+ // ignores anything else, so a malformed or foreign event never reaches a
138
+ // fetch call. Re-registration for the same provider replaces the previous
139
+ // source, so a second `session_start` emitting the same payload is a no-op
140
+ // in effect, not an accumulation.
141
+ export const USAGE_SOURCE_EVENT = "gentle-pi:usage-source/v1";
142
+ export const USAGE_SOURCE_SCHEMA = "gentle-pi.usage-source/v1";
143
+ const USAGE_SOURCE_PROVIDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
144
+
145
+ export interface UsageSource {
146
+ schema: typeof USAGE_SOURCE_SCHEMA;
147
+ provider: string;
148
+ pendingNote?: string;
149
+ fetch(apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined>;
150
+ }
151
+
152
+ // Pure and defensive: the payload crosses an event bus from another
153
+ // extension, so nothing here is trusted until every field is checked. Any
154
+ // mismatch returns undefined rather than throwing, exactly like the other
155
+ // payload parsers in this file.
156
+ export function parseUsageSource(value: unknown): UsageSource | undefined {
157
+ if (!value || typeof value !== "object") return undefined;
158
+ const raw = value as Record<string, unknown>;
159
+ if (raw.schema !== USAGE_SOURCE_SCHEMA) return undefined;
160
+ if (typeof raw.provider !== "string" || !USAGE_SOURCE_PROVIDER_PATTERN.test(raw.provider)) return undefined;
161
+ if (typeof raw.fetch !== "function") return undefined;
162
+ if (raw.pendingNote !== undefined && typeof raw.pendingNote !== "string") return undefined;
163
+ const source: UsageSource = { schema: USAGE_SOURCE_SCHEMA, provider: raw.provider, fetch: raw.fetch as UsageSource["fetch"] };
164
+ if (typeof raw.pendingNote === "string") source.pendingNote = raw.pendingNote;
165
+ return source;
166
+ }
167
+
168
+ // One source per provider, most recent registration wins. Nothing here fetches
169
+ // or touches the network; it only remembers who to ask.
170
+ export class UsageSourceRegistry {
171
+ private readonly sources = new Map<string, UsageSource>();
172
+
173
+ register(source: UsageSource): void {
174
+ this.sources.set(source.provider, source);
175
+ }
176
+
177
+ get(provider: string): UsageSource | undefined {
178
+ return this.sources.get(provider);
179
+ }
180
+
181
+ has(provider: string): boolean {
182
+ return this.sources.has(provider);
183
+ }
184
+
185
+ note(provider: string): string | undefined {
186
+ const source = this.sources.get(provider);
187
+ return source ? (source.pendingNote ?? DEFAULT_PENDING_NOTE) : undefined;
188
+ }
189
+ }
190
+
191
+ export function providerNote(provider: string, registry?: UsageSourceRegistry): string {
192
+ return PENDING_NOTE[provider] ?? registry?.note(provider) ?? UNSUPPORTED_NOTE;
193
+ }
194
+
195
+ function isFiniteNumber(value: unknown): value is number {
196
+ return typeof value === "number" && Number.isFinite(value);
197
+ }
198
+
199
+ function parseSourceUsageWindow(value: unknown): UsageWindow | undefined {
200
+ if (!value || typeof value !== "object") return undefined;
201
+ const raw = value as Record<string, unknown>;
202
+ if (typeof raw.label !== "string") return undefined;
203
+ if (!isFiniteNumber(raw.usedPercent)) return undefined;
204
+ if (!isFiniteNumber(raw.windowSeconds)) return undefined;
205
+ if (raw.resetAt !== null && !isFiniteNumber(raw.resetAt)) return undefined;
206
+ if (raw.used !== undefined && !isFiniteNumber(raw.used)) return undefined;
207
+ if (raw.budget !== undefined && !isFiniteNumber(raw.budget)) return undefined;
208
+ const window: UsageWindow = { label: raw.label, usedPercent: raw.usedPercent, windowSeconds: raw.windowSeconds, resetAt: raw.resetAt as number | null };
209
+ if (raw.used !== undefined) window.used = raw.used as number;
210
+ if (raw.budget !== undefined) window.budget = raw.budget as number;
211
+ return window;
212
+ }
213
+
214
+ function parseSourceUsageLimit(value: unknown): UsageLimit | undefined {
215
+ if (!value || typeof value !== "object") return undefined;
216
+ const raw = value as Record<string, unknown>;
217
+ if (typeof raw.name !== "string") return undefined;
218
+ if (typeof raw.limitReached !== "boolean") return undefined;
219
+ if (!Array.isArray(raw.windows)) return undefined;
220
+ const windows: UsageWindow[] = [];
221
+ for (const entry of raw.windows) {
222
+ const window = parseSourceUsageWindow(entry);
223
+ if (!window) return undefined;
224
+ windows.push(window);
225
+ }
226
+ return { name: raw.name, limitReached: raw.limitReached, windows };
227
+ }
228
+
229
+ // A registered source's resolved value crosses the same trust boundary a
230
+ // parsed HTTP payload does: it is foreign code's own object, so it is
231
+ // validated field by field and never recorded by reference. Every accepted
232
+ // shape is rebuilt from scratch, so a source mutating its own object after
233
+ // returning it can never reach a snapshot gentle-shell already recorded.
234
+ export function parseProviderUsage(value: unknown, expectedProvider: string): ProviderUsage | undefined {
235
+ if (!value || typeof value !== "object") return undefined;
236
+ const raw = value as Record<string, unknown>;
237
+ if (raw.provider !== expectedProvider) return undefined;
238
+ if (raw.plan !== undefined && typeof raw.plan !== "string") return undefined;
239
+ if (!isFiniteNumber(raw.fetchedAt)) return undefined;
240
+ if (!Array.isArray(raw.limits)) return undefined;
241
+ const limits: UsageLimit[] = [];
242
+ for (const entry of raw.limits) {
243
+ const limit = parseSourceUsageLimit(entry);
244
+ if (!limit) return undefined;
245
+ limits.push(limit);
246
+ }
247
+ return { provider: raw.provider, plan: typeof raw.plan === "string" ? raw.plan : undefined, limits, fetchedAt: raw.fetchedAt };
134
248
  }
135
249
 
136
250
  export function windowLabel(seconds: number): string {
@@ -415,13 +529,13 @@ function updatedAgo(fetchedAt: number, now: number): string {
415
529
 
416
530
  // The active provider comes first, marked with the petal, and explains
417
531
  // itself when it has no data yet. Other providers seen this session follow.
418
- export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider): string[] {
532
+ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry): string[] {
419
533
  const activeUsage = active ? usages.find((usage) => usage.provider === active.provider) : undefined;
420
534
  const others = usages.filter((usage) => usage !== activeUsage);
421
535
  if (!active && usages.length === 0) return [truncateToWidth(USAGE_EMPTY_MESSAGE, width, "…")];
422
536
  const lines: string[] = [];
423
537
  if (active && !activeUsage) {
424
- lines.push(`${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ${theme.fg(ROLE.PROVIDER, active.provider)} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, providerNote(active.provider))}`);
538
+ lines.push(`${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ${theme.fg(ROLE.PROVIDER, active.provider)} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, providerNote(active.provider, registry))}`);
425
539
  }
426
540
  for (const usage of [...(activeUsage ? [activeUsage] : []), ...others]) {
427
541
  const mark = usage === activeUsage ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "bin": {
8
+ "gentle-shell": "bin/gentle-shell.mjs"
9
+ },
7
10
  "keywords": [
8
11
  "pi-package",
9
12
  "pi",
@@ -24,6 +27,7 @@
24
27
  },
25
28
  "files": [
26
29
  "assets/",
30
+ "bin/",
27
31
  "contracts/",
28
32
  "docs/",
29
33
  "extensions/",