Why this matters
A local agent task is the long-running Claude Code worker that can keep thinking, and a shell task is the long-running command process that keeps a terminal open. They both live in the same task system, but they protect different things: shell tasks protect command output, while local agent tasks protect a conversation state that can pause, background, and accept more messages later.
Start with the state
Before the longer lifecycle code, it helps to look at the data shape the UI is trying to keep safe.
export type LocalAgentTaskState = TaskStateBase & {
type: 'local_agent';
agentId: string;
prompt: string;
selectedAgent?: AgentDefinition;
agentType: string;
model?: string;
abortController?: AbortController;
unregisterCleanup?: () => void;
error?: string;
result?: AgentToolResult;
progress?: AgentProgress;
retrieved: boolean;
messages?: Message[];
// Track what we last reported for computing deltas
lastReportedToolCount: number;
lastReportedTokenCount: number;
// Whether the task has been backgrounded (false = foreground running, true = backgrounded)
isBackgrounded: boolean;
// Messages queued mid-turn via SendMessage, drained at tool-round boundaries
pendingMessages: string[];
// UI is holding this task: blocks eviction, enables stream-append, triggers
// disk bootstrap. Set by enterTeammateView. Separate from viewingAgentTaskId
// (which is "what am I LOOKING at") — retain is "what am I HOLDING."
retain: boolean;
// Bootstrap has read the sidechain JSONL and UUID-merged into messages.
// One-shot per retain cycle; stream appends from there.
diskLoaded: boolean;
// Panel visibility deadline. undefined = no deadline (running or retained);
// timestamp = hide + GC-eligible after this time. Set at terminal transition
// and on unselect; cleared on retain.
evictAfter?: number;
};
retain keeps the UI from evicting a task, diskLoaded keeps the UI from double-loading sidechain history, and pendingMessages keeps the UI from dropping prompts that arrive between tool rounds.
Queueing messages
The queue helpers are small on purpose. They separate what the agent should say next from what the transcript should display right now.
export function queuePendingMessage(taskId: string, msg: string, setAppState: (f: (prev: AppState) => AppState) => void): void {
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => ({
...task,
pendingMessages: [...task.pendingMessages, msg]
}));
}
/**
* Append a message to task.messages so it appears in the viewed transcript
* immediately. Caller constructs the Message (breaks the messages.ts cycle).
* queuePendingMessage and resumeAgentBackground route the prompt to the
* agent's API input but don't touch the display.
*/
export function appendMessageToLocalAgent(taskId: string, message: Message, setAppState: (f: (prev: AppState) => AppState) => void): void {
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => ({
...task,
messages: [...(task.messages ?? []), message]
}));
}
export function drainPendingMessages(taskId: string, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): string[] {
const task = getAppState().tasks[taskId];
if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) {
return [];
}
const drained = task.pendingMessages;
updateTaskState<LocalAgentTaskState>(taskId, setAppState, t => ({
...t,
pendingMessages: []
}));
return drained;
}
In plain English: the queue keeps mid-turn user input from vanishing, the message append keeps the transcript responsive, and draining the queue gives the runtime one clean batch to send into the next agent turn.
Registration and backgrounding
This is the lifecycle code that turns a fresh agent request into a task, lets it move between foreground and background, and tears it down safely when the session ends.
export const LocalAgentTask: Task = {
name: 'LocalAgentTask',
type: 'local_agent',
async kill(taskId, setAppState) {
killAsyncAgent(taskId, setAppState);
}
};
export function registerAsyncAgent({
agentId,
description,
prompt,
selectedAgent,
setAppState,
parentAbortController,
toolUseId
}: {
agentId: string;
description: string;
prompt: string;
selectedAgent: AgentDefinition;
setAppState: SetAppState;
parentAbortController?: AbortController;
toolUseId?: string;
}): LocalAgentTaskState {
void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId)));
// Create abort controller - if parent provided, create child that auto-aborts with parent
const abortController = parentAbortController ? createChildAbortController(parentAbortController) : createAbortController();
const taskState: LocalAgentTaskState = {
...createTaskStateBase(agentId, 'local_agent', description, toolUseId),
type: 'local_agent',
status: 'running',
agentId,
prompt,
selectedAgent,
agentType: selectedAgent.agentType ?? 'general-purpose',
abortController,
retrieved: false,
lastReportedToolCount: 0,
lastReportedTokenCount: 0,
isBackgrounded: true,
// registerAsyncAgent immediately backgrounds
pendingMessages: [],
retain: false,
diskLoaded: false
};
// Register cleanup handler
const unregisterCleanup = registerCleanup(async () => {
killAsyncAgent(agentId, setAppState);
});
taskState.unregisterCleanup = unregisterCleanup;
// Register task in AppState
registerTask(taskState, setAppState);
return taskState;
}
// Map of taskId -> resolve function for background signals
// When backgroundAgentTask is called, it resolves the corresponding promise
const backgroundSignalResolvers = new Map<string, () => void>();
export function registerAgentForeground({
agentId,
description,
prompt,
selectedAgent,
setAppState,
autoBackgroundMs,
toolUseId
}: {
agentId: string;
description: string;
prompt: string;
selectedAgent: AgentDefinition;
setAppState: SetAppState;
autoBackgroundMs?: number;
toolUseId?: string;
}): {
taskId: string;
backgroundSignal: Promise<void>;
cancelAutoBackground?: () => void;
} {
void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId)));
const abortController = createAbortController();
const unregisterCleanup = registerCleanup(async () => {
killAsyncAgent(agentId, setAppState);
});
const taskState: LocalAgentTaskState = {
...createTaskStateBase(agentId, 'local_agent', description, toolUseId),
type: 'local_agent',
status: 'running',
agentId,
prompt,
selectedAgent,
agentType: selectedAgent.agentType ?? 'general-purpose',
abortController,
unregisterCleanup,
retrieved: false,
lastReportedToolCount: 0,
lastReportedTokenCount: 0,
isBackgrounded: false,
// Not yet backgrounded - running in foreground
pendingMessages: [],
retain: false,
diskLoaded: false
};
// Create background signal promise
let resolveBackgroundSignal: () => void;
const backgroundSignal = new Promise<void>(resolve => {
resolveBackgroundSignal = resolve;
});
backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!);
registerTask(taskState, setAppState);
// Auto-background after timeout if configured
let cancelAutoBackground: (() => void) | undefined;
if (autoBackgroundMs !== undefined && autoBackgroundMs > 0) {
const timer = setTimeout((setAppState, agentId) => {
// Mark task as backgrounded and resolve the signal
setAppState(prev => {
const prevTask = prev.tasks[agentId];
if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) {
return prev;
}
return {
...prev,
tasks: {
...prev.tasks,
[agentId]: {
...prevTask,
isBackgrounded: true
}
}
};
});
const resolver = backgroundSignalResolvers.get(agentId);
if (resolver) {
resolver();
backgroundSignalResolvers.delete(agentId);
}
}, autoBackgroundMs, setAppState, agentId);
cancelAutoBackground = () => clearTimeout(timer);
}
return {
taskId: agentId,
backgroundSignal,
cancelAutoBackground
};
}
export function backgroundAgentTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean {
const state = getAppState();
const task = state.tasks[taskId];
if (!isLocalAgentTask(task) || task.isBackgrounded) {
return false;
}
// Update state to mark as backgrounded
setAppState(prev => {
const prevTask = prev.tasks[taskId];
if (!isLocalAgentTask(prevTask)) {
return prev;
}
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: {
...prevTask,
isBackgrounded: true
}
}
};
});
// Resolve the background signal to interrupt the agent loop
const resolver = backgroundSignalResolvers.get(taskId);
if (resolver) {
resolver();
backgroundSignalResolvers.delete(taskId);
}
return true;
}
export function unregisterAgentForeground(taskId: string, setAppState: SetAppState): void {
// Clean up the background signal resolver
backgroundSignalResolvers.delete(taskId);
let cleanupFn: (() => void) | undefined;
setAppState(prev => {
const task = prev.tasks[taskId];
// Only remove if it's a foreground task (not backgrounded)
if (!isLocalAgentTask(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?.();
}
The main idea is that registration always creates a durable task record first, backgrounding flips the runtime state without losing the task, and unregistering only removes the foreground-only record once the task is done.