944e64a1f9
When /stop-continuation is invoked during the 2s countdown, the stop flag was never checked inside injectContinuation, so the injection would still fire after the countdown elapsed. Propagate isContinuationStopped from handleSessionIdle through startCountdown into injectContinuation, where it is now re-checked before any API call.
174 lines
5.6 KiB
TypeScript
174 lines
5.6 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
|
|
import type { BackgroundManager } from "../../features/background-agent"
|
|
import { normalizeSDKResponse, resolveInheritedPromptTools } from "../../shared"
|
|
import {
|
|
findNearestMessageWithFields,
|
|
findNearestMessageWithFieldsFromSDK,
|
|
type ToolPermission,
|
|
} from "../../features/hook-message-injector"
|
|
import { log } from "../../shared/logger"
|
|
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
|
|
|
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
|
|
isContinuationStopped?: (sessionID: string) => boolean
|
|
}): Promise<void> {
|
|
const {
|
|
ctx,
|
|
sessionID,
|
|
backgroundManager,
|
|
skipAgents = DEFAULT_SKIP_AGENTS,
|
|
resolvedInfo,
|
|
sessionStateStore,
|
|
isContinuationStopped,
|
|
} = args
|
|
|
|
const state = sessionStateStore.getExistingState(sessionID)
|
|
if (state?.isRecovering) {
|
|
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
|
return
|
|
}
|
|
|
|
if (isContinuationStopped?.(sessionID)) {
|
|
log(`[${HOOK_NAME}] Skipped injection: continuation stopped for session`, { 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 = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
|
|
} 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) {
|
|
let previousMessage = null
|
|
if (isSqliteBackend()) {
|
|
previousMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
|
|
} else {
|
|
const messageDir = getMessageDir(sessionID)
|
|
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.some(s => getAgentConfigKey(s) === getAgentConfigKey(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,
|
|
})
|
|
|
|
const inheritedTools = resolveInheritedPromptTools(sessionID, tools)
|
|
|
|
await ctx.client.session.promptAsync({
|
|
path: { id: sessionID },
|
|
body: {
|
|
agent: agentName,
|
|
...(model !== undefined ? { model } : {}),
|
|
...(inheritedTools ? { tools: inheritedTools } : {}),
|
|
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 = (injectionState.consecutiveFailures ?? 0) + 1
|
|
}
|
|
}
|
|
}
|