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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-19 21:32:55 -06:00 committed by GitHub
parent 2a87687483
commit 2822a2954f
2 changed files with 117 additions and 31 deletions

View file

@ -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;
}

View file

@ -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<boolean> {
// 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 <instruction> 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 <instruction> 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;
}