Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLI/CMUXCLI+PiExtensionSource.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
extension CMUXCLI {
static let piExtensionSource = [
piExtensionSourcePart1,
piExtensionSourceDiagnostics,
piExtensionSourceDispatch,
piExtensionSourcePart2,
].joined(separator: "\n")
Expand Down
107 changes: 107 additions & 0 deletions CLI/CMUXCLI+PiExtensionSourceDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
extension CMUXCLI {
static let piExtensionSourceDiagnostics = #"""
type CommandFailureReason = "timeout" | "nonzero-exit" | "spawn-error" | "cancelled";
type CommandTerminationReason = "timeout" | "cancelled";

// Loaded repositories have produced successful 9s+ lifecycle hooks. Leave
// headroom above that observed tail without allowing a stuck child to block a
// session's serialized control queue indefinitely.
const defaultPiHookTimeoutMilliseconds = 15_000;
const maximumPiHookTimeoutMilliseconds = 60_000;

function piHookTimeoutMilliseconds(
rawValue: string | undefined = process.env.CMUX_PI_HOOK_TIMEOUT_MS,
): number {
const normalized = rawValue?.trim();
if (!normalized || !/^\d+$/.test(normalized)) return defaultPiHookTimeoutMilliseconds;
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed <= 0) return defaultPiHookTimeoutMilliseconds;
if (parsed >= maximumPiHookTimeoutMilliseconds) return maximumPiHookTimeoutMilliseconds;
return Number.isSafeInteger(parsed) ? parsed : defaultPiHookTimeoutMilliseconds;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

function commandFailureReason(
status: number | null,
error: unknown,
terminationReason?: CommandTerminationReason,
): CommandFailureReason | undefined {
if (terminationReason) return terminationReason;
if (status === 0 && error === undefined) return undefined;
if (status !== null && status !== 0) return "nonzero-exit";
return "spawn-error";
}

function piHookName(args: string[]): string {
if (args[0] === "hooks" && args[1] === "pi") {
return firstString(args[2]) || "unknown";
}
if (args[0] === "hooks" && args[1] === "feed") {
const eventIndex = args.indexOf("--event");
const eventName = eventIndex >= 0 ? firstString(args[eventIndex + 1]) : null;
return eventName ? `feed:${eventName}` : "feed";
}
if (args[0] === "--json" && args[1] === "surface" && args[2] === "resume") {
return `surface-resume-${firstString(args[3]) || "unknown"}`;
}
return "cmux-command";
}

function expandedPiHookLogPath(value: string): string {
if (value === "~") return process.env.HOME || value;
if (value.startsWith("~/") && process.env.HOME) {
return path.join(process.env.HOME, value.slice(2));
}
return value;
}

function piHookDiagnosticPath(): string {
const explicit = firstString(process.env.CMUX_DEBUG_LOG);
if (explicit) return expandedPiHookLogPath(explicit);

const socketPath = firstString(process.env.CMUX_SOCKET_PATH, process.env.CMUX_SOCKET);
if (socketPath) {
const socketName = path.basename(socketPath);
if (socketName.startsWith("cmux-debug-") && socketName.endsWith(".sock")) {
return path.join("/tmp", `${socketName.slice(0, -".sock".length)}.log`);
}
}
return "/tmp/cmux-debug.log";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

function appendPiHookDiagnostic(payload: Record<string, unknown>): void {
let line: string;
try {
line = JSON.stringify({ timestamp: new Date().toISOString(), ...payload });
} catch (_) {
line = JSON.stringify({
timestamp: new Date().toISOString(),
source: "cmux-pi-extension",
level: "warning",
message: "failed to serialize Pi hook diagnostic",
hook_name: "extension",
reason: "serialization-error",
timeout_ms: piHookTimeoutMilliseconds(),
elapsed_ms: 0,
});
}
try {
void fs.promises.appendFile(piHookDiagnosticPath(), `${line}\n`, "utf8").catch(() => {});
} catch (_) {}
}

function commandFailureDetails(
args: string[],
result: CommandResult,
): Record<string, unknown> {
return {
hook_name: piHookName(args),
reason: result.reason || commandFailureReason(result.status, result.error) || "spawn-error",
timeout_ms: result.timeoutMs,
elapsed_ms: result.elapsedMs,
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
};
}
"""#
}
73 changes: 53 additions & 20 deletions CLI/CMUXCLI+PiExtensionSourceDispatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ class PiCmuxCommandDispatcher {
this.failTerminalFeedForSession(sessionId);
this.discardFeedForSession(sessionId);
}
} else if (result.error instanceof Error && result.error.message.includes("timed out after")) {
} else if (result.reason === "timeout") {
const sessionId = command.context.sessionId;
if (sessionId) {
this.failTerminalFeedForSession(sessionId);
Expand Down Expand Up @@ -367,18 +367,16 @@ class PiCmuxCommandDispatcher {
}

const result = await this.spawnCmux(args, cwd, input, cancellation);
if (this.isSurfaceResolutionFailure(result)) {
const shouldWarn = !sessionId || !this.unavailableSessions.has(sessionId);
const surfaceUnavailable = this.isSurfaceResolutionFailure(result);
const shouldLogFailure = !surfaceUnavailable || !sessionId || !this.unavailableSessions.has(sessionId);
if (!result.ok && result.reason !== "cancelled" && shouldLogFailure) {
warn(context, "cmux hook command failed", {
...commandFailureDetails(args, result),
...(surfaceUnavailable ? { surface_unavailable: true, dispatch_disabled: true } : {}),
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (surfaceUnavailable) {
if (sessionId) this.unavailableSessions.add(sessionId);
if (shouldWarn) {
warn(context, "cmux hook command failed", {
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
surface_unavailable: true,
dispatch_disabled: true,
});
}
return { ...result, surfaceUnavailable: true };
}
return result;
Expand All @@ -391,6 +389,8 @@ class PiCmuxCommandDispatcher {
cancellation?: PiCommandCancellation,
): Promise<CommandResult> {
return new Promise<CommandResult>((resolve) => {
const startedAt = performance.now();
const timeoutMs = piHookTimeoutMilliseconds();
let settled = false;
let stdout = "";
let stderr = "";
Expand All @@ -399,6 +399,7 @@ class PiCmuxCommandDispatcher {
let terminateGrace: ReturnType<typeof setTimeout> | null = null;
let forceSettleTimeout: ReturnType<typeof setTimeout> | null = null;
let terminationError: Error | undefined;
let terminationReason: CommandTerminationReason | undefined;

const appendOutput = (current: string, chunk: unknown): string => {
const limit = 1024 * 1024;
Expand All @@ -414,12 +415,18 @@ class PiCmuxCommandDispatcher {
if (cancellation) cancellation.cancel = undefined;
resolve(result);
};
const elapsedMilliseconds = (): number => (
Math.max(0, Math.round(performance.now() - startedAt))
);
const terminatedResult = (): CommandResult => ({
ok: false,
status: null,
stdout,
stderr,
error: terminationError,
reason: commandFailureReason(null, terminationError, terminationReason),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});

try {
Expand All @@ -438,9 +445,10 @@ class PiCmuxCommandDispatcher {
child.stdin.on("error", (error) => {
inputError = error;
});
const beginTermination = (error: Error) => {
const beginTermination = (reason: CommandTerminationReason, error: Error) => {
if (terminationError) return;
terminationError = error;
terminationReason = reason;
child.stdin.destroy();
try {
child.kill("SIGTERM");
Expand All @@ -458,32 +466,55 @@ class PiCmuxCommandDispatcher {
}, 250);
};
child.on("error", (error) => {
settle(terminationError ? terminatedResult() : { ok: false, status: null, stdout, stderr, error });
settle(terminationError ? terminatedResult() : {
ok: false,
status: null,
stdout,
stderr,
error,
reason: commandFailureReason(null, error),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
});
child.on("close", (code) => {
if (terminationError) {
settle(terminatedResult());
return;
}
const status = typeof code === "number" ? code : null;
const error = inputError;
const reason = commandFailureReason(status, error);
settle({
ok: status === 0 && inputError === undefined,
ok: reason === undefined,
status,
stdout,
stderr,
error: inputError,
error,
reason,
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
});
if (cancellation) {
cancellation.cancel = () => beginTermination(new Error("cmux feed command cancelled"));
cancellation.cancel = () => beginTermination("cancelled", new Error("cmux feed command cancelled"));
if (cancellation.cancelled) cancellation.cancel();
}
timeout = setTimeout(() => {
beginTermination(new Error("cmux command timed out after 5000ms"));
}, 5000);
beginTermination("timeout", new Error(`cmux command timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdin.end(input);
} catch (error) {
settle({ ok: false, status: null, stdout, stderr, error });
settle({
ok: false,
status: null,
stdout,
stderr,
error,
reason: commandFailureReason(null, error),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
}
});
}
Expand All @@ -498,6 +529,8 @@ class PiCmuxCommandDispatcher {
status: null,
stdout: "",
stderr: "",
timeoutMs: piHookTimeoutMilliseconds(),
elapsedMs: 0,
surfaceUnavailable: true,
};
}
Expand Down
43 changes: 17 additions & 26 deletions CLI/CMUXCLI+PiExtensionSourcePart1.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
extension CMUXCLI {
static let piExtensionSourcePart1 = #"""
// cmux-pi-session-extension-marker v2
// cmux-pi-session-extension-marker v3
// Bridges Pi session lifecycle, tool telemetry, notifications, and resume bindings into cmux.
// Installed by `cmux hooks pi install` or `cmux hooks setup`.
// DO NOT EDIT MANUALLY. cmux upgrades this file in place.
Expand Down Expand Up @@ -34,13 +34,15 @@ interface CommandResult {
stdout: string;
stderr: string;
error?: unknown;
reason?: CommandFailureReason;
timeoutMs: number;
elapsedMs: number;
surfaceUnavailable?: boolean;
}

interface PiExtensionContextSnapshot {
readonly sessionId: string | null;
readonly cwd: string;
readonly notifyWarning?: () => void;
}

function firstString(...values: unknown[]): string | null {
Expand Down Expand Up @@ -348,6 +350,7 @@ function safeCmuxEnvKey(key: string): boolean {
if (key.startsWith("CMUX_AGENT_LAUNCH_")) return !secretLikeEnvKey(key);
if (key === "CMUX_AGENT_HOOK_STATE_DIR") return true;
if (key === "CMUX_PI_CMUX_BIN" || key === "CMUX_PI_HOOKS_DISABLED") return true;
if (key === "CMUX_PI_HOOK_TIMEOUT_MS") return true;
if (key === "CMUX_SURFACE_ID" || key === "CMUX_WORKSPACE_ID" || key === "CMUX_WINDOW_ID") return true;
if (key === "CMUX_PANE_ID" || key === "CMUX_TAB_ID" || key === "CMUX_PANEL_ID") return true;
if (key === "CMUX_SOCKET" || key === "CMUX_SOCKET_PATH") return true;
Expand Down Expand Up @@ -460,17 +463,9 @@ function cwdFrom(ctx: ExtensionContext): string {
}

function snapshotContext(ctx: ExtensionContext): PiExtensionContextSnapshot {
let notifyWarning: (() => void) | undefined;
try {
const ui = (ctx as unknown as { ui?: { notify?: (message: string, type?: string) => void } }).ui;
if (typeof ui?.notify === "function") {
notifyWarning = () => ui.notify?.("cmux Pi integration warning - check the terminal for details", "warning");
}
} catch (_) {}
return {
sessionId: sessionIdFrom(ctx),
cwd: cwdFrom(ctx),
notifyWarning,
};
}

Expand Down Expand Up @@ -529,25 +524,21 @@ function settleTurn(sessionStates: Map<string, SessionState>, sessionId: string)
}

function warn(
ctx: PiExtensionContextSnapshot | null,
_ctx: PiExtensionContextSnapshot | null,
message: string,
details: Record<string, unknown> = {},
notifyUser = false,
): void {
const payload = { source: "cmux-pi-extension", level: "warning", message, ...details };
try {
console.warn(JSON.stringify(payload));
} catch (_) {
console.warn(`[cmux-pi-extension] ${message}`);
}
// Hook transport is best-effort telemetry. Keep routine command failures in
// the terminal instead of interrupting Pi with a generic toast; reserve the
// UI warning for an unexpected extension-task exception.
if (notifyUser) {
try {
ctx?.notifyWarning?.();
} catch (_) {}
}
const payload = {
source: "cmux-pi-extension",
level: "warning",
message,
hook_name: "extension",
reason: "extension-error",
timeout_ms: piHookTimeoutMilliseconds(),
elapsed_ms: 0,
...details,
};
appendPiHookDiagnostic(payload);
}

function cmuxExecutable(): string {
Expand Down
Loading