Why this matters
The plain-English job of a shell task is simple: run a command, keep its output, and tell the rest of the app whether it finished, failed, or got stopped.
The lifecycle is easier to remember as:
register state -> background shell -> watch for stalls -> report completion
That sequence is the whole chapter in miniature. First the task is registered, then the shell is moved into the background, then a watchdog looks for a stuck interactive prompt, and finally the task reports completion or failure through a notification.
Prompt detection
When a command is no longer making progress, Claude Code does not immediately assume it is broken. It first reads the tail of the output file and checks whether the last line looks like a prompt the model can answer.
// 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));
}
Completion summaries
The notification text starts from a shared prefix so the UI can collapse many background shell completions into one readable summary.
export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command ';
Registering the task
This is the background path. The command already exists, the task state is created, and the shell is then backgrounded so the work keeps running after the original invocation has returned.
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();
}
};
}
Foreground registration
Foreground registration is the same state shape, but the shell has not been backgrounded yet. The task is still tracked durably, which makes it easy to promote later without inventing a second model.
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;
}
Why output stays on disk
The shell task does not try to cram all output into a tiny in-memory field. That would be fragile for long commands, easy to lose on restart, and awkward for stall detection. Instead, output lives on disk instead of inside a tiny in-memory field, and the task model points to that file.
That choice is what makes the lifecycle durable: the state record can stay small, the output can grow as long as needed, and the watchdog can inspect the tail without needing the whole stream in memory.