gentle-pi 3.5.1 → 3.7.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 +8 -1
- package/assets/orchestrator-delegation.md +2 -0
- package/bin/gentle-shell.mjs +900 -23
- package/docs/gentle-shell.md +5 -4
- package/docs/readme-reference.md +53 -39
- package/extensions/gentle-agents.ts +89 -22
- package/extensions/gentle-ai.ts +27 -48
- package/lib/agents-runner.ts +9 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/gentle-shell-launcher.ts +314 -2
- package/lib/inprocess-reviewer.ts +54 -6
- package/lib/native-review-cli.ts +16 -0
- package/lib/session-change-capture.ts +10 -1
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +313 -1
- package/runtime/native-review-cli.mjs +16 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-tui-mode-setting.mjs +21 -2
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +22 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/gentle-agents.test.ts +362 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +54 -49
- package/tests/gentle-ai.test.ts +147 -2
- package/tests/gentle-shell-bin.test.ts +1739 -4
- package/tests/gentle-shell-launcher.test.ts +394 -0
- package/tests/inprocess-reviewer.test.ts +179 -0
- package/tests/install-tui-mode-setting.test.ts +22 -4
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/odd-runtime-delegation-gate.test.ts +18 -197
- package/tests/package-manifest.test.ts +6 -6
- package/tests/runtime-harness.mjs +1 -2
- package/tests/session-change-capture.test.ts +12 -1
- package/lib/odd-runtime-delegation-gate.ts +0 -88
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
|
|
2
2
|
import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
|
|
3
|
-
import { OddRuntimeDelegationGate } from "../lib/odd-runtime-delegation-gate.ts";
|
|
4
3
|
import { resolveResearchCapabilities, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
|
|
5
4
|
import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
|
|
6
5
|
import { execFileSync } from "node:child_process";
|
|
@@ -8,12 +7,10 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
|
8
7
|
import {
|
|
9
8
|
existsSync,
|
|
10
9
|
lstatSync,
|
|
11
|
-
mkdtempSync,
|
|
12
10
|
mkdirSync,
|
|
13
11
|
readdirSync,
|
|
14
12
|
readFileSync,
|
|
15
13
|
realpathSync,
|
|
16
|
-
rmSync,
|
|
17
14
|
writeFileSync,
|
|
18
15
|
} from "node:fs";
|
|
19
16
|
import {
|
|
@@ -23,7 +20,7 @@ import {
|
|
|
23
20
|
readdir,
|
|
24
21
|
writeFile,
|
|
25
22
|
} from "node:fs/promises";
|
|
26
|
-
import { homedir
|
|
23
|
+
import { homedir } from "node:os";
|
|
27
24
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
28
25
|
import { fileURLToPath } from "node:url";
|
|
29
26
|
import type {
|
|
@@ -197,7 +194,7 @@ import {
|
|
|
197
194
|
nativeReviewLegacyQuarantineAuthorization,
|
|
198
195
|
nativeReviewReconcileAuthorization,
|
|
199
196
|
nativeReviewRecoverAuthorization,
|
|
200
|
-
|
|
197
|
+
|
|
201
198
|
NativeReviewCliError,
|
|
202
199
|
nativeUntrackedSelection,
|
|
203
200
|
NativeReviewConsentBindingError,
|
|
@@ -2439,24 +2436,6 @@ function builtinAgentDirs(cwd: string): string[] {
|
|
|
2439
2436
|
];
|
|
2440
2437
|
}
|
|
2441
2438
|
|
|
2442
|
-
function listBuiltinAgentNames(cwd: string): Set<string> {
|
|
2443
|
-
return new Set(
|
|
2444
|
-
builtinAgentDirs(cwd).flatMap((dir) =>
|
|
2445
|
-
listAgentsFromDir(dir, "builtin").map((agent) => agent.name),
|
|
2446
|
-
),
|
|
2447
|
-
);
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
|
|
2451
|
-
const names = new Set<string>();
|
|
2452
|
-
for (const dir of builtinAgentDirs(cwd)) {
|
|
2453
|
-
for (const agent of await listAgentsFromDirAsync(dir, "builtin")) {
|
|
2454
|
-
names.add(agent.name);
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
return names;
|
|
2458
|
-
}
|
|
2459
|
-
|
|
2460
2439
|
function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
2461
2440
|
const builtinDirs = builtinAgentDirs(cwd);
|
|
2462
2441
|
const agents = [
|
|
@@ -2837,7 +2816,20 @@ function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
|
2837
2816
|
}
|
|
2838
2817
|
|
|
2839
2818
|
async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
|
|
2840
|
-
const
|
|
2819
|
+
const registry = ctx.modelRegistry;
|
|
2820
|
+
if (!registry) {
|
|
2821
|
+
return [...MODEL_CONTROL_OPTIONS];
|
|
2822
|
+
}
|
|
2823
|
+
let raw: unknown;
|
|
2824
|
+
try {
|
|
2825
|
+
raw = await registry.getAvailable();
|
|
2826
|
+
} catch {
|
|
2827
|
+
return [...MODEL_CONTROL_OPTIONS];
|
|
2828
|
+
}
|
|
2829
|
+
if (!Array.isArray(raw)) {
|
|
2830
|
+
return [...MODEL_CONTROL_OPTIONS];
|
|
2831
|
+
}
|
|
2832
|
+
const models = raw as { provider: string; id: string }[];
|
|
2841
2833
|
const modelIds = models
|
|
2842
2834
|
.map((model) => normalizeModelId(`${model.provider}/${model.id}`))
|
|
2843
2835
|
.filter((model): model is string => model !== undefined)
|
|
@@ -4164,7 +4156,14 @@ async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession,
|
|
|
4164
4156
|
const reference = parseOrchestratorModelRef(entry.model);
|
|
4165
4157
|
if (reference === undefined) return "";
|
|
4166
4158
|
const label = `${reference.provider}/${reference.model}`;
|
|
4167
|
-
const
|
|
4159
|
+
const registry = ctx.modelRegistry;
|
|
4160
|
+
if (!registry) {
|
|
4161
|
+
if (ctx.hasUI && ctx.ui.notify) {
|
|
4162
|
+
ctx.ui.notify("Model registry unavailable; this session keeps its current model.", "warning");
|
|
4163
|
+
}
|
|
4164
|
+
return `\nModel registry unavailable; this session keeps its current model.`;
|
|
4165
|
+
}
|
|
4166
|
+
const model = registry.find(reference.provider, reference.model);
|
|
4168
4167
|
if (model === undefined) return `\n${label} is not in the model catalog; this session keeps its current model.`;
|
|
4169
4168
|
let switched = false;
|
|
4170
4169
|
try {
|
|
@@ -5145,14 +5144,6 @@ function isReviewTransition(value: string): value is ReviewTransition {
|
|
|
5145
5144
|
return Object.values(REVIEW_TRANSITION).some((transition) => transition === value);
|
|
5146
5145
|
}
|
|
5147
5146
|
|
|
5148
|
-
function isGraphV1JudgmentDayLineage(cwd: string, lineageId: string): boolean {
|
|
5149
|
-
try {
|
|
5150
|
-
return ReviewTransactionStore.forRepository(cwd).read(lineageId).mode === REVIEW_MODE.JUDGMENT_DAY;
|
|
5151
|
-
} catch {
|
|
5152
|
-
return false;
|
|
5153
|
-
}
|
|
5154
|
-
}
|
|
5155
|
-
|
|
5156
5147
|
interface NativeStartPreAuthorityRejection {
|
|
5157
5148
|
lineage_created: false;
|
|
5158
5149
|
mutation_performed: false;
|
|
@@ -8699,6 +8690,9 @@ export const __testing = {
|
|
|
8699
8690
|
readSddChangeFlag,
|
|
8700
8691
|
resetTelemetryTriggerGuardForTesting,
|
|
8701
8692
|
createGentleAiExtension: createGentleAiExtensionForTesting,
|
|
8693
|
+
getPiModelOptions,
|
|
8694
|
+
MODEL_CONTROL_OPTIONS,
|
|
8695
|
+
switchLiveOrchestrator,
|
|
8702
8696
|
};
|
|
8703
8697
|
|
|
8704
8698
|
export interface GentleAiRuntimeDependencies {
|
|
@@ -8759,11 +8753,6 @@ function createGentleAiExtensionForTesting(
|
|
|
8759
8753
|
const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
|
|
8760
8754
|
const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
|
|
8761
8755
|
const permissionEnvironment = dependencies.processEnv ?? process.env;
|
|
8762
|
-
const oddDelegationGate = new OddRuntimeDelegationGate();
|
|
8763
|
-
const oddSessionId = (ctx: ExtensionContext): string => {
|
|
8764
|
-
try { return ctx.sessionManager.getSessionId(); }
|
|
8765
|
-
catch { return ""; }
|
|
8766
|
-
};
|
|
8767
8756
|
|
|
8768
8757
|
const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
|
|
8769
8758
|
try {
|
|
@@ -9169,10 +9158,6 @@ function createGentleAiExtensionForTesting(
|
|
|
9169
9158
|
const retiredSync = readAgentStartNames(event).includes("sdd-sync") || /\bSDD sync executor\b/i.test(event.systemPrompt ?? "");
|
|
9170
9159
|
const isSddAgent = retiredSync || isSddAgentStartEvent(event);
|
|
9171
9160
|
const isNamedAgent = isNamedAgentStartEvent(event);
|
|
9172
|
-
oddDelegationGate.start(
|
|
9173
|
-
oddSessionId(ctx),
|
|
9174
|
-
!isNamedAgent && !isSddAgent && permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1",
|
|
9175
|
-
);
|
|
9176
9161
|
const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
9177
9162
|
if (isSddAgent || isNamedAgent) {
|
|
9178
9163
|
processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
|
|
@@ -9282,7 +9267,6 @@ function createGentleAiExtensionForTesting(
|
|
|
9282
9267
|
// consent, or chooses a partial candidate. Durable own-mutation receipts
|
|
9283
9268
|
// gate STATUS and consume only the generation captured before that await.
|
|
9284
9269
|
pi.on("agent_end", async (_event, ctx) => {
|
|
9285
|
-
oddDelegationGate.endChild(oddSessionId(ctx));
|
|
9286
9270
|
if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
|
|
9287
9271
|
if (ctx.hasUI !== true || !reminderSessionActive) return;
|
|
9288
9272
|
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
@@ -9318,7 +9302,6 @@ function createGentleAiExtensionForTesting(
|
|
|
9318
9302
|
pi.on("tool_result", (event, ctx) => {
|
|
9319
9303
|
if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
|
|
9320
9304
|
if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return;
|
|
9321
|
-
oddDelegationGate.recordSuccess(oddSessionId(ctx), event.toolName, event.input, ctx.cwd);
|
|
9322
9305
|
try {
|
|
9323
9306
|
const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root;
|
|
9324
9307
|
if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId });
|
|
@@ -9332,10 +9315,6 @@ function createGentleAiExtensionForTesting(
|
|
|
9332
9315
|
event.input,
|
|
9333
9316
|
);
|
|
9334
9317
|
if (sensitivePathDenied) return sensitivePathDenied;
|
|
9335
|
-
const oddDelegationDenied = oddDelegationGate.beforeTool(
|
|
9336
|
-
oddSessionId(ctx), event.toolName, event.input, ctx.cwd, readActiveToolNames(pi),
|
|
9337
|
-
);
|
|
9338
|
-
if (oddDelegationDenied) return oddDelegationDenied;
|
|
9339
9318
|
if (event.toolName === "subagent_run") {
|
|
9340
9319
|
const sddAgent = sddDispatchAgentName(event.input);
|
|
9341
9320
|
if (sddAgent === "invalid") {
|
package/lib/agents-runner.ts
CHANGED
|
@@ -166,6 +166,9 @@ export interface TaskRequest {
|
|
|
166
166
|
// Untrusted narrowing intent; paths come only from matching host provenance.
|
|
167
167
|
researchSelection?: unknown;
|
|
168
168
|
extensionPaths?: string[];
|
|
169
|
+
// Synchronous admission recheck at dequeue, before any OS spawn. Throws fail
|
|
170
|
+
// only this task; unlike onLaunch, it must never persist Changes evidence.
|
|
171
|
+
beforeSpawn?: () => void;
|
|
169
172
|
// Captures the originating session; invoked only after successful OS spawn.
|
|
170
173
|
onLaunch?: () => void;
|
|
171
174
|
/** Default off. Parent owns policy before opting into bounded local buffering,
|
|
@@ -489,6 +492,12 @@ export class AgentRunner {
|
|
|
489
492
|
// A child that cannot start (missing pi, bad cwd) fails only its task:
|
|
490
493
|
// spawn exceptions and process errors settle without uncaught host errors.
|
|
491
494
|
private launch(id: string, request: TaskRequest): void {
|
|
495
|
+
try { request.beforeSpawn?.(); }
|
|
496
|
+
catch (error) {
|
|
497
|
+
this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
|
|
498
|
+
this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error instanceof Error ? error.message : String(error)}`);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
492
501
|
const detached = this.processControl.platform !== "win32";
|
|
493
502
|
const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
|
|
494
503
|
const permissionChannelStdio = this.processControl.platform === "win32" ? "overlapped" : "pipe";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { WorktreeIdentity } from "./session-worktree-registry.ts";
|
|
3
|
+
|
|
4
|
+
// Deliberately ephemeral: no session entries or disk persistence can restore consent.
|
|
5
|
+
export class ForeignTargetGrants {
|
|
6
|
+
private readonly grants = new Map<string, Set<string>>();
|
|
7
|
+
private manager?: ExtensionContext["sessionManager"];
|
|
8
|
+
private sessionId?: string;
|
|
9
|
+
|
|
10
|
+
async authorize(ctx: Pick<ExtensionContext, "sessionManager" | "hasUI" | "ui">, target: WorktreeIdentity, options: { continuation?: boolean; signal?: AbortSignal } = {}): Promise<void> {
|
|
11
|
+
if (options.signal?.aborted) throw new Error("Foreign clone authorization aborted.");
|
|
12
|
+
const id = ctx.sessionManager.getSessionId();
|
|
13
|
+
if (!id) throw new Error("Foreign target requires an active session identity.");
|
|
14
|
+
if (this.manager !== ctx.sessionManager || this.sessionId !== id) {
|
|
15
|
+
this.grants.clear();
|
|
16
|
+
this.manager = ctx.sessionManager;
|
|
17
|
+
this.sessionId = id;
|
|
18
|
+
}
|
|
19
|
+
if (this.grants.get(id)?.has(target.commonDir)) return;
|
|
20
|
+
if (options.continuation) throw new Error("Foreign clone continuation grant was lost; start a new explicit launch.");
|
|
21
|
+
if (!ctx.hasUI || !ctx.ui?.confirm || await ctx.ui.confirm("Authorize foreign clone subagent", `Launch a subagent in ${target.root}? Git common directory: ${target.commonDir}. This grant applies only to this live session and clone.`) !== true) throw new Error("Foreign clone requires interactive human consent before launch.");
|
|
22
|
+
if (options.signal?.aborted) throw new Error("Foreign clone authorization aborted.");
|
|
23
|
+
if (ctx.sessionManager !== this.manager || ctx.sessionManager.getSessionId() !== id) throw new Error("Session identity changed during foreign clone authorization.");
|
|
24
|
+
const grants = this.grants.get(id) ?? new Set<string>();
|
|
25
|
+
grants.add(target.commonDir);
|
|
26
|
+
this.grants.set(id, grants);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
assertCurrent(ctx: Pick<ExtensionContext, "sessionManager">, target: WorktreeIdentity): void {
|
|
30
|
+
if (ctx.sessionManager !== this.manager || !this.sessionId || ctx.sessionManager.getSessionId() !== this.sessionId || !this.grants.get(this.sessionId)?.has(target.commonDir)) throw new Error("Foreign clone grant is no longer live.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -4,7 +4,7 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
4
4
|
// env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
|
|
5
5
|
// filesystem and child process so this module stays fully unit-testable.
|
|
6
6
|
|
|
7
|
-
export type LauncherCommand = "home";
|
|
7
|
+
export type LauncherCommand = "home" | "setup";
|
|
8
8
|
|
|
9
9
|
// pi's own package-management subcommands (see pi's cli/args.ts printHelp
|
|
10
10
|
// "Commands" list): each is dispatched by pi itself, before pi's own flag
|
|
@@ -62,6 +62,8 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
|
|
|
62
62
|
let help = false;
|
|
63
63
|
let version = false;
|
|
64
64
|
let error: string | undefined;
|
|
65
|
+
let command: LauncherCommand | undefined;
|
|
66
|
+
let commandArgs: string[] = [];
|
|
65
67
|
let piSubcommand: PiSubcommand | undefined;
|
|
66
68
|
const passthrough: string[] = [];
|
|
67
69
|
|
|
@@ -131,6 +133,18 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
|
|
|
131
133
|
i += 1;
|
|
132
134
|
continue;
|
|
133
135
|
}
|
|
136
|
+
// Unlike `home`, `setup` is not restricted to argv[0]: it accepts the
|
|
137
|
+
// home selectors (--link, --isolated, --home <dir>) ahead of it, same
|
|
138
|
+
// as a pi subcommand would, so it provisions whichever home those
|
|
139
|
+
// selectors resolve to. It is only recognised as the FIRST non-flag
|
|
140
|
+
// token — once a pi subcommand (or any other passthrough token) has
|
|
141
|
+
// already started, a later "setup" is just an ordinary passthrough
|
|
142
|
+
// argument, same as "home" is.
|
|
143
|
+
if (arg === "setup" && command === undefined && passthrough.length === 0) {
|
|
144
|
+
command = "setup";
|
|
145
|
+
commandArgs = argv.slice(i + 1);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
134
148
|
if (passthrough.length === 0 && isPiSubcommand(arg)) {
|
|
135
149
|
piSubcommand = arg;
|
|
136
150
|
}
|
|
@@ -147,7 +161,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
|
|
|
147
161
|
}
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
return { link, isolated, home, packageRoot, help, version, command
|
|
164
|
+
return { link, isolated, home, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error };
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
// --- home resolution -------------------------------------------------------
|
|
@@ -200,6 +214,20 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome {
|
|
|
200
214
|
return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" };
|
|
201
215
|
}
|
|
202
216
|
|
|
217
|
+
// The flags that reproduce `home`'s resolved mode on a later `gentle-shell
|
|
218
|
+
// <flags> ...` invocation — used by remediation messages (e.g. "run
|
|
219
|
+
// `gentle-shell <flags> remove <source>`") so they point at the exact home
|
|
220
|
+
// setup provisioned instead of silently defaulting to the isolated home.
|
|
221
|
+
// Mirrors the three ResolvedHome modes one-to-one: "link" needs --link
|
|
222
|
+
// (PI_CODING_AGENT_DIR-derived dirs aren't reproducible as a literal path),
|
|
223
|
+
// "path" needs its --home <dir>, and "isolated" needs nothing since it's
|
|
224
|
+
// gentle-shell's own default when no selector is given.
|
|
225
|
+
export function homeSelectorFlags(home: ResolvedHome): string[] {
|
|
226
|
+
if (home.mode === "link") return ["--link"];
|
|
227
|
+
if (home.mode === "path") return ["--home", home.dir];
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
203
231
|
export function launcherConfigPath(homedir: string): string {
|
|
204
232
|
return join(homedir, ".gentle-shell", "config.json");
|
|
205
233
|
}
|
|
@@ -228,6 +256,88 @@ export function parseLauncherConfig(text: string): LauncherConfig | undefined {
|
|
|
228
256
|
return { mode: "path", dir: home };
|
|
229
257
|
}
|
|
230
258
|
|
|
259
|
+
// --- provisioning marker (S7 auto-provision) --------------------------------
|
|
260
|
+
|
|
261
|
+
// Raw config.json shape as actually stored on disk: a plain object that may
|
|
262
|
+
// carry `home` (see LauncherConfig above), `provisioned`, and any other key
|
|
263
|
+
// a future feature adds. Unlike parseLauncherConfig's discriminated
|
|
264
|
+
// LauncherConfig, these helpers operate on (and return) the whole object so
|
|
265
|
+
// a write never drops a field it does not itself understand — notably
|
|
266
|
+
// another home's provisioned marker when `gentle-shell home ...` persists a
|
|
267
|
+
// mode change.
|
|
268
|
+
export type RawLauncherConfig = Record<string, unknown>;
|
|
269
|
+
|
|
270
|
+
// Tolerant like parseLauncherConfig: a missing, malformed, or foreign
|
|
271
|
+
// config.json resolves to an empty object rather than throwing, so a caller
|
|
272
|
+
// can always merge into (and write back) whatever it finds.
|
|
273
|
+
export function parseRawLauncherConfig(text: string | undefined): RawLauncherConfig {
|
|
274
|
+
if (text === undefined) return {};
|
|
275
|
+
let parsed: unknown;
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(text);
|
|
278
|
+
} catch {
|
|
279
|
+
return {};
|
|
280
|
+
}
|
|
281
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
282
|
+
return parsed as RawLauncherConfig;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface ProvisionedEntry {
|
|
286
|
+
gentleAi: string;
|
|
287
|
+
// Optional: a marker written before gentle-pi version tracking (S8) has
|
|
288
|
+
// no `gentlePi` field at all. needsProvisioning below treats that
|
|
289
|
+
// omission as "needs provisioning" rather than trusting or crashing on it.
|
|
290
|
+
gentlePi?: string;
|
|
291
|
+
at: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isProvisionedEntry(value: unknown): value is ProvisionedEntry {
|
|
295
|
+
if (typeof value !== "object" || value === null) return false;
|
|
296
|
+
const record = value as Record<string, unknown>;
|
|
297
|
+
if (typeof record.gentleAi !== "string" || typeof record.at !== "string") return false;
|
|
298
|
+
return record.gentlePi === undefined || typeof record.gentlePi === "string";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Tolerant read of config.provisioned: a missing, non-object, or malformed
|
|
302
|
+
// map (or a malformed individual entry) is dropped rather than thrown, same
|
|
303
|
+
// tolerance policy as parseLauncherConfig above.
|
|
304
|
+
function provisionedMap(config: RawLauncherConfig): Record<string, ProvisionedEntry> {
|
|
305
|
+
const value = config.provisioned;
|
|
306
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
307
|
+
const map: Record<string, ProvisionedEntry> = {};
|
|
308
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
309
|
+
if (isProvisionedEntry(entry)) map[key] = entry;
|
|
310
|
+
}
|
|
311
|
+
return map;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The provisioning record for `homeDir` (the caller passes a realpath, so
|
|
315
|
+
// two different-looking paths to the same home never diverge), or undefined
|
|
316
|
+
// when that home has never been auto- or manually provisioned.
|
|
317
|
+
export function provisionedEntry(config: RawLauncherConfig, homeDir: string): ProvisionedEntry | undefined {
|
|
318
|
+
return provisionedMap(config)[homeDir];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// True when `homeDir` has never been provisioned, was provisioned with a
|
|
322
|
+
// gentle-ai pin other than `pin`, or was provisioned against a gentle-pi
|
|
323
|
+
// other than `gentlePiVersion` (the running launcher's own version, from its
|
|
324
|
+
// package.json) — the signal bin/gentle-shell.mjs uses to decide whether a
|
|
325
|
+
// plain launch should run the setup flow automatically before starting pi.
|
|
326
|
+
// A marker written before gentle-pi version tracking existed has no
|
|
327
|
+
// `gentlePi` field, which never strictly-equals a real version string, so it
|
|
328
|
+
// always counts as needing provisioning too — see ProvisionedEntry above.
|
|
329
|
+
export function needsProvisioning(config: RawLauncherConfig, homeDir: string, pin: string, gentlePiVersion: string): boolean {
|
|
330
|
+
const entry = provisionedEntry(config, homeDir);
|
|
331
|
+
return entry === undefined || entry.gentleAi !== pin || entry.gentlePi !== gentlePiVersion;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Returns a new config object recording `homeDir` as provisioned at `pin`
|
|
335
|
+
// and `gentlePiVersion`, preserving every other key — including every other
|
|
336
|
+
// home's provisioned entry — unchanged. Never mutates `config`.
|
|
337
|
+
export function recordProvisioned(config: RawLauncherConfig, homeDir: string, pin: string, gentlePiVersion: string, now: string): RawLauncherConfig {
|
|
338
|
+
return { ...config, provisioned: { ...provisionedMap(config), [homeDir]: { gentleAi: pin, gentlePi: gentlePiVersion, at: now } } };
|
|
339
|
+
}
|
|
340
|
+
|
|
231
341
|
// --- pi runtime resolution ---------------------------------------------------
|
|
232
342
|
|
|
233
343
|
export type PiRuntimeKind = "env" | "bundled" | "path";
|
|
@@ -298,6 +408,25 @@ export function checkPiVersion(output: string, minimum: string = MIN_PI_VERSION)
|
|
|
298
408
|
return { ok: true, version };
|
|
299
409
|
}
|
|
300
410
|
|
|
411
|
+
// --- setup subcommand's gentle-ai pin gate -----------------------------------
|
|
412
|
+
|
|
413
|
+
// The first gentle-ai release that honors PI_CODING_AGENT_DIR in its own
|
|
414
|
+
// `install --agent pi` provisioning. `gentle-shell setup` spawns the
|
|
415
|
+
// package-local pinned gentle-ai with PI_CODING_AGENT_DIR set to the
|
|
416
|
+
// resolved home; an older pin ignores that variable and silently provisions
|
|
417
|
+
// the caller's real ~/.pi/agent instead, so setup must refuse to run it.
|
|
418
|
+
export const MIN_SETUP_GENTLE_AI_VERSION = "3.6.0";
|
|
419
|
+
|
|
420
|
+
export function isSetupCapablePin(version: string, minimum: string = MIN_SETUP_GENTLE_AI_VERSION): boolean {
|
|
421
|
+
const match = VERSION_PATTERN.exec(version);
|
|
422
|
+
if (!match) return false;
|
|
423
|
+
const minimumMatch = VERSION_PATTERN.exec(minimum);
|
|
424
|
+
if (!minimumMatch) throw new Error(`invalid minimum version "${minimum}"`);
|
|
425
|
+
const found: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
426
|
+
const wanted: [number, number, number] = [Number(minimumMatch[1]), Number(minimumMatch[2]), Number(minimumMatch[3])];
|
|
427
|
+
return compareVersions(found, wanted) >= 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
301
430
|
// --- packaging drift guard -----------------------------------------------------
|
|
302
431
|
|
|
303
432
|
export interface PackageJsonPeerShape {
|
|
@@ -400,6 +529,61 @@ export function settingsDeclareGentlePi(settingsText: string | undefined): boole
|
|
|
400
529
|
return packages.some(packageEntryDeclaresGentlePi);
|
|
401
530
|
}
|
|
402
531
|
|
|
532
|
+
// gentle-ai's own managed Pi stack still installs
|
|
533
|
+
// npm:@juicesharp/rpiv-ask-user-question, which conflicts with gentle-pi's
|
|
534
|
+
// first-party ask_user_question tool: Pi tool names are exclusive, so a
|
|
535
|
+
// second provider for the same name fails the whole load (see
|
|
536
|
+
// extensions/ask-user-question.ts). Tracked upstream as gentle-ai #4820 and
|
|
537
|
+
// gentle-shell #1277; the gentle-ai fix lands separately, so `gentle-shell
|
|
538
|
+
// setup` (bin/gentle-shell.mjs) must remove it from the provisioned home
|
|
539
|
+
// itself.
|
|
540
|
+
//
|
|
541
|
+
// gentle-ai's managed Pi stack also always declares npm:gentle-pi itself.
|
|
542
|
+
// That declaration must never survive setup either, for an unrelated reason:
|
|
543
|
+
// this launcher always loads its own gentle-pi (its own package root, or a
|
|
544
|
+
// take-over), never the one gentle-ai's stack installs, so leaving the
|
|
545
|
+
// declaration in place would silently let the home drift onto whatever
|
|
546
|
+
// gentle-pi npm last installed — or, for a developer running from a source
|
|
547
|
+
// checkout, onto the published npm package — instead of the running
|
|
548
|
+
// launcher's own copy. See docs/readme-reference.md's "setup" section.
|
|
549
|
+
//
|
|
550
|
+
// Table of every package `setup` removes after gentle-ai finishes, so a
|
|
551
|
+
// future addition only needs a new row here.
|
|
552
|
+
const POST_INSTALL_REMOVAL_PACKAGES: readonly { readonly name: string; readonly source: string }[] = [
|
|
553
|
+
{ name: "@juicesharp/rpiv-ask-user-question", source: "npm:@juicesharp/rpiv-ask-user-question" },
|
|
554
|
+
{ name: "gentle-pi", source: "npm:gentle-pi" },
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
// The known removal sources, exposed so a `--dry-run` caller can report what
|
|
558
|
+
// setup would remove *if* gentle-ai's install declares it, without reading
|
|
559
|
+
// settings.json itself: a dry run writes nothing, so settings.json
|
|
560
|
+
// afterwards would only reflect whatever pre-existed the run, not what the
|
|
561
|
+
// (skipped) install would have declared. See runPostInstallCleanup in
|
|
562
|
+
// bin/gentle-shell.mjs.
|
|
563
|
+
export const POST_INSTALL_REMOVAL_SOURCES: readonly string[] = POST_INSTALL_REMOVAL_PACKAGES.map((entry) => entry.source);
|
|
564
|
+
|
|
565
|
+
// Scans a settings.json `packages` list (same string/object-source parsing
|
|
566
|
+
// as settingsDeclareGentlePi/findGentlePiDeclaration above) for any entry
|
|
567
|
+
// whose npm package name matches POST_INSTALL_REMOVAL_PACKAGES, at any
|
|
568
|
+
// version spec. Returns each match's canonical unversioned source, deduped,
|
|
569
|
+
// in the order those packages first appear in `packages` — never the
|
|
570
|
+
// declared (possibly versioned) source text, since the caller always removes
|
|
571
|
+
// the bare package.
|
|
572
|
+
export function postInstallRemovals(settingsText: string | undefined): string[] {
|
|
573
|
+
const packages = parseSettingsPackages(settingsText);
|
|
574
|
+
if (packages === undefined) return [];
|
|
575
|
+
|
|
576
|
+
const found: string[] = [];
|
|
577
|
+
for (const entry of packages) {
|
|
578
|
+
const source = entrySource(entry);
|
|
579
|
+
if (source === undefined || packageSourceKind(source) !== "npm") continue;
|
|
580
|
+
const name = npmPackageName(source);
|
|
581
|
+
const match = POST_INSTALL_REMOVAL_PACKAGES.find((candidate) => candidate.name === name);
|
|
582
|
+
if (match !== undefined && !found.includes(match.source)) found.push(match.source);
|
|
583
|
+
}
|
|
584
|
+
return found;
|
|
585
|
+
}
|
|
586
|
+
|
|
403
587
|
export type GentlePiDeclaration = { kind: "npm" } | { kind: "path"; dir: string };
|
|
404
588
|
|
|
405
589
|
export interface FindGentlePiDeclarationOptions {
|
|
@@ -789,6 +973,20 @@ export function quoteForCmdExe(token: string): string {
|
|
|
789
973
|
return `"${token.replace(/"/g, '\\"')}"`;
|
|
790
974
|
}
|
|
791
975
|
|
|
976
|
+
const POSIX_SHELL_SPECIAL_CHARS = /[\s"'`\\$&|;<>(){}*?[\]!#~]/;
|
|
977
|
+
|
|
978
|
+
// POSIX/bash single-quote shell quoting for a copy-pasteable command
|
|
979
|
+
// bin/gentle-shell.mjs prints to stderr (e.g. the setup remediation
|
|
980
|
+
// command): wraps a token in single quotes when it is empty or contains
|
|
981
|
+
// whitespace or a shell metacharacter, escaping an embedded single quote as
|
|
982
|
+
// `'\''` (close quote, escaped literal quote, reopen quote) — inside single
|
|
983
|
+
// quotes nothing else needs escaping, unlike cmd.exe's `"`-based quoting
|
|
984
|
+
// (quoteForCmdExe above).
|
|
985
|
+
export function shellQuote(value: string): string {
|
|
986
|
+
if (value.length > 0 && !POSIX_SHELL_SPECIAL_CHARS.test(value)) return value;
|
|
987
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
988
|
+
}
|
|
989
|
+
|
|
792
990
|
export interface PlanSpawnInput {
|
|
793
991
|
command: string;
|
|
794
992
|
args: string[];
|
|
@@ -809,6 +1007,115 @@ export function planSpawn(input: PlanSpawnInput): SpawnPlan {
|
|
|
809
1007
|
return { command, args, shell: false };
|
|
810
1008
|
}
|
|
811
1009
|
|
|
1010
|
+
// --- JSON field restore --------------------------------------------------
|
|
1011
|
+
|
|
1012
|
+
// Detects the indentation unit and trailing-newline presence of a JSON text,
|
|
1013
|
+
// so restoreJsonField below can re-serialize as close to the original
|
|
1014
|
+
// formatting as practical instead of imposing its own. `indent` is
|
|
1015
|
+
// `undefined` for compact (no-whitespace) JSON, matching what
|
|
1016
|
+
// `JSON.stringify(value)` (no third argument) produces.
|
|
1017
|
+
function detectJsonFormatting(text: string): { indent: string | undefined; trailingNewline: boolean } {
|
|
1018
|
+
const match = text.match(/\{\r?\n([ \t]+)/);
|
|
1019
|
+
return { indent: match ? match[1] : undefined, trailingNewline: text.endsWith("\n") };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function jsonValuesEqual(a: unknown, b: unknown): boolean {
|
|
1023
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Pure JSON merge: restores `field` in `currentText` back to whatever it was
|
|
1027
|
+
// in `originalText`, keeping every other field exactly as `currentText` left
|
|
1028
|
+
// it, and formatting the result to match `originalText`'s indentation and
|
|
1029
|
+
// trailing newline. Used by bin/gentle-shell.mjs's setup flow to restore
|
|
1030
|
+
// `managed_asset_digest` in the user's shared `~/.gentle-ai/state.json` after
|
|
1031
|
+
// the pinned gentle-ai spawn rewrites it (the same shared-file problem
|
|
1032
|
+
// persona.json has — see sharedPersonaPath/snapshotFile/restoreFile in
|
|
1033
|
+
// bin/gentle-shell.mjs — but state.json also carries fields the pinned
|
|
1034
|
+
// gentle-ai is supposed to update, like installed_agents, so this restores
|
|
1035
|
+
// only the one field instead of the whole file).
|
|
1036
|
+
//
|
|
1037
|
+
// Returns the new text, or `undefined` when either text fails to parse as a
|
|
1038
|
+
// JSON object, or the field's presence and value are already identical on
|
|
1039
|
+
// both sides (nothing to restore). Never called by the caller when
|
|
1040
|
+
// `originalText` comes from a file that did not exist before the spawn —
|
|
1041
|
+
// there is nothing to restore a nonexistent file back to.
|
|
1042
|
+
export function restoreJsonField(originalText: string, currentText: string, field: string): string | undefined {
|
|
1043
|
+
let originalValue: unknown;
|
|
1044
|
+
let currentValue: unknown;
|
|
1045
|
+
try {
|
|
1046
|
+
originalValue = JSON.parse(originalText);
|
|
1047
|
+
currentValue = JSON.parse(currentText);
|
|
1048
|
+
} catch {
|
|
1049
|
+
return undefined;
|
|
1050
|
+
}
|
|
1051
|
+
if (
|
|
1052
|
+
typeof originalValue !== "object" ||
|
|
1053
|
+
originalValue === null ||
|
|
1054
|
+
Array.isArray(originalValue) ||
|
|
1055
|
+
typeof currentValue !== "object" ||
|
|
1056
|
+
currentValue === null ||
|
|
1057
|
+
Array.isArray(currentValue)
|
|
1058
|
+
) {
|
|
1059
|
+
return undefined;
|
|
1060
|
+
}
|
|
1061
|
+
const originalObj = originalValue as Record<string, unknown>;
|
|
1062
|
+
const currentObj = currentValue as Record<string, unknown>;
|
|
1063
|
+
const hadField = Object.prototype.hasOwnProperty.call(originalObj, field);
|
|
1064
|
+
const hasFieldNow = Object.prototype.hasOwnProperty.call(currentObj, field);
|
|
1065
|
+
const unchanged = hadField === hasFieldNow && (!hadField || jsonValuesEqual(originalObj[field], currentObj[field]));
|
|
1066
|
+
if (unchanged) return undefined;
|
|
1067
|
+
|
|
1068
|
+
let restored: Record<string, unknown>;
|
|
1069
|
+
if (hadField) {
|
|
1070
|
+
restored = { ...currentObj, [field]: originalObj[field] };
|
|
1071
|
+
} else {
|
|
1072
|
+
restored = { ...currentObj };
|
|
1073
|
+
delete restored[field];
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const { indent, trailingNewline } = detectJsonFormatting(originalText);
|
|
1077
|
+
const serialized = JSON.stringify(restored, null, indent);
|
|
1078
|
+
return trailingNewline ? `${serialized}\n` : serialized;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Pure JSON merge: forces `field` in `currentText` to `value`, but only when
|
|
1082
|
+
// `originalText` (the state from before whatever wrote `currentText`) did not
|
|
1083
|
+
// declare that field at all — never overriding a value the original already
|
|
1084
|
+
// had, in either direction. Keeps every other field exactly as `currentText`
|
|
1085
|
+
// left it, and formats the result to match `currentText`'s own indentation
|
|
1086
|
+
// and trailing newline (unlike restoreJsonField above, which matches the
|
|
1087
|
+
// *original*'s formatting — here `currentText` is what the other writer just
|
|
1088
|
+
// produced, so its own convention is respected instead of imposed on).
|
|
1089
|
+
// Used by bin/gentle-shell.mjs's setup flow so a home gentle-shell provisions
|
|
1090
|
+
// ends up with the maintainer's default theme unless the home (or the user)
|
|
1091
|
+
// already had an opinion about it, even when gentle-ai's own managed install
|
|
1092
|
+
// writes a *different* default theme into settings.json.
|
|
1093
|
+
//
|
|
1094
|
+
// Returns the new text, or `undefined` when either text fails to parse as a
|
|
1095
|
+
// JSON object, the original text already declared `field` (nothing to
|
|
1096
|
+
// force), or the current value already equals `value` (nothing to change).
|
|
1097
|
+
export function forceJsonFieldIfAbsentInOriginal(originalText: string, currentText: string, field: string, value: unknown): string | undefined {
|
|
1098
|
+
let originalValue: unknown;
|
|
1099
|
+
let currentValue: unknown;
|
|
1100
|
+
try {
|
|
1101
|
+
originalValue = JSON.parse(originalText);
|
|
1102
|
+
currentValue = JSON.parse(currentText);
|
|
1103
|
+
} catch {
|
|
1104
|
+
return undefined;
|
|
1105
|
+
}
|
|
1106
|
+
if (typeof originalValue !== "object" || originalValue === null || Array.isArray(originalValue)) return undefined;
|
|
1107
|
+
if (typeof currentValue !== "object" || currentValue === null || Array.isArray(currentValue)) return undefined;
|
|
1108
|
+
const originalObj = originalValue as Record<string, unknown>;
|
|
1109
|
+
const currentObj = currentValue as Record<string, unknown>;
|
|
1110
|
+
if (Object.prototype.hasOwnProperty.call(originalObj, field)) return undefined;
|
|
1111
|
+
if (jsonValuesEqual(currentObj[field], value)) return undefined;
|
|
1112
|
+
|
|
1113
|
+
const forced = { ...currentObj, [field]: value };
|
|
1114
|
+
const { indent, trailingNewline } = detectJsonFormatting(currentText);
|
|
1115
|
+
const serialized = JSON.stringify(forced, null, indent);
|
|
1116
|
+
return trailingNewline ? `${serialized}\n` : serialized;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
812
1119
|
// --- reporting ---------------------------------------------------------------
|
|
813
1120
|
|
|
814
1121
|
export interface DescribeVersionInput {
|
|
@@ -829,6 +1136,7 @@ export function helpText(): string {
|
|
|
829
1136
|
return [
|
|
830
1137
|
"Usage: gentle-shell [options] [-- pi-args...]",
|
|
831
1138
|
" gentle-shell home [link|isolated|<path>]",
|
|
1139
|
+
" gentle-shell [home selectors] setup [--dry-run]",
|
|
832
1140
|
"",
|
|
833
1141
|
"Opens pi with the Gentle Shell package loaded, without touching your",
|
|
834
1142
|
"vanilla pi installation.",
|
|
@@ -844,6 +1152,10 @@ export function helpText(): string {
|
|
|
844
1152
|
"",
|
|
845
1153
|
"Commands:",
|
|
846
1154
|
" home Print or persist the effective home mode (link, isolated, or a path).",
|
|
1155
|
+
" setup Provision the resolved home with the gentle-ai companion packages",
|
|
1156
|
+
" (runs the package-local gentle-ai 'install --agent pi --scope global').",
|
|
1157
|
+
" Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector",
|
|
1158
|
+
" (--link, --isolated, --home <dir>) before it.",
|
|
847
1159
|
"",
|
|
848
1160
|
"Managing packages:",
|
|
849
1161
|
" gentle-shell install npm:<pkg> Run pi's own 'install' against the resolved home.",
|