import { randomBytes } from 'node:crypto'; import { join } from 'pathe'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#human/llm/message'; import { Disposable } from '#/_base/di/lifecycle'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/state/state'; import { abortable, userCancellationReason, } from '#/_base/utils/abort'; import { setClampedTimeout } from '#/_base/utils/timer'; import { escapeXml, escapeXmlAttr, escapeXmlTags } from '#/_base/utils/xml-escape'; import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; import { Error2, ErrorCodes } from '#/errors'; import { z } from 'zod'; import { ContextAppendMessage, ContextSpliced, } from '#/agent/contextMemory/contextEvents'; import '#/agent/contextMemory/conversationTime'; import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { TaskOrigin } from '#/agent/contextMemory/types'; import { IAgentReminderService } from '#/features/reminder/reminderService'; import { IAgentLoopService, type LoopNotifyHandle } from '#/agent/loop/loop'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { ITaskService, type ITaskHandle, TERMINAL_TASK_STATES } from '#/app/task/task'; import { TERMINAL_STATUSES, type AgentTaskInfoBase, type AgentTaskSettlement, } from './types'; import { renderNotificationXml } from './notificationXml'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IConfigService } from '#/app/config/config'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTaskService, type AgentTaskLoadOptions, type AgentTask, type AgentTaskInfo, type AgentTaskOutputSnapshot, type AgentTaskStatus, type AgentTaskTrackOptions, type AgentTaskWaitDelivery, type ForegroundTaskReleaseReason, type IAgentTaskEntry, type RegisterAgentTaskOptions, } from './task'; import { resolveAgentTaskConfig } from './configSection'; import { AgentTaskPersistence } from './persist'; import { taskKey, TaskNotified, TaskStarted, TaskTerminated, TaskWaitDelivered } from './taskOps'; import { formatTaskList } from '#/agent/tools/task/task-list/taskListTool'; import '#/agent/tools/task/task-output/taskOutputTool'; import '#/agent/tools/task/task-stop/taskStopTool'; import '#/agent/tools/task/task-wait/taskWaitTool'; interface ForegroundRelease { readonly promise: Promise; resolve(reason: ForegroundTaskReleaseReason): void; } type AgentTaskNotification = Record & { readonly id: string; readonly category: 'task'; readonly type: string; readonly source_kind: 'background_task'; readonly source_id: string; readonly agent_id?: string | undefined; readonly title: string; readonly severity: 'info' | 'warning'; readonly body: string; readonly children?: readonly string[] | undefined; }; interface AgentTaskNotificationBuildContext { readonly content: readonly ContentPart[]; readonly origin: TaskOrigin; readonly notification: AgentTaskNotification; } export const taskNotificationDeliveryKey = defineState( 'task.notificationDelivery', (): readonly string[] => [], ) .replayable({ schema: z.custom() }) .undoable() .on(ContextAppendMessage, (s, e) => { const origin = taskOriginFromMessage(e.message); if (origin === undefined) return; const key = notificationKey(origin); if (!s.includes(key)) { s.push(key); } }) .on(TaskWaitDelivered, (s, e) => { for (const key of e.keys) { if (!s.includes(key)) { s.push(key); } } }); interface ManagedTask { readonly taskId: string; readonly task: AgentTask | undefined; readonly handle: ITaskHandle | undefined; readonly toInfoFn?: (base: AgentTaskInfoBase) => AgentTaskInfo; readonly forceStopFn?: () => Promise; readonly onDetachFn?: () => void; readonly outputChunks: string[]; outputSizeBytes: number; retainedOutputBytes: number; outputLimitTripped: boolean; status: AgentTaskStatus; options: RegisterAgentTaskOptions & { description?: string }; readonly startedAt: number; endedAt: number | null; foregroundRelease?: ForegroundRelease; stopReason?: string; terminalNotificationSuppressed?: boolean; terminalFired: boolean; readonly abortController: AbortController; foregroundSignalCleanup?: () => void; lifecyclePromise: Promise; persistWriteQueue: Promise; outputWriteQueue: Promise; pendingOutput: string[]; pendingOutputBytes: number; outputPersistStarted: boolean; timeoutHandle?: ReturnType; timedOut: boolean; readonly waiters: Array<() => void>; handleSubscription?: { dispose(): void }; } const MAX_OUTPUT_BYTES = 1024 * 1024; const TERMINAL_OUTPUT_TAIL_BYTES = 4 * 1024; const MAX_TASK_OUTPUT_BYTES = 16 * 1024 * 1024; function outputLimitReason(): string { const mib = Math.floor(MAX_TASK_OUTPUT_BYTES / (1024 * 1024)); return ( `Output limit exceeded: the command produced more than ${mib} MiB and was ` + 'terminated. Redirect large output to a file (e.g. `command > out.txt`) and ' + 'inspect it in slices instead.' ); } const SIGTERM_GRACE_MS = 5_000; const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; const SESSION_CLOSED_REASON = 'Session closed'; const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000; const QUESTION_ANSWER_INLINE_BYTES = 16_000; const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status'; const TASK_RESUME_TERMINATION_VARIANT = 'task_resume_termination'; const ACTIVE_BACKGROUND_TASK_GUIDANCE = [ 'The conversation was compacted, so the earlier messages that started these background tasks are gone — but the tasks are still running from before.', 'Do not start duplicates. Use TaskList to list them, TaskOutput for a non-blocking status/output snapshot, and TaskStop to cancel one — completion arrives via automatic notification.', ].join(' '); export function isAgentTaskTerminal(status: AgentTaskStatus): boolean { return TERMINAL_STATUSES.has(status); } function coerceTimeoutSettlement( entry: ManagedTask, settlement: AgentTaskSettlement, ): AgentTaskSettlement { if (entry.timedOut && settlement.status === 'killed') { return { ...settlement, status: 'timed_out' }; } return settlement; } export const taskGhostsKey = defineState>( 'task.ghosts', () => new Map(), ); export const taskScheduledNotificationKeysKey = defineState>( 'task.scheduledNotificationKeys', () => new Set(), ); export const taskDeliveredNotificationKeysKey = defineState>( 'task.deliveredNotificationKeys', () => new Set(), ); export const taskActiveTaskReminderPendingKey = defineState( 'task.activeTaskReminderPending', () => false, ); export class AgentTaskService extends Disposable implements IAgentTaskService { declare readonly _serviceBrand: undefined; private readonly tasks = new Map(); private exitSuppressionArmed = false; private readonly buildingNotificationKeys = new Set(); private readonly pendingNotificationRequests = new Map(); private readonly persistence: AgentTaskPersistence; private notificationRestoreQueue: Promise = Promise.resolve(); constructor( @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IConfigService private readonly config: IConfigService, @IAtomicDocumentStore atomicDocs: IAtomicDocumentStore, @IFileSystemStorageService byteStore: IFileSystemStorageService, @ISessionContext session: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITaskService private readonly taskService: ITaskService, @IEventBus private readonly eventBus: IEventBus, @ISessionEventBus private readonly sessionEventBus: ISessionEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentReminderService private readonly reminder: IAgentReminderService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentConversationUndoParticipantRegistry undoParticipants: IAgentConversationUndoParticipantRegistry, @ILogService private readonly log: ILogService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); this.states.contributeState(taskKey); this.states.contributeState(taskNotificationDeliveryKey); this.states.contributeState(taskGhostsKey); this.states.contributeState(taskScheduledNotificationKeysKey); this.states.contributeState(taskDeliveredNotificationKeysKey); this.states.contributeState(taskActiveTaskReminderPendingKey); const fallbackRoot = this.scopeContext.agentId === 'main' ? { dir: session.sessionDir, scope: session.scope() } : undefined; this.persistence = new AgentTaskPersistence( join(session.sessionDir, 'agents', this.scopeContext.agentId), this.scopeContext.scope(), atomicDocs, byteStore, fallbackRoot, ); this._register( undoParticipants.register({ id: 'task.notificationDelivery', reconcileAfterUndo: () => this.reconcileNotificationDeliveryAfterUndo(), }), ); this._register( this.dispatcher.hooks.onDidRestore.register('task', async (_ctx, next) => { for (const key of this.states.get(taskNotificationDeliveryKey)) { this.deliveredNotificationKeys.add(key); } await this.restoreAfterReplay(); await next(); }), ); this._register( this.eventBus.subscribe(ContextSpliced, (e) => { if (isCompactionSplice(e)) { this.activeTaskReminderPending = true; } for (const message of e.messages) { if (isTaskOrigin(message.origin)) { this.markDeliveredNotification(message.origin); } } }), ); this._register( this.reminder.register(ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT, () => this.activeBackgroundTaskReminder(), ), ); } private get ghosts(): Map { return this.states.get(taskGhostsKey); } private get scheduledNotificationKeys(): Set { return this.states.get(taskScheduledNotificationKeysKey); } private get deliveredNotificationKeys(): Set { return this.states.get(taskDeliveredNotificationKeysKey); } private get activeTaskReminderPending(): boolean { return this.states.get(taskActiveTaskReminderPendingKey); } private set activeTaskReminderPending(value: boolean) { this.states.set(taskActiveTaskReminderPendingKey, value); } private async restoreAfterReplay(): Promise { this.restoreGhostsFromWire(); await this.loadFromDisk({ replace: false }); await this.reconcile(); } private activeBackgroundTaskReminder(): string | undefined { if (!this.activeTaskReminderPending) return undefined; this.activeTaskReminderPending = false; const tasks = this.list(true); if (tasks.length === 0) return undefined; return `${ACTIVE_BACKGROUND_TASK_GUIDANCE}\n\n${formatTaskList(tasks, true)}`; } private restoreGhostsFromWire(): void { for (const [taskId, info] of this.states.get(taskKey)) { if (this.tasks.has(taskId)) continue; this.ghosts.set(taskId, info); } } registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string { const detached = options.detached ?? true; const timeoutMs = options.timeoutMs ?? task.timeoutMs; const entryOptions: RegisterAgentTaskOptions = { detached, timeoutMs, detachTimeoutMs: options.detachTimeoutMs, autoBackgroundOnTimeout: options.autoBackgroundOnTimeout, signal: detached ? undefined : options.signal, }; this.assertCanRegister(detached); const entry: ManagedTask = { taskId: generateTaskId(task.idPrefix), task, handle: undefined, outputChunks: [], outputSizeBytes: 0, retainedOutputBytes: 0, outputLimitTripped: false, status: 'running', options: entryOptions, startedAt: Date.now(), endedAt: null, foregroundRelease: detached ? undefined : createForegroundRelease(), abortController: new AbortController(), lifecyclePromise: Promise.resolve(), persistWriteQueue: Promise.resolve(), outputWriteQueue: Promise.resolve(), pendingOutput: [], pendingOutputBytes: 0, outputPersistStarted: detached, waiters: [], terminalFired: false, timedOut: false, }; this.tasks.set(entry.taskId, entry); this.ghosts.delete(entry.taskId); if (timeoutMs !== undefined && timeoutMs > 0) { this.armManagerTimeout(entry, timeoutMs); } entry.lifecyclePromise = Promise.resolve() .then(() => task.start({ signal: entry.abortController.signal, appendOutput: (chunk) => { this.appendOutput(entry, chunk); }, settle: (settlement) => this.settleTask(entry, coerceTimeoutSettlement(entry, settlement)), }), ) .catch(async (error: unknown) => { const aborted = entry.abortController.signal.aborted; let status: AgentTaskStatus; if (entry.timedOut) { status = 'timed_out'; } else if (aborted) { status = 'killed'; } else { status = 'failed'; } await this.settleTask(entry, { status, stopReason: status === 'failed' ? errorMessage(error) : undefined, }); }); this.installForegroundSignal(entry); if (this.isDetached(entry)) { void this.persistLive(entry); this.recordTaskStarted(this.toInfo(entry)); } return entry.taskId; } track(handle: ITaskHandle, options: AgentTaskTrackOptions): IAgentTaskEntry { const detached = options.detached ?? true; this.assertCanRegister(detached); const taskId = generateTaskId(options.idPrefix ?? 'task'); const timeoutMs = options.timeoutMs; const entry: ManagedTask = { taskId, task: undefined, handle, toInfoFn: options.toInfo, forceStopFn: options.forceStop, onDetachFn: options.onDetach, outputChunks: [], outputSizeBytes: 0, retainedOutputBytes: 0, outputLimitTripped: false, status: 'running', options: { detached, timeoutMs, detachTimeoutMs: options.detachTimeoutMs, signal: detached ? undefined : options.signal, description: options.description }, startedAt: Date.now(), endedAt: null, foregroundRelease: detached ? undefined : createForegroundRelease(), abortController: new AbortController(), lifecyclePromise: Promise.resolve(), persistWriteQueue: Promise.resolve(), outputWriteQueue: Promise.resolve(), pendingOutput: [], pendingOutputBytes: 0, outputPersistStarted: detached, waiters: [], terminalFired: false, timedOut: false, }; this.tasks.set(taskId, entry); this.ghosts.delete(taskId); if (timeoutMs !== undefined && timeoutMs > 0) { this.armManagerTimeout(entry, timeoutMs); } const outputSub = handle.onDidOutput((chunk) => { this.appendOutput(entry, chunk); }); const stateSub = handle.onDidChangeState((state) => { if (!TERMINAL_TASK_STATES.has(state)) return; const status = entry.timedOut ? 'timed_out' as const : state === 'cancelled' ? 'killed' as const : state === 'failed' ? 'failed' as const : 'completed' as const; void this.settleTask(entry, { status, stopReason: entry.stopReason }); }); entry.handleSubscription = { dispose() { outputSub.dispose(); stateSub.dispose(); }, }; entry.lifecyclePromise = handle.result.then(() => { }, () => { }); this.installForegroundSignal(entry); if (this.isDetached(entry)) { void this.persistLive(entry); this.recordTaskStarted(this.toInfo(entry)); } return { taskId, onDidDetach: entry.foregroundRelease?.promise ?? Promise.resolve('terminal' as const), }; } getTask(taskId: string): AgentTaskInfo | undefined { const entry = this.tasks.get(taskId); return entry === undefined ? this.ghosts.get(taskId) : this.toInfo(entry); } list(activeOnly = true, limit?: number): readonly AgentTaskInfo[] { const result: AgentTaskInfo[] = []; for (const entry of this.tasks.values()) { const info = this.toInfo(entry); if (!shouldListTask(info, activeOnly)) continue; result.push(info); if (limit !== undefined && result.length >= limit) return result; } if (!activeOnly) { for (const ghost of this.ghosts.values()) { if (!shouldListTask(ghost, activeOnly)) continue; result.push(ghost); if (limit !== undefined && result.length >= limit) return result; } } return result; } private async reconcileNotificationDeliveryAfterUndo(): Promise { const restoredKeys = new Set(this.states.get(taskNotificationDeliveryKey)); for (const [key, request] of this.pendingNotificationRequests) { if (request.dropped) this.clearPendingNotification(key, request); } this.deliveredNotificationKeys.clear(); for (const key of restoredKeys) this.deliveredNotificationKeys.add(key); for (const key of this.scheduledNotificationKeys) { if (restoredKeys.has(key) || !this.pendingNotificationRequests.has(key)) { this.scheduledNotificationKeys.delete(key); } } await this.restoreAgentTaskNotifications(); } persistOutput(taskId: string): void { const entry = this.tasks.get(taskId); if (entry === undefined) return; this.startOutputPersist(entry); } async loadFromDisk(options: AgentTaskLoadOptions = {}): Promise { const persistence = this.persistence; if (options.replace !== false) { this.ghosts.clear(); } const tasks = await persistence.listTasks(); for (const task of tasks) { if (this.tasks.has(task.taskId)) continue; const existing = this.ghosts.get(task.taskId); if (existing !== undefined) { this.ghosts.set(task.taskId, newerRestoredTask(existing, task)); continue; } this.ghosts.set(task.taskId, task); } } async reconcile(): Promise { const lostTasks = await this.markLoadedTasksLost(); for (const info of lostTasks) { this.recordTaskTerminated(info); } this.appendPreviousSessionTasksReminder(); await this.restoreAgentTaskNotifications(); return lostTasks; } async getOutputSnapshot( taskId: string, maxPreviewBytes: number, ): Promise { if (this.getTask(taskId) === undefined) return emptyOutputSnapshot(); await this.tasks.get(taskId)?.outputWriteQueue; const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); const persistence = this.persistence; const persisted = await persistence.readTaskOutputSnapshot(taskId, previewLimit); if (persisted !== undefined) { return { ...persisted, fullOutputAvailable: true, }; } const entry = this.tasks.get(taskId); if (entry === undefined) return emptyOutputSnapshot(); const available = Buffer.from(entry.outputChunks.join(''), 'utf-8'); const previewBytes = Math.min(previewLimit, available.byteLength, entry.outputSizeBytes); const previewOffset = Math.max(0, available.byteLength - previewBytes); return { outputSizeBytes: entry.outputSizeBytes, previewBytes, truncated: entry.outputSizeBytes > previewBytes, fullOutputAvailable: false, preview: available.subarray(previewOffset).toString('utf-8'), }; } async readOutput(taskId: string, tail?: number): Promise { const output = (await this.getOutputSnapshot(taskId, Number.MAX_SAFE_INTEGER)).preview; if (tail === undefined) return output; return output.slice(-Math.max(0, Math.trunc(tail))); } async suppressTerminalNotification(taskId: string): Promise { const entry = this.tasks.get(taskId); if (entry !== undefined) { if (entry.terminalNotificationSuppressed === true) return; entry.terminalNotificationSuppressed = true; await this.persistLive(entry); return; } const ghost = this.ghosts.get(taskId); if (ghost !== undefined) return; } markTasksDeliveredViaWait(tasks: readonly AgentTaskWaitDelivery[]): void { if (tasks.length === 0) return; const keys: string[] = []; for (const { taskId, status } of tasks) { const origin: TaskNotificationOrigin = { taskId, status, notificationId: taskNotificationId(taskId, status), }; const key = notificationKey(origin); this.pendingNotificationRequests.get(key)?.drop(); this.markDeliveredNotification(origin); keys.push(key); } void this.dispatcher.dispatch( new TaskWaitDelivered({ agentId: this.scopeContext.agentId, keys }), ); } detach(taskId: string): AgentTaskInfo | undefined { const entry = this.tasks.get(taskId); if (entry === undefined) return this.ghosts.get(taskId); return this.detachEntry(entry, false); } private detachEntry(entry: ManagedTask, viaTimeout: boolean): AgentTaskInfo | undefined { if (TERMINAL_STATUSES.has(entry.status)) return this.toInfo(entry); const foregroundRelease = entry.foregroundRelease; if (foregroundRelease === undefined) return this.toInfo(entry); entry.foregroundRelease = undefined; entry.foregroundSignalCleanup?.(); entry.foregroundSignalCleanup = undefined; this.applyDetachTimeout(entry); try { const onDetach = entry.onDetachFn ?? (entry.task === undefined ? undefined : entry.task.onDetach?.bind(entry.task)); onDetach?.(); } catch { } this.startOutputPersist(entry); void this.persistLive(entry); this.recordTaskStarted(this.toInfo(entry)); foregroundRelease.resolve(viaTimeout ? 'timeout_detached' : 'detached'); return this.toInfo(entry); } private applyDetachTimeout(entry: ManagedTask): void { const timeoutMs = entry.options.detachTimeoutMs; if (timeoutMs === undefined) return; entry.options = { ...entry.options, timeoutMs }; if (entry.timeoutHandle !== undefined) { clearTimeout(entry.timeoutHandle); entry.timeoutHandle = undefined; } if (timeoutMs > 0) { this.armManagerTimeout(entry, timeoutMs); } } private armManagerTimeout(entry: ManagedTask, timeoutMs: number): void { entry.timeoutHandle = setClampedTimeout(() => { entry.timeoutHandle = undefined; if (this.canAutoBackgroundOnTimeout(entry)) { this.detachEntry(entry, true); return; } void this.terminateWithGrace(entry, { abortReason: 'Timed out', finalStatus: 'timed_out', }); }, timeoutMs); entry.timeoutHandle.unref?.(); } private canAutoBackgroundOnTimeout(entry: ManagedTask): boolean { return entry.options.autoBackgroundOnTimeout === true && !this.isDetached(entry); } async stop(taskId: string, reason?: string): Promise { const entry = this.tasks.get(taskId); if (entry === undefined) return undefined; const normalized = normalizeReason(reason); return this.terminateWithGrace(entry, { stopReason: normalized, abortReason: normalized, finalStatus: 'killed', }); } async stopByUser(taskId: string): Promise { const entry = this.tasks.get(taskId); if (entry === undefined) return undefined; const reason = userCancellationReason(); return this.terminateWithGrace(entry, { stopReason: reason.message, abortReason: reason, finalStatus: 'killed', }); } private async terminateWithGrace( entry: ManagedTask, options: { readonly stopReason?: string; readonly abortReason: unknown; readonly finalStatus: 'killed' | 'timed_out'; }, ): Promise { if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; return this.toInfo(entry); } if (entry.timeoutHandle !== undefined) { clearTimeout(entry.timeoutHandle); entry.timeoutHandle = undefined; } if (options.finalStatus === 'timed_out') { entry.timedOut = true; } entry.stopReason = options.stopReason; if (entry.handle) { entry.handle.cancel(); } else { entry.abortController.abort(options.abortReason); } const graceMs = resolveAgentTaskConfig(this.config)?.killGracePeriodMs ?? SIGTERM_GRACE_MS; let graceTimer: ReturnType | undefined; const graceful = await Promise.race([ entry.lifecyclePromise.then( () => true, () => true, ), new Promise((resolve) => { graceTimer = setTimeout(() => { resolve(false); }, graceMs); graceTimer.unref?.(); }), ]); if (graceTimer !== undefined) clearTimeout(graceTimer); if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; return this.toInfo(entry); } if (!graceful) { try { const forceStop = entry.forceStopFn ?? (entry.task === undefined ? undefined : entry.task.forceStop?.bind(entry.task)); await forceStop?.(); } catch { } } if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; return this.toInfo(entry); } await this.settleTask(entry, { status: options.finalStatus, stopReason: options.stopReason, }); await entry.persistWriteQueue; return this.toInfo(entry); } async stopAll(reason?: string): Promise { const results = await Promise.all( Array.from(this.tasks.keys()).map((taskId) => this.stop(taskId, reason)), ); return results.filter((info): info is AgentTaskInfo => info !== undefined); } async suppressAllTerminalNotifications(): Promise { this.exitSuppressionArmed = true; for (const [, request] of Array.from(this.pendingNotificationRequests)) { request.drop(); } } async stopAllOnExit(reason: string): Promise { await this.suppressAllTerminalNotifications(); if (this.keepAliveOnExit()) return []; return this.stopAll(reason); } override dispose(): void { if (!this.keepAliveOnExit()) { for (const entry of this.tasks.values()) { if (TERMINAL_STATUSES.has(entry.status)) continue; if (entry.timeoutHandle !== undefined) { clearTimeout(entry.timeoutHandle); entry.timeoutHandle = undefined; } if (entry.handle !== undefined) { entry.handle.cancel(); } else { entry.abortController.abort(SESSION_CLOSED_REASON); } this.forceStopOnDispose(entry); } } super.dispose(); } private forceStopOnDispose(entry: ManagedTask): void { const forceStop = entry.forceStopFn ?? (entry.task === undefined ? undefined : entry.task.forceStop?.bind(entry.task)); if (forceStop === undefined) return; try { void forceStop().catch(() => {}); } catch {} } private keepAliveOnExit(): boolean { return resolveAgentTaskConfig(this.config)?.keepAliveOnExit === true; } private lifecycleActive(): boolean { return this.sessionEventBus.isAgentActive(this.scopeContext.agentContext); } private marksTerminalNotificationSuppressed(entry: ManagedTask): boolean { return this.exitSuppressionArmed && !this.keepAliveOnExit() && this.isDetached(entry); } async wait( taskId: string, timeoutMs = 30_000, signal?: AbortSignal, ): Promise { const entry = this.tasks.get(taskId); if (entry === undefined) return this.ghosts.get(taskId); if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; return this.toInfo(entry); } if (timeoutMs <= 0) { return this.toInfo(entry); } let waiter: (() => void) | undefined; let timeout: ReturnType | undefined; try { const pending = Promise.race([ new Promise((resolve) => { waiter = resolve; entry.waiters.push(resolve); }), new Promise((resolve) => { timeout = setClampedTimeout(resolve, timeoutMs); timeout.unref?.(); }), ]); await (signal === undefined ? pending : abortable(pending, signal)); } finally { if (timeout !== undefined) clearTimeout(timeout); if (waiter !== undefined) { const index = entry.waiters.indexOf(waiter); if (index !== -1) entry.waiters.splice(index, 1); } } if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; } return this.toInfo(entry); } async waitForForegroundRelease( taskId: string, ): Promise { const entry = this.tasks.get(taskId); if (entry === undefined) return undefined; if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; return 'terminal'; } if (this.isDetached(entry)) return 'detached'; const foregroundRelease = entry.foregroundRelease; if (foregroundRelease === undefined) return 'detached'; const foregroundReleasePromise = foregroundRelease.promise; const reason = await Promise.race([ foregroundReleasePromise, entry.lifecyclePromise.then(() => 'terminal' as const), ]); if (reason === 'terminal') { await entry.persistWriteQueue; } return reason; } private assertCanRegister(detached: boolean): void { const maxRunningTasks = resolveAgentTaskConfig(this.config)?.maxRunningTasks; if (maxRunningTasks === undefined) return; if (!detached) return; if (this.activeTaskCount() < maxRunningTasks) return; throw new Error2(ErrorCodes.TASK_LIMIT_EXCEEDED, 'Too many background tasks are already running.', { details: { running: this.activeTaskCount(), max: maxRunningTasks }, }); } private activeTaskCount(): number { let count = 0; for (const entry of this.tasks.values()) { if (!TERMINAL_STATUSES.has(entry.status) && this.startsDetached(entry)) count++; } return count; } private startsDetached(entry: ManagedTask): boolean { return entry.options.detached !== false; } private isDetached(entry: ManagedTask): boolean { return entry.foregroundRelease === undefined; } private async markLoadedTasksLost(): Promise { const lostTasks: AgentTaskInfo[] = []; const persistence = this.persistence; for (const [taskId, info] of this.ghosts) { if (TERMINAL_STATUSES.has(info.status)) continue; const updated: AgentTaskInfo = { ...info, status: 'lost', endedAt: info.endedAt ?? Date.now(), }; this.ghosts.set(taskId, updated); await persistence.writeTask(updated); lostTasks.push(updated); } return lostTasks; } private persistLive(entry: ManagedTask): Promise { const persistence = this.persistence; const info = this.toInfo(entry); entry.persistWriteQueue = entry.persistWriteQueue .then(() => persistence.writeTask(info)) .catch(() => { }); return entry.persistWriteQueue; } private appendOutput(entry: ManagedTask, chunk: string): void { const chunkBytes = Buffer.byteLength(chunk, 'utf-8'); entry.outputSizeBytes += chunkBytes; this.appendRetainedOutput(entry, chunk, chunkBytes); if ( !entry.outputLimitTripped && entry.task?.kind === 'process' && entry.outputSizeBytes > MAX_TASK_OUTPUT_BYTES ) { entry.outputLimitTripped = true; void this.stop(entry.taskId, outputLimitReason()); } if (entry.outputLimitTripped) return; if (!entry.outputPersistStarted) { entry.pendingOutput.push(chunk); entry.pendingOutputBytes += chunkBytes; if (entry.pendingOutputBytes > MAX_OUTPUT_BYTES) { this.startOutputPersist(entry); } return; } this.appendTaskOutput(entry, chunk); } private appendTaskOutput(entry: ManagedTask, chunk: string): void { const persistence = this.persistence; entry.outputWriteQueue = entry.outputWriteQueue .then(() => persistence.appendTaskOutput(entry.taskId, chunk)) .catch(() => { }); } private startOutputPersist(entry: ManagedTask): void { if (entry.outputPersistStarted) return; entry.outputPersistStarted = true; if (entry.pendingOutput.length > 0) { this.appendTaskOutput(entry, entry.pendingOutput.join('')); } entry.pendingOutput = []; entry.pendingOutputBytes = 0; } private appendRetainedOutput(entry: ManagedTask, chunk: string, chunkBytes: number): void { if (chunkBytes >= MAX_OUTPUT_BYTES) { const retained = Buffer.from(chunk, 'utf-8') .subarray(chunkBytes - MAX_OUTPUT_BYTES) .toString('utf-8'); entry.outputChunks.length = 0; entry.outputChunks.push(retained); entry.retainedOutputBytes = Buffer.byteLength(retained, 'utf-8'); return; } entry.outputChunks.push(chunk); entry.retainedOutputBytes += chunkBytes; while (entry.retainedOutputBytes > MAX_OUTPUT_BYTES) { const removed = entry.outputChunks.shift(); if (removed === undefined) break; entry.retainedOutputBytes -= Buffer.byteLength(removed, 'utf-8'); } } private async settleTask( entry: ManagedTask, settlement: AgentTaskSettlement, ): Promise { if (TERMINAL_STATUSES.has(entry.status)) return false; entry.status = settlement.status; entry.endedAt = Date.now(); entry.stopReason = settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined); entry.foregroundSignalCleanup?.(); entry.foregroundSignalCleanup = undefined; entry.handleSubscription?.dispose(); entry.handleSubscription = undefined; if (entry.timeoutHandle !== undefined) { clearTimeout(entry.timeoutHandle); entry.timeoutHandle = undefined; } const foregroundRelease = entry.foregroundRelease; if (this.marksTerminalNotificationSuppressed(entry)) { entry.terminalNotificationSuppressed = true; } if (entry.outputPersistStarted) { await this.persistLive(entry); } else { entry.pendingOutput = []; entry.pendingOutputBytes = 0; } if ( this.marksTerminalNotificationSuppressed(entry) && entry.terminalNotificationSuppressed !== true ) { entry.terminalNotificationSuppressed = true; await this.persistLive(entry); } this.fireTerminalEffects(entry); foregroundRelease?.resolve('terminal'); this.resolveWaiters(entry); return true; } private fireTerminalEffects(entry: ManagedTask): void { if (entry.terminalFired) return; if (!this.isDetached(entry)) return; entry.terminalFired = true; const info = this.toInfo(entry); void this.notifyAgentTask(info).catch((error) => { this.log.error('task notification delivery failed', { taskId: info.taskId, error }); }); this.recordTaskTerminated(info, this.retainedOutputTail(entry)); } private retainedOutputTail(entry: ManagedTask): string | undefined { if (entry.outputChunks.length === 0) return undefined; const retained = Buffer.from(entry.outputChunks.join(''), 'utf-8'); const offset = Math.max(0, retained.byteLength - TERMINAL_OUTPUT_TAIL_BYTES); return retained.subarray(offset).toString('utf-8'); } private recordTaskStarted(info: AgentTaskInfo): void { if (this.lifecycleActive()) { void this.dispatcher.dispatch( new TaskStarted({ agentId: this.scopeContext.agentId, info }), ); } this.telemetry.track2('background_task_created', { task_id: info.taskId, kind: info.kind === 'process' ? 'bash' : info.kind, }); } private recordTaskTerminated(info: AgentTaskInfo, outputTail?: string): void { if (this.lifecycleActive()) { void this.dispatcher.dispatch( new TaskTerminated({ agentId: this.scopeContext.agentId, info, outputTail }), ); } this.telemetry.track2('background_task_completed', { task_id: info.taskId, kind: info.kind, duration_ms: info.endedAt !== null ? info.endedAt - info.startedAt : null, status: info.status, }); } private async notifyAgentTask(info: AgentTaskInfo): Promise { if (!this.lifecycleActive()) return; const context = await this.buildAgentTaskNotificationContext(info); if (context === undefined) return; if (!this.lifecycleActive() || this.isTerminalNotificationSuppressed(info.taskId)) return; const key = notificationKey(context.origin); if (this.deliveredNotificationKeys.has(key)) return; const handle = this.loop.notify({ message: { role: 'user', content: [...context.content], toolCalls: [], origin: context.origin, }, turnScoped: false, onConsume: () => { this.pendingNotificationRequests.delete(key); this.fireNotificationHook(context.notification); }, onDrop: () => this.clearPendingNotification(key, handle), }); this.pendingNotificationRequests.set(key, handle); } private restoreAgentTaskNotifications(): Promise { const restore = this.notificationRestoreQueue.then(() => this.restoreAgentTaskNotificationsNow(), ); this.notificationRestoreQueue = restore.catch(() => {}); return restore; } private async restoreAgentTaskNotificationsNow(): Promise { for (const info of this.list(false)) { if (!isAgentTaskTerminal(info.status)) continue; if (info.status === 'lost') continue; await this.restoreAgentTaskNotification(info); } } private appendPreviousSessionTasksReminder(): void { const tasks: AgentTaskInfo[] = []; for (const info of this.ghosts.values()) { if (info.resumeReminded === true) continue; if (!isPreviousSessionTermination(info)) continue; if ( this.hasPreviousSessionReminder(info.taskId) || (info.status === 'lost' && this.hasDeliveredTaskOrigin(info)) ) { this.persistPreviousSessionReminderMarker(info); continue; } tasks.push(info); } if (tasks.length === 0) return; const lines = tasks.map((info) => previousSessionTaskLine(info)); this.reminder.notify( [ 'The user exited the application after your last turn, so your background tasks from the previous session lost contact:', ...lines, "Don't assume any of them completed; check current state (they may still be running), then re-run or resume only what you still need.", ].join('\n'), { variant: TASK_RESUME_TERMINATION_VARIANT }, ); for (const info of tasks) { this.firePreviousSessionLostTaskNotificationHook(info); this.persistPreviousSessionReminderMarker(info); } } private hasPreviousSessionReminder(taskId: string): boolean { const taskLinePrefix = `- ${taskId} "`; return this.context.get().some((message) => { if ( message.origin?.kind !== 'injection' || message.origin.variant !== TASK_RESUME_TERMINATION_VARIANT ) { return false; } return message.content.some( (part) => part.type === 'text' && part.text.split('\n').some((line) => line.startsWith(taskLinePrefix)), ); }); } private hasDeliveredTaskOrigin(info: AgentTaskInfo): boolean { const origin: TaskNotificationOrigin = { taskId: info.taskId, status: info.status, notificationId: taskNotificationId(info.taskId, info.status), }; const key = notificationKey(origin); return ( this.states.get(taskNotificationDeliveryKey).includes(key) || this.deliveredNotificationKeys.has(key) || this.hasDeliveredNotification(key) ); } private persistPreviousSessionReminderMarker(info: AgentTaskInfo): void { const marked: AgentTaskInfo = { ...info, resumeReminded: true }; this.ghosts.set(info.taskId, marked); void this.persistence.writeTask(marked).catch((error: unknown) => { this.log.error('previous-session task reminder marker write failed', { taskId: info.taskId, error, }); }); } private firePreviousSessionLostTaskNotificationHook(info: AgentTaskInfo): void { if (info.status !== 'lost') return; if (info.detached === false) return; if (info.terminalNotificationSuppressed === true) return; const origin: TaskNotificationOrigin = { taskId: info.taskId, status: info.status, notificationId: taskNotificationId(info.taskId, info.status), }; const key = notificationKey(origin); if (this.scheduledNotificationKeys.has(key)) return; if (this.deliveredNotificationKeys.has(key)) return; if (this.hasDeliveredNotification(key)) return; this.fireNotificationHook(buildAgentTaskNotification(info)); } private async restoreAgentTaskNotification(info: AgentTaskInfo): Promise { const context = await this.buildAgentTaskNotificationContext(info); if (context === undefined) return; this.context.append({ role: 'user', content: [...context.content], toolCalls: [], origin: context.origin, }); this.fireNotificationHook(context.notification); } private async buildAgentTaskNotificationContext( info: AgentTaskInfo, ): Promise { if (info.detached === false) return undefined; if (info.terminalNotificationSuppressed === true) return undefined; const origin: TaskOrigin = { kind: 'task', taskId: info.taskId, status: info.status, notificationId: taskNotificationId(info.taskId, info.status), }; const key = notificationKey(origin); if (this.buildingNotificationKeys.has(key)) return undefined; if (this.scheduledNotificationKeys.has(key)) return undefined; if (this.deliveredNotificationKeys.has(key)) return undefined; if (this.hasDeliveredNotification(key)) return undefined; this.buildingNotificationKeys.add(key); try { let output = emptyOutputSnapshot(); try { output = await this.notificationOutputSnapshot(info); } catch (error) { this.log.error('task notification output read failed; delivering without output', { taskId: info.taskId, error, }); } if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; if (this.scheduledNotificationKeys.has(key)) return undefined; if (this.deliveredNotificationKeys.has(key)) return undefined; if (this.hasDeliveredNotification(key)) return undefined; this.scheduledNotificationKeys.add(key); const notification = buildAgentTaskNotification(info, output); const content = [ { type: 'text', text: renderNotificationXml(notification), }, ] as const; return { content, origin, notification }; } finally { this.buildingNotificationKeys.delete(key); } } private async notificationOutputSnapshot(info: AgentTaskInfo): Promise { if (info.kind === 'question') { return this.getOutputSnapshot(info.taskId, QUESTION_ANSWER_INLINE_BYTES); } const persisted = await this.getOutputSnapshot(info.taskId, 0); if (persisted.fullOutputAvailable) return persisted; return this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); } private fireNotificationHook(notification: AgentTaskNotification): void { if (!this.lifecycleActive()) return; void this.dispatcher.dispatch( new TaskNotified({ agentId: this.scopeContext.agentId, notificationType: notification.type, title: notification.title, body: notification.body, severity: notification.severity, sourceKind: notification.source_kind, sourceId: notification.source_id, }), ); } private isTerminalNotificationSuppressed(taskId: string): boolean { return ( this.exitSuppressionArmed || this.tasks.get(taskId)?.terminalNotificationSuppressed === true || this.ghosts.get(taskId)?.terminalNotificationSuppressed === true ); } private markDeliveredNotification(origin: TaskNotificationOrigin): void { const key = notificationKey(origin); this.scheduledNotificationKeys.delete(key); this.pendingNotificationRequests.delete(key); this.deliveredNotificationKeys.add(key); } private clearPendingNotification(key: string, request: LoopNotifyHandle): void { if (this.pendingNotificationRequests.get(key) !== request) return; this.pendingNotificationRequests.delete(key); if (!this.deliveredNotificationKeys.has(key) && !this.hasDeliveredNotification(key)) { this.scheduledNotificationKeys.delete(key); } } private hasDeliveredNotification(key: string): boolean { return this.context.get().some((message) => { return isTaskOrigin(message.origin) && notificationKey(message.origin) === key; }); } private resolveWaiters(entry: ManagedTask): void { const waiters = entry.waiters.splice(0); for (const resolve of waiters) resolve(); } private installForegroundSignal(entry: ManagedTask): void { const signal = entry.options.signal; if (signal === undefined) return; const abortFromSignal = (): void => { if (this.isDetached(entry)) return; const userReason = userCancellationReason(); void this.terminateWithGrace(entry, { stopReason: userReason.message, abortReason: signal.reason, finalStatus: 'killed', }); }; if (signal.aborted) { abortFromSignal(); return; } signal.addEventListener('abort', abortFromSignal, { once: true }); entry.foregroundSignalCleanup = () => { signal.removeEventListener('abort', abortFromSignal); }; } private toInfo(entry: ManagedTask): AgentTaskInfo { const base: AgentTaskInfoBase = { taskId: entry.taskId, description: entry.task?.description ?? entry.options.description ?? '', status: entry.status, detached: this.isDetached(entry) ? true : false, startedAt: entry.startedAt, endedAt: entry.endedAt, stopReason: entry.stopReason, terminalNotificationSuppressed: entry.terminalNotificationSuppressed, timeoutMs: entry.options.timeoutMs, }; if (entry.toInfoFn) return entry.toInfoFn(base); return entry.task!.toInfo(base); } } function emptyOutputSnapshot(): AgentTaskOutputSnapshot { return { outputSizeBytes: 0, previewBytes: 0, truncated: false, fullOutputAvailable: false, preview: '', }; } function agentTaskNotificationChildren( info: AgentTaskInfo, output: AgentTaskOutputSnapshot | undefined, ): readonly string[] | undefined { if (output === undefined) return undefined; if (inlinesQuestionAnswer(info, output)) { return output.preview.length === 0 ? undefined : [renderAnswerBlock(output.preview)]; } if (output.fullOutputAvailable && output.outputPath !== undefined) { return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)]; } if (output.preview.length === 0) return undefined; return [renderOutputPreviewBlock(output)]; } function inlinesQuestionAnswer(info: AgentTaskInfo, output: AgentTaskOutputSnapshot): boolean { return info.kind === 'question' && !output.truncated; } function renderAnswerBlock(answer: string): string { return ['', escapeXmlTags(answer), ''].join('\n'); } function questionNotificationText( info: AgentTaskInfo, output: AgentTaskOutputSnapshot | undefined, ): { readonly title: string; readonly body: string } | undefined { if (info.status !== 'completed' || output === undefined || !inlinesQuestionAnswer(info, output)) { return undefined; } const outcome = questionOutcome(output.preview); if (outcome === 'answered') { return { title: 'Background question answered', body: `The user answered "${info.description}".`, }; } if (outcome === 'dismissed') { return { title: 'Background question dismissed', body: `The user dismissed "${info.description}" without answering.`, }; } return undefined; } function questionOutcome(output: string): 'answered' | 'dismissed' | undefined { let parsed: unknown; try { parsed = JSON.parse(output); } catch { return undefined; } if (typeof parsed !== 'object' || parsed === null) return undefined; const answers = (parsed as { readonly answers?: unknown }).answers; if (typeof answers !== 'object' || answers === null || Array.isArray(answers)) return undefined; return Object.keys(answers).length > 0 ? 'answered' : 'dismissed'; } function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string { return [ ``, `Read the output file to retrieve the result: ${escapeXml(outputPath)}`, '', ].join('\n'); } function renderOutputPreviewBlock(output: AgentTaskOutputSnapshot): string { return [ ``, output.truncated ? `Showing the last ${String(output.previewBytes)} bytes. No persisted full output is available.` : 'No persisted full output is available; this preview is the currently buffered task output.', escapeXml(output.preview), '', ].join('\n'); } function shouldListTask(info: AgentTaskInfo, activeOnly: boolean): boolean { if (!TERMINAL_STATUSES.has(info.status)) return true; if (activeOnly) return false; return info.detached !== false; } function isCompactionSplice(splice: { readonly deleteCount: number; readonly messages: readonly { readonly origin?: { readonly kind: string } | undefined }[]; }): boolean { return ( splice.deleteCount > 0 && splice.messages.some((message) => message.origin?.kind === 'compaction_summary') ); } function newerRestoredTask( existing: AgentTaskInfo, loaded: AgentTaskInfo, ): AgentTaskInfo { const existingTerminal = isAgentTaskTerminal(existing.status); const loadedTerminal = isAgentTaskTerminal(loaded.status); if (existingTerminal && !loadedTerminal) return existing; if (!existingTerminal && loadedTerminal) return loaded; if (existing.endedAt !== null && loaded.endedAt !== null) { return loaded.endedAt >= existing.endedAt ? loaded : existing; } if (existing.endedAt !== null) return existing; if (loaded.endedAt !== null) return loaded; return loaded; } type TaskNotificationOrigin = Pick; function isTaskOrigin(origin: unknown): origin is TaskNotificationOrigin { if (typeof origin !== 'object' || origin === null) return false; const value = origin as Record; return ( (value['kind'] === 'background_task' || value['kind'] === 'task') && typeof value['taskId'] === 'string' && typeof value['status'] === 'string' && typeof value['notificationId'] === 'string' ); } function taskNotificationId(taskId: string, status: string): string { return `task:${taskId}:${status}`; } function notificationKey(origin: TaskNotificationOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } function taskOriginFromMessage(message: unknown): TaskNotificationOrigin | undefined { if (typeof message !== 'object' || message === null) return undefined; const origin = (message as { readonly origin?: unknown }).origin; return isTaskOrigin(origin) ? origin : undefined; } function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { const baseLine = info.status === 'timed_out' ? `${info.description} timed out.` : info.status === 'killed' && isSerializedUserCancellation(info.stopReason) ? `${info.description} was stopped by user.` : info.stopReason ? `${info.description} ${info.status === 'killed' ? 'was stopped' : info.status}. Reason: ${info.stopReason}` : `${info.description} ${info.status}.`; if (info.kind !== 'agent') return baseLine; if (info.status === 'completed') return baseLine; const agentId = info.agentId; if (agentId === undefined || agentId === info.taskId) return baseLine; const recovery = [ '', `To recover or continue this subagent, call Agent(resume="${agentId}", prompt="Pick up where you left off; redo the last tool call if its result was never observed.").`, `Use agent_id ("${agentId}"), NOT source_id / task_id ("${info.taskId}") — the two look alike but only agent_id is accepted by the resume parameter.`, 'Add run_in_background=true to keep it backgrounded, or omit it to take the result inline in the current turn.', 'The subagent retains its full prior context across the restart, but any in-flight tool call lost its result and may need to be redone.', ].join('\n'); return `${baseLine}${recovery}`; } function buildAgentTaskNotification( info: AgentTaskInfo, output?: AgentTaskOutputSnapshot, ): AgentTaskNotification { const question = questionNotificationText(info, output); return { id: taskNotificationId(info.taskId, info.status), category: 'task', type: `task.${info.status}`, source_kind: 'background_task', source_id: info.taskId, agent_id: info.kind === 'agent' ? info.agentId : undefined, title: question?.title ?? `Background ${info.kind} ${info.status}`, severity: info.status === 'completed' ? 'info' : 'warning', body: question?.body ?? buildAgentTaskNotificationBody(info), children: agentTaskNotificationChildren(info, output), }; } function generateTaskId(kind: string): string { const bytes = randomBytes(8); let suffix = ''; for (let index = 0; index < 8; index++) { suffix += TASK_ID_ALPHABET[bytes[index]! % TASK_ID_ALPHABET.length]; } return `${kind}-${suffix}`; } function normalizeReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; } function isSerializedUserCancellation(reason: string | undefined): boolean { return reason === userCancellationReason().message; } function createForegroundRelease(): ForegroundRelease { let resolve!: (reason: ForegroundTaskReleaseReason) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } function previousSessionTaskLine(info: AgentTaskInfo): string { if (info.kind === 'agent' && info.agentId !== undefined) { return `- ${info.taskId} "${info.description}" (subagent) — resume it with Agent(resume="${info.agentId}", prompt="Pick up where you left off; redo the last tool call if its result was never observed.") to continue from its prior context.`; } return `- ${info.taskId} "${info.description}" (${info.kind === 'process' ? 'bash' : info.kind})`; } function isPreviousSessionTermination(info: AgentTaskInfo): boolean { if (info.status === 'lost') return true; return ( info.status === 'killed' && info.terminalNotificationSuppressed === true && info.stopReason === SESSION_CLOSED_REASON ); } registerScopedService( LifecycleScope.Agent, IAgentTaskService, AgentTaskService, ScopeActivation.OnScopeCreated, 'task', );