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; 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): 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(); 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 | 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, candidate: MonitorEventPayload, cwd?: string, ): Promise { return new Promise((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 = { 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 => { 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( (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 & { 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 }) => 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 ?? ""}` : 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; try { overlayPromise = ctx.ui.custom( (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( (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((resolve) => setTimeout(() => resolve(false), waitMs)), new Promise((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; try { overlayPromise = ctx.ui.custom( (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( (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, 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); } }); }