feat: integrate cmux with gsd runtime (#1532)

This commit is contained in:
Jeremy McSpadden 2026-03-19 21:05:06 -05:00 committed by GitHub
parent 37d657b949
commit b247c3510e
20 changed files with 1120 additions and 82 deletions

View file

@ -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 };
}

View file

@ -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<string, string>();
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<string | null> {
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<string[]> {
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<string | null> {
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<boolean> {
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<string>();
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<string, unknown>)) {
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);
}

View file

@ -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;
}

View file

@ -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<void> {
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());
}

View file

@ -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<string, unknown>) => void,
): Promise<void> {
const path = getProjectGSDPreferencesPath();
await ensurePreferencesFile(path, ctx, "project");
const existing = loadProjectGSDPreferences();
const prefs: Record<string, unknown> = 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<string, unknown> | 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<void> {
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<string, unknown> | undefined) ?? {}),
};
(prefs.cmux as Record<string, unknown>).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<string, unknown> | 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<string, unknown> | 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 <status|on|off|notifications on|notifications off|sidebar on|sidebar off|splits on|splits off|browser on|browser off>",
"info",
);
}

View file

@ -740,7 +740,7 @@ export function serializePreferencesToFrontmatter(prefs: Record<string, unknown>
"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",

View file

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

View file

@ -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

View file

@ -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();

View file

@ -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);

View file

@ -68,6 +68,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"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[];

View file

@ -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<string, unknown>;
const validatedCmux: NonNullable<GSDPreferences["cmux"]> = {};
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") {

View file

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

View file

@ -57,6 +57,12 @@ notifications:
on_budget:
on_milestone:
on_attention:
cmux:
enabled:
notifications:
sidebar:
splits:
browser:
remote_questions:
channel:
channel_id:

View file

@ -317,6 +317,8 @@ function makeMockDeps(
},
clearUnitTimeout: () => {},
updateProgressWidget: () => {},
syncCmuxSidebar: () => {},
logCmuxEvent: () => {},
invalidateAllCaches: () => {
callLog.push("invalidateAllCaches");
},

View file

@ -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" });
});

View file

@ -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: {

View file

@ -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;
}

View file

@ -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<boolean> {
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<SubagentDetails>) => 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<number>((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<SingleResult> {
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) {

View file

@ -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();
}
});