From 2822a2954fb0804ea7cfb3e9cef712b8ec449efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Thu, 19 Mar 2026 21:32:55 -0600 Subject: [PATCH] fix: interactive guard menu for remote auto-mode sessions (#1507) (#1524) Replace the simple notifyRemoteAutoActive notification with an interactive guardRemoteSession menu that shows session details and offers actionable choices (view status, steer, stop, or force start). Guards all auto-mode entry points: bare /gsd, /gsd next, and /gsd auto. Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto.ts | 32 +++++++ src/resources/extensions/gsd/commands.ts | 116 +++++++++++++++++------ 2 files changed, 117 insertions(+), 31 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 191dc8e21..e4e068113 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -418,6 +418,38 @@ export function stopAutoRemote(projectRoot: string): { } } +/** + * Check if a remote auto-mode session is running (from a different process). + * Reads the crash lock, checks PID liveness, and returns session details. + * Used by the guard in commands.ts to prevent bare /gsd, /gsd next, and + * /gsd auto from stealing the session lock. + */ +export function checkRemoteAutoSession(projectRoot: string): { + running: boolean; + pid?: number; + unitType?: string; + unitId?: string; + startedAt?: string; + completedUnits?: number; +} { + const lock = readCrashLock(projectRoot); + if (!lock) return { running: false }; + + if (!isLockProcessAlive(lock)) { + // Stale lock from a dead process — not a live remote session + return { running: false }; + } + + return { + running: true, + pid: lock.pid, + unitType: lock.unitType, + unitId: lock.unitId, + startedAt: lock.startedAt, + completedUnits: lock.completedUnits, + }; +} + export function isStepMode(): boolean { return s.stepMode; } diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 485554242..a8973c080 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -15,7 +15,7 @@ import { deriveState } from "./state.js"; import { GSDDashboardOverlay } from "./dashboard-overlay.js"; import { GSDVisualizerOverlay } from "./visualizer-overlay.js"; import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js"; -import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js"; +import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, checkRemoteAutoSession } from "./auto.js"; import { dispatchDirectPhase } from "./auto-direct-dispatch.js"; import { resolveProjectRoot } from "./worktree.js"; import { assertSafeDirectory } from "./validate-directory.js"; @@ -50,6 +50,7 @@ import { handleLogs } from "./commands-logs.js"; import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js"; import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js"; import { handleCmux } from "./commands-cmux.js"; +import { showNextAction } from "../shared/mod.js"; /** Resolve the effective project root, accounting for worktree paths. */ @@ -72,36 +73,88 @@ export function projectRoot(): string { } /** - * Check if another process holds the auto-mode session lock. - * Returns the lock data if a remote session is alive, null otherwise. + * Guard against starting auto-mode when a remote session is already running. + * Returns true if the caller should proceed with startAuto, false if handled. */ -function getRemoteAutoSession(basePath: string): { pid: number } | null { - const lockData = readSessionLockData(basePath); - if (!lockData) return null; - if (lockData.pid === process.pid) return null; - if (!isSessionLockProcessAlive(lockData)) return null; - return { pid: lockData.pid }; -} +async function guardRemoteSession( + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + // Local session already active — proceed (startAuto handles re-entrant calls) + if (isAutoActive() || isAutoPaused()) return true; -/** - * Show a steering menu when auto-mode is running in another process. - * Returns true if a remote session was detected (caller should return early). - */ -function notifyRemoteAutoActive(ctx: ExtensionCommandContext, basePath: string): boolean { - const remote = getRemoteAutoSession(basePath); - if (!remote) return false; - ctx.ui.notify( - `Auto-mode is running in another process (PID ${remote.pid}).\n` + - `Use these commands to interact with it:\n` + - ` /gsd status — check progress\n` + - ` /gsd discuss — discuss architecture decisions\n` + - ` /gsd queue — queue the next milestone\n` + - ` /gsd steer — apply an override to active work\n` + - ` /gsd capture — fire-and-forget thought\n` + - ` /gsd stop — stop auto-mode`, - "warning", - ); - return true; + const remote = checkRemoteAutoSession(projectRoot()); + if (!remote.running || !remote.pid) return true; + + const unitLabel = remote.unitType && remote.unitId + ? `${remote.unitType} (${remote.unitId})` + : "unknown unit"; + const unitsMsg = remote.completedUnits != null + ? `${remote.completedUnits} units completed` + : ""; + + const choice = await showNextAction(ctx, { + title: `Auto-mode is running in another terminal (PID ${remote.pid})`, + summary: [ + `Currently executing: ${unitLabel}`, + ...(unitsMsg ? [unitsMsg] : []), + ...(remote.startedAt ? [`Started: ${remote.startedAt}`] : []), + ], + actions: [ + { + id: "status", + label: "View status", + description: "Show the current GSD progress dashboard.", + recommended: true, + }, + { + id: "steer", + label: "Steer the session", + description: "Use /gsd steer to redirect the running session.", + }, + { + id: "stop", + label: "Stop remote session", + description: `Send SIGTERM to PID ${remote.pid} to stop it gracefully.`, + }, + { + id: "force", + label: "Force start (steal lock)", + description: "Start a new session, terminating the existing one.", + }, + ], + notYetMessage: "Run /gsd when ready.", + }); + + if (choice === "status") { + await handleStatus(ctx); + return false; + } + if (choice === "steer") { + ctx.ui.notify( + "Use /gsd steer to redirect the running auto-mode session.\n" + + "Example: /gsd steer Use Postgres instead of SQLite", + "info", + ); + return false; + } + if (choice === "stop") { + const result = stopAutoRemote(projectRoot()); + if (result.found) { + ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info"); + } else if (result.error) { + ctx.ui.notify(`Failed to stop remote auto-mode: ${result.error}`, "error"); + } else { + ctx.ui.notify("Remote session is no longer running.", "info"); + } + return false; + } + if (choice === "force") { + return true; // Proceed — startAuto will steal the lock + } + + // "not_yet" or escape + return false; } export function registerGSDCommand(pi: ExtensionAPI): void { @@ -598,10 +651,10 @@ export async function handleGSDCommand( await handleDryRun(ctx, projectRoot()); return; } - if (notifyRemoteAutoActive(ctx, projectRoot())) return; const verboseMode = trimmed.includes("--verbose"); const debugMode = trimmed.includes("--debug"); if (debugMode) enableDebug(projectRoot()); + if (!(await guardRemoteSession(ctx, pi))) return; await startAuto(ctx, pi, projectRoot(), verboseMode, { step: true }); return; } @@ -610,6 +663,7 @@ export async function handleGSDCommand( const verboseMode = trimmed.includes("--verbose"); const debugMode = trimmed.includes("--debug"); if (debugMode) enableDebug(projectRoot()); + if (!(await guardRemoteSession(ctx, pi))) return; await startAuto(ctx, pi, projectRoot(), verboseMode); return; } @@ -993,7 +1047,7 @@ Examples: } if (trimmed === "") { - if (notifyRemoteAutoActive(ctx, projectRoot())) return; + if (!(await guardRemoteSession(ctx, pi))) return; await startAuto(ctx, pi, projectRoot(), false, { step: true }); return; }