mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
158
src/tasks/DreamTask/DreamTask.ts
Normal file
158
src/tasks/DreamTask/DreamTask.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
126
src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx
Normal file
126
src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx
Normal file
File diff suppressed because one or more lines are too long
122
src/tasks/InProcessTeammateTask/types.ts
Normal file
122
src/tasks/InProcessTeammateTask/types.ts
Normal 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]
|
||||
}
|
||||
|
||||
683
src/tasks/LocalAgentTask/LocalAgentTask.tsx
Normal file
683
src/tasks/LocalAgentTask/LocalAgentTask.tsx
Normal file
File diff suppressed because one or more lines are too long
480
src/tasks/LocalMainSessionTask.ts
Normal file
480
src/tasks/LocalMainSessionTask.ts
Normal 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
|
||||
}
|
||||
|
||||
523
src/tasks/LocalShellTask/LocalShellTask.tsx
Normal file
523
src/tasks/LocalShellTask/LocalShellTask.tsx
Normal file
File diff suppressed because one or more lines are too long
42
src/tasks/LocalShellTask/guards.ts
Normal file
42
src/tasks/LocalShellTask/guards.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
|
||||
77
src/tasks/LocalShellTask/killShellTasks.ts
Normal file
77
src/tasks/LocalShellTask/killShellTasks.ts
Normal 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)
|
||||
}
|
||||
|
||||
856
src/tasks/RemoteAgentTask/RemoteAgentTask.tsx
Normal file
856
src/tasks/RemoteAgentTask/RemoteAgentTask.tsx
Normal file
File diff suppressed because one or more lines are too long
83
src/tasks/pillLabel.ts
Normal file
83
src/tasks/pillLabel.ts
Normal 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
101
src/tasks/stopTask.ts
Normal 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
47
src/tasks/types.ts
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user