diff --git a/src/resources/extensions/bg-shell/bg-shell-lifecycle.ts b/src/resources/extensions/bg-shell/bg-shell-lifecycle.ts index 2f5766595..688db06c4 100644 --- a/src/resources/extensions/bg-shell/bg-shell-lifecycle.ts +++ b/src/resources/extensions/bg-shell/bg-shell-lifecycle.ts @@ -22,7 +22,7 @@ import { loadManifest, pruneDeadProcesses, } from "./process-manager.js"; -import { formatUptime, resolveBgShellPersistenceCwd } from "./utilities.js"; +import { formatUptime, getBgShellLiveCwd, resolveBgShellPersistenceCwd } from "./utilities.js"; import { formatTokenCount } from "../shared/format-utils.js"; import type { BgShellSharedState } from "./index.js"; @@ -213,7 +213,7 @@ export function registerBgShellLifecycle(pi: ExtensionAPI, state: BgShellSharedS return { render(width: number): string[] { // ── Line 1: pwd (branch) [session] ... bg status ── - let pwd = process.cwd(); + let pwd = getBgShellLiveCwd(state.latestCtx?.cwd); const home = process.env.HOME || process.env.USERPROFILE; if (home && pwd.startsWith(home)) { pwd = `~${pwd.slice(home.length)}`; diff --git a/src/resources/extensions/bg-shell/utilities.ts b/src/resources/extensions/bg-shell/utilities.ts index 9b17c130f..05b8fe654 100644 --- a/src/resources/extensions/bg-shell/utilities.ts +++ b/src/resources/extensions/bg-shell/utilities.ts @@ -42,16 +42,51 @@ export function formatTimeAgo(timestamp: number): string { return formatDuration(Date.now() - timestamp) + " ago"; } +function deriveProjectRootFromAutoWorktree(cachedCwd?: string): string | undefined { + if (!cachedCwd) return undefined; + const match = cachedCwd.match(/^(.*?)[\\/]\.gsd[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/); + return match?.[1]; +} + +export function getBgShellLiveCwd( + cachedCwd?: string, + pathExists: (path: string) => boolean = existsSync, + getCwd: () => string = () => process.cwd(), + chdir: (path: string) => void = (path) => process.chdir(path), +): string { + try { + return getCwd(); + } catch { + const projectRoot = deriveProjectRootFromAutoWorktree(cachedCwd); + const home = process.env.HOME || process.env.USERPROFILE; + const fallbacks = [projectRoot, cachedCwd, home, "/"].filter( + (candidate): candidate is string => Boolean(candidate), + ); + + for (const candidate of fallbacks) { + if (candidate !== "/" && !pathExists(candidate)) continue; + try { + chdir(candidate); + } catch { + // Best-effort only. Returning a known-good fallback is enough to avoid crashes. + } + return candidate; + } + + return "/"; + } +} export function resolveBgShellPersistenceCwd( cachedCwd: string, - liveCwd = process.cwd(), + liveCwd: string | undefined = undefined, pathExists: (path: string) => boolean = existsSync, ): string { + const resolvedLiveCwd = liveCwd ?? getBgShellLiveCwd(cachedCwd, pathExists); const cachedIsAutoWorktree = /(?:^|[\\/])\.gsd[\\/]worktrees[\\/]/.test(cachedCwd); if (!cachedIsAutoWorktree) return cachedCwd; - if (cachedCwd === liveCwd && pathExists(cachedCwd)) return cachedCwd; - if (!pathExists(cachedCwd)) return liveCwd; - if (liveCwd !== cachedCwd) return liveCwd; + if (cachedCwd === resolvedLiveCwd && pathExists(cachedCwd)) return cachedCwd; + if (!pathExists(cachedCwd)) return resolvedLiveCwd; + if (resolvedLiveCwd !== cachedCwd) return resolvedLiveCwd; return cachedCwd; } diff --git a/src/resources/extensions/gsd/bootstrap/register-extension.ts b/src/resources/extensions/gsd/bootstrap/register-extension.ts index 166d227ad..1e1b62f5a 100644 --- a/src/resources/extensions/gsd/bootstrap/register-extension.ts +++ b/src/resources/extensions/gsd/bootstrap/register-extension.ts @@ -9,14 +9,28 @@ import { registerJournalTools } from "./journal-tools.js"; import { registerHooks } from "./register-hooks.js"; import { registerShortcuts } from "./register-shortcuts.js"; +export function handleRecoverableExtensionProcessError(err: Error): boolean { + if ((err as NodeJS.ErrnoException).code === "EPIPE") { + process.exit(0); + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + const syscall = (err as NodeJS.ErrnoException).syscall; + if (syscall?.startsWith("spawn")) { + process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`); + return true; + } + if (syscall === "uv_cwd") { + process.stderr.write(`[gsd] ENOENT (${syscall}): ${err.message}\n`); + return true; + } + } + return false; +} + function installEpipeGuard(): void { if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) { const _gsdEpipeGuard = (err: Error): void => { - if ((err as NodeJS.ErrnoException).code === "EPIPE") { - process.exit(0); - } - if ((err as NodeJS.ErrnoException).code === "ENOENT" && (err as any).syscall?.startsWith("spawn")) { - process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`); + if (handleRecoverableExtensionProcessError(err)) { return; } throw err; @@ -45,4 +59,3 @@ export function registerGsdExtension(pi: ExtensionAPI): void { registerShortcuts(pi); registerHooks(pi); } - diff --git a/src/resources/extensions/gsd/tests/register-extension-guard.test.ts b/src/resources/extensions/gsd/tests/register-extension-guard.test.ts new file mode 100644 index 000000000..9d926b852 --- /dev/null +++ b/src/resources/extensions/gsd/tests/register-extension-guard.test.ts @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { handleRecoverableExtensionProcessError } from "../bootstrap/register-extension.ts"; + +test("handleRecoverableExtensionProcessError swallows spawn ENOENT", () => { + let stderr = ""; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + try { + const handled = handleRecoverableExtensionProcessError( + Object.assign(new Error("missing binary"), { + code: "ENOENT", + syscall: "spawn npm", + path: "npm", + }), + ); + assert.equal(handled, true); + assert.match(stderr, /spawn ENOENT: npm/); + } finally { + process.stderr.write = originalWrite; + } +}); + +test("handleRecoverableExtensionProcessError swallows uv_cwd ENOENT", () => { + let stderr = ""; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + try { + const handled = handleRecoverableExtensionProcessError( + Object.assign(new Error("process.cwd failed"), { + code: "ENOENT", + syscall: "uv_cwd", + }), + ); + assert.equal(handled, true); + assert.match(stderr, /ENOENT \(uv_cwd\): process\.cwd failed/); + } finally { + process.stderr.write = originalWrite; + } +}); + +test("handleRecoverableExtensionProcessError leaves unrelated errors unhandled", () => { + const handled = handleRecoverableExtensionProcessError( + Object.assign(new Error("permission denied"), { + code: "EPERM", + syscall: "open", + }), + ); + assert.equal(handled, false); +}); diff --git a/src/tests/bg-shell-persistence-cwd.test.ts b/src/tests/bg-shell-persistence-cwd.test.ts index f1277b1e7..15e63f8e5 100644 --- a/src/tests/bg-shell-persistence-cwd.test.ts +++ b/src/tests/bg-shell-persistence-cwd.test.ts @@ -1,7 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveBgShellPersistenceCwd } from "../resources/extensions/bg-shell/utilities.ts"; +import { + getBgShellLiveCwd, + resolveBgShellPersistenceCwd, +} from "../resources/extensions/bg-shell/utilities.ts"; test("keeps non-worktree cwd unchanged", () => { const cached = "/repo"; @@ -43,3 +46,18 @@ test("keeps current auto-worktree cwd when it still matches process cwd", () => cached, ); }); + +test("falls back to project root when process.cwd throws inside a stale auto-worktree", () => { + const cached = "/repo/.gsd/worktrees/M001"; + const live = getBgShellLiveCwd( + cached, + (path) => path === "/repo", + () => { + throw Object.assign(new Error("uv_cwd"), { code: "ENOENT", syscall: "uv_cwd" }); + }, + () => {}, + ); + + assert.equal(live, "/repo"); + assert.equal(resolveBgShellPersistenceCwd(cached, live, (path) => path === "/repo"), "/repo"); +});