feat(config): add experimental config for gating unstable features (#110)

* feat(anthropic-auto-compact): add aggressive truncation and empty message recovery

Add truncateUntilTargetTokens method, empty content recovery mechanism, and
emptyContentAttemptBySession tracking for robust message handling.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(session-recovery): add auto-resume and recovery callbacks

Implement ResumeConfig, resumeSession() method, and callback support for
enhanced session recovery and resume functionality.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(config): add experimental config schema for gating unstable features

This adds a new 'experimental' config field to the OhMyOpenCode schema that enables fine-grained control over unstable/experimental features:

- aggressive_truncation: Enables aggressive token truncation in anthropic-auto-compact hook for more aggressive token limit handling
- empty_message_recovery: Enables empty message recovery mechanism in anthropic-auto-compact hook for fixing truncation-induced empty message errors
- auto_resume: Enables automatic session resume after recovery in session-recovery hook for seamless recovery experience

The experimental config is optional and all experimental features are disabled by default, ensuring backward compatibility while allowing early adopters to opt-in to cutting-edge features.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-19 02:45:59 +09:00
committed by GitHub
parent 7fe6423abf
commit 8cf713e149
11 changed files with 422 additions and 33 deletions

View File

@@ -6,6 +6,7 @@ export {
AgentNameSchema, AgentNameSchema,
HookNameSchema, HookNameSchema,
OmoAgentConfigSchema, OmoAgentConfigSchema,
ExperimentalConfigSchema,
} from "./schema" } from "./schema"
export type { export type {
@@ -16,4 +17,5 @@ export type {
AgentName, AgentName,
HookName, HookName,
OmoAgentConfig, OmoAgentConfig,
ExperimentalConfig,
} from "./schema" } from "./schema"

View File

@@ -106,6 +106,12 @@ export const OmoAgentConfigSchema = z.object({
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
}) })
export const ExperimentalConfigSchema = z.object({
aggressive_truncation: z.boolean().optional(),
empty_message_recovery: z.boolean().optional(),
auto_resume: z.boolean().optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({ export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(), $schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(), disabled_mcps: z.array(McpNameSchema).optional(),
@@ -115,6 +121,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
claude_code: ClaudeCodeConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(),
google_auth: z.boolean().optional(), google_auth: z.boolean().optional(),
omo_agent: OmoAgentConfigSchema.optional(), omo_agent: OmoAgentConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
}) })
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema> export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
@@ -123,5 +130,6 @@ export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type AgentName = z.infer<typeof AgentNameSchema> export type AgentName = z.infer<typeof AgentNameSchema>
export type HookName = z.infer<typeof HookNameSchema> export type HookName = z.infer<typeof HookNameSchema>
export type OmoAgentConfig = z.infer<typeof OmoAgentConfigSchema> export type OmoAgentConfig = z.infer<typeof OmoAgentConfigSchema>
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
export { McpNameSchema, type McpName } from "../mcp/types" export { McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -1,6 +1,9 @@
import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types" import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types"
import type { ExperimentalConfig } from "../../config"
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types" import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
import { findLargestToolResult, truncateToolResult } from "./storage" import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
import { findEmptyMessages, injectTextPart } from "../session-recovery/storage"
import { log } from "../../shared/logger"
type Client = { type Client = {
session: { session: {
@@ -151,24 +154,151 @@ function clearSessionState(autoCompactState: AutoCompactState, sessionID: string
autoCompactState.retryStateBySession.delete(sessionID) autoCompactState.retryStateBySession.delete(sessionID)
autoCompactState.fallbackStateBySession.delete(sessionID) autoCompactState.fallbackStateBySession.delete(sessionID)
autoCompactState.truncateStateBySession.delete(sessionID) autoCompactState.truncateStateBySession.delete(sessionID)
autoCompactState.emptyContentAttemptBySession.delete(sessionID)
autoCompactState.compactionInProgress.delete(sessionID) autoCompactState.compactionInProgress.delete(sessionID)
} }
function getOrCreateEmptyContentAttempt(
autoCompactState: AutoCompactState,
sessionID: string
): number {
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0
}
async function fixEmptyMessages(
sessionID: string,
autoCompactState: AutoCompactState,
client: Client
): Promise<boolean> {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
const emptyMessageIds = findEmptyMessages(sessionID)
if (emptyMessageIds.length === 0) {
await client.tui
.showToast({
body: {
title: "Empty Content Error",
message: "No empty messages found in storage. Cannot auto-recover.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return false
}
let fixed = false
for (const messageID of emptyMessageIds) {
const success = injectTextPart(sessionID, messageID, "[user interrupted]")
if (success) fixed = true
}
if (fixed) {
await client.tui
.showToast({
body: {
title: "Session Recovery",
message: `Fixed ${emptyMessageIds.length} empty messages. Retrying...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
}
return fixed
}
export async function executeCompact( export async function executeCompact(
sessionID: string, sessionID: string,
msg: Record<string, unknown>, msg: Record<string, unknown>,
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any, client: any,
directory: string directory: string,
experimental?: ExperimentalConfig
): Promise<void> { ): Promise<void> {
if (autoCompactState.compactionInProgress.has(sessionID)) { if (autoCompactState.compactionInProgress.has(sessionID)) {
return return
} }
autoCompactState.compactionInProgress.add(sessionID) autoCompactState.compactionInProgress.add(sessionID)
const errorData = autoCompactState.errorDataBySession.get(sessionID)
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID) const truncateState = getOrCreateTruncateState(autoCompactState, sessionID)
if (
experimental?.aggressive_truncation &&
errorData?.currentTokens &&
errorData?.maxTokens &&
errorData.currentTokens > errorData.maxTokens &&
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
) {
log("[auto-compact] aggressive truncation triggered (experimental)", {
currentTokens: errorData.currentTokens,
maxTokens: errorData.maxTokens,
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
})
const aggressiveResult = truncateUntilTargetTokens(
sessionID,
errorData.currentTokens,
errorData.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken
)
if (aggressiveResult.truncatedCount > 0) {
truncateState.truncateAttempt += aggressiveResult.truncatedCount
const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ")
const statusMsg = aggressiveResult.sufficient
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`
await (client as Client).tui
.showToast({
body: {
title: aggressiveResult.sufficient ? "Aggressive Truncation" : "Partial Truncation",
message: `${statusMsg}: ${toolNames}`,
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
log("[auto-compact] aggressive truncation completed", aggressiveResult)
if (aggressiveResult.sufficient) {
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
})
} catch {}
}, 500)
return
}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Truncation Skipped",
message: "No tool outputs found to truncate.",
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
}
}
let skipSummarize = false
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) { if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
const largest = findLargestToolResult(sessionID) const largest = findLargestToolResult(sessionID)
@@ -203,12 +333,68 @@ export async function executeCompact(
}, 500) }, 500)
return return
} }
} else if (errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens) {
skipSummarize = true
await (client as Client).tui
.showToast({
body: {
title: "Summarize Skipped",
message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
} else if (!errorData?.currentTokens) {
await (client as Client).tui
.showToast({
body: {
title: "Truncation Skipped",
message: "No large tool outputs found.",
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
} }
} }
const retryState = getOrCreateRetryState(autoCompactState, sessionID) const retryState = getOrCreateRetryState(autoCompactState, sessionID)
if (retryState.attempt < RETRY_CONFIG.maxAttempts) { if (experimental?.empty_message_recovery && errorData?.errorType?.includes("non-empty content")) {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
if (attempt < 3) {
const fixed = await fixEmptyMessages(sessionID, autoCompactState, client as Client)
if (fixed) {
autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
}, 500)
return
}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Recovery Failed",
message: "Max recovery attempts (3) reached for empty content error. Please start a new session.",
variant: "error",
duration: 10000,
},
})
.catch(() => {})
autoCompactState.compactionInProgress.delete(sessionID)
return
}
}
if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0
autoCompactState.fallbackStateBySession.delete(sessionID)
autoCompactState.truncateStateBySession.delete(sessionID)
}
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
retryState.attempt++ retryState.attempt++
retryState.lastAttemptTime = Date.now() retryState.lastAttemptTime = Date.now()
@@ -234,7 +420,7 @@ export async function executeCompact(
query: { directory }, query: { directory },
}) })
clearSessionState(autoCompactState, sessionID) autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(async () => { setTimeout(async () => {
try { try {
@@ -253,10 +439,21 @@ export async function executeCompact(
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs) const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
setTimeout(() => { setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory) executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
}, cappedDelay) }, cappedDelay)
return return
} }
} else {
await (client as Client).tui
.showToast({
body: {
title: "Summarize Skipped",
message: "Missing providerID or modelID. Skipping to revert...",
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
} }
} }
@@ -301,10 +498,21 @@ export async function executeCompact(
autoCompactState.compactionInProgress.delete(sessionID) autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(() => { setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory) executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
}, 1000) }, 1000)
return return
} catch {} } catch {}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Revert Skipped",
message: "Could not find last message pair to revert.",
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
} }
} }

View File

@@ -1,7 +1,13 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { AutoCompactState, ParsedTokenLimitError } from "./types" import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser" import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor" import { executeCompact, getLastAssistant } from "./executor"
import { log } from "../../shared/logger"
export interface AnthropicAutoCompactOptions {
experimental?: ExperimentalConfig
}
function createAutoCompactState(): AutoCompactState { function createAutoCompactState(): AutoCompactState {
return { return {
@@ -10,12 +16,14 @@ function createAutoCompactState(): AutoCompactState {
retryStateBySession: new Map(), retryStateBySession: new Map(),
fallbackStateBySession: new Map(), fallbackStateBySession: new Map(),
truncateStateBySession: new Map(), truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(), compactionInProgress: new Set<string>(),
} }
} }
export function createAnthropicAutoCompactHook(ctx: PluginInput) { export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: AnthropicAutoCompactOptions) {
const autoCompactState = createAutoCompactState() const autoCompactState = createAutoCompactState()
const experimental = options?.experimental
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
@@ -28,6 +36,7 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
autoCompactState.retryStateBySession.delete(sessionInfo.id) autoCompactState.retryStateBySession.delete(sessionInfo.id)
autoCompactState.fallbackStateBySession.delete(sessionInfo.id) autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
autoCompactState.truncateStateBySession.delete(sessionInfo.id) autoCompactState.truncateStateBySession.delete(sessionInfo.id)
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
autoCompactState.compactionInProgress.delete(sessionInfo.id) autoCompactState.compactionInProgress.delete(sessionInfo.id)
} }
return return
@@ -35,9 +44,11 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined const sessionID = props?.sessionID as string | undefined
log("[auto-compact] session.error received", { sessionID, error: props?.error })
if (!sessionID) return if (!sessionID) return
const parsed = parseAnthropicTokenLimitError(props?.error) const parsed = parseAnthropicTokenLimitError(props?.error)
log("[auto-compact] parsed result", { parsed, hasError: !!props?.error })
if (parsed) { if (parsed) {
autoCompactState.pendingCompact.add(sessionID) autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed) autoCompactState.errorDataBySession.set(sessionID, parsed)
@@ -67,7 +78,8 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
{ providerID, modelID }, { providerID, modelID },
autoCompactState, autoCompactState,
ctx.client, ctx.client,
ctx.directory ctx.directory,
experimental
) )
}, 300) }, 300)
} }
@@ -79,7 +91,9 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
const sessionID = info?.sessionID as string | undefined const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "assistant" && info.error) { if (sessionID && info?.role === "assistant" && info.error) {
log("[auto-compact] message.updated with error", { sessionID, error: info.error })
const parsed = parseAnthropicTokenLimitError(info.error) const parsed = parseAnthropicTokenLimitError(info.error)
log("[auto-compact] message.updated parsed result", { parsed })
if (parsed) { if (parsed) {
parsed.providerID = info.providerID as string | undefined parsed.providerID = info.providerID as string | undefined
parsed.modelID = info.modelID as string | undefined parsed.modelID = info.modelID as string | undefined
@@ -123,7 +137,8 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
{ providerID, modelID }, { providerID, modelID },
autoCompactState, autoCompactState,
ctx.client, ctx.client,
ctx.directory ctx.directory,
experimental
) )
} }
} }

View File

@@ -25,6 +25,7 @@ const TOKEN_LIMIT_KEYWORDS = [
"token limit", "token limit",
"context length", "context length",
"too many tokens", "too many tokens",
"non-empty content",
] ]
function extractTokensFromMessage(message: string): { current: number; max: number } | null { function extractTokensFromMessage(message: string): { current: number; max: number } | null {
@@ -46,6 +47,13 @@ function isTokenLimitError(text: string): boolean {
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null { export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
if (typeof err === "string") { if (typeof err === "string") {
if (err.toLowerCase().includes("non-empty content")) {
return {
currentTokens: 0,
maxTokens: 0,
errorType: "non-empty content",
}
}
if (isTokenLimitError(err)) { if (isTokenLimitError(err)) {
const tokens = extractTokensFromMessage(err) const tokens = extractTokensFromMessage(err)
return { return {
@@ -142,6 +150,14 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
} }
} }
if (combinedText.toLowerCase().includes("non-empty content")) {
return {
currentTokens: 0,
maxTokens: 0,
errorType: "non-empty content",
}
}
if (isTokenLimitError(combinedText)) { if (isTokenLimitError(combinedText)) {
return { return {
currentTokens: 0, currentTokens: 0,

View File

@@ -1,8 +1,19 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { xdgData } from "xdg-basedir" import { xdgData } from "xdg-basedir"
const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage") let OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
// Fix for macOS where xdg-basedir points to ~/Library/Application Support
// but OpenCode (cli) uses ~/.local/share
if (process.platform === "darwin" && !existsSync(OPENCODE_STORAGE)) {
const localShare = join(homedir(), ".local", "share", "opencode", "storage")
if (existsSync(localShare)) {
OPENCODE_STORAGE = localShare
}
}
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
const PART_STORAGE = join(OPENCODE_STORAGE, "part") const PART_STORAGE = join(OPENCODE_STORAGE, "part")
@@ -171,3 +182,76 @@ export function countTruncatedResults(sessionID: string): number {
return count return count
} }
export interface AggressiveTruncateResult {
success: boolean
sufficient: boolean
truncatedCount: number
totalBytesRemoved: number
targetBytesToRemove: number
truncatedTools: Array<{ toolName: string; originalSize: number }>
}
export function truncateUntilTargetTokens(
sessionID: string,
currentTokens: number,
maxTokens: number,
targetRatio: number = 0.8,
charsPerToken: number = 4
): AggressiveTruncateResult {
const targetTokens = Math.floor(maxTokens * targetRatio)
const tokensToReduce = currentTokens - targetTokens
const charsToReduce = tokensToReduce * charsPerToken
if (tokensToReduce <= 0) {
return {
success: true,
sufficient: true,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove: 0,
truncatedTools: [],
}
}
const results = findToolResultsBySize(sessionID)
if (results.length === 0) {
return {
success: false,
sufficient: false,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove: charsToReduce,
truncatedTools: [],
}
}
let totalRemoved = 0
let truncatedCount = 0
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
for (const result of results) {
const truncateResult = truncateToolResult(result.partPath)
if (truncateResult.success) {
truncatedCount++
const removedSize = truncateResult.originalSize ?? result.outputSize
totalRemoved += removedSize
truncatedTools.push({
toolName: truncateResult.toolName ?? result.toolName,
originalSize: removedSize,
})
}
}
const sufficient = totalRemoved >= charsToReduce
return {
success: truncatedCount > 0,
sufficient,
truncatedCount,
totalBytesRemoved: totalRemoved,
targetBytesToRemove: charsToReduce,
truncatedTools,
}
}

View File

@@ -28,6 +28,7 @@ export interface AutoCompactState {
retryStateBySession: Map<string, RetryState> retryStateBySession: Map<string, RetryState>
fallbackStateBySession: Map<string, FallbackState> fallbackStateBySession: Map<string, FallbackState>
truncateStateBySession: Map<string, TruncateState> truncateStateBySession: Map<string, TruncateState>
emptyContentAttemptBySession: Map<string, number>
compactionInProgress: Set<string> compactionInProgress: Set<string>
} }
@@ -44,6 +45,8 @@ export const FALLBACK_CONFIG = {
} as const } as const
export const TRUNCATE_CONFIG = { export const TRUNCATE_CONFIG = {
maxTruncateAttempts: 10, maxTruncateAttempts: 20,
minOutputSizeToTruncate: 1000, minOutputSizeToTruncate: 500,
targetTokenRatio: 0.5,
charsPerToken: 4,
} as const } as const

View File

@@ -1,14 +1,14 @@
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer"; export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor"; export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification"; export { createSessionNotification } from "./session-notification";
export { createSessionRecoveryHook, type SessionRecoveryHook } from "./session-recovery"; export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker"; export { createCommentCheckerHooks } from "./comment-checker";
export { createGrepOutputTruncatorHook } from "./grep-output-truncator"; export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
export { createToolOutputTruncatorHook } from "./tool-output-truncator"; export { createToolOutputTruncatorHook } from "./tool-output-truncator";
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector"; export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector"; export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector"; export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact"; export { createAnthropicAutoCompactHook, type AnthropicAutoCompactOptions } from "./anthropic-auto-compact";
export { createThinkModeHook } from "./think-mode"; export { createThinkModeHook } from "./think-mode";
export { createClaudeCodeHooksHook } from "./claude-code-hooks"; export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector"; export { createRulesInjectorHook } from "./rules-injector";

View File

@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk" import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { ExperimentalConfig } from "../../config"
import { import {
findEmptyMessages, findEmptyMessages,
findEmptyMessageByIndex, findEmptyMessageByIndex,
@@ -14,7 +15,11 @@ import {
replaceEmptyTextParts, replaceEmptyTextParts,
stripThinkingParts, stripThinkingParts,
} from "./storage" } from "./storage"
import type { MessageData } from "./types" import type { MessageData, ResumeConfig } from "./types"
export interface SessionRecoveryOptions {
experimental?: ExperimentalConfig
}
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
@@ -22,7 +27,6 @@ type RecoveryErrorType =
| "tool_result_missing" | "tool_result_missing"
| "thinking_block_order" | "thinking_block_order"
| "thinking_disabled_violation" | "thinking_disabled_violation"
| "empty_content_message"
| null | null
interface MessageInfo { interface MessageInfo {
@@ -49,6 +53,41 @@ interface MessagePart {
input?: Record<string, unknown> 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.prompt({
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 { function getErrorMessage(error: unknown): string {
if (!error) return "" if (!error) return ""
if (typeof error === "string") return error.toLowerCase() if (typeof error === "string") return error.toLowerCase()
@@ -104,15 +143,6 @@ function detectErrorType(error: unknown): RecoveryErrorType {
return "thinking_disabled_violation" return "thinking_disabled_violation"
} }
if (
message.includes("non-empty content") ||
message.includes("must have non-empty content") ||
(message.includes("content") && message.includes("is empty")) ||
(message.includes("content field") && message.includes("empty"))
) {
return "empty_content_message"
}
return null return null
} }
@@ -286,8 +316,9 @@ export interface SessionRecoveryHook {
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
} }
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook { export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook {
const processingErrors = new Set<string>() const processingErrors = new Set<string>()
const experimental = options?.experimental
let onAbortCallback: ((sessionID: string) => void) | null = null let onAbortCallback: ((sessionID: string) => void) | null = null
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
@@ -338,13 +369,11 @@ export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook
tool_result_missing: "Tool Crash Recovery", tool_result_missing: "Tool Crash Recovery",
thinking_block_order: "Thinking Block Recovery", thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery", thinking_disabled_violation: "Thinking Strip Recovery",
empty_content_message: "Empty Message Recovery",
} }
const toastMessages: Record<RecoveryErrorType & string, string> = { const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...", tool_result_missing: "Injecting cancelled tool results...",
thinking_block_order: "Fixing message structure...", thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...", thinking_disabled_violation: "Stripping thinking blocks...",
empty_content_message: "Fixing empty message...",
} }
await ctx.client.tui await ctx.client.tui
@@ -364,10 +393,18 @@ export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") { } else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) 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") { } else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
} else if (errorType === "empty_content_message") { if (success && experimental?.auto_resume) {
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory, info.error) const lastUser = findLastUserMessage(msgs ?? [])
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
} }
return success return success

View File

@@ -69,6 +69,13 @@ export interface MessageData {
sessionID?: string sessionID?: string
parentID?: string parentID?: string
error?: unknown error?: unknown
agent?: string
model?: {
providerID: string
modelID: string
}
system?: string
tools?: Record<string, boolean>
} }
parts?: Array<{ parts?: Array<{
type: string type: string
@@ -80,3 +87,12 @@ export interface MessageData {
callID?: string callID?: string
}> }>
} }
export interface ResumeConfig {
sessionID: string
agent?: string
model?: {
providerID: string
modelID: string
}
}

View File

@@ -177,7 +177,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createContextWindowMonitorHook(ctx) ? createContextWindowMonitorHook(ctx)
: null; : null;
const sessionRecovery = isHookEnabled("session-recovery") const sessionRecovery = isHookEnabled("session-recovery")
? createSessionRecoveryHook(ctx) ? createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })
: null; : null;
const sessionNotification = isHookEnabled("session-notification") const sessionNotification = isHookEnabled("session-notification")
? createSessionNotification(ctx) ? createSessionNotification(ctx)
@@ -212,7 +212,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true, disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
}); });
const anthropicAutoCompact = isHookEnabled("anthropic-auto-compact") const anthropicAutoCompact = isHookEnabled("anthropic-auto-compact")
? createAnthropicAutoCompactHook(ctx) ? createAnthropicAutoCompactHook(ctx, { experimental: pluginConfig.experimental })
: null; : null;
const rulesInjector = isHookEnabled("rules-injector") const rulesInjector = isHookEnabled("rules-injector")
? createRulesInjectorHook(ctx) ? createRulesInjectorHook(ctx)