Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions bin/gentle-shell.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
constants as fsConstants,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
readdirSync,
readFileSync,
Expand All @@ -20,7 +21,7 @@ import {
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { constants as osConstants, homedir } from "node:os";
import { constants as osConstants, homedir, tmpdir } from "node:os";
import { delimiter, dirname, join, resolve as resolvePath } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -53,6 +54,13 @@ import {
restoreJsonField,
shellQuote,
} from "../runtime/gentle-shell-launcher.mjs";
import {
parseResumeHandoff,
planResumeHint,
RESUME_HANDOFF_DIR_PREFIX,
RESUME_HANDOFF_ENV,
RESUME_HANDOFF_FILE,
} from "../runtime/gentle-shell-resume-hint.mjs";
import { GENTLE_AI_VERSION, gentleAiBinaryPath, PackageLocalGentleAiBinaryMissingError } from "../runtime/gentle-ai-binary.mjs";
import { DEFAULT_THEME_NAME, installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs";

Expand Down Expand Up @@ -1233,17 +1241,90 @@ async function main() {
baseEnv: process.env,
});

// Only an interactive session ends with pi's exit resume hint, which
// gentle-shell completes with its own line; a pi subcommand gets no handoff.
const resumeHandoff = args.piSubcommand === undefined ? createResumeHandoff() : undefined;
const childEnv = resumeHandoff ? { ...invocation.env, [RESUME_HANDOFF_ENV]: resumeHandoff.path } : invocation.env;

const launchPlan = planSpawn({ command: invocation.command, args: invocation.args, platform: process.platform });
const child = spawn(launchPlan.command, launchPlan.args, { stdio: "inherit", env: invocation.env, shell: launchPlan.shell });
let child;
try {
child = spawn(launchPlan.command, launchPlan.args, { stdio: "inherit", env: childEnv, shell: launchPlan.shell });
} catch (error) {
resumeHandoff?.dispose();
throw error;
}
// Only SIGHUP means the terminal is gone. pi may survive a forwarded
// SIGINT and keep running, so other signals must not silence the hint.
let terminalHungUp = false;
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(signal, () => child.kill(signal));
process.on(signal, () => {
if (signal === "SIGHUP") terminalHungUp = true;
child.kill(signal);
});
}
child.on("error", (error) => fail(`Could not start pi: ${error.message}`, 1));
child.on("error", (error) => {
resumeHandoff?.dispose();
fail(`Could not start pi: ${error.message}`, 1);
});
child.on("exit", (code, signal) => {
process.exit(signal ? signalExitCode(signal) : (code ?? 1));
const exitCode = signal ? signalExitCode(signal) : (code ?? 1);
if (resumeHandoff) {
const hint = planResumeHint({
handoff: resumeHandoff.read(),
homeFlags: homeSelectorFlags(home),
stdoutIsTTY: process.stdout.isTTY === true,
terminalHungUp,
platform: process.platform,
color: process.stdout.hasColors?.() === true,
});
resumeHandoff.dispose();
// TTY writes are asynchronous on Windows: exit only once the
// hint is flushed, or it can be lost.
if (hint) {
// A write error (e.g. EIO on a closed terminal) must not turn
// pi's exit into a launcher crash.
process.stdout.once("error", () => process.exit(exitCode));
process.stdout.write(hint, () => process.exit(exitCode));
return;
}
}
process.exit(exitCode);
});
}

// Private temp dir for the resume-hint handoff (lib/gentle-shell-resume-hint.ts).
// Best effort: if it cannot be created, only pi's own hint is printed.
function createResumeHandoff() {
let dir;
try {
dir = mkdtempSync(join(tmpdir(), RESUME_HANDOFF_DIR_PREFIX));
} catch {
return undefined;
}
const path = join(dir, RESUME_HANDOFF_FILE);
return {
path,
read: () => {
try {
const text = readJsonIfExists(path);
return text === undefined ? undefined : parseResumeHandoff(text);
} catch {
return undefined;
}
},
// Never throws: it runs inside the exit handler, where an EPERM on
// Windows would otherwise replace pi's exit code with a crash.
dispose: () => {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// A leftover empty temp dir is harmless.
}
},
};
}

main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
Expand Down
177 changes: 177 additions & 0 deletions runtime/gentle-shell-resume-hint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Generated by scripts/build-runtime-modules.mjs. Do not edit.
// Pure logic behind the gentle-shell resume hint. On interactive quit pi
// prints "To resume this session: pi --session <id>" (interactive-mode
// formatResumeCommand). Under gentle-shell that command cannot find the
// session: pi resolves sessions from PI_CODING_AGENT_DIR, which the launcher
// points at the gentle-shell home, but the hint names the bare `pi` binary
// and never mentions that directory, so running it looks in ~/.pi/agent.
//
// The clean fix belongs in pi (earendil-works/pi#8048, #9750). Until then,
// gentle-shell appends its own line below pi's, leaving pi's output as is:
// extensions/resume-hint.ts writes a ResumeHandoff on session_shutdown, and
// bin/gentle-shell.mjs prints the planResumeHint line after pi exits.
import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
import { shellQuote } from "./gentle-shell-launcher.mjs";

export const RESUME_HANDOFF_ENV = "GENTLE_SHELL_RESUME_HANDOFF";

const HINT_LABEL = "To resume in gentle-shell:";

// pi's assertValidSessionId charset. The handoff ends up on the terminal, so
// anything outside it (or any C0/C1 control in a session dir) is refused
// rather than escaped.
const SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;

// The launcher creates <tmpdir>/<RESUME_HANDOFF_DIR_PREFIX>XXXXXX/<RESUME_HANDOFF_FILE>.
export const RESUME_HANDOFF_DIR_PREFIX = "gentle-shell-resume-";
export const RESUME_HANDOFF_FILE = "handoff.json";

// The extension only writes to a path shaped like the launcher's private
// handoff, so an inherited or foreign env value cannot aim it at another file.
export function isResumeHandoffPath(path ) {
return (
isAbsolute(path) &&
basename(path) === RESUME_HANDOFF_FILE &&
basename(dirname(path)).startsWith(RESUME_HANDOFF_DIR_PREFIX) &&
!CONTROL_CHARS.test(path)
);
}













// Mirror of pi's getDefaultSessionDirPath (core/session-manager), which pi
// does not export. Kept byte-for-byte so usesDefaultSessionDir agrees.
export function piDefaultSessionDir(cwd , agentDir ) {
const resolvedCwd = resolvePath(cwd);
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
return join(resolvePath(agentDir), "sessions", safePath);
}












// Mirrors the guards in pi's formatResumeCommand: no hint for an
// unpersisted session or one whose file was never written.
export function resumeHandoffFromSession(snapshot ) {
const { sessionId, sessionDir, sessionFile, cwd, launchCwd, agentDir, fileExists } = snapshot;
if (!sessionFile || !fileExists(sessionFile)) return undefined;
if (resolvePath(cwd) !== resolvePath(launchCwd)) return { sessionId, sessionFile: resolvePath(sessionFile) };
if (sessionDir === piDefaultSessionDir(cwd, agentDir)) return { sessionId };
return { sessionId, sessionDir };
}

export function serializeResumeHandoff(handoff ) {
return JSON.stringify(handoff);
}

// Tolerant on purpose: a missing, stale, or foreign handoff file just means
// "print nothing".
export function parseResumeHandoff(text ) {
let parsed ;
try {
parsed = JSON.parse(text);
} catch {
return undefined;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
const { sessionId, sessionDir, sessionFile } = parsed ;
if (typeof sessionId !== "string" || !SESSION_ID_PATTERN.test(sessionId)) return undefined;
if (sessionFile !== undefined) {
if (sessionDir !== undefined || typeof sessionFile !== "string") return undefined;
if (!isAbsolute(sessionFile) || !sessionFile.endsWith(".jsonl") || CONTROL_CHARS.test(sessionFile)) return undefined;
return { sessionId, sessionFile };
}
if (sessionDir === undefined) return { sessionId };
if (typeof sessionDir !== "string" || sessionDir.length === 0 || CONTROL_CHARS.test(sessionDir)) return undefined;
return { sessionId, sessionDir };
}

// Values that need no quoting in any shell a user might paste the hint into.
const PLAIN_ARG = /^[A-Za-z0-9_\-.:/\\=]+$/;
// Characters that stay live inside double quotes: cmd.exe expands %VAR% (and
// !VAR! under delayed expansion), PowerShell expands $var and `escapes, and
// an inner " ends the quoted argument in both. A trailing backslash would
// escape the closing quote under the Windows argv rules. cmd.exe operators
// are refused too: gentle-shell is installed as a .cmd shim, and PowerShell
// drops the quotes of a space-free argument when it calls one, so cmd.exe
// would read & | < > ^ ( ) as operators.
const WINDOWS_UNQUOTABLE = /["%!$`&|<>^()]|\\$/;

// Quotes one argument for the shell the user is likely to paste into:
// POSIX single quotes elsewhere, double quotes on win32, where cmd.exe and
// PowerShell do not treat single quotes as quoting. Returns undefined when
// the value cannot be quoted safely there.
function quoteArg(value , platform ) {
if (platform !== "win32") return shellQuote(value);
if (PLAIN_ARG.test(value)) return value;
if (WINDOWS_UNQUOTABLE.test(value) || CONTROL_CHARS.test(value)) return undefined;
return `"${value}"`;
}

// Returns the gentle-shell command that resumes the handed-off session, or
// undefined when some argument cannot be quoted safely for the platform's
// shell (the caller then prints no hint and leaves pi's own line alone).
export function gentleShellResumeCommand(
handoff ,
homeFlags ,
platform ,
) {
const values = [...homeFlags];
// pi treats a --session value containing a path separator as a file path
// and opens it directly, whatever the launch directory.
if (handoff.sessionFile !== undefined) {
values.push("--session", handoff.sessionFile);
} else {
if (handoff.sessionDir !== undefined) values.push("--session-dir", handoff.sessionDir);
values.push("--session", handoff.sessionId);
}
const args = ["gentle-shell"];
for (const value of values) {
const quoted = quoteArg(value, platform);
if (quoted === undefined) return undefined;
args.push(quoted);
}
return args.join(" ");
}













// Returns the line to print after pi exits, or undefined to print nothing.
// Like pi, it only prints to a TTY, and never after the terminal hung up.
export function planResumeHint(input ) {
const { handoff, homeFlags, stdoutIsTTY, terminalHungUp, platform, color } = input;
if (!handoff || !stdoutIsTTY || terminalHungUp) return undefined;
const command = gentleShellResumeCommand(handoff, homeFlags, platform);
if (command === undefined) return undefined;
const label = color ? `\u001b[2m${HINT_LABEL}\u001b[22m` : HINT_LABEL;
return `${label} ${command}\n`;
}
1 change: 1 addition & 0 deletions scripts/build-runtime-modules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const sources = [
"native-review-cli",
"telemetry-trigger",
"gentle-shell-launcher",
"gentle-shell-resume-hint",
];
const header = "// Generated by scripts/build-runtime-modules.mjs. Do not edit.\n";

Expand Down
1 change: 1 addition & 0 deletions scripts/verify-package-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const requiredPaths = [
"lib/telemetry-trigger.ts",
"runtime/gentle-ai-binary.mjs",
"runtime/gentle-shell-launcher.mjs",
"runtime/gentle-shell-resume-hint.mjs",
"runtime/native-review-cli.mjs",
"runtime/review-integration-v2.mjs",
"runtime/review-risk-assessment.mjs",
Expand Down
Loading
Loading