From b247c3510e43d86e1ed965ec9f594fdfe37fc682 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Thu, 19 Mar 2026 21:05:06 -0500 Subject: [PATCH] feat: integrate cmux with gsd runtime (#1532) --- packages/pi-tui/src/terminal-image.ts | 5 + src/resources/extensions/cmux/index.ts | 384 ++++++++++++++++++ src/resources/extensions/gsd/auto-loop.ts | 42 ++ src/resources/extensions/gsd/auto.ts | 21 + src/resources/extensions/gsd/commands-cmux.ts | 143 +++++++ .../extensions/gsd/commands-prefs-wizard.ts | 2 +- src/resources/extensions/gsd/commands.ts | 42 +- .../gsd/docs/preferences-reference.md | 25 ++ src/resources/extensions/gsd/index.ts | 8 + src/resources/extensions/gsd/notifications.ts | 11 +- .../extensions/gsd/preferences-types.ts | 10 + .../extensions/gsd/preferences-validation.ts | 26 ++ src/resources/extensions/gsd/preferences.ts | 4 + .../extensions/gsd/templates/preferences.md | 6 + .../extensions/gsd/tests/auto-loop.test.ts | 2 + .../extensions/gsd/tests/cmux.test.ts | 98 +++++ .../extensions/gsd/tests/preferences.test.ts | 23 ++ src/resources/extensions/shared/terminal.ts | 5 + src/resources/extensions/subagent/index.ts | 315 ++++++++++---- src/tests/terminal-cmux.test.ts | 30 ++ 20 files changed, 1120 insertions(+), 82 deletions(-) create mode 100644 src/resources/extensions/cmux/index.ts create mode 100644 src/resources/extensions/gsd/commands-cmux.ts create mode 100644 src/resources/extensions/gsd/tests/cmux.test.ts create mode 100644 src/tests/terminal-cmux.test.ts diff --git a/packages/pi-tui/src/terminal-image.ts b/packages/pi-tui/src/terminal-image.ts index 7e219ca99..bb99b279c 100644 --- a/packages/pi-tui/src/terminal-image.ts +++ b/packages/pi-tui/src/terminal-image.ts @@ -41,11 +41,16 @@ export function detectCapabilities(): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; + const isCmux = Boolean(process.env.CMUX_WORKSPACE_ID && process.env.CMUX_SURFACE_ID); if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") { return { images: "kitty", trueColor: true, hyperlinks: true }; } + if (isCmux) { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) { return { images: "kitty", trueColor: true, hyperlinks: true }; } diff --git a/src/resources/extensions/cmux/index.ts b/src/resources/extensions/cmux/index.ts new file mode 100644 index 000000000..bac7d2f81 --- /dev/null +++ b/src/resources/extensions/cmux/index.ts @@ -0,0 +1,384 @@ +import { execFile, execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { promisify } from "node:util"; +import type { GSDPreferences } from "../gsd/preferences.js"; +import type { GSDState, Phase } from "../gsd/types.js"; + +const execFileAsync = promisify(execFile); +const DEFAULT_SOCKET_PATH = "/tmp/cmux.sock"; +const STATUS_KEY = "gsd"; +const lastSidebarSnapshots = new Map(); +let cmuxPromptedThisSession = false; +let cachedCliAvailability: boolean | null = null; + +export interface CmuxEnvironment { + available: boolean; + cliAvailable: boolean; + socketPath: string; + workspaceId?: string; + surfaceId?: string; +} + +export interface ResolvedCmuxConfig extends CmuxEnvironment { + enabled: boolean; + notifications: boolean; + sidebar: boolean; + splits: boolean; + browser: boolean; +} + +export interface CmuxSidebarProgress { + value: number; + label: string; +} + +export type CmuxLogLevel = "info" | "progress" | "success" | "warning" | "error"; + +export function detectCmuxEnvironment( + env: NodeJS.ProcessEnv = process.env, + socketExists: (path: string) => boolean = existsSync, + cliAvailable: () => boolean = isCmuxCliAvailable, +): CmuxEnvironment { + const socketPath = env.CMUX_SOCKET_PATH ?? DEFAULT_SOCKET_PATH; + const workspaceId = env.CMUX_WORKSPACE_ID?.trim() || undefined; + const surfaceId = env.CMUX_SURFACE_ID?.trim() || undefined; + const available = Boolean(workspaceId && surfaceId && socketExists(socketPath)); + return { + available, + cliAvailable: cliAvailable(), + socketPath, + workspaceId, + surfaceId, + }; +} + +export function resolveCmuxConfig( + preferences: GSDPreferences | undefined, + env: NodeJS.ProcessEnv = process.env, + socketExists: (path: string) => boolean = existsSync, + cliAvailable: () => boolean = isCmuxCliAvailable, +): ResolvedCmuxConfig { + const detected = detectCmuxEnvironment(env, socketExists, cliAvailable); + const cmux = preferences?.cmux ?? {}; + const enabled = detected.available && cmux.enabled === true; + return { + ...detected, + enabled, + notifications: enabled && cmux.notifications !== false, + sidebar: enabled && cmux.sidebar !== false, + splits: enabled && cmux.splits === true, + browser: enabled && cmux.browser === true, + }; +} + +export function shouldPromptToEnableCmux( + preferences: GSDPreferences | undefined, + env: NodeJS.ProcessEnv = process.env, + socketExists: (path: string) => boolean = existsSync, + cliAvailable: () => boolean = isCmuxCliAvailable, +): boolean { + if (cmuxPromptedThisSession) return false; + const detected = detectCmuxEnvironment(env, socketExists, cliAvailable); + if (!detected.available) return false; + return preferences?.cmux?.enabled === undefined; +} + +export function markCmuxPromptShown(): void { + cmuxPromptedThisSession = true; +} + +export function resetCmuxPromptState(): void { + cmuxPromptedThisSession = false; +} + +export function isCmuxCliAvailable(): boolean { + if (cachedCliAvailability !== null) return cachedCliAvailability; + try { + execFileSync("cmux", ["--help"], { stdio: "ignore", timeout: 1000 }); + cachedCliAvailability = true; + } catch { + cachedCliAvailability = false; + } + return cachedCliAvailability; +} + +export function supportsOsc777Notifications(env: NodeJS.ProcessEnv = process.env): boolean { + const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? ""; + return termProgram === "ghostty" || termProgram === "wezterm" || termProgram === "iterm.app"; +} + +export function emitOsc777Notification(title: string, body: string): void { + if (!supportsOsc777Notifications()) return; + const safeTitle = normalizeNotificationText(title).replace(/;/g, ","); + const safeBody = normalizeNotificationText(body).replace(/;/g, ","); + process.stdout.write(`\x1b]777;notify;${safeTitle};${safeBody}\x07`); +} + +export function buildCmuxStatusLabel(state: GSDState): string { + const parts: string[] = []; + if (state.activeMilestone) parts.push(state.activeMilestone.id); + if (state.activeSlice) parts.push(state.activeSlice.id); + if (state.activeTask) { + const prev = parts.pop(); + parts.push(prev ? `${prev}/${state.activeTask.id}` : state.activeTask.id); + } + if (parts.length === 0) return state.phase; + return `${parts.join(" ")} · ${state.phase}`; +} + +export function buildCmuxProgress(state: GSDState): CmuxSidebarProgress | null { + const progress = state.progress; + if (!progress) return null; + + const choose = (done: number, total: number, label: string): CmuxSidebarProgress | null => { + if (total <= 0) return null; + return { value: Math.max(0, Math.min(1, done / total)), label: `${done}/${total} ${label}` }; + }; + + return choose(progress.tasks?.done ?? 0, progress.tasks?.total ?? 0, "tasks") + ?? choose(progress.slices?.done ?? 0, progress.slices?.total ?? 0, "slices") + ?? choose(progress.milestones.done, progress.milestones.total, "milestones"); +} + +function phaseVisuals(phase: Phase): { icon: string; color: string } { + switch (phase) { + case "blocked": + return { icon: "triangle-alert", color: "#ef4444" }; + case "paused": + return { icon: "pause", color: "#f59e0b" }; + case "complete": + case "completing-milestone": + return { icon: "check", color: "#22c55e" }; + case "planning": + case "researching": + case "replanning-slice": + return { icon: "compass", color: "#3b82f6" }; + case "validating-milestone": + case "verifying": + return { icon: "shield-check", color: "#06b6d4" }; + default: + return { icon: "rocket", color: "#4ade80" }; + } +} + +function sidebarSnapshotKey(config: ResolvedCmuxConfig): string { + return config.workspaceId ?? "default"; +} + +export class CmuxClient { + private readonly config: ResolvedCmuxConfig; + + constructor(config: ResolvedCmuxConfig) { + this.config = config; + } + + static fromPreferences(preferences: GSDPreferences | undefined): CmuxClient { + return new CmuxClient(resolveCmuxConfig(preferences)); + } + + getConfig(): ResolvedCmuxConfig { + return this.config; + } + + private canRun(): boolean { + return this.config.available && this.config.cliAvailable; + } + + private appendWorkspace(args: string[]): string[] { + return this.config.workspaceId ? [...args, "--workspace", this.config.workspaceId] : args; + } + + private appendSurface(args: string[], surfaceId?: string): string[] { + return surfaceId ? [...args, "--surface", surfaceId] : args; + } + + private runSync(args: string[]): string | null { + if (!this.canRun()) return null; + try { + return execFileSync("cmux", args, { + encoding: "utf-8", + timeout: 3000, + env: process.env, + }); + } catch { + return null; + } + } + + private async runAsync(args: string[]): Promise { + if (!this.canRun()) return null; + try { + const result = await execFileAsync("cmux", args, { + encoding: "utf-8", + timeout: 5000, + env: process.env, + }); + return result.stdout; + } catch { + return null; + } + } + + getCapabilities(): unknown | null { + const stdout = this.runSync(["capabilities", "--json"]); + return stdout ? parseJson(stdout) : null; + } + + identify(): unknown | null { + const stdout = this.runSync(["identify", "--json"]); + return stdout ? parseJson(stdout) : null; + } + + setStatus(label: string, phase: Phase): void { + if (!this.config.sidebar) return; + const visuals = phaseVisuals(phase); + this.runSync(this.appendWorkspace([ + "set-status", + STATUS_KEY, + label, + "--icon", + visuals.icon, + "--color", + visuals.color, + ])); + } + + clearStatus(): void { + if (!this.config.sidebar) return; + this.runSync(this.appendWorkspace(["clear-status", STATUS_KEY])); + } + + setProgress(progress: CmuxSidebarProgress | null): void { + if (!this.config.sidebar) return; + if (!progress) { + this.runSync(this.appendWorkspace(["clear-progress"])); + return; + } + this.runSync(this.appendWorkspace([ + "set-progress", + progress.value.toFixed(3), + "--label", + progress.label, + ])); + } + + log(message: string, level: CmuxLogLevel = "info", source = "gsd"): void { + if (!this.config.sidebar) return; + this.runSync(this.appendWorkspace([ + "log", + "--level", + level, + "--source", + source, + "--", + message, + ])); + } + + notify(title: string, body: string, subtitle?: string): boolean { + if (!this.config.notifications) return false; + const args = ["notify", "--title", title, "--body", body]; + if (subtitle) args.push("--subtitle", subtitle); + return this.runSync(args) !== null; + } + + async listSurfaceIds(): Promise { + const stdout = await this.runAsync(this.appendWorkspace(["list-surfaces", "--json", "--id-format", "both"])); + const parsed = stdout ? parseJson(stdout) : null; + return extractSurfaceIds(parsed); + } + + async createSplit(direction: "right" | "down" | "left" | "up"): Promise { + if (!this.config.splits) return null; + const before = new Set(await this.listSurfaceIds()); + const args = ["new-split", direction]; + const scopedArgs = this.appendSurface(this.appendWorkspace(args), this.config.surfaceId); + await this.runAsync(scopedArgs); + const after = await this.listSurfaceIds(); + for (const id of after) { + if (!before.has(id)) return id; + } + return null; + } + + async sendSurface(surfaceId: string, text: string): Promise { + const payload = text.endsWith("\n") ? text : `${text}\n`; + const stdout = await this.runAsync(["send-surface", "--surface", surfaceId, payload]); + return stdout !== null; + } +} + +export function syncCmuxSidebar(preferences: GSDPreferences | undefined, state: GSDState): void { + const client = CmuxClient.fromPreferences(preferences); + const config = client.getConfig(); + if (!config.sidebar) return; + + const label = buildCmuxStatusLabel(state); + const progress = buildCmuxProgress(state); + const snapshot = JSON.stringify({ label, progress, phase: state.phase }); + const key = sidebarSnapshotKey(config); + if (lastSidebarSnapshots.get(key) === snapshot) return; + + client.setStatus(label, state.phase); + client.setProgress(progress); + lastSidebarSnapshots.set(key, snapshot); +} + +export function clearCmuxSidebar(preferences: GSDPreferences | undefined): void { + const config = resolveCmuxConfig(preferences); + if (!config.available || !config.cliAvailable) return; + const client = new CmuxClient({ ...config, enabled: true, sidebar: true }); + const key = sidebarSnapshotKey(config); + client.clearStatus(); + client.setProgress(null); + lastSidebarSnapshots.delete(key); +} + +export function logCmuxEvent( + preferences: GSDPreferences | undefined, + message: string, + level: CmuxLogLevel = "info", +): void { + CmuxClient.fromPreferences(preferences).log(message, level); +} + +export function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function normalizeNotificationText(value: string): string { + return value.replace(/\r?\n/g, " ").trim(); +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function extractSurfaceIds(value: unknown): string[] { + const found = new Set(); + + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + if (!node || typeof node !== "object") return; + + for (const [key, child] of Object.entries(node as Record)) { + if ( + typeof child === "string" + && (key === "surface_id" || key === "surface" || (key === "id" && child.includes("surface"))) + ) { + found.add(child); + } + visit(child); + } + }; + + visit(value); + return Array.from(found); +} diff --git a/src/resources/extensions/gsd/auto-loop.ts b/src/resources/extensions/gsd/auto-loop.ts index 9ba92275f..7ce847d20 100644 --- a/src/resources/extensions/gsd/auto-loop.ts +++ b/src/resources/extensions/gsd/auto-loop.ts @@ -25,6 +25,7 @@ import type { import type { DispatchAction } from "./auto-dispatch.js"; import type { WorktreeResolver } from "./worktree-resolver.js"; import { debugLog } from "./debug-logger.js"; +import type { CmuxLogLevel } from "../cmux/index.js"; /** * Maximum total loop iterations before forced stop. Prevents runaway loops @@ -276,6 +277,12 @@ export interface LoopDeps { unitId: string, state: GSDState, ) => void; + syncCmuxSidebar: (preferences: GSDPreferences | undefined, state: GSDState) => void; + logCmuxEvent: ( + preferences: GSDPreferences | undefined, + message: string, + level?: CmuxLogLevel, + ) => void; // State and cache functions invalidateAllCaches: () => void; @@ -609,6 +616,7 @@ export async function autoLoop( // Derive state let state = await deps.deriveState(s.basePath); + deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state); let mid = state.activeMilestone?.id; let midTitle = state.activeMilestone?.title; debugLog("autoLoop", { @@ -630,6 +638,11 @@ export async function autoLoop( "success", "milestone", ); + deps.logCmuxEvent( + deps.loadEffectiveGSDPreferences()?.preferences, + `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, + "success", + ); const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences; if (vizPrefs?.auto_visualize) { @@ -767,12 +780,18 @@ export async function autoLoop( "success", "milestone", ); + deps.logCmuxEvent( + deps.loadEffectiveGSDPreferences()?.preferences, + "All milestones complete.", + "success", + ); await deps.stopAuto(ctx, pi, "All milestones complete"); } else if (state.phase === "blocked") { const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; await deps.stopAuto(ctx, pi, blockerMsg); ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error"); } else { const ids = incomplete.map((m: { id: string }) => m.id).join(", "); const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; @@ -850,6 +869,11 @@ export async function autoLoop( "success", "milestone", ); + deps.logCmuxEvent( + deps.loadEffectiveGSDPreferences()?.preferences, + `Milestone ${mid} complete.`, + "success", + ); await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`); debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); break; @@ -871,6 +895,7 @@ export async function autoLoop( await deps.stopAuto(ctx, pi, blockerMsg); ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error"); debugLog("autoLoop", { phase: "exit", reason: "blocked" }); break; } @@ -914,12 +939,14 @@ export async function autoLoop( "warning", ); deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + deps.logCmuxEvent(prefs, msg, "warning"); await deps.pauseAuto(ctx, pi); debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); break; } ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + deps.logCmuxEvent(prefs, msg, "warning"); } else if (newBudgetAlertLevel === 90) { s.lastBudgetAlertLevel = newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; @@ -933,6 +960,11 @@ export async function autoLoop( "warning", "budget", ); + deps.logCmuxEvent( + prefs, + `Budget 90%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "warning", + ); } else if (newBudgetAlertLevel === 80) { s.lastBudgetAlertLevel = newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; @@ -946,6 +978,11 @@ export async function autoLoop( "warning", "budget", ); + deps.logCmuxEvent( + prefs, + `Budget 80%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "warning", + ); } else if (newBudgetAlertLevel === 75) { s.lastBudgetAlertLevel = newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; @@ -959,6 +996,11 @@ export async function autoLoop( "info", "budget", ); + deps.logCmuxEvent( + prefs, + `Budget 75%: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`, + "progress", + ); } else if (budgetAlertLevel === 0) { s.lastBudgetAlertLevel = 0; } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 756c95d5f..c3de44263 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -184,6 +184,7 @@ import { } from "./auto-supervisor.js"; import { isDbAvailable } from "./gsd-db.js"; import { countPendingCaptures } from "./captures.js"; +import { clearCmuxSidebar, logCmuxEvent, syncCmuxSidebar } from "../cmux/index.js"; // ── Extracted modules ────────────────────────────────────────────────────── import { startUnitSupervision } from "./auto-timers.js"; @@ -466,6 +467,7 @@ function handleLostSessionLock(ctx?: ExtensionContext): void { s.paused = false; clearUnitTimeout(); deregisterSigtermHandler(); + clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences); ctx?.ui.notify( "Session lock lost — another GSD process appears to have taken over. Stopping gracefully.", "error", @@ -481,6 +483,7 @@ export async function stopAuto( reason?: string, ): Promise { if (!s.active && !s.paused) return; + const loadedPreferences = loadEffectiveGSDPreferences()?.preferences; const reasonSuffix = reason ? ` — ${reason}` : ""; clearUnitTimeout(); if (lockBase()) clearLock(lockBase()); @@ -543,6 +546,13 @@ export async function stopAuto( } } + clearCmuxSidebar(loadedPreferences); + logCmuxEvent( + loadedPreferences, + `Auto-mode stopped${reasonSuffix || ""}.`, + reason?.startsWith("Blocked:") ? "warning" : "info", + ); + if (isDebugEnabled()) { const logPath = writeDebugSummary(); if (logPath) { @@ -708,6 +718,8 @@ function buildLoopDeps(): LoopDeps { pauseAuto, clearUnitTimeout, updateProgressWidget, + syncCmuxSidebar, + logCmuxEvent, // State and cache invalidateAllCaches, @@ -890,6 +902,7 @@ export async function startAuto( restoreHookState(s.basePath); try { await rebuildState(s.basePath); + syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); } catch (e) { debugLog("resume-rebuild-state-failed", { error: e instanceof Error ? e.message : String(e), @@ -941,6 +954,7 @@ export async function startAuto( s.currentMilestoneId ?? "unknown", s.completedUnits.length, ); + logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); await autoLoop(ctx, pi, s, buildLoopDeps()); return; @@ -965,6 +979,13 @@ export async function startAuto( ); if (!ready) return; + try { + syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); + } catch { + // Best-effort only — sidebar sync must never block auto-mode startup + } + logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress"); + // Dispatch the first unit await autoLoop(ctx, pi, s, buildLoopDeps()); } diff --git a/src/resources/extensions/gsd/commands-cmux.ts b/src/resources/extensions/gsd/commands-cmux.ts new file mode 100644 index 000000000..e00f2dea2 --- /dev/null +++ b/src/resources/extensions/gsd/commands-cmux.ts @@ -0,0 +1,143 @@ +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { existsSync, readFileSync } from "node:fs"; +import { clearCmuxSidebar, CmuxClient, detectCmuxEnvironment, resolveCmuxConfig } from "../cmux/index.js"; +import { saveFile } from "./files.js"; +import { + getProjectGSDPreferencesPath, + loadEffectiveGSDPreferences, + loadProjectGSDPreferences, +} from "./preferences.js"; +import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js"; + +function extractBodyAfterFrontmatter(content: string): string | null { + const start = content.startsWith("---\n") ? 4 : content.startsWith("---\r\n") ? 5 : -1; + if (start === -1) return null; + const closingIdx = content.indexOf("\n---", start); + if (closingIdx === -1) return null; + const after = content.slice(closingIdx + 4); + return after.trim() ? after : null; +} + +async function writeProjectCmuxPreferences( + ctx: ExtensionCommandContext, + updater: (prefs: Record) => void, +): Promise { + const path = getProjectGSDPreferencesPath(); + await ensurePreferencesFile(path, ctx, "project"); + + const existing = loadProjectGSDPreferences(); + const prefs: Record = existing?.preferences ? { ...existing.preferences } : { version: 1 }; + updater(prefs); + 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"; + if (existsSync(path)) { + const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); + if (preserved) body = preserved; + } + + await saveFile(path, `---\n${frontmatter}---${body}`); + await ctx.waitForIdle(); + await ctx.reload(); +} + +function formatCmuxStatus(): string { + const loaded = loadEffectiveGSDPreferences(); + const detected = detectCmuxEnvironment(); + const resolved = resolveCmuxConfig(loaded?.preferences); + const capabilities = new CmuxClient(resolved).getCapabilities() as Record | null; + const accessMode = typeof capabilities?.mode === "string" + ? capabilities.mode + : typeof capabilities?.access_mode === "string" + ? capabilities.access_mode + : "unknown"; + const methods = Array.isArray(capabilities?.methods) ? capabilities.methods.length : 0; + + return [ + "cmux status", + "", + `Detected: ${detected.available ? "yes" : "no"}`, + `Enabled: ${resolved.enabled ? "yes" : "no"}`, + `CLI available: ${detected.cliAvailable ? "yes" : "no"}`, + `Socket: ${detected.socketPath}`, + `Workspace: ${detected.workspaceId ?? "(none)"}`, + `Surface: ${detected.surfaceId ?? "(none)"}`, + `Features: notifications=${resolved.notifications ? "on" : "off"}, sidebar=${resolved.sidebar ? "on" : "off"}, splits=${resolved.splits ? "on" : "off"}, browser=${resolved.browser ? "on" : "off"}`, + `Capabilities: access=${accessMode}, methods=${methods}`, + ].join("\n"); +} + +function ensureCmuxAvailableForEnable(ctx: ExtensionCommandContext): boolean { + const detected = detectCmuxEnvironment(); + if (detected.available) return true; + ctx.ui.notify( + "cmux not detected. Install it from https://cmux.com and run gsd inside a cmux terminal.", + "warning", + ); + return false; +} + +export async function handleCmux(args: string, ctx: ExtensionCommandContext): Promise { + const trimmed = args.trim(); + if (!trimmed || trimmed === "status") { + ctx.ui.notify(formatCmuxStatus(), "info"); + return; + } + + if (trimmed === "on") { + if (!ensureCmuxAvailableForEnable(ctx)) return; + await writeProjectCmuxPreferences(ctx, (prefs) => { + prefs.cmux = { + enabled: true, + notifications: true, + sidebar: true, + splits: false, + browser: false, + ...((prefs.cmux as Record | undefined) ?? {}), + }; + (prefs.cmux as Record).enabled = true; + }); + ctx.ui.notify("cmux integration enabled in project preferences.", "info"); + return; + } + + if (trimmed === "off") { + const effective = loadEffectiveGSDPreferences()?.preferences; + await writeProjectCmuxPreferences(ctx, (prefs) => { + prefs.cmux = { ...((prefs.cmux as Record | undefined) ?? {}), enabled: false }; + }); + clearCmuxSidebar(effective); + ctx.ui.notify("cmux integration disabled in project preferences.", "info"); + return; + } + + const parts = trimmed.split(/\s+/); + if (parts.length === 2 && ["notifications", "sidebar", "splits", "browser"].includes(parts[0]) && ["on", "off"].includes(parts[1])) { + const feature = parts[0] as "notifications" | "sidebar" | "splits" | "browser"; + const enabled = parts[1] === "on"; + if (enabled && !ensureCmuxAvailableForEnable(ctx)) return; + + await writeProjectCmuxPreferences(ctx, (prefs) => { + const next = { ...((prefs.cmux as Record | undefined) ?? {}) }; + next[feature] = enabled; + if (enabled) next.enabled = true; + prefs.cmux = next; + }); + + if (!enabled && feature === "sidebar") { + clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences); + } + + const note = feature === "browser" && enabled + ? " Browser surfaces are still a follow-up path." + : ""; + ctx.ui.notify(`cmux ${feature} ${enabled ? "enabled" : "disabled"}.${note}`, "info"); + return; + } + + ctx.ui.notify( + "Usage: /gsd cmux ", + "info", + ); +} diff --git a/src/resources/extensions/gsd/commands-prefs-wizard.ts b/src/resources/extensions/gsd/commands-prefs-wizard.ts index a0baf1035..4ec4c3dc1 100644 --- a/src/resources/extensions/gsd/commands-prefs-wizard.ts +++ b/src/resources/extensions/gsd/commands-prefs-wizard.ts @@ -740,7 +740,7 @@ export function serializePreferencesToFrontmatter(prefs: Record "skill_rules", "custom_instructions", "models", "skill_discovery", "skill_staleness_days", "auto_supervisor", "uat_dispatch", "unique_milestone_ids", "budget_ceiling", "budget_enforcement", "context_pause_threshold", - "notifications", "remote_questions", "git", + "notifications", "cmux", "remote_questions", "git", "post_unit_hooks", "pre_dispatch_hooks", "dynamic_routing", "token_profile", "phases", "parallel", "auto_visualize", "auto_report", diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index be318cef8..e0d415602 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -49,6 +49,7 @@ import { runEnvironmentChecks } from "./doctor-environment.js"; 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"; /** Resolve the effective project root, accounting for worktree paths. */ @@ -105,7 +106,7 @@ function notifyRemoteAutoActive(ctx: ExtensionCommandContext, basePath: string): export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|update", + description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|update", getArgumentCompletions: (prefix: string) => { const subcommands = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -147,6 +148,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { { cmd: "knowledge", desc: "Add persistent project knowledge (rule, pattern, or lesson)" }, { cmd: "new-milestone", desc: "Create a milestone from a specification document (headless)" }, { cmd: "parallel", desc: "Parallel milestone orchestration (start, status, stop, merge)" }, + { cmd: "cmux", desc: "Manage cmux integration (status, sidebar, notifications, splits)" }, { cmd: "park", desc: "Park a milestone — skip without deleting" }, { cmd: "unpark", desc: "Reactivate a parked milestone" }, { cmd: "update", desc: "Update GSD to the latest version" }, @@ -203,6 +205,38 @@ export function registerGSDCommand(pi: ExtensionAPI): void { .map((s) => ({ value: `parallel ${s.cmd}`, label: s.cmd, description: s.desc })); } + if (parts[0] === "cmux") { + if (parts.length <= 2) { + const subPrefix = parts[1] ?? ""; + const subs = [ + { cmd: "status", desc: "Show cmux detection, prefs, and capabilities" }, + { cmd: "on", desc: "Enable cmux integration" }, + { cmd: "off", desc: "Disable cmux integration" }, + { cmd: "notifications", desc: "Toggle cmux desktop notifications" }, + { cmd: "sidebar", desc: "Toggle cmux sidebar metadata" }, + { cmd: "splits", desc: "Toggle cmux visual subagent splits" }, + { cmd: "browser", desc: "Toggle future browser integration flag" }, + ]; + return subs + .filter((s) => s.cmd.startsWith(subPrefix)) + .map((s) => ({ value: `cmux ${s.cmd}`, label: s.cmd, description: s.desc })); + } + + if (parts.length <= 3 && ["notifications", "sidebar", "splits", "browser"].includes(parts[1])) { + const togglePrefix = parts[2] ?? ""; + return [ + { cmd: "on", desc: "Enable this cmux area" }, + { cmd: "off", desc: "Disable this cmux area" }, + ] + .filter((item) => item.cmd.startsWith(togglePrefix)) + .map((item) => ({ + value: `cmux ${parts[1]} ${item.cmd}`, + label: item.cmd, + description: item.desc, + })); + } + } + if (parts[0] === "setup" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; const subs = [ @@ -493,6 +527,11 @@ export async function handleGSDCommand( return; } + if (trimmed === "cmux" || trimmed.startsWith("cmux ")) { + await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx); + return; + } + if (trimmed === "init") { const { detectProjectState } = await import("./detection.js"); const { showProjectInit, handleReinit } = await import("./init-wizard.js"); @@ -996,6 +1035,7 @@ function showHelp(ctx: ExtensionCommandContext): void { " /gsd setup Global setup status [llm|search|remote|keys|prefs]", " /gsd mode Set workflow mode (solo/team) [global|project]", " /gsd prefs Manage preferences [global|project|status|wizard|setup|import-claude]", + " /gsd cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", " /gsd config Set API keys for external tools", " /gsd keys API key manager [list|add|remove|test|rotate|doctor]", " /gsd hooks Show post-unit hook configuration", diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index ac4d48f21..290eb446c 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -173,6 +173,13 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `on_milestone`: boolean — notify when a milestone finishes. Default: `true`. - `on_attention`: boolean — notify when manual attention is needed. Default: `true`. +- `cmux`: configures cmux terminal integration when GSD is running inside a cmux workspace. Keys: + - `enabled`: boolean — master toggle for cmux integration. Default: `false`. + - `notifications`: boolean — route desktop notifications through cmux. Default: `true` when enabled. + - `sidebar`: boolean — publish status, progress, and log metadata to the cmux sidebar. Default: `true` when enabled. + - `splits`: boolean — run supported subagent work in visible cmux splits. Default: `false`. + - `browser`: boolean — reserve the future browser integration flag. Default: `false`. + - `dynamic_routing`: configures the dynamic model router that adjusts model selection based on task complexity. Keys: - `enabled`: boolean — enable dynamic routing. Default: `false`. - `tier_models`: object — model overrides per complexity tier. Keys: `light`, `standard`, `heavy`. Values are model ID strings. @@ -477,6 +484,24 @@ Disables per-unit completion notifications (noisy in long runs) while keeping er --- +## cmux Example + +```yaml +--- +version: 1 +cmux: + enabled: true + notifications: true + sidebar: true + splits: true + browser: false +--- +``` + +Enables cmux-aware notifications, sidebar metadata, and visible subagent splits when GSD is running inside a cmux terminal. + +--- + ## Post-Unit Hooks Example ```yaml diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 8a5fd8d96..a72b92abc 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -65,6 +65,7 @@ import { pauseAutoForProviderError, classifyProviderError } from "./provider-err import { toPosixPath } from "../shared/mod.js"; import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js"; import { DEFAULT_BASH_TIMEOUT_SECS } from "./constants.js"; +import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../cmux/index.js"; // ── Agent Instructions (DEPRECATED) ────────────────────────────────────── // agent-instructions.md is deprecated. Use AGENTS.md or CLAUDE.md instead. @@ -623,6 +624,13 @@ export default function (pi: ExtensionAPI) { const stopContextTimer = debugTime("context-inject"); const systemContent = loadPrompt("system"); const 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", + ); + } let preferenceBlock = ""; if (loadedPreferences) { const cwd = process.cwd(); diff --git a/src/resources/extensions/gsd/notifications.ts b/src/resources/extensions/gsd/notifications.ts index c7ac30f80..901d48819 100644 --- a/src/resources/extensions/gsd/notifications.ts +++ b/src/resources/extensions/gsd/notifications.ts @@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process"; import type { NotificationPreferences } from "./types.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { CmuxClient, emitOsc777Notification, resolveCmuxConfig } from "../cmux/index.js"; export type NotifyLevel = "info" | "success" | "warning" | "error"; export type NotificationKind = "complete" | "error" | "budget" | "milestone" | "attention"; @@ -23,7 +24,15 @@ export function sendDesktopNotification( level: NotifyLevel = "info", kind: NotificationKind = "complete", ): void { - if (!shouldSendDesktopNotification(kind)) return; + const loaded = loadEffectiveGSDPreferences()?.preferences; + if (!shouldSendDesktopNotification(kind, loaded?.notifications)) return; + + const cmux = resolveCmuxConfig(loaded); + if (cmux.notifications) { + const delivered = CmuxClient.fromPreferences(loaded).notify(title, message); + if (delivered) return; + emitOsc777Notification(title, message); + } try { const command = buildDesktopNotificationCommand(process.platform, title, message, level); diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 342a316c2..193cf4f97 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -68,6 +68,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "budget_enforcement", "context_pause_threshold", "notifications", + "cmux", "remote_questions", "git", "post_unit_hooks", @@ -164,6 +165,14 @@ export interface RemoteQuestionsConfig { poll_interval_seconds?: number; // clamped to 2-30 } +export interface CmuxPreferences { + enabled?: boolean; + notifications?: boolean; + sidebar?: boolean; + splits?: boolean; + browser?: boolean; +} + export interface GSDPreferences { version?: number; mode?: WorkflowMode; @@ -182,6 +191,7 @@ export interface GSDPreferences { budget_enforcement?: BudgetEnforcementMode; context_pause_threshold?: number; notifications?: NotificationPreferences; + cmux?: CmuxPreferences; remote_questions?: RemoteQuestionsConfig; git?: GitPreferences; post_unit_hooks?: PostUnitHookConfig[]; diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index f09695b2e..9732ab369 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -242,6 +242,32 @@ export function validatePreferences(preferences: GSDPreferences): { } } + // ─── Cmux ─────────────────────────────────────────────────────────────── + if (preferences.cmux !== undefined) { + if (preferences.cmux && typeof preferences.cmux === "object") { + const cmux = preferences.cmux as Record; + const validatedCmux: NonNullable = {}; + if (cmux.enabled !== undefined) validatedCmux.enabled = !!cmux.enabled; + if (cmux.notifications !== undefined) validatedCmux.notifications = !!cmux.notifications; + if (cmux.sidebar !== undefined) validatedCmux.sidebar = !!cmux.sidebar; + if (cmux.splits !== undefined) validatedCmux.splits = !!cmux.splits; + if (cmux.browser !== undefined) validatedCmux.browser = !!cmux.browser; + + const knownCmuxKeys = new Set(["enabled", "notifications", "sidebar", "splits", "browser"]); + for (const key of Object.keys(cmux)) { + if (!knownCmuxKeys.has(key)) { + warnings.push(`unknown cmux key "${key}" — ignored`); + } + } + + if (Object.keys(validatedCmux).length > 0) { + validated.cmux = validatedCmux; + } + } else { + errors.push("cmux must be an object"); + } + } + // ─── Remote Questions ─────────────────────────────────────────────── if (preferences.remote_questions !== undefined) { if (preferences.remote_questions && typeof preferences.remote_questions === "object") { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 1ea1a037a..ef748b3eb 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -45,6 +45,7 @@ export type { SkillDiscoveryMode, AutoSupervisorConfig, RemoteQuestionsConfig, + CmuxPreferences, GSDPreferences, LoadedGSDPreferences, SkillResolution, @@ -241,6 +242,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr notifications: (base.notifications || override.notifications) ? { ...(base.notifications ?? {}), ...(override.notifications ?? {}) } : undefined, + cmux: (base.cmux || override.cmux) + ? { ...(base.cmux ?? {}), ...(override.cmux ?? {}) } + : undefined, remote_questions: override.remote_questions ? { ...(base.remote_questions ?? {}), ...override.remote_questions } : base.remote_questions, diff --git a/src/resources/extensions/gsd/templates/preferences.md b/src/resources/extensions/gsd/templates/preferences.md index c0ce5aec6..83fcde1a2 100644 --- a/src/resources/extensions/gsd/templates/preferences.md +++ b/src/resources/extensions/gsd/templates/preferences.md @@ -57,6 +57,12 @@ notifications: on_budget: on_milestone: on_attention: +cmux: + enabled: + notifications: + sidebar: + splits: + browser: remote_questions: channel: channel_id: diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index e018b3cd6..f5dfe4db3 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -317,6 +317,8 @@ function makeMockDeps( }, clearUnitTimeout: () => {}, updateProgressWidget: () => {}, + syncCmuxSidebar: () => {}, + logCmuxEvent: () => {}, invalidateAllCaches: () => { callLog.push("invalidateAllCaches"); }, diff --git a/src/resources/extensions/gsd/tests/cmux.test.ts b/src/resources/extensions/gsd/tests/cmux.test.ts new file mode 100644 index 000000000..2efbed1a8 --- /dev/null +++ b/src/resources/extensions/gsd/tests/cmux.test.ts @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildCmuxProgress, + buildCmuxStatusLabel, + detectCmuxEnvironment, + markCmuxPromptShown, + resetCmuxPromptState, + resolveCmuxConfig, + shouldPromptToEnableCmux, +} from "../../cmux/index.ts"; +import type { GSDState } from "../types.ts"; + +test("detectCmuxEnvironment requires workspace, surface, and socket", () => { + const detected = detectCmuxEnvironment( + { + CMUX_WORKSPACE_ID: "workspace:1", + CMUX_SURFACE_ID: "surface:2", + CMUX_SOCKET_PATH: "/tmp/cmux.sock", + }, + (path) => path === "/tmp/cmux.sock", + () => true, + ); + assert.equal(detected.available, true); + assert.equal(detected.cliAvailable, true); +}); + +test("resolveCmuxConfig enables only when preference and environment are both active", () => { + const config = resolveCmuxConfig( + { cmux: { enabled: true, notifications: true, sidebar: true, splits: true } }, + { + CMUX_WORKSPACE_ID: "workspace:1", + CMUX_SURFACE_ID: "surface:2", + CMUX_SOCKET_PATH: "/tmp/cmux.sock", + }, + () => true, + () => true, + ); + assert.equal(config.enabled, true); + assert.equal(config.notifications, true); + assert.equal(config.sidebar, true); + assert.equal(config.splits, true); +}); + +test("shouldPromptToEnableCmux only prompts once per session", () => { + resetCmuxPromptState(); + assert.equal(shouldPromptToEnableCmux({}, {}, () => false, () => true), false); + + assert.equal( + shouldPromptToEnableCmux( + {}, + { + CMUX_WORKSPACE_ID: "workspace:1", + CMUX_SURFACE_ID: "surface:2", + CMUX_SOCKET_PATH: "/tmp/cmux.sock", + }, + () => true, + () => true, + ), + true, + ); + markCmuxPromptShown(); + assert.equal( + shouldPromptToEnableCmux( + {}, + { + CMUX_WORKSPACE_ID: "workspace:1", + CMUX_SURFACE_ID: "surface:2", + CMUX_SOCKET_PATH: "/tmp/cmux.sock", + }, + () => true, + () => true, + ), + false, + ); + resetCmuxPromptState(); +}); + +test("buildCmuxStatusLabel and progress prefer deepest active unit", () => { + const state: GSDState = { + activeMilestone: { id: "M001", title: "Milestone" }, + activeSlice: { id: "S02", title: "Slice" }, + activeTask: { id: "T03", title: "Task" }, + phase: "executing", + recentDecisions: [], + blockers: [], + nextAction: "Keep going", + registry: [], + progress: { + milestones: { done: 0, total: 1 }, + slices: { done: 1, total: 3 }, + tasks: { done: 2, total: 5 }, + }, + }; + + assert.equal(buildCmuxStatusLabel(state), "M001 S02/T03 · executing"); + assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" }); +}); diff --git a/src/resources/extensions/gsd/tests/preferences.test.ts b/src/resources/extensions/gsd/tests/preferences.test.ts index f129c19d0..52080fbb8 100644 --- a/src/resources/extensions/gsd/tests/preferences.test.ts +++ b/src/resources/extensions/gsd/tests/preferences.test.ts @@ -171,6 +171,29 @@ test("notification fields validate correctly", () => { assert.equal(preferences.notifications?.on_complete, false); }); +test("cmux fields validate correctly", () => { + const { preferences, errors } = validatePreferences({ + cmux: { + enabled: true, + notifications: true, + sidebar: false, + splits: true, + browser: false, + }, + }); + assert.equal(errors.length, 0); + assert.equal(preferences.cmux?.enabled, true); + assert.equal(preferences.cmux?.sidebar, false); + assert.equal(preferences.cmux?.splits, true); +}); + +test("cmux unknown keys produce warnings", () => { + const { warnings } = validatePreferences({ + cmux: { enabled: true, strange_mode: true } as any, + }); + assert.ok(warnings.some((warning) => warning.includes('unknown cmux key "strange_mode"'))); +}); + test("git fields comprehensive validation", () => { const { preferences, errors } = validatePreferences({ git: { diff --git a/src/resources/extensions/shared/terminal.ts b/src/resources/extensions/shared/terminal.ts index 5c6f0f889..0705d5248 100644 --- a/src/resources/extensions/shared/terminal.ts +++ b/src/resources/extensions/shared/terminal.ts @@ -7,9 +7,14 @@ const UNSUPPORTED_TERMS = ["apple_terminal", "warpterm"]; +export function isCmuxTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.CMUX_WORKSPACE_ID && env.CMUX_SURFACE_ID); +} + export function supportsCtrlAltShortcuts(): boolean { const term = (process.env.TERM_PROGRAM || "").toLowerCase(); const jetbrains = (process.env.TERMINAL_EMULATOR || "").toLowerCase().includes("jetbrains"); + if (isCmuxTerminal()) return true; return !UNSUPPORTED_TERMS.some((t) => term.includes(t)) && !jetbrains; } diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index 46beea372..b60e8b2d6 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -34,6 +34,8 @@ import { readIsolationMode, } from "./isolation.js"; import { registerWorker, updateWorker } from "./worker-registry.js"; +import { loadEffectiveGSDPreferences } from "../gsd/preferences.js"; +import { CmuxClient, shellEscape } from "../cmux/index.js"; const MAX_PARALLEL_TASKS = 8; const MAX_CONCURRENCY = 4; @@ -257,6 +259,70 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string return { dir: tmpDir, filePath }; } +function buildSubagentProcessArgs( + agent: AgentConfig, + task: string, + tmpPromptPath: string | null, +): string[] { + const args: string[] = ["--mode", "json", "-p", "--no-session"]; + if (agent.model) args.push("--model", agent.model); + if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); + if (tmpPromptPath) args.push("--append-system-prompt", tmpPromptPath); + args.push(`Task: ${task}`); + return args; +} + +function processSubagentEventLine( + line: string, + currentResult: SingleResult, + emitUpdate: () => void, +): void { + if (!line.trim()) return; + let event: any; + try { + event = JSON.parse(line); + } catch { + return; + } + + if (event.type === "message_end" && event.message) { + const msg = event.message as Message; + currentResult.messages.push(msg); + + if (msg.role === "assistant") { + currentResult.usage.turns++; + const usage = msg.usage; + if (usage) { + currentResult.usage.input += usage.input || 0; + currentResult.usage.output += usage.output || 0; + currentResult.usage.cacheRead += usage.cacheRead || 0; + currentResult.usage.cacheWrite += usage.cacheWrite || 0; + currentResult.usage.cost += usage.cost?.total || 0; + currentResult.usage.contextTokens = usage.totalTokens || 0; + } + if (!currentResult.model && msg.model) currentResult.model = msg.model; + if (msg.stopReason) currentResult.stopReason = msg.stopReason; + if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage; + } + emitUpdate(); + } + + if (event.type === "tool_result_end" && event.message) { + currentResult.messages.push(event.message as Message); + emitUpdate(); + } +} + +async function waitForFile(filePath: string, signal: AbortSignal | undefined, timeoutMs = 30 * 60 * 1000): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (signal?.aborted) return false; + if (fs.existsSync(filePath)) return true; + await new Promise((resolve) => setTimeout(resolve, 150)); + } + return false; +} + type OnUpdateCallback = (partial: AgentToolResult) => void; async function runSingleAgent( @@ -286,10 +352,6 @@ async function runSingleAgent( }; } - const args: string[] = ["--mode", "json", "-p", "--no-session"]; - if (agent.model) args.push("--model", agent.model); - if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); - let tmpPromptDir: string | null = null; let tmpPromptPath: string | null = null; @@ -319,10 +381,8 @@ async function runSingleAgent( const tmp = writePromptToTempFile(agent.name, agent.systemPrompt); tmpPromptDir = tmp.dir; tmpPromptPath = tmp.filePath; - args.push("--append-system-prompt", tmpPromptPath); } - - args.push(`Task: ${task}`); + const args = buildSubagentProcessArgs(agent, task, tmpPromptPath); let wasAborted = false; const exitCode = await new Promise((resolve) => { @@ -336,48 +396,11 @@ async function runSingleAgent( liveSubagentProcesses.add(proc); let buffer = ""; - const processLine = (line: string) => { - if (!line.trim()) return; - let event: any; - try { - event = JSON.parse(line); - } catch { - return; - } - - if (event.type === "message_end" && event.message) { - const msg = event.message as Message; - currentResult.messages.push(msg); - - if (msg.role === "assistant") { - currentResult.usage.turns++; - const usage = msg.usage; - if (usage) { - currentResult.usage.input += usage.input || 0; - currentResult.usage.output += usage.output || 0; - currentResult.usage.cacheRead += usage.cacheRead || 0; - currentResult.usage.cacheWrite += usage.cacheWrite || 0; - currentResult.usage.cost += usage.cost?.total || 0; - currentResult.usage.contextTokens = usage.totalTokens || 0; - } - if (!currentResult.model && msg.model) currentResult.model = msg.model; - if (msg.stopReason) currentResult.stopReason = msg.stopReason; - if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage; - } - emitUpdate(); - } - - if (event.type === "tool_result_end" && event.message) { - currentResult.messages.push(event.message as Message); - emitUpdate(); - } - }; - proc.stdout.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; - for (const line of lines) processLine(line); + for (const line of lines) processSubagentEventLine(line, currentResult, emitUpdate); }); proc.stderr.on("data", (data) => { @@ -386,7 +409,7 @@ async function runSingleAgent( proc.on("close", (code) => { liveSubagentProcesses.delete(proc); - if (buffer.trim()) processLine(buffer); + if (buffer.trim()) processSubagentEventLine(buffer, currentResult, emitUpdate); resolve(code ?? 0); }); @@ -427,6 +450,120 @@ async function runSingleAgent( } } +async function runSingleAgentInCmuxSplit( + cmuxClient: CmuxClient, + direction: "right" | "down", + defaultCwd: string, + agents: AgentConfig[], + agentName: string, + task: string, + cwd: string | undefined, + step: number | undefined, + signal: AbortSignal | undefined, + onUpdate: OnUpdateCallback | undefined, + makeDetails: (results: SingleResult[]) => SubagentDetails, +): Promise { + const agent = agents.find((a) => a.name === agentName); + if (!agent) { + return runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails); + } + + let tmpPromptDir: string | null = null; + let tmpPromptPath: string | null = null; + let tmpOutputDir: string | null = null; + + const currentResult: SingleResult = { + agent: agentName, + agentSource: agent.source, + task, + exitCode: 0, + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + model: agent.model, + step, + }; + + const emitUpdate = () => { + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }], + details: makeDetails([currentResult]), + }); + } + }; + + try { + if (agent.systemPrompt.trim()) { + const tmp = writePromptToTempFile(agent.name, agent.systemPrompt); + tmpPromptDir = tmp.dir; + tmpPromptPath = tmp.filePath; + } + tmpOutputDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-cmux-")); + const stdoutPath = path.join(tmpOutputDir, "stdout.jsonl"); + const stderrPath = path.join(tmpOutputDir, "stderr.log"); + const exitPath = path.join(tmpOutputDir, "exit.code"); + const cmuxSurfaceId = await cmuxClient.createSplit(direction); + if (!cmuxSurfaceId) { + return runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails); + } + + const bundledPaths = (process.env.GSD_BUNDLED_EXTENSION_PATHS ?? "").split(path.delimiter).map((s) => s.trim()).filter(Boolean); + const extensionArgs = bundledPaths.flatMap((p) => ["--extension", p]); + const processArgs = [process.env.GSD_BIN_PATH!, ...extensionArgs, ...buildSubagentProcessArgs(agent, task, tmpPromptPath)]; + const innerScript = [ + `cd ${shellEscape(cwd ?? defaultCwd)}`, + "set -o pipefail", + `${shellEscape(process.execPath)} ${processArgs.map(shellEscape).join(" ")} 2> >(tee ${shellEscape(stderrPath)} >&2) | tee ${shellEscape(stdoutPath)}`, + "status=${PIPESTATUS[0]}", + `printf '%s' "$status" > ${shellEscape(exitPath)}`, + ].join("; "); + + const sent = await cmuxClient.sendSurface(cmuxSurfaceId, `bash -lc ${shellEscape(innerScript)}`); + if (!sent) { + return runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails); + } + + const finished = await waitForFile(exitPath, signal); + if (!finished) { + currentResult.exitCode = 1; + currentResult.stderr = "cmux split execution timed out or was aborted"; + return currentResult; + } + + if (fs.existsSync(stdoutPath)) { + const stdout = fs.readFileSync(stdoutPath, "utf-8"); + for (const line of stdout.split("\n")) { + processSubagentEventLine(line, currentResult, emitUpdate); + } + } + if (fs.existsSync(stderrPath)) { + currentResult.stderr = fs.readFileSync(stderrPath, "utf-8"); + } + currentResult.exitCode = Number.parseInt(fs.readFileSync(exitPath, "utf-8").trim() || "1", 10) || 0; + return currentResult; + } finally { + if (tmpPromptPath) + try { + fs.unlinkSync(tmpPromptPath); + } catch { + /* ignore */ + } + if (tmpPromptDir) + try { + fs.rmdirSync(tmpPromptDir); + } catch { + /* ignore */ + } + if (tmpOutputDir) + try { + fs.rmSync(tmpOutputDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +} + const TaskItem = Type.Object({ agent: Type.String({ description: "Name of the agent to invoke" }), task: Type.String({ description: "Task to delegate to the agent" }), @@ -511,6 +648,8 @@ export default function (pi: ExtensionAPI) { const discovery = discoverAgents(ctx.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? false; + const cmuxClient = CmuxClient.fromPreferences(loadEffectiveGSDPreferences()?.preferences); + const cmuxSplitsEnabled = cmuxClient.getConfig().splits; // Resolve isolation mode const isolationMode = readIsolationMode(); @@ -669,28 +808,26 @@ export default function (pi: ExtensionAPI) { const batchSize = params.tasks.length; const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { const workerId = registerWorker(t.agent, t.task, index, batchSize, batchId); - let result = await runSingleAgent( - ctx.cwd, - agents, - t.agent, - t.task, - t.cwd, - undefined, - signal, - // Per-task update callback - (partial) => { - if (partial.details?.results[0]) { - allResults[index] = partial.details.results[0]; - emitParallelUpdate(); - } - }, - makeDetails("parallel"), - ); - - // Auto-retry failed tasks (likely API rate limit or transient error) - const isFailed = result.exitCode !== 0 || (result.messages.length === 0 && !signal?.aborted); - if (isFailed && MAX_RETRIES > 0 && !signal?.aborted) { - result = await runSingleAgent( + const runTask = () => cmuxSplitsEnabled + ? runSingleAgentInCmuxSplit( + cmuxClient, + index % 2 === 0 ? "right" : "down", + ctx.cwd, + agents, + t.agent, + t.task, + t.cwd, + undefined, + signal, + (partial) => { + if (partial.details?.results[0]) { + allResults[index] = partial.details.results[0]; + emitParallelUpdate(); + } + }, + makeDetails("parallel"), + ) + : runSingleAgent( ctx.cwd, agents, t.agent, @@ -706,6 +843,12 @@ export default function (pi: ExtensionAPI) { }, makeDetails("parallel"), ); + let result = await runTask(); + + // Auto-retry failed tasks (likely API rate limit or transient error) + const isFailed = result.exitCode !== 0 || (result.messages.length === 0 && !signal?.aborted); + if (isFailed && MAX_RETRIES > 0 && !signal?.aborted) { + result = await runTask(); } updateWorker(workerId, result.exitCode === 0 ? "completed" : "failed"); @@ -744,17 +887,31 @@ export default function (pi: ExtensionAPI) { isolation = await createIsolation(effectiveCwd, taskId, isolationMode); } - const result = await runSingleAgent( - ctx.cwd, - agents, - params.agent, - params.task, - isolation ? isolation.workDir : params.cwd, - undefined, - signal, - onUpdate, - makeDetails("single"), - ); + const result = cmuxSplitsEnabled + ? await runSingleAgentInCmuxSplit( + cmuxClient, + "right", + ctx.cwd, + agents, + params.agent, + params.task, + isolation ? isolation.workDir : params.cwd, + undefined, + signal, + onUpdate, + makeDetails("single"), + ) + : await runSingleAgent( + ctx.cwd, + agents, + params.agent, + params.task, + isolation ? isolation.workDir : params.cwd, + undefined, + signal, + onUpdate, + makeDetails("single"), + ); // Capture and merge delta if isolated if (isolation) { diff --git a/src/tests/terminal-cmux.test.ts b/src/tests/terminal-cmux.test.ts new file mode 100644 index 000000000..97e89d096 --- /dev/null +++ b/src/tests/terminal-cmux.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectCapabilities, resetCapabilitiesCache } from "../../packages/pi-tui/src/terminal-image.ts"; +import { isCmuxTerminal } from "../resources/extensions/shared/terminal.ts"; + +test("isCmuxTerminal detects cmux env vars", () => { + assert.equal(isCmuxTerminal({ CMUX_WORKSPACE_ID: "workspace:1", CMUX_SURFACE_ID: "surface:2" } as NodeJS.ProcessEnv), true); + assert.equal(isCmuxTerminal({ TERM_PROGRAM: "ghostty" } as NodeJS.ProcessEnv), false); +}); + +test("detectCapabilities treats cmux as kitty-capable", () => { + const originalEnv = process.env; + process.env = { + ...originalEnv, + CMUX_WORKSPACE_ID: "workspace:1", + CMUX_SURFACE_ID: "surface:2", + TERM_PROGRAM: "ghostty", + }; + try { + resetCapabilitiesCache(); + assert.deepEqual(detectCapabilities(), { + images: "kitty", + trueColor: true, + hyperlinks: true, + }); + } finally { + process.env = originalEnv; + resetCapabilitiesCache(); + } +});