1902 lines
68 KiB
TypeScript
1902 lines
68 KiB
TypeScript
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
import { isKeyRelease, isKeyRepeat, matchesKey } from "@mariozechner/pi-tui";
|
|
import { InteractiveShellOverlay } from "./overlay-component.js";
|
|
import { ReattachOverlay } from "./reattach-overlay.js";
|
|
import { PtyTerminalSession } from "./pty-session.js";
|
|
import { formatDuration, formatDurationMs } from "./types.js";
|
|
import type {
|
|
HandsFreeUpdate,
|
|
InteractiveShellResult,
|
|
MonitorConfig,
|
|
MonitorEventPayload,
|
|
MonitorFileWatchConfig,
|
|
MonitorStrategy,
|
|
MonitorTerminalReason,
|
|
MonitorThresholdOperator,
|
|
MonitorTriggerConfig,
|
|
} from "./types.js";
|
|
import { sessionManager, generateSessionId } from "./session-manager.js";
|
|
import { loadConfig } from "./config.js";
|
|
import type { InteractiveShellConfig } from "./config.js";
|
|
import { parseSpawnArgs, resolveSpawn, type SpawnRequest } from "./spawn.js";
|
|
import { translateInput } from "./key-encoding.js";
|
|
import { TOOL_NAME, TOOL_LABEL, TOOL_DESCRIPTION, toolParameters, type ToolParams } from "./tool-schema.js";
|
|
import { HeadlessDispatchMonitor } from "./headless-monitor.js";
|
|
import type {
|
|
HeadlessCompletionInfo,
|
|
MonitorMatchInfo,
|
|
MonitorRuntimeConfig,
|
|
MonitorTriggerMatcher,
|
|
} from "./headless-monitor.js";
|
|
import { setupBackgroundWidget } from "./background-widget.js";
|
|
import { buildDispatchNotification, buildHandsFreeUpdateMessage, buildMonitorEventNotification, buildMonitorLifecycleNotification, buildResultNotification, summarizeInteractiveResult } from "./notification-utils.js";
|
|
import { createSessionQueryState, getSessionOutput } from "./session-query.js";
|
|
import { InteractiveShellCoordinator } from "./runtime-coordinator.js";
|
|
import { spawn as spawnChildProcess } from "node:child_process";
|
|
|
|
const coordinator = new InteractiveShellCoordinator();
|
|
const SIDE_CHAT_SHORTCUT = "alt+/";
|
|
|
|
function scheduleMonitorHistoryCleanup(sessionId: string, delayMs = 5 * 60 * 1000): void {
|
|
const attempt = () => {
|
|
const stillInUse = Boolean(coordinator.getMonitor(sessionId))
|
|
|| Boolean(sessionManager.getActive(sessionId))
|
|
|| sessionManager.list().some((session) => session.id === sessionId);
|
|
if (stillInUse) {
|
|
setTimeout(attempt, 30_000);
|
|
return;
|
|
}
|
|
coordinator.clearMonitorEvents(sessionId);
|
|
};
|
|
setTimeout(attempt, delayMs);
|
|
}
|
|
|
|
function makeMonitorCompletionCallback(
|
|
pi: ExtensionAPI,
|
|
id: string,
|
|
startTime: number,
|
|
): (info: HeadlessCompletionInfo) => void {
|
|
return (info) => {
|
|
const wasAgentHandled = coordinator.consumeAgentHandledCompletion(id);
|
|
if (!wasAgentHandled) {
|
|
const duration = formatDuration(Date.now() - startTime);
|
|
const content = buildDispatchNotification(id, info, duration);
|
|
pi.sendMessage({
|
|
customType: "interactive-shell-transfer",
|
|
content,
|
|
display: true,
|
|
details: { sessionId: id, duration, ...info },
|
|
}, { triggerTurn: true });
|
|
pi.events.emit("interactive-shell:transfer", { sessionId: id, ...info });
|
|
}
|
|
sessionManager.unregisterActive(id, false);
|
|
coordinator.deleteMonitor(id);
|
|
scheduleMonitorHistoryCleanup(id);
|
|
sessionManager.scheduleCleanup(id, 5 * 60 * 1000);
|
|
};
|
|
}
|
|
|
|
function resolveMonitorTerminalReason(info: HeadlessCompletionInfo, override?: MonitorTerminalReason): MonitorTerminalReason {
|
|
if (override) return override;
|
|
if (info.timedOut) return "timed-out";
|
|
if (info.cancelled) return "stopped";
|
|
if (info.exitCode === 0) return "stream-ended";
|
|
return "script-failed";
|
|
}
|
|
|
|
function makeStructuredMonitorCompletionCallback(
|
|
pi: ExtensionAPI,
|
|
id: string,
|
|
): (info: HeadlessCompletionInfo) => void {
|
|
return (info) => {
|
|
const reason = resolveMonitorTerminalReason(info, coordinator.consumePendingMonitorReason(id));
|
|
const state = coordinator.finalizeMonitorSession(id, { exitCode: info.exitCode, signal: info.signal }, reason);
|
|
const wasAgentHandled = coordinator.consumeAgentHandledCompletion(id);
|
|
if (!wasAgentHandled && state) {
|
|
const content = buildMonitorLifecycleNotification(state);
|
|
pi.sendMessage({
|
|
customType: "interactive-shell-monitor-lifecycle",
|
|
content,
|
|
display: true,
|
|
details: { sessionId: id, state, completion: info },
|
|
}, { triggerTurn: true });
|
|
pi.events.emit("interactive-shell:monitor-lifecycle", { sessionId: id, state, completion: info });
|
|
}
|
|
sessionManager.unregisterActive(id, false);
|
|
coordinator.deleteMonitor(id);
|
|
scheduleMonitorHistoryCleanup(id);
|
|
sessionManager.scheduleCleanup(id, 5 * 60 * 1000);
|
|
};
|
|
}
|
|
|
|
type CompiledMonitorConfig = {
|
|
runtime: MonitorRuntimeConfig;
|
|
persistence: {
|
|
stopAfterFirstEvent: boolean;
|
|
maxEvents?: number;
|
|
};
|
|
fileWatch?: Required<MonitorFileWatchConfig>;
|
|
detector?: {
|
|
detectorCommand: string;
|
|
timeoutMs: number;
|
|
};
|
|
publicConfig: MonitorConfig;
|
|
};
|
|
|
|
type DetectorDecision = {
|
|
emit: boolean;
|
|
triggerId?: string;
|
|
eventType?: string;
|
|
matchedText?: string;
|
|
lineOrDiff?: string;
|
|
};
|
|
|
|
function buildPollDiffLoopCommand(command: string, intervalMs: number): string {
|
|
if (process.platform === "win32") {
|
|
const seconds = Math.max(1, Math.ceil(intervalMs / 1000));
|
|
return `for /L %i in (0,0,1) do (${command} & timeout /t ${seconds} /nobreak >nul)`;
|
|
}
|
|
const seconds = Math.max(0.25, intervalMs / 1000);
|
|
const roundedSeconds = Number(seconds.toFixed(3));
|
|
return `while true; do ${command}; sleep ${roundedSeconds}; done`;
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
if (process.platform === "win32") {
|
|
return `"${value.replace(/"/g, '""')}"`;
|
|
}
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function buildFileWatchCommand(fileWatch: Required<MonitorFileWatchConfig>): string {
|
|
const script = `
|
|
const fs = require("node:fs");
|
|
const watchPath = process.argv[1];
|
|
const recursive = process.argv[2] === "1";
|
|
const allowed = new Set((process.argv[3] || "rename,change").split(",").filter(Boolean));
|
|
function emit(eventType, filename) {
|
|
if (!allowed.has(eventType)) return;
|
|
const name = filename ? String(filename) : ".";
|
|
process.stdout.write(eventType.toUpperCase() + " " + name + "\\n");
|
|
}
|
|
let watcher;
|
|
try {
|
|
watcher = fs.watch(watchPath, { recursive }, (eventType, filename) => emit(eventType, filename));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error("file-watch failed: " + message);
|
|
process.exit(1);
|
|
}
|
|
watcher.on("error", (error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error("file-watch error: " + message);
|
|
process.exit(1);
|
|
});
|
|
process.stdin.resume();
|
|
`.trim();
|
|
|
|
const encoded = Buffer.from(script, "utf8").toString("base64");
|
|
const eventCsv = fileWatch.events.join(",");
|
|
return `${shellQuote(process.execPath)} -e "eval(Buffer.from('${encoded}','base64').toString('utf8'))" ${shellQuote(fileWatch.path)} ${fileWatch.recursive ? "1" : "0"} ${shellQuote(eventCsv)}`;
|
|
}
|
|
|
|
function compareThreshold(value: number, op: MonitorThresholdOperator, expected: number): boolean {
|
|
if (op === "lt") return value < expected;
|
|
if (op === "lte") return value <= expected;
|
|
if (op === "gt") return value > expected;
|
|
return value >= expected;
|
|
}
|
|
|
|
function parseRegexPattern(value: string): { ok: true; regex: RegExp } | { ok: false; error: string } {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return { ok: false, error: "Regex pattern cannot be empty." };
|
|
}
|
|
|
|
const literal = /^\/(.+)\/([A-Za-z]*)$/.exec(trimmed);
|
|
let source = trimmed;
|
|
let flags = "";
|
|
if (literal) {
|
|
if (!/^[dgimsuvy]*$/i.test(literal[2])) {
|
|
return { ok: false, error: `Invalid regex flags: ${literal[2]}` };
|
|
}
|
|
source = literal[1];
|
|
flags = literal[2].replace(/[gy]/gi, "");
|
|
}
|
|
|
|
try {
|
|
return { ok: true, regex: new RegExp(source, flags) };
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
return { ok: false, error: `Invalid regex '${value}': ${error.message}` };
|
|
}
|
|
return { ok: false, error: `Invalid regex '${value}'.` };
|
|
}
|
|
}
|
|
|
|
function compileMonitorTrigger(trigger: MonitorTriggerConfig, index: number):
|
|
| { ok: true; compiled: MonitorTriggerMatcher }
|
|
| { ok: false; error: string } {
|
|
const id = trigger.id?.trim();
|
|
if (!id) {
|
|
return { ok: false, error: `monitor.triggers[${index}] requires non-empty id.` };
|
|
}
|
|
|
|
const hasLiteral = typeof trigger.literal === "string";
|
|
const hasRegex = typeof trigger.regex === "string";
|
|
if ((hasLiteral ? 1 : 0) + (hasRegex ? 1 : 0) !== 1) {
|
|
return { ok: false, error: `monitor.triggers[${index}] must define exactly one matcher: literal or regex.` };
|
|
}
|
|
|
|
if (trigger.threshold && !hasRegex) {
|
|
return { ok: false, error: `monitor.triggers[${index}].threshold requires regex matcher.` };
|
|
}
|
|
|
|
if (hasLiteral) {
|
|
const literal = trigger.literal!.trim();
|
|
if (!literal) {
|
|
return { ok: false, error: `monitor.triggers[${index}].literal cannot be empty.` };
|
|
}
|
|
return {
|
|
ok: true,
|
|
compiled: {
|
|
id,
|
|
cooldownMs: trigger.cooldownMs,
|
|
match: (input: string) => {
|
|
const idx = input.indexOf(literal);
|
|
if (idx === -1) return undefined;
|
|
return input.slice(idx, idx + literal.length);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const parsed = parseRegexPattern(trigger.regex!);
|
|
if (!parsed.ok) {
|
|
return { ok: false, error: `monitor.triggers[${index}].regex ${parsed.error}` };
|
|
}
|
|
|
|
const threshold = trigger.threshold;
|
|
if (threshold) {
|
|
if (!Number.isInteger(threshold.captureGroup) || threshold.captureGroup < 1) {
|
|
return { ok: false, error: `monitor.triggers[${index}].threshold.captureGroup must be an integer >= 1.` };
|
|
}
|
|
if (!["lt", "lte", "gt", "gte"].includes(threshold.op)) {
|
|
return { ok: false, error: `monitor.triggers[${index}].threshold.op must be one of: lt, lte, gt, gte.` };
|
|
}
|
|
if (!Number.isFinite(threshold.value)) {
|
|
return { ok: false, error: `monitor.triggers[${index}].threshold.value must be a finite number.` };
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
compiled: {
|
|
id,
|
|
cooldownMs: trigger.cooldownMs,
|
|
match: (input: string) => {
|
|
parsed.regex.lastIndex = 0;
|
|
const match = parsed.regex.exec(input);
|
|
if (!match) return undefined;
|
|
if (!threshold) return match[0];
|
|
const captured = match[threshold.captureGroup];
|
|
if (captured === undefined) return undefined;
|
|
const numeric = Number(captured);
|
|
if (!Number.isFinite(numeric)) return undefined;
|
|
if (!compareThreshold(numeric, threshold.op, threshold.value)) return undefined;
|
|
return match[0];
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function compileMonitorConfig(raw: MonitorConfig | undefined):
|
|
| { ok: true; compiled: CompiledMonitorConfig }
|
|
| { ok: false; error: string } {
|
|
if (!raw) {
|
|
return { ok: false, error: "mode='monitor' requires monitor configuration." };
|
|
}
|
|
|
|
const strategy: MonitorStrategy = raw.strategy ?? "stream";
|
|
if (strategy !== "stream" && strategy !== "poll-diff" && strategy !== "file-watch") {
|
|
return { ok: false, error: `Unsupported monitor.strategy: ${String(raw.strategy)}` };
|
|
}
|
|
|
|
if (!Array.isArray(raw.triggers) || raw.triggers.length === 0) {
|
|
return { ok: false, error: "monitor.triggers must contain at least one trigger." };
|
|
}
|
|
|
|
const ids = new Set<string>();
|
|
const compiledTriggers: MonitorTriggerMatcher[] = [];
|
|
for (let i = 0; i < raw.triggers.length; i++) {
|
|
const trigger = raw.triggers[i];
|
|
const compiled = compileMonitorTrigger(trigger, i);
|
|
if (!compiled.ok) return compiled;
|
|
if (ids.has(compiled.compiled.id)) {
|
|
return { ok: false, error: `Duplicate monitor trigger id: ${compiled.compiled.id}` };
|
|
}
|
|
ids.add(compiled.compiled.id);
|
|
compiledTriggers.push(compiled.compiled);
|
|
}
|
|
|
|
let fileWatch: Required<MonitorFileWatchConfig> | undefined;
|
|
if (strategy === "file-watch") {
|
|
if (!raw.fileWatch) {
|
|
return { ok: false, error: "monitor.fileWatch is required when monitor.strategy='file-watch'." };
|
|
}
|
|
const watchPath = raw.fileWatch.path?.trim();
|
|
if (!watchPath) {
|
|
return { ok: false, error: "monitor.fileWatch.path must be a non-empty string." };
|
|
}
|
|
const watchEvents = raw.fileWatch.events ?? ["rename", "change"];
|
|
if (!Array.isArray(watchEvents) || watchEvents.length === 0) {
|
|
return { ok: false, error: "monitor.fileWatch.events must contain at least one event." };
|
|
}
|
|
for (const eventName of watchEvents) {
|
|
if (eventName !== "rename" && eventName !== "change") {
|
|
return { ok: false, error: `Unsupported monitor.fileWatch event: ${String(eventName)}. Use 'rename' or 'change'.` };
|
|
}
|
|
}
|
|
fileWatch = {
|
|
path: watchPath,
|
|
recursive: raw.fileWatch.recursive === true,
|
|
events: Array.from(new Set(watchEvents)),
|
|
};
|
|
} else if (raw.fileWatch) {
|
|
return { ok: false, error: "monitor.fileWatch is only valid when monitor.strategy='file-watch'." };
|
|
}
|
|
|
|
if (strategy !== "poll-diff" && raw.poll) {
|
|
return { ok: false, error: "monitor.poll is only valid when monitor.strategy='poll-diff'." };
|
|
}
|
|
|
|
const pollIntervalMs = Math.max(250, Math.trunc(raw.poll?.intervalMs ?? 5000));
|
|
const dedupeExactLine = raw.throttle?.dedupeExactLine !== false;
|
|
const cooldownMs = raw.throttle?.cooldownMs !== undefined
|
|
? Math.max(0, Math.trunc(raw.throttle.cooldownMs))
|
|
: undefined;
|
|
const stopAfterFirstEvent = raw.persistence?.stopAfterFirstEvent === true;
|
|
const maxEvents = raw.persistence?.maxEvents !== undefined
|
|
? Math.max(1, Math.trunc(raw.persistence.maxEvents))
|
|
: undefined;
|
|
|
|
const detectorCommand = raw.detector?.detectorCommand?.trim();
|
|
const detector = detectorCommand
|
|
? {
|
|
detectorCommand,
|
|
timeoutMs: Math.max(100, Math.trunc(raw.detector?.timeoutMs ?? 3000)),
|
|
}
|
|
: undefined;
|
|
|
|
const publicConfig: MonitorConfig = {
|
|
strategy,
|
|
triggers: raw.triggers,
|
|
fileWatch,
|
|
poll: strategy === "poll-diff" ? { intervalMs: pollIntervalMs } : undefined,
|
|
persistence: {
|
|
stopAfterFirstEvent,
|
|
maxEvents,
|
|
},
|
|
throttle: {
|
|
dedupeExactLine,
|
|
cooldownMs,
|
|
},
|
|
detector: detector
|
|
? {
|
|
detectorCommand: detector.detectorCommand,
|
|
timeoutMs: detector.timeoutMs,
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
return {
|
|
ok: true,
|
|
compiled: {
|
|
runtime: {
|
|
strategy,
|
|
triggers: compiledTriggers,
|
|
pollIntervalMs,
|
|
dedupeExactLine,
|
|
cooldownMs,
|
|
},
|
|
persistence: {
|
|
stopAfterFirstEvent,
|
|
maxEvents,
|
|
},
|
|
fileWatch,
|
|
detector,
|
|
publicConfig,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runDetectorCommand(
|
|
detector: NonNullable<CompiledMonitorConfig["detector"]>,
|
|
candidate: MonitorEventPayload,
|
|
cwd?: string,
|
|
): Promise<DetectorDecision> {
|
|
return new Promise<DetectorDecision>((resolve, reject) => {
|
|
const shell = process.platform === "win32"
|
|
? (process.env.COMSPEC || "cmd.exe")
|
|
: (process.env.SHELL || "/bin/sh");
|
|
const args = process.platform === "win32"
|
|
? ["/d", "/s", "/c", detector.detectorCommand]
|
|
: ["-c", detector.detectorCommand];
|
|
|
|
const child = spawnChildProcess(shell, args, {
|
|
cwd,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: process.env,
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
const timer = setTimeout(() => {
|
|
child.kill();
|
|
reject(new Error(`detectorCommand timed out after ${detector.timeoutMs}ms`));
|
|
}, detector.timeoutMs);
|
|
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
|
|
child.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
|
|
child.on("exit", (code) => {
|
|
clearTimeout(timer);
|
|
if (code !== 0) {
|
|
reject(new Error(`detectorCommand exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
return;
|
|
}
|
|
const raw = stdout.trim();
|
|
if (!raw) {
|
|
resolve({ emit: true });
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as DetectorDecision | boolean;
|
|
if (typeof parsed === "boolean") {
|
|
resolve({ emit: parsed });
|
|
return;
|
|
}
|
|
resolve({
|
|
emit: parsed.emit !== false,
|
|
triggerId: parsed.triggerId,
|
|
eventType: parsed.eventType,
|
|
matchedText: parsed.matchedText,
|
|
lineOrDiff: parsed.lineOrDiff,
|
|
});
|
|
} catch (error) {
|
|
reject(new Error(`detectorCommand returned invalid JSON: ${(error as Error).message}`));
|
|
}
|
|
});
|
|
|
|
child.stdin.write(`${JSON.stringify(candidate)}\n`);
|
|
child.stdin.end();
|
|
});
|
|
}
|
|
|
|
function makeMonitorEventCallback(
|
|
pi: ExtensionAPI,
|
|
sessionId: string,
|
|
config: CompiledMonitorConfig,
|
|
cwd?: string,
|
|
): (event: MonitorMatchInfo) => void {
|
|
let queue = Promise.resolve();
|
|
let emitted = 0;
|
|
let stopped = false;
|
|
|
|
return (event) => {
|
|
queue = queue.then(async () => {
|
|
if (stopped) return;
|
|
if (!coordinator.getMonitor(sessionId)) {
|
|
stopped = true;
|
|
return;
|
|
}
|
|
|
|
let candidate: Omit<MonitorEventPayload, "eventId" | "timestamp"> = {
|
|
sessionId,
|
|
strategy: event.strategy,
|
|
triggerId: event.triggerId,
|
|
eventType: event.eventType,
|
|
matchedText: event.matchedText,
|
|
lineOrDiff: event.lineOrDiff,
|
|
stream: event.stream,
|
|
};
|
|
|
|
if (config.detector) {
|
|
try {
|
|
const detectorPreview: MonitorEventPayload = {
|
|
...candidate,
|
|
eventId: 0,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
const decision = await runDetectorCommand(config.detector, detectorPreview, cwd);
|
|
if (!decision.emit) return;
|
|
if (decision.triggerId) candidate = { ...candidate, triggerId: decision.triggerId };
|
|
if (decision.eventType) candidate = { ...candidate, eventType: decision.eventType };
|
|
if (decision.matchedText) candidate = { ...candidate, matchedText: decision.matchedText };
|
|
if (decision.lineOrDiff) candidate = { ...candidate, lineOrDiff: decision.lineOrDiff };
|
|
} catch (error) {
|
|
console.error(`interactive-shell: detectorCommand failed for ${sessionId}:`, error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const payload = coordinator.recordMonitorEvent(candidate);
|
|
const content = buildMonitorEventNotification(payload);
|
|
pi.sendMessage({
|
|
customType: "interactive-shell-monitor-event",
|
|
content,
|
|
display: true,
|
|
details: payload,
|
|
}, { triggerTurn: true });
|
|
pi.events.emit("interactive-shell:monitor-event", payload);
|
|
|
|
emitted += 1;
|
|
if (config.persistence.stopAfterFirstEvent || (config.persistence.maxEvents !== undefined && emitted >= config.persistence.maxEvents)) {
|
|
stopped = true;
|
|
coordinator.markMonitorStopping(sessionId, "stopped");
|
|
sessionManager.getActive(sessionId)?.kill();
|
|
}
|
|
}).catch((error) => {
|
|
console.error(`interactive-shell: monitor callback queue error for ${sessionId}:`, error);
|
|
});
|
|
};
|
|
}
|
|
|
|
function registerHeadlessActive(
|
|
id: string,
|
|
command: string,
|
|
reason: string | undefined,
|
|
session: PtyTerminalSession,
|
|
monitor: HeadlessDispatchMonitor,
|
|
startTime: number,
|
|
config: InteractiveShellConfig,
|
|
status: "running" | "monitoring" = "running",
|
|
): void {
|
|
const queryState = createSessionQueryState();
|
|
coordinator.setMonitor(id, monitor);
|
|
const getCompletionOutput = () => monitor.getResult()?.completionOutput;
|
|
|
|
sessionManager.registerActive({
|
|
id,
|
|
command,
|
|
reason,
|
|
write: (data) => session.write(data),
|
|
kill: () => {
|
|
const monitorState = coordinator.getMonitorSessionState(id);
|
|
if (monitorState?.status === "running") {
|
|
coordinator.markMonitorStopping(id, "stopped");
|
|
}
|
|
const liveMonitor = coordinator.getMonitor(id);
|
|
if (liveMonitor && !liveMonitor.disposed) {
|
|
session.kill();
|
|
return;
|
|
}
|
|
coordinator.disposeMonitor(id);
|
|
scheduleMonitorHistoryCleanup(id);
|
|
sessionManager.remove(id);
|
|
sessionManager.unregisterActive(id, true);
|
|
},
|
|
background: () => {},
|
|
getOutput: (opts) => getSessionOutput(session, config, queryState, opts, getCompletionOutput()),
|
|
getStatus: () => session.exited ? "exited" : status,
|
|
getRuntime: () => Date.now() - startTime,
|
|
getResult: () => monitor.getResult(),
|
|
onComplete: (cb) => monitor.registerCompleteCallback(cb),
|
|
});
|
|
}
|
|
|
|
function makeNonBlockingUpdateHandler(pi: ExtensionAPI): (update: HandsFreeUpdate) => void {
|
|
return (update) => {
|
|
pi.events.emit("interactive-shell:update", update);
|
|
const message = buildHandsFreeUpdateMessage(update);
|
|
if (!message) return;
|
|
pi.sendMessage({
|
|
customType: "interactive-shell-update",
|
|
content: message.content,
|
|
display: true,
|
|
details: message.details,
|
|
}, { triggerTurn: true });
|
|
};
|
|
}
|
|
|
|
function emitTransferredOutput(
|
|
pi: ExtensionAPI,
|
|
result: InteractiveShellResult,
|
|
fallbackSessionId?: string,
|
|
): void {
|
|
if (!result.transferred) return;
|
|
const sessionId = result.sessionId ?? fallbackSessionId;
|
|
const truncatedNote = result.transferred.truncated
|
|
? ` (truncated from ${result.transferred.totalLines} total lines)`
|
|
: "";
|
|
const prefix = sessionId
|
|
? `Session ${sessionId} output transferred`
|
|
: "Interactive shell output transferred";
|
|
const content = `${prefix} (${result.transferred.lines.length} lines${truncatedNote}):\n\n${result.transferred.lines.join("\n")}`;
|
|
pi.sendMessage({
|
|
customType: "interactive-shell-transfer",
|
|
content,
|
|
display: true,
|
|
details: {
|
|
sessionId,
|
|
transferred: result.transferred,
|
|
exitCode: result.exitCode,
|
|
signal: result.signal,
|
|
},
|
|
}, { triggerTurn: true });
|
|
pi.events.emit("interactive-shell:transfer", {
|
|
sessionId,
|
|
transferred: result.transferred,
|
|
exitCode: result.exitCode,
|
|
signal: result.signal,
|
|
});
|
|
}
|
|
|
|
function appendWorktreeNotice(text: string, worktreePath: string | undefined): string {
|
|
if (!worktreePath) return text;
|
|
return `${text}\nWorktree left in place: ${worktreePath}`;
|
|
}
|
|
|
|
export default function interactiveShellExtension(pi: ExtensionAPI) {
|
|
const startupConfig = loadConfig(process.cwd());
|
|
let terminalInputCleanup: (() => void) | null = null;
|
|
const loadRuntimeConfig = (cwd: string): InteractiveShellConfig => {
|
|
const config = loadConfig(cwd);
|
|
return {
|
|
...config,
|
|
focusShortcut: startupConfig.focusShortcut,
|
|
spawn: {
|
|
...config.spawn,
|
|
shortcut: startupConfig.spawn.shortcut,
|
|
},
|
|
};
|
|
};
|
|
const disposeStaleMonitor = (id: string, monitor: HeadlessDispatchMonitor | undefined): void => {
|
|
if (!monitor || monitor.disposed) return;
|
|
coordinator.disposeMonitor(id);
|
|
coordinator.clearMonitorEvents(id);
|
|
sessionManager.unregisterActive(id, false);
|
|
};
|
|
const createOverlayUiOptions = (config: InteractiveShellConfig) => ({
|
|
overlay: true,
|
|
overlayOptions: {
|
|
width: `${config.overlayWidthPercent}%`,
|
|
maxHeight: `${config.overlayHeightPercent}%`,
|
|
anchor: "center",
|
|
margin: 1,
|
|
nonCapturing: true,
|
|
},
|
|
onHandle: (handle) => {
|
|
coordinator.setOverlayHandle(handle);
|
|
handle.focus();
|
|
},
|
|
});
|
|
const spawnOverlay = async (ctx: ExtensionContext, request?: SpawnRequest): Promise<void> => {
|
|
if (coordinator.isOverlayOpen()) {
|
|
ctx.ui.notify("An overlay is already open. Close it first.", "error");
|
|
return;
|
|
}
|
|
|
|
const config = loadRuntimeConfig(ctx.cwd);
|
|
const spawn = resolveSpawn(config, ctx.cwd, request, () => ctx.sessionManager.getSessionFile());
|
|
if (!spawn.ok) {
|
|
ctx.ui.notify(spawn.error, "error");
|
|
return;
|
|
}
|
|
|
|
if (!coordinator.beginOverlay()) {
|
|
ctx.ui.notify(appendWorktreeNotice("An overlay is already open. Close it first.", spawn.spawn.worktreePath), "error");
|
|
return;
|
|
}
|
|
try {
|
|
const result = await ctx.ui.custom<InteractiveShellResult>(
|
|
(tui, theme, _kb, done) =>
|
|
new InteractiveShellOverlay(tui, theme, {
|
|
command: spawn.spawn.command,
|
|
cwd: spawn.spawn.cwd,
|
|
reason: spawn.spawn.reason,
|
|
onUnfocus: () => coordinator.unfocusOverlay(),
|
|
}, config, done),
|
|
createOverlayUiOptions(config),
|
|
);
|
|
if (spawn.spawn.worktreePath) {
|
|
ctx.ui.notify(`Worktree left in place: ${spawn.spawn.worktreePath}`, "info");
|
|
}
|
|
emitTransferredOutput(pi, result);
|
|
} finally {
|
|
coordinator.endOverlay();
|
|
}
|
|
};
|
|
const startNewSession = async (params: {
|
|
ctx: Pick<ExtensionContext, "ui" | "cwd" | "sessionManager"> & { hasUI?: boolean };
|
|
command?: string;
|
|
spawn?: SpawnRequest;
|
|
cwd?: string;
|
|
name?: string;
|
|
reason?: string;
|
|
mode?: "interactive" | "hands-free" | "dispatch" | "monitor";
|
|
background?: boolean;
|
|
handsFree?: ToolParams["handsFree"];
|
|
handoffPreview?: ToolParams["handoffPreview"];
|
|
handoffSnapshot?: ToolParams["handoffSnapshot"];
|
|
timeout?: number;
|
|
monitor?: ToolParams["monitor"];
|
|
onUpdate?: (update: { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }) => void;
|
|
}): Promise<{ content: Array<{ type: "text"; text: string }>; details?: any; isError?: boolean }> => {
|
|
const { ctx, command, spawn, cwd, name, reason, mode, background, handsFree, handoffPreview, handoffSnapshot, timeout, monitor, onUpdate } = params;
|
|
const allowsGeneratedCommand = mode === "monitor" && monitor?.strategy === "file-watch";
|
|
if (!command && !spawn && !allowsGeneratedCommand) {
|
|
return {
|
|
content: [{ type: "text", text: "One of 'command' or 'spawn' is required." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
let effectiveCwd = cwd ?? ctx.cwd;
|
|
const config = loadRuntimeConfig(effectiveCwd);
|
|
const isMonitorMode = mode === "monitor";
|
|
const isNonBlocking = mode === "hands-free" || mode === "dispatch" || isMonitorMode;
|
|
const hasUI = ctx.hasUI !== false;
|
|
|
|
if (background && mode !== "dispatch" && mode !== "monitor") {
|
|
return {
|
|
content: [{ type: "text", text: "background: true requires mode='dispatch' or mode='monitor' for new sessions." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
if (!isMonitorMode && !(mode === "dispatch" && background)) {
|
|
if (!hasUI) {
|
|
return {
|
|
content: [{ type: "text", text: "Interactive shell requires interactive TUI mode" }],
|
|
isError: true,
|
|
};
|
|
}
|
|
if (coordinator.isOverlayOpen()) {
|
|
return {
|
|
content: [{ type: "text", text: "An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one." }],
|
|
isError: true,
|
|
details: { error: "overlay_already_open" },
|
|
};
|
|
}
|
|
}
|
|
|
|
let effectiveCommand = command;
|
|
let effectiveReason = reason;
|
|
let spawnWorktreePath: string | undefined;
|
|
let spawnAgent: string | undefined;
|
|
let spawnMode: string | undefined;
|
|
if (spawn) {
|
|
const resolvedSpawn = resolveSpawn(config, effectiveCwd, spawn, () => ctx.sessionManager.getSessionFile());
|
|
if (!resolvedSpawn.ok) {
|
|
return {
|
|
content: [{ type: "text", text: resolvedSpawn.error }],
|
|
isError: true,
|
|
};
|
|
}
|
|
effectiveCommand = resolvedSpawn.spawn.command;
|
|
effectiveCwd = resolvedSpawn.spawn.cwd;
|
|
effectiveReason = effectiveReason
|
|
? `${effectiveReason} • ${resolvedSpawn.spawn.reason}`
|
|
: resolvedSpawn.spawn.reason;
|
|
spawnWorktreePath = resolvedSpawn.spawn.worktreePath;
|
|
spawnAgent = resolvedSpawn.spawn.agent;
|
|
spawnMode = resolvedSpawn.spawn.mode;
|
|
}
|
|
const expectsGeneratedCommand = isMonitorMode && monitor?.strategy === "file-watch";
|
|
if (!effectiveCommand && !expectsGeneratedCommand) {
|
|
return {
|
|
content: [{ type: "text", text: "Failed to resolve the command to launch." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
if (isMonitorMode) {
|
|
const compiledMonitor = compileMonitorConfig(monitor);
|
|
if (!compiledMonitor.ok) {
|
|
return {
|
|
content: [{ type: "text", text: compiledMonitor.error }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const id = generateSessionId(name);
|
|
const sessionCommand = compiledMonitor.compiled.runtime.strategy === "file-watch"
|
|
? `file-watch ${compiledMonitor.compiled.fileWatch?.path ?? "<unknown>"}`
|
|
: effectiveCommand!;
|
|
const monitorCommand = compiledMonitor.compiled.runtime.strategy === "poll-diff"
|
|
? buildPollDiffLoopCommand(sessionCommand, compiledMonitor.compiled.runtime.pollIntervalMs)
|
|
: compiledMonitor.compiled.runtime.strategy === "file-watch"
|
|
? buildFileWatchCommand(compiledMonitor.compiled.fileWatch!)
|
|
: sessionCommand;
|
|
const session = new PtyTerminalSession(
|
|
{ command: monitorCommand, cwd: effectiveCwd, cols: 120, rows: 40, scrollback: config.scrollbackLines },
|
|
);
|
|
const startTime = Date.now();
|
|
sessionManager.add(sessionCommand, session, name, effectiveReason, { id, noAutoCleanup: true, startedAt: new Date(startTime) });
|
|
|
|
coordinator.registerMonitorSession(id, compiledMonitor.compiled.publicConfig, new Date(startTime));
|
|
const monitorRunner = new HeadlessDispatchMonitor(session, config, {
|
|
autoExitOnQuiet: handsFree?.autoExitOnQuiet === true,
|
|
quietThreshold: handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
|
|
gracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
|
|
timeout,
|
|
startedAt: startTime,
|
|
monitor: compiledMonitor.compiled.runtime,
|
|
onMonitorEvent: makeMonitorEventCallback(pi, id, compiledMonitor.compiled, effectiveCwd),
|
|
}, makeStructuredMonitorCompletionCallback(pi, id));
|
|
registerHeadlessActive(id, sessionCommand, effectiveReason, session, monitorRunner, startTime, config, "monitoring");
|
|
|
|
return {
|
|
content: [{ type: "text", text: appendWorktreeNotice(`Monitor started in background (id: ${id}).\nStrategy: ${compiledMonitor.compiled.publicConfig.strategy ?? "stream"}\nTriggers: ${compiledMonitor.compiled.publicConfig.triggers.map((trigger) => trigger.id).join(", ")}\nYou'll be notified when a trigger emits an event.`, spawnWorktreePath) }],
|
|
details: { sessionId: id, backgroundId: id, mode: "monitor", monitor: compiledMonitor.compiled.publicConfig, background: true, spawnAgent, spawnMode, spawnWorktreePath },
|
|
};
|
|
}
|
|
|
|
if (mode === "dispatch" && background) {
|
|
const id = generateSessionId(name);
|
|
const session = new PtyTerminalSession(
|
|
{ command: effectiveCommand, cwd: effectiveCwd, cols: 120, rows: 40, scrollback: config.scrollbackLines },
|
|
);
|
|
|
|
const startTime = Date.now();
|
|
sessionManager.add(effectiveCommand, session, name, effectiveReason, { id, noAutoCleanup: true, startedAt: new Date(startTime) });
|
|
|
|
const monitor = new HeadlessDispatchMonitor(session, config, {
|
|
autoExitOnQuiet: handsFree?.autoExitOnQuiet !== false,
|
|
quietThreshold: handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
|
|
gracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
|
|
timeout,
|
|
startedAt: startTime,
|
|
}, makeMonitorCompletionCallback(pi, id, startTime));
|
|
registerHeadlessActive(id, effectiveCommand, effectiveReason, session, monitor, startTime, config);
|
|
|
|
return {
|
|
content: [{ type: "text", text: appendWorktreeNotice(`Session dispatched in background (id: ${id}).\nYou'll be notified when it completes. User can /attach ${id} to watch.`, spawnWorktreePath) }],
|
|
details: { sessionId: id, backgroundId: id, mode: "dispatch", background: true, spawnAgent, spawnMode, spawnWorktreePath },
|
|
};
|
|
}
|
|
|
|
const generatedSessionId = isNonBlocking ? generateSessionId(name) : undefined;
|
|
if (isNonBlocking && generatedSessionId) {
|
|
if (!coordinator.beginOverlay()) {
|
|
return {
|
|
content: [{ type: "text", text: appendWorktreeNotice("An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one.", spawnWorktreePath) }],
|
|
isError: true,
|
|
details: { error: "overlay_already_open", spawnAgent, spawnMode, spawnWorktreePath },
|
|
};
|
|
}
|
|
const overlayStartTime = Date.now();
|
|
|
|
let overlayPromise: Promise<InteractiveShellResult>;
|
|
try {
|
|
overlayPromise = ctx.ui.custom<InteractiveShellResult>(
|
|
(tui, theme, _kb, done) =>
|
|
new InteractiveShellOverlay(tui, theme, {
|
|
command: effectiveCommand,
|
|
cwd: effectiveCwd,
|
|
name,
|
|
reason: effectiveReason,
|
|
mode,
|
|
sessionId: generatedSessionId,
|
|
startedAt: overlayStartTime,
|
|
handsFreeUpdateMode: handsFree?.updateMode,
|
|
handsFreeUpdateInterval: handsFree?.updateInterval,
|
|
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
autoExitOnQuiet: mode === "dispatch"
|
|
? handsFree?.autoExitOnQuiet !== false
|
|
: handsFree?.autoExitOnQuiet === true,
|
|
autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
|
|
onUnfocus: () => coordinator.unfocusOverlay(),
|
|
onHandsFreeUpdate: mode === "hands-free"
|
|
? makeNonBlockingUpdateHandler(pi)
|
|
: undefined,
|
|
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
handoffPreviewLines: handoffPreview?.lines,
|
|
handoffPreviewMaxChars: handoffPreview?.maxChars,
|
|
handoffSnapshotEnabled: handoffSnapshot?.enabled,
|
|
handoffSnapshotLines: handoffSnapshot?.lines,
|
|
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
timeout,
|
|
}, config, done),
|
|
createOverlayUiOptions(config),
|
|
);
|
|
} catch (error) {
|
|
coordinator.endOverlay();
|
|
throw error;
|
|
}
|
|
|
|
setupDispatchCompletion(pi, overlayPromise, config, {
|
|
id: generatedSessionId,
|
|
mode,
|
|
command: effectiveCommand,
|
|
reason: effectiveReason,
|
|
timeout,
|
|
handsFree,
|
|
overlayStartTime,
|
|
});
|
|
|
|
if (mode === "dispatch") {
|
|
return {
|
|
content: [{ type: "text", text: appendWorktreeNotice(`Session dispatched (id: ${generatedSessionId}).\nYou'll be notified when it completes.\nYou can still query with interactive_shell({ sessionId: "${generatedSessionId}" }) if needed.`, spawnWorktreePath) }],
|
|
details: { sessionId: generatedSessionId, status: "running", command: effectiveCommand, reason: effectiveReason, mode, spawnAgent, spawnMode, spawnWorktreePath },
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: appendWorktreeNotice(`Session started: ${generatedSessionId}\nCommand: ${effectiveCommand}\n\nUse interactive_shell({ sessionId: "${generatedSessionId}" }) to check status/output.\nUse interactive_shell({ sessionId: "${generatedSessionId}", kill: true }) to end when done.`, spawnWorktreePath) }],
|
|
details: { sessionId: generatedSessionId, status: "running", command: effectiveCommand, reason: effectiveReason, spawnAgent, spawnMode, spawnWorktreePath },
|
|
};
|
|
}
|
|
|
|
if (!coordinator.beginOverlay()) {
|
|
return {
|
|
content: [{ type: "text", text: appendWorktreeNotice("An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one.", spawnWorktreePath) }],
|
|
isError: true,
|
|
details: { error: "overlay_already_open", spawnAgent, spawnMode, spawnWorktreePath },
|
|
};
|
|
}
|
|
onUpdate?.({
|
|
content: [{ type: "text", text: appendWorktreeNotice(`Opening: ${effectiveCommand}`, spawnWorktreePath) }],
|
|
details: { exitCode: null, backgrounded: false, cancelled: false },
|
|
});
|
|
|
|
let result: InteractiveShellResult;
|
|
try {
|
|
result = await ctx.ui.custom<InteractiveShellResult>(
|
|
(tui, theme, _kb, done) =>
|
|
new InteractiveShellOverlay(tui, theme, {
|
|
command: effectiveCommand,
|
|
cwd: effectiveCwd,
|
|
name,
|
|
reason: effectiveReason,
|
|
mode,
|
|
sessionId: generatedSessionId,
|
|
handsFreeUpdateMode: handsFree?.updateMode,
|
|
handsFreeUpdateInterval: handsFree?.updateInterval,
|
|
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
autoExitOnQuiet: handsFree?.autoExitOnQuiet,
|
|
autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
|
|
onUnfocus: () => coordinator.unfocusOverlay(),
|
|
streamingMode: mode === "hands-free",
|
|
onHandsFreeUpdate: mode === "hands-free"
|
|
? (update) => {
|
|
let statusText: string;
|
|
switch (update.status) {
|
|
case "user-takeover":
|
|
statusText = `User took over session ${update.sessionId}`;
|
|
break;
|
|
case "agent-resumed":
|
|
statusText = `Agent resumed monitoring session ${update.sessionId}`;
|
|
break;
|
|
case "exited":
|
|
statusText = `Session ${update.sessionId} exited`;
|
|
break;
|
|
case "killed":
|
|
statusText = `Session ${update.sessionId} killed`;
|
|
break;
|
|
default: {
|
|
const budgetInfo = update.budgetExhausted ? " [budget exhausted]" : "";
|
|
statusText = `Session ${update.sessionId} running (${formatDurationMs(update.runtime)})${budgetInfo}`;
|
|
}
|
|
}
|
|
const newOutput = update.status === "running" && update.tail.length > 0
|
|
? `\n\n${update.tail.join("\n")}`
|
|
: "";
|
|
onUpdate?.({
|
|
content: [{ type: "text", text: statusText + newOutput }],
|
|
details: {
|
|
status: update.status,
|
|
sessionId: update.sessionId,
|
|
runtime: update.runtime,
|
|
newChars: update.tail.join("\n").length,
|
|
totalCharsSent: update.totalCharsSent,
|
|
budgetExhausted: update.budgetExhausted,
|
|
userTookOver: update.userTookOver,
|
|
},
|
|
});
|
|
pi.events.emit("interactive-shell:update", update);
|
|
}
|
|
: undefined,
|
|
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
handoffPreviewLines: handoffPreview?.lines,
|
|
handoffPreviewMaxChars: handoffPreview?.maxChars,
|
|
handoffSnapshotEnabled: handoffSnapshot?.enabled,
|
|
handoffSnapshotLines: handoffSnapshot?.lines,
|
|
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
timeout,
|
|
}, config, done),
|
|
createOverlayUiOptions(config),
|
|
);
|
|
} finally {
|
|
coordinator.endOverlay();
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: appendWorktreeNotice(summarizeInteractiveResult(effectiveCommand, result, timeout, effectiveReason), spawnWorktreePath) }],
|
|
details: { ...result, spawnAgent, spawnMode, spawnWorktreePath },
|
|
};
|
|
};
|
|
pi.registerShortcut(startupConfig.focusShortcut, {
|
|
description: "Focus interactive shell overlay",
|
|
handler: () => {
|
|
coordinator.focusOverlay();
|
|
},
|
|
});
|
|
pi.registerShortcut(startupConfig.spawn.shortcut, {
|
|
description: "Spawn the configured default agent in a fresh interactive shell overlay",
|
|
handler: (ctx) => spawnOverlay(ctx),
|
|
});
|
|
|
|
pi.on("session_start", (_event, ctx) => {
|
|
coordinator.replaceBackgroundWidgetCleanup(setupBackgroundWidget(ctx, sessionManager, coordinator));
|
|
terminalInputCleanup?.();
|
|
terminalInputCleanup = ctx.ui.onTerminalInput((data) => {
|
|
if (!coordinator.isOverlayOpen()) return undefined;
|
|
if (isKeyRelease(data) || isKeyRepeat(data)) {
|
|
return undefined;
|
|
}
|
|
if (matchesKey(data, startupConfig.focusShortcut)) {
|
|
if (coordinator.isOverlayFocused()) {
|
|
coordinator.unfocusOverlay();
|
|
} else {
|
|
coordinator.focusOverlay();
|
|
}
|
|
return { consume: true };
|
|
}
|
|
if (matchesKey(data, SIDE_CHAT_SHORTCUT)) {
|
|
ctx.ui.notify("Close pi-interactive-shell first.", "warning");
|
|
return { consume: true };
|
|
}
|
|
return undefined;
|
|
});
|
|
});
|
|
|
|
pi.on("session_shutdown", () => {
|
|
terminalInputCleanup?.();
|
|
terminalInputCleanup = null;
|
|
coordinator.clearBackgroundWidget();
|
|
sessionManager.killAll();
|
|
coordinator.disposeAllMonitors();
|
|
});
|
|
|
|
pi.registerTool({
|
|
name: TOOL_NAME,
|
|
label: TOOL_LABEL,
|
|
description: TOOL_DESCRIPTION,
|
|
promptSnippet:
|
|
"Use this only to delegate tasks to interactive CLI coding agents (pi/claude/cursor/gemini/codex/aider). Prefer mode='dispatch' for fire-and-forget delegations. When sending slash commands or prompts to an existing session, use submit=true so the text is actually submitted.",
|
|
parameters: toolParameters,
|
|
|
|
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
const {
|
|
command,
|
|
spawn,
|
|
sessionId,
|
|
kill,
|
|
outputLines,
|
|
outputMaxChars,
|
|
outputOffset,
|
|
drain,
|
|
incremental,
|
|
settings,
|
|
input,
|
|
submit,
|
|
inputKeys,
|
|
inputHex,
|
|
inputPaste,
|
|
cwd,
|
|
name,
|
|
reason,
|
|
mode,
|
|
background,
|
|
attach,
|
|
listBackground,
|
|
dismissBackground,
|
|
monitorEvents,
|
|
monitorStatus,
|
|
monitorSessionId,
|
|
monitorEventLimit,
|
|
monitorEventOffset,
|
|
monitorSinceEventId,
|
|
monitorTriggerId,
|
|
handsFree,
|
|
handoffPreview,
|
|
handoffSnapshot,
|
|
timeout,
|
|
monitor,
|
|
} = params as ToolParams;
|
|
|
|
const hasStructuredInput = inputKeys?.length || inputHex?.length || inputPaste;
|
|
const effectiveInput = hasStructuredInput
|
|
? { text: input, keys: inputKeys, hex: inputHex, paste: inputPaste }
|
|
: input;
|
|
|
|
if (spawn && command) {
|
|
return {
|
|
content: [{ type: "text", text: "Use either 'command' or 'spawn', not both." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
if (spawn && (sessionId || attach || listBackground || dismissBackground || monitorEvents || monitorStatus)) {
|
|
return {
|
|
content: [{ type: "text", text: "'spawn' is only valid when starting a new session." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
if ((params as { monitorFilter?: unknown }).monitorFilter !== undefined) {
|
|
return {
|
|
content: [{ type: "text", text: "monitorFilter was removed. Use mode='monitor' with a structured monitor object." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
if (monitorStatus) {
|
|
const targetMonitorSessionId = monitorSessionId ?? sessionId;
|
|
if (!targetMonitorSessionId) {
|
|
return {
|
|
content: [{ type: "text", text: "monitorStatus requires monitorSessionId (or sessionId)." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const state = coordinator.getMonitorSessionState(targetMonitorSessionId);
|
|
if (!state) {
|
|
return {
|
|
content: [{ type: "text", text: `No monitor state for session ${targetMonitorSessionId}.` }],
|
|
details: { sessionId: targetMonitorSessionId, state: null },
|
|
};
|
|
}
|
|
|
|
const summary = [
|
|
`Monitor state for ${targetMonitorSessionId}`,
|
|
`Status: ${state.status}`,
|
|
`Strategy: ${state.strategy}`,
|
|
`Triggers: ${state.triggerIds.join(", ") || "(none)"}`,
|
|
`Events: ${state.eventCount}`,
|
|
`Started: ${state.startedAt}`,
|
|
state.lastEventAt ? `Last event: #${state.lastEventId} at ${state.lastEventAt}` : "Last event: none",
|
|
state.terminalReason ? `Terminal reason: ${state.terminalReason}` : "Terminal reason: (running)",
|
|
].join("\n");
|
|
|
|
return {
|
|
content: [{ type: "text", text: summary }],
|
|
details: { sessionId: targetMonitorSessionId, state },
|
|
};
|
|
}
|
|
|
|
if (monitorEvents) {
|
|
const targetMonitorSessionId = monitorSessionId ?? sessionId;
|
|
if (!targetMonitorSessionId) {
|
|
return {
|
|
content: [{ type: "text", text: "monitorEvents requires monitorSessionId (or sessionId)." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const history = coordinator.getMonitorEvents(targetMonitorSessionId, {
|
|
limit: monitorEventLimit,
|
|
offset: monitorEventOffset,
|
|
sinceEventId: monitorSinceEventId,
|
|
triggerId: monitorTriggerId,
|
|
});
|
|
const state = coordinator.getMonitorSessionState(targetMonitorSessionId);
|
|
if (history.total === 0) {
|
|
return {
|
|
content: [{ type: "text", text: `No monitor events for session ${targetMonitorSessionId}.` }],
|
|
details: {
|
|
sessionId: targetMonitorSessionId,
|
|
events: [],
|
|
total: 0,
|
|
limit: history.limit,
|
|
offset: history.offset,
|
|
sinceEventId: history.sinceEventId,
|
|
triggerId: history.triggerId,
|
|
state,
|
|
},
|
|
};
|
|
}
|
|
|
|
const lines = history.events.map((event) =>
|
|
`#${event.eventId} [${event.strategy}/${event.triggerId}] ${event.timestamp} :: ${event.matchedText}`,
|
|
);
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Monitor events for ${targetMonitorSessionId} (${history.events.length}/${history.total}, offset ${history.offset}):\n${lines.join("\n")}`,
|
|
}],
|
|
details: {
|
|
sessionId: targetMonitorSessionId,
|
|
events: history.events,
|
|
total: history.total,
|
|
limit: history.limit,
|
|
offset: history.offset,
|
|
sinceEventId: history.sinceEventId,
|
|
triggerId: history.triggerId,
|
|
state,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── Branch 1: Interact with existing session ──
|
|
if (sessionId) {
|
|
const session = sessionManager.getActive(sessionId);
|
|
if (!session) {
|
|
return {
|
|
content: [{ type: "text", text: `Session not found or no longer active: ${sessionId}` }],
|
|
isError: true,
|
|
details: { sessionId, error: "session_not_found" },
|
|
};
|
|
}
|
|
|
|
// Kill
|
|
if (kill) {
|
|
const alreadyCompleted = Boolean(session.getResult());
|
|
if (!alreadyCompleted) {
|
|
coordinator.markAgentHandledCompletion(sessionId);
|
|
}
|
|
const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
const status = session.getStatus();
|
|
const runtime = session.getRuntime();
|
|
session.kill();
|
|
sessionManager.unregisterActive(sessionId, true);
|
|
|
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} killed after ${formatDurationMs(runtime)}${output ? `\n\nFinal output${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
details: { sessionId, status: "killed", runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, previousStatus: status },
|
|
};
|
|
}
|
|
|
|
// Background
|
|
if (background) {
|
|
if (session.getResult()) {
|
|
return {
|
|
content: [{ type: "text", text: "Session already completed." }],
|
|
details: session.getResult(),
|
|
};
|
|
}
|
|
const bMonitor = coordinator.getMonitor(sessionId);
|
|
if (!bMonitor || bMonitor.disposed) {
|
|
coordinator.markAgentHandledCompletion(sessionId);
|
|
}
|
|
session.background();
|
|
const result = session.getResult();
|
|
if (!result || !result.backgrounded) {
|
|
coordinator.consumeAgentHandledCompletion(sessionId);
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} is already running in the background.` }],
|
|
details: { sessionId },
|
|
};
|
|
}
|
|
sessionManager.unregisterActive(sessionId, false);
|
|
return {
|
|
content: [{ type: "text", text: `Session backgrounded (id: ${result.backgroundId})` }],
|
|
details: { sessionId, backgroundId: result.backgroundId, ...result },
|
|
};
|
|
}
|
|
|
|
const actions: string[] = [];
|
|
|
|
if (settings?.updateInterval !== undefined) {
|
|
if (sessionManager.setActiveUpdateInterval(sessionId, settings.updateInterval)) {
|
|
actions.push(`update interval set to ${settings.updateInterval}ms`);
|
|
}
|
|
}
|
|
if (settings?.quietThreshold !== undefined) {
|
|
if (sessionManager.setActiveQuietThreshold(sessionId, settings.quietThreshold)) {
|
|
actions.push(`quiet threshold set to ${settings.quietThreshold}ms`);
|
|
}
|
|
}
|
|
|
|
if (effectiveInput !== undefined || submit) {
|
|
const translatedInput = effectiveInput !== undefined ? translateInput(effectiveInput) : "";
|
|
const finalInput = submit ? `${translatedInput}\r` : translatedInput;
|
|
const success = sessionManager.writeToActive(sessionId, finalInput);
|
|
if (!success) {
|
|
return {
|
|
content: [{ type: "text", text: `Failed to send input to session: ${sessionId}` }],
|
|
isError: true,
|
|
details: { sessionId, error: "write_failed" },
|
|
};
|
|
}
|
|
const inputDesc = effectiveInput === undefined
|
|
? ""
|
|
: typeof effectiveInput === "string"
|
|
? effectiveInput.length === 0 ? "(empty)" : effectiveInput.length > 50 ? `${effectiveInput.slice(0, 50)}...` : effectiveInput
|
|
: [effectiveInput.text ?? "", effectiveInput.keys ? `keys:[${effectiveInput.keys.join(",")}]` : "", effectiveInput.hex ? `hex:[${effectiveInput.hex.length} bytes]` : "", effectiveInput.paste ? `paste:[${effectiveInput.paste.length} chars]` : ""].filter(Boolean).join(" + ") || "(empty)";
|
|
if (submit) {
|
|
actions.push(inputDesc ? `sent: ${inputDesc} + enter` : "sent: enter");
|
|
} else {
|
|
actions.push(`sent: ${inputDesc}`);
|
|
}
|
|
}
|
|
|
|
if (actions.length === 0) {
|
|
const status = session.getStatus();
|
|
const runtime = session.getRuntime();
|
|
const result = session.getResult();
|
|
|
|
if (result) {
|
|
const { output, truncated, totalBytes, totalLines, hasMore } = session.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
const hasOutput = output.length > 0;
|
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
sessionManager.unregisterActive(sessionId, !result.backgrounded);
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} ${status} after ${formatDurationMs(runtime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
details: { sessionId, status, runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, exitCode: result.exitCode, signal: result.signal, backgroundId: result.backgroundId },
|
|
};
|
|
}
|
|
|
|
const outputResult = session.getOutput({ lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
|
|
if (outputResult.rateLimited && outputResult.waitSeconds) {
|
|
const waitMs = outputResult.waitSeconds * 1000;
|
|
const completedEarly = await Promise.race([
|
|
new Promise<false>((resolve) => setTimeout(() => resolve(false), waitMs)),
|
|
new Promise<true>((resolve) => session.onComplete(() => resolve(true))),
|
|
]);
|
|
|
|
if (completedEarly) {
|
|
const earlySession = sessionManager.getActive(sessionId);
|
|
if (!earlySession) {
|
|
return { content: [{ type: "text", text: `Session ${sessionId} ended` }], details: { sessionId, status: "ended" } };
|
|
}
|
|
const earlyResult = earlySession.getResult();
|
|
const { output, truncated, totalBytes, totalLines, hasMore } = earlySession.getOutput({ skipRateLimit: true, lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
const earlyStatus = earlySession.getStatus();
|
|
const earlyRuntime = earlySession.getRuntime();
|
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
const hasOutput = output.length > 0;
|
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
if (earlyResult) {
|
|
sessionManager.unregisterActive(sessionId, !earlyResult.backgrounded);
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} ${earlyStatus} after ${formatDurationMs(earlyRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
details: { sessionId, status: earlyStatus, runtime: earlyRuntime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, exitCode: earlyResult.exitCode, signal: earlyResult.signal, backgroundId: earlyResult.backgroundId },
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} ${earlyStatus} (${formatDurationMs(earlyRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
details: { sessionId, status: earlyStatus, runtime: earlyRuntime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, hasOutput },
|
|
};
|
|
}
|
|
|
|
const freshOutput = session.getOutput({ lines: outputLines, maxChars: outputMaxChars, offset: outputOffset, drain, incremental });
|
|
const truncatedNote = freshOutput.truncated ? ` (${freshOutput.totalBytes} bytes total, truncated)` : "";
|
|
const hasOutput = freshOutput.output.length > 0;
|
|
const hasMoreNote = freshOutput.hasMore === true ? " (more available)" : "";
|
|
const freshStatus = session.getStatus();
|
|
const freshRuntime = session.getRuntime();
|
|
const freshResult = session.getResult();
|
|
if (freshResult) {
|
|
sessionManager.unregisterActive(sessionId, !freshResult.backgrounded);
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} ${freshStatus} after ${formatDurationMs(freshRuntime)}${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}` }],
|
|
details: { sessionId, status: freshStatus, runtime: freshRuntime, output: freshOutput.output, outputTruncated: freshOutput.truncated, outputTotalBytes: freshOutput.totalBytes, outputTotalLines: freshOutput.totalLines, hasMore: freshOutput.hasMore, exitCode: freshResult.exitCode, signal: freshResult.signal, backgroundId: freshResult.backgroundId },
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} ${freshStatus} (${formatDurationMs(freshRuntime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${freshOutput.output}` : ""}` }],
|
|
details: { sessionId, status: freshStatus, runtime: freshRuntime, output: freshOutput.output, outputTruncated: freshOutput.truncated, outputTotalBytes: freshOutput.totalBytes, outputTotalLines: freshOutput.totalLines, hasMore: freshOutput.hasMore, hasOutput },
|
|
};
|
|
}
|
|
|
|
const { output, truncated, totalBytes, totalLines, hasMore } = outputResult;
|
|
const truncatedNote = truncated ? ` (${totalBytes} bytes total, truncated)` : "";
|
|
const hasOutput = output.length > 0;
|
|
const hasMoreNote = hasMore === true ? " (more available)" : "";
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId} ${status} (${formatDurationMs(runtime)})${hasOutput ? `\n\nOutput${truncatedNote}${hasMoreNote}:\n${output}` : ""}` }],
|
|
details: { sessionId, status, runtime, output, outputTruncated: truncated, outputTotalBytes: totalBytes, outputTotalLines: totalLines, hasMore, hasOutput },
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: `Session ${sessionId}: ${actions.join(", ")}` }],
|
|
details: { sessionId, actions },
|
|
};
|
|
}
|
|
|
|
// ── Branch 2: Attach to background session ──
|
|
if (attach) {
|
|
if (background) {
|
|
return {
|
|
content: [{ type: "text", text: "Cannot attach and background simultaneously." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
if (!ctx.hasUI) {
|
|
return {
|
|
content: [{ type: "text", text: "Attach requires interactive TUI mode" }],
|
|
isError: true,
|
|
};
|
|
}
|
|
if (coordinator.isOverlayOpen()) {
|
|
return {
|
|
content: [{ type: "text", text: "An interactive shell overlay is already open." }],
|
|
isError: true,
|
|
details: { error: "overlay_already_open" },
|
|
};
|
|
}
|
|
|
|
const monitor = coordinator.getMonitor(attach);
|
|
const bgSession = sessionManager.take(attach);
|
|
if (!bgSession) {
|
|
disposeStaleMonitor(attach, monitor);
|
|
return {
|
|
content: [{ type: "text", text: `Background session not found: ${attach}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const restoreAttachSession = () => {
|
|
bgSession.session.setEventHandlers({});
|
|
sessionManager.restore(bgSession, { noAutoCleanup: Boolean(monitor && !monitor.disposed) });
|
|
return {
|
|
releaseId: false,
|
|
disposeMonitor: false,
|
|
};
|
|
};
|
|
if (!coordinator.beginOverlay()) {
|
|
restoreAttachSession();
|
|
return {
|
|
content: [{ type: "text", text: "An interactive shell overlay is already open." }],
|
|
isError: true,
|
|
details: { error: "overlay_already_open" },
|
|
};
|
|
}
|
|
|
|
const config = loadRuntimeConfig(cwd ?? ctx.cwd);
|
|
const reattachSessionId = attach;
|
|
const isNonBlocking = mode === "hands-free" || mode === "dispatch";
|
|
const attachStartTime = bgSession.startedAt.getTime();
|
|
let overlayPromise: Promise<InteractiveShellResult>;
|
|
try {
|
|
overlayPromise = ctx.ui.custom<InteractiveShellResult>(
|
|
(tui, theme, _kb, done) =>
|
|
new InteractiveShellOverlay(tui, theme, {
|
|
command: bgSession.command,
|
|
existingSession: bgSession.session,
|
|
sessionId: reattachSessionId,
|
|
mode,
|
|
cwd: cwd ?? ctx.cwd,
|
|
name: bgSession.name,
|
|
reason: bgSession.reason ?? reason,
|
|
startedAt: attachStartTime,
|
|
handsFreeUpdateMode: handsFree?.updateMode,
|
|
handsFreeUpdateInterval: handsFree?.updateInterval,
|
|
handsFreeQuietThreshold: handsFree?.quietThreshold,
|
|
handsFreeUpdateMaxChars: handsFree?.updateMaxChars,
|
|
handsFreeMaxTotalChars: handsFree?.maxTotalChars,
|
|
autoExitOnQuiet: mode === "dispatch"
|
|
? handsFree?.autoExitOnQuiet !== false
|
|
: handsFree?.autoExitOnQuiet === true,
|
|
autoExitGracePeriod: handsFree?.gracePeriod ?? config.autoExitGracePeriod,
|
|
onUnfocus: () => coordinator.unfocusOverlay(),
|
|
onHandsFreeUpdate: mode === "hands-free"
|
|
? makeNonBlockingUpdateHandler(pi)
|
|
: undefined,
|
|
handoffPreviewEnabled: handoffPreview?.enabled,
|
|
handoffPreviewLines: handoffPreview?.lines,
|
|
handoffPreviewMaxChars: handoffPreview?.maxChars,
|
|
handoffSnapshotEnabled: handoffSnapshot?.enabled,
|
|
handoffSnapshotLines: handoffSnapshot?.lines,
|
|
handoffSnapshotMaxChars: handoffSnapshot?.maxChars,
|
|
timeout,
|
|
}, config, done),
|
|
createOverlayUiOptions(config),
|
|
);
|
|
} catch (error) {
|
|
coordinator.endOverlay();
|
|
restoreAttachSession();
|
|
throw error;
|
|
}
|
|
|
|
if (isNonBlocking) {
|
|
setupDispatchCompletion(pi, overlayPromise, config, {
|
|
id: reattachSessionId,
|
|
mode: mode!,
|
|
command: bgSession.command,
|
|
reason: bgSession.reason,
|
|
timeout,
|
|
handsFree,
|
|
overlayStartTime: attachStartTime,
|
|
onOverlayError: restoreAttachSession,
|
|
});
|
|
return {
|
|
content: [{ type: "text", text: mode === "dispatch"
|
|
? `Reattached to ${reattachSessionId}. You'll be notified when it completes.`
|
|
: `Reattached to ${reattachSessionId}.\nUse interactive_shell({ sessionId: "${reattachSessionId}" }) to check status/output.` }],
|
|
details: { sessionId: reattachSessionId, status: "running", command: bgSession.command, reason: bgSession.reason, mode },
|
|
};
|
|
}
|
|
|
|
let result: InteractiveShellResult;
|
|
try {
|
|
result = await overlayPromise;
|
|
} catch (error) {
|
|
restoreAttachSession();
|
|
throw error;
|
|
} finally {
|
|
coordinator.endOverlay();
|
|
}
|
|
if (monitor && !monitor.disposed) {
|
|
if (!result.backgrounded) {
|
|
monitor.handleExternalCompletion(result.exitCode, result.signal, result.completionOutput);
|
|
coordinator.deleteMonitor(attach);
|
|
} else {
|
|
const monitoredId = result.backgroundId ?? attach;
|
|
const monitoredSession = sessionManager.take(monitoredId);
|
|
if (monitoredSession) {
|
|
sessionManager.restore(monitoredSession, { noAutoCleanup: true });
|
|
}
|
|
}
|
|
} else if (result.backgrounded) {
|
|
sessionManager.restartAutoCleanup(attach);
|
|
} else {
|
|
sessionManager.scheduleCleanup(attach);
|
|
}
|
|
|
|
return { content: [{ type: "text", text: summarizeInteractiveResult(command ?? bgSession.command, result, timeout, bgSession.reason ?? reason) }], details: result };
|
|
}
|
|
|
|
// ── Branch 3: List background sessions ──
|
|
if (listBackground) {
|
|
const sessions = sessionManager.list();
|
|
if (sessions.length === 0) {
|
|
return { content: [{ type: "text", text: "No background sessions." }] };
|
|
}
|
|
const lines = sessions.map(s => {
|
|
const monitorState = coordinator.getMonitorSessionState(s.id);
|
|
const status = s.session.exited ? "exited" : "running";
|
|
const duration = formatDuration(Date.now() - s.startedAt.getTime());
|
|
const r = s.reason ? ` \u2022 ${s.reason}` : "";
|
|
const monitorLabel = monitorState
|
|
? ` \u2022 monitor:${monitorState.strategy} events=${monitorState.eventCount}${monitorState.lastEventAt ? ` last=${monitorState.lastEventAt}` : ""}`
|
|
: "";
|
|
return ` ${s.id} - ${s.command}${r}${monitorLabel} (${status}, ${duration})`;
|
|
});
|
|
return { content: [{ type: "text", text: `Background sessions:\n${lines.join("\n")}` }] };
|
|
}
|
|
|
|
// ── Branch 3b: Dismiss background sessions ──
|
|
if (dismissBackground) {
|
|
if (typeof dismissBackground === "string") {
|
|
if (!sessionManager.list().some(s => s.id === dismissBackground)) {
|
|
return { content: [{ type: "text", text: `Background session not found: ${dismissBackground}` }], isError: true };
|
|
}
|
|
}
|
|
|
|
const targetIds = typeof dismissBackground === "string"
|
|
? [dismissBackground]
|
|
: sessionManager.list().map(s => s.id);
|
|
|
|
if (targetIds.length === 0) {
|
|
return { content: [{ type: "text", text: "No background sessions to dismiss." }] };
|
|
}
|
|
|
|
for (const tid of targetIds) {
|
|
coordinator.disposeMonitor(tid);
|
|
coordinator.clearMonitorEvents(tid);
|
|
sessionManager.unregisterActive(tid, false);
|
|
sessionManager.remove(tid);
|
|
}
|
|
|
|
const summary = targetIds.length === 1
|
|
? `Dismissed session ${targetIds[0]}.`
|
|
: `Dismissed ${targetIds.length} sessions: ${targetIds.join(", ")}.`;
|
|
return { content: [{ type: "text", text: summary }] };
|
|
}
|
|
|
|
// ── Branch 4: Start new session ──
|
|
const allowsGeneratedCommand = mode === "monitor" && monitor?.strategy === "file-watch";
|
|
if (!command && !spawn && !allowsGeneratedCommand) {
|
|
return {
|
|
content: [{ type: "text", text: "One of 'command', 'spawn', 'sessionId', 'attach', 'listBackground', or 'dismissBackground' is required." }],
|
|
isError: true,
|
|
};
|
|
}
|
|
return startNewSession({
|
|
ctx,
|
|
command,
|
|
spawn,
|
|
cwd,
|
|
name,
|
|
reason,
|
|
mode,
|
|
background,
|
|
monitor,
|
|
handsFree,
|
|
handoffPreview,
|
|
handoffSnapshot,
|
|
timeout,
|
|
onUpdate,
|
|
});
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("spawn", {
|
|
description: "Spawn the configured default agent, pi, codex, claude, or cursor in an interactive shell overlay",
|
|
handler: async (args, ctx) => {
|
|
const parsed = parseSpawnArgs(args);
|
|
if (!parsed.ok) {
|
|
ctx.ui.notify(`${parsed.error}\nUsage: /spawn [pi|codex|claude|cursor] [fresh|fork] [--worktree] [\"prompt\" --hands-free|--dispatch]`, "error");
|
|
return;
|
|
}
|
|
if (parsed.parsed.monitorMode) {
|
|
const result = await startNewSession({
|
|
ctx,
|
|
spawn: parsed.parsed.request,
|
|
mode: parsed.parsed.monitorMode,
|
|
});
|
|
if (result.isError) {
|
|
ctx.ui.notify(result.content[0]?.text ?? "Failed to start session.", "error");
|
|
}
|
|
return;
|
|
}
|
|
await spawnOverlay(ctx, parsed.parsed.request);
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("attach", {
|
|
description: "Reattach to a background shell session",
|
|
handler: async (args, ctx) => {
|
|
if (coordinator.isOverlayOpen()) {
|
|
ctx.ui.notify("An overlay is already open. Close it first.", "error");
|
|
return;
|
|
}
|
|
|
|
const sessions = sessionManager.list();
|
|
if (sessions.length === 0) {
|
|
ctx.ui.notify("No background sessions", "info");
|
|
return;
|
|
}
|
|
|
|
let targetId = args.trim();
|
|
if (!targetId) {
|
|
const options = sessions.map((s) => {
|
|
const status = s.session.exited ? "exited" : "running";
|
|
const duration = formatDuration(Date.now() - s.startedAt.getTime());
|
|
const sanitizedCommand = s.command.replace(/\s+/g, " ").trim();
|
|
const sanitizedReason = s.reason?.replace(/\s+/g, " ").trim();
|
|
const r = sanitizedReason ? ` \u2022 ${sanitizedReason}` : "";
|
|
return {
|
|
id: s.id,
|
|
label: `${s.id} - ${sanitizedCommand}${r} (${status}, ${duration})`,
|
|
};
|
|
});
|
|
const choice = await ctx.ui.select("Background Sessions", options.map((o) => o.label));
|
|
if (!choice) return;
|
|
targetId = options.find((o) => o.label === choice)!.id;
|
|
}
|
|
|
|
const monitor = coordinator.getMonitor(targetId);
|
|
if (!coordinator.beginOverlay()) {
|
|
ctx.ui.notify("An overlay is already open. Close it first.", "error");
|
|
return;
|
|
}
|
|
|
|
const session = sessionManager.get(targetId);
|
|
if (!session) {
|
|
disposeStaleMonitor(targetId, monitor);
|
|
coordinator.endOverlay();
|
|
ctx.ui.notify(`Session not found: ${targetId}`, "error");
|
|
return;
|
|
}
|
|
|
|
const restoreBackgroundLifecycle = () => {
|
|
session.session.setEventHandlers({});
|
|
if (monitor && !monitor.disposed) {
|
|
return;
|
|
}
|
|
if (session.session.exited) {
|
|
sessionManager.scheduleCleanup(targetId);
|
|
return;
|
|
}
|
|
sessionManager.restartAutoCleanup(targetId);
|
|
};
|
|
|
|
const config = loadRuntimeConfig(ctx.cwd);
|
|
try {
|
|
const result = await ctx.ui.custom<InteractiveShellResult>(
|
|
(tui, theme, _kb, done) =>
|
|
new ReattachOverlay(
|
|
tui,
|
|
theme,
|
|
{ id: session.id, command: session.command, reason: session.reason, session: session.session },
|
|
config,
|
|
done,
|
|
() => coordinator.unfocusOverlay(),
|
|
),
|
|
createOverlayUiOptions(config),
|
|
);
|
|
|
|
emitTransferredOutput(pi, result, targetId);
|
|
|
|
if (monitor && !monitor.disposed) {
|
|
if (!result.backgrounded) {
|
|
if (result.transferred) {
|
|
coordinator.markAgentHandledCompletion(targetId);
|
|
}
|
|
monitor.handleExternalCompletion(result.exitCode, result.signal, result.completionOutput);
|
|
coordinator.deleteMonitor(targetId);
|
|
}
|
|
} else if (result.backgrounded) {
|
|
sessionManager.restartAutoCleanup(targetId);
|
|
} else {
|
|
sessionManager.scheduleCleanup(targetId);
|
|
}
|
|
} catch (error) {
|
|
restoreBackgroundLifecycle();
|
|
throw error;
|
|
} finally {
|
|
coordinator.endOverlay();
|
|
}
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("dismiss", {
|
|
description: "Dismiss background shell sessions (kill running, remove exited)",
|
|
handler: async (args, ctx) => {
|
|
const sessions = sessionManager.list();
|
|
if (sessions.length === 0) {
|
|
ctx.ui.notify("No background sessions", "info");
|
|
return;
|
|
}
|
|
|
|
let targetIds: string[];
|
|
const arg = args.trim();
|
|
if (arg) {
|
|
if (!sessions.some(s => s.id === arg)) {
|
|
ctx.ui.notify(`Session not found: ${arg}`, "error");
|
|
return;
|
|
}
|
|
targetIds = [arg];
|
|
} else if (sessions.length === 1) {
|
|
targetIds = [sessions[0].id];
|
|
} else {
|
|
const options = [
|
|
{ label: "All sessions" },
|
|
...sessions.map((s) => {
|
|
const status = s.session.exited ? "exited" : "running";
|
|
const duration = formatDuration(Date.now() - s.startedAt.getTime());
|
|
return { id: s.id, label: `${s.id} (${status}, ${duration})` };
|
|
}),
|
|
];
|
|
const choice = await ctx.ui.select("Dismiss sessions", options.map((o) => o.label));
|
|
if (!choice) return;
|
|
const selected = options.find((o) => o.label === choice);
|
|
targetIds = selected?.id ? [selected.id] : sessions.map((s) => s.id);
|
|
}
|
|
|
|
for (const tid of targetIds) {
|
|
coordinator.disposeMonitor(tid);
|
|
coordinator.clearMonitorEvents(tid);
|
|
sessionManager.unregisterActive(tid, false);
|
|
sessionManager.remove(tid);
|
|
}
|
|
|
|
const noun = targetIds.length === 1 ? "session" : "sessions";
|
|
ctx.ui.notify(`Dismissed ${targetIds.length} ${noun}`, "info");
|
|
},
|
|
});
|
|
}
|
|
|
|
function setupDispatchCompletion(
|
|
pi: ExtensionAPI,
|
|
overlayPromise: Promise<InteractiveShellResult>,
|
|
config: InteractiveShellConfig,
|
|
ctx: {
|
|
id: string;
|
|
mode: string;
|
|
command: string;
|
|
reason?: string;
|
|
timeout?: number;
|
|
handsFree?: { autoExitOnQuiet?: boolean; quietThreshold?: number; gracePeriod?: number };
|
|
overlayStartTime?: number;
|
|
onOverlayError?: () => { releaseId?: boolean; disposeMonitor?: boolean } | void;
|
|
},
|
|
): void {
|
|
const { id, mode, command, reason } = ctx;
|
|
|
|
overlayPromise.then((result) => {
|
|
coordinator.endOverlay();
|
|
|
|
const wasAgentInitiated = coordinator.consumeAgentHandledCompletion(id);
|
|
|
|
if (result.transferred) {
|
|
emitTransferredOutput(pi, result, id);
|
|
sessionManager.unregisterActive(id, true);
|
|
coordinator.disposeMonitor(id);
|
|
return;
|
|
}
|
|
|
|
if (mode === "dispatch" && result.backgrounded) {
|
|
if (!wasAgentInitiated) {
|
|
pi.sendMessage({
|
|
customType: "interactive-shell-transfer",
|
|
content: `Session ${id} moved to background (id: ${result.backgroundId}).`,
|
|
display: true,
|
|
details: { sessionId: id, backgroundId: result.backgroundId },
|
|
}, { triggerTurn: true });
|
|
}
|
|
|
|
const bgId = result.backgroundId!;
|
|
const existingMonitor = coordinator.getMonitor(id);
|
|
const bgSession = sessionManager.get(bgId);
|
|
if (!bgSession) {
|
|
sessionManager.unregisterActive(id, true);
|
|
coordinator.disposeMonitor(id);
|
|
return;
|
|
}
|
|
|
|
sessionManager.unregisterActive(id, bgId !== id);
|
|
|
|
if (existingMonitor && !existingMonitor.disposed) {
|
|
coordinator.deleteMonitor(id);
|
|
registerHeadlessActive(bgId, command, reason, bgSession.session, existingMonitor, bgSession.startedAt.getTime(), config);
|
|
return;
|
|
}
|
|
|
|
const elapsed = ctx.overlayStartTime ? Date.now() - ctx.overlayStartTime : 0;
|
|
const remainingTimeout = ctx.timeout ? Math.max(0, ctx.timeout - elapsed) : undefined;
|
|
const bgStartTime = bgSession.startedAt.getTime();
|
|
const monitor = new HeadlessDispatchMonitor(bgSession.session, config, {
|
|
autoExitOnQuiet: ctx.handsFree?.autoExitOnQuiet !== false,
|
|
quietThreshold: ctx.handsFree?.quietThreshold ?? config.handsFreeQuietThreshold,
|
|
gracePeriod: ctx.handsFree?.gracePeriod ?? config.autoExitGracePeriod,
|
|
timeout: remainingTimeout,
|
|
startedAt: bgStartTime,
|
|
}, makeMonitorCompletionCallback(pi, bgId, bgStartTime));
|
|
registerHeadlessActive(bgId, command, reason, bgSession.session, monitor, bgStartTime, config);
|
|
return;
|
|
}
|
|
|
|
if (mode === "dispatch") {
|
|
if (!wasAgentInitiated) {
|
|
const content = buildResultNotification(id, result);
|
|
pi.sendMessage({
|
|
customType: "interactive-shell-transfer",
|
|
content,
|
|
display: true,
|
|
details: { sessionId: id, exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, cancelled: result.cancelled, completionOutput: result.completionOutput },
|
|
}, { triggerTurn: true });
|
|
}
|
|
pi.events.emit("interactive-shell:transfer", {
|
|
sessionId: id,
|
|
completionOutput: result.completionOutput,
|
|
exitCode: result.exitCode,
|
|
signal: result.signal,
|
|
timedOut: result.timedOut,
|
|
cancelled: result.cancelled,
|
|
});
|
|
sessionManager.unregisterActive(id, true);
|
|
coordinator.disposeMonitor(id);
|
|
return;
|
|
}
|
|
|
|
coordinator.disposeMonitor(id);
|
|
}).catch((error) => {
|
|
console.error(`interactive-shell: overlay error for session ${id}:`, error);
|
|
coordinator.endOverlay();
|
|
const recovery = ctx.onOverlayError?.();
|
|
sessionManager.unregisterActive(id, recovery?.releaseId ?? true);
|
|
if (recovery?.disposeMonitor !== false) {
|
|
coordinator.disposeMonitor(id);
|
|
}
|
|
});
|
|
}
|