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:
YeonGyu-Kim
2026-02-08 15:01:42 +09:00
parent df03300211
commit cea97f896b
158 changed files with 7806 additions and 7050 deletions
@@ -0,0 +1,65 @@
export type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
| null
function getErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error.toLowerCase()
const errorObj = error as Record<string, unknown>
const paths = [
errorObj.data,
errorObj.error,
errorObj,
(errorObj.data as Record<string, unknown>)?.error,
]
for (const obj of paths) {
if (obj && typeof obj === "object") {
const msg = (obj as Record<string, unknown>).message
if (typeof msg === "string" && msg.length > 0) {
return msg.toLowerCase()
}
}
}
try {
return JSON.stringify(error).toLowerCase()
} catch {
return ""
}
}
export function extractMessageIndex(error: unknown): number | null {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
}
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (
message.includes("thinking") &&
(message.includes("first block") ||
message.includes("must start with") ||
message.includes("preceeding") ||
message.includes("final block") ||
message.includes("cannot be thinking") ||
(message.includes("expected") && message.includes("found")))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
return null
}
+141
View File
@@ -0,0 +1,141 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../../config"
import { log } from "../../shared/logger"
import { detectErrorType } from "./detect-error-type"
import type { RecoveryErrorType } from "./detect-error-type"
import type { MessageData } from "./types"
import { recoverToolResultMissing } from "./recover-tool-result-missing"
import { recoverThinkingBlockOrder } from "./recover-thinking-block-order"
import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation"
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
interface MessageInfo {
id?: string
role?: string
sessionID?: string
parentID?: string
error?: unknown
}
export interface SessionRecoveryOptions {
experimental?: ExperimentalConfig
}
export interface SessionRecoveryHook {
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
isRecoverableError: (error: unknown) => boolean
setOnAbortCallback: (callback: (sessionID: string) => void) => void
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
}
export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook {
const processingErrors = new Set<string>()
const experimental = options?.experimental
let onAbortCallback: ((sessionID: string) => void) | null = null
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = callback
}
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
onRecoveryCompleteCallback = callback
}
const isRecoverableError = (error: unknown): boolean => {
return detectErrorType(error) !== null
}
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
if (!info || info.role !== "assistant" || !info.error) return false
const errorType = detectErrorType(info.error)
if (!errorType) return false
const sessionID = info.sessionID
const assistantMsgID = info.id
if (!sessionID || !assistantMsgID) return false
if (processingErrors.has(assistantMsgID)) return false
processingErrors.add(assistantMsgID)
try {
if (onAbortCallback) {
onAbortCallback(sessionID)
}
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
if (!failedMsg) {
return false
}
const toastTitles: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Tool Crash Recovery",
thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery",
}
const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...",
thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...",
}
await ctx.client.tui
.showToast({
body: {
title: toastTitles[errorType],
message: toastMessages[errorType],
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
let success = false
if (errorType === "tool_result_missing") {
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
if (success && experimental?.auto_resume) {
const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
} else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
if (success && experimental?.auto_resume) {
const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
}
return success
} catch (err) {
log("[session-recovery] Recovery failed:", err)
return false
} finally {
processingErrors.delete(assistantMsgID)
if (sessionID && onRecoveryCompleteCallback) {
onRecoveryCompleteCallback(sessionID)
}
}
}
return {
handleSessionRecovery,
isRecoverableError,
setOnAbortCallback,
setOnRecoveryCompleteCallback,
}
}
+5 -434
View File
@@ -1,436 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { ExperimentalConfig } from "../../config"
import {
findEmptyMessages,
findEmptyMessageByIndex,
findMessageByIndexNeedingThinking,
findMessagesWithEmptyTextParts,
findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks,
findMessagesWithThinkingOnly,
injectTextPart,
prependThinkingPart,
readParts,
replaceEmptyTextParts,
stripThinkingParts,
} from "./storage"
import type { MessageData, ResumeConfig } from "./types"
import { log } from "../../shared/logger"
export { createSessionRecoveryHook } from "./hook"
export type { SessionRecoveryHook, SessionRecoveryOptions } from "./hook"
export interface SessionRecoveryOptions {
experimental?: ExperimentalConfig
}
export { detectErrorType } from "./detect-error-type"
export type { RecoveryErrorType } from "./detect-error-type"
type Client = ReturnType<typeof createOpencodeClient>
type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
| null
interface MessageInfo {
id?: string
role?: string
sessionID?: string
parentID?: string
error?: unknown
}
interface ToolUsePart {
type: "tool_use"
id: string
name: string
input: Record<string, unknown>
}
interface MessagePart {
type: string
id?: string
text?: string
thinking?: string
name?: string
input?: Record<string, unknown>
}
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
function findLastUserMessage(messages: MessageData[]): MessageData | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info?.role === "user") {
return messages[i]
}
}
return undefined
}
function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig {
return {
sessionID,
agent: userMessage?.info?.agent,
model: userMessage?.info?.model,
}
}
async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
try {
await client.session.promptAsync({
path: { id: config.sessionID },
body: {
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
agent: config.agent,
model: config.model,
},
})
return true
} catch {
return false
}
}
function getErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error.toLowerCase()
const errorObj = error as Record<string, unknown>
const paths = [
errorObj.data,
errorObj.error,
errorObj,
(errorObj.data as Record<string, unknown>)?.error,
]
for (const obj of paths) {
if (obj && typeof obj === "object") {
const msg = (obj as Record<string, unknown>).message
if (typeof msg === "string" && msg.length > 0) {
return msg.toLowerCase()
}
}
}
try {
return JSON.stringify(error).toLowerCase()
} catch {
return ""
}
}
function extractMessageIndex(error: unknown): number | null {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
}
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
// IMPORTANT: Check thinking_block_order BEFORE tool_result_missing
// because Anthropic's extended thinking error messages contain "tool_use" and "tool_result"
// in the documentation URL, which would incorrectly match tool_result_missing
if (
message.includes("thinking") &&
(message.includes("first block") ||
message.includes("must start with") ||
message.includes("preceeding") ||
message.includes("final block") ||
message.includes("cannot be thinking") ||
(message.includes("expected") && message.includes("found")))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
return null
}
function extractToolUseIds(parts: MessagePart[]): string[] {
return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id)
}
async function recoverToolResultMissing(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
// Try API parts first, fallback to filesystem if empty
let parts = failedAssistantMsg.parts || []
if (parts.length === 0 && failedAssistantMsg.info?.id) {
const storedParts = readParts(failedAssistantMsg.info.id)
parts = storedParts.map((p) => ({
type: p.type === "tool" ? "tool_use" : p.type,
id: "callID" in p ? (p as { callID?: string }).callID : p.id,
name: "tool" in p ? (p as { tool?: string }).tool : undefined,
input: "state" in p ? (p as { state?: { input?: Record<string, unknown> } }).state?.input : undefined,
}))
}
const toolUseIds = extractToolUseIds(parts)
if (toolUseIds.length === 0) {
return false
}
const toolResultParts = toolUseIds.map((id) => ({
type: "tool_result" as const,
tool_use_id: id,
content: "Operation cancelled by user (ESC pressed)",
}))
try {
await client.session.promptAsync({
path: { id: sessionID },
// @ts-expect-error - SDK types may not include tool_result parts
body: { parts: toolResultParts },
})
return true
} catch {
return false
}
}
async function recoverThinkingBlockOrder(
_client: Client,
sessionID: string,
_failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
if (targetIndex !== null) {
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
if (targetMessageID) {
return prependThinkingPart(sessionID, targetMessageID)
}
}
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
if (orphanMessages.length === 0) {
return false
}
let anySuccess = false
for (const messageID of orphanMessages) {
if (prependThinkingPart(sessionID, messageID)) {
anySuccess = true
}
}
return anySuccess
}
async function recoverThinkingDisabledViolation(
_client: Client,
sessionID: string,
_failedAssistantMsg: MessageData
): Promise<boolean> {
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
if (messagesWithThinking.length === 0) {
return false
}
let anySuccess = false
for (const messageID of messagesWithThinking) {
if (stripThinkingParts(messageID)) {
anySuccess = true
}
}
return anySuccess
}
const PLACEHOLDER_TEXT = "[user interrupted]"
async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false
const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID)
for (const messageID of messagesWithEmptyText) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) {
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
}
}
if (failedID) {
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
for (const messageID of emptyMessageIDs) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
return anySuccess
}
// NOTE: fallbackRevertStrategy was removed (2025-12-08)
// Reason: Function was defined but never called - no error recovery paths used it.
// All error types have dedicated recovery functions (recoverToolResultMissing,
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
export interface SessionRecoveryHook {
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
isRecoverableError: (error: unknown) => boolean
setOnAbortCallback: (callback: (sessionID: string) => void) => void
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
}
export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook {
const processingErrors = new Set<string>()
const experimental = options?.experimental
let onAbortCallback: ((sessionID: string) => void) | null = null
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = callback
}
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
onRecoveryCompleteCallback = callback
}
const isRecoverableError = (error: unknown): boolean => {
return detectErrorType(error) !== null
}
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
if (!info || info.role !== "assistant" || !info.error) return false
const errorType = detectErrorType(info.error)
if (!errorType) return false
const sessionID = info.sessionID
const assistantMsgID = info.id
if (!sessionID || !assistantMsgID) return false
if (processingErrors.has(assistantMsgID)) return false
processingErrors.add(assistantMsgID)
try {
if (onAbortCallback) {
onAbortCallback(sessionID) // Mark recovering BEFORE abort
}
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
if (!failedMsg) {
return false
}
const toastTitles: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Tool Crash Recovery",
thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery",
}
const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...",
thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...",
}
await ctx.client.tui
.showToast({
body: {
title: toastTitles[errorType],
message: toastMessages[errorType],
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
let success = false
if (errorType === "tool_result_missing") {
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
if (success && experimental?.auto_resume) {
const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
} else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
if (success && experimental?.auto_resume) {
const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
}
return success
} catch (err) {
log("[session-recovery] Recovery failed:", err)
return false
} finally {
processingErrors.delete(assistantMsgID)
// Always notify recovery complete, regardless of success or failure
if (sessionID && onRecoveryCompleteCallback) {
onRecoveryCompleteCallback(sessionID)
}
}
}
return {
handleSessionRecovery,
isRecoverableError,
setOnAbortCallback,
setOnRecoveryCompleteCallback,
}
}
export type { MessageData, ResumeConfig } from "./types"
@@ -0,0 +1,74 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import {
findEmptyMessageByIndex,
findEmptyMessages,
findMessagesWithEmptyTextParts,
findMessagesWithThinkingOnly,
injectTextPart,
replaceEmptyTextParts,
} from "./storage"
type Client = ReturnType<typeof createOpencodeClient>
const PLACEHOLDER_TEXT = "[user interrupted]"
export async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false
const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID)
for (const messageID of messagesWithEmptyText) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) {
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
}
}
if (failedID) {
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
for (const messageID of emptyMessageIDs) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
return anySuccess
}
@@ -0,0 +1,36 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage"
type Client = ReturnType<typeof createOpencodeClient>
export async function recoverThinkingBlockOrder(
_client: Client,
sessionID: string,
_failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
if (targetIndex !== null) {
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
if (targetMessageID) {
return prependThinkingPart(sessionID, targetMessageID)
}
}
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
if (orphanMessages.length === 0) {
return false
}
let anySuccess = false
for (const messageID of orphanMessages) {
if (prependThinkingPart(sessionID, messageID)) {
anySuccess = true
}
}
return anySuccess
}
@@ -0,0 +1,25 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage"
type Client = ReturnType<typeof createOpencodeClient>
export async function recoverThinkingDisabledViolation(
_client: Client,
sessionID: string,
_failedAssistantMsg: MessageData
): Promise<boolean> {
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
if (messagesWithThinking.length === 0) {
return false
}
let anySuccess = false
for (const messageID of messagesWithThinking) {
if (stripThinkingParts(messageID)) {
anySuccess = true
}
}
return anySuccess
}
@@ -0,0 +1,61 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { readParts } from "./storage"
type Client = ReturnType<typeof createOpencodeClient>
interface ToolUsePart {
type: "tool_use"
id: string
name: string
input: Record<string, unknown>
}
interface MessagePart {
type: string
id?: string
}
function extractToolUseIds(parts: MessagePart[]): string[] {
return parts.filter((part): part is ToolUsePart => part.type === "tool_use" && !!part.id).map((part) => part.id)
}
export async function recoverToolResultMissing(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
let parts = failedAssistantMsg.parts || []
if (parts.length === 0 && failedAssistantMsg.info?.id) {
const storedParts = readParts(failedAssistantMsg.info.id)
parts = storedParts.map((part) => ({
type: part.type === "tool" ? "tool_use" : part.type,
id: "callID" in part ? (part as { callID?: string }).callID : part.id,
}))
}
const toolUseIds = extractToolUseIds(parts)
if (toolUseIds.length === 0) {
return false
}
const toolResultParts = toolUseIds.map((id) => ({
type: "tool_result" as const,
tool_use_id: id,
content: "Operation cancelled by user (ESC pressed)",
}))
const promptInput = {
path: { id: sessionID },
body: { parts: toolResultParts },
}
try {
// @ts-expect-error - SDK types may not include tool_result parts
await client.session.promptAsync(promptInput)
return true
} catch {
return false
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData, ResumeConfig } from "./types"
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
type Client = ReturnType<typeof createOpencodeClient>
export function findLastUserMessage(messages: MessageData[]): MessageData | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info?.role === "user") {
return messages[i]
}
}
return undefined
}
export function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig {
return {
sessionID,
agent: userMessage?.info?.agent,
model: userMessage?.info?.model,
}
}
export async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
try {
await client.session.promptAsync({
path: { id: config.sessionID },
body: {
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
agent: config.agent,
model: config.model,
},
})
return true
} catch {
return false
}
}
+26 -390
View File
@@ -1,390 +1,26 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
export function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
export function getMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) return ""
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 ""
}
export function readMessages(sessionID: string): StoredMessageMeta[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: StoredMessageMeta[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(messageDir, file), "utf-8")
messages.push(JSON.parse(content))
} catch {
continue
}
}
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
export function readParts(messageID: string): StoredPart[] {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: StoredPart[] = []
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
return parts
}
export function hasContent(part: StoredPart): boolean {
if (THINKING_TYPES.has(part.type)) return false
if (META_TYPES.has(part.type)) return false
if (part.type === "text") {
const textPart = part as StoredTextPart
return !!(textPart.text?.trim())
}
if (part.type === "tool" || part.type === "tool_use") {
return true
}
if (part.type === "tool_result") {
return true
}
return false
}
export function messageHasContent(messageID: string): boolean {
const parts = readParts(messageID)
return parts.some(hasContent)
}
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const partId = generatePartId()
const part: StoredTextPart = {
id: partId,
sessionID,
messageID,
type: "text",
text,
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
export function findEmptyMessages(sessionID: string): string[] {
const messages = readMessages(sessionID)
const emptyIds: string[] = []
for (const msg of messages) {
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
}
return emptyIds
}
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
// API index may differ from storage index due to system messages
const indicesToTry = [
targetIndex,
targetIndex - 1,
targetIndex + 1,
targetIndex - 2,
targetIndex + 2,
targetIndex - 3,
targetIndex - 4,
targetIndex - 5,
]
for (const idx of indicesToTry) {
if (idx < 0 || idx >= messages.length) continue
const targetMsg = messages[idx]
if (!messageHasContent(targetMsg.id)) {
return targetMsg.id
}
}
return null
}
export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null
}
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
if (hasThinking) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
const hasTextContent = parts.some(hasContent)
// Has thinking but no text content = orphan thinking
if (hasThinking && !hasTextContent) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== "assistant") continue
// NOTE: Removed isLastMessage skip - recovery needs to fix last message too
// when "thinking must start with" errors occur on final assistant message
const parts = readParts(msg.id)
if (parts.length === 0) continue
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
// NOTE: Changed condition - if first part is not thinking, it's orphan
// regardless of whether thinking blocks exist elsewhere in the message
if (!firstIsThinking) {
result.push(msg.id)
}
}
return result
}
/**
* Find the most recent thinking content from previous assistant messages
* Following Anthropic's recommendation to include thinking blocks from previous turns
*/
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
const messages = readMessages(sessionID)
// Find the index of the current message
const currentIndex = messages.findIndex(m => m.id === beforeMessageID)
if (currentIndex === -1) return ""
// Search backwards through previous assistant messages
for (let i = currentIndex - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
// Look for thinking parts in this message
const parts = readParts(msg.id)
for (const part of parts) {
if (THINKING_TYPES.has(part.type)) {
// Found thinking content - return it
// Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property
const thinking = (part as { thinking?: string; text?: string }).thinking
const reasoning = (part as { thinking?: string; text?: string }).text
const content = thinking || reasoning
if (content && content.trim().length > 0) {
return content
}
}
}
}
return ""
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
// Try to get thinking content from previous turns (Anthropic's recommendation)
const previousThinking = findLastThinkingContent(sessionID, messageID)
const partId = `prt_0000000000_thinking`
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: previousThinking || "[Continuing from previous reasoning]",
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
export function stripThinkingParts(messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyRemoved = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (THINKING_TYPES.has(part.type)) {
unlinkSync(filePath)
anyRemoved = true
}
} catch {
continue
}
}
return anyRemoved
}
export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyReplaced = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (part.type === "text") {
const textPart = part as StoredTextPart
if (!textPart.text?.trim()) {
textPart.text = replacementText
textPart.synthetic = true
writeFileSync(filePath, JSON.stringify(textPart, null, 2))
anyReplaced = true
}
}
} catch {
continue
}
}
return anyReplaced
}
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
const parts = readParts(msg.id)
const hasEmptyTextPart = parts.some((p) => {
if (p.type !== "text") return false
const textPart = p as StoredTextPart
return !textPart.text?.trim()
})
if (hasEmptyTextPart) {
result.push(msg.id)
}
}
return result
}
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
if (targetIndex < 0 || targetIndex >= messages.length) return null
const targetMsg = messages[targetIndex]
if (targetMsg.role !== "assistant") return null
const parts = readParts(targetMsg.id)
if (parts.length === 0) return null
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
if (!firstIsThinking) {
return targetMsg.id
}
return null
}
export { generatePartId } from "./storage/part-id"
export { getMessageDir } from "./storage/message-dir"
export { readMessages } from "./storage/messages-reader"
export { readParts } from "./storage/parts-reader"
export { hasContent, messageHasContent } from "./storage/part-content"
export { injectTextPart } from "./storage/text-part-injector"
export {
findEmptyMessages,
findEmptyMessageByIndex,
findFirstEmptyMessage,
} from "./storage/empty-messages"
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
export {
findMessagesWithThinkingBlocks,
findMessagesWithThinkingOnly,
} from "./storage/thinking-block-search"
export {
findMessagesWithOrphanThinking,
findMessageByIndexNeedingThinking,
} from "./storage/orphan-thinking-search"
export { prependThinkingPart } from "./storage/thinking-prepend"
export { stripThinkingParts } from "./storage/thinking-strip"
export { replaceEmptyTextParts } from "./storage/empty-text"
@@ -0,0 +1,47 @@
import { messageHasContent } from "./part-content"
import { readMessages } from "./messages-reader"
export function findEmptyMessages(sessionID: string): string[] {
const messages = readMessages(sessionID)
const emptyIds: string[] = []
for (const msg of messages) {
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
}
return emptyIds
}
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
const indicesToTry = [
targetIndex,
targetIndex - 1,
targetIndex + 1,
targetIndex - 2,
targetIndex + 2,
targetIndex - 3,
targetIndex - 4,
targetIndex - 5,
]
for (const index of indicesToTry) {
if (index < 0 || index >= messages.length) continue
const targetMessage = messages[index]
if (!messageHasContent(targetMessage.id)) {
return targetMessage.id
}
}
return null
}
export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null
}
@@ -0,0 +1,55 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE } from "../constants"
import type { StoredPart, StoredTextPart } from "../types"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyReplaced = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (part.type === "text") {
const textPart = part as StoredTextPart
if (!textPart.text?.trim()) {
textPart.text = replacementText
textPart.synthetic = true
writeFileSync(filePath, JSON.stringify(textPart, null, 2))
anyReplaced = true
}
}
} catch {
continue
}
}
return anyReplaced
}
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
const parts = readParts(msg.id)
const hasEmptyTextPart = parts.some((part) => {
if (part.type !== "text") return false
const textPart = part as StoredTextPart
return !textPart.text?.trim()
})
if (hasEmptyTextPart) {
result.push(msg.id)
}
}
return result
}
@@ -0,0 +1,21 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE } from "../constants"
export function getMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) return ""
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 ""
}
@@ -0,0 +1,27 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { StoredMessageMeta } from "../types"
import { getMessageDir } from "./message-dir"
export function readMessages(sessionID: string): StoredMessageMeta[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: StoredMessageMeta[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(messageDir, file), "utf-8")
messages.push(JSON.parse(content))
} catch {
continue
}
}
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
@@ -0,0 +1,43 @@
import { THINKING_TYPES } from "../constants"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
if (!firstIsThinking) {
result.push(msg.id)
}
}
return result
}
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
if (targetIndex < 0 || targetIndex >= messages.length) return null
const targetMessage = messages[targetIndex]
if (targetMessage.role !== "assistant") return null
const parts = readParts(targetMessage.id)
if (parts.length === 0) return null
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
return firstIsThinking ? null : targetMessage.id
}
@@ -0,0 +1,28 @@
import { THINKING_TYPES, META_TYPES } from "../constants"
import type { StoredPart, StoredTextPart } from "../types"
import { readParts } from "./parts-reader"
export function hasContent(part: StoredPart): boolean {
if (THINKING_TYPES.has(part.type)) return false
if (META_TYPES.has(part.type)) return false
if (part.type === "text") {
const textPart = part as StoredTextPart
return !!textPart.text?.trim()
}
if (part.type === "tool" || part.type === "tool_use") {
return true
}
if (part.type === "tool_result") {
return true
}
return false
}
export function messageHasContent(messageID: string): boolean {
const parts = readParts(messageID)
return parts.some(hasContent)
}
@@ -0,0 +1,5 @@
export function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
@@ -0,0 +1,22 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE } from "../constants"
import type { StoredPart } from "../types"
export function readParts(messageID: string): StoredPart[] {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: StoredPart[] = []
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
return parts
}
@@ -0,0 +1,30 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE } from "../constants"
import type { StoredTextPart } from "../types"
import { generatePartId } from "./part-id"
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const partId = generatePartId()
const part: StoredTextPart = {
id: partId,
sessionID,
messageID,
type: "text",
text,
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
@@ -0,0 +1,42 @@
import { THINKING_TYPES } from "../constants"
import { hasContent } from "./part-content"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type))
if (hasThinking) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type))
const hasTextContent = parts.some(hasContent)
if (hasThinking && !hasTextContent) {
result.push(msg.id)
}
}
return result
}
@@ -0,0 +1,58 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE, THINKING_TYPES } from "../constants"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
const messages = readMessages(sessionID)
const currentIndex = messages.findIndex((message) => message.id === beforeMessageID)
if (currentIndex === -1) return ""
for (let i = currentIndex - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== "assistant") continue
const parts = readParts(message.id)
for (const part of parts) {
if (THINKING_TYPES.has(part.type)) {
const thinking = (part as { thinking?: string; text?: string }).thinking
const reasoning = (part as { thinking?: string; text?: string }).text
const content = thinking || reasoning
if (content && content.trim().length > 0) {
return content
}
}
}
}
return ""
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const previousThinking = findLastThinkingContent(sessionID, messageID)
const partId = "prt_0000000000_thinking"
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: previousThinking || "[Continuing from previous reasoning]",
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
@@ -0,0 +1,27 @@
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
import { join } from "node:path"
import { PART_STORAGE, THINKING_TYPES } from "../constants"
import type { StoredPart } from "../types"
export function stripThinkingParts(messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyRemoved = false
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const filePath = join(partDir, file)
const content = readFileSync(filePath, "utf-8")
const part = JSON.parse(content) as StoredPart
if (THINKING_TYPES.has(part.type)) {
unlinkSync(filePath)
anyRemoved = true
}
} catch {
continue
}
}
return anyRemoved
}