refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks
- Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.) - Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files - Decompose session-recovery into separate recovery strategy files per error type - Extract todo-continuation-enforcer from monolith to directory with dedicated modules - Split background-task/tools.ts into individual tool creator files - Extract command-executor, tmux-utils into focused sub-modules - Split config/schema.ts into domain-specific schema files - Decompose cli/config-manager.ts into focused modules - Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests - Fix all import path depths for moved files (../../ -> ../../../) - Add explicit type annotations to resolve TS7006 implicit any errors Typecheck: 0 errors Tests: 2359 pass, 5 fail (all pre-existing)
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { MessageInfo } from "./types"
|
||||
|
||||
export function isLastAssistantMessageAborted(
|
||||
messages: Array<{ info?: MessageInfo }>
|
||||
): boolean {
|
||||
if (!messages || messages.length === 0) return false
|
||||
|
||||
const assistantMessages = messages.filter((message) => message.info?.role === "assistant")
|
||||
if (assistantMessages.length === 0) return false
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
const errorName = lastAssistant.info?.error?.name
|
||||
|
||||
if (!errorName) return false
|
||||
|
||||
return errorName === "MessageAbortedError" || errorName === "AbortError"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||
|
||||
export const HOOK_NAME = "todo-continuation-enforcer"
|
||||
|
||||
export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
|
||||
|
||||
export const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
|
||||
|
||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||
|
||||
- Proceed without asking for permission
|
||||
- Mark each task complete when finished
|
||||
- Do not stop until all tasks are done`
|
||||
|
||||
export const COUNTDOWN_SECONDS = 2
|
||||
export const TOAST_DURATION_MS = 900
|
||||
export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||
|
||||
export const ABORT_WINDOW_MS = 3000
|
||||
@@ -0,0 +1,139 @@
|
||||
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}`
|
||||
|
||||
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 })
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import {
|
||||
COUNTDOWN_SECONDS,
|
||||
HOOK_NAME,
|
||||
TOAST_DURATION_MS,
|
||||
} from "./constants"
|
||||
import type { ResolvedMessageInfo } from "./types"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
import { injectContinuation } from "./continuation-injection"
|
||||
|
||||
async function showCountdownToast(
|
||||
ctx: PluginInput,
|
||||
seconds: number,
|
||||
incompleteCount: number
|
||||
): Promise<void> {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Todo Continuation",
|
||||
message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`,
|
||||
variant: "warning" as const,
|
||||
duration: TOAST_DURATION_MS,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export function startCountdown(args: {
|
||||
ctx: PluginInput
|
||||
sessionID: string
|
||||
incompleteCount: number
|
||||
total: number
|
||||
resolvedInfo?: ResolvedMessageInfo
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents: string[]
|
||||
sessionStateStore: SessionStateStore
|
||||
}): void {
|
||||
const {
|
||||
ctx,
|
||||
sessionID,
|
||||
incompleteCount,
|
||||
resolvedInfo,
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
sessionStateStore,
|
||||
} = args
|
||||
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
|
||||
let secondsRemaining = COUNTDOWN_SECONDS
|
||||
showCountdownToast(ctx, secondsRemaining, incompleteCount)
|
||||
state.countdownStartedAt = Date.now()
|
||||
|
||||
state.countdownInterval = setInterval(() => {
|
||||
secondsRemaining--
|
||||
if (secondsRemaining > 0) {
|
||||
showCountdownToast(ctx, secondsRemaining, incompleteCount)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
state.countdownTimer = setTimeout(() => {
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
resolvedInfo,
|
||||
sessionStateStore,
|
||||
})
|
||||
}, COUNTDOWN_SECONDS * 1000)
|
||||
|
||||
log(`[${HOOK_NAME}] Countdown started`, {
|
||||
sessionID,
|
||||
seconds: COUNTDOWN_SECONDS,
|
||||
incompleteCount,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
import { handleSessionIdle } from "./idle-event"
|
||||
import { handleNonIdleEvent } from "./non-idle-events"
|
||||
|
||||
export function createTodoContinuationHandler(args: {
|
||||
ctx: PluginInput
|
||||
sessionStateStore: SessionStateStore
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const {
|
||||
ctx,
|
||||
sessionStateStore,
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
} = args
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.abortDetectedAt = Date.now()
|
||||
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
||||
}
|
||||
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
await handleSessionIdle({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionStateStore,
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
isContinuationStopped,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
handleNonIdleEvent({
|
||||
eventType: event.type,
|
||||
properties: props,
|
||||
sessionStateStore,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import type { ToolPermission } from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import {
|
||||
ABORT_WINDOW_MS,
|
||||
DEFAULT_SKIP_AGENTS,
|
||||
HOOK_NAME,
|
||||
} from "./constants"
|
||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||
import { getIncompleteCount } from "./todo"
|
||||
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
import { startCountdown } from "./countdown"
|
||||
|
||||
export async function handleSessionIdle(args: {
|
||||
ctx: PluginInput
|
||||
sessionID: string
|
||||
sessionStateStore: SessionStateStore
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
}): Promise<void> {
|
||||
const {
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionStateStore,
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
} = args
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isMainSession = sessionID === mainSessionID
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
|
||||
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
if (state.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (state.abortDetectedAt) {
|
||||
const timeSinceAbort = Date.now() - state.abortDetectedAt
|
||||
if (timeSinceAbort < ABORT_WINDOW_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID })
|
||||
state.abortDetectedAt = undefined
|
||||
return
|
||||
}
|
||||
state.abortDetectedAt = undefined
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
|
||||
if (isLastAssistantMessageAborted(messages)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) })
|
||||
}
|
||||
|
||||
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}] Todo fetch failed`, { sessionID, error: String(error) })
|
||||
return
|
||||
}
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const incompleteCount = getIncompleteCount(todos)
|
||||
if (incompleteCount === 0) {
|
||||
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedInfo: ResolvedMessageInfo | undefined
|
||||
let hasCompactionMessage = false
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent === "compaction") {
|
||||
hasCompactionMessage = true
|
||||
continue
|
||||
}
|
||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||
resolvedInfo = {
|
||||
agent: info.agent,
|
||||
model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined),
|
||||
tools: info.tools as Record<string, ToolPermission> | undefined,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) })
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
|
||||
|
||||
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
|
||||
return
|
||||
}
|
||||
if (hasCompactionMessage && !resolvedInfo?.agent) {
|
||||
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (isContinuationStopped?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown({
|
||||
ctx,
|
||||
sessionID,
|
||||
incompleteCount,
|
||||
total: todos.length,
|
||||
resolvedInfo,
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
sessionStateStore,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
|
||||
import { createTodoContinuationHandler } from "./handler"
|
||||
import { createSessionStateStore } from "./session-state"
|
||||
import type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types"
|
||||
|
||||
export type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types"
|
||||
|
||||
export function createTodoContinuationEnforcer(
|
||||
ctx: PluginInput,
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
): TodoContinuationEnforcer {
|
||||
const {
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
} = options
|
||||
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
|
||||
const markRecovering = (sessionID: string): void => {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.isRecovering = true
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
|
||||
}
|
||||
|
||||
const markRecoveryComplete = (sessionID: string): void => {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) {
|
||||
state.isRecovering = false
|
||||
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
const handler = createTodoContinuationHandler({
|
||||
ctx,
|
||||
sessionStateStore,
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
isContinuationStopped,
|
||||
})
|
||||
|
||||
const cancelAllCountdowns = (): void => {
|
||||
sessionStateStore.cancelAllCountdowns()
|
||||
log(`[${HOOK_NAME}] All countdowns cancelled`)
|
||||
}
|
||||
|
||||
return {
|
||||
handler,
|
||||
markRecovering,
|
||||
markRecoveryComplete,
|
||||
cancelAllCountdowns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
|
||||
export function handleNonIdleEvent(args: {
|
||||
eventType: string
|
||||
properties: Record<string, unknown> | undefined
|
||||
sessionStateStore: SessionStateStore
|
||||
}): void {
|
||||
const { eventType, properties, sessionStateStore } = args
|
||||
|
||||
if (eventType === "message.updated") {
|
||||
const info = properties?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (role === "user") {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state?.countdownStartedAt) {
|
||||
const elapsed = Date.now() - state.countdownStartedAt
|
||||
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
|
||||
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (eventType === "message.part.updated") {
|
||||
const info = properties?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (eventType === "session.deleted") {
|
||||
const sessionInfo = properties?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionStateStore.cleanup(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SessionState } from "./types"
|
||||
|
||||
export interface SessionStateStore {
|
||||
getState: (sessionID: string) => SessionState
|
||||
getExistingState: (sessionID: string) => SessionState | undefined
|
||||
cancelCountdown: (sessionID: string) => void
|
||||
cleanup: (sessionID: string) => void
|
||||
cancelAllCountdowns: () => void
|
||||
}
|
||||
|
||||
export function createSessionStateStore(): SessionStateStore {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
const existingState = sessions.get(sessionID)
|
||||
if (existingState) return existingState
|
||||
|
||||
const state: SessionState = {}
|
||||
sessions.set(sessionID, state)
|
||||
return state
|
||||
}
|
||||
|
||||
function getExistingState(sessionID: string): SessionState | undefined {
|
||||
return sessions.get(sessionID)
|
||||
}
|
||||
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
const state = sessions.get(sessionID)
|
||||
if (!state) return
|
||||
|
||||
if (state.countdownTimer) {
|
||||
clearTimeout(state.countdownTimer)
|
||||
state.countdownTimer = undefined
|
||||
}
|
||||
|
||||
if (state.countdownInterval) {
|
||||
clearInterval(state.countdownInterval)
|
||||
state.countdownInterval = undefined
|
||||
}
|
||||
|
||||
state.countdownStartedAt = undefined
|
||||
}
|
||||
|
||||
function cleanup(sessionID: string): void {
|
||||
cancelCountdown(sessionID)
|
||||
sessions.delete(sessionID)
|
||||
}
|
||||
|
||||
function cancelAllCountdowns(): void {
|
||||
for (const sessionID of sessions.keys()) {
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getState,
|
||||
getExistingState,
|
||||
cancelCountdown,
|
||||
cleanup,
|
||||
cancelAllCountdowns,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
import type { Todo } from "./types"
|
||||
|
||||
export function getIncompleteCount(todos: Todo[]): number {
|
||||
return todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled").length
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { ToolPermission } from "../../features/hook-message-injector"
|
||||
|
||||
export interface TodoContinuationEnforcerOptions {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
}
|
||||
|
||||
export interface TodoContinuationEnforcer {
|
||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
markRecovering: (sessionID: string) => void
|
||||
markRecoveryComplete: (sessionID: string) => void
|
||||
cancelAllCountdowns: () => void
|
||||
}
|
||||
|
||||
export interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
countdownTimer?: ReturnType<typeof setTimeout>
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
abortDetectedAt?: number
|
||||
}
|
||||
|
||||
export interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
error?: { name?: string; data?: unknown }
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
|
||||
export interface ResolvedMessageInfo {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
Reference in New Issue
Block a user