Skip to content
Merged
2 changes: 2 additions & 0 deletions bin/gentle-shell.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,8 @@ async function main() {
piSubcommand: args.piSubcommand,
baseEnv: process.env,
homedir: homedir(),
// The spawn below sets no cwd, so pi runs in the launcher's own.
cwd: process.cwd(),
});

// Only an interactive session ends with pi's exit resume hint, which
Expand Down
47 changes: 32 additions & 15 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9796,6 +9796,35 @@ function createGentleAiExtensionForTesting(
reminderEpoch += 1;
unbindPreparation?.();
reminderManager = ctx.sessionManager;
// gentle-shell#1690: a delegated child runs in the parent's resolved
// worktree. Repository preparation, review negotiation, asset install and
// model config belong to the parent session and write shared state. The
// standing review grant is host-only (a child never captures an identity),
// so revoke/refresh have nothing to act on; the child relay is load-time.
// Only that parent-owned work is skipped: the session-local resets above,
// the dev-binary notice, and any step added after this call, still run in
// children.
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") await startParentSession(event, ctx);
else await surfaceDevBinaryOverride(ctx);
});

// Loud, every session: an active dev-binary override means this session
// runs an unpinned gentle-ai. One visible startup notice: the gentle-shell
// 🌹 card owns the announcement when it can render (shell enabled with UI);
// this toast is only the fallback for when the card is unavailable. The
// hasUI guard stays: headless contexts have no toast to show.
const surfaceDevBinaryOverride = async (ctx: ExtensionContext): Promise<void> => {
const devBinaryToastFallback = ctx.hasUI && !shellEnabled();
try {
const devBinary = await describeDevBinaryOverride();
if (devBinaryToastFallback && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
if (devBinaryToastFallback && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
} catch (error) {
if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
}
};

const startParentSession = async (event: unknown, ctx: ExtensionContext): Promise<void> => {
const epoch = reminderEpoch;
const manager = ctx.sessionManager;
const originalCwd = manager?.getCwd?.() ?? ctx.cwd;
Expand All @@ -9816,19 +9845,7 @@ function createGentleAiExtensionForTesting(
const reason = (event as { reason?: unknown }).reason;
if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx);
await refreshReviewSessionPermissionStatus(ctx);
// Loud, every session: an active dev-binary override means this session
// runs an unpinned gentle-ai. One visible startup notice: the gentle-shell
// 🌹 card owns the announcement when it can render (shell enabled with UI);
// this toast is only the fallback for when the card is unavailable. The
// hasUI guard stays: headless contexts have no toast to show.
const devBinaryToastFallback = ctx.hasUI && !shellEnabled();
try {
const devBinary = await describeDevBinaryOverride();
if (devBinaryToastFallback && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
if (devBinaryToastFallback && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
} catch (error) {
if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
}
await surfaceDevBinaryOverride(ctx);
try {
const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]);
migrateLegacyProjectModelOverrides(ctx.cwd);
Expand Down Expand Up @@ -9864,7 +9881,7 @@ function createGentleAiExtensionForTesting(
} catch {
// Startup negotiation is best-effort only; never surface or throw.
}
});
};

pi.on("before_agent_start", async (event, ctx) => {
const isNamedAgent = isNamedAgentStartEvent(event);
Expand Down Expand Up @@ -9984,7 +10001,7 @@ function createGentleAiExtensionForTesting(
// Persist the observed own write before any await. Preparation is not
// mutation evidence, and cannot invent a pre-write Changes baseline.
if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId, ...directWriterProfile(pi, ctx) });
if (prospectiveRoot && !resolveSessionWorktree(ctx.cwd, ctx.cwd)) await prepareBoundSessionRepository(ctx.sessionManager, ctx.sessionManager.getCwd?.() ?? ctx.cwd, ctx.signal);
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && prospectiveRoot && !resolveSessionWorktree(ctx.cwd, ctx.cwd)) await prepareBoundSessionRepository(ctx.sessionManager, ctx.sessionManager.getCwd?.() ?? ctx.cwd, ctx.signal);
} catch { /* Preparation and receipt persistence cannot change a successful tool result. */ }
});

Expand Down
6 changes: 4 additions & 2 deletions extensions/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,8 +1316,10 @@ export default function promptHistoryExtension(
): void {
const env = deps.env ?? process.env;
const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env);
// Per-prompt gate: re-read so a Customize toggle applies live.
const capturing = () => captureEnabled(env, configHome);
// Per-prompt gate: re-read so a Customize toggle applies live. A delegated
// child never captures: its prompt is a delegation brief, not user history,
// and the parent owns the store's init and GC (gentle-shell#1690).
const capturing = () => env.GENTLE_PI_AGENTS_CHILD !== "1" && captureEnabled(env, configHome);
const root = deps.root ?? PI_HISTORY_ROOT;
const cwd = deps.cwd ?? process.cwd();
const instanceId = deps.instanceId ?? randomUUID();
Expand Down
3 changes: 3 additions & 0 deletions extensions/pi-pretty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export default async function gentlePiPrettyExtension(
bundled?: PiPrettyExtension,
env: NodeJS.ProcessEnv = process.env,
): Promise<unknown> {
// gentle-shell#1690: a delegated child has no transcript to prettify, and
// the upstream fallback would start its own file indexing in every child.
if (env.GENTLE_PI_AGENTS_CHILD === "1") return undefined;
if (quietToolsEnabled()) {
process.env.PRETTY_DISABLE_TOOLS = mergeDisabledTools(
process.env.PRETTY_DISABLE_TOOLS,
Expand Down
3 changes: 3 additions & 0 deletions extensions/skill-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,9 @@ function shouldSkipSkillRegistryStartup(
env = process.env,
): boolean {
return (
// gentle-shell#1690: delegated children share the parent's cwd; the
// parent owns .atl/ writes, the legacy rename and the watcher.
env.GENTLE_PI_AGENTS_CHILD === "1" ||
pi.getFlag(NO_SKILL_REGISTRY_FLAG) === true ||
isTruthyEnv(env[NO_SKILL_REGISTRY_ENV]) ||
hasCliArg(argv, "--no-skills", "-ns")
Expand Down
3 changes: 3 additions & 0 deletions extensions/startup-banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,9 @@ export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
disposeHeader();
if (!ctx.hasUI) return;
// Delegated rpc children report hasUI=true but have no terminal to paint
// (gentle-shell#1690); do not rely on a piped stdout lacking rows/columns.
if (process.env.GENTLE_PI_AGENTS_CHILD === "1") return;

// CLI subcommands such as `pi update` or `pi install` skip the animated intro.
if (isPiCliSubcommandInvocation(process.argv)) return;
Expand Down
62 changes: 62 additions & 0 deletions lib/child-package-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { isAbsolute } from "node:path";

// #1690: the gentle-shell launcher injects the gentle-pi package into the
// parent pi with `-e` instead of a settings declaration, so delegated children
// would otherwise start without it. The launcher records the exact extension
// set it injected in this variable; the subagent runner forwards that set to
// every child. Kept free of launcher imports so the runner can load it cheaply.
export const CHILD_PACKAGE_INJECTION_ENV = "GENTLE_SHELL_CHILD_PACKAGE_INJECTION";

const CHILD_PACKAGE_INJECTION_VERSION = 1;

export interface ChildPackageInjection {
// True for a launcher takeover: the parent ran with --no-extensions, so the
// child must too, or settings discovery would load a second gentle-pi.
noExtensions: boolean;
// Absolute extension paths, in the order the launcher passed them to `-e`.
extensionPaths: string[];
}

export function encodeChildPackageInjection(value: ChildPackageInjection): string {
return JSON.stringify({
version: CHILD_PACKAGE_INJECTION_VERSION,
noExtensions: value.noExtensions,
extensionPaths: value.extensionPaths,
});
}

// Returns undefined for an absent or invalid value and never throws: a bad
// signal must degrade to "no forwarding", not break a child launch. The path
// flavor is injectable so tests can check Windows paths on any platform.
export function parseChildPackageInjection(
env: Record<string, string | undefined>,
pathFlavor: { isAbsolute(path: string): boolean } = { isAbsolute },
): ChildPackageInjection | undefined {
const raw = env[CHILD_PACKAGE_INJECTION_ENV];
if (raw === undefined || raw.length === 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
const record = parsed as Record<string, unknown>;
if (record.version !== CHILD_PACKAGE_INJECTION_VERSION) return undefined;
if (typeof record.noExtensions !== "boolean") return undefined;
const paths = record.extensionPaths;
if (!Array.isArray(paths) || paths.length === 0) return undefined;
const extensionPaths: string[] = [];
for (const path of paths) {
if (typeof path !== "string" || path.length === 0 || !pathFlavor.isAbsolute(path)) return undefined;
extensionPaths.push(path);
}
return { noExtensions: record.noExtensions, extensionPaths };
}

// Plain argv elements for a child pi: spawned without a shell, so no quoting.
export function childPackageExtensionArgs(injection: ChildPackageInjection): string[] {
const args = injection.noExtensions ? ["--no-extensions"] : [];
for (const path of injection.extensionPaths) args.push("--extension", path);
return args;
}
47 changes: 36 additions & 11 deletions lib/gentle-shell-launcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join, resolve as resolvePath } from "node:path";
import { isAbsolute, join, resolve as resolvePath } from "node:path";
import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, type ChildPackageInjection } from "./child-package-injection.ts";

// The gentle-shell launcher: pure, side-effect-free functions over injected
// env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
Expand Down Expand Up @@ -878,6 +879,10 @@ export interface BuildPiInvocationInput {
baseEnv: Record<string, string | undefined>;
// The OS home behind userPiHome's conventional ~/.pi/agent fallback.
homedir: string;
// The directory pi is spawned in, which pi resolves a relative -e path
// against. bin/gentle-shell.mjs spawns pi without a cwd, so this is the
// launcher's own process.cwd().
cwd: string;
}

export interface PiInvocation {
Expand Down Expand Up @@ -919,8 +924,15 @@ export interface PiInvocation {
// - Not takeOver, with a declaration: no injection at all — the target
// settings already load a gentle-pi the launcher accepts as-is (the
// `--link` case with a pi-managed install matching this launcher).
//
// The two injecting cases also export CHILD_PACKAGE_INJECTION_ENV (#1690) so
// the subagent runner can give delegated children the same package. It holds
// only the launcher's own computed -e set; passthrough -e flags (the managed
// herdr extension, or one the user typed) are not part of it. Every other case
// removes an inherited value, so a nested launch never leaks a stale signal.
export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation {
const args = [...input.runtime.args];
let childInjection: ChildPackageInjection | undefined;

if (input.piSubcommand !== undefined) {
// No injection at all: pi must see the bare subcommand as argv[0].
Expand All @@ -945,23 +957,36 @@ export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation {
injected.add(input.packageRoot);
args.push("-e", input.packageRoot);
}

// The argv dedupe above compares raw strings; the signal dedupes again
// after absolutizing, so a relative and an absolute spelling of the same
// file appear once, in first-occurrence order.
const signalPaths = new Set([...injected].map((path) => absoluteExtensionPath(path, input.cwd)));
childInjection = { noExtensions: true, extensionPaths: [...signalPaths] };
} else if (input.declaration === undefined) {
args.push("-e", input.packageRoot);
childInjection = { noExtensions: false, extensionPaths: [absoluteExtensionPath(input.packageRoot, input.cwd)] };
}

args.push(...input.passthrough);

return {
command: input.runtime.command,
args,
env: {
...input.baseEnv,
PI_CODING_AGENT_DIR: input.home.dir,
GENTLE_PI_AGENT_HOME: input.home.dir,
[USER_PI_HOME_ENV]: userPiHome(input.baseEnv, input.homedir),
},
const env: Record<string, string | undefined> = {
...input.baseEnv,
PI_CODING_AGENT_DIR: input.home.dir,
GENTLE_PI_AGENT_HOME: input.home.dir,
[USER_PI_HOME_ENV]: userPiHome(input.baseEnv, input.homedir),
};
if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV];
else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection);

return { command: input.runtime.command, args, env };
}

// pi resolves a relative -e path against its spawn cwd. Children may run
// elsewhere, so the signal carries the same file as an absolute path. Loose
// entries can be relative when the isolated or linked home comes from a
// relative env value.
function absoluteExtensionPath(path: string, cwd: string): string {
return isAbsolute(path) ? path : resolvePath(cwd, path);
}

// --- spawn planning ------------------------------------------------------------
Expand Down
Loading
Loading