fix(surfaces): stamp correct surface in AutoSession + /mode yolo headless command

Surface stamp:
- AutoSession._loadPersistedModeState() now calls detectSurface() to stamp
  the correct surface (headless/web/tui) from env vars on every startup.
  Persisted surface value was the previous launch's surface — wrong when
  switching between TUI and headless on the same project.
  SF_HEADLESS=1 → 'headless', SF_WEB_BRIDGE_TUI=1 → 'web', else 'tui'.

/mode yolo:
- handleModeCommand now recognises 'yolo' as a toggleable special case.
  Headless callers can now run: sf headless --command '/mode yolo'
  Same behaviour as Ctrl+Y: full-autonomy slam + settingsManager bypass.
  /mode catalog description updated to list 'yolo' as an option.

Documentation:
- headless.ts /query and /doctor short-circuits annotated as intentional
  architecture trade-offs with a note to keep them in sync with the extension.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-09 17:03:33 +02:00
parent 38a654d5e4
commit 995a57335b
4 changed files with 40 additions and 3 deletions

View file

@ -799,7 +799,10 @@ async function runHeadlessOnce(
}
}
// Query: read-only state snapshot, no RPC child needed
// Query: read-only state snapshot, no RPC child needed.
// ARCHITECTURE NOTE: this intentionally bypasses the SF extension dispatcher
// for performance — no child process, direct DB read. If /query gains new
// behaviour in the extension, mirror it here in headless-query.ts.
if (options.command === "query") {
const { handleQuery } = await import("./headless-query.js");
const result = await handleQuery(process.cwd());
@ -807,6 +810,10 @@ async function runHeadlessOnce(
}
// Doctor: read-only health check, no RPC child needed (#4904 live-regression).
// ARCHITECTURE NOTE: this intentionally bypasses the SF extension dispatcher
// for performance and TTY-independence. The interactive `/doctor` command in
// the extension calls the same runSFDoctor() engine function — keep them in
// sync if doctor.js gains new capabilities.
// The interactive `/sf doctor` command lives in the SF extension; this CLI
// path lets non-interactive callers (CI, recovery scripts, the live-regression
// suite) get the same diagnostic without a TTY.

View file

@ -28,6 +28,18 @@ import {
import { loadSessionModeState, saveSessionModeState } from "../sf-db.js";
// ─── Constants ───────────────────────────────────────────────────────────────
/**
* Detect the current surface from environment variables.
* Each surface sets a distinct env var in the child process it spawns.
* This is the only reliable way to identify surface inside the SF extension,
* because the `ctx.surface` field is not propagated to AutoSession directly.
*/
function detectSurface() {
if (process.env.SF_HEADLESS === "1") return "headless";
if (process.env.SF_WEB_BRIDGE_TUI === "1") return "web";
return "tui";
}
export const MAX_UNIT_DISPATCHES = 3;
export const STUB_RECOVERY_THRESHOLD = 2;
export const MAX_LIFETIME_DISPATCHES = 6;
@ -98,12 +110,14 @@ export class AutoSession {
persisted.permissionProfile,
);
this.modelMode = resolveModelMode(persisted.modelMode);
this.surface = persisted.surface ?? "tui";
this.modeUpdatedAt = persisted.updatedAt;
}
} catch {
// DB may not be open yet — use defaults
}
// Always stamp surface from env: persisted value reflects previous launch's
// surface and should not override the current surface detection.
this.surface = detectSurface();
}
// ── Lifecycle ────────────────────────────────────────────────────────────

View file

@ -118,7 +118,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [
{ cmd: "model", desc: "Switch the active session model or open a picker" },
{
cmd: "mode",
desc: "Switch mode: ask (explore/discuss) · plan (structure first) · build (execute autonomously) — or Shift+Tab to cycle",
desc: "Switch mode: ask · plan · build · yolo (full autonomy) — or Shift+Tab to cycle",
},
{ cmd: "control", desc: "Override run control (manual/assisted/autonomous) — advanced" },
{

View file

@ -435,6 +435,22 @@ function handleModeCommand(args, ctx) {
return true;
}
const name = parts[0].toLowerCase();
// "yolo" is a special toggle — not a standard preset
if (name === "yolo") {
const enabled = s.toggleYolo();
if (ctx.settingsManager && ctx.settingsManager.toggleYOLO) {
ctx.settingsManager.toggleYOLO();
}
if (enabled) {
ctx.ui.notify(
"🚀 YOLO ON — build · autonomous · deep · unrestricted · no git prompts",
"success",
);
} else {
ctx.ui.notify("YOLO OFF — mode restored", "info");
}
return true;
}
const preset = resolvePreset(name);
if (preset) {
// If YOLO is active, exit it first so status and safe-git bypass are cleared