import { feature } from 'bun:bundle'; import { stat } from 'fs/promises'; import { OUTPUT_FILE_TAG, STATUS_TAG, SUMMARY_TAG, TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG } from '../../constants/xml.js'; import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; import type { AppState } from '../../state/AppState.js'; import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js'; import { createTaskStateBase } from '../../Task.js'; import type { AgentId } from '../../types/ids.js'; import { registerCleanup } from '../../utils/cleanupRegistry.js'; import { tailFile } from '../../utils/fsOperations.js'; import { logError } from '../../utils/log.js'; import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; import type { ShellCommand } from '../../utils/ShellCommand.js'; import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js'; import { registerTask, updateTaskState } from '../../utils/task/framework.js'; import { escapeXml } from '../../utils/xml.js'; import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js'; import { isMainSessionTask } from '../LocalMainSessionTask.js'; import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js'; import { killTask } from './killShellTasks.js'; /** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */ export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '; const STALL_CHECK_INTERVAL_MS = 5_000; const STALL_THRESHOLD_MS = 45_000; const STALL_TAIL_BYTES = 1024; // Last-line patterns that suggest a command is blocked waiting for keyboard // input. Used to gate the stall notification — we stay silent on commands that // are merely slow (git log -S, long builds) and only notify when the tail // looks like an interactive prompt the model can act on. See CC-1175. const PROMPT_PATTERNS = [/\(y\/n\)/i, // (Y/n), (y/N) /\[y\/n\]/i, // [Y/n], [y/N] /\(yes\/no\)/i, /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, // directed questions /Press (any key|Enter)/i, /Continue\?/i, /Overwrite\?/i]; export function looksLikePrompt(tail: string): boolean { const lastLine = tail.trimEnd().split('\n').pop() ?? ''; return PROMPT_PATTERNS.some(p => p.test(lastLine)); } // Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot // notification if output stops growing and the tail looks like a prompt. function startStallWatchdog(taskId: string, description: string, kind: BashTaskKind | undefined, toolUseId?: string, agentId?: AgentId): () => void { if (kind === 'monitor') return () => {}; const outputPath = getTaskOutputPath(taskId); let lastSize = 0; let lastGrowth = Date.now(); let cancelled = false; const timer = setInterval(() => { void stat(outputPath).then(s => { if (s.size > lastSize) { lastSize = s.size; lastGrowth = Date.now(); return; } if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return; void tailFile(outputPath, STALL_TAIL_BYTES).then(({ content }) => { if (cancelled) return; if (!looksLikePrompt(content)) { // Not a prompt — keep watching. Reset so the next check is // 45s out instead of re-reading the tail on every tick. lastGrowth = Date.now(); return; } // Latch before the async-boundary-visible side effects so an // overlapping tick's callback sees cancelled=true and bails. cancelled = true; clearInterval(timer); const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`; // No tag — print.ts treats as a terminal // signal and an unknown value falls through to 'completed', // falsely closing the task for SDK consumers. Statusless // notifications are skipped by the SDK emitter (progress ping). const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${SUMMARY_TAG}>${escapeXml(summary)} Last output: ${content.trimEnd()} The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`; enqueuePendingNotification({ value: message, mode: 'task-notification', priority: 'next', agentId }); }, () => {}); }, () => {} // File may not exist yet ); }, STALL_CHECK_INTERVAL_MS); timer.unref(); return () => { cancelled = true; clearInterval(timer); }; } function enqueueShellNotification(taskId: string, description: string, status: 'completed' | 'failed' | 'killed', exitCode: number | undefined, setAppState: SetAppState, toolUseId?: string, kind: BashTaskKind = 'bash', agentId?: AgentId): void { // Atomically check and set notified flag to prevent duplicate notifications. // If the task was already marked as notified (e.g., by TaskStopTool), skip // enqueueing to avoid sending redundant messages to the model. let shouldEnqueue = false; updateTaskState(taskId, setAppState, task => { if (task.notified) { return task; } shouldEnqueue = true; return { ...task, notified: true }; }); if (!shouldEnqueue) { return; } // Abort any active speculation — background task state changed, so speculated // results may reference stale task output. The prompt suggestion text is // preserved; only the pre-computed response is discarded. abortSpeculation(setAppState); let summary: string; if (feature('MONITOR_TOOL') && kind === 'monitor') { // Monitor is streaming-only (post-#22764) — the script exiting means // the stream ended, not "condition met". Distinct from the bash prefix // so Monitor completions don't fold into the "N background commands // completed" collapse. switch (status) { case 'completed': summary = `Monitor "${description}" stream ended`; break; case 'failed': summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`; break; case 'killed': summary = `Monitor "${description}" stopped`; break; } } else { switch (status) { case 'completed': summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`; break; case 'failed': summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`; break; case 'killed': summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`; break; } } const outputPath = getTaskOutputPath(taskId); const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>${escapeXml(summary)} `; enqueuePendingNotification({ value: message, mode: 'task-notification', priority: feature('MONITOR_TOOL') ? 'next' : 'later', agentId }); } export const LocalShellTask: Task = { name: 'LocalShellTask', type: 'local_bash', async kill(taskId, setAppState) { killTask(taskId, setAppState); } }; export async function spawnShellTask(input: LocalShellSpawnInput & { shellCommand: ShellCommand; }, context: TaskContext): Promise { const { command, description, shellCommand, toolUseId, agentId, kind } = input; const { setAppState } = context; // TaskOutput owns the data — use its taskId so disk writes are consistent const { taskOutput } = shellCommand; const taskId = taskOutput.taskId; const unregisterCleanup = registerCleanup(async () => { killTask(taskId, setAppState); }); const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), type: 'local_bash', status: 'running', command, completionStatusSentInAttachment: false, shellCommand, unregisterCleanup, lastReportedTotalLines: 0, isBackgrounded: true, agentId, kind }; registerTask(taskState, setAppState); // Data flows through TaskOutput automatically — no stream listeners needed. // Just transition to backgrounded state so the process keeps running. shellCommand.background(taskId); const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); void shellCommand.result.then(async result => { cancelStallWatchdog(); await flushAndCleanup(shellCommand); let wasKilled = false; updateTaskState(taskId, setAppState, task => { if (task.status === 'killed') { wasKilled = true; return task; } return { ...task, status: result.code === 0 ? 'completed' : 'failed', result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, endTime: Date.now() }; }); enqueueShellNotification(taskId, description, wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed', result.code, setAppState, toolUseId, kind, agentId); void evictTaskOutput(taskId); }); return { taskId, cleanup: () => { unregisterCleanup(); } }; } /** * Register a foreground task that could be backgrounded later. * Called when a bash command has been running long enough to show the BackgroundHint. * @returns taskId for the registered task */ export function registerForeground(input: LocalShellSpawnInput & { shellCommand: ShellCommand; }, setAppState: SetAppState, toolUseId?: string): string { const { command, description, shellCommand, agentId } = input; const taskId = shellCommand.taskOutput.taskId; const unregisterCleanup = registerCleanup(async () => { killTask(taskId, setAppState); }); const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), type: 'local_bash', status: 'running', command, completionStatusSentInAttachment: false, shellCommand, unregisterCleanup, lastReportedTotalLines: 0, isBackgrounded: false, // Not yet backgrounded - running in foreground agentId }; registerTask(taskState, setAppState); return taskId; } /** * Background a specific foreground task. * @returns true if backgrounded successfully, false otherwise */ function backgroundTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { // Step 1: Get the task and shell command from current state const state = getAppState(); const task = state.tasks[taskId]; if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) { return false; } const shellCommand = task.shellCommand; const description = task.description; const { toolUseId, kind, agentId } = task; // Transition to backgrounded — TaskOutput continues receiving data automatically if (!shellCommand.background(taskId)) { return false; } setAppState(prev => { const prevTask = prev.tasks[taskId]; if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { return prev; } return { ...prev, tasks: { ...prev.tasks, [taskId]: { ...prevTask, isBackgrounded: true } } }; }); const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); // Set up result handler void shellCommand.result.then(async result => { cancelStallWatchdog(); await flushAndCleanup(shellCommand); let wasKilled = false; let cleanupFn: (() => void) | undefined; updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { wasKilled = true; return t; } // Capture cleanup function to call outside of updater cleanupFn = t.unregisterCleanup; return { ...t, status: result.code === 0 ? 'completed' : 'failed', result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, endTime: Date.now() }; }); // Call cleanup outside of the state updater (avoid side effects in updater) cleanupFn?.(); if (wasKilled) { enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId); } else { const finalStatus = result.code === 0 ? 'completed' : 'failed'; enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId); } void evictTaskOutput(taskId); }); return true; } /** * Background ALL foreground tasks (bash commands and agents). * Called when user presses Ctrl+B to background all running tasks. */ /** * Check if there are any foreground tasks (bash or agent) that can be backgrounded. * Used to determine whether Ctrl+B should background existing tasks vs. background the session. */ export function hasForegroundTasks(state: AppState): boolean { return Object.values(state.tasks).some(task => { if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) { return true; } // Exclude main session tasks - they display in the main view, not as foreground tasks if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) { return true; } return false; }); } export function backgroundAll(getAppState: () => AppState, setAppState: SetAppState): void { const state = getAppState(); // Background all foreground bash tasks const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => { const task = state.tasks[id]; return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand; }); for (const taskId of foregroundBashTaskIds) { backgroundTask(taskId, getAppState, setAppState); } // Background all foreground agent tasks const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => { const task = state.tasks[id]; return isLocalAgentTask(task) && !task.isBackgrounded; }); for (const taskId of foregroundAgentTaskIds) { backgroundAgentTask(taskId, getAppState, setAppState); } } /** * Background an already-registered foreground task in-place. * Unlike spawn(), this does NOT re-register the task — it flips isBackgrounded * on the existing registration and sets up a completion handler. * Used when the auto-background timer fires after registerForeground() has * already registered the task (avoiding duplicate task_started SDK events * and leaked cleanup callbacks). */ export function backgroundExistingForegroundTask(taskId: string, shellCommand: ShellCommand, description: string, setAppState: SetAppState, toolUseId?: string): boolean { if (!shellCommand.background(taskId)) { return false; } let agentId: AgentId | undefined; setAppState(prev => { const prevTask = prev.tasks[taskId]; if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { return prev; } agentId = prevTask.agentId; return { ...prev, tasks: { ...prev.tasks, [taskId]: { ...prevTask, isBackgrounded: true } } }; }); const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId); // Set up result handler (mirrors backgroundTask's handler) void shellCommand.result.then(async result => { cancelStallWatchdog(); await flushAndCleanup(shellCommand); let wasKilled = false; let cleanupFn: (() => void) | undefined; updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { wasKilled = true; return t; } cleanupFn = t.unregisterCleanup; return { ...t, status: result.code === 0 ? 'completed' : 'failed', result: { code: result.code, interrupted: result.interrupted }, shellCommand: null, unregisterCleanup: undefined, endTime: Date.now() }; }); cleanupFn?.(); const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed'; enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId); void evictTaskOutput(taskId); }); return true; } /** * Mark a task as notified to suppress a pending enqueueShellNotification. * Used when backgrounding raced with completion — the tool result already * carries the full output, so the would be redundant. */ export function markTaskNotified(taskId: string, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, t => t.notified ? t : { ...t, notified: true }); } /** * Unregister a foreground task when the command completes without being backgrounded. */ export function unregisterForeground(taskId: string, setAppState: SetAppState): void { let cleanupFn: (() => void) | undefined; setAppState(prev => { const task = prev.tasks[taskId]; // Only remove if it's a foreground task (not backgrounded) if (!isLocalShellTask(task) || task.isBackgrounded) { return prev; } // Capture cleanup function to call outside of updater cleanupFn = task.unregisterCleanup; const { [taskId]: removed, ...rest } = prev.tasks; return { ...prev, tasks: rest }; }); // Call cleanup outside of the state updater (avoid side effects in updater) cleanupFn?.(); } async function flushAndCleanup(shellCommand: ShellCommand): Promise { try { await shellCommand.taskOutput.flush(); shellCommand.cleanup(); } catch (error) { logError(error); } }