Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538aba0d0f | ||
|
|
97f7540600 | ||
|
|
462e2ec2b0 | ||
|
|
9acdd6b85d | ||
|
|
1fb6a7cc80 | ||
|
|
d3b79064c6 | ||
|
|
744dee70e9 | ||
|
|
0265fa6990 |
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.7.0",
|
||||
"oh-my-opencode-darwin-x64": "3.7.0",
|
||||
"oh-my-opencode-linux-arm64": "3.7.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.7.0",
|
||||
"oh-my-opencode-linux-x64": "3.7.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.7.0",
|
||||
"oh-my-opencode-windows-x64": "3.7.0"
|
||||
"oh-my-opencode-darwin-arm64": "3.7.1",
|
||||
"oh-my-opencode-darwin-x64": "3.7.1",
|
||||
"oh-my-opencode-linux-arm64": "3.7.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.7.1",
|
||||
"oh-my-opencode-linux-x64": "3.7.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.7.1",
|
||||
"oh-my-opencode-windows-x64": "3.7.1"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { cleanupTaskAfterSessionEnds } from "./session-task-cleanup"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
|
||||
type Event = { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key]
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export function handleBackgroundEvent(args: {
|
||||
event: Event
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
getAllDescendantTasks: (sessionID: string) => BackgroundTask[]
|
||||
releaseConcurrencyKey?: (key: string) => void
|
||||
cancelTask: (
|
||||
taskId: string,
|
||||
options: { source: string; reason: string; skipNotification: true }
|
||||
) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
tasks: Map<string, BackgroundTask>
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
emitIdleEvent: (sessionID: string) => void
|
||||
}): void {
|
||||
const {
|
||||
event,
|
||||
findBySession,
|
||||
getAllDescendantTasks,
|
||||
releaseConcurrencyKey,
|
||||
cancelTask,
|
||||
tryCompleteTask,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
tasks,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
emitIdleEvent,
|
||||
} = args
|
||||
|
||||
const props = event.properties
|
||||
|
||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const sessionID = getString(props, "sessionID")
|
||||
if (!sessionID) return
|
||||
|
||||
const task = findBySession(sessionID)
|
||||
if (!task) return
|
||||
|
||||
const existingTimer = idleDeferralTimers.get(task.id)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const type = getString(props, "type")
|
||||
const tool = getString(props, "tool")
|
||||
|
||||
if (!task.progress) {
|
||||
task.progress = { toolCalls: 0, lastUpdate: new Date() }
|
||||
}
|
||||
task.progress.lastUpdate = new Date()
|
||||
|
||||
if (type === "tool" || tool) {
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = tool
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
if (!props || !isRecord(props)) return
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const sessionID = getString(props, "sessionID")
|
||||
if (!sessionID) return
|
||||
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const errorRaw = props["error"]
|
||||
const dataRaw = isRecord(errorRaw) ? errorRaw["data"] : undefined
|
||||
const message =
|
||||
(isRecord(dataRaw) ? getString(dataRaw, "message") : undefined) ??
|
||||
(isRecord(errorRaw) ? getString(errorRaw, "message") : undefined) ??
|
||||
"Session error"
|
||||
|
||||
task.status = "error"
|
||||
task.error = message
|
||||
task.completedAt = new Date()
|
||||
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const infoRaw = props["info"]
|
||||
if (!isRecord(infoRaw)) return
|
||||
const sessionID = getString(infoRaw, "id")
|
||||
if (!sessionID) return
|
||||
|
||||
const tasksToCancel = new Map<string, BackgroundTask>()
|
||||
const directTask = findBySession(sessionID)
|
||||
if (directTask) {
|
||||
tasksToCancel.set(directTask.id, directTask)
|
||||
}
|
||||
for (const descendant of getAllDescendantTasks(sessionID)) {
|
||||
tasksToCancel.set(descendant.id, descendant)
|
||||
}
|
||||
if (tasksToCancel.size === 0) return
|
||||
|
||||
for (const task of tasksToCancel.values()) {
|
||||
if (task.status === "running" || task.status === "pending") {
|
||||
void cancelTask(task.id, {
|
||||
source: "session.deleted",
|
||||
reason: "Session deleted",
|
||||
skipNotification: true,
|
||||
}).catch((err) => {
|
||||
log("[background-agent] Failed to cancel task on session.deleted:", {
|
||||
taskId: task.id,
|
||||
error: err,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export function shutdownBackgroundManager(args: {
|
||||
shutdownTriggered: { value: boolean }
|
||||
stopPolling: () => void
|
||||
tasks: Map<string, BackgroundTask>
|
||||
client: PluginInput["client"]
|
||||
onShutdown?: () => void
|
||||
concurrencyManager: ConcurrencyManager
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
notifications: Map<string, BackgroundTask[]>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
processingKeys: Set<string>
|
||||
unregisterProcessCleanup: () => void
|
||||
}): void {
|
||||
const {
|
||||
shutdownTriggered,
|
||||
stopPolling,
|
||||
tasks,
|
||||
client,
|
||||
onShutdown,
|
||||
concurrencyManager,
|
||||
completionTimers,
|
||||
idleDeferralTimers,
|
||||
notifications,
|
||||
pendingByParent,
|
||||
queuesByKey,
|
||||
processingKeys,
|
||||
unregisterProcessCleanup,
|
||||
} = args
|
||||
|
||||
if (shutdownTriggered.value) return
|
||||
shutdownTriggered.value = true
|
||||
|
||||
log("[background-agent] Shutting down BackgroundManager")
|
||||
stopPolling()
|
||||
|
||||
for (const task of tasks.values()) {
|
||||
if (task.status === "running" && task.sessionID) {
|
||||
client.session.abort({ path: { id: task.sessionID } }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (onShutdown) {
|
||||
try {
|
||||
onShutdown()
|
||||
} catch (error) {
|
||||
log("[background-agent] Error in onShutdown callback:", error)
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasks.values()) {
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
}
|
||||
|
||||
for (const timer of completionTimers.values()) clearTimeout(timer)
|
||||
completionTimers.clear()
|
||||
|
||||
for (const timer of idleDeferralTimers.values()) clearTimeout(timer)
|
||||
idleDeferralTimers.clear()
|
||||
|
||||
concurrencyManager.clear()
|
||||
tasks.clear()
|
||||
notifications.clear()
|
||||
pendingByParent.clear()
|
||||
queuesByKey.clear()
|
||||
processingKeys.clear()
|
||||
unregisterProcessCleanup()
|
||||
|
||||
log("[background-agent] Shutdown complete")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
export function markForNotification(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
task: BackgroundTask
|
||||
): void {
|
||||
const queue = notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
export function getPendingNotifications(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
sessionID: string
|
||||
): BackgroundTask[] {
|
||||
return notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
export function clearNotifications(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
sessionID: string
|
||||
): void {
|
||||
notifications.delete(sessionID)
|
||||
}
|
||||
|
||||
export function clearNotificationsForTask(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
taskId: string
|
||||
): void {
|
||||
for (const [sessionID, tasks] of notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
notifications.delete(sessionID)
|
||||
} else {
|
||||
notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupPendingByParent(
|
||||
pendingByParent: Map<string, Set<string>>,
|
||||
task: BackgroundTask
|
||||
): void {
|
||||
if (!task.parentSessionID) return
|
||||
const pending = pendingByParent.get(task.parentSessionID)
|
||||
if (!pending) return
|
||||
|
||||
pending.delete(task.id)
|
||||
if (pending.size === 0) {
|
||||
pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
import { findNearestMessageWithFields } from "../hook-message-injector"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { formatDuration } from "./format-duration"
|
||||
import { isAbortedSessionError } from "./error-classifier"
|
||||
import { getMessageDir } from "./message-dir"
|
||||
import { buildBackgroundTaskNotificationText } from "./notification-builder"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type AgentModel = { providerID: string; modelID: string }
|
||||
|
||||
type MessageInfo = {
|
||||
agent?: string
|
||||
model?: AgentModel
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function extractMessageInfo(message: unknown): MessageInfo {
|
||||
if (!isRecord(message)) return {}
|
||||
const info = message["info"]
|
||||
if (!isRecord(info)) return {}
|
||||
|
||||
const agent = typeof info["agent"] === "string" ? info["agent"] : undefined
|
||||
const modelObj = info["model"]
|
||||
if (isRecord(modelObj)) {
|
||||
const providerID = modelObj["providerID"]
|
||||
const modelID = modelObj["modelID"]
|
||||
if (typeof providerID === "string" && typeof modelID === "string") {
|
||||
return { agent, model: { providerID, modelID } }
|
||||
}
|
||||
}
|
||||
|
||||
const providerID = info["providerID"]
|
||||
const modelID = info["modelID"]
|
||||
if (typeof providerID === "string" && typeof modelID === "string") {
|
||||
return { agent, model: { providerID, modelID } }
|
||||
}
|
||||
|
||||
return { agent }
|
||||
}
|
||||
|
||||
export async function notifyParentSession(args: {
|
||||
task: BackgroundTask
|
||||
tasks: Map<string, BackgroundTask>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
client: OpencodeClient
|
||||
}): Promise<void> {
|
||||
const { task, tasks, pendingByParent, completionTimers, clearNotificationsForTask, client } = args
|
||||
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
log("[background-agent] notifyParentSession called for task:", task.id)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.showCompletionToast({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
const pendingSet = pendingByParent.get(task.parentSessionID)
|
||||
if (pendingSet) {
|
||||
pendingSet.delete(task.id)
|
||||
if (pendingSet.size === 0) {
|
||||
pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const allComplete = !pendingSet || pendingSet.size === 0
|
||||
const remainingCount = pendingSet?.size ?? 0
|
||||
|
||||
const completedTasks = allComplete
|
||||
? Array.from(tasks.values()).filter(
|
||||
(t) =>
|
||||
t.parentSessionID === task.parentSessionID &&
|
||||
t.status !== "running" &&
|
||||
t.status !== "pending"
|
||||
)
|
||||
: []
|
||||
|
||||
const notification = buildBackgroundTaskNotificationText({
|
||||
task,
|
||||
duration,
|
||||
allComplete,
|
||||
remainingCount,
|
||||
completedTasks,
|
||||
})
|
||||
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: AgentModel | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: task.parentSessionID },
|
||||
})
|
||||
const raw = normalizeSDKResponse(messagesResp, [] as unknown[])
|
||||
const messages = Array.isArray(raw) ? raw : []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const extracted = extractMessageInfo(messages[i])
|
||||
if (extracted.agent || extracted.model) {
|
||||
agent = extracted.agent ?? task.parentAgent
|
||||
model = extracted.model
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent ?? task.parentAgent
|
||||
model =
|
||||
currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
log("[background-agent] notifyParentSession context:", {
|
||||
taskId: task.id,
|
||||
resolvedAgent: agent,
|
||||
resolvedModel: model,
|
||||
})
|
||||
|
||||
try {
|
||||
await client.session.promptAsync({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(task.parentTools ? { tools: task.parentTools } : {}),
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
noReply: !allComplete,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
|
||||
if (!allComplete) return
|
||||
|
||||
for (const completedTask of completedTasks) {
|
||||
const taskId = completedTask.id
|
||||
const existingTimer = completionTimers.get(taskId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
completionTimers.delete(taskId)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
completionTimers.delete(taskId)
|
||||
if (tasks.has(taskId)) {
|
||||
clearNotificationsForTask(taskId)
|
||||
tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
}, TASK_CLEANUP_DELAY_MS)
|
||||
|
||||
completionTimers.set(taskId, timer)
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
import {
|
||||
MIN_STABILITY_TIME_MS,
|
||||
} from "./constants"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type SessionStatusMap = Record<string, { type: string }>
|
||||
|
||||
type MessagePart = {
|
||||
type?: string
|
||||
tool?: string
|
||||
name?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type SessionMessage = {
|
||||
info?: { role?: string }
|
||||
parts?: MessagePart[]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function asSessionMessages(value: unknown): SessionMessage[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isRecord) as SessionMessage[]
|
||||
}
|
||||
|
||||
export async function pollRunningTasks(args: {
|
||||
tasks: Iterable<BackgroundTask>
|
||||
client: OpencodeClient
|
||||
pruneStaleTasksAndNotifications: () => void
|
||||
checkAndInterruptStaleTasks: (statuses: Record<string, { type: string }>) => Promise<void>
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
hasRunningTasks: () => boolean
|
||||
stopPolling: () => void
|
||||
}): Promise<void> {
|
||||
const {
|
||||
tasks,
|
||||
client,
|
||||
pruneStaleTasksAndNotifications,
|
||||
checkAndInterruptStaleTasks,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
hasRunningTasks,
|
||||
stopPolling,
|
||||
} = args
|
||||
|
||||
pruneStaleTasksAndNotifications()
|
||||
|
||||
const statusResult = await client.session.status()
|
||||
const allStatuses = normalizeSDKResponse(statusResult, {} as SessionStatusMap)
|
||||
|
||||
await checkAndInterruptStaleTasks(allStatuses)
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const sessionID = task.sessionID
|
||||
if (!sessionID) continue
|
||||
|
||||
try {
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
if (sessionStatus?.type === "idle") {
|
||||
const hasValidOutput = await validateSessionHasOutput(sessionID)
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
await tryCompleteTask(task, "polling (idle status)")
|
||||
continue
|
||||
}
|
||||
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
if ((messagesResult as { error?: unknown }).error) {
|
||||
continue
|
||||
}
|
||||
|
||||
const messages = asSessionMessages(normalizeSDKResponse(messagesResult, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
}))
|
||||
const assistantMsgs = messages.filter((m) => m.info?.role === "assistant")
|
||||
|
||||
let toolCalls = 0
|
||||
let lastTool: string | undefined
|
||||
let lastMessage: string | undefined
|
||||
|
||||
for (const msg of assistantMsgs) {
|
||||
const parts = msg.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "tool_use" || part.tool) {
|
||||
toolCalls += 1
|
||||
lastTool = part.tool || part.name || "unknown"
|
||||
}
|
||||
if (part.type === "text" && part.text) {
|
||||
lastMessage = part.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!task.progress) {
|
||||
task.progress = { toolCalls: 0, lastUpdate: new Date() }
|
||||
}
|
||||
task.progress.toolCalls = toolCalls
|
||||
task.progress.lastTool = lastTool
|
||||
task.progress.lastUpdate = new Date()
|
||||
if (lastMessage) {
|
||||
task.progress.lastMessage = lastMessage
|
||||
task.progress.lastMessageAt = new Date()
|
||||
}
|
||||
|
||||
const currentMsgCount = messages.length
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) continue
|
||||
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs >= MIN_STABILITY_TIME_MS) {
|
||||
if (task.lastMsgCount === currentMsgCount) {
|
||||
task.stablePolls = (task.stablePolls ?? 0) + 1
|
||||
if (task.stablePolls >= 3) {
|
||||
const recheckStatus = await client.session.status()
|
||||
const recheckData = normalizeSDKResponse(recheckStatus, {} as SessionStatusMap)
|
||||
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
|
||||
}
|
||||
|
||||
const hasValidOutput = await validateSessionHasOutput(sessionID)
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Stability reached but no valid output, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
if (!hasIncompleteTodos) {
|
||||
await tryCompleteTask(task, "stability detection")
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
task.stablePolls = 0
|
||||
}
|
||||
}
|
||||
|
||||
task.lastMsgCount = currentMsgCount
|
||||
} catch (error) {
|
||||
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRunningTasks()) {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
|
||||
|
||||
export function registerProcessSignal(
|
||||
signal: ProcessCleanupEvent,
|
||||
handler: () => void,
|
||||
exitAfter: boolean
|
||||
): () => void {
|
||||
const listener = () => {
|
||||
handler()
|
||||
if (exitAfter) {
|
||||
// Set exitCode and schedule exit after delay to allow other handlers to complete async cleanup
|
||||
// Use 6s delay to accommodate LSP cleanup (5s timeout + 1s SIGKILL wait)
|
||||
process.exitCode = 0
|
||||
setTimeout(() => process.exit(), 6000)
|
||||
}
|
||||
}
|
||||
process.on(signal, listener)
|
||||
return listener
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type Todo = {
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
id: string
|
||||
}
|
||||
|
||||
type SessionMessage = {
|
||||
info?: { role?: string }
|
||||
parts?: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function asSessionMessages(value: unknown): SessionMessage[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value as SessionMessage[]
|
||||
}
|
||||
|
||||
function asParts(value: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isRecord)
|
||||
}
|
||||
|
||||
function hasNonEmptyText(value: unknown): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
}
|
||||
|
||||
function isToolResultContentNonEmpty(content: unknown): boolean {
|
||||
if (typeof content === "string") return content.trim().length > 0
|
||||
if (Array.isArray(content)) return content.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a session has actual assistant/tool output before marking complete.
|
||||
* Prevents premature completion when session.idle fires before agent responds.
|
||||
*/
|
||||
export async function validateSessionHasOutput(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = asSessionMessages(normalizeSDKResponse(response, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
}))
|
||||
|
||||
const hasAssistantOrToolMessage = messages.some(
|
||||
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
if (!hasAssistantOrToolMessage) {
|
||||
log("[background-agent] No assistant/tool messages found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
const hasContent = messages.some((m) => {
|
||||
if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false
|
||||
|
||||
const parts = asParts(m.parts)
|
||||
return parts.some((part) => {
|
||||
const type = part.type
|
||||
if (type === "tool") return true
|
||||
if (type === "text" && hasNonEmptyText(part.text)) return true
|
||||
if (type === "reasoning" && hasNonEmptyText(part.text)) return true
|
||||
if (type === "tool_result" && isToolResultContentNonEmpty(part.content)) return true
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
if (!hasContent) {
|
||||
log("[background-agent] Messages exist but no content found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log("[background-agent] Error validating session output:", error)
|
||||
// On error, allow completion to proceed (don't block indefinitely)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSessionTodos(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.todo({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const todos = normalizeSDKResponse(response, [] as Todo[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
if (todos.length === 0) return false
|
||||
|
||||
const incomplete = todos.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||
)
|
||||
return incomplete.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import type { BackgroundTask, LaunchInput } from "../types"
|
||||
|
||||
export function createTask(input: LaunchInput): BackgroundTask {
|
||||
return {
|
||||
id: `bg_${randomUUID().slice(0, 8)}`,
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { BackgroundTask, ResumeInput } from "../types"
|
||||
import { log, getAgentToolRestrictions } from "../../../shared"
|
||||
import { setSessionTools } from "../../../shared/session-tools-store"
|
||||
import type { SpawnerContext } from "./spawner-context"
|
||||
import { subagentSessions } from "../../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../../task-toast-manager"
|
||||
|
||||
export async function resumeTask(
|
||||
task: BackgroundTask,
|
||||
input: ResumeInput,
|
||||
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
|
||||
): Promise<void> {
|
||||
const { client, concurrencyManager, onTaskError } = ctx
|
||||
|
||||
if (!task.sessionID) {
|
||||
throw new Error(`Task has no sessionID: ${task.id}`)
|
||||
}
|
||||
|
||||
if (task.status === "running") {
|
||||
log("[background-agent] Resume skipped - task already running:", {
|
||||
taskId: task.id,
|
||||
sessionID: task.sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const concurrencyKey = task.concurrencyGroup ?? task.agent
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
task.status = "running"
|
||||
task.completedAt = undefined
|
||||
task.error = undefined
|
||||
task.parentSessionID = input.parentSessionID
|
||||
task.parentMessageID = input.parentMessageID
|
||||
task.parentModel = input.parentModel
|
||||
task.parentAgent = input.parentAgent
|
||||
if (input.parentTools) {
|
||||
task.parentTools = input.parentTools
|
||||
}
|
||||
task.startedAt = new Date()
|
||||
|
||||
task.progress = {
|
||||
toolCalls: task.progress?.toolCalls ?? 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
subagentSessions.add(task.sessionID)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
agent: task.agent,
|
||||
isBackground: true,
|
||||
})
|
||||
}
|
||||
|
||||
log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionID })
|
||||
|
||||
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
|
||||
sessionID: task.sessionID,
|
||||
agent: task.agent,
|
||||
model: task.model,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
client.session
|
||||
.promptAsync({
|
||||
path: { id: task.sessionID },
|
||||
body: {
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(task.sessionID!, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { QueueItem } from "../constants"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../../shared"
|
||||
import { setSessionTools } from "../../../shared/session-tools-store"
|
||||
import { subagentSessions } from "../../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../../task-toast-manager"
|
||||
import { createBackgroundSession } from "./background-session-creator"
|
||||
import { getConcurrencyKeyFromLaunchInput } from "./concurrency-key-from-launch-input"
|
||||
import { resolveParentDirectory } from "./parent-directory-resolver"
|
||||
import type { SpawnerContext } from "./spawner-context"
|
||||
import { maybeInvokeTmuxCallback } from "./tmux-callback-invoker"
|
||||
|
||||
export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise<void> {
|
||||
const { task, input } = item
|
||||
const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx
|
||||
|
||||
log("[background-agent] Starting task:", {
|
||||
taskId: task.id,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
})
|
||||
|
||||
const concurrencyKey = getConcurrencyKeyFromLaunchInput(input)
|
||||
const parentDirectory = await resolveParentDirectory({
|
||||
client,
|
||||
parentSessionID: input.parentSessionID,
|
||||
defaultDirectory: directory,
|
||||
})
|
||||
|
||||
const sessionID = await createBackgroundSession({
|
||||
client,
|
||||
input,
|
||||
parentDirectory,
|
||||
concurrencyManager,
|
||||
concurrencyKey,
|
||||
})
|
||||
subagentSessions.add(sessionID)
|
||||
|
||||
await maybeInvokeTmuxCallback({
|
||||
onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
title: input.description,
|
||||
})
|
||||
|
||||
task.status = "running"
|
||||
task.startedAt = new Date()
|
||||
task.sessionID = sessionID
|
||||
task.progress = {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.updateTask(task.id, "running")
|
||||
}
|
||||
|
||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
hasSkillContent: !!input.skillContent,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import { TASK_TTL_MS } from "./constants"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export function pruneStaleState(args: {
|
||||
tasks: Map<string, BackgroundTask>
|
||||
notifications: Map<string, BackgroundTask[]>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
}): void {
|
||||
const {
|
||||
tasks,
|
||||
notifications,
|
||||
queuesByKey,
|
||||
concurrencyManager,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
} = args
|
||||
|
||||
pruneStaleTasksAndNotifications({
|
||||
tasks,
|
||||
notifications,
|
||||
onTaskPruned: (taskId, task, errorMessage) => {
|
||||
const wasPending = task.status === "pending"
|
||||
const now = Date.now()
|
||||
const timestamp = task.status === "pending"
|
||||
? task.queuedAt?.getTime()
|
||||
: task.startedAt?.getTime()
|
||||
const age = timestamp ? now - timestamp : TASK_TTL_MS
|
||||
|
||||
log("[background-agent] Pruning stale task:", {
|
||||
taskId,
|
||||
status: task.status,
|
||||
age: Math.round(age / 1000) + "s",
|
||||
})
|
||||
|
||||
task.status = "error"
|
||||
task.error = errorMessage
|
||||
task.completedAt = new Date()
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
if (wasPending) {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clearNotificationsForTask(taskId)
|
||||
tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export async function cancelBackgroundTask(args: {
|
||||
taskId: string
|
||||
options?: {
|
||||
source?: string
|
||||
reason?: string
|
||||
abortSession?: boolean
|
||||
skipNotification?: boolean
|
||||
}
|
||||
tasks: Map<string, BackgroundTask>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
client: OpencodeClient
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
taskId,
|
||||
options,
|
||||
tasks,
|
||||
queuesByKey,
|
||||
completionTimers,
|
||||
idleDeferralTimers,
|
||||
concurrencyManager,
|
||||
client,
|
||||
cleanupPendingByParent,
|
||||
markForNotification,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
const task = tasks.get(taskId)
|
||||
if (!task || (task.status !== "running" && task.status !== "pending")) {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = options?.source ?? "cancel"
|
||||
const abortSession = options?.abortSession !== false
|
||||
const reason = options?.reason
|
||||
|
||||
if (task.status === "pending") {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
log("[background-agent] Cancelled pending task:", { taskId, key })
|
||||
}
|
||||
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
if (reason) {
|
||||
task.error = reason
|
||||
}
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
const completionTimer = completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
|
||||
if (abortSession && task.sessionID) {
|
||||
client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
if (options?.skipNotification) {
|
||||
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
|
||||
return true
|
||||
}
|
||||
|
||||
markForNotification(task)
|
||||
|
||||
try {
|
||||
await notifyParentSession(task)
|
||||
log(`[background-agent] Task cancelled via ${source}:`, task.id)
|
||||
} catch (err) {
|
||||
log("[background-agent] Error in notifyParentSession for cancelled task:", {
|
||||
taskId: task.id,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
export async function tryCompleteBackgroundTask(args: {
|
||||
task: BackgroundTask
|
||||
source: string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
client: OpencodeClient
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
task,
|
||||
source,
|
||||
concurrencyManager,
|
||||
idleDeferralTimers,
|
||||
client,
|
||||
markForNotification,
|
||||
cleanupPendingByParent,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task already completed, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
source,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
markForNotification(task)
|
||||
cleanupPendingByParent(task)
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
if (task.sessionID) {
|
||||
client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
await notifyParentSession(task)
|
||||
log(`[background-agent] Task completed via ${source}:`, task.id)
|
||||
} catch (err) {
|
||||
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { LaunchInput } from "./types"
|
||||
|
||||
type QueueItem = {
|
||||
task: BackgroundTask
|
||||
input: LaunchInput
|
||||
}
|
||||
|
||||
export function launchBackgroundTask(args: {
|
||||
input: LaunchInput
|
||||
tasks: Map<string, BackgroundTask>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
getConcurrencyKeyFromInput: (input: LaunchInput) => string
|
||||
processKey: (key: string) => void
|
||||
}): BackgroundTask {
|
||||
const { input, tasks, pendingByParent, queuesByKey, getConcurrencyKeyFromInput, processKey } = args
|
||||
|
||||
log("[background-agent] launch() called with:", {
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
description: input.description,
|
||||
parentSessionID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (!input.agent || input.agent.trim() === "") {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
model: input.model,
|
||||
category: input.category,
|
||||
}
|
||||
|
||||
tasks.set(task.id, task)
|
||||
|
||||
if (input.parentSessionID) {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(task.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
const key = getConcurrencyKeyFromInput(input)
|
||||
const queue = queuesByKey.get(key) ?? []
|
||||
queue.push({ task, input })
|
||||
queuesByKey.set(key, queue)
|
||||
|
||||
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
isBackground: true,
|
||||
status: "queued",
|
||||
skills: input.skills,
|
||||
})
|
||||
}
|
||||
|
||||
processKey(key)
|
||||
return task
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
export function getTasksByParentSession(
|
||||
tasks: Iterable<BackgroundTask>,
|
||||
sessionID: string
|
||||
): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
for (const task of tasks) {
|
||||
if (task.parentSessionID === sessionID) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function getAllDescendantTasks(
|
||||
tasksByParent: (sessionID: string) => BackgroundTask[],
|
||||
sessionID: string
|
||||
): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
const directChildren = tasksByParent(sessionID)
|
||||
|
||||
for (const child of directChildren) {
|
||||
result.push(child)
|
||||
if (child.sessionID) {
|
||||
result.push(...getAllDescendantTasks(tasksByParent, child.sessionID))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findTaskBySession(
|
||||
tasks: Iterable<BackgroundTask>,
|
||||
sessionID: string
|
||||
): BackgroundTask | undefined {
|
||||
for (const task of tasks) {
|
||||
if (task.sessionID === sessionID) return task
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getRunningTasks(tasks: Iterable<BackgroundTask>): BackgroundTask[] {
|
||||
return Array.from(tasks).filter((t) => t.status === "running")
|
||||
}
|
||||
|
||||
export function getNonRunningTasks(tasks: Iterable<BackgroundTask>): BackgroundTask[] {
|
||||
return Array.from(tasks).filter((t) => t.status !== "running")
|
||||
}
|
||||
|
||||
export function hasRunningTasks(tasks: Iterable<BackgroundTask>): boolean {
|
||||
for (const task of tasks) {
|
||||
if (task.status === "running") return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
type QueueItem = {
|
||||
task: BackgroundTask
|
||||
input: import("./types").LaunchInput
|
||||
}
|
||||
|
||||
export async function processConcurrencyKeyQueue(args: {
|
||||
key: string
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
processingKeys: Set<string>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
startTask: (item: QueueItem) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const { key, queuesByKey, processingKeys, concurrencyManager, startTask } = args
|
||||
|
||||
if (processingKeys.has(key)) return
|
||||
processingKeys.add(key)
|
||||
|
||||
try {
|
||||
const queue = queuesByKey.get(key)
|
||||
while (queue && queue.length > 0) {
|
||||
const item = queue[0]
|
||||
|
||||
await concurrencyManager.acquire(key)
|
||||
|
||||
if (item.task.status === "cancelled" || item.task.status === "error") {
|
||||
concurrencyManager.release(key)
|
||||
queue.shift()
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await startTask(item)
|
||||
} catch (error) {
|
||||
log("[background-agent] Error starting task:", error)
|
||||
// Release concurrency slot if startTask failed and didn't release it itself
|
||||
// This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set
|
||||
if (!item.task.concurrencyKey) {
|
||||
concurrencyManager.release(key)
|
||||
}
|
||||
}
|
||||
|
||||
queue.shift()
|
||||
}
|
||||
} finally {
|
||||
processingKeys.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { log, getAgentToolRestrictions } from "../../shared"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
import type { BackgroundTask, ResumeInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type ModelRef = { providerID: string; modelID: string }
|
||||
|
||||
export async function resumeBackgroundTask(args: {
|
||||
input: ResumeInput
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
client: OpencodeClient
|
||||
concurrencyManager: ConcurrencyManager
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
startPolling: () => void
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<BackgroundTask> {
|
||||
const {
|
||||
input,
|
||||
findBySession,
|
||||
client,
|
||||
concurrencyManager,
|
||||
pendingByParent,
|
||||
startPolling,
|
||||
markForNotification,
|
||||
cleanupPendingByParent,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
const existingTask = findBySession(input.sessionId)
|
||||
if (!existingTask) {
|
||||
throw new Error(`Task not found for session: ${input.sessionId}`)
|
||||
}
|
||||
|
||||
if (!existingTask.sessionID) {
|
||||
throw new Error(`Task has no sessionID: ${existingTask.id}`)
|
||||
}
|
||||
|
||||
if (existingTask.status === "running") {
|
||||
log("[background-agent] Resume skipped - task already running:", {
|
||||
taskId: existingTask.id,
|
||||
sessionID: existingTask.sessionID,
|
||||
})
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const concurrencyKey =
|
||||
existingTask.concurrencyGroup ??
|
||||
(existingTask.model
|
||||
? `${existingTask.model.providerID}/${existingTask.model.modelID}`
|
||||
: existingTask.agent)
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
existingTask.concurrencyKey = concurrencyKey
|
||||
existingTask.concurrencyGroup = concurrencyKey
|
||||
|
||||
existingTask.status = "running"
|
||||
existingTask.completedAt = undefined
|
||||
existingTask.error = undefined
|
||||
existingTask.parentSessionID = input.parentSessionID
|
||||
existingTask.parentMessageID = input.parentMessageID
|
||||
existingTask.parentModel = input.parentModel
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
existingTask.startedAt = new Date()
|
||||
|
||||
existingTask.progress = {
|
||||
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
startPolling()
|
||||
if (existingTask.sessionID) {
|
||||
subagentSessions.add(existingTask.sessionID)
|
||||
}
|
||||
|
||||
if (input.parentSessionID) {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(existingTask.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: existingTask.id,
|
||||
description: existingTask.description,
|
||||
agent: existingTask.agent,
|
||||
isBackground: true,
|
||||
})
|
||||
}
|
||||
|
||||
log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID })
|
||||
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
|
||||
sessionID: existingTask.sessionID,
|
||||
agent: existingTask.agent,
|
||||
model: existingTask.model,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const resumeModel: ModelRef | undefined = existingTask.model
|
||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = existingTask.model?.variant
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
existingTask.status = "interrupt"
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
existingTask.error = errorMessage
|
||||
existingTask.completedAt = new Date()
|
||||
|
||||
if (existingTask.concurrencyKey) {
|
||||
concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
if (existingTask.sessionID) {
|
||||
client.session.abort({
|
||||
path: { id: existingTask.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
markForNotification(existingTask)
|
||||
cleanupPendingByParent(existingTask)
|
||||
notifyParentSession(existingTask).catch((err) => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
})
|
||||
})
|
||||
|
||||
return existingTask
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type QueueItem = {
|
||||
task: BackgroundTask
|
||||
input: LaunchInput
|
||||
}
|
||||
|
||||
type ModelRef = { providerID: string; modelID: string }
|
||||
|
||||
export async function startQueuedTask(args: {
|
||||
item: QueueItem
|
||||
client: OpencodeClient
|
||||
defaultDirectory: string
|
||||
tmuxEnabled: boolean
|
||||
onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
||||
startPolling: () => void
|
||||
getConcurrencyKeyFromInput: (input: LaunchInput) => string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const {
|
||||
item,
|
||||
client,
|
||||
defaultDirectory,
|
||||
tmuxEnabled,
|
||||
onSubagentSessionCreated,
|
||||
startPolling,
|
||||
getConcurrencyKeyFromInput,
|
||||
concurrencyManager,
|
||||
findBySession,
|
||||
markForNotification,
|
||||
cleanupPendingByParent,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
const { task, input } = item
|
||||
|
||||
log("[background-agent] Starting task:", {
|
||||
taskId: task.id,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
})
|
||||
|
||||
const concurrencyKey = getConcurrencyKeyFromInput(input)
|
||||
|
||||
const parentSession = await client.session.get({
|
||||
path: { id: input.parentSessionID },
|
||||
}).catch((err) => {
|
||||
log(`[background-agent] Failed to get parent session: ${err}`)
|
||||
return null
|
||||
})
|
||||
|
||||
const parentDirectory = parentSession?.data?.directory ?? defaultDirectory
|
||||
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
|
||||
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agent} subagent)`,
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
if (!createResult.data?.id) {
|
||||
throw new Error("Failed to create background session: API returned no session ID")
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
subagentSessions.add(sessionID)
|
||||
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
|
||||
log("[background-agent] Invoking tmux callback NOW", { sessionID })
|
||||
await onSubagentSessionCreated({
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
title: input.description,
|
||||
}).catch((err) => {
|
||||
log("[background-agent] Failed to spawn tmux pane:", err)
|
||||
})
|
||||
log("[background-agent] tmux callback completed, waiting 200ms")
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 200)
|
||||
})
|
||||
} else {
|
||||
log("[background-agent] SKIP tmux callback - conditions not met")
|
||||
}
|
||||
|
||||
task.status = "running"
|
||||
task.startedAt = new Date()
|
||||
task.sessionID = sessionID
|
||||
task.progress = {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
startPolling()
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.updateTask(task.id, "running")
|
||||
}
|
||||
|
||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
hasSkillContent: !!input.skillContent,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const launchModel: ModelRef | undefined = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
const existingTask = findBySession(sessionID)
|
||||
if (!existingTask) return
|
||||
|
||||
existingTask.status = "interrupt"
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
existingTask.error = `Agent "${input.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) {
|
||||
concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
client.session.abort({
|
||||
path: { id: sessionID },
|
||||
}).catch(() => {})
|
||||
|
||||
markForNotification(existingTask)
|
||||
cleanupPendingByParent(existingTask)
|
||||
notifyParentSession(existingTask).catch((err) => {
|
||||
log("[background-agent] Failed to notify on error:", err)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
export async function trackExternalTask(args: {
|
||||
input: {
|
||||
taskId: string
|
||||
sessionID: string
|
||||
parentSessionID: string
|
||||
description: string
|
||||
agent?: string
|
||||
parentAgent?: string
|
||||
concurrencyKey?: string
|
||||
}
|
||||
tasks: Map<string, BackgroundTask>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
startPolling: () => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
}): Promise<BackgroundTask> {
|
||||
const { input, tasks, pendingByParent, concurrencyManager, startPolling, cleanupPendingByParent } = args
|
||||
|
||||
const existingTask = tasks.get(input.taskId)
|
||||
if (existingTask) {
|
||||
const parentChanged = input.parentSessionID !== existingTask.parentSessionID
|
||||
if (parentChanged) {
|
||||
cleanupPendingByParent(existingTask)
|
||||
existingTask.parentSessionID = input.parentSessionID
|
||||
}
|
||||
if (input.parentAgent !== undefined) {
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
}
|
||||
if (!existingTask.concurrencyGroup) {
|
||||
existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent
|
||||
}
|
||||
|
||||
if (existingTask.sessionID) {
|
||||
subagentSessions.add(existingTask.sessionID)
|
||||
}
|
||||
startPolling()
|
||||
|
||||
if (existingTask.status === "pending" || existingTask.status === "running") {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(existingTask.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
} else if (!parentChanged) {
|
||||
cleanupPendingByParent(existingTask)
|
||||
}
|
||||
|
||||
log("[background-agent] External task already registered:", {
|
||||
taskId: existingTask.id,
|
||||
sessionID: existingTask.sessionID,
|
||||
status: existingTask.status,
|
||||
})
|
||||
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task"
|
||||
if (input.concurrencyKey) {
|
||||
await concurrencyManager.acquire(input.concurrencyKey)
|
||||
}
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: input.taskId,
|
||||
sessionID: input.sessionID,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: "",
|
||||
description: input.description,
|
||||
prompt: "",
|
||||
agent: input.agent || "task",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentAgent: input.parentAgent,
|
||||
concurrencyKey: input.concurrencyKey,
|
||||
concurrencyGroup,
|
||||
}
|
||||
|
||||
tasks.set(task.id, task)
|
||||
subagentSessions.add(input.sessionID)
|
||||
startPolling()
|
||||
|
||||
if (input.parentSessionID) {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(task.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
|
||||
return task
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { executeAction } from "./action-executor"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
|
||||
export class ManagerCleanup {
|
||||
constructor(
|
||||
private sessions: Map<string, TrackedSession>,
|
||||
private sourcePaneId: string | undefined,
|
||||
private pollingManager: TmuxPollingManager,
|
||||
private tmuxConfig: TmuxConfig,
|
||||
private serverUrl: string
|
||||
) {}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.pollingManager.stopPolling()
|
||||
|
||||
if (this.sessions.size > 0) {
|
||||
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
||||
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||
|
||||
if (state) {
|
||||
const closePromises = Array.from(this.sessions.values()).map((s) =>
|
||||
executeAction(
|
||||
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
).catch((err) =>
|
||||
log("[tmux-session-manager] cleanup error for pane", {
|
||||
paneId: s.paneId,
|
||||
error: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Promise.all(closePromises)
|
||||
}
|
||||
this.sessions.clear()
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] cleanup complete")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { MIN_PANE_WIDTH } from "./types"
|
||||
import type { SplitDirection, TmuxPaneInfo } from "./types"
|
||||
import {
|
||||
DIVIDER_SIZE,
|
||||
MAX_COLS,
|
||||
MAX_ROWS,
|
||||
MIN_SPLIT_HEIGHT,
|
||||
MIN_SPLIT_WIDTH,
|
||||
DIVIDER_SIZE,
|
||||
MAX_COLS,
|
||||
MAX_ROWS,
|
||||
MIN_SPLIT_HEIGHT,
|
||||
} from "./tmux-grid-constants"
|
||||
|
||||
function minSplitWidthFor(minPaneWidth: number): number {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession } from "./types"
|
||||
import type { SessionMapping } from "./decision-engine"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideCloseAction } from "./decision-engine"
|
||||
import { executeAction } from "./action-executor"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
export class SessionCleaner {
|
||||
constructor(
|
||||
private tmuxConfig: TmuxConfig,
|
||||
private deps: TmuxUtilDeps,
|
||||
private sessions: Map<string, TrackedSession>,
|
||||
private sourcePaneId: string | undefined,
|
||||
private getSessionMappings: () => SessionMapping[],
|
||||
private pollingManager: TmuxPollingManager,
|
||||
private serverUrl: string
|
||||
) {}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||
}
|
||||
|
||||
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
if (!this.sourcePaneId) return
|
||||
|
||||
const tracked = this.sessions.get(event.sessionID)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||
|
||||
const state = await queryWindowState(this.sourcePaneId)
|
||||
if (!state) {
|
||||
this.sessions.delete(event.sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
|
||||
if (closeAction) {
|
||||
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
|
||||
}
|
||||
|
||||
this.sessions.delete(event.sessionID)
|
||||
|
||||
if (this.sessions.size === 0) {
|
||||
this.pollingManager.stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async closeSessionById(sessionId: string): Promise<void> {
|
||||
const tracked = this.sessions.get(sessionId)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] closing session pane", {
|
||||
sessionId,
|
||||
paneId: tracked.paneId,
|
||||
})
|
||||
|
||||
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||
if (state) {
|
||||
await executeAction(
|
||||
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
)
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId)
|
||||
|
||||
if (this.sessions.size === 0) {
|
||||
this.pollingManager.stopPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
||||
import { executeActions } from "./action-executor"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
|
||||
interface SessionCreatedEvent {
|
||||
type: string
|
||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||
}
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
export class SessionSpawner {
|
||||
constructor(
|
||||
private tmuxConfig: TmuxConfig,
|
||||
private deps: TmuxUtilDeps,
|
||||
private sessions: Map<string, TrackedSession>,
|
||||
private pendingSessions: Set<string>,
|
||||
private sourcePaneId: string | undefined,
|
||||
private getCapacityConfig: () => CapacityConfig,
|
||||
private getSessionMappings: () => SessionMapping[],
|
||||
private waitForSessionReady: (sessionId: string) => Promise<boolean>,
|
||||
private pollingManager: TmuxPollingManager,
|
||||
private serverUrl: string
|
||||
) {}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||
}
|
||||
|
||||
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
|
||||
const enabled = this.isEnabled()
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||
isInsideTmux: this.deps.isInsideTmux(),
|
||||
eventType: event.type,
|
||||
infoId: event.properties?.info?.id,
|
||||
infoParentID: event.properties?.info?.parentID,
|
||||
})
|
||||
|
||||
if (!enabled) return
|
||||
if (event.type !== "session.created") return
|
||||
|
||||
const info = event.properties?.info
|
||||
if (!info?.id || !info?.parentID) return
|
||||
|
||||
const sessionId = info.id
|
||||
const title = info.title ?? "Subagent"
|
||||
|
||||
if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) {
|
||||
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.sourcePaneId) {
|
||||
log("[tmux-session-manager] no source pane id")
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingSessions.add(sessionId)
|
||||
|
||||
try {
|
||||
const state = await queryWindowState(this.sourcePaneId)
|
||||
if (!state) {
|
||||
log("[tmux-session-manager] failed to query window state")
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] window state queried", {
|
||||
windowWidth: state.windowWidth,
|
||||
mainPane: state.mainPane?.paneId,
|
||||
agentPaneCount: state.agentPanes.length,
|
||||
agentPanes: state.agentPanes.map((p) => p.paneId),
|
||||
})
|
||||
|
||||
const decision = decideSpawnActions(
|
||||
state,
|
||||
sessionId,
|
||||
title,
|
||||
this.getCapacityConfig(),
|
||||
this.getSessionMappings()
|
||||
)
|
||||
|
||||
log("[tmux-session-manager] spawn decision", {
|
||||
canSpawn: decision.canSpawn,
|
||||
reason: decision.reason,
|
||||
actionCount: decision.actions.length,
|
||||
actions: decision.actions.map((a) => {
|
||||
if (a.type === "close") return { type: "close", paneId: a.paneId }
|
||||
if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
|
||||
return { type: "spawn", sessionId: a.sessionId }
|
||||
}),
|
||||
})
|
||||
|
||||
if (!decision.canSpawn) {
|
||||
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await executeActions(
|
||||
decision.actions,
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
)
|
||||
|
||||
for (const { action, result: actionResult } of result.results) {
|
||||
if (action.type === "close" && actionResult.success) {
|
||||
this.sessions.delete(action.sessionId)
|
||||
log("[tmux-session-manager] removed closed session from cache", {
|
||||
sessionId: action.sessionId,
|
||||
})
|
||||
}
|
||||
if (action.type === "replace" && actionResult.success) {
|
||||
this.sessions.delete(action.oldSessionId)
|
||||
log("[tmux-session-manager] removed replaced session from cache", {
|
||||
oldSessionId: action.oldSessionId,
|
||||
newSessionId: action.newSessionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success && result.spawnedPaneId) {
|
||||
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||
|
||||
if (!sessionReady) {
|
||||
log("[tmux-session-manager] session not ready after timeout, closing spawned pane", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
})
|
||||
|
||||
await executeActions(
|
||||
[{ type: "close", paneId: result.spawnedPaneId, sessionId }],
|
||||
{
|
||||
config: this.tmuxConfig,
|
||||
serverUrl: this.serverUrl,
|
||||
windowState: state,
|
||||
},
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
this.sessions.set(sessionId, {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
description: title,
|
||||
createdAt: new Date(now),
|
||||
lastSeenAt: new Date(now),
|
||||
})
|
||||
log("[tmux-session-manager] pane spawned and tracked", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
sessionReady,
|
||||
})
|
||||
this.pollingManager.startPolling()
|
||||
} else {
|
||||
log("[tmux-session-manager] spawn failed", {
|
||||
success: result.success,
|
||||
results: result.results.map((r) => ({
|
||||
type: r.action.type,
|
||||
success: r.result.success,
|
||||
error: r.result.error,
|
||||
})),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.pendingSessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker";
|
||||
import { parseTmuxCommand } from "./tmux-command-parser";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createInteractiveBashSessionHook(ctx: PluginInput) {
|
||||
const tracker = createInteractiveBashSessionTracker({
|
||||
abortSession: (args) => ctx.client.session.abort(args),
|
||||
})
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID, args } = input;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (toolLower !== "interactive_bash") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof args?.tmux_command !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmuxCommand = args.tmux_command;
|
||||
const { subCommand, sessionName } = parseTmuxCommand(tmuxCommand)
|
||||
|
||||
const toolOutput = output?.output ?? ""
|
||||
if (toolOutput.startsWith("Error:")) {
|
||||
return
|
||||
}
|
||||
|
||||
const { reminderToAppend } = tracker.handleTmuxCommand({
|
||||
sessionID,
|
||||
subCommand,
|
||||
sessionName,
|
||||
toolOutput,
|
||||
})
|
||||
if (reminderToAppend) {
|
||||
output.output += reminderToAppend
|
||||
}
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
const sessionID = sessionInfo?.id;
|
||||
|
||||
if (sessionID) {
|
||||
await tracker.handleSessionDeleted(sessionID)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { addModelsFromModelsJsonCache } from "./models-json-cache-reader"
|
||||
import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors"
|
||||
import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader"
|
||||
import { log } from "./logger"
|
||||
import { normalizeSDKResponse } from "./normalize-sdk-response"
|
||||
|
||||
export async function getConnectedProviders(client: unknown): Promise<string[]> {
|
||||
const providerList = getProviderListFunction(client)
|
||||
if (!providerList) {
|
||||
log("[getConnectedProviders] client.provider.list not available")
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await providerList()
|
||||
const connected = result.data?.connected ?? []
|
||||
log("[getConnectedProviders] connected providers", {
|
||||
count: connected.length,
|
||||
providers: connected,
|
||||
})
|
||||
return connected
|
||||
} catch (err) {
|
||||
log("[getConnectedProviders] SDK error", { error: String(err) })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAvailableModels(
|
||||
client?: unknown,
|
||||
options?: { connectedProviders?: string[] | null },
|
||||
): Promise<Set<string>> {
|
||||
let connectedProviders = options?.connectedProviders ?? null
|
||||
let connectedProvidersUnknown = connectedProviders === null
|
||||
|
||||
log("[fetchAvailableModels] CALLED", {
|
||||
connectedProvidersUnknown,
|
||||
connectedProviders: options?.connectedProviders,
|
||||
})
|
||||
|
||||
if (connectedProvidersUnknown && client !== undefined) {
|
||||
const liveConnected = await getConnectedProviders(client)
|
||||
if (liveConnected.length > 0) {
|
||||
connectedProviders = liveConnected
|
||||
connectedProvidersUnknown = false
|
||||
log("[fetchAvailableModels] connected providers fetched from client", {
|
||||
count: liveConnected.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedProvidersUnknown) {
|
||||
const modelList = client === undefined ? null : getModelListFunction(client)
|
||||
if (modelList) {
|
||||
const modelSet = new Set<string>()
|
||||
try {
|
||||
const modelsResult = await modelList()
|
||||
const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
|
||||
for (const model of models) {
|
||||
if (model.provider && model.id) {
|
||||
modelSet.add(`${model.provider}/${model.id}`)
|
||||
}
|
||||
}
|
||||
log(
|
||||
"[fetchAvailableModels] fetched models from client without provider filter",
|
||||
{ count: modelSet.size },
|
||||
)
|
||||
return modelSet
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] client.model.list error", {
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
log(
|
||||
"[fetchAvailableModels] connected providers unknown, returning empty set for fallback resolution",
|
||||
)
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
const connectedProvidersList = connectedProviders ?? []
|
||||
const connectedSet = new Set(connectedProvidersList)
|
||||
const modelSet = new Set<string>()
|
||||
|
||||
if (addModelsFromProviderModelsCache(connectedSet, modelSet)) {
|
||||
return modelSet
|
||||
}
|
||||
log("[fetchAvailableModels] provider-models cache not found, falling back to models.json")
|
||||
if (addModelsFromModelsJsonCache(connectedSet, modelSet)) {
|
||||
return modelSet
|
||||
}
|
||||
|
||||
const modelList = client === undefined ? null : getModelListFunction(client)
|
||||
if (modelList) {
|
||||
try {
|
||||
const modelsResult = await modelList()
|
||||
const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
|
||||
|
||||
for (const model of models) {
|
||||
if (!model.provider || !model.id) continue
|
||||
if (connectedSet.has(model.provider)) {
|
||||
modelSet.add(`${model.provider}/${model.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] fetched models from client (filtered)", {
|
||||
count: modelSet.size,
|
||||
connectedProviders: connectedProvidersList.slice(0, 5),
|
||||
})
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] client.model.list error", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
return modelSet
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { getOpenCodeCacheDir } from "./data-path"
|
||||
import { hasProviderModelsCache } from "./connected-providers-cache"
|
||||
|
||||
export function __resetModelCache(): void {}
|
||||
|
||||
export function isModelCacheAvailable(): boolean {
|
||||
if (hasProviderModelsCache()) {
|
||||
return true
|
||||
}
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundCancelClient } from "../types"
|
||||
import type { BackgroundManager } from "../../../features/background-agent"
|
||||
import type { BackgroundCancelArgs } from "../types"
|
||||
import { BACKGROUND_CANCEL_DESCRIPTION } from "../constants"
|
||||
|
||||
export function createBackgroundCancel(manager: BackgroundManager, _client: BackgroundCancelClient): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_CANCEL_DESCRIPTION,
|
||||
args: {
|
||||
taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"),
|
||||
all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"),
|
||||
},
|
||||
async execute(args: BackgroundCancelArgs, toolContext) {
|
||||
try {
|
||||
const cancelAll = args.all === true
|
||||
|
||||
if (!cancelAll && !args.taskId) {
|
||||
return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.`
|
||||
}
|
||||
|
||||
if (cancelAll) {
|
||||
const tasks = manager.getAllDescendantTasks(toolContext.sessionID)
|
||||
const cancellableTasks = tasks.filter((t: any) => t.status === "running" || t.status === "pending")
|
||||
|
||||
if (cancellableTasks.length === 0) {
|
||||
return `No running or pending background tasks to cancel.`
|
||||
}
|
||||
|
||||
const cancelledInfo: Array<{
|
||||
id: string
|
||||
description: string
|
||||
status: string
|
||||
sessionID?: string
|
||||
}> = []
|
||||
|
||||
for (const task of cancellableTasks) {
|
||||
const originalStatus = task.status
|
||||
const cancelled = await manager.cancelTask(task.id, {
|
||||
source: "background_cancel",
|
||||
abortSession: originalStatus === "running",
|
||||
skipNotification: true,
|
||||
})
|
||||
if (!cancelled) continue
|
||||
cancelledInfo.push({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
status: originalStatus === "pending" ? "pending" : "running",
|
||||
sessionID: task.sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
const tableRows = cancelledInfo
|
||||
.map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`)
|
||||
.join("\n")
|
||||
|
||||
const resumableTasks = cancelledInfo.filter(t => t.sessionID)
|
||||
const resumeSection = resumableTasks.length > 0
|
||||
? `\n## Continue Instructions
|
||||
|
||||
To continue a cancelled task, use:
|
||||
\`\`\`
|
||||
task(session_id="<session_id>", prompt="Continue: <your follow-up>")
|
||||
\`\`\`
|
||||
|
||||
Continuable sessions:
|
||||
${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}`
|
||||
: ""
|
||||
|
||||
return `Cancelled ${cancelledInfo.length} background task(s):
|
||||
|
||||
| Task ID | Description | Status | Session ID |
|
||||
|---------|-------------|--------|------------|
|
||||
${tableRows}
|
||||
${resumeSection}`
|
||||
}
|
||||
|
||||
const task = manager.getTask(args.taskId!)
|
||||
if (!task) {
|
||||
return `[ERROR] Task not found: ${args.taskId}`
|
||||
}
|
||||
|
||||
if (task.status !== "running" && task.status !== "pending") {
|
||||
return `[ERROR] Cannot cancel task: current status is "${task.status}".
|
||||
Only running or pending tasks can be cancelled.`
|
||||
}
|
||||
|
||||
const cancelled = await manager.cancelTask(task.id, {
|
||||
source: "background_cancel",
|
||||
abortSession: task.status === "running",
|
||||
skipNotification: true,
|
||||
})
|
||||
if (!cancelled) {
|
||||
return `[ERROR] Failed to cancel task: ${task.id}`
|
||||
}
|
||||
|
||||
if (task.status === "pending") {
|
||||
return `Pending task cancelled successfully
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Status: ${task.status}`
|
||||
}
|
||||
|
||||
return `Task cancelled successfully
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Session ID: ${task.sessionID}
|
||||
Status: ${task.status}`
|
||||
} catch (error) {
|
||||
return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundOutputManager, BackgroundOutputClient } from "../types"
|
||||
import type { BackgroundOutputArgs } from "../types"
|
||||
import { BACKGROUND_OUTPUT_DESCRIPTION } from "../constants"
|
||||
import { formatTaskStatus, formatTaskResult, formatFullSession } from "./formatters"
|
||||
import { delay } from "./utils"
|
||||
import { storeToolMetadata } from "../../../features/tool-metadata-store"
|
||||
import type { BackgroundTask } from "../../../features/background-agent"
|
||||
import type { ToolContextWithMetadata } from "./utils"
|
||||
|
||||
import { getAgentDisplayName } from "../../../shared/agent-display-names"
|
||||
|
||||
const SISYPHUS_JUNIOR_AGENT = getAgentDisplayName("sisyphus-junior")
|
||||
|
||||
type ToolContextWithCallId = ToolContextWithMetadata & {
|
||||
callID?: string
|
||||
callId?: string
|
||||
call_id?: string
|
||||
}
|
||||
|
||||
function resolveToolCallID(ctx: ToolContextWithCallId): string | undefined {
|
||||
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") {
|
||||
return ctx.callID
|
||||
}
|
||||
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") {
|
||||
return ctx.callId
|
||||
}
|
||||
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") {
|
||||
return ctx.call_id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function formatResolvedTitle(task: BackgroundTask): string {
|
||||
const label = task.agent === SISYPHUS_JUNIOR_AGENT && task.category
|
||||
? task.category
|
||||
: task.agent
|
||||
return `${label} - ${task.description}`
|
||||
}
|
||||
|
||||
export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_OUTPUT_DESCRIPTION,
|
||||
args: {
|
||||
task_id: tool.schema.string().describe("Task ID to get output from"),
|
||||
block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."),
|
||||
timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"),
|
||||
full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"),
|
||||
include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"),
|
||||
message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"),
|
||||
since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"),
|
||||
include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"),
|
||||
thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"),
|
||||
},
|
||||
async execute(args: BackgroundOutputArgs, toolContext) {
|
||||
try {
|
||||
const ctx = toolContext as ToolContextWithCallId
|
||||
const task = manager.getTask(args.task_id)
|
||||
if (!task) {
|
||||
return `Task not found: ${args.task_id}`
|
||||
}
|
||||
|
||||
const resolvedTitle = formatResolvedTitle(task)
|
||||
const meta = {
|
||||
title: resolvedTitle,
|
||||
metadata: {
|
||||
task_id: task.id,
|
||||
agent: task.agent,
|
||||
category: task.category,
|
||||
description: task.description,
|
||||
sessionId: task.sessionID ?? "pending",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
await ctx.metadata?.(meta)
|
||||
const callID = resolveToolCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, meta)
|
||||
}
|
||||
|
||||
const isActive = task.status === "pending" || task.status === "running"
|
||||
const fullSession = args.full_session ?? isActive
|
||||
const includeThinking = isActive || (args.include_thinking ?? false)
|
||||
const includeToolResults = isActive || (args.include_tool_results ?? false)
|
||||
|
||||
if (fullSession) {
|
||||
return await formatFullSession(task, client, {
|
||||
includeThinking,
|
||||
messageLimit: args.message_limit,
|
||||
sinceMessageId: args.since_message_id,
|
||||
includeToolResults,
|
||||
thinkingMaxChars: args.thinking_max_chars,
|
||||
})
|
||||
}
|
||||
|
||||
const shouldBlock = args.block === true
|
||||
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
|
||||
|
||||
// Already completed: return result immediately (regardless of block flag)
|
||||
if (task.status === "completed") {
|
||||
return await formatTaskResult(task, client)
|
||||
}
|
||||
|
||||
// Error or cancelled: return status immediately
|
||||
if (task.status === "error" || task.status === "cancelled" || task.status === "interrupt") {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
// Non-blocking and still running: return status
|
||||
if (!shouldBlock) {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
// Blocking: poll until completion or timeout
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await delay(1000)
|
||||
|
||||
const currentTask = manager.getTask(args.task_id)
|
||||
if (!currentTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
|
||||
if (currentTask.status === "completed") {
|
||||
return await formatTaskResult(currentTask, client)
|
||||
}
|
||||
|
||||
if (currentTask.status === "error" || currentTask.status === "cancelled" || currentTask.status === "interrupt") {
|
||||
return formatTaskStatus(currentTask)
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout exceeded: return current status
|
||||
const finalTask = manager.getTask(args.task_id)
|
||||
if (!finalTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`
|
||||
} catch (error) {
|
||||
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../../features/background-agent"
|
||||
import type { BackgroundTaskArgs } from "../types"
|
||||
import { BACKGROUND_TASK_DESCRIPTION } from "../constants"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../../features/claude-code-session-state"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { storeToolMetadata } from "../../../features/tool-metadata-store"
|
||||
import { getMessageDir, delay, type ToolContextWithMetadata } from "./utils"
|
||||
|
||||
export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_TASK_DESCRIPTION,
|
||||
args: {
|
||||
description: tool.schema.string().describe("Short task description (shown in status)"),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
agent: tool.schema.string().describe("Agent type to use (any registered agent)"),
|
||||
},
|
||||
async execute(args: BackgroundTaskArgs, toolContext) {
|
||||
const ctx = toolContext as ToolContextWithMetadata
|
||||
|
||||
if (!args.agent || args.agent.trim() === "") {
|
||||
return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
|
||||
}
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(ctx.sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
|
||||
const sessionAgent = getSessionAgent(ctx.sessionID)
|
||||
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||
|
||||
log("[background_task] parentAgent resolution", {
|
||||
sessionID: ctx.sessionID,
|
||||
ctxAgent: ctx.agent,
|
||||
sessionAgent,
|
||||
firstMessageAgent,
|
||||
prevMessageAgent: prevMessage?.agent,
|
||||
resolvedParentAgent: parentAgent,
|
||||
})
|
||||
|
||||
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? {
|
||||
providerID: prevMessage.model.providerID,
|
||||
modelID: prevMessage.model.modelID,
|
||||
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {})
|
||||
}
|
||||
: undefined
|
||||
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
agent: args.agent.trim(),
|
||||
parentSessionID: ctx.sessionID,
|
||||
parentMessageID: ctx.messageID,
|
||||
parentModel,
|
||||
parentAgent,
|
||||
})
|
||||
|
||||
const WAIT_FOR_SESSION_INTERVAL_MS = 50
|
||||
const WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
const waitStart = Date.now()
|
||||
let sessionId = task.sessionID
|
||||
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
|
||||
if (ctx.abort?.aborted) {
|
||||
await manager.cancelTask(task.id)
|
||||
return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
await delay(WAIT_FOR_SESSION_INTERVAL_MS)
|
||||
const updated = manager.getTask(task.id)
|
||||
if (!updated || updated.status === "error") {
|
||||
return `Task ${!updated ? "was deleted" : `entered error state`}.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
sessionId = updated?.sessionID
|
||||
}
|
||||
|
||||
const bgMeta = {
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionId ?? "pending" } as Record<string, unknown>,
|
||||
}
|
||||
await ctx.metadata?.(bgMeta)
|
||||
const callID = (ctx as any).callID as string | undefined
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, bgMeta)
|
||||
}
|
||||
|
||||
return `Background task launched successfully.
|
||||
|
||||
Task ID: ${task.id}
|
||||
Session ID: ${sessionId ?? "pending"}
|
||||
Description: ${task.description}
|
||||
Agent: ${task.agent}
|
||||
Status: ${task.status}
|
||||
|
||||
The system will notify you when the task completes.
|
||||
Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
||||
- block=false (default): Check status immediately - returns full status info
|
||||
- block=true: Wait for completion (rarely needed since system notifies)`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `[ERROR] Failed to launch background task: ${message}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import type { BackgroundTask } from "../../../features/background-agent"
|
||||
import type { BackgroundOutputClient } from "../types"
|
||||
import { formatDuration, truncateText, formatMessageTime } from "./utils"
|
||||
import { extractMessages, getErrorMessage, type BackgroundOutputMessagesResult, type FullSessionMessage, extractToolResultText } from "./message-processing"
|
||||
import { consumeNewMessages } from "../../../shared/session-cursor"
|
||||
|
||||
const MAX_MESSAGE_LIMIT = 100
|
||||
const THINKING_MAX_CHARS = 2000
|
||||
|
||||
export function formatTaskStatus(task: BackgroundTask): string {
|
||||
let duration: string
|
||||
if (task.status === "pending" && task.queuedAt) {
|
||||
duration = formatDuration(task.queuedAt, undefined)
|
||||
} else if (task.startedAt) {
|
||||
duration = formatDuration(task.startedAt, task.completedAt)
|
||||
} else {
|
||||
duration = "N/A"
|
||||
}
|
||||
const promptPreview = truncateText(task.prompt, 500)
|
||||
|
||||
let progressSection = ""
|
||||
if (task.progress?.lastTool) {
|
||||
progressSection = `\n| Last tool | ${task.progress.lastTool} |`
|
||||
}
|
||||
|
||||
let lastMessageSection = ""
|
||||
if (task.progress?.lastMessage) {
|
||||
const truncated = truncateText(task.progress.lastMessage, 500)
|
||||
const messageTime = task.progress.lastMessageAt
|
||||
? task.progress.lastMessageAt.toISOString()
|
||||
: "N/A"
|
||||
lastMessageSection = `
|
||||
|
||||
## Last Message (${messageTime})
|
||||
|
||||
\`\`\`
|
||||
${truncated}
|
||||
\`\`\``
|
||||
}
|
||||
|
||||
let statusNote = ""
|
||||
if (task.status === "pending") {
|
||||
statusNote = `
|
||||
|
||||
> **Queued**: Task is waiting for a concurrency slot to become available.`
|
||||
} else if (task.status === "running") {
|
||||
statusNote = `
|
||||
|
||||
> **Note**: No need to wait explicitly - the system will notify you when this task completes.`
|
||||
} else if (task.status === "error") {
|
||||
statusNote = `
|
||||
|
||||
> **Failed**: The task encountered an error. Check the last message for details.`
|
||||
} else if (task.status === "interrupt") {
|
||||
statusNote = `
|
||||
|
||||
> **Interrupted**: The task was interrupted by a prompt error. The session may contain partial results.`
|
||||
}
|
||||
|
||||
const durationLabel = task.status === "pending" ? "Queued for" : "Duration"
|
||||
|
||||
return `# Task Status
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Task ID | \`${task.id}\` |
|
||||
| Description | ${task.description} |
|
||||
| Agent | ${task.agent} |
|
||||
| Status | **${task.status}** |
|
||||
| ${durationLabel} | ${duration} |
|
||||
| Session ID | \`${task.sessionID}\` |${progressSection}
|
||||
${statusNote}
|
||||
## Original Prompt
|
||||
|
||||
\`\`\`
|
||||
${promptPreview}
|
||||
\`\`\`${lastMessageSection}`
|
||||
}
|
||||
|
||||
export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise<string> {
|
||||
if (!task.sessionID) {
|
||||
return `Error: Task has no sessionID`
|
||||
}
|
||||
|
||||
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
const errorMessage = getErrorMessage(messagesResult)
|
||||
if (errorMessage) {
|
||||
return `Error fetching messages: ${errorMessage}`
|
||||
}
|
||||
|
||||
const messages = extractMessages(messagesResult)
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No messages found)`
|
||||
}
|
||||
|
||||
// Include both assistant messages AND tool messages
|
||||
// Tool results (grep, glob, bash output) come from role "tool"
|
||||
const relevantMessages = messages.filter(
|
||||
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (relevantMessages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No assistant or tool response found)`
|
||||
}
|
||||
|
||||
// Sort by time ascending (oldest first) to process messages in order
|
||||
const sortedMessages = [...relevantMessages].sort((a, b) => {
|
||||
const timeA = String((a as { info?: { time?: string } }).info?.time ?? "")
|
||||
const timeB = String((b as { info?: { time?: string } }).info?.time ?? "")
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
const newMessages = consumeNewMessages(task.sessionID, sortedMessages)
|
||||
if (newMessages.length === 0) {
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No new output since last check)`
|
||||
}
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of newMessages) {
|
||||
for (const part of message.parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
extractedContent.push(part.text)
|
||||
} else if (part.type === "tool_result") {
|
||||
// Tool results contain the actual output from tool calls
|
||||
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
|
||||
if (typeof toolResult.content === "string" && toolResult.content) {
|
||||
extractedContent.push(toolResult.content)
|
||||
} else if (Array.isArray(toolResult.content)) {
|
||||
// Handle array of content blocks
|
||||
for (const block of toolResult.content) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((block.type === "text" || block.type === "reasoning") && block.text) {
|
||||
extractedContent.push(block.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textContent = extractedContent
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n")
|
||||
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
${textContent || "(No text output)"}`
|
||||
}
|
||||
|
||||
export async function formatFullSession(
|
||||
task: BackgroundTask,
|
||||
client: BackgroundOutputClient,
|
||||
options: {
|
||||
includeThinking: boolean
|
||||
messageLimit?: number
|
||||
sinceMessageId?: string
|
||||
includeToolResults: boolean
|
||||
thinkingMaxChars?: number
|
||||
}
|
||||
): Promise<string> {
|
||||
if (!task.sessionID) {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
const errorMessage = getErrorMessage(messagesResult)
|
||||
if (errorMessage) {
|
||||
return `Error fetching messages: ${errorMessage}`
|
||||
}
|
||||
|
||||
const rawMessages = extractMessages(messagesResult)
|
||||
if (!Array.isArray(rawMessages)) {
|
||||
return "Error fetching messages: invalid response"
|
||||
}
|
||||
|
||||
const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => {
|
||||
const timeA = String(a.info?.time ?? "")
|
||||
const timeB = String(b.info?.time ?? "")
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
let filteredMessages = sortedMessages
|
||||
|
||||
if (options.sinceMessageId) {
|
||||
const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId)
|
||||
if (index === -1) {
|
||||
return `Error: since_message_id not found: ${options.sinceMessageId}`
|
||||
}
|
||||
filteredMessages = filteredMessages.slice(index + 1)
|
||||
}
|
||||
|
||||
const includeThinking = options.includeThinking
|
||||
const includeToolResults = options.includeToolResults
|
||||
const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS
|
||||
|
||||
const normalizedMessages: FullSessionMessage[] = []
|
||||
for (const message of filteredMessages) {
|
||||
const parts = (message.parts ?? []).filter((part) => {
|
||||
if (part.type === "thinking" || part.type === "reasoning") {
|
||||
return includeThinking
|
||||
}
|
||||
if (part.type === "tool_result") {
|
||||
return includeToolResults
|
||||
}
|
||||
return part.type === "text"
|
||||
})
|
||||
|
||||
if (parts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedMessages.push({ ...message, parts })
|
||||
}
|
||||
|
||||
const limit = typeof options.messageLimit === "number"
|
||||
? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT)
|
||||
: undefined
|
||||
const hasMore = limit !== undefined && normalizedMessages.length > limit
|
||||
const visibleMessages = limit !== undefined
|
||||
? normalizedMessages.slice(0, limit)
|
||||
: normalizedMessages
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push("# Full Session Output")
|
||||
lines.push("")
|
||||
lines.push(`Task ID: ${task.id}`)
|
||||
lines.push(`Description: ${task.description}`)
|
||||
lines.push(`Status: ${task.status}`)
|
||||
lines.push(`Session ID: ${task.sessionID}`)
|
||||
lines.push(`Total messages: ${normalizedMessages.length}`)
|
||||
lines.push(`Returned: ${visibleMessages.length}`)
|
||||
lines.push(`Has more: ${hasMore ? "true" : "false"}`)
|
||||
lines.push("")
|
||||
lines.push("## Messages")
|
||||
|
||||
if (visibleMessages.length === 0) {
|
||||
lines.push("")
|
||||
lines.push("(No messages found)")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
for (const message of visibleMessages) {
|
||||
const role = message.info?.role ?? "unknown"
|
||||
const agent = message.info?.agent ? ` (${message.info.agent})` : ""
|
||||
const time = formatMessageTime(message.info?.time)
|
||||
const idLabel = message.id ? ` id=${message.id}` : ""
|
||||
lines.push("")
|
||||
lines.push(`[${role}${agent}] ${time}${idLabel}`)
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "text" && part.text) {
|
||||
lines.push(part.text.trim())
|
||||
} else if (part.type === "thinking" && part.thinking) {
|
||||
lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`)
|
||||
} else if (part.type === "reasoning" && part.text) {
|
||||
lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`)
|
||||
} else if (part.type === "tool_result") {
|
||||
const toolTexts = extractToolResultText(part)
|
||||
for (const toolText of toolTexts) {
|
||||
lines.push(`[tool result] ${toolText}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export type BackgroundOutputMessage = {
|
||||
info?: { role?: string; time?: string | { created?: number }; agent?: string }
|
||||
parts?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
content?: string | Array<{ type: string; text?: string }>
|
||||
name?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type BackgroundOutputMessagesResult =
|
||||
| { data?: BackgroundOutputMessage[]; error?: unknown }
|
||||
| BackgroundOutputMessage[]
|
||||
|
||||
export type FullSessionMessagePart = {
|
||||
type?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
content?: string | Array<{ type?: string; text?: string }>
|
||||
output?: string
|
||||
}
|
||||
|
||||
export type FullSessionMessage = {
|
||||
id?: string
|
||||
info?: { role?: string; time?: string; agent?: string }
|
||||
parts?: FullSessionMessagePart[]
|
||||
}
|
||||
|
||||
export function getErrorMessage(value: BackgroundOutputMessagesResult): string | null {
|
||||
if (Array.isArray(value)) return null
|
||||
if (value.error === undefined || value.error === null) return null
|
||||
if (typeof value.error === "string" && value.error.length > 0) return value.error
|
||||
return String(value.error)
|
||||
}
|
||||
|
||||
export function isSessionMessage(value: unknown): value is {
|
||||
info?: { role?: string; time?: string }
|
||||
parts?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
content?: string | Array<{ type: string; text?: string }>
|
||||
name?: string
|
||||
}>
|
||||
} {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(isSessionMessage)
|
||||
}
|
||||
if (Array.isArray(value.data)) {
|
||||
return value.data.filter(isSessionMessage)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function extractToolResultText(part: FullSessionMessagePart): string[] {
|
||||
if (typeof part.content === "string" && part.content.length > 0) {
|
||||
return [part.content]
|
||||
}
|
||||
|
||||
if (Array.isArray(part.content)) {
|
||||
const blocks = part.content
|
||||
.filter((block) => (block.type === "text" || block.type === "reasoning") && block.text)
|
||||
.map((block) => block.text as string)
|
||||
if (blocks.length > 0) return blocks
|
||||
}
|
||||
|
||||
if (part.output && part.output.length > 0) {
|
||||
return [part.output]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getMessageDir } from "../../../shared"
|
||||
|
||||
export { getMessageDir }
|
||||
|
||||
export function formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
} else {
|
||||
return `${seconds}s`
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength) + "..."
|
||||
}
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function formatMessageTime(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toISOString()
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if ("created" in value) {
|
||||
const created = (value as { created?: number }).created
|
||||
if (typeof created === "number") {
|
||||
return new Date(created).toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Unknown time"
|
||||
}
|
||||
|
||||
export type ToolContextWithMetadata = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ALLOWED_AGENTS } from "./constants"
|
||||
import type { AllowedAgentType } from "./types"
|
||||
|
||||
export function normalizeAgentType(input: string): AllowedAgentType | null {
|
||||
const lowered = input.toLowerCase()
|
||||
for (const allowed of ALLOWED_AGENTS) {
|
||||
if (allowed.toLowerCase() === lowered) {
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared"
|
||||
import { extractNewSessionOutput, type SessionMessage } from "./session-message-output-extractor"
|
||||
import { waitForSessionCompletion } from "./session-completion-poller"
|
||||
import { resolveOrCreateSessionId } from "./subagent-session-creator"
|
||||
import { promptSubagentSession } from "./subagent-session-prompter"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
||||
|
||||
function buildTaskMetadata(sessionID: string): string {
|
||||
return ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
function getMessagesArray(result: unknown): SessionMessage[] {
|
||||
if (Array.isArray(result)) return result as SessionMessage[]
|
||||
if (typeof result !== "object" || result === null) return []
|
||||
if (!("data" in result)) return []
|
||||
const data = (result as { data?: unknown }).data
|
||||
return Array.isArray(data) ? (data as SessionMessage[]) : []
|
||||
}
|
||||
|
||||
export async function executeSyncAgent(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: ToolContextWithMetadata,
|
||||
ctx: PluginInput,
|
||||
): Promise<string> {
|
||||
const sessionResult = await resolveOrCreateSessionId(ctx, args, toolContext)
|
||||
if (!sessionResult.ok) {
|
||||
return sessionResult.error
|
||||
}
|
||||
const sessionID = sessionResult.sessionID
|
||||
|
||||
await toolContext.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionID },
|
||||
})
|
||||
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log("[call_omo_agent] Prompt preview", { preview: args.prompt.substring(0, 100) })
|
||||
|
||||
const promptResult = await promptSubagentSession(ctx, {
|
||||
sessionID,
|
||||
agent: args.subagent_type,
|
||||
prompt: args.prompt,
|
||||
})
|
||||
if (!promptResult.ok) {
|
||||
const errorMessage = promptResult.error
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
return `Error: Failed to send prompt: ${errorMessage}\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
|
||||
log("[call_omo_agent] Prompt sent, polling for completion...")
|
||||
const completion = await waitForSessionCompletion(ctx, {
|
||||
sessionID,
|
||||
abortSignal: toolContext.abort,
|
||||
maxPollTimeMs: 5 * 60 * 1000,
|
||||
pollIntervalMs: 500,
|
||||
stabilityRequired: 3,
|
||||
})
|
||||
if (!completion.ok) {
|
||||
if (completion.reason === "aborted") {
|
||||
return `Task aborted.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
return `Error: Agent task timed out after 5 minutes.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
|
||||
const messagesResult = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
if (messagesResult.error) {
|
||||
log("[call_omo_agent] Messages error", { error: messagesResult.error })
|
||||
return `Error: Failed to get messages: ${messagesResult.error}`
|
||||
}
|
||||
|
||||
const messages = getMessagesArray(messagesResult)
|
||||
log("[call_omo_agent] Got messages", { count: messages.length })
|
||||
|
||||
const extracted = extractNewSessionOutput(sessionID, messages)
|
||||
if (!extracted.hasNewOutput) {
|
||||
return `No new output since last check.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
|
||||
log("[call_omo_agent] Got response", { length: extracted.output.length })
|
||||
return `${extracted.output}\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
|
||||
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
|
||||
export async function resolveSkillContent(
|
||||
skills: string[],
|
||||
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string> }
|
||||
): Promise<{ content: string | undefined; error: string | null }> {
|
||||
if (skills.length === 0) {
|
||||
return { content: undefined, error: null }
|
||||
}
|
||||
|
||||
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
|
||||
if (notFound.length > 0) {
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
|
||||
const available = allSkills.map(s => s.name).join(", ")
|
||||
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
|
||||
}
|
||||
|
||||
return { content: Array.from(resolved.values()).join("\n\n"), error: null }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HASH_DICT } from "./constants"
|
||||
|
||||
export function computeLineHash(lineNumber: number, content: string): string {
|
||||
export function computeLineHash(_lineNumber: number, content: string): string {
|
||||
const stripped = content.replace(/\s+/g, "")
|
||||
const hash = Bun.hash.xxHash32(stripped)
|
||||
const index = hash % 256
|
||||
|
||||
Reference in New Issue
Block a user