diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 7eed24840..e3d53d844 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -10,6 +10,7 @@ import { constants as fsConstants, existsSync, mkdirSync, + mkdtempSync, openSync, readdirSync, readFileSync, @@ -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"; @@ -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"; @@ -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); diff --git a/runtime/gentle-shell-resume-hint.mjs b/runtime/gentle-shell-resume-hint.mjs new file mode 100644 index 000000000..d2232f933 --- /dev/null +++ b/runtime/gentle-shell-resume-hint.mjs @@ -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 " (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 /XXXXXX/. +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`; +} diff --git a/scripts/build-runtime-modules.mjs b/scripts/build-runtime-modules.mjs index 8bca0176c..86b047e23 100644 --- a/scripts/build-runtime-modules.mjs +++ b/scripts/build-runtime-modules.mjs @@ -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"; diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index 6e6f9f3a8..4a81c7c3f 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -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", diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index cd6f992f5..e867116ec 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -2470,3 +2470,179 @@ test("--link take-over treats a file named `extensions` as not a loose extension const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]); }); + +// --- resume-hint handoff (lib/gentle-shell-resume-hint.ts) ---------------------- + +// A stand-in pi that writes a resume handoff like extensions/resume-hint.ts +// does, prints pi's own exit hint, and exits with the given code. +function writeHandoffPiScript(path: string, exitCode = 0) { + writeFileSync( + path, + [ + "#!/usr/bin/env node", + "const { writeFileSync } = require('node:fs');", + "const args = process.argv.slice(2);", + "if (args.includes('--version')) { console.log('0.85.1'); process.exit(0); }", + "const handoff = process.env.GENTLE_SHELL_RESUME_HANDOFF;", + "if (handoff) writeFileSync(handoff, JSON.stringify({ sessionId: 'abc' }));", + "if (process.env.PI_STUB_PRINT_ENV) console.log(JSON.stringify({ args, handoff }));", + "process.stdout.write('To resume this session: pi --session abc\\n');", + `process.exit(${exitCode});`, + "", + ].join("\n"), + ); + chmodSync(path, 0o755); +} + +test("interactive launch hands pi a private resume handoff and cleans it up", (t) => { + const f = fixture(t); + const piScript = join(f.root, "handoff-pi.cjs"); + writeHandoffPiScript(piScript); + const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, []); + assert.equal(result.status, 0, result.stderr); + const lines = result.stdout.trim().split("\n"); + const { handoff } = JSON.parse(lines[0]); + assert.equal(typeof handoff, "string"); + assert.match(handoff, /gentle-shell-resume-/); + assert.equal(existsSync(dirname(handoff)), false, "handoff dir must be removed after pi exits"); + // Not a TTY: like pi's own hint, the gentle-shell line is not printed. + assert.deepEqual(lines.slice(1), ["To resume this session: pi --session abc"]); +}); + +test("pi subcommands get no resume handoff", (t) => { + const f = fixture(t); + const piScript = join(f.root, "handoff-pi.cjs"); + writeHandoffPiScript(piScript); + const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, ["list"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout.trim().split("\n")[0]).handoff, undefined); +}); + +// The hint is only printed to a real TTY, so drive the launcher through a +// pseudo-terminal. Python's pty module is the portable POSIX way to get one +// without a native dependency; skipped where it is unavailable. +const hasPythonPty = process.platform !== "win32" && spawnSync("python3", ["-c", "import pty"], { stdio: "ignore" }).status === 0; +const PTY_RUNNER = [ + "import fcntl, os, pty, struct, subprocess, sys, termios", + "m, s = pty.openpty()", + "fcntl.ioctl(s, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 120, 0, 0))", + "p = subprocess.Popen(sys.argv[1:], stdin=s, stdout=s, stderr=s)", + "os.close(s)", + "out = b''", + // PTY_SIGNAL_AFTER: once this text appears, send PTY_SIGNAL to the launcher. + "after = os.environ.get('PTY_SIGNAL_AFTER', '').encode()", + "while True:", + " try: d = os.read(m, 4096)", + " except OSError: break", + " if not d: break", + " out += d", + " if after and after in out:", + " after = b''", + " os.kill(p.pid, int(os.environ['PTY_SIGNAL']))", + "sys.stdout.write(out.decode())", + "sys.exit(p.wait())", +].join("\n"); + +function runInPty(env: NodeJS.ProcessEnv, args: string[], expectedStatus = 0): string { + // Pin the color environment so the launcher's hasColors() check does not + // depend on the machine running the tests; a test can still override it. + const { NO_COLOR, FORCE_COLOR, NODE_DISABLE_COLORS, ...rest } = env; + const colorEnv = { ...rest, TERM: "xterm-256color", ...(env.PTY_NO_COLOR ? { NO_COLOR: "1" } : {}) }; + // Bounded so a stand-in pi that never exits fails the test instead of hanging CI. + const result = spawnSync("python3", ["-c", PTY_RUNNER, process.execPath, binPath, ...args], { encoding: "utf8", env: colorEnv, timeout: 30_000 }); + assert.equal(result.error, undefined, String(result.error)); + assert.equal(result.status, expectedStatus, result.stdout + result.stderr); + return result.stdout; +} + +const PI_HINT = "To resume this session: pi --session abc\r\n"; +const GENTLE_HINT = "\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session abc\r\n"; + +test("on a TTY the launcher appends a gentle-shell resume line below pi's hint", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { + const f = fixture(t); + const piScript = join(f.root, "tty-pi.cjs"); + writeHandoffPiScript(piScript); + const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]); + assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out)); +}); + +test("on a TTY the gentle-shell line still follows a non-zero pi exit, keeping the code", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { + const f = fixture(t); + const piScript = join(f.root, "tty-pi.cjs"); + writeHandoffPiScript(piScript, 3); + const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"], 3); + assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out)); +}); + +// A stand-in pi that writes the handoff and then waits: it quits with its +// own hint on SIGHUP/SIGTERM, but survives SIGINT (like an interrupted turn) +// and quits only on the next line of input. +function writeWaitingPiScript(path: string) { + writeFileSync( + path, + [ + "#!/usr/bin/env node", + "const { writeFileSync } = require('node:fs');", + "if (process.argv.includes('--version')) { console.log('0.85.1'); process.exit(0); }", + "writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc' }));", + "const quit = () => { process.stdout.write('To resume this session: pi --session abc\\n'); process.exit(0); };", + "process.on('SIGHUP', quit);", + "process.on('SIGTERM', quit);", + "process.on('SIGINT', () => { process.stdout.write('interrupted\\n'); setTimeout(quit, 50); });", + "process.stdout.write('ready\\n');", + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + ); + chmodSync(path, 0o755); +} + +test("on a TTY the launcher prints nothing extra after the terminal hangs up", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { + const f = fixture(t); + const piScript = join(f.root, "tty-pi.cjs"); + writeWaitingPiScript(piScript); + const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "1" }, ["--link"]); + // pi quit cleanly with its own hint; only the gentle-shell line is withheld. + assert.ok(out.endsWith(PI_HINT), JSON.stringify(out)); + assert.equal(out.includes("gentle-shell --"), false, JSON.stringify(out)); +}); + +test("on a TTY a forwarded SIGINT that pi survives does not silence the gentle-shell line", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { + const f = fixture(t); + const piScript = join(f.root, "tty-pi.cjs"); + writeWaitingPiScript(piScript); + const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "2" }, ["--link"]); + assert.ok(out.includes("interrupted"), JSON.stringify(out)); + assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out)); +}); + +test("on a TTY a cross-project session resumes by its session file", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { + const f = fixture(t); + const sessionFile = join(f.root, "other project", "session.jsonl"); + const piScript = join(f.root, "tty-pi.cjs"); + writeFileSync( + piScript, + [ + "#!/usr/bin/env node", + "const { writeFileSync } = require('node:fs');", + "if (process.argv.includes('--version')) { console.log('0.85.1'); process.exit(0); }", + `writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc', sessionFile: ${JSON.stringify(sessionFile)} }));`, + "process.stdout.write('To resume this session: pi --session abc\\n');", + "", + ].join("\n"), + ); + chmodSync(piScript, 0o755); + const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]); + assert.ok( + out.endsWith(`${PI_HINT}\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session '${sessionFile}'\r\n`), + JSON.stringify(out), + ); +}); + +test("on a TTY without colors the gentle-shell line has no ANSI styling", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { + const f = fixture(t); + const piScript = join(f.root, "tty-pi.cjs"); + writeHandoffPiScript(piScript); + const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_NO_COLOR: "1" }, ["--link"]); + assert.ok(out.endsWith(`${PI_HINT}To resume in gentle-shell: gentle-shell --link --session abc\r\n`), JSON.stringify(out)); +});