Merge pull request #3948 from jeremymcs/fix/cmux-auto-enable

fix(gsd): auto-enable cmux when detected
This commit is contained in:
Jeremy McSpadden 2026-04-10 17:16:46 -05:00 committed by GitHub
commit 7a44ca7aed
3 changed files with 108 additions and 7 deletions

View file

@ -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 = "";

View file

@ -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<string, unknown> = existing?.preferences ? { ...existing.preferences } : { version: 1 };
prefs.cmux = {
enabled: true,
notifications: true,
sidebar: true,
splits: false,
browser: false,
...((prefs.cmux as Record<string, unknown> | undefined) ?? {}),
};
(prefs.cmux as Record<string, unknown>).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;

View file

@ -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" },