Compare commits

..

15 Commits

Author SHA1 Message Date
github-actions[bot]
66fcd8570b release: v0.1.27 2025-12-08 08:01:03 +00:00
YeonGyu-Kim
5cd3f0cbf2 docs: add Directory AGENTS.md Injector hook documentation
🤖 GENERATED BY [OPENCODE](https://opencode.ai/)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
9a9512b705 test: add test directory with sample files 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
6ece7476ef feat(hooks): add empty-task-response-detector hook 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
9ed23d4037 feat(hooks): implement directory-agents-injector hook 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
79b791117a fix(session-recovery): improve error message extraction 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
4e328a937c feat(hooks): integrate directory-agents-injector hook into plugin pipeline
- Add directoryAgentsInjector to plugin event handlers
- Wire up tool.execute.after hook for directory agents injection
- Fix: Format src/index.ts with consistent semicolon style
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
a500f0c9ad export(hooks): add directory-agents-injector hook to public API
- Export createDirectoryAgentsInjectorHook from hooks index
- Fix: Formatting (add semicolons to match code style)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
16806da615 refactor(session-recovery): process entire message history for empty/thinking block recovery
- Scan all non-final assistant messages for empty content, orphan thinking blocks, and disabled thinking
- Add storage utility functions: findMessagesWithThinkingBlocks, findMessagesWithOrphanThinking, stripThinkingParts, prependThinkingPart
- Fix: Previously only processed single failed message, now handles multiple broken messages in history
- Improve: Use filesystem-based recovery instead of unreliable SDK APIs
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
c5f651c0a9 refactor(hooks): remove grep-blocker (grep tool now overrides built-in)
The grep tool now properly overrides OpenCode's built-in grep,
making the blocker hook unnecessary.

Generated by [OpenCode](https://opencode.ai/)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
ed3d7a55f4 feat(tools): add glob tool with timeout protection
- Override OpenCode's built-in glob with 60s timeout
- Kill process on expiration to prevent indefinite hanging
- Reuse grep's CLI resolver for ripgrep detection

Generated by [OpenCode](https://opencode.ai/)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
b77dd2fcdf refactor(tools): rename safe-grep to grep with override capability 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
64b3564760 refactor(session-recovery): extract storage utilities to separate module
Split session-recovery.ts into modular structure:
- types.ts: SDK-aligned type definitions
- constants.ts: storage paths and part type sets
- storage.ts: reusable read/write operations
- index.ts: main recovery hook logic
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
0df7e9b10b fix(session-recovery): recognize 'tool' type as valid content
OpenCode storage uses 'tool' type for tool calls, but the hasContent
check only recognized 'tool_use' (Anthropic API format). This caused
messages with tool calls to be incorrectly identified as empty.
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
aa35f2eab6 fix(session-recovery): detect empty messages with zero parts
Previously, findEmptyContentMessageFromStorage only detected messages
with parts.length > 0 that had no content. This missed the case where
a message has zero parts entirely, causing infinite recovery loops.
2025-12-08 17:00:02 +09:00
35 changed files with 1262 additions and 672 deletions

View File

@@ -13,7 +13,7 @@
- [Tools](#tools)
- [내장 LSP Tools](#내장-lsp-tools)
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
- [Safe Grep](#safe-grep)
- [Grep](#grep)
- [내장 MCPs](#내장-mcps)
- [기타 편의 기능](#기타-편의-기능)
- [설정](#설정)
@@ -142,6 +142,7 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 디렉토리별 컨텍스트를 에이전트에게 제공합니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
### Agents
@@ -185,11 +186,16 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
- **ast_grep_replace**: AST 인식 코드 교체
#### Safe Grep
- **safe_grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한).
#### Grep
- **grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한). OpenCode의 내장 `grep` 도구를 대체합니다.
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
- safe_grep 은 timeout 과 더 엄격한 출력 제한을 적용합니다.
- **주의**: 기본 grep 도구는 Agent 를 햇갈리게 하지 않기 위해 비활성화됩니다. 그러나 SafeGrep 은 Grep 이 제공하는 모든 기능을 제공합니다.
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
#### Glob
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
#### 내장 MCPs

View File

@@ -13,7 +13,7 @@ English | [한국어](README.ko.md)
- [Tools](#tools)
- [Built-in LSP Tools](#built-in-lsp-tools)
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
- [Safe Grep](#safe-grep)
- [Grep](#grep)
- [Built-in MCPs](#built-in-mcps)
- [Other Features](#other-features)
- [Configuration](#configuration)
@@ -138,6 +138,7 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, providing directory-level context to the agent. Inspired by Claude Code's CLAUDE.md feature.
### Agents
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
@@ -181,12 +182,17 @@ The features you use in your editor—other agents cannot access them. Oh My Ope
- **ast_grep_search**: AST-aware code pattern search (25 languages)
- **ast_grep_replace**: AST-aware code replacement
#### Safe Grep
#### Grep
- **safe_grep**: Content search with safety limits (5min timeout, 10MB output).
- **grep**: Content search with safety limits (5min timeout, 10MB output). Overrides OpenCode's built-in `grep` tool.
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
- `safe_grep` enforces strict limits.
- **Note**: Default `grep` is disabled to prevent Agent confusion. `safe_grep` delivers full `grep` functionality with safety assurance.
- This tool enforces strict limits and completely replaces the built-in `grep`.
#### Glob
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
- This tool enforces timeouts and kills the process on expiration.
#### Built-in MCPs

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "0.1.26",
"version": "0.1.27",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -0,0 +1,9 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const AGENTS_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-agents",
);
export const AGENTS_FILENAME = "AGENTS.md";

View File

@@ -0,0 +1,126 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import {
loadInjectedPaths,
saveInjectedPaths,
clearInjectedPaths,
} from "./storage";
import { AGENTS_FILENAME } from "./constants";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
const sessionCaches = new Map<string, Set<string>>();
function getSessionCache(sessionID: string): Set<string> {
if (!sessionCaches.has(sessionID)) {
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
}
return sessionCaches.get(sessionID)!;
}
function resolveFilePath(title: string): string | null {
if (!title) return null;
if (title.startsWith("/")) return title;
return resolve(ctx.directory, title);
}
function findAgentsMdUp(startDir: string): string[] {
const found: string[] = [];
let current = startDir;
while (true) {
const agentsPath = join(current, AGENTS_FILENAME);
if (existsSync(agentsPath)) {
found.push(agentsPath);
}
if (current === ctx.directory) break;
const parent = dirname(current);
if (parent === current) break;
if (!parent.startsWith(ctx.directory)) break;
current = parent;
}
return found.reverse();
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
if (input.tool.toLowerCase() !== "read") return;
const filePath = resolveFilePath(output.title);
if (!filePath) return;
const dir = dirname(filePath);
const cache = getSessionCache(input.sessionID);
const agentsPaths = findAgentsMdUp(dir);
const toInject: { path: string; content: string }[] = [];
for (const agentsPath of agentsPaths) {
const agentsDir = dirname(agentsPath);
if (cache.has(agentsDir)) continue;
try {
const content = readFileSync(agentsPath, "utf-8");
toInject.push({ path: agentsPath, content });
cache.add(agentsDir);
} catch {}
}
if (toInject.length === 0) return;
for (const { path, content } of toInject) {
output.output += `\n\n[Directory Context: ${path}]\n${content}`;
}
saveInjectedPaths(input.sessionID, cache);
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
sessionCaches.delete(sessionInfo.id);
clearInjectedPaths(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}

View File

@@ -0,0 +1,48 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { AGENTS_INJECTOR_STORAGE } from "./constants";
import type { InjectedPathsData } from "./types";
function getStoragePath(sessionID: string): string {
return join(AGENTS_INJECTOR_STORAGE, `${sessionID}.json`);
}
export function loadInjectedPaths(sessionID: string): Set<string> {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return new Set();
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedPathsData = JSON.parse(content);
return new Set(data.injectedPaths);
} catch {
return new Set();
}
}
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
if (!existsSync(AGENTS_INJECTOR_STORAGE)) {
mkdirSync(AGENTS_INJECTOR_STORAGE, { recursive: true });
}
const data: InjectedPathsData = {
sessionID,
injectedPaths: [...paths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
}
export function clearInjectedPaths(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}

View File

@@ -0,0 +1,5 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}

View File

@@ -0,0 +1,27 @@
import type { PluginInput } from "@opencode-ai/plugin"
const EMPTY_RESPONSE_WARNING = `[Task Empty Response Warning]
Task invocation completed but returned no response. This indicates the agent either:
- Failed to execute properly
- Did not terminate correctly
- Returned an empty result
Note: The call has already completed - you are NOT waiting for a response. Proceed accordingly.`
export function createEmptyTaskResponseDetectorHook(_ctx: PluginInput) {
return {
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (input.tool !== "Task") return
const responseText = output.output?.trim() ?? ""
if (responseText === "") {
output.output = EMPTY_RESPONSE_WARNING
}
},
}
}

View File

@@ -1,18 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
const BLOCKED_MESSAGE =
"Error: [BLOCKED] grep has no timeout and can freeze the system. " +
"It is permanently disabled. Use 'safe_grep' instead."
export function createGrepBlocker(_ctx: PluginInput) {
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
_output: { args: unknown }
) => {
if (input.tool === "grep") {
throw new Error(BLOCKED_MESSAGE)
}
},
}
}

View File

@@ -1,7 +1,9 @@
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
export { createContextWindowMonitorHook } from "./context-window-monitor"
export { createSessionNotification } from "./session-notification"
export { createSessionRecoveryHook } from "./session-recovery"
export { createCommentCheckerHooks } from "./comment-checker"
export { createGrepOutputTruncatorHook } from "./grep-output-truncator"
export { createPulseMonitorHook } from "./pulse-monitor"
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification";
export { createSessionRecoveryHook } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker";
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
export { createPulseMonitorHook } from "./pulse-monitor";
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";

View File

@@ -1,552 +0,0 @@
/**
* Session Recovery - Message State Error Recovery
*
* Handles FOUR specific scenarios:
* 1. tool_use block exists without tool_result
* - Recovery: inject tool_result with "cancelled" content
*
* 2. Thinking block order violation (first block must be thinking)
* - Recovery: prepend empty thinking block
*
* 3. Thinking disabled but message contains thinking blocks
* - Recovery: strip thinking/redacted_thinking blocks
*
* 4. Empty content message (non-empty content required)
* - Recovery: inject text part directly via filesystem
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { xdgData } from "xdg-basedir"
import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
type Client = ReturnType<typeof createOpencodeClient>
const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | 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 ThinkingPart {
type: "thinking"
thinking: string
}
interface MessagePart {
type: string
id?: string
text?: string
thinking?: string
name?: string
input?: Record<string, unknown>
}
interface MessageData {
info?: MessageInfo
parts?: MessagePart[]
}
function getErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error.toLowerCase()
const errorObj = error as { data?: { message?: string }; message?: string }
return (errorObj.data?.message || errorObj.message || "").toLowerCase()
}
function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
if (
message.includes("thinking") &&
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("non-empty content") || message.includes("must have non-empty content")) {
return "empty_content_message"
}
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> {
const parts = failedAssistantMsg.parts || []
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.prompt({
path: { id: sessionID },
// @ts-expect-error - SDK types may not include tool_result parts, but runtime accepts it
body: { parts: toolResultParts },
})
return true
} catch {
return false
}
}
async function recoverThinkingBlockOrder(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
directory: string
): Promise<boolean> {
const messageID = failedAssistantMsg.info?.id
if (!messageID) {
return false
}
const existingParts = failedAssistantMsg.parts || []
const patchedParts: MessagePart[] = [{ type: "thinking", thinking: "" } as ThinkingPart, ...existingParts]
try {
// @ts-expect-error - Experimental API
await client.message?.update?.({
path: { id: messageID },
body: { parts: patchedParts },
})
return true
} catch {
// message.update not available
}
try {
// @ts-expect-error - Experimental API
await client.session.patch?.({
path: { id: sessionID },
body: {
messageID,
parts: patchedParts,
},
})
return true
} catch {
// session.patch not available
}
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
}
async function recoverThinkingDisabledViolation(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
const messageID = failedAssistantMsg.info?.id
if (!messageID) {
return false
}
const existingParts = failedAssistantMsg.parts || []
const strippedParts = existingParts.filter((p) => p.type !== "thinking" && p.type !== "redacted_thinking")
if (strippedParts.length === 0) {
return false
}
try {
// @ts-expect-error - Experimental API
await client.message?.update?.({
path: { id: messageID },
body: { parts: strippedParts },
})
return true
} catch {
// message.update not available
}
try {
// @ts-expect-error - Experimental API
await client.session.patch?.({
path: { id: sessionID },
body: {
messageID,
parts: strippedParts,
},
})
return true
} catch {
// session.patch not available
}
return false
}
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
const META_TYPES = new Set(["step-start", "step-finish"])
interface StoredMessageMeta {
id: string
sessionID: string
role: string
parentID?: string
}
interface StoredPart {
id: string
sessionID: string
messageID: string
type: string
text?: string
}
function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
function getMessageDir(sessionID: string): string {
const projectHash = readdirSync(MESSAGE_STORAGE).find((dir) => {
const sessionDir = join(MESSAGE_STORAGE, dir)
try {
return readdirSync(sessionDir).some((f) => f.includes(sessionID.replace("ses_", "")))
} catch {
return false
}
})
if (projectHash) {
return join(MESSAGE_STORAGE, projectHash, sessionID)
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
return ""
}
function readMessagesFromStorage(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) => a.id.localeCompare(b.id))
}
function readPartsFromStorage(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
}
function injectTextPartToStorage(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: StoredPart = {
id: partId,
sessionID,
messageID,
type: "text",
text,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
function findEmptyContentMessageFromStorage(sessionID: string): string | null {
const messages = readMessagesFromStorage(sessionID)
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
const parts = readPartsFromStorage(msg.id)
const hasContent = parts.some((p) => {
if (THINKING_TYPES.has(p.type)) return false
if (META_TYPES.has(p.type)) return false
if (p.type === "text" && p.text?.trim()) return true
if (p.type === "tool_use") return true
if (p.type === "tool_result") return true
return false
})
if (!hasContent && parts.length > 0) {
return msg.id
}
}
return null
}
function hasNonEmptyOutput(msg: MessageData): boolean {
const parts = msg.parts
if (!parts || parts.length === 0) return false
return parts.some((p) => {
if (THINKING_TYPES.has(p.type)) return false
if (p.type === "step-start" || p.type === "step-finish") return false
if (p.type === "text" && p.text && p.text.trim()) return true
if (p.type === "tool_use" && p.id) return true
if (p.type === "tool_result") return true
return false
})
}
function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
const isLastMessage = i === msgs.length - 1
const isAssistant = msg.info?.role === "assistant"
if (isLastMessage && isAssistant) continue
if (!hasNonEmptyOutput(msg)) {
return msg
}
}
return null
}
async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string
): Promise<boolean> {
const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id
if (!emptyMessageID) return false
return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)")
}
async function fallbackRevertStrategy(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
directory: string
): Promise<boolean> {
const parentMsgID = failedAssistantMsg.info?.parentID
const messagesResp = await client.session.messages({
path: { id: sessionID },
query: { directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data
if (!msgs || msgs.length === 0) {
return false
}
let targetUserMsg: MessageData | null = null
if (parentMsgID) {
targetUserMsg = msgs.find((m) => m.info?.id === parentMsgID) ?? null
}
if (!targetUserMsg) {
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].info?.role === "user") {
targetUserMsg = msgs[i]
break
}
}
}
if (!targetUserMsg?.parts?.length) {
return false
}
await client.session.revert({
path: { id: sessionID },
body: { messageID: targetUserMsg.info?.id ?? "" },
query: { directory },
})
const textParts = targetUserMsg.parts
.filter((p) => p.type === "text" && p.text)
.map((p) => ({ type: "text" as const, text: p.text ?? "" }))
if (textParts.length === 0) {
return false
}
await client.session.prompt({
path: { id: sessionID },
body: { parts: textParts },
query: { directory },
})
return true
}
export function createSessionRecoveryHook(ctx: PluginInput) {
const processingErrors = new Set<string>()
let onAbortCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = 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 {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
if (onAbortCallback) {
onAbortCallback(sessionID)
}
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",
empty_content_message: "Empty Message 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...",
empty_content_message: "Deleting empty message...",
}
const toastTitle = toastTitles[errorType]
const toastMessage = toastMessages[errorType]
await ctx.client.tui
.showToast({
body: {
title: toastTitle,
message: toastMessage,
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)
} else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
} else if (errorType === "empty_content_message") {
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory)
}
return success
} catch {
return false
} finally {
processingErrors.delete(assistantMsgID)
}
}
return {
handleSessionRecovery,
isRecoverableError,
setOnAbortCallback,
}
}

View File

@@ -0,0 +1,10 @@
import { join } from "node:path"
import { xdgData } from "xdg-basedir"
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
export const META_TYPES = new Set(["step-start", "step-finish"])
export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"])

View File

@@ -0,0 +1,338 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
import {
findEmptyMessages,
findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks,
injectTextPart,
prependThinkingPart,
stripThinkingParts,
} from "./storage"
import type { MessageData } from "./types"
type Client = ReturnType<typeof createOpencodeClient>
type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
| "empty_content_message"
| 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 ThinkingPart {
type: "thinking"
thinking: string
}
interface MessagePart {
type: string
id?: string
text?: string
thinking?: string
name?: string
input?: Record<string, unknown>
}
function getErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error.toLowerCase()
const errorObj = error as {
data?: { message?: string }
message?: string
error?: { message?: string }
}
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
}
function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
if (
message.includes("thinking") &&
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("non-empty content") || message.includes("must have non-empty content")) {
return "empty_content_message"
}
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> {
const parts = failedAssistantMsg.parts || []
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.prompt({
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
): Promise<boolean> {
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
}
async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string
): Promise<boolean> {
const emptyMessageIDs = findEmptyMessages(sessionID)
if (emptyMessageIDs.length === 0) {
const fallbackID = failedAssistantMsg.info?.id
if (!fallbackID) return false
return injectTextPart(sessionID, fallbackID, "(interrupted)")
}
let anySuccess = false
for (const messageID of emptyMessageIDs) {
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
anySuccess = true
}
}
return anySuccess
}
async function fallbackRevertStrategy(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
directory: string
): Promise<boolean> {
const parentMsgID = failedAssistantMsg.info?.parentID
const messagesResp = await client.session.messages({
path: { id: sessionID },
query: { directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data
if (!msgs || msgs.length === 0) {
return false
}
let targetUserMsg: MessageData | null = null
if (parentMsgID) {
targetUserMsg = msgs.find((m) => m.info?.id === parentMsgID) ?? null
}
if (!targetUserMsg) {
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].info?.role === "user") {
targetUserMsg = msgs[i]
break
}
}
}
if (!targetUserMsg?.parts?.length) {
return false
}
await client.session.revert({
path: { id: sessionID },
body: { messageID: targetUserMsg.info?.id ?? "" },
query: { directory },
})
const textParts = targetUserMsg.parts
.filter((p) => p.type === "text" && p.text)
.map((p) => ({ type: "text" as const, text: p.text ?? "" }))
if (textParts.length === 0) {
return false
}
await client.session.prompt({
path: { id: sessionID },
body: { parts: textParts },
query: { directory },
})
return true
}
export function createSessionRecoveryHook(ctx: PluginInput) {
const processingErrors = new Set<string>()
let onAbortCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = 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 {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
if (onAbortCallback) {
onAbortCallback(sessionID)
}
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",
empty_content_message: "Empty Message 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...",
empty_content_message: "Fixing empty message...",
}
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)
} else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
} else if (errorType === "empty_content_message") {
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory)
}
return success
} catch {
return false
} finally {
processingErrors.delete(assistantMsgID)
}
}
return {
handleSessionRecovery,
isRecoverableError,
setOnAbortCallback,
}
}

View File

@@ -0,0 +1,235 @@
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) => 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 (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
}
return emptyIds
}
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 (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) 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 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
const isLastMessage = i === messages.length - 1
if (isLastMessage) 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 hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
if (hasThinking && !firstIsThinking) {
result.push(msg.id)
}
}
return result
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const partId = `prt_0000000000_thinking`
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: "",
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
}

View File

@@ -0,0 +1,82 @@
export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning"
export type MetaPartType = "step-start" | "step-finish"
export type ContentPartType = "text" | "tool" | "tool_use" | "tool_result"
export interface StoredMessageMeta {
id: string
sessionID: string
role: "user" | "assistant"
parentID?: string
time?: {
created: number
completed?: number
}
error?: unknown
}
export interface StoredTextPart {
id: string
sessionID: string
messageID: string
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
}
export interface StoredToolPart {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: {
status: "pending" | "running" | "completed" | "error"
input: Record<string, unknown>
output?: string
error?: string
}
}
export interface StoredReasoningPart {
id: string
sessionID: string
messageID: string
type: "reasoning"
text: string
}
export interface StoredStepPart {
id: string
sessionID: string
messageID: string
type: "step-start" | "step-finish"
}
export type StoredPart = StoredTextPart | StoredToolPart | StoredReasoningPart | StoredStepPart | {
id: string
sessionID: string
messageID: string
type: string
[key: string]: unknown
}
export interface MessageData {
info?: {
id?: string
role?: string
sessionID?: string
parentID?: string
error?: unknown
}
parts?: Array<{
type: string
id?: string
text?: string
thinking?: string
name?: string
input?: Record<string, unknown>
callID?: string
}>
}

View File

@@ -1,59 +1,72 @@
import type { Plugin } from "@opencode-ai/plugin"
import { createBuiltinAgents } from "./agents"
import { createTodoContinuationEnforcer, createContextWindowMonitorHook, createSessionRecoveryHook, createCommentCheckerHooks, createGrepOutputTruncatorHook, createPulseMonitorHook } from "./hooks"
import { updateTerminalTitle } from "./features/terminal"
import { builtinTools } from "./tools"
import { createBuiltinMcps } from "./mcp"
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"
import * as fs from "fs"
import * as path from "path"
import type { Plugin } from "@opencode-ai/plugin";
import { createBuiltinAgents } from "./agents";
import {
createTodoContinuationEnforcer,
createContextWindowMonitorHook,
createSessionRecoveryHook,
createCommentCheckerHooks,
createGrepOutputTruncatorHook,
createPulseMonitorHook,
createDirectoryAgentsInjectorHook,
createEmptyTaskResponseDetectorHook,
} from "./hooks";
import { updateTerminalTitle } from "./features/terminal";
import { builtinTools } from "./tools";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import * as fs from "fs";
import * as path from "path";
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
const configPaths = [
path.join(directory, "oh-my-opencode.json"),
path.join(directory, ".oh-my-opencode.json"),
]
];
for (const configPath of configPaths) {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8")
const rawConfig = JSON.parse(content)
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = JSON.parse(content);
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (!result.success) {
console.error(`[oh-my-opencode] Config validation error in ${configPath}:`)
console.error(
`[oh-my-opencode] Config validation error in ${configPath}:`,
);
for (const issue of result.error.issues) {
console.error(` - ${issue.path.join(".")}: ${issue.message}`)
console.error(` - ${issue.path.join(".")}: ${issue.message}`);
}
return {}
return {};
}
return result.data
return result.data;
}
} catch {
// Ignore parse errors, use defaults
}
}
return {}
return {};
}
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx)
const contextWindowMonitor = createContextWindowMonitorHook(ctx)
const sessionRecovery = createSessionRecoveryHook(ctx)
const pulseMonitor = createPulseMonitorHook(ctx)
const commentChecker = createCommentCheckerHooks()
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx)
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx);
const pulseMonitor = createPulseMonitorHook(ctx);
const commentChecker = createCommentCheckerHooks();
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
updateTerminalTitle({ sessionId: "main" })
updateTerminalTitle({ sessionId: "main" });
const pluginConfig = loadPluginConfig(ctx.directory)
const pluginConfig = loadPluginConfig(ctx.directory);
let mainSessionID: string | undefined
let currentSessionID: string | undefined
let currentSessionTitle: string | undefined
let mainSessionID: string | undefined;
let currentSessionID: string | undefined;
let currentSessionTitle: string | undefined;
return {
tool: builtinTools,
@@ -61,76 +74,80 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
config: async (config) => {
const agents = createBuiltinAgents(
pluginConfig.disabled_agents,
pluginConfig.agents
)
pluginConfig.agents,
);
config.agent = {
...config.agent,
...agents,
}
};
config.tools = {
...config.tools,
grep: false,
}
};
config.mcp = {
...config.mcp,
...createBuiltinMcps(pluginConfig.disabled_mcps),
}
};
},
event: async (input) => {
await todoContinuationEnforcer(input)
await contextWindowMonitor.event(input)
await pulseMonitor.event(input)
await todoContinuationEnforcer(input);
await contextWindowMonitor.event(input);
await pulseMonitor.event(input);
await directoryAgentsInjector.event(input);
const { event } = input
const props = event.properties as Record<string, unknown> | undefined
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.created") {
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
const sessionInfo = props?.info as
| { id?: string; title?: string; parentID?: string }
| undefined;
if (!sessionInfo?.parentID) {
mainSessionID = sessionInfo?.id
currentSessionID = sessionInfo?.id
currentSessionTitle = sessionInfo?.title
mainSessionID = sessionInfo?.id;
currentSessionID = sessionInfo?.id;
currentSessionTitle = sessionInfo?.title;
updateTerminalTitle({
sessionId: currentSessionID || "main",
status: "idle",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
})
});
}
}
if (event.type === "session.updated") {
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
const sessionInfo = props?.info as
| { id?: string; title?: string; parentID?: string }
| undefined;
if (!sessionInfo?.parentID) {
currentSessionID = sessionInfo?.id
currentSessionTitle = sessionInfo?.title
currentSessionID = sessionInfo?.id;
currentSessionTitle = sessionInfo?.title;
updateTerminalTitle({
sessionId: currentSessionID || "main",
status: "processing",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
})
});
}
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id === mainSessionID) {
mainSessionID = undefined
currentSessionID = undefined
currentSessionTitle = undefined
mainSessionID = undefined;
currentSessionID = undefined;
currentSessionTitle = undefined;
updateTerminalTitle({
sessionId: "main",
status: "idle",
})
});
}
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error
const sessionID = props?.sessionID as string | undefined;
const error = props?.error;
if (sessionRecovery.isRecoverableError(error)) {
const messageInfo = {
@@ -138,15 +155,18 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
role: "assistant" as const,
sessionID,
error,
}
const recovered = await sessionRecovery.handleSessionRecovery(messageInfo)
};
const recovered =
await sessionRecovery.handleSessionRecovery(messageInfo);
if (recovered && sessionID && sessionID === mainSessionID) {
await ctx.client.session.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
}).catch(() => {})
await ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
})
.catch(() => {});
}
}
@@ -156,26 +176,26 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
status: "error",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
})
});
}
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
const sessionID = props?.sessionID as string | undefined;
if (sessionID && sessionID === mainSessionID) {
updateTerminalTitle({
sessionId: sessionID,
status: "idle",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
})
});
}
}
},
"tool.execute.before": async (input, output) => {
await pulseMonitor["tool.execute.before"]()
await commentChecker["tool.execute.before"](input, output)
await pulseMonitor["tool.execute.before"]();
await commentChecker["tool.execute.before"](input, output);
if (input.sessionID === mainSessionID) {
updateTerminalTitle({
@@ -184,15 +204,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
currentTool: input.tool,
directory: ctx.directory,
sessionTitle: currentSessionTitle,
})
});
}
},
"tool.execute.after": async (input, output) => {
await pulseMonitor["tool.execute.after"](input)
await grepOutputTruncator["tool.execute.after"](input, output)
await contextWindowMonitor["tool.execute.after"](input, output)
await commentChecker["tool.execute.after"](input, output)
await pulseMonitor["tool.execute.after"](input);
await grepOutputTruncator["tool.execute.after"](input, output);
await contextWindowMonitor["tool.execute.after"](input, output);
await commentChecker["tool.execute.after"](input, output);
await directoryAgentsInjector["tool.execute.after"](input, output);
await emptyTaskResponseDetector["tool.execute.after"](input, output);
if (input.sessionID === mainSessionID) {
updateTerminalTitle({
@@ -200,13 +222,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
status: "idle",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
})
});
}
},
}
}
};
};
export default OhMyOpenCodePlugin
export default OhMyOpenCodePlugin;
export type {
OhMyOpenCodeConfig,
@@ -214,4 +236,4 @@ export type {
AgentOverrideConfig,
AgentOverrides,
McpName,
} from "./config"
} from "./config";

129
src/tools/glob/cli.ts Normal file
View File

@@ -0,0 +1,129 @@
import { spawn } from "bun"
import {
resolveGrepCli,
DEFAULT_TIMEOUT_MS,
DEFAULT_LIMIT,
DEFAULT_MAX_DEPTH,
DEFAULT_MAX_OUTPUT_BYTES,
RG_FILES_FLAGS,
} from "./constants"
import type { GlobOptions, GlobResult, FileMatch } from "./types"
import { stat } from "node:fs/promises"
function buildRgArgs(options: GlobOptions): string[] {
const args: string[] = [
...RG_FILES_FLAGS,
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
]
if (options.hidden) args.push("--hidden")
if (options.noIgnore) args.push("--no-ignore")
args.push(`--glob=${options.pattern}`)
return args
}
function buildFindArgs(options: GlobOptions): string[] {
const args: string[] = ["."]
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
args.push("-maxdepth", String(maxDepth))
args.push("-type", "f")
args.push("-name", options.pattern)
if (!options.hidden) {
args.push("-not", "-path", "*/.*")
}
return args
}
async function getFileMtime(filePath: string): Promise<number> {
try {
const stats = await stat(filePath)
return stats.mtime.getTime()
} catch {
return 0
}
}
export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
const cli = resolveGrepCli()
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
const isRg = cli.backend === "rg"
const args = isRg ? buildRgArgs(options) : buildFindArgs(options)
const paths = options.paths?.length ? options.paths : ["."]
if (isRg) {
args.push(...paths)
}
const cwd = paths[0] || "."
const proc = spawn([cli.path, ...args], {
stdout: "pipe",
stderr: "pipe",
cwd: isRg ? undefined : cwd,
})
const timeoutPromise = new Promise<never>((_, reject) => {
const id = setTimeout(() => {
proc.kill()
reject(new Error(`Glob search timeout after ${timeout}ms`))
}, timeout)
proc.exited.then(() => clearTimeout(id))
})
try {
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode > 1 && stderr.trim()) {
return {
files: [],
totalFiles: 0,
truncated: false,
error: stderr.trim(),
}
}
const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
const lines = outputToProcess.trim().split("\n").filter(Boolean)
const files: FileMatch[] = []
let truncated = false
for (const line of lines) {
if (files.length >= limit) {
truncated = true
break
}
const filePath = isRg ? line : `${cwd}/${line}`
const mtime = await getFileMtime(filePath)
files.push({ path: filePath, mtime })
}
files.sort((a, b) => b.mtime - a.mtime)
return {
files,
totalFiles: files.length,
truncated: truncated || truncatedOutput,
}
} catch (e) {
return {
files: [],
totalFiles: 0,
truncated: false,
error: e instanceof Error ? e.message : String(e),
}
}
}

View File

@@ -0,0 +1,12 @@
export { resolveGrepCli, type GrepBackend } from "../grep/constants"
export const DEFAULT_TIMEOUT_MS = 60_000
export const DEFAULT_LIMIT = 100
export const DEFAULT_MAX_DEPTH = 20
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
export const RG_FILES_FLAGS = [
"--files",
"--color=never",
"--glob=!.git/*",
] as const

3
src/tools/glob/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import { glob } from "./tools"
export { glob }

36
src/tools/glob/tools.ts Normal file
View File

@@ -0,0 +1,36 @@
import { tool } from "@opencode-ai/plugin/tool"
import { runRgFiles } from "./cli"
import { formatGlobResult } from "./utils"
export const glob = tool({
description:
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
"Returns matching file paths sorted by modification time. " +
"Use this tool when you need to find files by name patterns.",
args: {
pattern: tool.schema.string().describe("The glob pattern to match files against"),
path: tool.schema
.string()
.optional()
.describe(
"The directory to search in. If not specified, the current working directory will be used. " +
"IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " +
"simply omit it for the default behavior. Must be a valid directory path if provided."
),
},
execute: async (args) => {
try {
const paths = args.path ? [args.path] : undefined
const result = await runRgFiles({
pattern: args.pattern,
paths,
})
return formatGlobResult(result)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})

21
src/tools/glob/types.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface FileMatch {
path: string
mtime: number
}
export interface GlobResult {
files: FileMatch[]
totalFiles: number
truncated: boolean
error?: string
}
export interface GlobOptions {
pattern: string
paths?: string[]
hidden?: boolean
noIgnore?: boolean
maxDepth?: number
timeout?: number
limit?: number
}

26
src/tools/glob/utils.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { GlobResult } from "./types"
export function formatGlobResult(result: GlobResult): string {
if (result.error) {
return `Error: ${result.error}`
}
if (result.files.length === 0) {
return "No files found"
}
const lines: string[] = []
lines.push(`Found ${result.totalFiles} file(s)`)
lines.push("")
for (const file of result.files) {
lines.push(file.path)
}
if (result.truncated) {
lines.push("")
lines.push("(Results are truncated. Consider using a more specific path or pattern.)")
}
return lines.join("\n")
}

3
src/tools/grep/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import { grep } from "./tools"
export { grep }

View File

@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin/tool"
import { runRg } from "./cli"
import { formatGrepResult } from "./utils"
export const safe_grep = tool({
export const grep = tool({
description:
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
"Searches file contents using regular expressions. " +

View File

@@ -17,7 +17,8 @@ import {
ast_grep_replace,
} from "./ast-grep"
import { safe_grep } from "./safe-grep"
import { grep } from "./grep"
import { glob } from "./glob"
export const builtinTools = {
lsp_hover,
@@ -33,5 +34,6 @@ export const builtinTools = {
lsp_code_action_resolve,
ast_grep_search,
ast_grep_replace,
safe_grep,
grep,
glob,
}

View File

@@ -1,3 +0,0 @@
import { safe_grep } from "./tools"
export { safe_grep }

4
test-dir/AGENTS.md Normal file
View File

@@ -0,0 +1,4 @@
## Root Level Rules
- Root rule 1
- Root rule 2

3
test-dir/another.ts Normal file
View File

@@ -0,0 +1,3 @@
export const config = {
strict: true
}

View File

@@ -0,0 +1,4 @@
## Nested Level Rules
- Nested rule 1 (더 specific)
- Nested rule 2

1
test-dir/nested/deep.ts Normal file
View File

@@ -0,0 +1 @@
export const deep = true

3
test-dir/test.ts Normal file
View File

@@ -0,0 +1,3 @@
export function greet(name: string): string {
return `Hello, ${name}!`
}