diff --git a/src/resources/extensions/sf/auto-runtime-state.js b/src/resources/extensions/sf/auto-runtime-state.js new file mode 100644 index 000000000..2e9a308c6 --- /dev/null +++ b/src/resources/extensions/sf/auto-runtime-state.js @@ -0,0 +1,53 @@ +/** + * auto-runtime-state.js — Thin facade over the AutoSession singleton for + * consumers that only need active/paused state and tool tracking, without + * importing the full auto.js module. + * + * Purpose: break the forced dependency on auto.js (>1700 lines) for modules + * that only need isAutoActive(), isAutoPaused(), markToolStart/End, or + * recordToolInvocationError. All state reads delegate to the same singleton + * that auto.js manages, so there is no separate instance. + * + * Consumer: forensics.js, rethink.js, ui/index.js, commands-handlers.js, + * bootstrap/ask-gate.js, steerable-autonomous-extension.js. + */ +import { getAutoSession } from "./auto/session.js"; +import { + isDeterministicPolicyError, + isQueuedUserMessageSkip, + isToolInvocationError, + markToolEnd as markTrackedToolEnd, + markToolStart as markTrackedToolStart, +} from "./auto-tool-tracking.js"; +export function getAutoRuntimeSnapshot() { + const s = getAutoSession(); + return { + active: s.active, + paused: s.paused, + currentUnit: s.currentUnit ? { ...s.currentUnit } : null, + basePath: s.basePath, + }; +} +export function isAutoActive() { + return getAutoSession().active; +} +export function isAutoPaused() { + return getAutoSession().paused; +} +export function markToolStart(toolCallId, toolName) { + markTrackedToolStart(toolCallId, getAutoSession().active, toolName); +} +export function markToolEnd(toolCallId) { + markTrackedToolEnd(toolCallId); +} +export function recordToolInvocationError(toolName, errorMsg) { + const s = getAutoSession(); + if (!s.active) return; + if ( + isToolInvocationError(errorMsg) || + isQueuedUserMessageSkip(errorMsg) || + isDeterministicPolicyError(errorMsg) + ) { + s.lastToolInvocationError = `${toolName}: ${errorMsg}`; + } +} diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index 2cd3bccd3..6bb913f86 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -79,12 +79,16 @@ import { import { startUnitSupervision } from "./auto-timers.js"; import { getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs, - markToolEnd as _markToolEnd, - markToolStart as _markToolStart, clearInFlightTools, isQueuedUserMessageSkip, isToolInvocationError, } from "./auto-tool-tracking.js"; +export { + isAutoActive, + isAutoPaused, + markToolEnd, + markToolStart, +} from "./auto-runtime-state.js"; import { autoWorktreeBranch, checkResourcesStale, @@ -439,12 +443,7 @@ export function getAutoDashboardData() { }; } // ─── Public API ─────────────────────────────────────────────────────────────── -export function isAutoActive() { - return s.active; -} -export function isAutoPaused() { - return s.paused; -} +// isAutoActive, isAutoPaused, markToolStart, markToolEnd re-exported from auto-runtime-state.js above export function getAutoCommandContext() { return s.cmdCtx; } @@ -543,13 +542,7 @@ export function markResearchTerminalTransition() { export function hasResearchTerminalTransition() { return getAutoSession().researchTerminalTransition; } -// Tool tracking — delegates to auto-tool-tracking.ts -export function markToolStart(toolCallId, toolName) { - _markToolStart(toolCallId, s.active, toolName); -} -export function markToolEnd(toolCallId) { - _markToolEnd(toolCallId); -} +// Tool tracking — delegates to auto-runtime-state.js (re-exported above) const TASK_COMPLETE_TOOL_NAMES = new Set(["complete_task"]); function normalizeTaskCompleteFailure(errorMsg) { return errorMsg diff --git a/src/resources/extensions/sf/tests/worktree-fixes.test.mjs b/src/resources/extensions/sf/tests/worktree-fixes.test.mjs index 209bd095a..17b601eb4 100644 --- a/src/resources/extensions/sf/tests/worktree-fixes.test.mjs +++ b/src/resources/extensions/sf/tests/worktree-fixes.test.mjs @@ -253,12 +253,12 @@ describe("WorktreeResolver.enterMilestone", () => { // ─── originalCwd clear-on-success (worktree-command.js) ──────────────────── describe("originalCwd lifecycle", () => { - test("merge succeeds: originalCwd set to null", () => { + test("merge succeeds: originalCwd cleared via clearWorktreeOriginalCwd()", () => { const __dirname = dirname(fileURLToPath(import.meta.url)); const sourcePath = join(__dirname, "..", "worktree-command.js"); const source = readFileSync(sourcePath, "utf-8"); const mergeSuccessPattern = - /mergeWorktreeToMain\([^)]+\);\s*\n\s*\/\/ Merge succeeded[^\n]*\n\s*originalCwd = null;/; + /mergeWorktreeToMain\([^)]+\);\s*\n\s*\/\/ Merge succeeded[^\n]*\n\s*clearWorktreeOriginalCwd\(\);/; expect(source).toMatch(mergeSuccessPattern); }); diff --git a/src/resources/extensions/sf/worktree-command.js b/src/resources/extensions/sf/worktree-command.js index dbf48c6ba..679329b49 100644 --- a/src/resources/extensions/sf/worktree-command.js +++ b/src/resources/extensions/sf/worktree-command.js @@ -16,7 +16,7 @@ import { rmSync, unlinkSync, } from "node:fs"; -import { basename, join, normalize, sep } from "node:path"; +import { join, normalize } from "node:path"; import { showConfirm } from "../shared/tui.js"; import { runWorktreePostCreateHook } from "./auto-worktree.js"; import { inferCommitType } from "./git-service.js"; @@ -41,34 +41,20 @@ import { worktreeBranchName, worktreePath, } from "./worktree-manager.js"; - -/** - * Tracks the original project root so we can switch back. - * Set when we first chdir into a worktree, cleared on return. - */ -let originalCwd = null; -/** - * Get the original project root if currently in a worktree, or null. - * Used to restore context after `/worktree merge` or `/worktree return`. - */ -export function getWorktreeOriginalCwd() { - return originalCwd; -} -/** - * Get the name of the active worktree, or null if not in one. - * Extracts from .sf/worktrees/ path segment. - */ -export function getActiveWorktreeName() { - if (!originalCwd) return null; - const cwd = normalize(process.cwd()); - const wtDir = normalize(join(originalCwd, ".sf", "worktrees")); - if (!cwd.startsWith(wtDir)) return null; - // Use basename on the first path segment after wtDir to handle both separators - // and avoid empty strings from trailing backslashes (split("/")[0] is fragile). - const rel = cwd.slice(wtDir.length).replace(/^[\\/]+/, ""); - const name = basename(rel.split(/[\\/]/)[0] ?? rel); - return name || null; -} +export { + clearWorktreeOriginalCwd, + ensureWorktreeOriginalCwdFromPath, + getActiveWorktreeName, + getWorktreeOriginalCwd, + setWorktreeOriginalCwd, +} from "./worktree-session-state.js"; +import { + clearWorktreeOriginalCwd, + ensureWorktreeOriginalCwdFromPath, + getActiveWorktreeName, + getWorktreeOriginalCwd, + setWorktreeOriginalCwd, +} from "./worktree-session-state.js"; // ─── Shared completions and handler (used by both /worktree and /wt) ──────── function worktreeCompletions(prefix) { const parts = prefix.trim().split(/\s+/); @@ -150,7 +136,7 @@ async function worktreeHandler(args, ctx, pi, alias) { return; } // create and switch both do the same thing: switch if exists, create if not - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; const existing = listWorktrees(mainBase); if (existing.some((wt) => wt.name === name)) { await handleSwitch(basePath, name, ctx); @@ -165,7 +151,7 @@ async function worktreeHandler(args, ctx, pi, alias) { .trim() .split(/\s+/) .filter(Boolean); - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; const activeWt = getActiveWorktreeName(); if (mergeArgs.length === 0) { // Bare "/worktree merge" — only valid when inside a worktree @@ -197,7 +183,7 @@ async function worktreeHandler(args, ctx, pi, alias) { } if (trimmed === "remove" || trimmed.startsWith("remove ")) { const name = trimmed.replace(/^remove\s*/, "").trim(); - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; if (name === "all") { await handleRemoveAll(mainBase, ctx); return; @@ -217,7 +203,7 @@ async function worktreeHandler(args, ctx, pi, alias) { ); return; } - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; const nameOnly = trimmed.split(/\s+/)[0]; if (trimmed !== nameOnly) { ctx.ui.notify( @@ -242,23 +228,15 @@ export async function handleWorktreeCommand(args, ctx, pi, alias) { } /** Register /worktree and /wt commands with completion support. */ export function registerWorktreeCommand(pi) { - // Restore worktree state after /reload. - // The module-level originalCwd resets to null when extensions are re-loaded, - // but process.cwd() is still inside the worktree. Detect this and recover. - if (!originalCwd) { - const cwd = process.cwd(); - const marker = `${sep}.sf${sep}worktrees${sep}`; - const markerIdx = cwd.indexOf(marker); - if (markerIdx !== -1) { - originalCwd = cwd.slice(0, markerIdx); - } - } + // Restore worktree state after /reload — detects if process.cwd() is still + // inside a worktree and recovers originalCwd from the path. + ensureWorktreeOriginalCwdFromPath(); // Orphaned-worktree recovery: a crash or hang between the pre-merge chdir and // merge completion may leave a worktree registered in git but not tracked by // originalCwd (because the old code cleared it prematurely). Detect such // worktrees on reload and warn — so the user knows to run /worktree list and // merge or remove them manually. - if (!originalCwd) { + if (!getWorktreeOriginalCwd()) { try { const cwd = process.cwd(); const worktrees = listWorktrees(cwd); @@ -343,7 +321,7 @@ async function handleCreate(basePath, name, ctx) { name, ); // Create from the main tree, not from inside another worktree - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; const info = createWorktree(mainBase, name); // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets const hookError = runWorktreePostCreateHook(mainBase, info.path); @@ -351,7 +329,7 @@ async function handleCreate(basePath, name, ctx) { ctx.ui.notify(hookError, "warning"); } // Track original cwd before switching - if (!originalCwd) originalCwd = basePath; + if (!getWorktreeOriginalCwd()) setWorktreeOriginalCwd(basePath); const prevCwd = process.cwd(); process.chdir(info.path); nudgeGitBranchCache(prevCwd); @@ -405,7 +383,7 @@ async function handleCreate(basePath, name, ctx) { } async function handleSwitch(basePath, name, ctx) { try { - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; const wtPath = worktreePath(mainBase, name); if (!existsSync(wtPath)) { ctx.ui.notify( @@ -421,7 +399,7 @@ async function handleSwitch(basePath, name, ctx) { name, ); // Track original cwd before switching - if (!originalCwd) originalCwd = basePath; + if (!getWorktreeOriginalCwd()) setWorktreeOriginalCwd(basePath); const prevCwd = process.cwd(); process.chdir(wtPath); nudgeGitBranchCache(prevCwd); @@ -448,7 +426,7 @@ async function handleSwitch(basePath, name, ctx) { } } async function handleReturn(ctx) { - if (!originalCwd) { + if (!getWorktreeOriginalCwd()) { ctx.ui.notify("Already in the main project tree.", "info"); return; } @@ -458,8 +436,8 @@ async function handleReturn(ctx) { "worktree-return", "worktree", ); - const returnTo = originalCwd; - originalCwd = null; + const returnTo = getWorktreeOriginalCwd(); + clearWorktreeOriginalCwd(); const prevCwd = process.cwd(); process.chdir(returnTo); nudgeGitBranchCache(prevCwd); @@ -514,7 +492,7 @@ const CLR = { }; async function handleList(basePath, ctx) { try { - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; const worktrees = listWorktrees(mainBase); if (worktrees.length === 0) { ctx.ui.notify( @@ -566,8 +544,8 @@ async function handleList(basePath, ctx) { } lines.push(""); } - if (originalCwd) { - lines.push(` ${CLR.label("main tree")} ${CLR.path(originalCwd)}`); + if (getWorktreeOriginalCwd()) { + lines.push(` ${CLR.label("main tree")} ${CLR.path(getWorktreeOriginalCwd())}`); } ctx.ui.notify(lines.join("\n"), "info"); } catch (error) { @@ -668,7 +646,7 @@ async function handleMerge(basePath, name, ctx, pi, targetBranch) { // worktree on restart. originalCwd is cleared only in the success path below. // The registerWorktreeCommand recovery logic reads process.cwd() on reload and // can restore originalCwd for orphaned worktree sessions. - if (originalCwd) { + if (getWorktreeOriginalCwd()) { const prevCwd = process.cwd(); process.chdir(basePath); nudgeGitBranchCache(prevCwd); @@ -691,7 +669,7 @@ async function handleMerge(basePath, name, ctx, pi, targetBranch) { try { mergeWorktreeToMain(basePath, name, commitMessage); // Merge succeeded — safe to clear the worktree tracking state now - originalCwd = null; + clearWorktreeOriginalCwd(); ctx.ui.notify( [ `${CLR.ok("✓")} Merged ${CLR.name(name)} → ${CLR.branch(mainBranch)} ${CLR.muted("(deterministic squash)")}`, @@ -763,7 +741,7 @@ async function handleMerge(basePath, name, ctx, pi, targetBranch) { } async function handleRemove(basePath, name, ctx) { try { - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; // Validate the worktree exists before attempting removal const worktrees = listWorktrees(mainBase); const wt = worktrees.find((w) => w.name === name); @@ -787,9 +765,9 @@ async function handleRemove(basePath, name, ctx) { const prevCwd = process.cwd(); removeWorktree(mainBase, name, { deleteBranch: true }); // If we were in that worktree, removeWorktree chdir'd us out — clear tracking - if (originalCwd && process.cwd() !== prevCwd) { + if (getWorktreeOriginalCwd() && process.cwd() !== prevCwd) { nudgeGitBranchCache(prevCwd); - originalCwd = null; + clearWorktreeOriginalCwd(); } ctx.ui.notify( `${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, @@ -802,7 +780,7 @@ async function handleRemove(basePath, name, ctx) { } async function handleRemoveAll(basePath, ctx) { try { - const mainBase = originalCwd ?? basePath; + const mainBase = getWorktreeOriginalCwd() ?? basePath; const worktrees = listWorktrees(mainBase); if (worktrees.length === 0) { ctx.ui.notify("No worktrees to remove.", "info"); @@ -831,9 +809,9 @@ async function handleRemoveAll(basePath, ctx) { } } // If we were in a worktree that got removed, clear tracking - if (originalCwd && process.cwd() !== prevCwd) { + if (getWorktreeOriginalCwd() && process.cwd() !== prevCwd) { nudgeGitBranchCache(prevCwd); - originalCwd = null; + clearWorktreeOriginalCwd(); } const lines = []; if (removed.length > 0) diff --git a/src/resources/extensions/sf/worktree-session-state.js b/src/resources/extensions/sf/worktree-session-state.js new file mode 100644 index 000000000..8e8344f38 --- /dev/null +++ b/src/resources/extensions/sf/worktree-session-state.js @@ -0,0 +1,52 @@ +/** + * worktree-session-state.js — Shared mutable state for the active worktree session. + * + * Purpose: isolate the originalCwd tracking variable so it can be read by other + * worktree modules (auto-worktree, worktree-root, etc.) without importing the full + * worktree-command.js command handler. + * + * Consumer: worktree-command.js (primary owner), any module that needs + * getActiveWorktreeName() or getWorktreeOriginalCwd() without the command handler. + */ +import { basename, join, normalize, sep } from "node:path"; +let originalCwd = null; +/** Return the original project root if currently in a worktree, or null. */ +export function getWorktreeOriginalCwd() { + return originalCwd; +} +/** Set the original project root when entering a worktree. */ +export function setWorktreeOriginalCwd(cwd) { + originalCwd = cwd; +} +/** Clear the stored root when returning from a worktree. */ +export function clearWorktreeOriginalCwd() { + originalCwd = null; +} +/** + * Detect and set originalCwd from the current process path if not already set. + * Used during /reload recovery when process.cwd() is still inside a worktree but + * the module-level variable was reset. + */ +export function ensureWorktreeOriginalCwdFromPath(cwd = process.cwd()) { + if (originalCwd) return originalCwd; + const marker = `${sep}.sf${sep}worktrees${sep}`; + const markerIdx = cwd.indexOf(marker); + if (markerIdx !== -1) { + originalCwd = cwd.slice(0, markerIdx); + } + return originalCwd; +} +/** + * Return the name of the active worktree, or null if not in one. + * Extracts the first path segment after .sf/worktrees/ using normalize to handle + * both separators correctly. + */ +export function getActiveWorktreeName() { + if (!originalCwd) return null; + const cwd = normalize(process.cwd()); + const wtDir = normalize(join(originalCwd, ".sf", "worktrees")); + if (!cwd.startsWith(wtDir)) return null; + const rel = cwd.slice(wtDir.length).replace(/^[\\/]+/, ""); + const name = basename(rel.split(/[\\/]/)[0] ?? rel); + return name || null; +}