2025-12-11 15:45:37 +09:00
|
|
|
import type { PluginInput } from "@opencode-ai/plugin"
|
2026-02-01 16:47:50 +09:00
|
|
|
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
2026-01-25 15:34:10 +09:00
|
|
|
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
2026-02-01 16:47:50 +09:00
|
|
|
import {
|
|
|
|
|
TASK_TTL_MS,
|
|
|
|
|
MIN_STABILITY_TIME_MS,
|
|
|
|
|
DEFAULT_STALE_TIMEOUT_MS,
|
|
|
|
|
MIN_RUNTIME_BEFORE_STALE_MS,
|
|
|
|
|
MIN_IDLE_TIME_MS,
|
|
|
|
|
POLLING_INTERVAL_MS,
|
|
|
|
|
type ProcessCleanupEvent,
|
|
|
|
|
type OpencodeClient,
|
|
|
|
|
type MessagePartInfo,
|
|
|
|
|
type BackgroundEvent,
|
|
|
|
|
} from "./constants"
|
|
|
|
|
import { TaskStateManager } from "./state"
|
|
|
|
|
import { createTask, startTask, resumeTask, type SpawnerContext } from "./spawner"
|
|
|
|
|
import {
|
|
|
|
|
checkSessionTodos,
|
|
|
|
|
validateSessionHasOutput,
|
|
|
|
|
tryCompleteTask,
|
|
|
|
|
notifyParentSession,
|
|
|
|
|
type ResultHandlerContext,
|
|
|
|
|
} from "./result-handler"
|
|
|
|
|
import { log } from "../../shared"
|
|
|
|
|
import { ConcurrencyManager } from "./concurrency"
|
2025-12-16 23:01:48 +09:00
|
|
|
import { subagentSessions } from "../claude-code-session-state"
|
2026-01-09 02:24:43 +09:00
|
|
|
import { getTaskToastManager } from "../task-toast-manager"
|
2026-01-18 14:29:46 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
export { type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./constants"
|
2026-01-26 12:02:37 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
type ProcessCleanupHandler = () => void
|
2026-01-26 12:02:37 +09:00
|
|
|
|
2025-12-11 15:45:37 +09:00
|
|
|
export class BackgroundManager {
|
2026-01-14 23:11:38 -08:00
|
|
|
private static cleanupManagers = new Set<BackgroundManager>()
|
|
|
|
|
private static cleanupRegistered = false
|
2026-02-01 16:47:50 +09:00
|
|
|
private static cleanupHandlers = new Map<ProcessCleanupEvent, ProcessCleanupHandler>()
|
2026-01-14 23:11:38 -08:00
|
|
|
|
2025-12-11 15:45:37 +09:00
|
|
|
private client: OpencodeClient
|
2025-12-11 18:13:02 +09:00
|
|
|
private directory: string
|
2025-12-27 23:06:44 +09:00
|
|
|
private pollingInterval?: ReturnType<typeof setInterval>
|
2026-01-07 01:24:47 +09:00
|
|
|
private concurrencyManager: ConcurrencyManager
|
2026-01-14 15:09:32 -08:00
|
|
|
private shutdownTriggered = false
|
2026-01-17 17:40:58 +09:00
|
|
|
private config?: BackgroundTaskConfig
|
2026-01-25 15:34:10 +09:00
|
|
|
private tmuxEnabled: boolean
|
2026-02-01 16:47:50 +09:00
|
|
|
private onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
2026-01-29 18:29:47 +09:00
|
|
|
private onShutdown?: () => void
|
2026-02-01 16:47:50 +09:00
|
|
|
private state: TaskStateManager
|
2026-01-18 14:29:46 +09:00
|
|
|
|
2026-01-25 15:34:10 +09:00
|
|
|
constructor(
|
|
|
|
|
ctx: PluginInput,
|
|
|
|
|
config?: BackgroundTaskConfig,
|
2026-01-26 12:02:37 +09:00
|
|
|
options?: {
|
|
|
|
|
tmuxConfig?: TmuxConfig
|
2026-02-01 16:47:50 +09:00
|
|
|
onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
2026-01-29 18:29:47 +09:00
|
|
|
onShutdown?: () => void
|
2026-01-26 12:02:37 +09:00
|
|
|
}
|
2026-01-25 15:34:10 +09:00
|
|
|
) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state = new TaskStateManager()
|
2025-12-11 18:13:02 +09:00
|
|
|
this.client = ctx.client
|
|
|
|
|
this.directory = ctx.directory
|
2026-01-07 01:24:47 +09:00
|
|
|
this.concurrencyManager = new ConcurrencyManager(config)
|
2026-01-17 17:40:58 +09:00
|
|
|
this.config = config
|
2026-01-26 12:02:37 +09:00
|
|
|
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
|
|
|
|
|
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
2026-01-29 18:29:47 +09:00
|
|
|
this.onShutdown = options?.onShutdown
|
2026-01-14 15:09:32 -08:00
|
|
|
this.registerProcessCleanup()
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
private getSpawnerContext(): SpawnerContext {
|
|
|
|
|
return {
|
|
|
|
|
client: this.client,
|
|
|
|
|
directory: this.directory,
|
|
|
|
|
concurrencyManager: this.concurrencyManager,
|
|
|
|
|
tmuxEnabled: this.tmuxEnabled,
|
|
|
|
|
onSubagentSessionCreated: this.onSubagentSessionCreated,
|
|
|
|
|
onTaskError: (task, error) => this.handleTaskError(task, error),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getResultHandlerContext(): ResultHandlerContext {
|
|
|
|
|
return {
|
|
|
|
|
client: this.client,
|
|
|
|
|
concurrencyManager: this.concurrencyManager,
|
|
|
|
|
state: this.state,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleTaskError(task: BackgroundTask, error: Error): void {
|
|
|
|
|
const existingTask = this.state.findBySession(task.sessionID ?? "")
|
|
|
|
|
if (existingTask) {
|
|
|
|
|
existingTask.status = "error"
|
|
|
|
|
const errorMessage = error.message
|
|
|
|
|
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
|
|
|
|
existingTask.error = `Agent "${task.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
|
|
|
|
|
} else {
|
|
|
|
|
existingTask.error = errorMessage
|
|
|
|
|
}
|
|
|
|
|
existingTask.completedAt = new Date()
|
|
|
|
|
if (existingTask.concurrencyKey) {
|
|
|
|
|
this.concurrencyManager.release(existingTask.concurrencyKey)
|
|
|
|
|
existingTask.concurrencyKey = undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.state.markForNotification(existingTask)
|
|
|
|
|
notifyParentSession(existingTask, this.getResultHandlerContext()).catch(err => {
|
|
|
|
|
log("[background-agent] Failed to notify on error:", err)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 15:45:37 +09:00
|
|
|
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
2026-01-10 13:00:25 +08:00
|
|
|
log("[background-agent] launch() called with:", {
|
|
|
|
|
agent: input.agent,
|
|
|
|
|
model: input.model,
|
|
|
|
|
description: input.description,
|
|
|
|
|
parentSessionID: input.parentSessionID,
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-14 01:22:28 +09:00
|
|
|
if (!input.agent || input.agent.trim() === "") {
|
|
|
|
|
throw new Error("Agent parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const task = createTask(input)
|
|
|
|
|
this.state.addTask(task)
|
2026-01-18 14:33:42 +09:00
|
|
|
|
2026-01-18 14:39:11 +09:00
|
|
|
if (input.parentSessionID) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.trackPendingTask(input.parentSessionID, task.id)
|
2026-01-18 14:39:11 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const key = this.state.getConcurrencyKeyFromInput(input)
|
|
|
|
|
this.state.addToQueue(key, { task, input })
|
2026-01-18 14:33:42 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: this.state.getQueue(key)?.length ?? 0 })
|
2026-01-18 14:33:42 +09:00
|
|
|
|
2026-01-19 10:22:34 +09:00
|
|
|
const toastManager = getTaskToastManager()
|
|
|
|
|
if (toastManager) {
|
|
|
|
|
toastManager.addTask({
|
|
|
|
|
id: task.id,
|
|
|
|
|
description: input.description,
|
|
|
|
|
agent: input.agent,
|
|
|
|
|
isBackground: true,
|
|
|
|
|
status: "queued",
|
|
|
|
|
skills: input.skills,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 14:33:42 +09:00
|
|
|
this.processKey(key)
|
|
|
|
|
|
|
|
|
|
return task
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async processKey(key: string): Promise<void> {
|
2026-02-01 16:47:50 +09:00
|
|
|
if (this.state.processingKeys.has(key)) {
|
2026-01-18 14:36:06 +09:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.processingKeys.add(key)
|
2026-01-18 14:36:06 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-02-01 16:47:50 +09:00
|
|
|
const queue = this.state.getQueue(key)
|
2026-01-18 14:36:06 +09:00
|
|
|
while (queue && queue.length > 0) {
|
|
|
|
|
const item = queue[0]
|
|
|
|
|
|
|
|
|
|
await this.concurrencyManager.acquire(key)
|
|
|
|
|
|
|
|
|
|
if (item.task.status === "cancelled") {
|
|
|
|
|
this.concurrencyManager.release(key)
|
|
|
|
|
queue.shift()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-01 16:47:50 +09:00
|
|
|
await startTask(item, this.getSpawnerContext())
|
|
|
|
|
this.startPolling()
|
2026-01-18 14:36:06 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
log("[background-agent] Error starting task:", error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
queue.shift()
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.processingKeys.delete(key)
|
2026-01-09 02:24:43 +09:00
|
|
|
}
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTask(id: string): BackgroundTask | undefined {
|
2026-02-01 16:47:50 +09:00
|
|
|
return this.state.getTask(id)
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
2026-02-01 16:47:50 +09:00
|
|
|
return this.state.getTasksByParentSession(sessionID)
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-19 01:56:38 +09:00
|
|
|
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
|
2026-02-01 16:47:50 +09:00
|
|
|
return this.state.getAllDescendantTasks(sessionID)
|
2025-12-19 01:56:38 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-11 15:45:37 +09:00
|
|
|
findBySession(sessionID: string): BackgroundTask | undefined {
|
2026-02-01 16:47:50 +09:00
|
|
|
return this.state.findBySession(sessionID)
|
2026-01-18 14:29:46 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-15 10:53:08 -08:00
|
|
|
async trackTask(input: {
|
2026-01-09 02:24:43 +09:00
|
|
|
taskId: string
|
|
|
|
|
sessionID: string
|
|
|
|
|
parentSessionID: string
|
|
|
|
|
description: string
|
|
|
|
|
agent?: string
|
2026-01-09 15:53:36 +09:00
|
|
|
parentAgent?: string
|
2026-01-14 15:09:32 -08:00
|
|
|
concurrencyKey?: string
|
|
|
|
|
}): Promise<BackgroundTask> {
|
2026-02-01 16:47:50 +09:00
|
|
|
const existingTask = this.state.getTask(input.taskId)
|
2026-01-14 22:40:16 -08:00
|
|
|
if (existingTask) {
|
2026-01-15 00:16:35 -08:00
|
|
|
const parentChanged = input.parentSessionID !== existingTask.parentSessionID
|
|
|
|
|
if (parentChanged) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.cleanupPendingByParent(existingTask)
|
2026-01-14 22:40:16 -08:00
|
|
|
existingTask.parentSessionID = input.parentSessionID
|
|
|
|
|
}
|
|
|
|
|
if (input.parentAgent !== undefined) {
|
|
|
|
|
existingTask.parentAgent = input.parentAgent
|
|
|
|
|
}
|
|
|
|
|
if (!existingTask.concurrencyGroup) {
|
|
|
|
|
existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 10:35:47 +09:00
|
|
|
if (existingTask.sessionID) {
|
|
|
|
|
subagentSessions.add(existingTask.sessionID)
|
|
|
|
|
}
|
2026-01-14 22:40:16 -08:00
|
|
|
this.startPolling()
|
|
|
|
|
|
2026-01-18 14:39:11 +09:00
|
|
|
if (existingTask.status === "pending" || existingTask.status === "running") {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.trackPendingTask(input.parentSessionID, existingTask.id)
|
2026-01-15 00:16:35 -08:00
|
|
|
} else if (!parentChanged) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.cleanupPendingByParent(existingTask)
|
2026-01-14 23:51:19 -08:00
|
|
|
}
|
2026-01-14 22:40:16 -08:00
|
|
|
|
2026-01-14 23:51:19 -08:00
|
|
|
log("[background-agent] External task already registered:", { taskId: existingTask.id, sessionID: existingTask.sessionID, status: existingTask.status })
|
2026-01-14 22:40:16 -08:00
|
|
|
|
|
|
|
|
return existingTask
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 17:34:40 +09:00
|
|
|
const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "delegate_task"
|
2026-01-14 22:40:16 -08:00
|
|
|
|
2026-01-14 15:09:32 -08:00
|
|
|
if (input.concurrencyKey) {
|
|
|
|
|
await this.concurrencyManager.acquire(input.concurrencyKey)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
const task: BackgroundTask = {
|
|
|
|
|
id: input.taskId,
|
|
|
|
|
sessionID: input.sessionID,
|
|
|
|
|
parentSessionID: input.parentSessionID,
|
|
|
|
|
parentMessageID: "",
|
|
|
|
|
description: input.description,
|
|
|
|
|
prompt: "",
|
2026-01-16 17:34:40 +09:00
|
|
|
agent: input.agent || "delegate_task",
|
2026-01-09 02:24:43 +09:00
|
|
|
status: "running",
|
|
|
|
|
startedAt: new Date(),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 0,
|
|
|
|
|
lastUpdate: new Date(),
|
|
|
|
|
},
|
2026-01-09 15:53:36 +09:00
|
|
|
parentAgent: input.parentAgent,
|
2026-01-14 15:09:32 -08:00
|
|
|
concurrencyKey: input.concurrencyKey,
|
2026-01-14 22:40:16 -08:00
|
|
|
concurrencyGroup,
|
2026-01-09 02:24:43 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.addTask(task)
|
2026-01-09 02:24:43 +09:00
|
|
|
subagentSessions.add(input.sessionID)
|
|
|
|
|
this.startPolling()
|
|
|
|
|
|
2026-01-18 14:39:11 +09:00
|
|
|
if (input.parentSessionID) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.trackPendingTask(input.parentSessionID, task.id)
|
2026-01-18 14:39:11 +09:00
|
|
|
}
|
2026-01-10 13:00:25 +08:00
|
|
|
|
2026-01-09 02:24:43 +09:00
|
|
|
log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
|
|
|
|
|
|
|
|
|
|
return task
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async resume(input: ResumeInput): Promise<BackgroundTask> {
|
2026-02-01 16:47:50 +09:00
|
|
|
const existingTask = this.state.findBySession(input.sessionId)
|
2026-01-09 02:24:43 +09:00
|
|
|
if (!existingTask) {
|
|
|
|
|
throw new Error(`Task not found for session: ${input.sessionId}`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
await resumeTask(existingTask, input, {
|
|
|
|
|
client: this.client,
|
|
|
|
|
concurrencyManager: this.concurrencyManager,
|
|
|
|
|
onTaskError: (task, error) => this.handleTaskError(task, error),
|
|
|
|
|
})
|
2026-01-09 02:24:43 +09:00
|
|
|
|
|
|
|
|
this.startPolling()
|
2026-01-19 10:35:47 +09:00
|
|
|
if (existingTask.sessionID) {
|
|
|
|
|
subagentSessions.add(existingTask.sessionID)
|
|
|
|
|
}
|
2026-01-09 02:24:43 +09:00
|
|
|
|
2026-01-18 14:39:11 +09:00
|
|
|
if (input.parentSessionID) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.trackPendingTask(input.parentSessionID, existingTask.id)
|
2026-01-09 02:24:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return existingTask
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
handleEvent(event: BackgroundEvent): void {
|
2025-12-11 15:45:37 +09:00
|
|
|
const props = event.properties
|
|
|
|
|
|
|
|
|
|
if (event.type === "message.part.updated") {
|
2025-12-11 16:56:16 +09:00
|
|
|
if (!props || typeof props !== "object" || !("sessionID" in props)) return
|
2025-12-11 15:45:37 +09:00
|
|
|
const partInfo = props as unknown as MessagePartInfo
|
|
|
|
|
const sessionID = partInfo?.sessionID
|
|
|
|
|
if (!sessionID) return
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const task = this.state.findBySession(sessionID)
|
2025-12-11 15:45:37 +09:00
|
|
|
if (!task) return
|
|
|
|
|
|
|
|
|
|
if (partInfo?.type === "tool" || partInfo?.tool) {
|
|
|
|
|
if (!task.progress) {
|
|
|
|
|
task.progress = {
|
|
|
|
|
toolCalls: 0,
|
|
|
|
|
lastUpdate: new Date(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
task.progress.toolCalls += 1
|
|
|
|
|
task.progress.lastTool = partInfo.tool
|
|
|
|
|
task.progress.lastUpdate = new Date()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 17:42:33 +09:00
|
|
|
if (event.type === "session.idle") {
|
|
|
|
|
const sessionID = props?.sessionID as string | undefined
|
|
|
|
|
if (!sessionID) return
|
2025-12-11 15:45:37 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const task = this.state.findBySession(sessionID)
|
2025-12-11 17:42:33 +09:00
|
|
|
if (!task || task.status !== "running") return
|
2026-01-19 10:35:47 +09:00
|
|
|
|
|
|
|
|
const startedAt = task.startedAt
|
|
|
|
|
if (!startedAt) return
|
2025-12-11 15:45:37 +09:00
|
|
|
|
2026-01-19 10:35:47 +09:00
|
|
|
const elapsedMs = Date.now() - startedAt.getTime()
|
2026-01-10 13:00:25 +08:00
|
|
|
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
|
|
|
|
log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
validateSessionHasOutput(this.client, sessionID).then(async (hasValidOutput) => {
|
2026-01-14 15:09:32 -08:00
|
|
|
if (task.status !== "running") {
|
|
|
|
|
log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 13:00:25 +08:00
|
|
|
if (!hasValidOutput) {
|
|
|
|
|
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const hasIncompleteTodos = await checkSessionTodos(this.client, sessionID)
|
2026-01-14 15:09:32 -08:00
|
|
|
|
|
|
|
|
if (task.status !== "running") {
|
|
|
|
|
log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 23:54:59 +09:00
|
|
|
if (hasIncompleteTodos) {
|
|
|
|
|
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
await tryCompleteTask(task, "session.idle event", this.getResultHandlerContext())
|
2026-01-10 13:00:25 +08:00
|
|
|
}).catch(err => {
|
|
|
|
|
log("[background-agent] Error in session.idle handler:", err)
|
2025-12-15 23:54:59 +09:00
|
|
|
})
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.type === "session.deleted") {
|
2025-12-11 17:42:33 +09:00
|
|
|
const info = props?.info
|
2025-12-11 16:56:16 +09:00
|
|
|
if (!info || typeof info.id !== "string") return
|
|
|
|
|
const sessionID = info.id
|
2025-12-11 15:45:37 +09:00
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const task = this.state.findBySession(sessionID)
|
2025-12-11 15:45:37 +09:00
|
|
|
if (!task) return
|
|
|
|
|
|
|
|
|
|
if (task.status === "running") {
|
|
|
|
|
task.status = "cancelled"
|
|
|
|
|
task.completedAt = new Date()
|
2025-12-11 17:42:33 +09:00
|
|
|
task.error = "Session deleted"
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
if (task.concurrencyKey) {
|
|
|
|
|
this.concurrencyManager.release(task.concurrencyKey)
|
|
|
|
|
task.concurrencyKey = undefined
|
2026-01-31 16:26:01 +09:00
|
|
|
}
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.clearCompletionTimer(task.id)
|
|
|
|
|
this.state.cleanupPendingByParent(task)
|
|
|
|
|
this.state.removeTask(task.id)
|
|
|
|
|
this.state.clearNotificationsForTask(task.id)
|
2025-12-16 23:01:48 +09:00
|
|
|
subagentSessions.delete(sessionID)
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
markForNotification(task: BackgroundTask): void {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.markForNotification(task)
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getPendingNotifications(sessionID: string): BackgroundTask[] {
|
2026-02-01 16:47:50 +09:00
|
|
|
return this.state.getPendingNotifications(sessionID)
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearNotifications(sessionID: string): void {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.clearNotifications(sessionID)
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
cancelPendingTask(taskId: string): boolean {
|
|
|
|
|
return this.state.cancelPendingTask(taskId)
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
getRunningTasks(): BackgroundTask[] {
|
|
|
|
|
return this.state.getRunningTasks()
|
2026-01-14 14:08:53 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
getCompletedTasks(): BackgroundTask[] {
|
|
|
|
|
return this.state.getCompletedTasks()
|
2026-01-19 10:18:10 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-11 17:12:45 +09:00
|
|
|
private startPolling(): void {
|
|
|
|
|
if (this.pollingInterval) return
|
|
|
|
|
|
|
|
|
|
this.pollingInterval = setInterval(() => {
|
|
|
|
|
this.pollRunningTasks()
|
2026-02-01 16:47:50 +09:00
|
|
|
}, POLLING_INTERVAL_MS)
|
2025-12-27 23:06:44 +09:00
|
|
|
this.pollingInterval.unref()
|
2025-12-11 17:12:45 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private stopPolling(): void {
|
|
|
|
|
if (this.pollingInterval) {
|
|
|
|
|
clearInterval(this.pollingInterval)
|
|
|
|
|
this.pollingInterval = undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 15:09:32 -08:00
|
|
|
private registerProcessCleanup(): void {
|
2026-01-14 23:11:38 -08:00
|
|
|
BackgroundManager.cleanupManagers.add(this)
|
2026-01-14 15:09:32 -08:00
|
|
|
|
2026-01-14 23:11:38 -08:00
|
|
|
if (BackgroundManager.cleanupRegistered) return
|
|
|
|
|
BackgroundManager.cleanupRegistered = true
|
|
|
|
|
|
|
|
|
|
const cleanupAll = () => {
|
|
|
|
|
for (const manager of BackgroundManager.cleanupManagers) {
|
|
|
|
|
try {
|
|
|
|
|
manager.shutdown()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log("[background-agent] Error during shutdown cleanup:", error)
|
|
|
|
|
}
|
2026-01-14 15:09:32 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 23:11:38 -08:00
|
|
|
const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => {
|
|
|
|
|
const listener = registerProcessSignal(signal, cleanupAll, exitAfter)
|
|
|
|
|
BackgroundManager.cleanupHandlers.set(signal, listener)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerSignal("SIGINT", true)
|
|
|
|
|
registerSignal("SIGTERM", true)
|
2026-01-14 15:09:32 -08:00
|
|
|
if (process.platform === "win32") {
|
2026-01-14 23:11:38 -08:00
|
|
|
registerSignal("SIGBREAK", true)
|
2026-01-14 15:09:32 -08:00
|
|
|
}
|
2026-01-14 23:11:38 -08:00
|
|
|
registerSignal("beforeExit", false)
|
|
|
|
|
registerSignal("exit", false)
|
2026-01-10 13:00:25 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-14 23:11:38 -08:00
|
|
|
private unregisterProcessCleanup(): void {
|
|
|
|
|
BackgroundManager.cleanupManagers.delete(this)
|
|
|
|
|
|
|
|
|
|
if (BackgroundManager.cleanupManagers.size > 0) return
|
|
|
|
|
|
|
|
|
|
for (const [signal, listener] of BackgroundManager.cleanupHandlers.entries()) {
|
|
|
|
|
process.off(signal, listener)
|
|
|
|
|
}
|
|
|
|
|
BackgroundManager.cleanupHandlers.clear()
|
|
|
|
|
BackgroundManager.cleanupRegistered = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 22:25:49 +09:00
|
|
|
private pruneStaleTasksAndNotifications(): void {
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
for (const [taskId, task] of this.state.tasks.entries()) {
|
2026-01-19 10:19:48 +09:00
|
|
|
const timestamp = task.status === "pending"
|
|
|
|
|
? task.queuedAt?.getTime()
|
|
|
|
|
: task.startedAt?.getTime()
|
|
|
|
|
|
|
|
|
|
if (!timestamp) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const age = now - timestamp
|
2026-01-02 22:25:49 +09:00
|
|
|
if (age > TASK_TTL_MS) {
|
2026-01-19 10:19:48 +09:00
|
|
|
const errorMessage = task.status === "pending"
|
|
|
|
|
? "Task timed out while queued (30 minutes)"
|
|
|
|
|
: "Task timed out after 30 minutes"
|
|
|
|
|
|
|
|
|
|
log("[background-agent] Pruning stale task:", { taskId, status: task.status, age: Math.round(age / 1000) + "s" })
|
2026-01-02 22:25:49 +09:00
|
|
|
task.status = "error"
|
2026-01-19 10:19:48 +09:00
|
|
|
task.error = errorMessage
|
2026-01-02 22:25:49 +09:00
|
|
|
task.completedAt = new Date()
|
2026-01-09 02:24:43 +09:00
|
|
|
if (task.concurrencyKey) {
|
|
|
|
|
this.concurrencyManager.release(task.concurrencyKey)
|
2026-01-14 15:09:32 -08:00
|
|
|
task.concurrencyKey = undefined
|
2026-01-07 01:24:47 +09:00
|
|
|
}
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.cleanupPendingByParent(task)
|
|
|
|
|
this.state.clearNotificationsForTask(taskId)
|
|
|
|
|
this.state.removeTask(taskId)
|
2026-01-02 22:25:49 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
for (const [sessionID, notifications] of this.state.notifications.entries()) {
|
2026-01-02 22:25:49 +09:00
|
|
|
if (notifications.length === 0) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.notifications.delete(sessionID)
|
2026-01-02 22:25:49 +09:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
const validNotifications = notifications.filter((task) => {
|
2026-01-19 10:35:47 +09:00
|
|
|
if (!task.startedAt) return false
|
2026-01-02 22:25:49 +09:00
|
|
|
const age = now - task.startedAt.getTime()
|
|
|
|
|
return age <= TASK_TTL_MS
|
|
|
|
|
})
|
|
|
|
|
if (validNotifications.length === 0) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.notifications.delete(sessionID)
|
2026-01-02 22:25:49 +09:00
|
|
|
} else if (validNotifications.length !== notifications.length) {
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.notifications.set(sessionID, validNotifications)
|
2026-01-02 22:25:49 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 17:40:58 +09:00
|
|
|
private async checkAndInterruptStaleTasks(): Promise<void> {
|
|
|
|
|
const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
for (const task of this.state.tasks.values()) {
|
2026-01-17 17:40:58 +09:00
|
|
|
if (task.status !== "running") continue
|
|
|
|
|
if (!task.progress?.lastUpdate) continue
|
2026-01-19 10:35:47 +09:00
|
|
|
|
|
|
|
|
const startedAt = task.startedAt
|
|
|
|
|
const sessionID = task.sessionID
|
|
|
|
|
if (!startedAt || !sessionID) continue
|
2026-01-17 17:40:58 +09:00
|
|
|
|
2026-01-19 10:35:47 +09:00
|
|
|
const runtime = now - startedAt.getTime()
|
2026-01-17 17:40:58 +09:00
|
|
|
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
|
|
|
|
|
|
|
|
|
|
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
|
|
|
|
|
if (timeSinceLastUpdate <= staleTimeoutMs) continue
|
|
|
|
|
|
|
|
|
|
if (task.status !== "running") continue
|
|
|
|
|
|
|
|
|
|
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
|
|
|
|
|
task.status = "cancelled"
|
|
|
|
|
task.error = `Stale timeout (no activity for ${staleMinutes}min)`
|
|
|
|
|
task.completedAt = new Date()
|
|
|
|
|
|
|
|
|
|
if (task.concurrencyKey) {
|
|
|
|
|
this.concurrencyManager.release(task.concurrencyKey)
|
|
|
|
|
task.concurrencyKey = undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.client.session.abort({
|
2026-01-19 10:35:47 +09:00
|
|
|
path: { id: sessionID },
|
2026-01-17 17:40:58 +09:00
|
|
|
}).catch(() => {})
|
|
|
|
|
|
|
|
|
|
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-01 16:47:50 +09:00
|
|
|
await notifyParentSession(task, this.getResultHandlerContext())
|
2026-01-17 17:40:58 +09:00
|
|
|
} catch (err) {
|
|
|
|
|
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 17:12:45 +09:00
|
|
|
private async pollRunningTasks(): Promise<void> {
|
2026-01-02 22:25:49 +09:00
|
|
|
this.pruneStaleTasksAndNotifications()
|
2026-01-17 17:40:58 +09:00
|
|
|
await this.checkAndInterruptStaleTasks()
|
2026-01-02 22:25:49 +09:00
|
|
|
|
2025-12-11 17:38:01 +09:00
|
|
|
const statusResult = await this.client.session.status()
|
|
|
|
|
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
for (const task of this.state.tasks.values()) {
|
2025-12-11 17:12:45 +09:00
|
|
|
if (task.status !== "running") continue
|
2026-01-19 10:35:47 +09:00
|
|
|
|
|
|
|
|
const sessionID = task.sessionID
|
|
|
|
|
if (!sessionID) continue
|
2025-12-11 17:12:45 +09:00
|
|
|
|
2026-01-14 15:09:32 -08:00
|
|
|
try {
|
2026-01-19 10:35:47 +09:00
|
|
|
const sessionStatus = allStatuses[sessionID]
|
2025-12-11 17:38:01 +09:00
|
|
|
|
2026-01-10 13:00:25 +08:00
|
|
|
if (sessionStatus?.type === "idle") {
|
2026-02-01 16:47:50 +09:00
|
|
|
const hasValidOutput = await validateSessionHasOutput(this.client, sessionID)
|
2026-01-10 13:00:25 +08:00
|
|
|
if (!hasValidOutput) {
|
|
|
|
|
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-12-11 17:12:45 +09:00
|
|
|
|
2026-01-14 15:09:32 -08:00
|
|
|
if (task.status !== "running") continue
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const hasIncompleteTodos = await checkSessionTodos(this.client, sessionID)
|
2025-12-15 23:54:59 +09:00
|
|
|
if (hasIncompleteTodos) {
|
|
|
|
|
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
await tryCompleteTask(task, "polling (idle status)", this.getResultHandlerContext())
|
2025-12-11 17:12:45 +09:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messagesResult = await this.client.session.messages({
|
2026-01-19 10:35:47 +09:00
|
|
|
path: { id: sessionID },
|
2025-12-11 17:12:45 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!messagesResult.error && messagesResult.data) {
|
|
|
|
|
const messages = messagesResult.data as Array<{
|
|
|
|
|
info?: { role?: string }
|
2025-12-13 13:05:12 +09:00
|
|
|
parts?: Array<{ type?: string; tool?: string; name?: string; text?: string }>
|
2025-12-11 17:12:45 +09:00
|
|
|
}>
|
|
|
|
|
const assistantMsgs = messages.filter(
|
|
|
|
|
(m) => m.info?.role === "assistant"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
let toolCalls = 0
|
|
|
|
|
let lastTool: string | undefined
|
2025-12-13 13:05:12 +09:00
|
|
|
let lastMessage: string | undefined
|
2025-12-11 17:12:45 +09:00
|
|
|
|
|
|
|
|
for (const msg of assistantMsgs) {
|
|
|
|
|
const parts = msg.parts ?? []
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (part.type === "tool_use" || part.tool) {
|
|
|
|
|
toolCalls++
|
|
|
|
|
lastTool = part.tool || part.name || "unknown"
|
|
|
|
|
}
|
2025-12-13 13:05:12 +09:00
|
|
|
if (part.type === "text" && part.text) {
|
|
|
|
|
lastMessage = part.text
|
|
|
|
|
}
|
2025-12-11 17:12:45 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 17:23:40 +09:00
|
|
|
if (!task.progress) {
|
|
|
|
|
task.progress = { toolCalls: 0, lastUpdate: new Date() }
|
2025-12-11 17:12:45 +09:00
|
|
|
}
|
2025-12-11 17:23:40 +09:00
|
|
|
task.progress.toolCalls = toolCalls
|
|
|
|
|
task.progress.lastTool = lastTool
|
|
|
|
|
task.progress.lastUpdate = new Date()
|
2026-01-14 15:09:32 -08:00
|
|
|
if (lastMessage) {
|
2025-12-13 13:05:12 +09:00
|
|
|
task.progress.lastMessage = lastMessage
|
|
|
|
|
task.progress.lastMessageAt = new Date()
|
|
|
|
|
}
|
2026-01-10 13:00:25 +08:00
|
|
|
|
|
|
|
|
const currentMsgCount = messages.length
|
2026-01-19 10:35:47 +09:00
|
|
|
const startedAt = task.startedAt
|
|
|
|
|
if (!startedAt) continue
|
|
|
|
|
|
|
|
|
|
const elapsedMs = Date.now() - startedAt.getTime()
|
2026-01-10 13:00:25 +08:00
|
|
|
|
|
|
|
|
if (elapsedMs >= MIN_STABILITY_TIME_MS) {
|
|
|
|
|
if (task.lastMsgCount === currentMsgCount) {
|
|
|
|
|
task.stablePolls = (task.stablePolls ?? 0) + 1
|
|
|
|
|
if (task.stablePolls >= 3) {
|
2026-01-19 14:34:15 +09:00
|
|
|
const recheckStatus = await this.client.session.status()
|
|
|
|
|
const recheckData = (recheckStatus.data ?? {}) as Record<string, { type: string }>
|
|
|
|
|
const currentStatus = recheckData[sessionID]
|
|
|
|
|
|
|
|
|
|
if (currentStatus?.type !== "idle") {
|
|
|
|
|
log("[background-agent] Stability reached but session not idle, resetting:", {
|
|
|
|
|
taskId: task.id,
|
|
|
|
|
sessionStatus: currentStatus?.type ?? "not_in_status"
|
|
|
|
|
})
|
|
|
|
|
task.stablePolls = 0
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const hasValidOutput = await validateSessionHasOutput(this.client, sessionID)
|
2026-01-10 13:00:25 +08:00
|
|
|
if (!hasValidOutput) {
|
|
|
|
|
log("[background-agent] Stability reached but no valid output, waiting:", task.id)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 15:09:32 -08:00
|
|
|
if (task.status !== "running") continue
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
const hasIncompleteTodos = await checkSessionTodos(this.client, sessionID)
|
2026-01-10 13:00:25 +08:00
|
|
|
if (!hasIncompleteTodos) {
|
2026-02-01 16:47:50 +09:00
|
|
|
await tryCompleteTask(task, "stability detection", this.getResultHandlerContext())
|
2026-01-10 13:00:25 +08:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
task.stablePolls = 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
task.lastMsgCount = currentMsgCount
|
2025-12-11 17:12:45 +09:00
|
|
|
}
|
2025-12-11 17:42:33 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
2025-12-11 17:12:45 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
if (!this.state.hasRunningTasks()) {
|
2025-12-11 17:12:45 +09:00
|
|
|
this.stopPolling()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-14 15:09:32 -08:00
|
|
|
|
|
|
|
|
shutdown(): void {
|
|
|
|
|
if (this.shutdownTriggered) return
|
|
|
|
|
this.shutdownTriggered = true
|
|
|
|
|
log("[background-agent] Shutting down BackgroundManager")
|
|
|
|
|
this.stopPolling()
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
for (const task of this.state.tasks.values()) {
|
2026-01-29 18:29:47 +09:00
|
|
|
if (task.status === "running" && task.sessionID) {
|
|
|
|
|
this.client.session.abort({
|
|
|
|
|
path: { id: task.sessionID },
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.onShutdown) {
|
|
|
|
|
try {
|
|
|
|
|
this.onShutdown()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log("[background-agent] Error in onShutdown callback:", error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
for (const task of this.state.tasks.values()) {
|
2026-01-14 15:09:32 -08:00
|
|
|
if (task.concurrencyKey) {
|
|
|
|
|
this.concurrencyManager.release(task.concurrencyKey)
|
|
|
|
|
task.concurrencyKey = undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 16:47:50 +09:00
|
|
|
this.state.clear()
|
2026-01-14 15:09:32 -08:00
|
|
|
this.concurrencyManager.clear()
|
2026-01-14 23:11:38 -08:00
|
|
|
this.unregisterProcessCleanup()
|
2026-01-14 15:09:32 -08:00
|
|
|
log("[background-agent] Shutdown complete")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function registerProcessSignal(
|
2026-01-14 23:11:38 -08:00
|
|
|
signal: ProcessCleanupEvent,
|
|
|
|
|
handler: () => void,
|
|
|
|
|
exitAfter: boolean
|
|
|
|
|
): () => void {
|
|
|
|
|
const listener = () => {
|
2026-01-14 15:09:32 -08:00
|
|
|
handler()
|
2026-01-14 23:11:38 -08:00
|
|
|
if (exitAfter) {
|
2026-01-31 14:01:19 +07:00
|
|
|
process.exitCode = 0
|
|
|
|
|
setTimeout(() => process.exit(), 6000)
|
2026-01-14 23:11:38 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
process.on(signal, listener)
|
|
|
|
|
return listener
|
2025-12-11 15:45:37 +09:00
|
|
|
}
|