feat: integrate cmux with gsd runtime (#1532)
This commit is contained in:
parent
37d657b949
commit
b247c3510e
20 changed files with 1120 additions and 82 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
384
src/resources/extensions/cmux/index.ts
Normal file
384
src/resources/extensions/cmux/index.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
143
src/resources/extensions/gsd/commands-cmux.ts
Normal file
143
src/resources/extensions/gsd/commands-cmux.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ notifications:
|
|||
on_budget:
|
||||
on_milestone:
|
||||
on_attention:
|
||||
cmux:
|
||||
enabled:
|
||||
notifications:
|
||||
sidebar:
|
||||
splits:
|
||||
browser:
|
||||
remote_questions:
|
||||
channel:
|
||||
channel_id:
|
||||
|
|
|
|||
|
|
@ -317,6 +317,8 @@ function makeMockDeps(
|
|||
},
|
||||
clearUnitTimeout: () => {},
|
||||
updateProgressWidget: () => {},
|
||||
syncCmuxSidebar: () => {},
|
||||
logCmuxEvent: () => {},
|
||||
invalidateAllCaches: () => {
|
||||
callLog.push("invalidateAllCaches");
|
||||
},
|
||||
|
|
|
|||
98
src/resources/extensions/gsd/tests/cmux.test.ts
Normal file
98
src/resources/extensions/gsd/tests/cmux.test.ts
Normal 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" });
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
30
src/tests/terminal-cmux.test.ts
Normal file
30
src/tests/terminal-cmux.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue