Inline base64 source maps had been checked into tracked src files. This strips those comments from the repository without changing runtime behavior or adding ongoing guardrails, per the requested one-time cleanup scope. Constraint: Keep this change limited to tracked source cleanup only Rejected: Add CI/source verification guard | user requested one-time cleanup only Confidence: high Scope-risk: narrow Reversibility: clean Directive: If these directives reappear, fix the producing transform instead of reintroducing repo-side cleanup code Tested: rg -n "sourceMappingURL" ., bun run smoke, bun run verify:privacy, bun run test:provider, npm run test:provider-recommendation Not-tested: bun run typecheck (repository has many pre-existing unrelated failures) Co-authored-by: anandh8x <test@example.com>
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
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}</${TOOL_USE_ID_TAG}>` : '';
|
|
const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`;
|
|
// No <status> tag — print.ts treats <status> 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}</${TASK_ID_TAG}>${toolUseIdLine}
|
|
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
|
|
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
|
|
</${TASK_NOTIFICATION_TAG}>
|
|
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}</${TOOL_USE_ID_TAG}>` : '';
|
|
const message = `<${TASK_NOTIFICATION_TAG}>
|
|
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
|
|
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
|
|
<${STATUS_TAG}>${status}</${STATUS_TAG}>
|
|
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
|
|
</${TASK_NOTIFICATION_TAG}>`;
|
|
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<TaskHandle> {
|
|
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<LocalShellTaskState>(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<LocalShellTaskState>(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<LocalShellTaskState>(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 <task_notification> 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<void> {
|
|
try {
|
|
await shellCommand.taskOutput.flush();
|
|
shellCommand.cleanup();
|
|
} catch (error) {
|
|
logError(error);
|
|
}
|
|
}
|