155 lines
4.7 KiB
TypeScript
155 lines
4.7 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
|
|
import type { BackgroundManager } from "../../features/background-agent"
|
|
import {
|
|
findNearestMessageWithFields,
|
|
type ToolPermission,
|
|
} from "../../features/hook-message-injector"
|
|
import { log } from "../../shared/logger"
|
|
|
|
import {
|
|
CONTINUATION_PROMPT,
|
|
DEFAULT_SKIP_AGENTS,
|
|
HOOK_NAME,
|
|
} from "./constants"
|
|
import { getMessageDir } from "./message-directory"
|
|
import { getIncompleteCount } from "./todo"
|
|
import type { ResolvedMessageInfo, Todo } from "./types"
|
|
import type { SessionStateStore } from "./session-state"
|
|
|
|
function hasWritePermission(tools: Record<string, ToolPermission> | undefined): boolean {
|
|
const editPermission = tools?.edit
|
|
const writePermission = tools?.write
|
|
return (
|
|
!tools ||
|
|
(editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny")
|
|
)
|
|
}
|
|
|
|
export async function injectContinuation(args: {
|
|
ctx: PluginInput
|
|
sessionID: string
|
|
backgroundManager?: BackgroundManager
|
|
skipAgents?: string[]
|
|
resolvedInfo?: ResolvedMessageInfo
|
|
sessionStateStore: SessionStateStore
|
|
}): Promise<void> {
|
|
const {
|
|
ctx,
|
|
sessionID,
|
|
backgroundManager,
|
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
|
resolvedInfo,
|
|
sessionStateStore,
|
|
} = args
|
|
|
|
const state = sessionStateStore.getExistingState(sessionID)
|
|
if (state?.isRecovering) {
|
|
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
|
return
|
|
}
|
|
|
|
const hasRunningBgTasks = backgroundManager
|
|
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
|
: false
|
|
|
|
if (hasRunningBgTasks) {
|
|
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
|
|
return
|
|
}
|
|
|
|
let todos: Todo[] = []
|
|
try {
|
|
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
|
todos = (response.data ?? response) as Todo[]
|
|
} catch (error) {
|
|
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) })
|
|
return
|
|
}
|
|
|
|
const freshIncompleteCount = getIncompleteCount(todos)
|
|
if (freshIncompleteCount === 0) {
|
|
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
|
|
return
|
|
}
|
|
|
|
let agentName = resolvedInfo?.agent
|
|
let model = resolvedInfo?.model
|
|
let tools = resolvedInfo?.tools
|
|
|
|
if (!agentName || !model) {
|
|
const messageDir = getMessageDir(sessionID)
|
|
const previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
|
agentName = agentName ?? previousMessage?.agent
|
|
model =
|
|
model ??
|
|
(previousMessage?.model?.providerID && previousMessage?.model?.modelID
|
|
? {
|
|
providerID: previousMessage.model.providerID,
|
|
modelID: previousMessage.model.modelID,
|
|
...(previousMessage.model.variant
|
|
? { variant: previousMessage.model.variant }
|
|
: {}),
|
|
}
|
|
: undefined)
|
|
tools = tools ?? previousMessage?.tools
|
|
}
|
|
|
|
if (agentName && skipAgents.includes(agentName)) {
|
|
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
|
return
|
|
}
|
|
|
|
if (!hasWritePermission(tools)) {
|
|
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
|
|
return
|
|
}
|
|
|
|
const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
|
|
const todoList = incompleteTodos.map((todo) => `- [${todo.status}] ${todo.content}`).join("\n")
|
|
const prompt = `${CONTINUATION_PROMPT}
|
|
|
|
[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]
|
|
|
|
Remaining tasks:
|
|
${todoList}`
|
|
|
|
const injectionState = sessionStateStore.getExistingState(sessionID)
|
|
if (injectionState) {
|
|
injectionState.inFlight = true
|
|
}
|
|
|
|
try {
|
|
log(`[${HOOK_NAME}] Injecting continuation`, {
|
|
sessionID,
|
|
agent: agentName,
|
|
model,
|
|
incompleteCount: freshIncompleteCount,
|
|
})
|
|
|
|
await ctx.client.session.promptAsync({
|
|
path: { id: sessionID },
|
|
body: {
|
|
agent: agentName,
|
|
...(model !== undefined ? { model } : {}),
|
|
parts: [{ type: "text", text: prompt }],
|
|
},
|
|
query: { directory: ctx.directory },
|
|
})
|
|
|
|
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
|
if (injectionState) {
|
|
injectionState.inFlight = false
|
|
injectionState.lastInjectedAt = Date.now()
|
|
injectionState.consecutiveFailures = 0
|
|
}
|
|
} catch (error) {
|
|
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
|
|
if (injectionState) {
|
|
injectionState.inFlight = false
|
|
injectionState.lastInjectedAt = Date.now()
|
|
injectionState.consecutiveFailures += 1
|
|
}
|
|
}
|
|
}
|