claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

View File

@@ -0,0 +1,158 @@
// Background task entry for auto-dream (memory consolidation subagent).
// Makes the otherwise-invisible forked agent visible in the footer pill and
// Shift+Down dialog. The dream agent itself is unchanged — this is pure UI
// surfacing via the existing task registry.
import { rollbackConsolidationLock } from '../../services/autoDream/consolidationLock.js'
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
import { createTaskStateBase, generateTaskId } from '../../Task.js'
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
// Keep only the N most recent turns for live display.
const MAX_TURNS = 30
// A single assistant turn from the dream agent, tool uses collapsed to a count.
export type DreamTurn = {
text: string
toolUseCount: number
}
// No phase detection — the dream prompt has a 4-stage structure
// (orient/gather/consolidate/prune) but we don't parse it. Just flip from
// 'starting' to 'updating' when the first Edit/Write tool_use lands.
export type DreamPhase = 'starting' | 'updating'
export type DreamTaskState = TaskStateBase & {
type: 'dream'
phase: DreamPhase
sessionsReviewing: number
/**
* Paths observed in Edit/Write tool_use blocks via onMessage. This is an
* INCOMPLETE reflection of what the dream agent actually changed — it misses
* any bash-mediated writes and only captures the tool calls we pattern-match.
* Treat as "at least these were touched", not "only these were touched".
*/
filesTouched: string[]
/** Assistant text responses, tool uses collapsed. Prompt is NOT included. */
turns: DreamTurn[]
abortController?: AbortController
/** Stashed so kill can rewind the lock mtime (same path as fork-failure). */
priorMtime: number
}
export function isDreamTask(task: unknown): task is DreamTaskState {
return (
typeof task === 'object' &&
task !== null &&
'type' in task &&
task.type === 'dream'
)
}
export function registerDreamTask(
setAppState: SetAppState,
opts: {
sessionsReviewing: number
priorMtime: number
abortController: AbortController
},
): string {
const id = generateTaskId('dream')
const task: DreamTaskState = {
...createTaskStateBase(id, 'dream', 'dreaming'),
type: 'dream',
status: 'running',
phase: 'starting',
sessionsReviewing: opts.sessionsReviewing,
filesTouched: [],
turns: [],
abortController: opts.abortController,
priorMtime: opts.priorMtime,
}
registerTask(task, setAppState)
return id
}
export function addDreamTurn(
taskId: string,
turn: DreamTurn,
touchedPaths: string[],
setAppState: SetAppState,
): void {
updateTaskState<DreamTaskState>(taskId, setAppState, task => {
const seen = new Set(task.filesTouched)
const newTouched = touchedPaths.filter(p => !seen.has(p) && seen.add(p))
// Skip the update entirely if the turn is empty AND nothing new was
// touched. Avoids re-rendering on pure no-ops.
if (
turn.text === '' &&
turn.toolUseCount === 0 &&
newTouched.length === 0
) {
return task
}
return {
...task,
phase: newTouched.length > 0 ? 'updating' : task.phase,
filesTouched:
newTouched.length > 0
? [...task.filesTouched, ...newTouched]
: task.filesTouched,
turns: task.turns.slice(-(MAX_TURNS - 1)).concat(turn),
}
})
}
export function completeDreamTask(
taskId: string,
setAppState: SetAppState,
): void {
// notified: true immediately — dream has no model-facing notification path
// (it's UI-only), and eviction requires terminal + notified. The inline
// appendSystemMessage completion note IS the user surface.
updateTaskState<DreamTaskState>(taskId, setAppState, task => ({
...task,
status: 'completed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
}
export function failDreamTask(taskId: string, setAppState: SetAppState): void {
updateTaskState<DreamTaskState>(taskId, setAppState, task => ({
...task,
status: 'failed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
}
export const DreamTask: Task = {
name: 'DreamTask',
type: 'dream',
async kill(taskId, setAppState) {
let priorMtime: number | undefined
updateTaskState<DreamTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') return task
task.abortController?.abort()
priorMtime = task.priorMtime
return {
...task,
status: 'killed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}
})
// Rewind the lock mtime so the next session can retry. Same path as the
// fork-failure catch in autoDream.ts. If updateTaskState was a no-op
// (already terminal), priorMtime stays undefined and we skip.
if (priorMtime !== undefined) {
await rollbackConsolidationLock(priorMtime)
}
},
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,122 @@
import type { TaskStateBase } from '../../Task.js'
import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js'
import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
import type { Message } from '../../types/message.js'
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
import type { AgentProgress } from '../LocalAgentTask/LocalAgentTask.js'
/**
* Teammate identity stored in task state.
* Same shape as TeammateContext (runtime) but stored as plain data.
* TeammateContext is for AsyncLocalStorage; this is for AppState persistence.
*/
export type TeammateIdentity = {
agentId: string // e.g., "researcher@my-team"
agentName: string // e.g., "researcher"
teamName: string
color?: string
planModeRequired: boolean
parentSessionId: string // Leader's session ID
}
export type InProcessTeammateTaskState = TaskStateBase & {
type: 'in_process_teammate'
// Identity as sub-object (matches TeammateContext shape for consistency)
// Stored as plain data in AppState, NOT a reference to AsyncLocalStorage
identity: TeammateIdentity
// Execution
prompt: string
// Optional model override for this teammate
model?: string
// Optional: Only set if teammate uses a specific agent definition
// Many teammates run as general-purpose agents without a predefined definition
selectedAgent?: AgentDefinition
abortController?: AbortController // Runtime only, not serialized to disk - kills WHOLE teammate
currentWorkAbortController?: AbortController // Runtime only - aborts current turn without killing teammate
unregisterCleanup?: () => void // Runtime only
// Plan mode approval tracking (planModeRequired is in identity)
awaitingPlanApproval: boolean
// Permission mode for this teammate (cycled independently via Shift+Tab when viewing)
permissionMode: PermissionMode
// State
error?: string
result?: AgentToolResult // Reuse existing type since teammates run via runAgent()
progress?: AgentProgress
// Conversation history for zoomed view (NOT mailbox messages)
// Mailbox messages are stored separately in teamContext.inProcessMailboxes
messages?: Message[]
// Tool use IDs currently being executed (for animation in transcript view)
inProgressToolUseIDs?: Set<string>
// Queue of user messages to deliver when viewing teammate transcript
pendingUserMessages: string[]
// UI: random spinner verbs (stable across re-renders, shared between components)
spinnerVerb?: string
pastTenseVerb?: string
// Lifecycle
isIdle: boolean
shutdownRequested: boolean
// Callbacks to notify when teammate becomes idle (runtime only)
// Used by leader to efficiently wait without polling
onIdleCallbacks?: Array<() => void>
// Progress tracking (for computing deltas in notifications)
lastReportedToolCount: number
lastReportedTokenCount: number
}
export function isInProcessTeammateTask(
task: unknown,
): task is InProcessTeammateTaskState {
return (
typeof task === 'object' &&
task !== null &&
'type' in task &&
task.type === 'in_process_teammate'
)
}
/**
* Cap on the number of messages kept in task.messages (the AppState UI mirror).
*
* task.messages exists purely for the zoomed transcript dialog, which only
* needs recent context. The full conversation lives in the local allMessages
* array (inProcessRunner) and on disk at the agent transcript path.
*
* BQ analysis (round 9, 2026-03-20) showed ~20MB RSS per agent at 500+ turn
* sessions and ~125MB per concurrent agent in swarm bursts. Whale session
* 9a990de8 launched 292 agents in 2 minutes and reached 36.8GB. The dominant
* cost is this array holding a second full copy of every message.
*/
export const TEAMMATE_MESSAGES_UI_CAP = 50
/**
* Append an item to a message array, capping the result at
* TEAMMATE_MESSAGES_UI_CAP entries by dropping the oldest. Always returns
* a new array (AppState immutability).
*/
export function appendCappedMessage<T>(
prev: readonly T[] | undefined,
item: T,
): T[] {
if (prev === undefined || prev.length === 0) {
return [item]
}
if (prev.length >= TEAMMATE_MESSAGES_UI_CAP) {
const next = prev.slice(-(TEAMMATE_MESSAGES_UI_CAP - 1))
next.push(item)
return next
}
return [...prev, item]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,480 @@
/**
* LocalMainSessionTask - Handles backgrounding the main session query.
*
* When user presses Ctrl+B twice during a query, the session is "backgrounded":
* - The query continues running in the background
* - The UI clears to a fresh prompt
* - A notification is sent when the query completes
*
* This reuses the LocalAgentTask state structure since the behavior is similar.
*/
import type { UUID } from 'crypto'
import { randomBytes } from 'crypto'
import {
OUTPUT_FILE_TAG,
STATUS_TAG,
SUMMARY_TAG,
TASK_ID_TAG,
TASK_NOTIFICATION_TAG,
TOOL_USE_ID_TAG,
} from '../constants/xml.js'
import { type QueryParams, query } from '../query.js'
import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
import type { SetAppState } from '../Task.js'
import { createTaskStateBase } from '../Task.js'
import type {
AgentDefinition,
CustomAgentDefinition,
} from '../tools/AgentTool/loadAgentsDir.js'
import { asAgentId } from '../types/ids.js'
import type { Message } from '../types/message.js'
import { createAbortController } from '../utils/abortController.js'
import {
runWithAgentContext,
type SubagentContext,
} from '../utils/agentContext.js'
import { registerCleanup } from '../utils/cleanupRegistry.js'
import { logForDebugging } from '../utils/debug.js'
import { logError } from '../utils/log.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js'
import {
getAgentTranscriptPath,
recordSidechainTranscript,
} from '../utils/sessionStorage.js'
import {
evictTaskOutput,
getTaskOutputPath,
initTaskOutputAsSymlink,
} from '../utils/task/diskOutput.js'
import { registerTask, updateTaskState } from '../utils/task/framework.js'
import type { LocalAgentTaskState } from './LocalAgentTask/LocalAgentTask.js'
// Main session tasks use LocalAgentTaskState with agentType='main-session'
export type LocalMainSessionTaskState = LocalAgentTaskState & {
agentType: 'main-session'
}
/**
* Default agent definition for main session tasks when no agent is specified.
*/
const DEFAULT_MAIN_SESSION_AGENT: CustomAgentDefinition = {
agentType: 'main-session',
whenToUse: 'Main session query',
source: 'userSettings',
getSystemPrompt: () => '',
}
/**
* Generate a unique task ID for main session tasks.
* Uses 's' prefix to distinguish from agent tasks ('a' prefix).
*/
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
function generateMainSessionTaskId(): string {
const bytes = randomBytes(8)
let id = 's'
for (let i = 0; i < 8; i++) {
id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
}
return id
}
/**
* Register a backgrounded main session task.
* Called when the user backgrounds the current session query.
*
* @param description - Description of the task
* @param setAppState - State setter function
* @param mainThreadAgentDefinition - Optional agent definition if running with --agent
* @param existingAbortController - Optional abort controller to reuse (for backgrounding an active query)
* @returns Object with task ID and abort signal for stopping the background query
*/
export function registerMainSessionTask(
description: string,
setAppState: SetAppState,
mainThreadAgentDefinition?: AgentDefinition,
existingAbortController?: AbortController,
): { taskId: string; abortSignal: AbortSignal } {
const taskId = generateMainSessionTaskId()
// Link output to an isolated per-task transcript file (same layout as
// sub-agents). Do NOT use getTranscriptPath() — that's the main session's
// file, and writing there from a background query after /clear would corrupt
// the post-clear conversation. The isolated path lets this task survive
// /clear: the symlink re-link in clearConversation handles session ID changes.
void initTaskOutputAsSymlink(
taskId,
getAgentTranscriptPath(asAgentId(taskId)),
)
// Use the existing abort controller if provided (important for backgrounding an active query)
// This ensures that aborting the task will abort the actual query
const abortController = existingAbortController ?? createAbortController()
const unregisterCleanup = registerCleanup(async () => {
// Clean up on process exit
setAppState(prev => {
const { [taskId]: removed, ...rest } = prev.tasks
return { ...prev, tasks: rest }
})
})
// Use provided agent definition or default
const selectedAgent = mainThreadAgentDefinition ?? DEFAULT_MAIN_SESSION_AGENT
// Create task state - already backgrounded since this is called when user backgrounds
const taskState: LocalMainSessionTaskState = {
...createTaskStateBase(taskId, 'local_agent', description),
type: 'local_agent',
status: 'running',
agentId: taskId,
prompt: description,
selectedAgent,
agentType: 'main-session',
abortController,
unregisterCleanup,
retrieved: false,
lastReportedToolCount: 0,
lastReportedTokenCount: 0,
isBackgrounded: true, // Already backgrounded
pendingMessages: [],
retain: false,
diskLoaded: false,
}
logForDebugging(
`[LocalMainSessionTask] Registering task ${taskId} with description: ${description}`,
)
registerTask(taskState, setAppState)
// Verify task was registered by checking state
setAppState(prev => {
const hasTask = taskId in prev.tasks
logForDebugging(
`[LocalMainSessionTask] After registration, task ${taskId} exists in state: ${hasTask}`,
)
return prev
})
return { taskId, abortSignal: abortController.signal }
}
/**
* Complete the main session task and send notification.
* Called when the backgrounded query finishes.
*/
export function completeMainSessionTask(
taskId: string,
success: boolean,
setAppState: SetAppState,
): void {
let wasBackgrounded = true
let toolUseId: string | undefined
updateTaskState<LocalMainSessionTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') {
return task
}
// Track if task was backgrounded (for notification decision)
wasBackgrounded = task.isBackgrounded ?? true
toolUseId = task.toolUseId
task.unregisterCleanup?.()
return {
...task,
status: success ? 'completed' : 'failed',
endTime: Date.now(),
messages: task.messages?.length ? [task.messages.at(-1)!] : undefined,
}
})
void evictTaskOutput(taskId)
// Only send notification if task is still backgrounded (not foregrounded)
// If foregrounded, user is watching it directly - no notification needed
if (wasBackgrounded) {
enqueueMainSessionNotification(
taskId,
'Background session',
success ? 'completed' : 'failed',
setAppState,
toolUseId,
)
} else {
// Foregrounded: no XML notification (TUI user is watching), but SDK
// consumers still need to see the task_started bookend close.
// Set notified so evictTerminalTask/generateTaskAttachments eviction
// guards pass; the backgrounded path sets this inside
// enqueueMainSessionNotification's check-and-set.
updateTaskState(taskId, setAppState, task => ({ ...task, notified: true }))
emitTaskTerminatedSdk(taskId, success ? 'completed' : 'failed', {
toolUseId,
summary: 'Background session',
})
}
}
/**
* Enqueue a notification about the backgrounded session completing.
*/
function enqueueMainSessionNotification(
taskId: string,
description: string,
status: 'completed' | 'failed',
setAppState: SetAppState,
toolUseId?: string,
): void {
// Atomically check and set notified flag to prevent duplicate notifications.
let shouldEnqueue = false
updateTaskState(taskId, setAppState, task => {
if (task.notified) {
return task
}
shouldEnqueue = true
return { ...task, notified: true }
})
if (!shouldEnqueue) {
return
}
const summary =
status === 'completed'
? `Background session "${description}" completed`
: `Background session "${description}" failed`
const toolUseIdLine = toolUseId
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
: ''
const outputPath = getTaskOutputPath(taskId)
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}>${summary}</${SUMMARY_TAG}>
</${TASK_NOTIFICATION_TAG}>`
enqueuePendingNotification({ value: message, mode: 'task-notification' })
}
/**
* Foreground a main session task - mark it as foregrounded so its output
* appears in the main view. The background query keeps running.
* Returns the task's accumulated messages, or undefined if task not found.
*/
export function foregroundMainSessionTask(
taskId: string,
setAppState: SetAppState,
): Message[] | undefined {
let taskMessages: Message[] | undefined
setAppState(prev => {
const task = prev.tasks[taskId]
if (!task || task.type !== 'local_agent') {
return prev
}
taskMessages = (task as LocalMainSessionTaskState).messages
// Restore previous foregrounded task to background if it exists
const prevId = prev.foregroundedTaskId
const prevTask = prevId ? prev.tasks[prevId] : undefined
const restorePrev =
prevId && prevId !== taskId && prevTask?.type === 'local_agent'
return {
...prev,
foregroundedTaskId: taskId,
tasks: {
...prev.tasks,
...(restorePrev && { [prevId]: { ...prevTask, isBackgrounded: true } }),
[taskId]: { ...task, isBackgrounded: false },
},
}
})
return taskMessages
}
/**
* Check if a task is a main session task (vs a regular agent task).
*/
export function isMainSessionTask(
task: unknown,
): task is LocalMainSessionTaskState {
if (
typeof task !== 'object' ||
task === null ||
!('type' in task) ||
!('agentType' in task)
) {
return false
}
return (
task.type === 'local_agent' &&
(task as LocalMainSessionTaskState).agentType === 'main-session'
)
}
// Max recent activities to keep for display
const MAX_RECENT_ACTIVITIES = 5
type ToolActivity = {
toolName: string
input: Record<string, unknown>
}
/**
* Start a fresh background session with the given messages.
*
* Spawns an independent query() call with the current messages and registers it
* as a background task. The caller's foreground query continues running normally.
*/
export function startBackgroundSession({
messages,
queryParams,
description,
setAppState,
agentDefinition,
}: {
messages: Message[]
queryParams: Omit<QueryParams, 'messages'>
description: string
setAppState: SetAppState
agentDefinition?: AgentDefinition
}): string {
const { taskId, abortSignal } = registerMainSessionTask(
description,
setAppState,
agentDefinition,
)
// Persist the pre-backgrounding conversation to the task's isolated
// transcript so TaskOutput shows context immediately. Subsequent messages
// are written incrementally below.
void recordSidechainTranscript(messages, taskId).catch(err =>
logForDebugging(`bg-session initial transcript write failed: ${err}`),
)
// Wrap in agent context so skill invocations scope to this task's agentId
// (not null). This lets clearInvokedSkills(preservedAgentIds) selectively
// preserve this task's skills across /clear. AsyncLocalStorage isolates
// concurrent async chains — this wrapper doesn't affect the foreground.
const agentContext: SubagentContext = {
agentId: taskId,
agentType: 'subagent',
subagentName: 'main-session',
isBuiltIn: true,
}
void runWithAgentContext(agentContext, async () => {
try {
const bgMessages: Message[] = [...messages]
const recentActivities: ToolActivity[] = []
let toolCount = 0
let tokenCount = 0
let lastRecordedUuid: UUID | null = messages.at(-1)?.uuid ?? null
for await (const event of query({
messages: bgMessages,
...queryParams,
})) {
if (abortSignal.aborted) {
// Aborted mid-stream — completeMainSessionTask won't be reached.
// chat:killAgents path already marked notified + emitted; stopTask path did not.
let alreadyNotified = false
updateTaskState(taskId, setAppState, task => {
alreadyNotified = task.notified === true
return alreadyNotified ? task : { ...task, notified: true }
})
if (!alreadyNotified) {
emitTaskTerminatedSdk(taskId, 'stopped', {
summary: description,
})
}
return
}
if (
event.type !== 'user' &&
event.type !== 'assistant' &&
event.type !== 'system'
) {
continue
}
bgMessages.push(event)
// Per-message write (matches runAgent.ts pattern) — gives live
// TaskOutput progress and keeps the transcript file current even if
// /clear re-links the symlink mid-run.
void recordSidechainTranscript([event], taskId, lastRecordedUuid).catch(
err => logForDebugging(`bg-session transcript write failed: ${err}`),
)
lastRecordedUuid = event.uuid
if (event.type === 'assistant') {
for (const block of event.message.content) {
if (block.type === 'text') {
tokenCount += roughTokenCountEstimation(block.text)
} else if (block.type === 'tool_use') {
toolCount++
const activity: ToolActivity = {
toolName: block.name,
input: block.input as Record<string, unknown>,
}
recentActivities.push(activity)
if (recentActivities.length > MAX_RECENT_ACTIVITIES) {
recentActivities.shift()
}
}
}
}
setAppState(prev => {
const task = prev.tasks[taskId]
if (!task || task.type !== 'local_agent') return prev
const prevProgress = task.progress
if (
prevProgress?.tokenCount === tokenCount &&
prevProgress.toolUseCount === toolCount &&
task.messages === bgMessages
) {
return prev
}
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: {
...task,
progress: {
tokenCount,
toolUseCount: toolCount,
recentActivities:
prevProgress?.toolUseCount === toolCount
? prevProgress.recentActivities
: [...recentActivities],
},
messages: bgMessages,
},
},
}
})
}
completeMainSessionTask(taskId, true, setAppState)
} catch (error) {
logError(error)
completeMainSessionTask(taskId, false, setAppState)
}
})
return taskId
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,42 @@
// Pure type + type guard for LocalShellTask state.
// Extracted from LocalShellTask.tsx so non-React consumers (stopTask.ts via
// print.ts) don't pull React/ink into the module graph.
import type { TaskStateBase } from '../../Task.js'
import type { AgentId } from '../../types/ids.js'
import type { ShellCommand } from '../../utils/ShellCommand.js'
export type BashTaskKind = 'bash' | 'monitor'
export type LocalShellTaskState = TaskStateBase & {
type: 'local_bash' // Keep as 'local_bash' for backward compatibility with persisted session state
command: string
result?: {
code: number
interrupted: boolean
}
completionStatusSentInAttachment: boolean
shellCommand: ShellCommand | null
unregisterCleanup?: () => void
cleanupTimeoutId?: NodeJS.Timeout
// Track what we last reported for computing deltas (total lines from TaskOutput)
lastReportedTotalLines: number
// Whether the task has been backgrounded (false = foreground running, true = backgrounded)
isBackgrounded: boolean
// Agent that spawned this task. Used to kill orphaned bash tasks when the
// agent exits (see killShellTasksForAgent). Undefined = main thread.
agentId?: AgentId
// UI display variant. 'monitor' → shows description instead of command,
// 'Monitor details' dialog title, distinct status bar pill.
kind?: BashTaskKind
}
export function isLocalShellTask(task: unknown): task is LocalShellTaskState {
return (
typeof task === 'object' &&
task !== null &&
'type' in task &&
task.type === 'local_bash'
)
}

View File

@@ -0,0 +1,77 @@
// Pure (non-React) kill helpers for LocalShellTask.
// Extracted so runAgent.ts can kill agent-scoped bash tasks without pulling
// React/Ink into its module graph (same rationale as guards.ts).
import type { AppState } from '../../state/AppState.js'
import type { AgentId } from '../../types/ids.js'
import { logForDebugging } from '../../utils/debug.js'
import { logError } from '../../utils/log.js'
import { dequeueAllMatching } from '../../utils/messageQueueManager.js'
import { evictTaskOutput } from '../../utils/task/diskOutput.js'
import { updateTaskState } from '../../utils/task/framework.js'
import { isLocalShellTask } from './guards.js'
type SetAppStateFn = (updater: (prev: AppState) => AppState) => void
export function killTask(taskId: string, setAppState: SetAppStateFn): void {
updateTaskState(taskId, setAppState, task => {
if (task.status !== 'running' || !isLocalShellTask(task)) {
return task
}
try {
logForDebugging(`LocalShellTask ${taskId} kill requested`)
task.shellCommand?.kill()
task.shellCommand?.cleanup()
} catch (error) {
logError(error)
}
task.unregisterCleanup?.()
if (task.cleanupTimeoutId) {
clearTimeout(task.cleanupTimeoutId)
}
return {
...task,
status: 'killed',
notified: true,
shellCommand: null,
unregisterCleanup: undefined,
cleanupTimeoutId: undefined,
endTime: Date.now(),
}
})
void evictTaskOutput(taskId)
}
/**
* Kill all running bash tasks spawned by a given agent.
* Called from runAgent.ts finally block so background processes don't outlive
* the agent that started them (prevents 10-day fake-logs.sh zombies).
*/
export function killShellTasksForAgent(
agentId: AgentId,
getAppState: () => AppState,
setAppState: SetAppStateFn,
): void {
const tasks = getAppState().tasks ?? {}
for (const [taskId, task] of Object.entries(tasks)) {
if (
isLocalShellTask(task) &&
task.agentId === agentId &&
task.status === 'running'
) {
logForDebugging(
`killShellTasksForAgent: killing orphaned shell task ${taskId} (agent ${agentId} exiting)`,
)
killTask(taskId, setAppState)
}
}
// Purge any queued notifications addressed to this agent — its query loop
// has exited and won't drain them. killTask fires 'killed' notifications
// asynchronously; drop the ones already queued and any that land later sit
// harmlessly (no consumer matches a dead agentId).
dequeueAllMatching(cmd => cmd.agentId === agentId)
}

File diff suppressed because one or more lines are too long

83
src/tasks/pillLabel.ts Normal file
View File

@@ -0,0 +1,83 @@
import { DIAMOND_FILLED, DIAMOND_OPEN } from '../constants/figures.js'
import { count } from '../utils/array.js'
import type { BackgroundTaskState } from './types.js'
/**
* Produces the compact footer-pill label for a set of background tasks.
* Used by both the footer pill and the turn-duration transcript line so the
* two surfaces agree on terminology.
*/
export function getPillLabel(tasks: BackgroundTaskState[]): string {
const n = tasks.length
const allSameType = tasks.every(t => t.type === tasks[0]!.type)
if (allSameType) {
switch (tasks[0]!.type) {
case 'local_bash': {
const monitors = count(
tasks,
t => t.type === 'local_bash' && t.kind === 'monitor',
)
const shells = n - monitors
const parts: string[] = []
if (shells > 0)
parts.push(shells === 1 ? '1 shell' : `${shells} shells`)
if (monitors > 0)
parts.push(monitors === 1 ? '1 monitor' : `${monitors} monitors`)
return parts.join(', ')
}
case 'in_process_teammate': {
const teamCount = new Set(
tasks.map(t =>
t.type === 'in_process_teammate' ? t.identity.teamName : '',
),
).size
return teamCount === 1 ? '1 team' : `${teamCount} teams`
}
case 'local_agent':
return n === 1 ? '1 local agent' : `${n} local agents`
case 'remote_agent': {
const first = tasks[0]!
// Per design mockup: ◇ open diamond while running/needs-input,
// ◆ filled once ExitPlanMode is awaiting approval.
if (n === 1 && first.type === 'remote_agent' && first.isUltraplan) {
switch (first.ultraplanPhase) {
case 'plan_ready':
return `${DIAMOND_FILLED} ultraplan ready`
case 'needs_input':
return `${DIAMOND_OPEN} ultraplan needs your input`
default:
return `${DIAMOND_OPEN} ultraplan`
}
}
return n === 1
? `${DIAMOND_OPEN} 1 cloud session`
: `${DIAMOND_OPEN} ${n} cloud sessions`
}
case 'local_workflow':
return n === 1 ? '1 background workflow' : `${n} background workflows`
case 'monitor_mcp':
return n === 1 ? '1 monitor' : `${n} monitors`
case 'dream':
return 'dreaming'
}
}
return `${n} background ${n === 1 ? 'task' : 'tasks'}`
}
/**
* True when the pill should show the dimmed " · ↓ to view" call-to-action.
* Per the state diagram: only the two attention states (needs_input,
* plan_ready) surface the CTA; plain running shows just the diamond + label.
*/
export function pillNeedsCta(tasks: BackgroundTaskState[]): boolean {
if (tasks.length !== 1) return false
const t = tasks[0]!
return (
t.type === 'remote_agent' &&
t.isUltraplan === true &&
t.ultraplanPhase !== undefined
)
}

101
src/tasks/stopTask.ts Normal file
View File

@@ -0,0 +1,101 @@
// Shared logic for stopping a running task.
// Used by TaskStopTool (LLM-invoked) and SDK stop_task control request.
import type { AppState } from '../state/AppState.js'
import type { TaskStateBase } from '../Task.js'
import { getTaskByType } from '../tasks.js'
import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js'
import { isLocalShellTask } from './LocalShellTask/guards.js'
export class StopTaskError extends Error {
constructor(
message: string,
public readonly code: 'not_found' | 'not_running' | 'unsupported_type',
) {
super(message)
this.name = 'StopTaskError'
}
}
type StopTaskContext = {
getAppState: () => AppState
setAppState: (f: (prev: AppState) => AppState) => void
}
type StopTaskResult = {
taskId: string
taskType: string
command: string | undefined
}
/**
* Look up a task by ID, validate it is running, kill it, and mark it as notified.
*
* Throws {@link StopTaskError} when the task cannot be stopped (not found,
* not running, or unsupported type). Callers can inspect `error.code` to
* distinguish the failure reason.
*/
export async function stopTask(
taskId: string,
context: StopTaskContext,
): Promise<StopTaskResult> {
const { getAppState, setAppState } = context
const appState = getAppState()
const task = appState.tasks?.[taskId] as TaskStateBase | undefined
if (!task) {
throw new StopTaskError(`No task found with ID: ${taskId}`, 'not_found')
}
if (task.status !== 'running') {
throw new StopTaskError(
`Task ${taskId} is not running (status: ${task.status})`,
'not_running',
)
}
const taskImpl = getTaskByType(task.type)
if (!taskImpl) {
throw new StopTaskError(
`Unsupported task type: ${task.type}`,
'unsupported_type',
)
}
await taskImpl.kill(taskId, setAppState)
// Bash: suppress the "exit code 137" notification (noise). Agent tasks: don't
// suppress — the AbortError catch sends a notification carrying
// extractPartialResult(agentMessages), which is the payload not noise.
if (isLocalShellTask(task)) {
let suppressed = false
setAppState(prev => {
const prevTask = prev.tasks[taskId]
if (!prevTask || prevTask.notified) {
return prev
}
suppressed = true
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: { ...prevTask, notified: true },
},
}
})
// Suppressing the XML notification also suppresses print.ts's parsed
// task_notification SDK event — emit it directly so SDK consumers see
// the task close.
if (suppressed) {
emitTaskTerminatedSdk(taskId, 'stopped', {
toolUseId: task.toolUseId,
summary: task.description,
})
}
}
const command = isLocalShellTask(task) ? task.command : task.description
return { taskId, taskType: task.type, command }
}

47
src/tasks/types.ts Normal file
View File

@@ -0,0 +1,47 @@
// Union of all concrete task state types
// Use this for components that need to work with any task type
import type { DreamTaskState } from './DreamTask/DreamTask.js'
import type { InProcessTeammateTaskState } from './InProcessTeammateTask/types.js'
import type { LocalAgentTaskState } from './LocalAgentTask/LocalAgentTask.js'
import type { LocalShellTaskState } from './LocalShellTask/guards.js'
import type { LocalWorkflowTaskState } from './LocalWorkflowTask/LocalWorkflowTask.js'
import type { MonitorMcpTaskState } from './MonitorMcpTask/MonitorMcpTask.js'
import type { RemoteAgentTaskState } from './RemoteAgentTask/RemoteAgentTask.js'
export type TaskState =
| LocalShellTaskState
| LocalAgentTaskState
| RemoteAgentTaskState
| InProcessTeammateTaskState
| LocalWorkflowTaskState
| MonitorMcpTaskState
| DreamTaskState
// Task types that can appear in the background tasks indicator
export type BackgroundTaskState =
| LocalShellTaskState
| LocalAgentTaskState
| RemoteAgentTaskState
| InProcessTeammateTaskState
| LocalWorkflowTaskState
| MonitorMcpTaskState
| DreamTaskState
/**
* Check if a task should be shown in the background tasks indicator.
* A task is considered a background task if:
* 1. It is running or pending
* 2. It has been explicitly backgrounded (not a foreground task)
*/
export function isBackgroundTask(task: TaskState): task is BackgroundTaskState {
if (task.status !== 'running' && task.status !== 'pending') {
return false
}
// Foreground tasks (isBackgrounded === false) are not yet "background tasks"
if ('isBackgrounded' in task && task.isBackgrounded === false) {
return false
}
return true
}