diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 8fe3890df..bad24512d 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -19,6 +19,7 @@ import { deriveState } from "../state.js"; import { formatOverridesSection, formatShortcut, loadActiveOverrides, loadFile, parseContinue, parseSummary } from "../files.js"; import { toPosixPath } from "../../shared/mod.js"; import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../../cmux/index.js"; +import { autoEnableCmuxPreferences } from "../commands-cmux.js"; const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); @@ -76,13 +77,16 @@ export async function buildBeforeAgentStartResult( shortcutDashboard: formatShortcut("Ctrl+Alt+G"), shortcutShell: formatShortcut("Ctrl+Alt+B"), }); - const loadedPreferences = loadEffectiveGSDPreferences(); + let loadedPreferences = loadEffectiveGSDPreferences(); if (shouldPromptToEnableCmux(loadedPreferences?.preferences)) { markCmuxPromptShown(); - ctx.ui.notify( - "cmux detected. Run /gsd cmux on to enable sidebar metadata, notifications, and visual subagent splits for this project.", - "info", - ); + if (autoEnableCmuxPreferences()) { + loadedPreferences = loadEffectiveGSDPreferences(); + ctx.ui.notify( + "cmux detected — auto-enabled. Run /gsd cmux off to disable.", + "info", + ); + } } let preferenceBlock = ""; diff --git a/src/resources/extensions/gsd/commands-cmux.ts b/src/resources/extensions/gsd/commands-cmux.ts index e00f2dea2..a1b8f5ee4 100644 --- a/src/resources/extensions/gsd/commands-cmux.ts +++ b/src/resources/extensions/gsd/commands-cmux.ts @@ -1,5 +1,5 @@ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { clearCmuxSidebar, CmuxClient, detectCmuxEnvironment, resolveCmuxConfig } from "../cmux/index.js"; import { saveFile } from "./files.js"; import { @@ -9,6 +9,37 @@ import { } from "./preferences.js"; import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js"; +/** + * Auto-enable cmux in project preferences when detected but never configured. + * Called at boot (before agent start) — no ExtensionCommandContext needed. + * Returns true if preferences were written, false if skipped. + */ +export function autoEnableCmuxPreferences(): boolean { + const path = getProjectGSDPreferencesPath(); + if (!existsSync(path)) return false; + + const existing = loadProjectGSDPreferences(); + const prefs: Record = existing?.preferences ? { ...existing.preferences } : { version: 1 }; + prefs.cmux = { + enabled: true, + notifications: true, + sidebar: true, + splits: false, + browser: false, + ...((prefs.cmux as Record | undefined) ?? {}), + }; + (prefs.cmux as Record).enabled = true; + prefs.version = prefs.version || 1; + + const frontmatter = serializePreferencesToFrontmatter(prefs); + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); + if (preserved) body = preserved; + + writeFileSync(path, `---\n${frontmatter}---${body}`, "utf-8"); + return true; +} + function extractBodyAfterFrontmatter(content: string): string | null { const start = content.startsWith("---\n") ? 4 : content.startsWith("---\r\n") ? 5 : -1; if (start === -1) return null; diff --git a/src/resources/extensions/gsd/tests/cmux.test.ts b/src/resources/extensions/gsd/tests/cmux.test.ts index 0e6dd8e77..305a3ef0d 100644 --- a/src/resources/extensions/gsd/tests/cmux.test.ts +++ b/src/resources/extensions/gsd/tests/cmux.test.ts @@ -1,7 +1,8 @@ -import test, { describe } from "node:test"; +import test, { describe, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { buildCmuxProgress, @@ -12,6 +13,7 @@ import { resolveCmuxConfig, shouldPromptToEnableCmux, } from "../../cmux/index.ts"; +import { autoEnableCmuxPreferences } from "../commands-cmux.ts"; import type { GSDState } from "../types.ts"; test("detectCmuxEnvironment requires workspace, surface, and socket", () => { @@ -79,6 +81,70 @@ test("shouldPromptToEnableCmux only prompts once per session", () => { resetCmuxPromptState(); }); +describe("autoEnableCmuxPreferences", () => { + let tmp: string; + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + tmp = fs.mkdtempSync(path.join(tmpdir(), "cmux-auto-test-")); + fs.mkdirSync(path.join(tmp, ".gsd"), { recursive: true }); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + test("writes cmux.enabled true when preferences file exists with no cmux config", () => { + const prefsPath = path.join(tmp, ".gsd", "preferences.md"); + fs.writeFileSync(prefsPath, [ + "---", + "version: 1", + "---", + "", + "# GSD Skill Preferences", + ].join("\n")); + + const result = autoEnableCmuxPreferences(); + assert.equal(result, true); + + const content = fs.readFileSync(prefsPath, "utf-8"); + assert.ok(content.includes("enabled: true"), "should write enabled: true"); + assert.ok(content.includes("notifications: true"), "should default notifications on"); + assert.ok(content.includes("sidebar: true"), "should default sidebar on"); + assert.ok(content.includes("splits: false"), "should default splits off"); + }); + + test("returns false when preferences file does not exist", () => { + const result = autoEnableCmuxPreferences(); + assert.equal(result, false); + }); + + test("preserves existing cmux sub-preferences when auto-enabling", () => { + const prefsPath = path.join(tmp, ".gsd", "preferences.md"); + fs.writeFileSync(prefsPath, [ + "---", + "version: 1", + "cmux:", + " splits: true", + " browser: true", + "---", + "", + "# GSD Skill Preferences", + ].join("\n")); + + const result = autoEnableCmuxPreferences(); + assert.equal(result, true); + + const content = fs.readFileSync(prefsPath, "utf-8"); + assert.ok(content.includes("enabled: true"), "should set enabled: true"); + assert.ok(content.includes("splits: true"), "should preserve existing splits: true"); + assert.ok(content.includes("browser: true"), "should preserve existing browser: true"); + }); +}); + test("buildCmuxStatusLabel and progress prefer deepest active unit", () => { const state: GSDState = { activeMilestone: { id: "M001", title: "Milestone" },