Compare commits

..

19 Commits

Author SHA1 Message Date
github-actions[bot]
30e0cc6ef1 release: v2.12.2 2026-01-03 01:02:03 +00:00
Sisyphus
f345101f91 fix(ralph-loop): adopt OContinue patterns for better performance and abort handling (#431) 2026-01-03 09:55:12 +09:00
Sisyphus
d09c994b91 fix(session-recovery): detect 'final block cannot be thinking' error pattern (#420) 2026-01-03 09:46:45 +09:00
sisyphus-dev-ai
8c30974c18 fix: address review feedback - fix typos and wording consistency 2026-01-03 00:43:09 +00:00
Sisyphus
c341c156ec docs: update Discord invite link in all README files (#429) 2026-01-03 09:35:35 +09:00
github-actions[bot]
b1528c590d release: v2.12.1 2026-01-02 17:42:01 +00:00
Jeon Suyeol
8b9913345b fix(todo-continuation-enforcer): add 500ms grace period to prevent false countdown cancellation (#424) 2026-01-03 02:22:55 +09:00
YeonGyu-Kim
fa204d8af0 chore: remove dead code - unused imports and variables
- Remove unused import OhMyOpenCodeConfig from src/index.ts
- Remove unused import dirname from src/features/opencode-skill-loader/loader.ts
- Remove unused import detectKeywords from src/hooks/keyword-detector/index.ts
- Remove unused import CliMatch from src/tools/ast-grep/utils.ts
- Prefix unused parameter _original in src/tools/ast-grep/utils.ts

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 23:03:16 +09:00
YeonGyu-Kim
924fa79bd3 style: improve git command env prefix readability with line continuation
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:52:39 +09:00
YeonGyu-Kim
c78241e78e docs(agents): regenerate AGENTS.md with updated commit reference (d0694e5) and corrected line counts
- Updated timestamp: 2026-01-02T22:41:22+09:00
- src/index.ts: 723→464 lines
- executor.ts: 554→564 lines

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:43:37 +09:00
YeonGyu-Kim
d0694e5aa4 fix(background-agent): prevent memory leaks by cleaning notifications in finally block and add TTL-based task pruning
- Move clearNotificationsForTask() to finally block to ensure cleanup even on success
- Add TASK_TTL_MS (30 min) constant for stale task detection
- Implement pruneStaleTasksAndNotifications() to remove expired tasks and notifications
- Add comprehensive tests for pruning functionality (fresh tasks, stale tasks, notifications)
- Prevents indefinite Map growth when tasks complete without errors

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:30:55 +09:00
YeonGyu-Kim
4a9bdc89aa fix(non-interactive-env): prepend env vars directly to git command string
OpenCode's bash tool ignores args.env and uses hardcoded process.env in spawn().
Work around this by prepending GIT_EDITOR, EDITOR, VISUAL, and PAGER env vars
directly to the command string. Only applies to git commands to avoid bloating
non-git commands.

Added shellEscape() and buildEnvPrefix() helper functions to properly escape
env var values and construct the prefix string.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:30:55 +09:00
sisyphus-dev-ai
50afbf7c37 chore: changes by sisyphus-dev-ai 2026-01-02 12:54:32 +00:00
YeonGyu-Kim
b64b3f96e6 fix(recovery): correct prompt_async API path parameter from sessionID to id
The prompt_async method expects path parameter named 'id' (not 'sessionID').
This bug prevented the 'Continue' message from being sent after compaction,
causing the recovery process to fail silently due to silent error swallowing
in the empty catch block.

Fixes:
- Type definition: changed path: { sessionID: string } to path: { id: string }
- Implementation: changed path: { sessionID } to path: { id: sessionID } at 2 locations (lines 411, 509)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 21:16:23 +09:00
YeonGyu-Kim
e3ad790185 feat(hooks): add edit-error-recovery hook for handling Edit tool errors (opencode#4718)
- Detects Edit tool errors (oldString/newString mismatches)
- Injects system reminder forcing AI to read file, verify state, apologize
- Includes comprehensive test suite (8 tests)
- Integrates with hook system and configuration schema

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 21:16:23 +09:00
github-actions[bot]
8d570af3dd release: v2.12.0 2026-01-02 11:41:56 +00:00
YeonGyu-Kim
ddeabb1a8b fix(claude-code-hooks): handle UserPromptSubmit on first message properly
- Allow UserPromptSubmit hooks to run on first message (used for title generation)
- For first message: prepend hook content directly to message parts
- For subsequent messages: use file system injection as before
- Preserves hook injection integrity while enabling title generation hooks

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 20:29:01 +09:00
YeonGyu-Kim
7a896fd2b9 fix(token-limit-recovery): exclude thinking block errors from token limit detection
- Removed 'invalid_request_error' from TOKEN_LIMIT_KEYWORDS (too broad)
- Added THINKING_BLOCK_ERROR_PATTERNS to explicitly detect thinking block structure errors
- Added isThinkingBlockError() function to filter these out before token limit checks
- Prevents 'thinking block order' errors from triggering compaction instead of session-recovery

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 20:28:37 +09:00
YeonGyu-Kim
823f12d88d fix(todo-continuation-enforcer): preserve model/provider from nearest message
When injecting continuation prompts, extract and pass the model field

(providerID + modelID) from the nearest stored message, matching the

pattern used in background-agent/manager.ts and session-recovery.

Also updated tests to capture the model field for verification.
2026-01-02 19:07:44 +09:00
30 changed files with 1176 additions and 151 deletions

View File

@@ -316,8 +316,8 @@ jobs:
---
Write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
Plan everything using todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitly, then use plan agent to plan, todo obsessively then create a PR to `BRANCH_PLACEHOLDER` branch.
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
PROMPT_EOF
)

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-01-02T10:35:00+09:00
**Commit:** bebe660
**Generated:** 2026-01-02T22:41:22+09:00
**Commit:** d0694e5
**Branch:** dev
## OVERVIEW
@@ -22,7 +22,7 @@ oh-my-opencode/
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # MCP configs: context7, websearch_exa, grep_app
│ ├── config/ # Zod schema, TypeScript types
│ └── index.ts # Main plugin entry (723 lines)
│ └── index.ts # Main plugin entry (464 lines)
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
└── dist/ # Build output (ESM + .d.ts)
```
@@ -96,11 +96,11 @@ CI auto-commits schema changes on master, maintains rolling `next` draft release
| File | Lines | Description |
|------|-------|-------------|
| `src/index.ts` | 723 | Main plugin, all hook/tool init |
| `src/index.ts` | 464 | Main plugin, all hook/tool init |
| `src/cli/config-manager.ts` | 669 | JSONC parsing, env detection |
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting |
| `src/tools/lsp/client.ts` | 611 | LSP protocol, JSON-RPC |
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 564 | Multi-stage recovery |
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt |
## NOTES

View File

@@ -4,7 +4,7 @@
>
> 一緒に歩みましょう!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | [Discordコミュニティ](https://discord.gg/aSfGzWtYxM)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |

View File

@@ -4,7 +4,7 @@
>
> 함께해주세요!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | [Discord 커뮤니티](https://discord.gg/aSfGzWtYxM)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |

View File

@@ -7,7 +7,7 @@
>
> Be with us!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | Join our [Discord community](https://discord.gg/aSfGzWtYxM) to connect with contributors and fellow `oh-my-opencode` users. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |

View File

@@ -4,7 +4,7 @@
>
> 与我们同行!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | 加入我们的 [Discord 社区](https://discord.gg/aSfGzWtYxM),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |

View File

@@ -74,7 +74,8 @@
"preemptive-compaction",
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command"
"auto-slash-command",
"edit-error-recovery"
]
}
},

View File

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

View File

@@ -74,6 +74,7 @@ export const HookNameSchema = z.enum([
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
])
export const BuiltinCommandNameSchema = z.enum([

View File

@@ -1,8 +1,11 @@
import { describe, test, expect, beforeEach } from "bun:test"
import type { BackgroundTask } from "./types"
const TASK_TTL_MS = 30 * 60 * 1000
class MockBackgroundManager {
private tasks: Map<string, BackgroundTask> = new Map()
private notifications: Map<string, BackgroundTask[]> = new Map()
addTask(task: BackgroundTask): void {
this.tasks.set(task.id, task)
@@ -34,6 +37,74 @@ class MockBackgroundManager {
return result
}
markForNotification(task: BackgroundTask): void {
const queue = this.notifications.get(task.parentSessionID) ?? []
queue.push(task)
this.notifications.set(task.parentSessionID, queue)
}
getPendingNotifications(sessionID: string): BackgroundTask[] {
return this.notifications.get(sessionID) ?? []
}
private clearNotificationsForTask(taskId: string): void {
for (const [sessionID, tasks] of this.notifications.entries()) {
const filtered = tasks.filter((t) => t.id !== taskId)
if (filtered.length === 0) {
this.notifications.delete(sessionID)
} else {
this.notifications.set(sessionID, filtered)
}
}
}
pruneStaleTasksAndNotifications(): { prunedTasks: string[]; prunedNotifications: number } {
const now = Date.now()
const prunedTasks: string[] = []
let prunedNotifications = 0
for (const [taskId, task] of this.tasks.entries()) {
const age = now - task.startedAt.getTime()
if (age > TASK_TTL_MS) {
prunedTasks.push(taskId)
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
}
}
for (const [sessionID, notifications] of this.notifications.entries()) {
if (notifications.length === 0) {
this.notifications.delete(sessionID)
continue
}
const validNotifications = notifications.filter((task) => {
const age = now - task.startedAt.getTime()
return age <= TASK_TTL_MS
})
const removed = notifications.length - validNotifications.length
prunedNotifications += removed
if (validNotifications.length === 0) {
this.notifications.delete(sessionID)
} else if (validNotifications.length !== notifications.length) {
this.notifications.set(sessionID, validNotifications)
}
}
return { prunedTasks, prunedNotifications }
}
getTaskCount(): number {
return this.tasks.size
}
getNotificationCount(): number {
let count = 0
for (const notifications of this.notifications.values()) {
count += notifications.length
}
return count
}
}
function createMockTask(overrides: Partial<BackgroundTask> & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask {
@@ -230,3 +301,116 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
expect(result[0].id).toBe("task-b")
})
})
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
let manager: MockBackgroundManager
beforeEach(() => {
// #given
manager = new MockBackgroundManager()
})
test("should not prune fresh tasks", () => {
// #given
const task = createMockTask({
id: "task-fresh",
sessionID: "session-fresh",
parentSessionID: "session-parent",
startedAt: new Date(),
})
manager.addTask(task)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedTasks).toHaveLength(0)
expect(manager.getTaskCount()).toBe(1)
})
test("should prune tasks older than 30 minutes", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.addTask(task)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedTasks).toContain("task-stale")
expect(manager.getTaskCount()).toBe(0)
})
test("should prune stale notifications", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.markForNotification(task)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedNotifications).toBe(1)
expect(manager.getNotificationCount()).toBe(0)
})
test("should clean up notifications when task is pruned", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.addTask(task)
manager.markForNotification(task)
// #when
manager.pruneStaleTasksAndNotifications()
// #then
expect(manager.getTaskCount()).toBe(0)
expect(manager.getNotificationCount()).toBe(0)
})
test("should keep fresh tasks while pruning stale ones", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const staleTask = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
const freshTask = createMockTask({
id: "task-fresh",
sessionID: "session-fresh",
parentSessionID: "session-parent",
startedAt: new Date(),
})
manager.addTask(staleTask)
manager.addTask(freshTask)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedTasks).toHaveLength(1)
expect(result.prunedTasks).toContain("task-stale")
expect(manager.getTaskCount()).toBe(1)
expect(manager.getTask("task-fresh")).toBeDefined()
})
})

View File

@@ -12,6 +12,8 @@ import {
} from "../hook-message-injector"
import { subagentSessions } from "../claude-code-session-state"
const TASK_TTL_MS = 30 * 60 * 1000
type OpencodeClient = PluginInput["client"]
interface MessagePartInfo {
@@ -345,11 +347,12 @@ export class BackgroundManager {
},
query: { directory: this.directory },
})
this.clearNotificationsForTask(taskId)
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
} catch (error) {
log("[background-agent] prompt failed:", String(error))
} finally {
// Always clean up both maps to prevent memory leaks
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
@@ -377,7 +380,42 @@ export class BackgroundManager {
return false
}
private pruneStaleTasksAndNotifications(): void {
const now = Date.now()
for (const [taskId, task] of this.tasks.entries()) {
const age = now - task.startedAt.getTime()
if (age > TASK_TTL_MS) {
log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s" })
task.status = "error"
task.error = "Task timed out after 30 minutes"
task.completedAt = new Date()
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
subagentSessions.delete(task.sessionID)
}
}
for (const [sessionID, notifications] of this.notifications.entries()) {
if (notifications.length === 0) {
this.notifications.delete(sessionID)
continue
}
const validNotifications = notifications.filter((task) => {
const age = now - task.startedAt.getTime()
return age <= TASK_TTL_MS
})
if (validNotifications.length === 0) {
this.notifications.delete(sessionID)
} else if (validNotifications.length !== notifications.length) {
this.notifications.set(sessionID, validNotifications)
}
}
}
private async pollRunningTasks(): Promise<void> {
this.pruneStaleTasksAndNotifications()
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>

View File

@@ -1,5 +1,5 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path"
import { join, basename } from "path"
import { homedir } from "os"
import yaml from "js-yaml"
import { parseFrontmatter } from "../../shared/frontmatter"

View File

@@ -39,7 +39,7 @@ type Client = {
query: { directory: string };
}) => Promise<unknown>;
prompt_async: (opts: {
path: { sessionID: string };
path: { id: string };
body: { parts: Array<{ type: string; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
@@ -408,7 +408,7 @@ export async function executeCompact(
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
path: { id: sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});
@@ -506,7 +506,7 @@ export async function executeCompact(
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
path: { id: sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});

View File

@@ -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()))
}

View File

@@ -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,
})
}
}
},

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
describe("createEditErrorRecoveryHook", () => {
let hook: ReturnType<typeof createEditErrorRecoveryHook>
beforeEach(() => {
hook = createEditErrorRecoveryHook({} as any)
})
describe("tool.execute.after", () => {
const createInput = (tool: string) => ({
tool,
sessionID: "test-session",
callID: "test-call-id",
})
const createOutput = (outputText: string) => ({
title: "Edit",
output: outputText,
metadata: {},
})
describe("#given Edit tool with oldString/newString same error", () => {
describe("#when the error message is detected", () => {
it("#then should append the recovery reminder", async () => {
const input = createInput("Edit")
const output = createOutput("Error: oldString and newString must be different")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
expect(output.output).toContain("oldString and newString must be different")
})
})
describe("#when the error appears without Error prefix", () => {
it("#then should still detect and append reminder", async () => {
const input = createInput("Edit")
const output = createOutput("oldString and newString must be different")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
describe("#given Edit tool with oldString not found error", () => {
describe("#when oldString not found in content", () => {
it("#then should append the recovery reminder", async () => {
const input = createInput("Edit")
const output = createOutput("Error: oldString not found in content")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
describe("#given Edit tool with multiple matches error", () => {
describe("#when oldString found multiple times", () => {
it("#then should append the recovery reminder", async () => {
const input = createInput("Edit")
const output = createOutput(
"Error: oldString found multiple times and requires more code context to uniquely identify the intended match"
)
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
describe("#given non-Edit tool", () => {
describe("#when tool is not Edit", () => {
it("#then should not modify output", async () => {
const input = createInput("Read")
const originalOutput = "some output"
const output = createOutput(originalOutput)
await hook["tool.execute.after"](input, output)
expect(output.output).toBe(originalOutput)
})
})
})
describe("#given Edit tool with successful output", () => {
describe("#when no error in output", () => {
it("#then should not modify output", async () => {
const input = createInput("Edit")
const originalOutput = "File edited successfully"
const output = createOutput(originalOutput)
await hook["tool.execute.after"](input, output)
expect(output.output).toBe(originalOutput)
})
})
})
describe("#given case insensitive tool name", () => {
describe("#when tool is 'edit' lowercase", () => {
it("#then should still detect and append reminder", async () => {
const input = createInput("edit")
const output = createOutput("oldString and newString must be different")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
})
describe("EDIT_ERROR_PATTERNS", () => {
it("#then should contain all known Edit error patterns", () => {
expect(EDIT_ERROR_PATTERNS).toContain("oldString and newString must be different")
expect(EDIT_ERROR_PATTERNS).toContain("oldString not found")
expect(EDIT_ERROR_PATTERNS).toContain("oldString found multiple times")
})
})
})

View File

@@ -0,0 +1,57 @@
import type { PluginInput } from "@opencode-ai/plugin"
/**
* Known Edit tool error patterns that indicate the AI made a mistake
*/
export const EDIT_ERROR_PATTERNS = [
"oldString and newString must be different",
"oldString not found",
"oldString found multiple times",
] as const
/**
* System reminder injected when Edit tool fails due to AI mistake
* Short, direct, and commanding - forces immediate corrective action
*/
export const EDIT_ERROR_REMINDER = `
[EDIT ERROR - IMMEDIATE ACTION REQUIRED]
You made an Edit mistake. STOP and do this NOW:
1. READ the file immediately to see its ACTUAL current state
2. VERIFY what the content really looks like (your assumption was wrong)
3. APOLOGIZE briefly to the user for the error
4. CONTINUE with corrected action based on the real file content
DO NOT attempt another edit until you've read and verified the file state.
`
/**
* Detects Edit tool errors caused by AI mistakes and injects a recovery reminder
*
* This hook catches common Edit tool failures:
* - oldString and newString must be different (trying to "edit" to same content)
* - oldString not found (wrong assumption about file content)
* - oldString found multiple times (ambiguous match, need more context)
*
* @see https://github.com/sst/opencode/issues/4718
*/
export function createEditErrorRecoveryHook(_ctx: PluginInput) {
return {
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (input.tool.toLowerCase() !== "edit") return
const outputLower = output.output.toLowerCase()
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
outputLower.includes(pattern.toLowerCase())
)
if (hasEditError) {
output.output += `\n${EDIT_ERROR_REMINDER}`
}
},
}
}

View File

@@ -24,3 +24,4 @@ export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
export { createAutoSlashCommandHook } from "./auto-slash-command";
export { createEditErrorRecoveryHook } from "./edit-error-recovery";

View File

@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
import { detectKeywordsWithType, extractPromptText } from "./detector"
import { log } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"

View File

@@ -0,0 +1,133 @@
import { describe, test, expect } from "bun:test"
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
describe("non-interactive-env hook", () => {
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
describe("git command modification", () => {
test("#given git command #when hook executes #then prepends env vars", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git commit -m 'test'" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toContain("GIT_EDITOR=:")
expect(cmd).toContain("EDITOR=:")
expect(cmd).toContain("PAGER=cat")
expect(cmd).toEndWith(" git commit -m 'test'")
})
test("#given non-git bash command #when hook executes #then command unchanged", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "ls -la" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.args.command).toBe("ls -la")
})
test("#given non-bash tool #when hook executes #then command unchanged", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "Read", sessionID: "test", callID: "1" },
output
)
expect(output.args.command).toBe("git status")
})
test("#given empty command #when hook executes #then no error", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: {},
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.args.command).toBeUndefined()
})
})
describe("shell escaping", () => {
test("#given git command #when building prefix #then VISUAL properly escaped", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toContain("VISUAL=''")
})
test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git log" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
for (const key of Object.keys(NON_INTERACTIVE_ENV)) {
expect(cmd).toContain(`${key}=`)
}
})
})
describe("banned command detection", () => {
test("#given vim command #when hook executes #then warning message set", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "vim file.txt" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.message).toContain("vim")
expect(output.message).toContain("interactive")
})
test("#given safe command #when hook executes #then no warning", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "ls -la" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.message).toBeUndefined()
})
})
})

View File

@@ -19,6 +19,33 @@ function detectBannedCommand(command: string): string | undefined {
return undefined
}
/**
* Shell-escape a value for use in VAR=value prefix.
* Wraps in single quotes if contains special chars.
*/
function shellEscape(value: string): string {
// Empty string needs quotes
if (value === "") return "''"
// If contains special chars, wrap in single quotes (escape existing single quotes)
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
return `'${value.replace(/'/g, "'\\''")}'`
}
return value
}
/**
* Build env prefix string with line continuation for readability:
* VAR1=val1 \
* VAR2=val2 \
* ...
* OpenCode's bash tool ignores args.env, so we must prepend to command.
*/
function buildEnvPrefix(env: Record<string, string>): string {
return Object.entries(env)
.map(([key, value]) => `${key}=${shellEscape(value)}`)
.join(" \\\n")
}
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return {
"tool.execute.before": async (
@@ -34,20 +61,25 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return
}
output.args.env = {
...process.env,
...(output.args.env as Record<string, string> | undefined),
...NON_INTERACTIVE_ENV,
}
const bannedCmd = detectBannedCommand(command)
if (bannedCmd) {
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
}
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
// Only prepend env vars for git commands (editor blocking, pager, etc.)
const isGitCommand = /\bgit\b/.test(command)
if (!isGitCommand) {
return
}
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
// ignoring any args.env we might set. Prepend to command instead.
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
output.args.command = `${envPrefix} ${command}`
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
sessionID: input.sessionID,
env: NON_INTERACTIVE_ENV,
envPrefix,
})
},
}

View File

@@ -423,5 +423,162 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("Create a calculator app")
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
test("should clear loop state on user abort (MessageAbortedError)", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
expect(hook.getState()).not.toBeNull()
// #when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError", message: "User aborted" },
},
},
})
// #then - loop state should be cleared immediately
expect(hook.getState()).toBeNull()
})
test("should NOT set recovery mode on user abort", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
// #when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError" },
},
},
})
// Start a new loop
hook.startLoop("session-123", "New task")
// #when - session goes idle immediately (should work, no recovery mode)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - continuation should be injected (not blocked by recovery)
expect(promptCalls.length).toBe(1)
})
test("should only check LAST assistant message for completion", async () => {
// #given - multiple assistant messages, only first has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should continue (last message has no completion promise)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion only in LAST assistant message", async () => {
// #given - last assistant message has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! <promise>DONE</promise>" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should complete (last message has completion promise)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should check transcript BEFORE API to optimize performance", async () => {
// #given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
mockSessionMessages = [
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - should complete via transcript (API not called when transcript succeeds)
expect(promptCalls.length).toBe(0)
expect(hook.getState()).toBeNull()
// API should NOT be called since transcript found completion
expect(messagesCalls.length).toBe(0)
})
})
describe("API timeout protection", () => {
test("should not hang when session.messages() times out", async () => {
// #given - slow API that takes longer than timeout
const slowMock = {
...createMockPluginInput(),
client: {
...createMockPluginInput().client,
session: {
...createMockPluginInput().client.session,
messages: async () => {
// Simulate slow API (would hang without timeout)
await new Promise((resolve) => setTimeout(resolve, 10000))
return { data: [] }
},
},
},
}
const hook = createRalphLoopHook(slowMock as any, {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
apiTimeout: 100, // 100ms timeout for test
})
hook.startLoop("session-123", "Build something")
// #when - session goes idle (API will timeout)
const startTime = Date.now()
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
const elapsed = Date.now() - startTime
// #then - should complete within timeout + buffer (not hang for 10s)
expect(elapsed).toBeLessThan(500)
// #then - loop should continue (API timeout = no completion detected)
expect(promptCalls.length).toBe(1)
})
})
})

View File

@@ -53,6 +53,8 @@ export interface RalphLoopHook {
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 3000
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
@@ -61,6 +63,7 @@ export function createRalphLoopHook(
const config = options?.config
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
@@ -97,32 +100,34 @@ export function createRalphLoopHook(
promise: string
): Promise<boolean> {
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const response = await Promise.race([
ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
),
])
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
(msg) => msg.info?.role === "assistant"
)
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (!lastAssistant?.parts) return false
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
const responseText = lastAssistant.parts
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("\n")
for (const msg of messages as OpenCodeSessionMessage[]) {
if (msg.info?.role !== "assistant") continue
for (const part of msg.parts || []) {
if (part.type === "text" && part.text) {
if (pattern.test(part.text)) {
return true
}
}
}
}
return false
return pattern.test(responseText)
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
return false
}
}
@@ -197,20 +202,19 @@ export function createRalphLoopHook(
return
}
const completionDetectedViaApi = await detectCompletionInSessionMessages(
sessionID,
state.completion_promise
)
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
if (completionDetectedViaApi || completionDetectedViaTranscript) {
const completionDetectedViaApi = completionDetectedViaTranscript
? false
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
if (completionDetectedViaTranscript || completionDetectedViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
})
clearState(ctx.directory, stateDir)
@@ -308,6 +312,20 @@ export function createRalphLoopHook(
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError") {
if (sessionID) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionID) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
}
sessions.delete(sessionID)
}
return
}
if (sessionID) {
const sessionState = getSessionState(sessionID)
sessionState.isRecovering = true

View File

@@ -13,4 +13,5 @@ export interface RalphLoopState {
export interface RalphLoopOptions {
config?: RalphLoopConfig
getTranscriptPath?: (sessionId: string) => string
apiTimeout?: number
}

View File

@@ -0,0 +1,203 @@
import { describe, expect, it } from "bun:test"
import { detectErrorType } from "./index"
describe("detectErrorType", () => {
describe("thinking_block_order errors", () => {
it("should detect 'first block' error pattern", () => {
// #given an error about thinking being the first block
const error = {
message: "messages.0: thinking block must not be the first block",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'must start with' error pattern", () => {
// #given an error about message must start with something
const error = {
message: "messages.5: thinking must start with text or tool_use",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'preceeding' error pattern", () => {
// #given an error about preceeding block
const error = {
message: "messages.10: thinking requires preceeding text block",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'expected/found' error pattern", () => {
// #given an error about expected vs found
const error = {
message: "messages.3: thinking block expected text but found tool_use",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'final block cannot be thinking' error pattern", () => {
// #given an error about final block cannot be thinking
const error = {
message:
"messages.125: The final block in an assistant message cannot be thinking.",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'final block' variant error pattern", () => {
// #given an error mentioning final block with thinking
const error = {
message:
"messages.17: thinking in the final block is not allowed in assistant messages",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'cannot be thinking' error pattern", () => {
// #given an error using 'cannot be thinking' phrasing
const error = {
message:
"messages.219: The last block in an assistant message cannot be thinking content",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
})
describe("tool_result_missing errors", () => {
it("should detect tool_use/tool_result mismatch", () => {
// #given an error about tool_use without tool_result
const error = {
message: "tool_use block requires corresponding tool_result",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return tool_result_missing
expect(result).toBe("tool_result_missing")
})
})
describe("thinking_disabled_violation errors", () => {
it("should detect thinking disabled violation", () => {
// #given an error about thinking being disabled
const error = {
message:
"thinking is disabled for this model and cannot contain thinking blocks",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_disabled_violation
expect(result).toBe("thinking_disabled_violation")
})
})
describe("unrecognized errors", () => {
it("should return null for unrecognized error patterns", () => {
// #given an unrelated error
const error = {
message: "Rate limit exceeded",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
expect(result).toBeNull()
})
it("should return null for empty error", () => {
// #given an empty error
const error = {}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
expect(result).toBeNull()
})
it("should return null for null error", () => {
// #given a null error
const error = null
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
expect(result).toBeNull()
})
})
describe("nested error objects", () => {
it("should detect error in data.error.message path", () => {
// #given an error with nested structure
const error = {
data: {
error: {
message:
"messages.163: The final block in an assistant message cannot be thinking.",
},
},
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect error in error.message path", () => {
// #given an error with error.message structure
const error = {
error: {
message: "messages.169: final block cannot be thinking",
},
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
})
})

View File

@@ -122,7 +122,7 @@ function extractMessageIndex(error: unknown): number | null {
return match ? parseInt(match[1], 10) : null
}
function detectErrorType(error: unknown): RecoveryErrorType {
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (message.includes("tool_use") && message.includes("tool_result")) {
@@ -134,6 +134,8 @@ function detectErrorType(error: unknown): RecoveryErrorType {
(message.includes("first block") ||
message.includes("must start with") ||
message.includes("preceeding") ||
message.includes("final block") ||
message.includes("cannot be thinking") ||
(message.includes("expected") && message.includes("found")))
) {
return "thinking_block_order"

View File

@@ -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
}
@@ -215,7 +216,7 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls.length).toBe(1)
})
test("should cancel countdown on user message", async () => {
test("should cancel countdown on user message after grace period", async () => {
// #given - session starting countdown
const sessionID = "main-cancel"
setMainSession(sessionID)
@@ -227,19 +228,46 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user sends message immediately (before 2s countdown)
// #when - wait past grace period (500ms), then user sends message
await new Promise(r => setTimeout(r, 600))
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// #then - wait past countdown time and verify no injection
// #then - wait past countdown time and verify no injection (countdown was cancelled)
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(0)
})
test("should ignore user message within grace period", async () => {
// #given - session starting countdown
const sessionID = "main-grace"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user message arrives within grace period (immediately)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// #then - countdown should continue (message was ignored)
// wait past 2s countdown and verify injection happens
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(1)
})
test("should cancel countdown on assistant activity", async () => {
// #given - session starting countdown
const sessionID = "main-assistant"
@@ -255,9 +283,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 +446,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 +475,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 +512,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 +546,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 +575,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 +612,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" } }
},
})

View File

@@ -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"
@@ -33,6 +33,7 @@ interface SessionState {
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
countdownStartedAt?: number
}
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
@@ -45,6 +46,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const COUNTDOWN_GRACE_PERIOD_MS = 500
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
@@ -62,22 +64,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 +106,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
@@ -113,6 +115,7 @@ export function createTodoContinuationEnforcer(
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
}
state.countdownStartedAt = undefined
}
function cleanup(sessionID: string): void {
@@ -148,7 +151,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 +186,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 +202,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) })
@@ -223,6 +231,7 @@ export function createTodoContinuationEnforcer(
let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(secondsRemaining, incompleteCount)
state.countdownStartedAt = Date.now()
state.countdownInterval = setInterval(() => {
secondsRemaining--
@@ -250,7 +259,7 @@ export function createTodoContinuationEnforcer(
const isAbort = isAbortError(props?.error)
state.lastEventWasAbortError = isAbort
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
return
}
@@ -264,7 +273,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
@@ -329,6 +338,13 @@ export function createTodoContinuationEnforcer(
}
if (role === "user") {
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
return
}
}
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID })
}

View File

@@ -25,6 +25,7 @@ import {
createThinkingBlockValidatorHook,
createRalphLoopHook,
createAutoSlashCommandHook,
createEditErrorRecoveryHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
@@ -52,7 +53,7 @@ import {
} from "./tools";
import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { type OhMyOpenCodeConfig, type HookName } from "./config";
import { type HookName } from "./config";
import { log } from "./shared";
import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState, getModelLimit } from "./plugin-state";
@@ -152,6 +153,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createAutoSlashCommandHook()
: null;
const editErrorRecovery = isHookEnabled("edit-error-recovery")
? createEditErrorRecoveryHook(ctx)
: null;
const backgroundManager = new BackgroundManager(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
@@ -436,6 +441,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
await agentUsageReminder?.["tool.execute.after"](input, output);
await interactiveBashSession?.["tool.execute.after"](input, output);
await editErrorRecovery?.["tool.execute.after"](input, output);
},
};
};

View File

@@ -1,4 +1,4 @@
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
import type { AnalyzeResult, SgResult } from "./types"
export function formatSearchResult(result: SgResult): string {
if (result.error) {
@@ -93,7 +93,7 @@ export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars:
return lines.join("\n")
}
export function formatTransformResult(original: string, transformed: string, editCount: number): string {
export function formatTransformResult(_original: string, transformed: string, editCount: number): string {
if (editCount === 0) {
return "No matches found to transform"
}