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.
- package/README.md +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/rpc-host.ts +36 -0
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +161 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/rpc-host.test.ts +77 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- 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
|
|
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
|
|
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
|
package/lib/review-host-relay.ts
CHANGED
|
@@ -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
|
|
package/lib/rpc-host.ts
ADDED
|
@@ -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
|
|
100
|
-
//
|
|
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
|
-
|
|
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:
|
|
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]();
|
package/lib/shell-usage-view.ts
CHANGED
|
@@ -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 }));
|
package/lib/shell-usage.ts
CHANGED
|
@@ -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]:
|
|
122
|
-
[NAN_PROVIDER]:
|
|
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
|
-
|
|
133
|
-
|
|
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
|
+
"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/",
|