source dump of claude code
at main 479 lines 15 kB view raw
1/** 2 * LocalMainSessionTask - Handles backgrounding the main session query. 3 * 4 * When user presses Ctrl+B twice during a query, the session is "backgrounded": 5 * - The query continues running in the background 6 * - The UI clears to a fresh prompt 7 * - A notification is sent when the query completes 8 * 9 * This reuses the LocalAgentTask state structure since the behavior is similar. 10 */ 11 12import type { UUID } from 'crypto' 13import { randomBytes } from 'crypto' 14import { 15 OUTPUT_FILE_TAG, 16 STATUS_TAG, 17 SUMMARY_TAG, 18 TASK_ID_TAG, 19 TASK_NOTIFICATION_TAG, 20 TOOL_USE_ID_TAG, 21} from '../constants/xml.js' 22import { type QueryParams, query } from '../query.js' 23import { roughTokenCountEstimation } from '../services/tokenEstimation.js' 24import type { SetAppState } from '../Task.js' 25import { createTaskStateBase } from '../Task.js' 26import type { 27 AgentDefinition, 28 CustomAgentDefinition, 29} from '../tools/AgentTool/loadAgentsDir.js' 30import { asAgentId } from '../types/ids.js' 31import type { Message } from '../types/message.js' 32import { createAbortController } from '../utils/abortController.js' 33import { 34 runWithAgentContext, 35 type SubagentContext, 36} from '../utils/agentContext.js' 37import { registerCleanup } from '../utils/cleanupRegistry.js' 38import { logForDebugging } from '../utils/debug.js' 39import { logError } from '../utils/log.js' 40import { enqueuePendingNotification } from '../utils/messageQueueManager.js' 41import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js' 42import { 43 getAgentTranscriptPath, 44 recordSidechainTranscript, 45} from '../utils/sessionStorage.js' 46import { 47 evictTaskOutput, 48 getTaskOutputPath, 49 initTaskOutputAsSymlink, 50} from '../utils/task/diskOutput.js' 51import { registerTask, updateTaskState } from '../utils/task/framework.js' 52import type { LocalAgentTaskState } from './LocalAgentTask/LocalAgentTask.js' 53 54// Main session tasks use LocalAgentTaskState with agentType='main-session' 55export type LocalMainSessionTaskState = LocalAgentTaskState & { 56 agentType: 'main-session' 57} 58 59/** 60 * Default agent definition for main session tasks when no agent is specified. 61 */ 62const DEFAULT_MAIN_SESSION_AGENT: CustomAgentDefinition = { 63 agentType: 'main-session', 64 whenToUse: 'Main session query', 65 source: 'userSettings', 66 getSystemPrompt: () => '', 67} 68 69/** 70 * Generate a unique task ID for main session tasks. 71 * Uses 's' prefix to distinguish from agent tasks ('a' prefix). 72 */ 73const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz' 74 75function generateMainSessionTaskId(): string { 76 const bytes = randomBytes(8) 77 let id = 's' 78 for (let i = 0; i < 8; i++) { 79 id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length] 80 } 81 return id 82} 83 84/** 85 * Register a backgrounded main session task. 86 * Called when the user backgrounds the current session query. 87 * 88 * @param description - Description of the task 89 * @param setAppState - State setter function 90 * @param mainThreadAgentDefinition - Optional agent definition if running with --agent 91 * @param existingAbortController - Optional abort controller to reuse (for backgrounding an active query) 92 * @returns Object with task ID and abort signal for stopping the background query 93 */ 94export function registerMainSessionTask( 95 description: string, 96 setAppState: SetAppState, 97 mainThreadAgentDefinition?: AgentDefinition, 98 existingAbortController?: AbortController, 99): { taskId: string; abortSignal: AbortSignal } { 100 const taskId = generateMainSessionTaskId() 101 102 // Link output to an isolated per-task transcript file (same layout as 103 // sub-agents). Do NOT use getTranscriptPath() — that's the main session's 104 // file, and writing there from a background query after /clear would corrupt 105 // the post-clear conversation. The isolated path lets this task survive 106 // /clear: the symlink re-link in clearConversation handles session ID changes. 107 void initTaskOutputAsSymlink( 108 taskId, 109 getAgentTranscriptPath(asAgentId(taskId)), 110 ) 111 112 // Use the existing abort controller if provided (important for backgrounding an active query) 113 // This ensures that aborting the task will abort the actual query 114 const abortController = existingAbortController ?? createAbortController() 115 116 const unregisterCleanup = registerCleanup(async () => { 117 // Clean up on process exit 118 setAppState(prev => { 119 const { [taskId]: removed, ...rest } = prev.tasks 120 return { ...prev, tasks: rest } 121 }) 122 }) 123 124 // Use provided agent definition or default 125 const selectedAgent = mainThreadAgentDefinition ?? DEFAULT_MAIN_SESSION_AGENT 126 127 // Create task state - already backgrounded since this is called when user backgrounds 128 const taskState: LocalMainSessionTaskState = { 129 ...createTaskStateBase(taskId, 'local_agent', description), 130 type: 'local_agent', 131 status: 'running', 132 agentId: taskId, 133 prompt: description, 134 selectedAgent, 135 agentType: 'main-session', 136 abortController, 137 unregisterCleanup, 138 retrieved: false, 139 lastReportedToolCount: 0, 140 lastReportedTokenCount: 0, 141 isBackgrounded: true, // Already backgrounded 142 pendingMessages: [], 143 retain: false, 144 diskLoaded: false, 145 } 146 147 logForDebugging( 148 `[LocalMainSessionTask] Registering task ${taskId} with description: ${description}`, 149 ) 150 registerTask(taskState, setAppState) 151 152 // Verify task was registered by checking state 153 setAppState(prev => { 154 const hasTask = taskId in prev.tasks 155 logForDebugging( 156 `[LocalMainSessionTask] After registration, task ${taskId} exists in state: ${hasTask}`, 157 ) 158 return prev 159 }) 160 161 return { taskId, abortSignal: abortController.signal } 162} 163 164/** 165 * Complete the main session task and send notification. 166 * Called when the backgrounded query finishes. 167 */ 168export function completeMainSessionTask( 169 taskId: string, 170 success: boolean, 171 setAppState: SetAppState, 172): void { 173 let wasBackgrounded = true 174 let toolUseId: string | undefined 175 176 updateTaskState<LocalMainSessionTaskState>(taskId, setAppState, task => { 177 if (task.status !== 'running') { 178 return task 179 } 180 181 // Track if task was backgrounded (for notification decision) 182 wasBackgrounded = task.isBackgrounded ?? true 183 toolUseId = task.toolUseId 184 185 task.unregisterCleanup?.() 186 187 return { 188 ...task, 189 status: success ? 'completed' : 'failed', 190 endTime: Date.now(), 191 messages: task.messages?.length ? [task.messages.at(-1)!] : undefined, 192 } 193 }) 194 195 void evictTaskOutput(taskId) 196 197 // Only send notification if task is still backgrounded (not foregrounded) 198 // If foregrounded, user is watching it directly - no notification needed 199 if (wasBackgrounded) { 200 enqueueMainSessionNotification( 201 taskId, 202 'Background session', 203 success ? 'completed' : 'failed', 204 setAppState, 205 toolUseId, 206 ) 207 } else { 208 // Foregrounded: no XML notification (TUI user is watching), but SDK 209 // consumers still need to see the task_started bookend close. 210 // Set notified so evictTerminalTask/generateTaskAttachments eviction 211 // guards pass; the backgrounded path sets this inside 212 // enqueueMainSessionNotification's check-and-set. 213 updateTaskState(taskId, setAppState, task => ({ ...task, notified: true })) 214 emitTaskTerminatedSdk(taskId, success ? 'completed' : 'failed', { 215 toolUseId, 216 summary: 'Background session', 217 }) 218 } 219} 220 221/** 222 * Enqueue a notification about the backgrounded session completing. 223 */ 224function enqueueMainSessionNotification( 225 taskId: string, 226 description: string, 227 status: 'completed' | 'failed', 228 setAppState: SetAppState, 229 toolUseId?: string, 230): void { 231 // Atomically check and set notified flag to prevent duplicate notifications. 232 let shouldEnqueue = false 233 updateTaskState(taskId, setAppState, task => { 234 if (task.notified) { 235 return task 236 } 237 shouldEnqueue = true 238 return { ...task, notified: true } 239 }) 240 241 if (!shouldEnqueue) { 242 return 243 } 244 245 const summary = 246 status === 'completed' 247 ? `Background session "${description}" completed` 248 : `Background session "${description}" failed` 249 250 const toolUseIdLine = toolUseId 251 ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` 252 : '' 253 254 const outputPath = getTaskOutputPath(taskId) 255 const message = `<${TASK_NOTIFICATION_TAG}> 256<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine} 257<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> 258<${STATUS_TAG}>${status}</${STATUS_TAG}> 259<${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}> 260</${TASK_NOTIFICATION_TAG}>` 261 262 enqueuePendingNotification({ value: message, mode: 'task-notification' }) 263} 264 265/** 266 * Foreground a main session task - mark it as foregrounded so its output 267 * appears in the main view. The background query keeps running. 268 * Returns the task's accumulated messages, or undefined if task not found. 269 */ 270export function foregroundMainSessionTask( 271 taskId: string, 272 setAppState: SetAppState, 273): Message[] | undefined { 274 let taskMessages: Message[] | undefined 275 276 setAppState(prev => { 277 const task = prev.tasks[taskId] 278 if (!task || task.type !== 'local_agent') { 279 return prev 280 } 281 282 taskMessages = (task as LocalMainSessionTaskState).messages 283 284 // Restore previous foregrounded task to background if it exists 285 const prevId = prev.foregroundedTaskId 286 const prevTask = prevId ? prev.tasks[prevId] : undefined 287 const restorePrev = 288 prevId && prevId !== taskId && prevTask?.type === 'local_agent' 289 290 return { 291 ...prev, 292 foregroundedTaskId: taskId, 293 tasks: { 294 ...prev.tasks, 295 ...(restorePrev && { [prevId]: { ...prevTask, isBackgrounded: true } }), 296 [taskId]: { ...task, isBackgrounded: false }, 297 }, 298 } 299 }) 300 301 return taskMessages 302} 303 304/** 305 * Check if a task is a main session task (vs a regular agent task). 306 */ 307export function isMainSessionTask( 308 task: unknown, 309): task is LocalMainSessionTaskState { 310 if ( 311 typeof task !== 'object' || 312 task === null || 313 !('type' in task) || 314 !('agentType' in task) 315 ) { 316 return false 317 } 318 return ( 319 task.type === 'local_agent' && 320 (task as LocalMainSessionTaskState).agentType === 'main-session' 321 ) 322} 323 324// Max recent activities to keep for display 325const MAX_RECENT_ACTIVITIES = 5 326 327type ToolActivity = { 328 toolName: string 329 input: Record<string, unknown> 330} 331 332/** 333 * Start a fresh background session with the given messages. 334 * 335 * Spawns an independent query() call with the current messages and registers it 336 * as a background task. The caller's foreground query continues running normally. 337 */ 338export function startBackgroundSession({ 339 messages, 340 queryParams, 341 description, 342 setAppState, 343 agentDefinition, 344}: { 345 messages: Message[] 346 queryParams: Omit<QueryParams, 'messages'> 347 description: string 348 setAppState: SetAppState 349 agentDefinition?: AgentDefinition 350}): string { 351 const { taskId, abortSignal } = registerMainSessionTask( 352 description, 353 setAppState, 354 agentDefinition, 355 ) 356 357 // Persist the pre-backgrounding conversation to the task's isolated 358 // transcript so TaskOutput shows context immediately. Subsequent messages 359 // are written incrementally below. 360 void recordSidechainTranscript(messages, taskId).catch(err => 361 logForDebugging(`bg-session initial transcript write failed: ${err}`), 362 ) 363 364 // Wrap in agent context so skill invocations scope to this task's agentId 365 // (not null). This lets clearInvokedSkills(preservedAgentIds) selectively 366 // preserve this task's skills across /clear. AsyncLocalStorage isolates 367 // concurrent async chains — this wrapper doesn't affect the foreground. 368 const agentContext: SubagentContext = { 369 agentId: taskId, 370 agentType: 'subagent', 371 subagentName: 'main-session', 372 isBuiltIn: true, 373 } 374 375 void runWithAgentContext(agentContext, async () => { 376 try { 377 const bgMessages: Message[] = [...messages] 378 const recentActivities: ToolActivity[] = [] 379 let toolCount = 0 380 let tokenCount = 0 381 let lastRecordedUuid: UUID | null = messages.at(-1)?.uuid ?? null 382 383 for await (const event of query({ 384 messages: bgMessages, 385 ...queryParams, 386 })) { 387 if (abortSignal.aborted) { 388 // Aborted mid-stream — completeMainSessionTask won't be reached. 389 // chat:killAgents path already marked notified + emitted; stopTask path did not. 390 let alreadyNotified = false 391 updateTaskState(taskId, setAppState, task => { 392 alreadyNotified = task.notified === true 393 return alreadyNotified ? task : { ...task, notified: true } 394 }) 395 if (!alreadyNotified) { 396 emitTaskTerminatedSdk(taskId, 'stopped', { 397 summary: description, 398 }) 399 } 400 return 401 } 402 403 if ( 404 event.type !== 'user' && 405 event.type !== 'assistant' && 406 event.type !== 'system' 407 ) { 408 continue 409 } 410 411 bgMessages.push(event) 412 413 // Per-message write (matches runAgent.ts pattern) — gives live 414 // TaskOutput progress and keeps the transcript file current even if 415 // /clear re-links the symlink mid-run. 416 void recordSidechainTranscript([event], taskId, lastRecordedUuid).catch( 417 err => logForDebugging(`bg-session transcript write failed: ${err}`), 418 ) 419 lastRecordedUuid = event.uuid 420 421 if (event.type === 'assistant') { 422 for (const block of event.message.content) { 423 if (block.type === 'text') { 424 tokenCount += roughTokenCountEstimation(block.text) 425 } else if (block.type === 'tool_use') { 426 toolCount++ 427 const activity: ToolActivity = { 428 toolName: block.name, 429 input: block.input as Record<string, unknown>, 430 } 431 recentActivities.push(activity) 432 if (recentActivities.length > MAX_RECENT_ACTIVITIES) { 433 recentActivities.shift() 434 } 435 } 436 } 437 } 438 439 setAppState(prev => { 440 const task = prev.tasks[taskId] 441 if (!task || task.type !== 'local_agent') return prev 442 const prevProgress = task.progress 443 if ( 444 prevProgress?.tokenCount === tokenCount && 445 prevProgress.toolUseCount === toolCount && 446 task.messages === bgMessages 447 ) { 448 return prev 449 } 450 return { 451 ...prev, 452 tasks: { 453 ...prev.tasks, 454 [taskId]: { 455 ...task, 456 progress: { 457 tokenCount, 458 toolUseCount: toolCount, 459 recentActivities: 460 prevProgress?.toolUseCount === toolCount 461 ? prevProgress.recentActivities 462 : [...recentActivities], 463 }, 464 messages: bgMessages, 465 }, 466 }, 467 } 468 }) 469 } 470 471 completeMainSessionTask(taskId, true, setAppState) 472 } catch (error) { 473 logError(error) 474 completeMainSessionTask(taskId, false, setAppState) 475 } 476 }) 477 478 return taskId 479}