Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d570af3dd | ||
|
|
ddeabb1a8b | ||
|
|
7a896fd2b9 | ||
|
|
823f12d88d |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.11.0",
|
||||
"version": "2.12.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -26,9 +26,23 @@ const TOKEN_LIMIT_KEYWORDS = [
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"non-empty content",
|
||||
"invalid_request_error",
|
||||
]
|
||||
|
||||
// Patterns that indicate thinking block structure errors (NOT token limit errors)
|
||||
// These should be handled by session-recovery hook, not compaction
|
||||
const THINKING_BLOCK_ERROR_PATTERNS = [
|
||||
/thinking.*first block/i,
|
||||
/first block.*thinking/i,
|
||||
/must.*start.*thinking/i,
|
||||
/thinking.*redacted_thinking/i,
|
||||
/expected.*thinking.*found/i,
|
||||
/thinking.*disabled.*cannot.*contain/i,
|
||||
]
|
||||
|
||||
function isThinkingBlockError(text: string): boolean {
|
||||
return THINKING_BLOCK_ERROR_PATTERNS.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
@@ -52,6 +66,9 @@ function extractMessageIndex(text: string): number | undefined {
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
if (isThinkingBlockError(text)) {
|
||||
return false
|
||||
}
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
@@ -112,11 +112,6 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
if (isFirstMessage) {
|
||||
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isHookDisabled(config, "UserPromptSubmit")) {
|
||||
const userPromptCtx: UserPromptSubmitContext = {
|
||||
sessionId: input.sessionID,
|
||||
@@ -144,24 +139,33 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length })
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
|
||||
if (isFirstMessage) {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}`
|
||||
log("UserPromptSubmit hooks prepended to first message parts directly", { sessionID: input.sessionID })
|
||||
}
|
||||
} else {
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
|
||||
describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
@@ -20,6 +20,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
@@ -41,8 +42,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
return {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
}
|
||||
@@ -229,9 +230,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - user sends message immediately (before 2s countdown)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -255,9 +256,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -418,12 +419,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - abort error occurs (with abort-specific error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -447,20 +448,20 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - assistant sends a message (intervening event clears abort state)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -484,12 +485,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { message: "aborted" }
|
||||
}
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { message: "aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -518,12 +519,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - non-abort error occurs (e.g., network error, API error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "NetworkError", message: "Connection failed" }
|
||||
}
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "NetworkError", message: "Connection failed" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -547,12 +548,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "AbortError", message: "cancelled" }
|
||||
}
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "AbortError", message: "cancelled" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -584,17 +585,17 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - first abort error
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "aborted" } }
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "aborted" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - second abort error (immediately before idle)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "interrupted" } }
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "interrupted" } }
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
} from "../features/hook-message-injector"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
@@ -62,22 +62,22 @@ function getMessageDir(sessionID: string): string | null {
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
||||
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message.includes("abort")) return true
|
||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function createTodoContinuationEnforcer(
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
const state = sessions.get(sessionID)
|
||||
if (!state) return
|
||||
|
||||
|
||||
if (state.countdownTimer) {
|
||||
clearTimeout(state.countdownTimer)
|
||||
state.countdownTimer = undefined
|
||||
@@ -148,7 +148,7 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||
const state = sessions.get(sessionID)
|
||||
|
||||
|
||||
if (state?.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||
return
|
||||
@@ -183,9 +183,9 @@ export function createTodoContinuationEnforcer(
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||
|
||||
|
||||
if (!hasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||
return
|
||||
@@ -199,18 +199,23 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
const modelField = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model: modelField, incompleteCount: freshIncompleteCount })
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
model: modelField,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
|
||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
|
||||
@@ -250,7 +255,7 @@ export function createTodoContinuationEnforcer(
|
||||
const isAbort = isAbortError(props?.error)
|
||||
state.lastEventWasAbortError = isAbort
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
||||
return
|
||||
}
|
||||
@@ -264,7 +269,7 @@ export function createTodoContinuationEnforcer(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user