diff --git a/AGENTS.md b/AGENTS.md
index a5b8d144..40a72f6a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,8 +1,8 @@
# PROJECT KNOWLEDGE BASE
-**Generated:** 2026-02-10T14:44:00+09:00
-**Commit:** b538806d
-**Branch:** dev
+**Generated:** 2026-02-16T14:58:00+09:00
+**Commit:** 28cd34c3
+**Branch:** fuck-v1.2
---
@@ -102,32 +102,32 @@ Oh-My-OpenCode is a **plugin for OpenCode**. You will frequently need to examine
## OVERVIEW
-OpenCode plugin (v3.4.0): multi-model agent orchestration with 11 specialized agents (Claude Opus 4.6, GPT-5.3 Codex, Gemini 3 Flash, GLM-4.7, Grok). 41 lifecycle hooks across 7 event types, 25+ tools (LSP, AST-Grep, delegation, task management), full Claude Code compatibility layer. "oh-my-zsh" for OpenCode.
+OpenCode plugin (oh-my-opencode): multi-model agent orchestration with 11 specialized agents, 41 lifecycle hooks across 7 event types, 26 tools (LSP, AST-Grep, delegation, task management), full Claude Code compatibility layer, 4-scope skill loading, background agent concurrency, tmux integration, and 3-tier MCP system. "oh-my-zsh" for OpenCode.
## STRUCTURE
```
oh-my-opencode/
├── src/
-│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md
-│ ├── hooks/ # 41 lifecycle hooks - see src/hooks/AGENTS.md
-│ ├── tools/ # 25+ tools - see src/tools/AGENTS.md
-│ ├── features/ # Background agents, skills, CC compat - see src/features/AGENTS.md
-│ ├── shared/ # 84 cross-cutting utilities - see src/shared/AGENTS.md
-│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
-│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
-│ ├── config/ # Zod schema - see src/config/AGENTS.md
-│ ├── plugin-handlers/ # Config loading - see src/plugin-handlers/AGENTS.md
+│ ├── agents/ # 11 AI agents — see src/agents/AGENTS.md
+│ ├── hooks/ # 41 lifecycle hooks — see src/hooks/AGENTS.md
+│ ├── tools/ # 26 tools — see src/tools/AGENTS.md
+│ ├── features/ # Background agents, skills, CC compat — see src/features/AGENTS.md
+│ ├── shared/ # Cross-cutting utilities — see src/shared/AGENTS.md
+│ ├── cli/ # CLI installer, doctor — see src/cli/AGENTS.md
+│ ├── mcp/ # Built-in MCPs — see src/mcp/AGENTS.md
+│ ├── config/ # Zod schema — see src/config/AGENTS.md
+│ ├── plugin-handlers/ # Config loading pipeline — see src/plugin-handlers/AGENTS.md
│ ├── plugin/ # Plugin interface composition (21 files)
-│ ├── index.ts # Main plugin entry (88 lines)
+│ ├── index.ts # Main plugin entry (106 lines)
│ ├── create-hooks.ts # Hook creation coordination (62 lines)
│ ├── create-managers.ts # Manager initialization (80 lines)
│ ├── create-tools.ts # Tool registry composition (54 lines)
│ ├── plugin-interface.ts # Plugin interface assembly (66 lines)
-│ ├── plugin-config.ts # Config loading orchestration
-│ └── plugin-state.ts # Model cache state
+│ ├── plugin-config.ts # Config loading orchestration (180 lines)
+│ └── plugin-state.ts # Model cache state (12 lines)
├── script/ # build-schema.ts, build-binaries.ts, publish.ts, generate-changelog.ts
-├── packages/ # 7 platform-specific binary packages
+├── packages/ # 11 platform-specific binary packages
└── dist/ # Build output (ESM + .d.ts)
```
@@ -143,7 +143,7 @@ OhMyOpenCodePlugin(ctx)
6. createManagers(ctx, config, tmux, cache) → TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
7. createTools(ctx, config, managers) → filteredTools, mergedSkills, availableSkills, availableCategories
8. createHooks(ctx, config, backgroundMgr) → 41 hooks (core + continuation + skill)
- 9. createPluginInterface(...) → tool, chat.params, chat.message, event, tool.execute.before/after
+ 9. createPluginInterface(...) → 7 OpenCode hook handlers
10. Return plugin with experimental.session.compacting
```
@@ -159,7 +159,7 @@ OhMyOpenCodePlugin(ctx)
| Add command | `src/features/builtin-commands/` | Add template + register in commands.ts |
| Config schema | `src/config/schema/` | 21 schema component files, run `bun run build:schema` |
| Plugin config | `src/plugin-handlers/config-handler.ts` | JSONC loading, merging, migration |
-| Background agents | `src/features/background-agent/` | manager.ts (1646 lines) |
+| Background agents | `src/features/background-agent/` | manager.ts (1701 lines) |
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (1976 lines) |
| Delegation | `src/tools/delegate-task/` | Category routing (constants.ts 569 lines) |
| Task system | `src/features/claude-tasks/` | Task schema, storage, todo sync |
@@ -174,7 +174,7 @@ OhMyOpenCodePlugin(ctx)
**Rules:**
- NEVER write implementation before test
-- NEVER delete failing tests - fix the code
+- NEVER delete failing tests — fix the code
- Test file: `*.test.ts` alongside source (176 test files)
- BDD comments: `//#given`, `//#when`, `//#then`
@@ -185,7 +185,7 @@ OhMyOpenCodePlugin(ctx)
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern via index.ts
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
-- **Testing**: BDD comments, 176 test files, 117k+ lines TypeScript
+- **Testing**: BDD comments, 176 test files, 1130 TypeScript files
- **Temperature**: 0.1 for code agents, max 0.3
- **Modular architecture**: 200 LOC hard limit per file (prompt strings exempt)
@@ -193,24 +193,24 @@ OhMyOpenCodePlugin(ctx)
| Category | Forbidden |
|----------|-----------|
-| Package Manager | npm, yarn - Bun exclusively |
-| Types | @types/node - use bun-types |
-| File Ops | mkdir/touch/rm/cp/mv in code - use bash tool |
-| Publishing | Direct `bun publish` - GitHub Actions only |
-| Versioning | Local version bump - CI manages |
+| Package Manager | npm, yarn — Bun exclusively |
+| Types | @types/node — use bun-types |
+| File Ops | mkdir/touch/rm/cp/mv in code — use bash tool |
+| Publishing | Direct `bun publish` — GitHub Actions only |
+| Versioning | Local version bump — CI manages |
| Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` |
| Error Handling | Empty catch blocks |
| Testing | Deleting failing tests, writing implementation before test |
-| Agent Calls | Sequential - use `task` parallel |
-| Hook Logic | Heavy PreToolUse - slows every call |
+| Agent Calls | Sequential — use `task` parallel |
+| Hook Logic | Heavy PreToolUse — slows every call |
| Commits | Giant (3+ files), separate test from impl |
| Temperature | >0.3 for code agents |
-| Trust | Agent self-reports - ALWAYS verify |
+| Trust | Agent self-reports — ALWAYS verify |
| Git | `git add -i`, `git rebase -i` (no interactive input) |
| Git | Skip hooks (--no-verify), force push without request |
-| Bash | `sleep N` - use conditional waits |
-| Bash | `cd dir && cmd` - use workdir parameter |
-| Files | Catch-all utils.ts/helpers.ts - name by purpose |
+| Bash | `sleep N` — use conditional waits |
+| Bash | `cd dir && cmd` — use workdir parameter |
+| Files | Catch-all utils.ts/helpers.ts — name by purpose |
## AGENT MODELS
@@ -230,7 +230,7 @@ OhMyOpenCodePlugin(ctx)
## OPENCODE PLUGIN API
-Plugin SDK from `@opencode-ai/plugin` (v1.1.19). Plugin = `async (PluginInput) => Hooks`.
+Plugin SDK from `@opencode-ai/plugin`. Plugin = `async (PluginInput) => Hooks`.
| Hook | Purpose |
|------|---------|
@@ -283,7 +283,7 @@ bun run build:schema # Regenerate JSON schema
| File | Lines | Description |
|------|-------|-------------|
-| `src/features/background-agent/manager.ts` | 1646 | Task lifecycle, concurrency |
+| `src/features/background-agent/manager.ts` | 1701 | Task lifecycle, concurrency |
| `src/hooks/anthropic-context-window-limit-recovery/` | 2232 | Multi-strategy context recovery |
| `src/hooks/claude-code-hooks/` | 2110 | Claude Code settings.json compat |
| `src/hooks/todo-continuation-enforcer/` | 2061 | Core boulder mechanism |
@@ -293,7 +293,7 @@ bun run build:schema # Regenerate JSON schema
| `src/hooks/rules-injector/` | 1604 | Conditional rules injection |
| `src/hooks/think-mode/` | 1365 | Model/variant switching |
| `src/hooks/session-recovery/` | 1279 | Auto error recovery |
-| `src/features/builtin-skills/skills/git-master.ts` | 1111 | Git master skill |
+| `src/features/builtin-skills/skills/git-master.ts` | 1112 | Git master skill |
| `src/tools/delegate-task/constants.ts` | 569 | Category routing configs |
## MCP ARCHITECTURE
@@ -313,7 +313,7 @@ Three-tier system:
## NOTES
- **OpenCode**: Requires >= 1.0.150
-- **1069 TypeScript files**, 176 test files, 117k+ lines
+- **1130 TypeScript files**, 176 test files, 127k+ lines
- **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **No linter/formatter**: No ESLint, Prettier, or Biome configured
diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json
index c443ff7e..32753645 100644
--- a/assets/oh-my-opencode.schema.json
+++ b/assets/oh-my-opencode.schema.json
@@ -162,6 +162,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -207,6 +210,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -294,6 +300,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -335,6 +344,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -380,6 +392,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -467,6 +482,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -508,6 +526,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -553,6 +574,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -640,6 +664,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -681,6 +708,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -726,6 +756,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -813,6 +846,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -854,6 +890,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -899,6 +938,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -986,6 +1028,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -1027,6 +1072,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -1072,6 +1120,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -1159,6 +1210,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -1200,6 +1254,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -1245,6 +1302,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -1332,6 +1392,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -1373,6 +1436,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -1418,6 +1484,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -1505,6 +1574,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -1546,6 +1618,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -1591,6 +1666,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -1678,6 +1756,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -1719,6 +1800,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -1764,6 +1848,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -1851,6 +1938,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -1892,6 +1982,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -1937,6 +2030,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -2024,6 +2120,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -2065,6 +2164,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -2110,6 +2212,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -2197,6 +2302,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -2238,6 +2346,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -2283,6 +2394,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -2370,6 +2484,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -2411,6 +2528,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -2456,6 +2576,9 @@
},
{
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "string",
"enum": [
@@ -2543,6 +2666,9 @@
},
"providerOptions": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
}
},
@@ -2553,6 +2679,9 @@
},
"categories": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "object",
"properties": {
@@ -2616,6 +2745,9 @@
},
"tools": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -2656,6 +2788,9 @@
},
"plugins_override": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "boolean"
}
@@ -2926,6 +3061,9 @@
},
"metadata": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {}
},
"allowed-tools": {
@@ -2977,6 +3115,9 @@
},
"providerConcurrency": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "number",
"minimum": 0
@@ -2984,6 +3125,9 @@
},
"modelConcurrency": {
"type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
"additionalProperties": {
"type": "number",
"minimum": 0
diff --git a/bun.lock b/bun.lock
index 36c9e59d..dbac7140 100644
--- a/bun.lock
+++ b/bun.lock
@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
- "oh-my-opencode-darwin-arm64": "3.5.3",
- "oh-my-opencode-darwin-x64": "3.5.3",
- "oh-my-opencode-linux-arm64": "3.5.3",
- "oh-my-opencode-linux-arm64-musl": "3.5.3",
- "oh-my-opencode-linux-x64": "3.5.3",
- "oh-my-opencode-linux-x64-musl": "3.5.3",
- "oh-my-opencode-windows-x64": "3.5.3",
+ "oh-my-opencode-darwin-arm64": "3.5.5",
+ "oh-my-opencode-darwin-x64": "3.5.5",
+ "oh-my-opencode-linux-arm64": "3.5.5",
+ "oh-my-opencode-linux-arm64-musl": "3.5.5",
+ "oh-my-opencode-linux-x64": "3.5.5",
+ "oh-my-opencode-linux-x64-musl": "3.5.5",
+ "oh-my-opencode-windows-x64": "3.5.5",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
- "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Dq0+PC2dyAqG7c3DUnQmdOkKbKmOsRHwoqgLCQNKN1lTRllF8zbWqp5B+LGKxSPxPqJIPS3mKt+wIR2KvkYJVw=="],
+ "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="],
- "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ke45Bv/ygZm3YUSUumIyk647KZ2PFzw30tH597cOpG8MDPGbNVBCM6EKFezcukUPT+gPFVpE1IiGzEkn4JmgZA=="],
+ "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="],
- "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aP5S3DngUhFkNeqYM33Ge6zccCWLzB/O3FLXLFXy/Iws03N8xugw72pnMK6lUbIia9QQBKK7IZBoYm9C79pZ3g=="],
+ "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zs/ETIxwcWBvw+jdlo8t+3+92oMMaXkFg1ZCuZrBRZOmtPFefdsH5/QEIe2TlNSjfoTwlA7cbpOD6oXgxRVrtg=="],
- "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UiD/hVKYZQyX4D5N5SnZT4M5Z/B2SDtJWBW4MibpYSAcPKNCEBKi/5E4hOPxAtTfFGR8tIXFmYZdQJDkVfvluw=="],
+ "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="],
- "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-L9kqwzElGkaQ8pgtv1ZjcHARw9LPaU4UEVjzauByTMi+/5Js/PTsNXBggxSRzZfQ8/MNBPSCiA4K10Kc0YjjvA=="],
+ "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6ysF5Pr2C1dyC5Dftzp05RJODgL+EYCWcOV59/UCV152cINlOhg80804o+6XTKV/taOAaboYaQwsBKiCs/BNQ=="],
- "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z0fVVih/b2dbNeb9DK9oca5dNYCZyPySBRtxRhDXod5d7fJNgIPrvUoEd3SNfkRGORyFB3hGBZ6nqQ6N8+8DEA=="],
+ "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="],
- "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ocWPjRs2sJgN02PJnEIYtqdMVDex1YhEj1FzAU5XIicfzQbgxLh9nz1yhHZzfqGJq69QStU6ofpc5kQpfX1LMg=="],
+ "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
diff --git a/package.json b/package.json
index fc5c5a44..2276ecbb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
- "oh-my-opencode-darwin-arm64": "3.5.5",
- "oh-my-opencode-darwin-x64": "3.5.5",
- "oh-my-opencode-linux-arm64": "3.5.5",
- "oh-my-opencode-linux-arm64-musl": "3.5.5",
- "oh-my-opencode-linux-x64": "3.5.5",
- "oh-my-opencode-linux-x64-musl": "3.5.5",
- "oh-my-opencode-windows-x64": "3.5.5"
+ "oh-my-opencode-darwin-arm64": "3.5.6",
+ "oh-my-opencode-darwin-x64": "3.5.6",
+ "oh-my-opencode-linux-arm64": "3.5.6",
+ "oh-my-opencode-linux-arm64-musl": "3.5.6",
+ "oh-my-opencode-linux-x64": "3.5.6",
+ "oh-my-opencode-linux-x64-musl": "3.5.6",
+ "oh-my-opencode-windows-x64": "3.5.6"
},
"trustedDependencies": [
"@ast-grep/cli",
diff --git a/packages/darwin-arm64/package.json b/packages/darwin-arm64/package.json
index 51ae742c..23dfd4ea 100644
--- a/packages/darwin-arm64/package.json
+++ b/packages/darwin-arm64/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {
diff --git a/packages/darwin-x64/package.json b/packages/darwin-x64/package.json
index a1f68494..003c210b 100644
--- a/packages/darwin-x64/package.json
+++ b/packages/darwin-x64/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {
diff --git a/packages/linux-arm64-musl/package.json b/packages/linux-arm64-musl/package.json
index 85605b14..98a2bcac 100644
--- a/packages/linux-arm64-musl/package.json
+++ b/packages/linux-arm64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {
diff --git a/packages/linux-arm64/package.json b/packages/linux-arm64/package.json
index f2996091..9a4d8aec 100644
--- a/packages/linux-arm64/package.json
+++ b/packages/linux-arm64/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {
diff --git a/packages/linux-x64-musl/package.json b/packages/linux-x64-musl/package.json
index 20d5caf9..47fa3aae 100644
--- a/packages/linux-x64-musl/package.json
+++ b/packages/linux-x64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {
diff --git a/packages/linux-x64/package.json b/packages/linux-x64/package.json
index 35185a2a..da80de17 100644
--- a/packages/linux-x64/package.json
+++ b/packages/linux-x64/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {
diff --git a/packages/windows-x64/package.json b/packages/windows-x64/package.json
index 881e8407..d8003b61 100644
--- a/packages/windows-x64/package.json
+++ b/packages/windows-x64/package.json
@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
- "version": "3.5.5",
+ "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {
diff --git a/signatures/cla.json b/signatures/cla.json
index 6b2d20b8..78d45fc4 100644
--- a/signatures/cla.json
+++ b/signatures/cla.json
@@ -1511,6 +1511,14 @@
"created_at": "2026-02-15T15:07:11Z",
"repoId": 1108837393,
"pullRequestNo": 1864
+ },
+ {
+ "name": "dankochetov",
+ "id": 33990502,
+ "comment_id": 3905398332,
+ "created_at": "2026-02-15T23:17:05Z",
+ "repoId": 1108837393,
+ "pullRequestNo": 1870
}
]
}
\ No newline at end of file
diff --git a/src/AGENTS.md b/src/AGENTS.md
index 0724e41e..5c98a404 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -5,25 +5,26 @@
Main plugin entry point and orchestration layer. Plugin initialization, hook registration, tool composition, and lifecycle management.
## STRUCTURE
+
```
src/
-├── index.ts # Main plugin entry (88 lines) — OhMyOpenCodePlugin factory
+├── index.ts # Main plugin entry (106 lines) — OhMyOpenCodePlugin factory
├── create-hooks.ts # Hook coordination: core, continuation, skill (62 lines)
├── create-managers.ts # Manager initialization: Tmux, Background, SkillMcp, Config (80 lines)
├── create-tools.ts # Tool registry + skill context composition (54 lines)
├── plugin-interface.ts # Plugin interface assembly — 7 OpenCode hooks (66 lines)
-├── plugin-config.ts # Config loading orchestration (user + project merge)
-├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag)
-├── agents/ # 11 AI agents (32 files) - see agents/AGENTS.md
-├── cli/ # CLI installer, doctor (107+ files) - see cli/AGENTS.md
-├── config/ # Zod schema (21 component files) - see config/AGENTS.md
-├── features/ # Background agents, skills, commands (18 dirs) - see features/AGENTS.md
-├── hooks/ # 41 lifecycle hooks (36 dirs) - see hooks/AGENTS.md
-├── mcp/ # Built-in MCPs (6 files) - see mcp/AGENTS.md
+├── plugin-config.ts # Config loading orchestration (user + project merge, 180 lines)
+├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag, 12 lines)
+├── agents/ # 11 AI agents (32 files) — see agents/AGENTS.md
+├── cli/ # CLI installer, doctor (107+ files) — see cli/AGENTS.md
+├── config/ # Zod schema (21 component files) — see config/AGENTS.md
+├── features/ # Background agents, skills, commands (18 dirs) — see features/AGENTS.md
+├── hooks/ # 41 lifecycle hooks (36 dirs) — see hooks/AGENTS.md
+├── mcp/ # Built-in MCPs (6 files) — see mcp/AGENTS.md
├── plugin/ # Plugin interface composition (21 files)
-├── plugin-handlers/ # Config loading, plan inheritance (15 files) - see plugin-handlers/AGENTS.md
-├── shared/ # Cross-cutting utilities (84 files) - see shared/AGENTS.md
-└── tools/ # 25+ tools (14 dirs) - see tools/AGENTS.md
+├── plugin-handlers/ # Config loading, plan inheritance (15 files) — see plugin-handlers/AGENTS.md
+├── shared/ # Cross-cutting utilities (96 files) — see shared/AGENTS.md
+└── tools/ # 26 tools (14 dirs) — see tools/AGENTS.md
```
## PLUGIN INITIALIZATION (10 steps)
diff --git a/src/agents/AGENTS.md b/src/agents/AGENTS.md
index 2ae8e4dd..4946b892 100644
--- a/src/agents/AGENTS.md
+++ b/src/agents/AGENTS.md
@@ -7,36 +7,22 @@
## STRUCTURE
```
agents/
-├── sisyphus.ts # Main orchestrator (530 lines)
-├── hephaestus.ts # Autonomous deep worker (624 lines)
-├── oracle.ts # Strategic advisor (170 lines)
-├── librarian.ts # Multi-repo research (328 lines)
-├── explore.ts # Fast codebase grep (124 lines)
-├── multimodal-looker.ts # Media analyzer (58 lines)
+├── sisyphus.ts # Main orchestrator (559 lines)
+├── hephaestus.ts # Autonomous deep worker (651 lines)
+├── oracle.ts # Strategic advisor (171 lines)
+├── librarian.ts # Multi-repo research (329 lines)
+├── explore.ts # Fast codebase grep (125 lines)
+├── multimodal-looker.ts # Media analyzer (59 lines)
├── metis.ts # Pre-planning analysis (347 lines)
├── momus.ts # Plan validator (244 lines)
-├── atlas/ # Master orchestrator
-│ ├── agent.ts # Atlas factory
-│ ├── default.ts # Claude-optimized prompt
-│ ├── gpt.ts # GPT-optimized prompt
-│ └── utils.ts
-├── prometheus/ # Planning agent
-│ ├── index.ts
-│ ├── system-prompt.ts # 6-section prompt assembly
-│ ├── plan-template.ts # Work plan structure (423 lines)
-│ ├── interview-mode.ts # Interview flow (335 lines)
-│ ├── plan-generation.ts
-│ ├── high-accuracy-mode.ts
-│ ├── identity-constraints.ts # Identity rules (301 lines)
-│ └── behavioral-summary.ts
-├── sisyphus-junior/ # Delegated task executor
-│ ├── agent.ts
-│ ├── default.ts # Claude prompt
-│ └── gpt.ts # GPT prompt
-├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (431 lines)
-├── builtin-agents/ # Agent registry (8 files)
+├── atlas/ # Master orchestrator (agent.ts + default.ts + gpt.ts)
+├── prometheus/ # Planning agent (8 files, plan-template 423 lines)
+├── sisyphus-junior/ # Delegated task executor (agent.ts + default.ts + gpt.ts)
+├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (433 lines)
+├── builtin-agents/ # Agent registry + model resolution
+├── agent-builder.ts # Agent construction with category merging (51 lines)
├── utils.ts # Agent creation, model fallback resolution (571 lines)
-├── types.ts # AgentModelConfig, AgentPromptMetadata
+├── types.ts # AgentModelConfig, AgentPromptMetadata (106 lines)
└── index.ts # Exports
```
@@ -78,6 +64,12 @@ agents/
| Momus | 32k budget tokens | reasoningEffort: "medium" |
| Sisyphus-Junior | 32k budget tokens | reasoningEffort: "medium" |
+## KEY PROMPT PATTERNS
+
+- **Sisyphus/Hephaestus**: Dynamic prompts via `dynamic-agent-prompt-builder.ts` injecting available tools/skills/categories
+- **Atlas, Sisyphus-Junior**: Model-specific prompts (Claude vs GPT variants)
+- **Prometheus**: 6-section modular prompt (identity → interview → plan-generation → high-accuracy → template → behavioral)
+
## HOW TO ADD
1. Create `src/agents/my-agent.ts` exporting factory + metadata
@@ -85,13 +77,6 @@ agents/
3. Update `AgentNameSchema` in `src/config/schema/agent-names.ts`
4. Register in `src/plugin-handlers/agent-config-handler.ts`
-## KEY PATTERNS
-
-- **Factory**: `createXXXAgent(model): AgentConfig`
-- **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers
-- **Model-specific prompts**: Atlas, Sisyphus-Junior have GPT vs Claude variants
-- **Dynamic prompts**: Sisyphus, Hephaestus use `dynamic-agent-prompt-builder.ts` to inject available tools/skills/categories
-
## ANTI-PATTERNS
- **Trust agent self-reports**: NEVER — always verify outputs
diff --git a/src/agents/prometheus-prompt.test.ts b/src/agents/prometheus-prompt.test.ts
index 625b4250..266b8f80 100644
--- a/src/agents/prometheus-prompt.test.ts
+++ b/src/agents/prometheus-prompt.test.ts
@@ -66,7 +66,7 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => {
expect(lowerPrompt).toContain("preconditions")
expect(lowerPrompt).toContain("failure indicators")
expect(lowerPrompt).toContain("evidence")
- expect(lowerPrompt).toMatch(/negative scenario/)
+ expect(prompt).toMatch(/negative/i)
})
test("should require QA scenario adequacy in self-review checklist", () => {
diff --git a/src/agents/prometheus/identity-constraints.ts b/src/agents/prometheus/identity-constraints.ts
index c8db667c..af16243a 100644
--- a/src/agents/prometheus/identity-constraints.ts
+++ b/src/agents/prometheus/identity-constraints.ts
@@ -129,7 +129,21 @@ Your ONLY valid output locations are \`.sisyphus/plans/*.md\` and \`.sisyphus/dr
Example: \`.sisyphus/plans/auth-refactor.md\`
-### 5. SINGLE PLAN MANDATE (CRITICAL)
+### 5. MAXIMUM PARALLELISM PRINCIPLE (NON-NEGOTIABLE)
+
+Your plans MUST maximize parallel execution. This is a core planning quality metric.
+
+**Granularity Rule**: One task = one module/concern = 1-3 files.
+If a task touches 4+ files or 2+ unrelated concerns, SPLIT IT.
+
+**Parallelism Target**: Aim for 5-8 tasks per wave.
+If any wave has fewer than 3 tasks (except the final integration), you under-split.
+
+**Dependency Minimization**: Structure tasks so shared dependencies
+(types, interfaces, configs) are extracted as early Wave-1 tasks,
+unblocking maximum parallelism in subsequent waves.
+
+### 6. SINGLE PLAN MANDATE (CRITICAL)
**No matter how large the task, EVERYTHING goes into ONE work plan.**
**NEVER:**
@@ -152,7 +166,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\`
**The plan can have 50+ TODOs. That's OK. ONE PLAN.**
-### 5.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
+### 6.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
**The Write tool OVERWRITES files. It does NOT append.**
@@ -188,7 +202,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\`
- [ ] File already exists with my content? → Use Edit to append, NOT Write
-### 6. DRAFT AS WORKING MEMORY (MANDATORY)
+### 7. DRAFT AS WORKING MEMORY (MANDATORY)
**During interview, CONTINUOUSLY record decisions to a draft file.**
**Draft Location**: \`.sisyphus/drafts/{name}.md\`
diff --git a/src/agents/prometheus/plan-template.ts b/src/agents/prometheus/plan-template.ts
index 75c9ced9..42c16fca 100644
--- a/src/agents/prometheus/plan-template.ts
+++ b/src/agents/prometheus/plan-template.ts
@@ -70,108 +70,25 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
## Verification Strategy (MANDATORY)
-> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION**
->
-> ALL tasks in this plan MUST be verifiable WITHOUT any human action.
-> This is NOT conditional — it applies to EVERY task, regardless of test strategy.
->
-> **FORBIDDEN** — acceptance criteria that require:
-> - "User manually tests..." / "사용자가 직접 테스트..."
-> - "User visually confirms..." / "사용자가 눈으로 확인..."
-> - "User interacts with..." / "사용자가 직접 조작..."
-> - "Ask user to verify..." / "사용자에게 확인 요청..."
-> - ANY step where a human must perform an action
->
-> **ALL verification is executed by the agent** using tools (Playwright, interactive_bash, curl, etc.). No exceptions.
+> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
+> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
### Test Decision
- **Infrastructure exists**: [YES/NO]
- **Automated tests**: [TDD / Tests-after / None]
- **Framework**: [bun test / vitest / jest / pytest / none]
+- **If TDD**: Each task follows RED (failing test) → GREEN (minimal impl) → REFACTOR
-### If TDD Enabled
+### QA Policy
+Every task MUST include agent-executed QA scenarios (see TODO template below).
+Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`.
-Each TODO follows RED-GREEN-REFACTOR:
-
-**Task Structure:**
-1. **RED**: Write failing test first
- - Test file: \`[path].test.ts\`
- - Test command: \`bun test [file]\`
- - Expected: FAIL (test exists, implementation doesn't)
-2. **GREEN**: Implement minimum code to pass
- - Command: \`bun test [file]\`
- - Expected: PASS
-3. **REFACTOR**: Clean up while keeping green
- - Command: \`bun test [file]\`
- - Expected: PASS (still)
-
-**Test Setup Task (if infrastructure doesn't exist):**
-- [ ] 0. Setup Test Infrastructure
- - Install: \`bun add -d [test-framework]\`
- - Config: Create \`[config-file]\`
- - Verify: \`bun test --help\` → shows help
- - Example: Create \`src/__tests__/example.test.ts\`
- - Verify: \`bun test\` → 1 test passes
-
-### Agent-Executed QA Scenarios (MANDATORY — ALL tasks)
-
-> Whether TDD is enabled or not, EVERY task MUST include Agent-Executed QA Scenarios.
-> - **With TDD**: QA scenarios complement unit tests at integration/E2E level
-> - **Without TDD**: QA scenarios are the PRIMARY verification method
->
-> These describe how the executing agent DIRECTLY verifies the deliverable
-> by running it — opening browsers, executing commands, sending API requests.
-> The agent performs what a human tester would do, but automated via tools.
-
-**Verification Tool by Deliverable Type:**
-
-| Type | Tool | How Agent Verifies |
-|------|------|-------------------|
-| **Frontend/UI** | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
-| **TUI/CLI** | interactive_bash (tmux) | Run command, send keystrokes, validate output |
-| **API/Backend** | Bash (curl/httpie) | Send requests, parse responses, assert fields |
-| **Library/Module** | Bash (bun/node REPL) | Import, call functions, compare output |
-| **Config/Infra** | Bash (shell commands) | Apply config, run state checks, validate |
-
-**Each Scenario MUST Follow This Format:**
-
-\`\`\`
-Scenario: [Descriptive name — what user action/flow is being verified]
- Tool: [Playwright / interactive_bash / Bash]
- Preconditions: [What must be true before this scenario runs]
- Steps:
- 1. [Exact action with specific selector/command/endpoint]
- 2. [Next action with expected intermediate state]
- 3. [Assertion with exact expected value]
- Expected Result: [Concrete, observable outcome]
- Failure Indicators: [What would indicate failure]
- Evidence: [Screenshot path / output capture / response body path]
-\`\`\`
-
-**Scenario Detail Requirements:**
-- **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
-- **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
-- **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
-- **Timing**: Include wait conditions where relevant (\`Wait for .dashboard (timeout: 10s)\`)
-- **Negative Scenarios**: At least ONE failure/error scenario per feature
-- **Evidence Paths**: Specific file paths (\`.sisyphus/evidence/task-N-scenario-name.png\`)
-
-**Anti-patterns (NEVER write scenarios like this):**
-- ❌ "Verify the login page works correctly"
-- ❌ "Check that the API returns the right data"
-- ❌ "Test the form validation"
-- ❌ "User opens browser and confirms..."
-
-**Write scenarios like this instead:**
-- ✅ \`Navigate to /login → Fill input[name="email"] with "test@example.com" → Fill input[name="password"] with "Pass123!" → Click button[type="submit"] → Wait for /dashboard → Assert h1 contains "Welcome"\`
-- ✅ \`POST /api/users {"name":"Test","email":"new@test.com"} → Assert status 201 → Assert response.id is UUID → GET /api/users/{id} → Assert name equals "Test"\`
-- ✅ \`Run ./cli --config test.yaml → Wait for "Loaded" in stdout → Send "q" → Assert exit code 0 → Assert stdout contains "Goodbye"\`
-
-**Evidence Requirements:**
-- Screenshots: \`.sisyphus/evidence/\` for all UI verifications
-- Terminal output: Captured for CLI/TUI verifications
-- Response bodies: Saved for API verifications
-- All evidence referenced by specific file path in acceptance criteria
+| Deliverable Type | Verification Tool | Method |
+|------------------|-------------------|--------|
+| Frontend/UI | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
+| TUI/CLI | interactive_bash (tmux) | Run command, send keystrokes, validate output |
+| API/Backend | Bash (curl) | Send requests, assert status + response fields |
+| Library/Module | Bash (bun/node REPL) | Import, call functions, compare output |
---
@@ -181,49 +98,82 @@ Scenario: [Descriptive name — what user action/flow is being verified]
> Maximize throughput by grouping independent tasks into parallel waves.
> Each wave completes before the next begins.
+> Target: 5-8 tasks per wave. Fewer than 3 per wave (except final) = under-splitting.
\`\`\`
-Wave 1 (Start Immediately):
-├── Task 1: [no dependencies]
-└── Task 5: [no dependencies]
+Wave 1 (Start Immediately — foundation + scaffolding):
+├── Task 1: Project scaffolding + config [quick]
+├── Task 2: Design system tokens [quick]
+├── Task 3: Type definitions [quick]
+├── Task 4: Schema definitions [quick]
+├── Task 5: Storage interface + in-memory impl [quick]
+├── Task 6: Auth middleware [quick]
+└── Task 7: Client module [quick]
-Wave 2 (After Wave 1):
-├── Task 2: [depends: 1]
-├── Task 3: [depends: 1]
-└── Task 6: [depends: 5]
+Wave 2 (After Wave 1 — core modules, MAX PARALLEL):
+├── Task 8: Core business logic (depends: 3, 5, 7) [deep]
+├── Task 9: API endpoints (depends: 4, 5) [unspecified-high]
+├── Task 10: Secondary storage impl (depends: 5) [unspecified-high]
+├── Task 11: Retry/fallback logic (depends: 8) [deep]
+├── Task 12: UI layout + navigation (depends: 2) [visual-engineering]
+├── Task 13: API client + hooks (depends: 4) [quick]
+└── Task 14: Telemetry middleware (depends: 5, 10) [unspecified-high]
-Wave 3 (After Wave 2):
-└── Task 4: [depends: 2, 3]
+Wave 3 (After Wave 2 — integration + UI):
+├── Task 15: Main route combining modules (depends: 6, 11, 14) [deep]
+├── Task 16: UI data visualization (depends: 12, 13) [visual-engineering]
+├── Task 17: Deployment config A (depends: 15) [quick]
+├── Task 18: Deployment config B (depends: 15) [quick]
+├── Task 19: Deployment config C (depends: 15) [quick]
+└── Task 20: UI request log + build (depends: 16) [visual-engineering]
-Critical Path: Task 1 → Task 2 → Task 4
-Parallel Speedup: ~40% faster than sequential
+Wave 4 (After Wave 3 — verification):
+├── Task 21: Integration tests (depends: 15) [deep]
+├── Task 22: UI QA - Playwright (depends: 20) [unspecified-high]
+├── Task 23: E2E QA (depends: 21) [deep]
+└── Task 24: Git cleanup + tagging (depends: 21) [git]
+
+Wave FINAL (After ALL tasks — independent review, 4 parallel):
+├── Task F1: Plan compliance audit (oracle)
+├── Task F2: Code quality review (unspecified-high)
+├── Task F3: Real manual QA (unspecified-high)
+└── Task F4: Scope fidelity check (deep)
+
+Critical Path: Task 1 → Task 5 → Task 8 → Task 11 → Task 15 → Task 21 → F1-F4
+Parallel Speedup: ~70% faster than sequential
+Max Concurrent: 7 (Waves 1 & 2)
\`\`\`
-### Dependency Matrix
+### Dependency Matrix (abbreviated — show ALL tasks in your generated plan)
-| Task | Depends On | Blocks | Can Parallelize With |
-|------|------------|--------|---------------------|
-| 1 | None | 2, 3 | 5 |
-| 2 | 1 | 4 | 3, 6 |
-| 3 | 1 | 4 | 2, 6 |
-| 4 | 2, 3 | None | None (final) |
-| 5 | None | 6 | 1 |
-| 6 | 5 | None | 2, 3 |
+| Task | Depends On | Blocks | Wave |
+|------|------------|--------|------|
+| 1-7 | — | 8-14 | 1 |
+| 8 | 3, 5, 7 | 11, 15 | 2 |
+| 11 | 8 | 15 | 2 |
+| 14 | 5, 10 | 15 | 2 |
+| 15 | 6, 11, 14 | 17-19, 21 | 3 |
+| 21 | 15 | 23, 24 | 4 |
+
+> This is abbreviated for reference. YOUR generated plan must include the FULL matrix for ALL tasks.
### Agent Dispatch Summary
-| Wave | Tasks | Recommended Agents |
-|------|-------|-------------------|
-| 1 | 1, 5 | task(category="...", load_skills=[...], run_in_background=false) |
-| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
-| 3 | 4 | final integration task |
+| Wave | # Parallel | Tasks → Agent Category |
+|------|------------|----------------------|
+| 1 | **7** | T1-T4 → \`quick\`, T5 → \`quick\`, T6 → \`quick\`, T7 → \`quick\` |
+| 2 | **7** | T8 → \`deep\`, T9 → \`unspecified-high\`, T10 → \`unspecified-high\`, T11 → \`deep\`, T12 → \`visual-engineering\`, T13 → \`quick\`, T14 → \`unspecified-high\` |
+| 3 | **6** | T15 → \`deep\`, T16 → \`visual-engineering\`, T17-T19 → \`quick\`, T20 → \`visual-engineering\` |
+| 4 | **4** | T21 → \`deep\`, T22 → \`unspecified-high\`, T23 → \`deep\`, T24 → \`git\` |
+| FINAL | **4** | F1 → \`oracle\`, F2 → \`unspecified-high\`, F3 → \`unspecified-high\`, F4 → \`deep\` |
---
## TODOs
> Implementation + Test = ONE Task. Never separate.
-> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
+> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
+> **A task WITHOUT QA Scenarios is INCOMPLETE. No exceptions.**
- [ ] 1. [Task Title]
@@ -257,22 +207,15 @@ Parallel Speedup: ~40% faster than sequential
**Pattern References** (existing code to follow):
- \`src/services/auth.ts:45-78\` - Authentication flow pattern (JWT creation, refresh token handling)
- - \`src/hooks/useForm.ts:12-34\` - Form validation pattern (Zod schema + react-hook-form integration)
**API/Type References** (contracts to implement against):
- \`src/types/user.ts:UserDTO\` - Response shape for user endpoints
- - \`src/api/schema.ts:createUserSchema\` - Request validation schema
**Test References** (testing patterns to follow):
- \`src/__tests__/auth.test.ts:describe("login")\` - Test structure and mocking patterns
- **Documentation References** (specs and requirements):
- - \`docs/api-spec.md#authentication\` - API contract details
- - \`ARCHITECTURE.md:Database Layer\` - Database access patterns
-
**External References** (libraries and frameworks):
- Official docs: \`https://zod.dev/?id=basic-usage\` - Zod validation syntax
- - Example repo: \`github.com/example/project/src/auth\` - Reference implementation
**WHY Each Reference Matters** (explain the relevance):
- Don't just list files - explain what pattern/information the executor should extract
@@ -283,113 +226,60 @@ Parallel Speedup: ~40% faster than sequential
> **AGENT-EXECUTABLE VERIFICATION ONLY** — No human action permitted.
> Every criterion MUST be verifiable by running a command or using a tool.
- > REPLACE all placeholders with actual values from task context.
**If TDD (tests enabled):**
- [ ] Test file created: src/auth/login.test.ts
- - [ ] Test covers: successful login returns JWT token
- [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures)
- **Agent-Executed QA Scenarios (MANDATORY — per-scenario, ultra-detailed):**
+ **QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
- > Write MULTIPLE named scenarios per task: happy path AND failure cases.
- > Each scenario = exact tool + steps with real selectors/data + evidence path.
-
- **Example — Frontend/UI (Playwright):**
+ > **This is NOT optional. A task without QA scenarios WILL BE REJECTED.**
+ >
+ > Write scenario tests that verify the ACTUAL BEHAVIOR of what you built.
+ > Minimum: 1 happy path + 1 failure/edge case per task.
+ > Each scenario = exact tool + exact steps + exact assertions + evidence path.
+ >
+ > **The executing agent MUST run these scenarios after implementation.**
+ > **The orchestrator WILL verify evidence files exist before marking task complete.**
\\\`\\\`\\\`
- Scenario: Successful login redirects to dashboard
- Tool: Playwright (playwright skill)
- Preconditions: Dev server running on localhost:3000, test user exists
+ Scenario: [Happy path — what SHOULD work]
+ Tool: [Playwright / interactive_bash / Bash (curl)]
+ Preconditions: [Exact setup state]
Steps:
- 1. Navigate to: http://localhost:3000/login
- 2. Wait for: input[name="email"] visible (timeout: 5s)
- 3. Fill: input[name="email"] → "test@example.com"
- 4. Fill: input[name="password"] → "ValidPass123!"
- 5. Click: button[type="submit"]
- 6. Wait for: navigation to /dashboard (timeout: 10s)
- 7. Assert: h1 text contains "Welcome back"
- 8. Assert: cookie "session_token" exists
- 9. Screenshot: .sisyphus/evidence/task-1-login-success.png
- Expected Result: Dashboard loads with welcome message
- Evidence: .sisyphus/evidence/task-1-login-success.png
+ 1. [Exact action — specific command/selector/endpoint, no vagueness]
+ 2. [Next action — with expected intermediate state]
+ 3. [Assertion — exact expected value, not "verify it works"]
+ Expected Result: [Concrete, observable, binary pass/fail]
+ Failure Indicators: [What specifically would mean this failed]
+ Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}.{ext}
- Scenario: Login fails with invalid credentials
- Tool: Playwright (playwright skill)
- Preconditions: Dev server running, no valid user with these credentials
+ Scenario: [Failure/edge case — what SHOULD fail gracefully]
+ Tool: [same format]
+ Preconditions: [Invalid input / missing dependency / error state]
Steps:
- 1. Navigate to: http://localhost:3000/login
- 2. Fill: input[name="email"] → "wrong@example.com"
- 3. Fill: input[name="password"] → "WrongPass"
- 4. Click: button[type="submit"]
- 5. Wait for: .error-message visible (timeout: 5s)
- 6. Assert: .error-message text contains "Invalid credentials"
- 7. Assert: URL is still /login (no redirect)
- 8. Screenshot: .sisyphus/evidence/task-1-login-failure.png
- Expected Result: Error message shown, stays on login page
- Evidence: .sisyphus/evidence/task-1-login-failure.png
+ 1. [Trigger the error condition]
+ 2. [Assert error is handled correctly]
+ Expected Result: [Graceful failure with correct error message/code]
+ Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}-error.{ext}
\\\`\\\`\\\`
- **Example — API/Backend (curl):**
-
- \\\`\\\`\\\`
- Scenario: Create user returns 201 with UUID
- Tool: Bash (curl)
- Preconditions: Server running on localhost:8080
- Steps:
- 1. curl -s -w "\\n%{http_code}" -X POST http://localhost:8080/api/users \\
- -H "Content-Type: application/json" \\
- -d '{"email":"new@test.com","name":"Test User"}'
- 2. Assert: HTTP status is 201
- 3. Assert: response.id matches UUID format
- 4. GET /api/users/{returned-id} → Assert name equals "Test User"
- Expected Result: User created and retrievable
- Evidence: Response bodies captured
-
- Scenario: Duplicate email returns 409
- Tool: Bash (curl)
- Preconditions: User with email "new@test.com" already exists
- Steps:
- 1. Repeat POST with same email
- 2. Assert: HTTP status is 409
- 3. Assert: response.error contains "already exists"
- Expected Result: Conflict error returned
- Evidence: Response body captured
- \\\`\\\`\\\`
-
- **Example — TUI/CLI (interactive_bash):**
-
- \\\`\\\`\\\`
- Scenario: CLI loads config and displays menu
- Tool: interactive_bash (tmux)
- Preconditions: Binary built, test config at ./test.yaml
- Steps:
- 1. tmux new-session: ./my-cli --config test.yaml
- 2. Wait for: "Configuration loaded" in output (timeout: 5s)
- 3. Assert: Menu items visible ("1. Create", "2. List", "3. Exit")
- 4. Send keys: "3" then Enter
- 5. Assert: "Goodbye" in output
- 6. Assert: Process exited with code 0
- Expected Result: CLI starts, shows menu, exits cleanly
- Evidence: Terminal output captured
-
- Scenario: CLI handles missing config gracefully
- Tool: interactive_bash (tmux)
- Preconditions: No config file at ./nonexistent.yaml
- Steps:
- 1. tmux new-session: ./my-cli --config nonexistent.yaml
- 2. Wait for: output (timeout: 3s)
- 3. Assert: stderr contains "Config file not found"
- 4. Assert: Process exited with code 1
- Expected Result: Meaningful error, non-zero exit
- Evidence: Error output captured
- \\\`\\\`\\\`
+ > **Specificity requirements — every scenario MUST use:**
+ > - **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
+ > - **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
+ > - **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
+ > - **Timing**: Wait conditions where relevant (\`timeout: 10s\`)
+ > - **Negative**: At least ONE failure/error scenario per task
+ >
+ > **Anti-patterns (your scenario is INVALID if it looks like this):**
+ > - ❌ "Verify it works correctly" — HOW? What does "correctly" mean?
+ > - ❌ "Check the API returns data" — WHAT data? What fields? What values?
+ > - ❌ "Test the component renders" — WHERE? What selector? What content?
+ > - ❌ Any scenario without an evidence path
**Evidence to Capture:**
- - [ ] Screenshots in .sisyphus/evidence/ for UI scenarios
- - [ ] Terminal output for CLI/TUI scenarios
- - [ ] Response bodies for API scenarios
- [ ] Each evidence file named: task-{N}-{scenario-slug}.{ext}
+ - [ ] Screenshots for UI, terminal output for CLI, response bodies for API
**Commit**: YES | NO (groups with N)
- Message: \`type(scope): desc\`
@@ -398,6 +288,28 @@ Parallel Speedup: ~40% faster than sequential
---
+## Final Verification Wave (MANDATORY — after ALL implementation tasks)
+
+> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
+
+- [ ] F1. **Plan Compliance Audit** — \`oracle\`
+ Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
+ Output: \`Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT\`
+
+- [ ] F2. **Code Quality Review** — \`unspecified-high\`
+ Run \`tsc --noEmit\` + linter + \`bun test\`. Review all changed files for: \`as any\`/\`@ts-ignore\`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp).
+ Output: \`Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT\`
+
+- [ ] F3. **Real Manual QA** — \`unspecified-high\` (+ \`playwright\` skill if UI)
+ Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to \`.sisyphus/evidence/final-qa/\`.
+ Output: \`Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT\`
+
+- [ ] F4. **Scope Fidelity Check** — \`deep\`
+ For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes.
+ Output: \`Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT\`
+
+---
+
## Commit Strategy
| After Task | Message | Files | Verification |
diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md
index 46f177a9..5ac159ab 100644
--- a/src/cli/AGENTS.md
+++ b/src/cli/AGENTS.md
@@ -2,9 +2,7 @@
## OVERVIEW
-CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI.
-
-**Commands**: install, run, doctor, get-local-version, mcp-oauth
+CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI. 5 commands: install, run, doctor, get-local-version, mcp-oauth.
## STRUCTURE
```
@@ -14,20 +12,22 @@ cli/
├── install.ts # TTY routing (TUI or CLI installer)
├── cli-installer.ts # Non-interactive installer (164 lines)
├── tui-installer.ts # Interactive TUI with @clack/prompts (140 lines)
-├── config-manager/ # 17 config utilities
+├── config-manager/ # 20 config utilities
│ ├── add-plugin-to-opencode-config.ts # Plugin registration
-│ ├── add-provider-config.ts # Provider setup
-│ ├── detect-current-config.ts # Project vs user config
+│ ├── add-provider-config.ts # Provider setup (Google/Antigravity)
+│ ├── detect-current-config.ts # Installed providers detection
│ ├── write-omo-config.ts # JSONC writing
-│ └── ...
-├── doctor/ # 14 health checks
-│ ├── runner.ts # Check orchestration
-│ ├── formatter.ts # Colored output
-│ └── checks/ # 29 files: auth, config, dependencies, gh, lsp, mcp, opencode, plugin, version, model-resolution (6 sub-checks)
+│ ├── generate-omo-config.ts # Config generation
+│ ├── jsonc-provider-editor.ts # JSONC editing
+│ └── ... # 14 more utilities
+├── doctor/ # 4 check categories, 21 check files
+│ ├── runner.ts # Parallel check execution + result aggregation
+│ ├── formatter.ts # Colored output (default/status/verbose/JSON)
+│ └── checks/ # system (4), config (1), tools (4), models (6 sub-checks)
├── run/ # Session launcher (24 files)
│ ├── runner.ts # Run orchestration (126 lines)
-│ ├── agent-resolver.ts # Agent selection: flag → env → config → fallback
-│ ├── session-resolver.ts # Session creation or resume
+│ ├── agent-resolver.ts # Agent: flag → env → config → Sisyphus
+│ ├── session-resolver.ts # Session create or resume with retries
│ ├── event-handlers.ts # Event processing (125 lines)
│ ├── completion.ts # Completion detection
│ └── poll-for-completion.ts # Polling with timeout
@@ -43,20 +43,17 @@ cli/
|---------|---------|-----------|
| `install` | Interactive setup | Provider selection → config generation → plugin registration |
| `run` | Session launcher | Agent: flag → env → config → Sisyphus. Enforces todo completion. |
-| `doctor` | 14 health checks | installation, config, auth, deps, tools, updates |
+| `doctor` | 4-category health checks | system, config, tools, models (6 sub-checks) |
| `get-local-version` | Version check | Detects installed, compares with npm latest |
| `mcp-oauth` | OAuth tokens | login (PKCE flow), logout, status |
-## DOCTOR CHECK CATEGORIES
+## RUN SESSION LIFECYCLE
-| Category | Checks |
-|----------|--------|
-| installation | opencode, plugin |
-| configuration | config validity, Zod, model-resolution (6 sub-checks) |
-| authentication | anthropic, openai, google |
-| dependencies | ast-grep, comment-checker, gh-cli |
-| tools | LSP, MCP, MCP-OAuth |
-| updates | version comparison |
+1. Load config, resolve agent (CLI > env > config > Sisyphus)
+2. Create server connection (port/attach), setup cleanup/signal handlers
+3. Resolve session (create new or resume with retries)
+4. Send prompt, start event processing, poll for completion
+5. Execute on-complete hook, output JSON if requested, cleanup
## HOW TO ADD CHECK
diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts
new file mode 100644
index 00000000..2320d951
--- /dev/null
+++ b/src/cli/cli-installer.test.ts
@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
+import * as configManager from "./config-manager"
+import { runCliInstaller } from "./cli-installer"
+import type { InstallArgs } from "./types"
+
+describe("runCliInstaller", () => {
+ const mockConsoleLog = mock(() => {})
+ const mockConsoleError = mock(() => {})
+ const originalConsoleLog = console.log
+ const originalConsoleError = console.error
+
+ beforeEach(() => {
+ console.log = mockConsoleLog
+ console.error = mockConsoleError
+ mockConsoleLog.mockClear()
+ mockConsoleError.mockClear()
+ })
+
+ afterEach(() => {
+ console.log = originalConsoleLog
+ console.error = originalConsoleError
+ })
+
+ it("runs auth and provider setup steps when openai or copilot are enabled without gemini", async () => {
+ //#given
+ const addAuthPluginsSpy = spyOn(configManager, "addAuthPlugins").mockResolvedValue({
+ success: true,
+ configPath: "/tmp/opencode.jsonc",
+ })
+ const addProviderConfigSpy = spyOn(configManager, "addProviderConfig").mockReturnValue({
+ success: true,
+ configPath: "/tmp/opencode.jsonc",
+ })
+ const restoreSpies = [
+ addAuthPluginsSpy,
+ addProviderConfigSpy,
+ spyOn(configManager, "detectCurrentConfig").mockReturnValue({
+ isInstalled: false,
+ hasClaude: false,
+ isMax20: false,
+ hasOpenAI: false,
+ hasGemini: false,
+ hasCopilot: false,
+ hasOpencodeZen: false,
+ hasZaiCodingPlan: false,
+ hasKimiForCoding: false,
+ }),
+ spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
+ spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
+ spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
+ success: true,
+ configPath: "/tmp/opencode.jsonc",
+ }),
+ spyOn(configManager, "writeOmoConfig").mockReturnValue({
+ success: true,
+ configPath: "/tmp/oh-my-opencode.jsonc",
+ }),
+ ]
+
+ const args: InstallArgs = {
+ tui: false,
+ claude: "no",
+ openai: "yes",
+ gemini: "no",
+ copilot: "yes",
+ opencodeZen: "no",
+ zaiCodingPlan: "no",
+ kimiForCoding: "no",
+ }
+
+ //#when
+ const result = await runCliInstaller(args, "3.4.0")
+
+ //#then
+ expect(result).toBe(0)
+ expect(addAuthPluginsSpy).toHaveBeenCalledTimes(1)
+ expect(addProviderConfigSpy).toHaveBeenCalledTimes(1)
+
+ for (const spy of restoreSpies) {
+ spy.mockRestore()
+ }
+ })
+})
diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts
index a38b2c80..141e694c 100644
--- a/src/cli/cli-installer.ts
+++ b/src/cli/cli-installer.ts
@@ -77,7 +77,9 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
- if (config.hasGemini) {
+ const needsProviderSetup = config.hasGemini || config.hasOpenAI || config.hasCopilot
+
+ if (needsProviderSetup) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
diff --git a/src/cli/run/completion.ts b/src/cli/run/completion.ts
index 11a24f4b..f339e9d2 100644
--- a/src/cli/run/completion.ts
+++ b/src/cli/run/completion.ts
@@ -1,5 +1,6 @@
import pc from "picocolors"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
+import { normalizeSDKResponse } from "../../shared"
export async function checkCompletionConditions(ctx: RunContext): Promise {
try {
@@ -20,7 +21,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise {
const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } })
- const todos = (todosRes.data ?? []) as Todo[]
+ const todos = normalizeSDKResponse(todosRes, [] as Todo[])
const incompleteTodos = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
@@ -43,7 +44,7 @@ async function fetchAllStatuses(
ctx: RunContext
): Promise> {
const statusRes = await ctx.client.session.status()
- return (statusRes.data ?? {}) as Record
+ return normalizeSDKResponse(statusRes, {} as Record)
}
async function areAllDescendantsIdle(
@@ -54,7 +55,7 @@ async function areAllDescendantsIdle(
const childrenRes = await ctx.client.session.children({
path: { id: sessionID },
})
- const children = (childrenRes.data ?? []) as ChildSession[]
+ const children = normalizeSDKResponse(childrenRes, [] as ChildSession[])
for (const child of children) {
const status = allStatuses[child.id]
diff --git a/src/cli/run/runner.test.ts b/src/cli/run/runner.test.ts
index 03f1b6e1..09263bb7 100644
--- a/src/cli/run/runner.test.ts
+++ b/src/cli/run/runner.test.ts
@@ -107,7 +107,7 @@ describe("waitForEventProcessorShutdown", () => {
const eventProcessor = new Promise(() => {})
const spy = spyOn(console, "log").mockImplementation(() => {})
consoleLogSpy = spy
- const timeoutMs = 50
+ const timeoutMs = 200
const start = performance.now()
try {
@@ -116,11 +116,8 @@ describe("waitForEventProcessorShutdown", () => {
//#then
const elapsed = performance.now() - start
- expect(elapsed).toBeGreaterThanOrEqual(timeoutMs)
- const callArgs = spy.mock.calls.flat().join("")
- expect(callArgs).toContain(
- `[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
- )
+ expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
+ expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1)
} finally {
spy.mockRestore()
}
diff --git a/src/cli/run/types.ts b/src/cli/run/types.ts
index 0e032d95..b155642f 100644
--- a/src/cli/run/types.ts
+++ b/src/cli/run/types.ts
@@ -34,10 +34,10 @@ export interface RunContext {
}
export interface Todo {
- id: string
- content: string
- status: string
- priority: string
+ id?: string;
+ content: string;
+ status: string;
+ priority: string;
}
export interface SessionStatus {
diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md
index 1da29b14..8844ab18 100644
--- a/src/features/AGENTS.md
+++ b/src/features/AGENTS.md
@@ -7,16 +7,17 @@
## STRUCTURE
```
features/
-├── background-agent/ # Task lifecycle, concurrency (50 files, 8330 LOC)
-│ ├── manager.ts # Main task orchestration (1646 lines)
-│ ├── concurrency.ts # Parallel execution limits per provider/model
-│ └── spawner/ # Task spawning utilities (8 files)
+├── background-agent/ # Task lifecycle, concurrency (56 files, 1701-line manager)
+│ ├── manager.ts # Main task orchestration (1701 lines)
+│ ├── concurrency.ts # Parallel execution limits per provider/model (137 lines)
+│ ├── task-history.ts # Task execution history per parent session (76 lines)
+│ └── spawner/ # Task spawning: factory, starter, resumer, tmux (8 files)
├── tmux-subagent/ # Tmux integration (28 files, 3303 LOC)
│ └── manager.ts # Pane management, grid planning (350 lines)
├── opencode-skill-loader/ # YAML frontmatter skill loading (28 files, 2967 LOC)
│ ├── loader.ts # Skill discovery (4 scopes)
-│ ├── skill-directory-loader.ts # Recursive directory scanning
-│ ├── skill-discovery.ts # getAllSkills() with caching
+│ ├── skill-directory-loader.ts # Recursive directory scanning (maxDepth=2)
+│ ├── skill-discovery.ts # getAllSkills() with caching + provider gating
│ └── merger/ # Skill merging with scope priority
├── mcp-oauth/ # OAuth 2.0 flow for MCP (18 files, 2164 LOC)
│ ├── provider.ts # McpOAuthProvider class
@@ -25,10 +26,10 @@ features/
├── skill-mcp-manager/ # MCP client lifecycle per session (12 files, 1769 LOC)
│ └── manager.ts # SkillMcpManager class (150 lines)
├── builtin-skills/ # 5 built-in skills (10 files, 1921 LOC)
-│ └── skills/ # git-master (1111), playwright, dev-browser, frontend-ui-ux
-├── builtin-commands/ # 6 command templates (11 files, 1511 LOC)
-│ └── templates/ # refactor, ralph-loop, init-deep, handoff, start-work, stop-continuation
-├── claude-tasks/ # Task schema + storage (7 files, 1165 LOC)
+│ └── skills/ # git-master (1112), playwright (313), dev-browser (222), frontend-ui-ux (80)
+├── builtin-commands/ # 7 command templates (11 files, 1511 LOC)
+│ └── templates/ # refactor (620), init-deep (306), handoff (178), start-work, ralph-loop, stop-continuation
+├── claude-tasks/ # Task schema + storage (7 files) — see AGENTS.md
├── context-injector/ # AGENTS.md, README.md, rules injection (6 files, 809 LOC)
├── claude-code-plugin-loader/ # Plugin discovery from .opencode/plugins/ (10 files)
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion (6 files)
@@ -44,7 +45,10 @@ features/
## KEY PATTERNS
**Background Agent Lifecycle:**
-Task creation → Queue → Concurrency check → Execute → Monitor/Poll → Notification → Cleanup
+pending → running → completed/error/cancelled/interrupt
+- Concurrency: Per provider/model limits (default: 5), queue-based FIFO
+- Events: session.idle + session.error drive completion detection
+- Key methods: `launch()`, `resume()`, `cancelTask()`, `getTask()`, `getAllDescendantTasks()`
**Skill Loading Pipeline (4-scope priority):**
opencode-project (`.opencode/skills/`) > opencode (`~/.config/opencode/skills/`) > project (`.claude/skills/`) > user (`~/.claude/skills/`)
diff --git a/src/features/background-agent/constants.ts b/src/features/background-agent/constants.ts
index 6e985d6d..cd3f3cf4 100644
--- a/src/features/background-agent/constants.ts
+++ b/src/features/background-agent/constants.ts
@@ -33,10 +33,10 @@ export interface BackgroundEvent {
}
export interface Todo {
- content: string
- status: string
- priority: string
- id: string
+ content: string;
+ status: string;
+ priority: string;
+ id?: string;
}
export interface QueueItem {
diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts
index 0ade3861..240fd187 100644
--- a/src/features/background-agent/manager.test.ts
+++ b/src/features/background-agent/manager.test.ts
@@ -6,6 +6,7 @@ import type { BackgroundTask, ResumeInput } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager"
import { ConcurrencyManager } from "./concurrency"
+import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
const TASK_TTL_MS = 30 * 60 * 1000
@@ -190,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map
return (manager as unknown as { pendingByParent: Map> }).pendingByParent
}
+function getCompletionTimers(manager: BackgroundManager): Map> {
+ return (manager as unknown as { completionTimers: Map> }).completionTimers
+}
+
function getQueuesByKey(
manager: BackgroundManager
): Map> {
@@ -215,6 +220,23 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise }).notifyParentSession = async () => {}
}
+function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
+ _resetTaskToastManagerForTesting()
+ const toastManager = initTaskToastManager({
+ tui: { showToast: async () => {} },
+ } as unknown as PluginInput["client"])
+ const removeTaskCalls: string[] = []
+ const originalRemoveTask = toastManager.removeTask.bind(toastManager)
+ toastManager.removeTask = (taskId: string): void => {
+ removeTaskCalls.push(taskId)
+ originalRemoveTask(taskId)
+ }
+ return {
+ removeTaskCalls,
+ resetToastManager: _resetTaskToastManagerForTesting,
+ }
+}
+
function getCleanupSignals(): Array {
const signals: Array = ["SIGINT", "SIGTERM", "beforeExit", "exit"]
if (process.platform === "win32") {
@@ -894,7 +916,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
})
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
- test("should skip notification when parent session is aborted", async () => {
+ test("should fall back and still notify when parent session messages are aborted", async () => {
//#given
let promptCalled = false
const promptMock = async () => {
@@ -933,7 +955,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
.notifyParentSession(task)
//#then
- expect(promptCalled).toBe(false)
+ expect(promptCalled).toBe(true)
manager.shutdown()
})
@@ -1816,6 +1838,32 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const pendingSet = pendingByParent.get(task.parentSessionID)
expect(pendingSet?.has(task.id) ?? false).toBe(false)
})
+
+ test("should remove task from toast manager when notification is skipped", async () => {
+ //#given
+ const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
+ const manager = createBackgroundManager()
+ const task = createMockTask({
+ id: "task-cancel-skip-notification",
+ sessionID: "session-cancel-skip-notification",
+ parentSessionID: "parent-cancel-skip-notification",
+ status: "running",
+ })
+ getTaskMap(manager).set(task.id, task)
+
+ //#when
+ const cancelled = await manager.cancelTask(task.id, {
+ source: "test",
+ skipNotification: true,
+ })
+
+ //#then
+ expect(cancelled).toBe(true)
+ expect(removeTaskCalls).toContain(task.id)
+
+ manager.shutdown()
+ resetToastManager()
+ })
})
describe("multiple keys process in parallel", () => {
@@ -2776,6 +2824,43 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
manager.shutdown()
})
+
+ test("should remove tasks from toast manager when session is deleted", () => {
+ //#given
+ const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
+ const manager = createBackgroundManager()
+ const parentSessionID = "session-parent-toast"
+ const childTask = createMockTask({
+ id: "task-child-toast",
+ sessionID: "session-child-toast",
+ parentSessionID,
+ status: "running",
+ })
+ const grandchildTask = createMockTask({
+ id: "task-grandchild-toast",
+ sessionID: "session-grandchild-toast",
+ parentSessionID: "session-child-toast",
+ status: "pending",
+ startedAt: undefined,
+ queuedAt: new Date(),
+ })
+ const taskMap = getTaskMap(manager)
+ taskMap.set(childTask.id, childTask)
+ taskMap.set(grandchildTask.id, grandchildTask)
+
+ //#when
+ manager.handleEvent({
+ type: "session.deleted",
+ properties: { info: { id: parentSessionID } },
+ })
+
+ //#then
+ expect(removeTaskCalls).toContain(childTask.id)
+ expect(removeTaskCalls).toContain(grandchildTask.id)
+
+ manager.shutdown()
+ resetToastManager()
+ })
})
describe("BackgroundManager.handleEvent - session.error", () => {
@@ -2823,6 +2908,35 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown()
})
+ test("removes errored task from toast manager", () => {
+ //#given
+ const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
+ const manager = createBackgroundManager()
+ const sessionID = "ses_error_toast"
+ const task = createMockTask({
+ id: "task-session-error-toast",
+ sessionID,
+ parentSessionID: "parent-session",
+ status: "running",
+ })
+ getTaskMap(manager).set(task.id, task)
+
+ //#when
+ manager.handleEvent({
+ type: "session.error",
+ properties: {
+ sessionID,
+ error: { name: "UnknownError", message: "boom" },
+ },
+ })
+
+ //#then
+ expect(removeTaskCalls).toContain(task.id)
+
+ manager.shutdown()
+ resetToastManager()
+ })
+
test("ignores session.error for non-running tasks", () => {
//#given
const manager = createBackgroundManager()
@@ -2968,13 +3082,32 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
manager.shutdown()
})
+
+ test("removes stale task from toast manager", () => {
+ //#given
+ const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
+ const manager = createBackgroundManager()
+ const staleTask = createMockTask({
+ id: "task-stale-toast",
+ sessionID: "session-stale-toast",
+ parentSessionID: "parent-session",
+ status: "running",
+ startedAt: new Date(Date.now() - 31 * 60 * 1000),
+ })
+ getTaskMap(manager).set(staleTask.id, staleTask)
+
+ //#when
+ pruneStaleTasksAndNotificationsForTest(manager)
+
+ //#then
+ expect(removeTaskCalls).toContain(staleTask.id)
+
+ manager.shutdown()
+ resetToastManager()
+ })
})
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
- function getCompletionTimers(manager: BackgroundManager): Map> {
- return (manager as unknown as { completionTimers: Map> }).completionTimers
- }
-
function setCompletionTimer(manager: BackgroundManager, taskId: string): void {
const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => {
@@ -3500,3 +3633,93 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
expect(task.status).toBe("running")
})
})
+
+describe("BackgroundManager regression fixes - resume and aborted notification", () => {
+ test("should keep resumed task in memory after previous completion timer deadline", async () => {
+ //#given
+ const client = {
+ session: {
+ prompt: async () => ({}),
+ promptAsync: async () => ({}),
+ abort: async () => ({}),
+ },
+ }
+ const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
+
+ const task: BackgroundTask = {
+ id: "task-resume-timer-regression",
+ sessionID: "session-resume-timer-regression",
+ parentSessionID: "parent-session",
+ parentMessageID: "msg-1",
+ description: "resume timer regression",
+ prompt: "test",
+ agent: "explore",
+ status: "completed",
+ startedAt: new Date(),
+ completedAt: new Date(),
+ concurrencyGroup: "explore",
+ }
+ getTaskMap(manager).set(task.id, task)
+
+ const completionTimers = getCompletionTimers(manager)
+ const timer = setTimeout(() => {
+ completionTimers.delete(task.id)
+ getTaskMap(manager).delete(task.id)
+ }, 25)
+ completionTimers.set(task.id, timer)
+
+ //#when
+ await manager.resume({
+ sessionId: "session-resume-timer-regression",
+ prompt: "resume task",
+ parentSessionID: "parent-session-2",
+ parentMessageID: "msg-2",
+ })
+ await new Promise((resolve) => setTimeout(resolve, 60))
+
+ //#then
+ expect(getTaskMap(manager).has(task.id)).toBe(true)
+ expect(completionTimers.has(task.id)).toBe(false)
+
+ manager.shutdown()
+ })
+
+ test("should start cleanup timer even when promptAsync aborts", async () => {
+ //#given
+ const client = {
+ session: {
+ prompt: async () => ({}),
+ promptAsync: async () => {
+ const error = new Error("User aborted")
+ error.name = "MessageAbortedError"
+ throw error
+ },
+ abort: async () => ({}),
+ messages: async () => ({ data: [] }),
+ },
+ }
+ const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
+ const task: BackgroundTask = {
+ id: "task-aborted-cleanup-regression",
+ sessionID: "session-aborted-cleanup-regression",
+ parentSessionID: "parent-session",
+ parentMessageID: "msg-1",
+ description: "aborted prompt cleanup regression",
+ prompt: "test",
+ agent: "explore",
+ status: "completed",
+ startedAt: new Date(),
+ completedAt: new Date(),
+ }
+ getTaskMap(manager).set(task.id, task)
+ getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
+
+ //#when
+ await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }).notifyParentSession(task)
+
+ //#then
+ expect(getCompletionTimers(manager).has(task.id)).toBe(true)
+
+ manager.shutdown()
+ })
+})
diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts
index 88641aae..6b60ebff 100644
--- a/src/features/background-agent/manager.ts
+++ b/src/features/background-agent/manager.ts
@@ -6,7 +6,7 @@ import type {
ResumeInput,
} from "./types"
import { TaskHistory } from "./task-history"
-import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
+import { log, getAgentToolRestrictions, normalizeSDKResponse, promptWithModelSuggestionRetry } from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
@@ -531,6 +531,12 @@ export class BackgroundManager {
return existingTask
}
+ const completionTimer = this.completionTimers.get(existingTask.id)
+ if (completionTimer) {
+ clearTimeout(completionTimer)
+ this.completionTimers.delete(existingTask.id)
+ }
+
// Re-acquire concurrency using the persisted concurrency group
const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent
await this.concurrencyManager.acquire(concurrencyKey)
@@ -648,7 +654,7 @@ export class BackgroundManager {
const response = await this.client.session.todo({
path: { id: sessionID },
})
- const todos = (response.data ?? response) as Todo[]
+ const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
if (!todos || todos.length === 0) return false
const incomplete = todos.filter(
@@ -786,6 +792,10 @@ export class BackgroundManager {
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
+ const toastManager = getTaskToastManager()
+ if (toastManager) {
+ toastManager.removeTask(task.id)
+ }
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
@@ -833,6 +843,10 @@ export class BackgroundManager {
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
+ const toastManager = getTaskToastManager()
+ if (toastManager) {
+ toastManager.removeTask(task.id)
+ }
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
@@ -864,7 +878,7 @@ export class BackgroundManager {
path: { id: sessionID },
})
- const messages = response.data ?? []
+ const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true })
// Check for at least one assistant or tool message
const hasAssistantOrToolMessage = messages.some(
@@ -1003,6 +1017,10 @@ export class BackgroundManager {
}
if (options?.skipNotification) {
+ const toastManager = getTaskToastManager()
+ if (toastManager) {
+ toastManager.removeTask(task.id)
+ }
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
return true
}
@@ -1232,9 +1250,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
- const messages = (messagesResp.data ?? []) as Array<{
+ const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
- }>
+ }>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
@@ -1245,11 +1263,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
} catch (error) {
if (this.isAbortedSessionError(error)) {
- log("[background-agent] Parent session aborted, skipping notification:", {
+ log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
- return
}
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
@@ -1283,13 +1300,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
})
} catch (error) {
if (this.isAbortedSessionError(error)) {
- log("[background-agent] Parent session aborted, skipping notification:", {
+ log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
- return
+ } else {
+ log("[background-agent] Failed to send notification:", error)
}
- log("[background-agent] Failed to send notification:", error)
}
} else {
log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
@@ -1425,6 +1442,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
this.clearNotificationsForTask(taskId)
+ const toastManager = getTaskToastManager()
+ if (toastManager) {
+ toastManager.removeTask(taskId)
+ }
this.tasks.delete(taskId)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
@@ -1526,7 +1547,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.pruneStaleTasksAndNotifications()
const statusResult = await this.client.session.status()
- const allStatuses = (statusResult.data ?? {}) as Record
+ const allStatuses = normalizeSDKResponse(statusResult, {} as Record)
await this.checkAndInterruptStaleTasks(allStatuses)
diff --git a/src/features/background-agent/message-dir.ts b/src/features/background-agent/message-dir.ts
index 138f5dab..cf8b56ed 100644
--- a/src/features/background-agent/message-dir.ts
+++ b/src/features/background-agent/message-dir.ts
@@ -1 +1 @@
-export { getMessageDir } from "./message-storage-locator"
+export { getMessageDir } from "../../shared"
diff --git a/src/features/background-agent/message-storage-locator.ts b/src/features/background-agent/message-storage-locator.ts
index ceecd329..f9cb8cfd 100644
--- a/src/features/background-agent/message-storage-locator.ts
+++ b/src/features/background-agent/message-storage-locator.ts
@@ -1,17 +1 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { MESSAGE_STORAGE } from "../hook-message-injector"
-
-export function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
+import { getMessageDir } from "../../shared"
diff --git a/src/features/background-agent/notify-parent-session.ts b/src/features/background-agent/notify-parent-session.ts
index da6a531e..15d24eb1 100644
--- a/src/features/background-agent/notify-parent-session.ts
+++ b/src/features/background-agent/notify-parent-session.ts
@@ -1,4 +1,4 @@
-import { log } from "../../shared"
+import { log, normalizeSDKResponse } from "../../shared"
import { findNearestMessageWithFields } from "../hook-message-injector"
import { getTaskToastManager } from "../task-toast-manager"
@@ -106,7 +106,7 @@ export async function notifyParentSession(args: {
const messagesResp = await client.session.messages({
path: { id: task.parentSessionID },
})
- const raw = (messagesResp as { data?: unknown }).data ?? []
+ const raw = normalizeSDKResponse(messagesResp, [] as unknown[])
const messages = Array.isArray(raw) ? raw : []
for (let i = messages.length - 1; i >= 0; i--) {
diff --git a/src/features/background-agent/parent-session-context-resolver.ts b/src/features/background-agent/parent-session-context-resolver.ts
index d27dd375..2eff0b7e 100644
--- a/src/features/background-agent/parent-session-context-resolver.ts
+++ b/src/features/background-agent/parent-session-context-resolver.ts
@@ -1,7 +1,7 @@
import type { OpencodeClient } from "./constants"
import type { BackgroundTask } from "./types"
import { findNearestMessageWithFields } from "../hook-message-injector"
-import { getMessageDir } from "./message-storage-locator"
+import { getMessageDir } from "../../shared"
type AgentModel = { providerID: string; modelID: string }
diff --git a/src/features/background-agent/poll-running-tasks.ts b/src/features/background-agent/poll-running-tasks.ts
index 023fbf55..e90c73d1 100644
--- a/src/features/background-agent/poll-running-tasks.ts
+++ b/src/features/background-agent/poll-running-tasks.ts
@@ -1,4 +1,4 @@
-import { log } from "../../shared"
+import { log, normalizeSDKResponse } from "../../shared"
import {
MIN_STABILITY_TIME_MS,
@@ -56,7 +56,7 @@ export async function pollRunningTasks(args: {
pruneStaleTasksAndNotifications()
const statusResult = await client.session.status()
- const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap
+ const allStatuses = normalizeSDKResponse(statusResult, {} as SessionStatusMap)
await checkAndInterruptStaleTasks(allStatuses)
@@ -95,10 +95,9 @@ export async function pollRunningTasks(args: {
continue
}
- const messagesPayload = Array.isArray(messagesResult)
- ? messagesResult
- : (messagesResult as { data?: unknown }).data
- const messages = asSessionMessages(messagesPayload)
+ const messages = asSessionMessages(normalizeSDKResponse(messagesResult, [] as SessionMessage[], {
+ preferResponseOnMissingData: true,
+ }))
const assistantMsgs = messages.filter((m) => m.info?.role === "assistant")
let toolCalls = 0
@@ -139,7 +138,7 @@ export async function pollRunningTasks(args: {
task.stablePolls = (task.stablePolls ?? 0) + 1
if (task.stablePolls >= 3) {
const recheckStatus = await client.session.status()
- const recheckData = ((recheckStatus as { data?: unknown }).data ?? {}) as SessionStatusMap
+ const recheckData = normalizeSDKResponse(recheckStatus, {} as SessionStatusMap)
const currentStatus = recheckData[sessionID]
if (currentStatus?.type !== "idle") {
diff --git a/src/features/background-agent/result-handler.ts b/src/features/background-agent/result-handler.ts
index ccc365c8..3f9f9a7a 100644
--- a/src/features/background-agent/result-handler.ts
+++ b/src/features/background-agent/result-handler.ts
@@ -1,6 +1,6 @@
export type { ResultHandlerContext } from "./result-handler-context"
export { formatDuration } from "./duration-formatter"
-export { getMessageDir } from "./message-storage-locator"
+export { getMessageDir } from "../../shared"
export { checkSessionTodos } from "./session-todo-checker"
export { validateSessionHasOutput } from "./session-output-validator"
export { tryCompleteTask } from "./background-task-completer"
diff --git a/src/features/background-agent/session-todo-checker.ts b/src/features/background-agent/session-todo-checker.ts
index 3feaedbf..c1bad337 100644
--- a/src/features/background-agent/session-todo-checker.ts
+++ b/src/features/background-agent/session-todo-checker.ts
@@ -4,7 +4,7 @@ function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) return false
const todo = value as Record
return (
- typeof todo["id"] === "string" &&
+ (typeof todo["id"] === "string" || todo["id"] === undefined) &&
typeof todo["content"] === "string" &&
typeof todo["status"] === "string" &&
typeof todo["priority"] === "string"
diff --git a/src/features/background-agent/session-validator.ts b/src/features/background-agent/session-validator.ts
index 6181dec9..fe8a7f8a 100644
--- a/src/features/background-agent/session-validator.ts
+++ b/src/features/background-agent/session-validator.ts
@@ -1,4 +1,4 @@
-import { log } from "../../shared"
+import { log, normalizeSDKResponse } from "../../shared"
import type { OpencodeClient } from "./opencode-client"
@@ -51,7 +51,9 @@ export async function validateSessionHasOutput(
path: { id: sessionID },
})
- const messages = asSessionMessages((response as { data?: unknown }).data ?? response)
+ const messages = asSessionMessages(normalizeSDKResponse(response, [] as SessionMessage[], {
+ preferResponseOnMissingData: true,
+ }))
const hasAssistantOrToolMessage = messages.some(
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
@@ -97,8 +99,9 @@ export async function checkSessionTodos(
path: { id: sessionID },
})
- const raw = (response as { data?: unknown }).data ?? response
- const todos = Array.isArray(raw) ? (raw as Todo[]) : []
+ const todos = normalizeSDKResponse(response, [] as Todo[], {
+ preferResponseOnMissingData: true,
+ })
if (todos.length === 0) return false
const incomplete = todos.filter(
diff --git a/src/features/claude-tasks/AGENTS.md b/src/features/claude-tasks/AGENTS.md
index b79c6506..25cbcee9 100644
--- a/src/features/claude-tasks/AGENTS.md
+++ b/src/features/claude-tasks/AGENTS.md
@@ -2,7 +2,7 @@
## OVERVIEW
-Claude Code compatible task schema and storage. Core task management with file-based persistence and atomic writes.
+Claude Code compatible task schema and storage. Core task management with file-based persistence, atomic writes, and OpenCode todo sync.
## STRUCTURE
```
@@ -50,39 +50,16 @@ interface Task {
## TODO SYNC
-Automatic bidirectional synchronization between tasks and OpenCode's todo system.
-
-| Function | Purpose |
-|----------|---------|
-| `syncTaskToTodo(task)` | Convert Task to TodoInfo, returns `null` for deleted tasks |
-| `syncTaskTodoUpdate(ctx, task, sessionID, writer?)` | Fetch current todos, update specific task, write back |
-| `syncAllTasksToTodos(ctx, tasks, sessionID?)` | Bulk sync multiple tasks to todos |
-
-### Status Mapping
+Automatic bidirectional sync between tasks and OpenCode's todo system.
| Task Status | Todo Status |
|-------------|-------------|
| `pending` | `pending` |
| `in_progress` | `in_progress` |
| `completed` | `completed` |
-| `deleted` | `null` (removed from todos) |
+| `deleted` | `null` (removed) |
-### Field Mapping
-
-| Task Field | Todo Field |
-|------------|------------|
-| `task.id` | `todo.id` |
-| `task.subject` | `todo.content` |
-| `task.status` (mapped) | `todo.status` |
-| `task.metadata.priority` | `todo.priority` |
-
-Priority values: `"low"`, `"medium"`, `"high"`
-
-### Automatic Sync Triggers
-
-Sync occurs automatically on:
-- `task_create` — new task added to todos
-- `task_update` — task changes reflected in todos
+Sync triggers: `task_create`, `task_update`.
## ANTI-PATTERNS
diff --git a/src/features/hook-message-injector/constants.ts b/src/features/hook-message-injector/constants.ts
index dc90e661..0424b96c 100644
--- a/src/features/hook-message-injector/constants.ts
+++ b/src/features/hook-message-injector/constants.ts
@@ -1,6 +1 @@
-import { join } from "node:path"
-import { getOpenCodeStorageDir } from "../../shared/data-path"
-
-export const OPENCODE_STORAGE = getOpenCodeStorageDir()
-export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
-export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
+export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
diff --git a/src/features/hook-message-injector/index.ts b/src/features/hook-message-injector/index.ts
index 9a46758f..e8b4ede4 100644
--- a/src/features/hook-message-injector/index.ts
+++ b/src/features/hook-message-injector/index.ts
@@ -1,4 +1,11 @@
-export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector"
+export {
+ injectHookMessage,
+ findNearestMessageWithFields,
+ findFirstMessageWithAgent,
+ findNearestMessageWithFieldsFromSDK,
+ findFirstMessageWithAgentFromSDK,
+ resolveMessageContext,
+} from "./injector"
export type { StoredMessage } from "./injector"
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
export { MESSAGE_STORAGE } from "./constants"
diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts
new file mode 100644
index 00000000..fffdf5a7
--- /dev/null
+++ b/src/features/hook-message-injector/injector.test.ts
@@ -0,0 +1,237 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"
+import {
+ findNearestMessageWithFields,
+ findFirstMessageWithAgent,
+ findNearestMessageWithFieldsFromSDK,
+ findFirstMessageWithAgentFromSDK,
+ injectHookMessage,
+} from "./injector"
+import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
+
+//#region Mocks
+
+const mockIsSqliteBackend = vi.fn()
+
+vi.mock("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: mockIsSqliteBackend,
+ resetSqliteBackendCache: () => {},
+}))
+
+//#endregion
+
+//#region Test Helpers
+
+function createMockClient(messages: Array<{
+ info?: {
+ agent?: string
+ model?: { providerID?: string; modelID?: string; variant?: string }
+ providerID?: string
+ modelID?: string
+ tools?: Record
+ }
+}>): {
+ session: {
+ messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
+ }
+} {
+ return {
+ session: {
+ messages: async () => ({ data: messages }),
+ },
+ }
+}
+
+//#endregion
+
+describe("findNearestMessageWithFieldsFromSDK", () => {
+ it("returns message with all fields when available", async () => {
+ const mockClient = createMockClient([
+ { info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" } } },
+ ])
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toEqual({
+ agent: "sisyphus",
+ model: { providerID: "anthropic", modelID: "claude-opus-4" },
+ tools: undefined,
+ })
+ })
+
+ it("returns message with assistant shape (providerID/modelID directly on info)", async () => {
+ const mockClient = createMockClient([
+ { info: { agent: "sisyphus", providerID: "openai", modelID: "gpt-5" } },
+ ])
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toEqual({
+ agent: "sisyphus",
+ model: { providerID: "openai", modelID: "gpt-5" },
+ tools: undefined,
+ })
+ })
+
+ it("returns nearest (most recent) message with all fields", async () => {
+ const mockClient = createMockClient([
+ { info: { agent: "old-agent", model: { providerID: "old", modelID: "model" } } },
+ { info: { agent: "new-agent", model: { providerID: "new", modelID: "model" } } },
+ ])
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result?.agent).toBe("new-agent")
+ })
+
+ it("falls back to message with partial fields", async () => {
+ const mockClient = createMockClient([
+ { info: { agent: "partial-agent" } },
+ ])
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result?.agent).toBe("partial-agent")
+ })
+
+ it("returns null when no messages have useful fields", async () => {
+ const mockClient = createMockClient([
+ { info: {} },
+ { info: {} },
+ ])
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toBeNull()
+ })
+
+ it("returns null when messages array is empty", async () => {
+ const mockClient = createMockClient([])
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toBeNull()
+ })
+
+ it("returns null on SDK error", async () => {
+ const mockClient = {
+ session: {
+ messages: async () => {
+ throw new Error("SDK error")
+ },
+ },
+ }
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toBeNull()
+ })
+
+ it("includes tools when available", async () => {
+ const mockClient = createMockClient([
+ {
+ info: {
+ agent: "sisyphus",
+ model: { providerID: "anthropic", modelID: "claude-opus-4" },
+ tools: { edit: true, write: false },
+ },
+ },
+ ])
+
+ const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
+
+ expect(result?.tools).toEqual({ edit: true, write: false })
+ })
+})
+
+describe("findFirstMessageWithAgentFromSDK", () => {
+ it("returns agent from first message", async () => {
+ const mockClient = createMockClient([
+ { info: { agent: "first-agent" } },
+ { info: { agent: "second-agent" } },
+ ])
+
+ const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toBe("first-agent")
+ })
+
+ it("skips messages without agent field", async () => {
+ const mockClient = createMockClient([
+ { info: {} },
+ { info: { agent: "first-real-agent" } },
+ ])
+
+ const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toBe("first-real-agent")
+ })
+
+ it("returns null when no messages have agent", async () => {
+ const mockClient = createMockClient([
+ { info: {} },
+ { info: {} },
+ ])
+
+ const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toBeNull()
+ })
+
+ it("returns null on SDK error", async () => {
+ const mockClient = {
+ session: {
+ messages: async () => {
+ throw new Error("SDK error")
+ },
+ },
+ }
+
+ const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
+
+ expect(result).toBeNull()
+ })
+})
+
+describe("injectHookMessage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("returns false and logs warning on beta/SQLite backend", () => {
+ mockIsSqliteBackend.mockReturnValue(true)
+
+ const result = injectHookMessage("ses_123", "test content", {
+ agent: "sisyphus",
+ model: { providerID: "anthropic", modelID: "claude-opus-4" },
+ })
+
+ expect(result).toBe(false)
+ expect(mockIsSqliteBackend).toHaveBeenCalled()
+ })
+
+ it("returns false for empty hook content", () => {
+ mockIsSqliteBackend.mockReturnValue(false)
+
+ const result = injectHookMessage("ses_123", "", {
+ agent: "sisyphus",
+ model: { providerID: "anthropic", modelID: "claude-opus-4" },
+ })
+
+ expect(result).toBe(false)
+ })
+
+ it("returns false for whitespace-only hook content", () => {
+ mockIsSqliteBackend.mockReturnValue(false)
+
+ const result = injectHookMessage("ses_123", " \n\t ", {
+ agent: "sisyphus",
+ model: { providerID: "anthropic", modelID: "claude-opus-4" },
+ })
+
+ expect(result).toBe(false)
+ })
+})
diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts
index bd3c5537..1acc72d3 100644
--- a/src/features/hook-message-injector/injector.ts
+++ b/src/features/hook-message-injector/injector.ts
@@ -1,8 +1,12 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
import { log } from "../../shared/logger"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { getMessageDir } from "../../shared/opencode-message-dir"
+import { normalizeSDKResponse } from "../../shared"
export interface StoredMessage {
agent?: string
@@ -10,14 +14,130 @@ export interface StoredMessage {
tools?: Record
}
+type OpencodeClient = PluginInput["client"]
+
+interface SDKMessage {
+ info?: {
+ agent?: string
+ model?: {
+ providerID?: string
+ modelID?: string
+ variant?: string
+ }
+ providerID?: string
+ modelID?: string
+ tools?: Record
+ }
+}
+
+function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
+ const info = msg.info
+ if (!info) return null
+
+ const providerID = info.model?.providerID ?? info.providerID
+ const modelID = info.model?.modelID ?? info.modelID
+ const variant = info.model?.variant
+
+ if (!info.agent && !providerID && !modelID) {
+ return null
+ }
+
+ return {
+ agent: info.agent,
+ model: providerID && modelID
+ ? { providerID, modelID, ...(variant ? { variant } : {}) }
+ : undefined,
+ tools: info.tools,
+ }
+}
+
+// TODO: These SDK-based functions are exported for future use when hooks migrate to async.
+// Currently, callers still use the sync JSON-based functions which return null on beta.
+// Migration requires making callers async, which is a larger refactoring.
+// See: https://github.com/code-yeongyu/oh-my-opencode/pull/1837
+
+/**
+ * Finds the nearest message with required fields using SDK (for beta/SQLite backend).
+ * Uses client.session.messages() to fetch message data from SQLite.
+ */
+export async function findNearestMessageWithFieldsFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const stored = convertSDKMessageToStoredMessage(messages[i])
+ if (stored?.agent && stored.model?.providerID && stored.model?.modelID) {
+ return stored
+ }
+ }
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const stored = convertSDKMessageToStoredMessage(messages[i])
+ if (stored?.agent || (stored?.model?.providerID && stored?.model?.modelID)) {
+ return stored
+ }
+ }
+ } catch (error) {
+ log("[hook-message-injector] SDK message fetch failed", {
+ sessionID,
+ error: String(error),
+ })
+ }
+ return null
+}
+
+/**
+ * Finds the FIRST (oldest) message with agent field using SDK (for beta/SQLite backend).
+ */
+export async function findFirstMessageWithAgentFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+
+ for (const msg of messages) {
+ const stored = convertSDKMessageToStoredMessage(msg)
+ if (stored?.agent) {
+ return stored.agent
+ }
+ }
+ } catch (error) {
+ log("[hook-message-injector] SDK agent fetch failed", {
+ sessionID,
+ error: String(error),
+ })
+ }
+ return null
+}
+
+/**
+ * Finds the nearest message with required fields (agent, model.providerID, model.modelID).
+ * Reads from JSON files - for stable (JSON) backend.
+ *
+ * **Version-gated behavior:**
+ * - On beta (SQLite backend): Returns null immediately (no JSON storage)
+ * - On stable (JSON backend): Reads from JSON files in messageDir
+ *
+ * @deprecated Use findNearestMessageWithFieldsFromSDK for beta/SQLite backend
+ */
export function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
+ // On beta SQLite backend, skip JSON file reads entirely
+ if (isSqliteBackend()) {
+ return null
+ }
+
try {
const files = readdirSync(messageDir)
.filter((f) => f.endsWith(".json"))
.sort()
.reverse()
- // First pass: find message with ALL fields (ideal)
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
@@ -30,8 +150,6 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
}
}
- // Second pass: find message with ANY useful field (fallback)
- // This ensures agent info isn't lost when model info is missing
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
@@ -51,15 +169,24 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
/**
* Finds the FIRST (oldest) message in the session with agent field.
- * This is used to get the original agent that started the session,
- * avoiding issues where newer messages may have a different agent
- * due to OpenCode's internal agent switching.
+ * Reads from JSON files - for stable (JSON) backend.
+ *
+ * **Version-gated behavior:**
+ * - On beta (SQLite backend): Returns null immediately (no JSON storage)
+ * - On stable (JSON backend): Reads from JSON files in messageDir
+ *
+ * @deprecated Use findFirstMessageWithAgentFromSDK for beta/SQLite backend
*/
export function findFirstMessageWithAgent(messageDir: string): string | null {
+ // On beta SQLite backend, skip JSON file reads entirely
+ if (isSqliteBackend()) {
+ return null
+ }
+
try {
const files = readdirSync(messageDir)
.filter((f) => f.endsWith(".json"))
- .sort() // Oldest first (no reverse)
+ .sort()
for (const file of files) {
try {
@@ -111,12 +238,29 @@ function getOrCreateMessageDir(sessionID: string): string {
return directPath
}
+/**
+ * Injects a hook message into the session storage.
+ *
+ * **Version-gated behavior:**
+ * - On beta (SQLite backend): Logs warning and skips injection (writes are invisible to SQLite)
+ * - On stable (JSON backend): Writes message and part JSON files
+ *
+ * Features degraded on beta:
+ * - Hook message injection (e.g., continuation prompts, context injection) won't persist
+ * - Atlas hook's injected messages won't be visible in SQLite backend
+ * - Todo continuation enforcer's injected prompts won't persist
+ * - Ralph loop's continuation prompts won't persist
+ *
+ * @param sessionID - Target session ID
+ * @param hookContent - Content to inject
+ * @param originalMessage - Context from the original message
+ * @returns true if injection succeeded, false otherwise
+ */
export function injectHookMessage(
sessionID: string,
hookContent: string,
originalMessage: OriginalMessageContext
): boolean {
- // Validate hook content to prevent empty message injection
if (!hookContent || hookContent.trim().length === 0) {
log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", {
sessionID,
@@ -126,6 +270,16 @@ export function injectHookMessage(
return false
}
+ if (isSqliteBackend()) {
+ log("[hook-message-injector] Skipping JSON message injection on SQLite backend. " +
+ "In-flight injection is handled via experimental.chat.messages.transform hook. " +
+ "JSON write path is not needed when SQLite is the storage backend.", {
+ sessionID,
+ agent: originalMessage.agent,
+ })
+ return false
+ }
+
const messageDir = getOrCreateMessageDir(sessionID)
const needsFallback =
@@ -202,3 +356,21 @@ export function injectHookMessage(
return false
}
}
+
+export async function resolveMessageContext(
+ sessionID: string,
+ client: OpencodeClient,
+ messageDir: string | null
+): Promise<{ prevMessage: StoredMessage | null; firstMessageAgent: string | null }> {
+ const [prevMessage, firstMessageAgent] = isSqliteBackend()
+ ? await Promise.all([
+ findNearestMessageWithFieldsFromSDK(client, sessionID),
+ findFirstMessageWithAgentFromSDK(client, sessionID),
+ ])
+ : [
+ messageDir ? findNearestMessageWithFields(messageDir) : null,
+ messageDir ? findFirstMessageWithAgent(messageDir) : null,
+ ]
+
+ return { prevMessage, firstMessageAgent }
+}
diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts
index 5bd8d6e8..e25223a3 100644
--- a/src/features/tmux-subagent/manager.ts
+++ b/src/features/tmux-subagent/manager.ts
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig } from "./types"
+import { log, normalizeSDKResponse } from "../../shared"
import {
isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId,
@@ -9,7 +10,6 @@ import {
SESSION_READY_POLL_INTERVAL_MS,
SESSION_READY_TIMEOUT_MS,
} from "../../shared/tmux"
-import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
import { executeActions, executeAction } from "./action-executor"
@@ -103,7 +103,7 @@ export class TmuxSessionManager {
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
try {
const statusResult = await this.client.session.status({ path: undefined })
- const allStatuses = (statusResult.data ?? {}) as Record
+ const allStatuses = normalizeSDKResponse(statusResult, {} as Record)
if (allStatuses[sessionId]) {
log("[tmux-session-manager] session ready", {
diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts
index 0a73cdc7..3d8492da 100644
--- a/src/features/tmux-subagent/polling-manager.ts
+++ b/src/features/tmux-subagent/polling-manager.ts
@@ -3,6 +3,7 @@ import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux"
import type { TrackedSession } from "./types"
import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux"
import { log } from "../../shared"
+import { normalizeSDKResponse } from "../../shared"
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000
@@ -43,7 +44,7 @@ export class TmuxPollingManager {
try {
const statusResult = await this.client.session.status({ path: undefined })
- const allStatuses = (statusResult.data ?? {}) as Record
+ const allStatuses = normalizeSDKResponse(statusResult, {} as Record)
log("[tmux-session-manager] pollSessions", {
trackedSessions: Array.from(this.sessions.keys()),
@@ -82,7 +83,7 @@ export class TmuxPollingManager {
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
const recheckResult = await this.client.session.status({ path: undefined })
- const recheckStatuses = (recheckResult.data ?? {}) as Record
+ const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record)
const recheckStatus = recheckStatuses[sessionId]
if (recheckStatus?.type === "idle") {
diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md
index 1baad154..1e1b7b34 100644
--- a/src/hooks/AGENTS.md
+++ b/src/hooks/AGENTS.md
@@ -8,18 +8,18 @@
```
hooks/
├── agent-usage-reminder/ # Specialized agent hints (109 lines)
-├── anthropic-context-window-limit-recovery/ # Auto-summarize on limit (2232 lines)
+├── anthropic-context-window-limit-recovery/ # Auto-summarize on limit (2232 lines, 29 files)
├── anthropic-effort/ # Effort=max for Opus max variant (56 lines)
-├── atlas/ # Main orchestration hook (1976 lines)
+├── atlas/ # Main orchestration hook (1976 lines, 17 files)
├── auto-slash-command/ # Detects /command patterns (1134 lines)
-├── auto-update-checker/ # Plugin update check (1140 lines)
+├── auto-update-checker/ # Plugin update check (1140 lines, 20 files)
├── background-notification/ # OS notifications (33 lines)
├── category-skill-reminder/ # Category+skill delegation reminders (597 lines)
-├── claude-code-hooks/ # settings.json compat (2110 lines) - see AGENTS.md
+├── claude-code-hooks/ # settings.json compat (2110 lines) — see AGENTS.md
├── comment-checker/ # Prevents AI slop comments (710 lines)
├── compaction-context-injector/ # Injects context on compaction (128 lines)
├── compaction-todo-preserver/ # Preserves todos during compaction (203 lines)
-├── context-window-monitor.ts # Reminds of headroom at 70% (99 lines)
+├── context-window-monitor.ts # Reminds of headroom at 70% (100 lines)
├── delegate-task-retry/ # Retries failed delegations (266 lines)
├── directory-agents-injector/ # Auto-injects AGENTS.md (195 lines)
├── directory-readme-injector/ # Auto-injects README.md (190 lines)
@@ -34,7 +34,7 @@ hooks/
├── ralph-loop/ # Self-referential dev loop (1687 lines)
├── rules-injector/ # Conditional .sisyphus/rules injection (1604 lines)
├── session-notification.ts # OS idle notifications (108 lines)
-├── session-recovery/ # Auto-recovers from crashes (1279 lines)
+├── session-recovery/ # Auto-recovers from crashes (1279 lines, 14 files)
├── sisyphus-junior-notepad/ # Junior notepad directive (76 lines)
├── start-work/ # Sisyphus work session starter (648 lines)
├── stop-continuation-guard/ # Guards stop continuation (214 lines)
@@ -57,10 +57,10 @@ hooks/
| UserPromptSubmit | `chat.message` | Yes | 4 |
| ChatParams | `chat.params` | No | 2 |
| PreToolUse | `tool.execute.before` | Yes | 13 |
-| PostToolUse | `tool.execute.after` | No | 18 |
+| PostToolUse | `tool.execute.after` | No | 15 |
| SessionEvent | `event` | No | 17 |
| MessagesTransform | `experimental.chat.messages.transform` | No | 1 |
-| Compaction | `onSummarize` | No | 1 |
+| Compaction | `onSummarize` | No | 2 |
## BLOCKING HOOKS (8)
@@ -78,7 +78,7 @@ hooks/
## EXECUTION ORDER
**UserPromptSubmit**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork
-**PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → writeExistingFileGuard → atlasHook
+**PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → writeExistingFileGuard → tasksToDoWriteDisabler → atlasHook
**PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo → taskReminder
## HOW TO ADD
diff --git a/src/hooks/agent-usage-reminder/constants.ts b/src/hooks/agent-usage-reminder/constants.ts
index 17be086d..d49b92b5 100644
--- a/src/hooks/agent-usage-reminder/constants.ts
+++ b/src/hooks/agent-usage-reminder/constants.ts
@@ -1,7 +1,5 @@
import { join } from "node:path";
-import { getOpenCodeStorageDir } from "../../shared/data-path";
-
-export const OPENCODE_STORAGE = getOpenCodeStorageDir();
+import { OPENCODE_STORAGE } from "../../shared";
export const AGENT_USAGE_REMINDER_STORAGE = join(
OPENCODE_STORAGE,
"agent-usage-reminder",
diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts
index 709cb0db..29a8d394 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts
@@ -25,12 +25,13 @@ export async function runAggressiveTruncationStrategy(params: {
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
})
- const aggressiveResult = truncateUntilTargetTokens(
+ const aggressiveResult = await truncateUntilTargetTokens(
params.sessionID,
params.currentTokens,
params.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken,
+ params.client,
)
if (aggressiveResult.truncatedCount <= 0) {
@@ -60,7 +61,7 @@ export async function runAggressiveTruncationStrategy(params: {
clearSessionState(params.autoCompactState, params.sessionID)
setTimeout(async () => {
try {
- await params.client.session.prompt_async({
+ await params.client.session.promptAsync({
path: { id: params.sessionID },
body: { auto: true } as never,
query: { directory: params.directory },
diff --git a/src/hooks/anthropic-context-window-limit-recovery/client.ts b/src/hooks/anthropic-context-window-limit-recovery/client.ts
index 13bef9ae..0ecaa263 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/client.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/client.ts
@@ -1,20 +1,8 @@
-export type Client = {
+import type { PluginInput } from "@opencode-ai/plugin"
+
+export type Client = PluginInput["client"] & {
session: {
- messages: (opts: {
- path: { id: string }
- query?: { directory?: string }
- }) => Promise
- summarize: (opts: {
- path: { id: string }
- body: { providerID: string; modelID: string }
- query: { directory: string }
- }) => Promise
- revert: (opts: {
- path: { id: string }
- body: { messageID: string; partID?: string }
- query: { directory: string }
- }) => Promise
- prompt_async: (opts: {
+ promptAsync: (opts: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }
query: { directory: string }
diff --git a/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts b/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts
index d7cb0314..5a76be36 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts
@@ -1,3 +1,4 @@
+import type { PluginInput } from "@opencode-ai/plugin"
import type { ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig } from "../../config"
import type { DeduplicationConfig } from "./pruning-deduplication"
@@ -6,6 +7,8 @@ import { executeDeduplication } from "./pruning-deduplication"
import { truncateToolOutputsByCallId } from "./pruning-tool-output-truncation"
import { log } from "../../shared/logger"
+type OpencodeClient = PluginInput["client"]
+
function createPruningState(): PruningState {
return {
toolIdsToPrune: new Set(),
@@ -43,6 +46,7 @@ export async function attemptDeduplicationRecovery(
sessionID: string,
parsed: ParsedTokenLimitError,
experimental: ExperimentalConfig | undefined,
+ client?: OpencodeClient,
): Promise {
if (!isPromptTooLongError(parsed)) return
@@ -50,15 +54,17 @@ export async function attemptDeduplicationRecovery(
if (!plan) return
const pruningState = createPruningState()
- const prunedCount = executeDeduplication(
+ const prunedCount = await executeDeduplication(
sessionID,
pruningState,
plan.config,
plan.protectedTools,
+ client,
)
- const { truncatedCount } = truncateToolOutputsByCallId(
+ const { truncatedCount } = await truncateToolOutputsByCallId(
sessionID,
pruningState.toolIdsToPrune,
+ client,
)
if (prunedCount > 0 || truncatedCount > 0) {
diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts
new file mode 100644
index 00000000..e7d0e8ee
--- /dev/null
+++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts
@@ -0,0 +1,166 @@
+import { describe, it, expect, mock, beforeEach } from "bun:test"
+import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
+
+const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false))
+const mockInjectTextPart = mock(() => Promise.resolve(false))
+
+mock.module("../session-recovery/storage/empty-text", () => ({
+ replaceEmptyTextPartsAsync: mockReplaceEmptyTextParts,
+}))
+mock.module("../session-recovery/storage/text-part-injector", () => ({
+ injectTextPartAsync: mockInjectTextPart,
+}))
+
+function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) {
+ return {
+ session: {
+ messages: mock(() => Promise.resolve({ data: messages })),
+ },
+ } as never
+}
+
+describe("fixEmptyMessagesWithSDK", () => {
+ beforeEach(() => {
+ mockReplaceEmptyTextParts.mockReset()
+ mockInjectTextPart.mockReset()
+ mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
+ mockInjectTextPart.mockReturnValue(Promise.resolve(false))
+ })
+
+ it("returns fixed=false when no empty messages exist", async () => {
+ //#given
+ const client = createMockClient([
+ { info: { id: "msg_1" }, parts: [{ type: "text", text: "Hello" }] },
+ ])
+
+ //#when
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: "ses_1",
+ client,
+ placeholderText: "[recovered]",
+ })
+
+ //#then
+ expect(result.fixed).toBe(false)
+ expect(result.fixedMessageIds).toEqual([])
+ expect(result.scannedEmptyCount).toBe(0)
+ })
+
+ it("fixes empty message via replace when scanning all", async () => {
+ //#given
+ const client = createMockClient([
+ { info: { id: "msg_1" }, parts: [{ type: "text", text: "" }] },
+ ])
+ mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
+
+ //#when
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: "ses_1",
+ client,
+ placeholderText: "[recovered]",
+ })
+
+ //#then
+ expect(result.fixed).toBe(true)
+ expect(result.fixedMessageIds).toContain("msg_1")
+ expect(result.scannedEmptyCount).toBe(1)
+ })
+
+ it("falls back to inject when replace fails", async () => {
+ //#given
+ const client = createMockClient([
+ { info: { id: "msg_1" }, parts: [] },
+ ])
+ mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
+ mockInjectTextPart.mockReturnValue(Promise.resolve(true))
+
+ //#when
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: "ses_1",
+ client,
+ placeholderText: "[recovered]",
+ })
+
+ //#then
+ expect(result.fixed).toBe(true)
+ expect(result.fixedMessageIds).toContain("msg_1")
+ })
+
+ it("fixes target message by index when provided", async () => {
+ //#given
+ const client = createMockClient([
+ { info: { id: "msg_0" }, parts: [{ type: "text", text: "ok" }] },
+ { info: { id: "msg_1" }, parts: [] },
+ ])
+ mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
+
+ //#when
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: "ses_1",
+ client,
+ placeholderText: "[recovered]",
+ messageIndex: 1,
+ })
+
+ //#then
+ expect(result.fixed).toBe(true)
+ expect(result.fixedMessageIds).toContain("msg_1")
+ expect(result.scannedEmptyCount).toBe(0)
+ })
+
+ it("skips messages without info.id", async () => {
+ //#given
+ const client = createMockClient([
+ { parts: [] },
+ { info: {}, parts: [] },
+ ])
+
+ //#when
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: "ses_1",
+ client,
+ placeholderText: "[recovered]",
+ })
+
+ //#then
+ expect(result.fixed).toBe(false)
+ expect(result.scannedEmptyCount).toBe(0)
+ })
+
+ it("treats thinking-only messages as empty", async () => {
+ //#given
+ const client = createMockClient([
+ { info: { id: "msg_1" }, parts: [{ type: "thinking", text: "hmm" }] },
+ ])
+ mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
+
+ //#when
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: "ses_1",
+ client,
+ placeholderText: "[recovered]",
+ })
+
+ //#then
+ expect(result.fixed).toBe(true)
+ expect(result.fixedMessageIds).toContain("msg_1")
+ })
+
+ it("treats tool_use messages as non-empty", async () => {
+ //#given
+ const client = createMockClient([
+ { info: { id: "msg_1" }, parts: [{ type: "tool_use" }] },
+ ])
+
+ //#when
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: "ses_1",
+ client,
+ placeholderText: "[recovered]",
+ })
+
+ //#then
+ expect(result.fixed).toBe(false)
+ expect(result.scannedEmptyCount).toBe(0)
+ })
+})
diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts
new file mode 100644
index 00000000..f95a0b51
--- /dev/null
+++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts
@@ -0,0 +1,194 @@
+import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
+import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
+import type { Client } from "./client"
+
+interface SDKPart {
+ id?: string
+ type?: string
+ text?: string
+}
+
+interface SDKMessage {
+ info?: { id?: string }
+ parts?: SDKPart[]
+}
+
+const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
+const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
+
+function messageHasContentFromSDK(message: SDKMessage): boolean {
+ const parts = message.parts
+ if (!parts || parts.length === 0) return false
+
+ let hasIgnoredParts = false
+
+ for (const part of parts) {
+ const type = part.type
+ if (!type) continue
+ if (IGNORE_TYPES.has(type)) {
+ hasIgnoredParts = true
+ continue
+ }
+
+ if (type === "text") {
+ if (part.text?.trim()) return true
+ continue
+ }
+
+ if (TOOL_TYPES.has(type)) return true
+
+ return true
+ }
+
+ // Messages with only thinking/meta parts are treated as empty
+ // to align with file-based logic (messageHasContent)
+ return false
+}
+
+function getSdkMessages(response: unknown): SDKMessage[] {
+ if (typeof response !== "object" || response === null) return []
+ if (Array.isArray(response)) return response as SDKMessage[]
+ const record = response as Record
+ const data = record["data"]
+ if (Array.isArray(data)) return data as SDKMessage[]
+ return Array.isArray(record) ? (record as SDKMessage[]) : []
+}
+
+async function findEmptyMessagesFromSDK(client: Client, sessionID: string): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = getSdkMessages(response)
+
+ const emptyIds: string[] = []
+ for (const message of messages) {
+ const messageID = message.info?.id
+ if (!messageID) continue
+ if (!messageHasContentFromSDK(message)) {
+ emptyIds.push(messageID)
+ }
+ }
+
+ return emptyIds
+ } catch {
+ return []
+ }
+}
+
+async function findEmptyMessageByIndexFromSDK(
+ client: Client,
+ sessionID: string,
+ targetIndex: number,
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = getSdkMessages(response)
+
+ const indicesToTry = [
+ targetIndex,
+ targetIndex - 1,
+ targetIndex + 1,
+ targetIndex - 2,
+ targetIndex + 2,
+ targetIndex - 3,
+ targetIndex - 4,
+ targetIndex - 5,
+ ]
+
+ for (const index of indicesToTry) {
+ if (index < 0 || index >= messages.length) continue
+
+ const targetMessage = messages[index]
+ const targetMessageId = targetMessage?.info?.id
+ if (!targetMessageId) continue
+
+ if (!messageHasContentFromSDK(targetMessage)) {
+ return targetMessageId
+ }
+ }
+
+ return null
+ } catch {
+ return null
+ }
+}
+
+export async function fixEmptyMessagesWithSDK(params: {
+ sessionID: string
+ client: Client
+ placeholderText: string
+ messageIndex?: number
+}): Promise<{ fixed: boolean; fixedMessageIds: string[]; scannedEmptyCount: number }> {
+ let fixed = false
+ const fixedMessageIds: string[] = []
+
+ if (params.messageIndex !== undefined) {
+ const targetMessageId = await findEmptyMessageByIndexFromSDK(
+ params.client,
+ params.sessionID,
+ params.messageIndex,
+ )
+
+ if (targetMessageId) {
+ const replaced = await replaceEmptyTextPartsAsync(
+ params.client,
+ params.sessionID,
+ targetMessageId,
+ params.placeholderText,
+ )
+
+ if (replaced) {
+ fixed = true
+ fixedMessageIds.push(targetMessageId)
+ } else {
+ const injected = await injectTextPartAsync(
+ params.client,
+ params.sessionID,
+ targetMessageId,
+ params.placeholderText,
+ )
+
+ if (injected) {
+ fixed = true
+ fixedMessageIds.push(targetMessageId)
+ }
+ }
+ }
+ }
+
+ if (fixed) {
+ return { fixed, fixedMessageIds, scannedEmptyCount: 0 }
+ }
+
+ const emptyMessageIds = await findEmptyMessagesFromSDK(params.client, params.sessionID)
+ if (emptyMessageIds.length === 0) {
+ return { fixed: false, fixedMessageIds: [], scannedEmptyCount: 0 }
+ }
+
+ for (const messageID of emptyMessageIds) {
+ const replaced = await replaceEmptyTextPartsAsync(
+ params.client,
+ params.sessionID,
+ messageID,
+ params.placeholderText,
+ )
+
+ if (replaced) {
+ fixed = true
+ fixedMessageIds.push(messageID)
+ } else {
+ const injected = await injectTextPartAsync(
+ params.client,
+ params.sessionID,
+ messageID,
+ params.placeholderText,
+ )
+
+ if (injected) {
+ fixed = true
+ fixedMessageIds.push(messageID)
+ }
+ }
+ }
+
+ return { fixed, fixedMessageIds, scannedEmptyCount: emptyMessageIds.length }
+}
diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts
index 140d98aa..f6f407e8 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts
@@ -4,10 +4,12 @@ import {
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import type { AutoCompactState } from "./types"
import type { Client } from "./client"
import { PLACEHOLDER_TEXT } from "./message-builder"
import { incrementEmptyContentAttempt } from "./state"
+import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
export async function fixEmptyMessages(params: {
sessionID: string
@@ -20,6 +22,44 @@ export async function fixEmptyMessages(params: {
let fixed = false
const fixedMessageIds: string[] = []
+ if (isSqliteBackend()) {
+ const result = await fixEmptyMessagesWithSDK({
+ sessionID: params.sessionID,
+ client: params.client,
+ placeholderText: PLACEHOLDER_TEXT,
+ messageIndex: params.messageIndex,
+ })
+
+ if (!result.fixed && result.scannedEmptyCount === 0) {
+ await params.client.tui
+ .showToast({
+ body: {
+ title: "Empty Content Error",
+ message: "No empty messages found in storage. Cannot auto-recover.",
+ variant: "error",
+ duration: 5000,
+ },
+ })
+ .catch(() => {})
+ return false
+ }
+
+ if (result.fixed) {
+ await params.client.tui
+ .showToast({
+ body: {
+ title: "Session Recovery",
+ message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`,
+ variant: "warning",
+ duration: 3000,
+ },
+ })
+ .catch(() => {})
+ }
+
+ return result.fixed
+ }
+
if (params.messageIndex !== undefined) {
const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex)
if (targetMessageId) {
diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts
index aa1fea43..8efb76de 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts
@@ -99,7 +99,7 @@ describe("executeCompact lock management", () => {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
- prompt_async: mock(() => Promise.resolve()),
+ promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
@@ -283,9 +283,9 @@ describe("executeCompact lock management", () => {
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
- test("clears lock when prompt_async in continuation throws", async () => {
- // given: prompt_async will fail during continuation
- mockClient.session.prompt_async = mock(() =>
+ test("clears lock when promptAsync in continuation throws", async () => {
+ // given: promptAsync will fail during continuation
+ mockClient.session.promptAsync = mock(() =>
Promise.reject(new Error("Prompt failed")),
)
autoCompactState.errorDataBySession.set(sessionID, {
@@ -313,7 +313,7 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
- const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
+ const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({
success: true,
sufficient: false,
truncatedCount: 3,
@@ -354,7 +354,7 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
- const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
+ const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({
success: true,
sufficient: true,
truncatedCount: 5,
@@ -378,8 +378,8 @@ describe("executeCompact lock management", () => {
// then: Summarize should NOT be called (early return from sufficient truncation)
expect(mockClient.session.summarize).not.toHaveBeenCalled()
- // then: prompt_async should be called (Continue after successful truncation)
- expect(mockClient.session.prompt_async).toHaveBeenCalled()
+ // then: promptAsync should be called (Continue after successful truncation)
+ expect(mockClient.session.promptAsync).toHaveBeenCalled()
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts
index cb600ca2..17f24220 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts
@@ -1,14 +1,121 @@
import { log } from "../../shared/logger"
+import type { PluginInput } from "@opencode-ai/plugin"
+import { normalizeSDKResponse } from "../../shared"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import {
findEmptyMessages,
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
+import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
+import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
import type { Client } from "./client"
export const PLACEHOLDER_TEXT = "[user interrupted]"
-export function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
+type OpencodeClient = PluginInput["client"]
+
+interface SDKPart {
+ type?: string
+ text?: string
+}
+
+interface SDKMessage {
+ info?: { id?: string }
+ parts?: SDKPart[]
+}
+
+const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
+const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
+
+function messageHasContentFromSDK(message: SDKMessage): boolean {
+ const parts = message.parts
+ if (!parts || parts.length === 0) return false
+
+ let hasIgnoredParts = false
+
+ for (const part of parts) {
+ const type = part.type
+ if (!type) continue
+ if (IGNORE_TYPES.has(type)) {
+ hasIgnoredParts = true
+ continue
+ }
+
+ if (type === "text") {
+ if (part.text?.trim()) return true
+ continue
+ }
+
+ if (TOOL_TYPES.has(type)) return true
+
+ return true
+ }
+
+ // Messages with only thinking/meta parts are treated as empty
+ // to align with file-based logic (messageHasContent)
+ return false
+}
+
+async function findEmptyMessageIdsFromSDK(
+ client: OpencodeClient,
+ sessionID: string,
+): Promise {
+ try {
+ const response = (await client.session.messages({
+ path: { id: sessionID },
+ })) as { data?: SDKMessage[] }
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+
+ const emptyIds: string[] = []
+ for (const message of messages) {
+ const messageID = message.info?.id
+ if (!messageID) continue
+ if (!messageHasContentFromSDK(message)) {
+ emptyIds.push(messageID)
+ }
+ }
+
+ return emptyIds
+ } catch {
+ return []
+ }
+}
+
+export async function sanitizeEmptyMessagesBeforeSummarize(
+ sessionID: string,
+ client?: OpencodeClient,
+): Promise {
+ if (client && isSqliteBackend()) {
+ const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID)
+ if (emptyMessageIds.length === 0) {
+ return 0
+ }
+
+ let fixedCount = 0
+ for (const messageID of emptyMessageIds) {
+ const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
+ if (replaced) {
+ fixedCount++
+ } else {
+ const injected = await injectTextPartAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
+ if (injected) {
+ fixedCount++
+ }
+ }
+ }
+
+ if (fixedCount > 0) {
+ log("[auto-compact] pre-summarize sanitization fixed empty messages", {
+ sessionID,
+ fixedCount,
+ totalEmpty: emptyMessageIds.length,
+ })
+ }
+
+ return fixedCount
+ }
+
const emptyMessageIds = findEmptyMessages(sessionID)
if (emptyMessageIds.length === 0) {
return 0
diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts
index 249e4644..f4a7e576 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts
@@ -1,36 +1,40 @@
import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
+import { getMessageDir } from "../../shared/opencode-message-dir"
+import { normalizeSDKResponse } from "../../shared"
-import { MESSAGE_STORAGE_DIR } from "./storage-paths"
+export { getMessageDir }
-export function getMessageDir(sessionID: string): string {
- if (!existsSync(MESSAGE_STORAGE_DIR)) return ""
+type OpencodeClient = PluginInput["client"]
- const directPath = join(MESSAGE_STORAGE_DIR, sessionID)
- if (existsSync(directPath)) {
- return directPath
- }
+interface SDKMessage {
+ info: { id: string }
+ parts: unknown[]
+}
- for (const directory of readdirSync(MESSAGE_STORAGE_DIR)) {
- const sessionPath = join(MESSAGE_STORAGE_DIR, directory, sessionID)
- if (existsSync(sessionPath)) {
- return sessionPath
- }
- }
-
- return ""
+export async function getMessageIdsFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+ return messages.map(msg => msg.info.id)
+ } catch {
+ return []
+ }
}
export function getMessageIds(sessionID: string): string[] {
- const messageDir = getMessageDir(sessionID)
- if (!messageDir || !existsSync(messageDir)) return []
+ const messageDir = getMessageDir(sessionID)
+ if (!messageDir || !existsSync(messageDir)) return []
- const messageIds: string[] = []
- for (const file of readdirSync(messageDir)) {
- if (!file.endsWith(".json")) continue
- const messageId = file.replace(".json", "")
- messageIds.push(messageId)
- }
+ const messageIds: string[] = []
+ for (const file of readdirSync(messageDir)) {
+ if (!file.endsWith(".json")) continue
+ const messageId = file.replace(".json", "")
+ messageIds.push(messageId)
+ }
- return messageIds
+ return messageIds
}
diff --git a/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts b/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts
index b3e8b520..ef1a761c 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts
@@ -1,9 +1,14 @@
-import { existsSync, readdirSync, readFileSync } from "node:fs"
+import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import type { PruningState, ToolCallSignature } from "./pruning-types"
import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger"
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
+import { getMessageDir } from "../../shared/opencode-message-dir"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { normalizeSDKResponse } from "../../shared"
+
+type OpencodeClient = PluginInput["client"]
export interface DeduplicationConfig {
enabled: boolean
@@ -43,20 +48,6 @@ function sortObject(obj: unknown): unknown {
return sorted
}
-function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
-
function readMessages(sessionID: string): MessagePart[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return []
@@ -64,7 +55,7 @@ function readMessages(sessionID: string): MessagePart[] {
const messages: MessagePart[] = []
try {
- const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
+ const files = readdirSync(messageDir).filter((f: string) => f.endsWith(".json"))
for (const file of files) {
const content = readFileSync(join(messageDir, file), "utf-8")
const data = JSON.parse(content)
@@ -79,15 +70,29 @@ function readMessages(sessionID: string): MessagePart[] {
return messages
}
-export function executeDeduplication(
+async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const rawMessages = normalizeSDKResponse(response, [] as Array<{ parts?: ToolPart[] }>, { preferResponseOnMissingData: true })
+ return rawMessages.filter((m) => m.parts) as MessagePart[]
+ } catch {
+ return []
+ }
+}
+
+export async function executeDeduplication(
sessionID: string,
state: PruningState,
config: DeduplicationConfig,
- protectedTools: Set
-): number {
+ protectedTools: Set,
+ client?: OpencodeClient,
+): Promise {
if (!config.enabled) return 0
- const messages = readMessages(sessionID)
+ const messages = (client && isSqliteBackend())
+ ? await readMessagesFromSDK(client, sessionID)
+ : readMessages(sessionID)
+
const signatures = new Map()
let currentTurn = 0
diff --git a/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts
index 0481e94c..4c3741aa 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts
@@ -1,8 +1,15 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { getOpenCodeStorageDir } from "../../shared/data-path"
import { truncateToolResult } from "./storage"
+import { truncateToolResultAsync } from "./tool-result-storage-sdk"
import { log } from "../../shared/logger"
+import { getMessageDir } from "../../shared/opencode-message-dir"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { normalizeSDKResponse } from "../../shared"
+
+type OpencodeClient = PluginInput["client"]
interface StoredToolPart {
type?: string
@@ -13,29 +20,23 @@ interface StoredToolPart {
}
}
-function getMessageStorage(): string {
- return join(getOpenCodeStorageDir(), "message")
+interface SDKToolPart {
+ id: string
+ type: string
+ callID?: string
+ tool?: string
+ state?: { output?: string; time?: { compacted?: number } }
+}
+
+interface SDKMessage {
+ info?: { id?: string }
+ parts?: SDKToolPart[]
}
function getPartStorage(): string {
return join(getOpenCodeStorageDir(), "part")
}
-function getMessageDir(sessionID: string): string | null {
- const messageStorage = getMessageStorage()
- if (!existsSync(messageStorage)) return null
-
- const directPath = join(messageStorage, sessionID)
- if (existsSync(directPath)) return directPath
-
- for (const dir of readdirSync(messageStorage)) {
- const sessionPath = join(messageStorage, dir, sessionID)
- if (existsSync(sessionPath)) return sessionPath
- }
-
- return null
-}
-
function getMessageIds(sessionID: string): string[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return []
@@ -49,12 +50,17 @@ function getMessageIds(sessionID: string): string[] {
return messageIds
}
-export function truncateToolOutputsByCallId(
+export async function truncateToolOutputsByCallId(
sessionID: string,
callIds: Set,
-): { truncatedCount: number } {
+ client?: OpencodeClient,
+): Promise<{ truncatedCount: number }> {
if (callIds.size === 0) return { truncatedCount: 0 }
+ if (client && isSqliteBackend()) {
+ return truncateToolOutputsByCallIdFromSDK(client, sessionID, callIds)
+ }
+
const messageIds = getMessageIds(sessionID)
if (messageIds.length === 0) return { truncatedCount: 0 }
@@ -95,3 +101,42 @@ export function truncateToolOutputsByCallId(
return { truncatedCount }
}
+
+async function truncateToolOutputsByCallIdFromSDK(
+ client: OpencodeClient,
+ sessionID: string,
+ callIds: Set,
+): Promise<{ truncatedCount: number }> {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+ let truncatedCount = 0
+
+ for (const msg of messages) {
+ const messageID = msg.info?.id
+ if (!messageID || !msg.parts) continue
+
+ for (const part of msg.parts) {
+ if (part.type !== "tool" || !part.callID) continue
+ if (!callIds.has(part.callID)) continue
+ if (!part.state?.output || part.state?.time?.compacted) continue
+
+ const result = await truncateToolResultAsync(client, sessionID, messageID, part.id, part)
+ if (result.success) {
+ truncatedCount++
+ }
+ }
+ }
+
+ if (truncatedCount > 0) {
+ log("[auto-compact] pruned duplicate tool outputs (SDK)", {
+ sessionID,
+ truncatedCount,
+ })
+ }
+
+ return { truncatedCount }
+ } catch {
+ return { truncatedCount: 0 }
+ }
+}
diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts
index 2e877277..65db7298 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts
@@ -53,7 +53,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => summarizePromise),
revert: mock(() => Promise.resolve()),
- prompt_async: mock(() => Promise.resolve()),
+ promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
@@ -97,7 +97,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
- prompt_async: mock(() => Promise.resolve()),
+ promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts
new file mode 100644
index 00000000..610c21a4
--- /dev/null
+++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts
@@ -0,0 +1,105 @@
+import { beforeEach, describe, expect, mock, test } from "bun:test"
+import type { PluginInput } from "@opencode-ai/plugin"
+
+const executeCompactMock = mock(async () => {})
+const getLastAssistantMock = mock(async () => ({
+ providerID: "anthropic",
+ modelID: "claude-sonnet-4-5",
+}))
+const parseAnthropicTokenLimitErrorMock = mock(() => ({
+ providerID: "anthropic",
+ modelID: "claude-sonnet-4-5",
+}))
+
+mock.module("./executor", () => ({
+ executeCompact: executeCompactMock,
+ getLastAssistant: getLastAssistantMock,
+}))
+
+mock.module("./parser", () => ({
+ parseAnthropicTokenLimitError: parseAnthropicTokenLimitErrorMock,
+}))
+
+mock.module("../../shared/logger", () => ({
+ log: () => {},
+}))
+
+function createMockContext(): PluginInput {
+ return {
+ client: {
+ session: {
+ messages: mock(() => Promise.resolve({ data: [] })),
+ },
+ tui: {
+ showToast: mock(() => Promise.resolve()),
+ },
+ },
+ directory: "/tmp",
+ } as PluginInput
+}
+
+function setupDelayedTimeoutMocks(): {
+ restore: () => void
+ getClearTimeoutCalls: () => Array>
+} {
+ const originalSetTimeout = globalThis.setTimeout
+ const originalClearTimeout = globalThis.clearTimeout
+ const clearTimeoutCalls: Array> = []
+ let timeoutCounter = 0
+
+ globalThis.setTimeout = ((_: () => void, _delay?: number) => {
+ timeoutCounter += 1
+ return timeoutCounter as ReturnType
+ }) as typeof setTimeout
+
+ globalThis.clearTimeout = ((timeoutID: ReturnType) => {
+ clearTimeoutCalls.push(timeoutID)
+ }) as typeof clearTimeout
+
+ return {
+ restore: () => {
+ globalThis.setTimeout = originalSetTimeout
+ globalThis.clearTimeout = originalClearTimeout
+ },
+ getClearTimeoutCalls: () => clearTimeoutCalls,
+ }
+}
+
+describe("createAnthropicContextWindowLimitRecoveryHook", () => {
+ beforeEach(() => {
+ executeCompactMock.mockClear()
+ getLastAssistantMock.mockClear()
+ parseAnthropicTokenLimitErrorMock.mockClear()
+ })
+
+ test("cancels pending timer when session.idle handles compaction first", async () => {
+ //#given
+ const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
+ const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
+ const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
+
+ try {
+ //#when
+ await hook.event({
+ event: {
+ type: "session.error",
+ properties: { sessionID: "session-race", error: "prompt is too long" },
+ },
+ })
+
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-race" },
+ },
+ })
+
+ //#then
+ expect(getClearTimeoutCalls()).toEqual([1 as ReturnType])
+ expect(executeCompactMock).toHaveBeenCalledTimes(1)
+ expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race")
+ } finally {
+ restore()
+ }
+ })
+})
diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts
index f4bcb0f2..e7064b4f 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts
@@ -28,6 +28,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
) {
const autoCompactState = createRecoveryState()
const experimental = options?.experimental
+ const pendingCompactionTimeoutBySession = new Map>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record | undefined
@@ -35,6 +36,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
+ const timeoutID = pendingCompactionTimeoutBySession.get(sessionInfo.id)
+ if (timeoutID !== undefined) {
+ clearTimeout(timeoutID)
+ pendingCompactionTimeoutBySession.delete(sessionInfo.id)
+ }
+
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id)
@@ -57,7 +64,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
autoCompactState.errorDataBySession.set(sessionID, parsed)
if (autoCompactState.compactionInProgress.has(sessionID)) {
- await attemptDeduplicationRecovery(sessionID, parsed, experimental)
+ await attemptDeduplicationRecovery(sessionID, parsed, experimental, ctx.client)
return
}
@@ -76,7 +83,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
})
.catch(() => {})
- setTimeout(() => {
+ const timeoutID = setTimeout(() => {
+ pendingCompactionTimeoutBySession.delete(sessionID)
executeCompact(
sessionID,
{ providerID, modelID },
@@ -86,6 +94,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
experimental,
)
}, 300)
+
+ pendingCompactionTimeoutBySession.set(sessionID, timeoutID)
}
return
}
@@ -114,6 +124,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (!autoCompactState.pendingCompact.has(sessionID)) return
+ const timeoutID = pendingCompactionTimeoutBySession.get(sessionID)
+ if (timeoutID !== undefined) {
+ clearTimeout(timeoutID)
+ pendingCompactionTimeoutBySession.delete(sessionID)
+ }
+
const errorData = autoCompactState.errorDataBySession.get(sessionID)
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts b/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts
index 95825a0a..249603fa 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts
@@ -1,10 +1,6 @@
-import { join } from "node:path"
-import { getOpenCodeStorageDir } from "../../shared/data-path"
+import { MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
-const OPENCODE_STORAGE_DIR = getOpenCodeStorageDir()
-
-export const MESSAGE_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "message")
-export const PART_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "part")
+export { MESSAGE_STORAGE as MESSAGE_STORAGE_DIR, PART_STORAGE as PART_STORAGE_DIR }
export const TRUNCATION_MESSAGE =
"[TOOL RESULT TRUNCATED - Context limit exceeded. Original output was too large and has been truncated to recover the session. Please re-run this tool if you need the full output.]"
diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts
index d5797590..ffe1fabc 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts
@@ -21,7 +21,7 @@ describe("truncateUntilTargetTokens", () => {
truncateToolResult.mockReset()
})
- test("truncates only until target is reached", () => {
+ test("truncates only until target is reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// given: Two tool results, each 1000 chars. Target reduction is 500 chars.
@@ -39,7 +39,7 @@ describe("truncateUntilTargetTokens", () => {
// when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// charsPerToken=1 for simplicity in test
- const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
+ const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// then: Should only truncate the first tool
expect(result.truncatedCount).toBe(1)
@@ -49,7 +49,7 @@ describe("truncateUntilTargetTokens", () => {
expect(result.sufficient).toBe(true)
})
- test("truncates all if target not reached", () => {
+ test("truncates all if target not reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// given: Two tool results, each 100 chars. Target reduction is 500 chars.
@@ -66,7 +66,7 @@ describe("truncateUntilTargetTokens", () => {
}))
// when: reduce 500 chars
- const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
+ const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// then: Should truncate both
expect(result.truncatedCount).toBe(2)
diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.ts
index 3cd302c8..2f2136fd 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/storage.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/storage.ts
@@ -8,4 +8,11 @@ export {
truncateToolResult,
} from "./tool-result-storage"
+export {
+ countTruncatedResultsFromSDK,
+ findToolResultsBySizeFromSDK,
+ getTotalToolOutputSizeFromSDK,
+ truncateToolResultAsync,
+} from "./tool-result-storage-sdk"
+
export { truncateUntilTargetTokens } from "./target-token-truncation"
diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts
index 41db33d0..7c57c841 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts
@@ -61,7 +61,7 @@ export async function runSummarizeRetryStrategy(params: {
if (providerID && modelID) {
try {
- sanitizeEmptyMessagesBeforeSummarize(params.sessionID)
+ await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client)
await params.client.tui
.showToast({
diff --git a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts
index 6e5ea6c2..f7d8dff9 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts
@@ -1,5 +1,27 @@
+import type { PluginInput } from "@opencode-ai/plugin"
import type { AggressiveTruncateResult } from "./tool-part-types"
import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage"
+import { truncateToolResultAsync } from "./tool-result-storage-sdk"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { normalizeSDKResponse } from "../../shared"
+
+type OpencodeClient = PluginInput["client"]
+
+interface SDKToolPart {
+ id: string
+ type: string
+ tool?: string
+ state?: {
+ output?: string
+ time?: { start?: number; end?: number; compacted?: number }
+ }
+ originalSize?: number
+}
+
+interface SDKMessage {
+ info?: { id?: string }
+ parts?: SDKToolPart[]
+}
function calculateTargetBytesToRemove(
currentTokens: number,
@@ -13,13 +35,14 @@ function calculateTargetBytesToRemove(
return { tokensToReduce, targetBytesToRemove }
}
-export function truncateUntilTargetTokens(
+export async function truncateUntilTargetTokens(
sessionID: string,
currentTokens: number,
maxTokens: number,
targetRatio: number = 0.8,
- charsPerToken: number = 4
-): AggressiveTruncateResult {
+ charsPerToken: number = 4,
+ client?: OpencodeClient
+): Promise {
const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove(
currentTokens,
maxTokens,
@@ -38,6 +61,94 @@ export function truncateUntilTargetTokens(
}
}
+ if (client && isSqliteBackend()) {
+ let toolPartsByKey = new Map()
+ try {
+ const response = (await client.session.messages({
+ path: { id: sessionID },
+ })) as { data?: SDKMessage[] }
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+ toolPartsByKey = new Map()
+
+ for (const message of messages) {
+ const messageID = message.info?.id
+ if (!messageID || !message.parts) continue
+ for (const part of message.parts) {
+ if (part.type !== "tool") continue
+ toolPartsByKey.set(`${messageID}:${part.id}`, part)
+ }
+ }
+ } catch {
+ toolPartsByKey = new Map()
+ }
+
+ const results: import("./tool-part-types").ToolResultInfo[] = []
+ for (const [key, part] of toolPartsByKey) {
+ if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) {
+ results.push({
+ partPath: "",
+ partId: part.id,
+ messageID: key.split(":")[0],
+ toolName: part.tool,
+ outputSize: part.state.output.length,
+ })
+ }
+ }
+ results.sort((a, b) => b.outputSize - a.outputSize)
+
+ if (results.length === 0) {
+ return {
+ success: false,
+ sufficient: false,
+ truncatedCount: 0,
+ totalBytesRemoved: 0,
+ targetBytesToRemove,
+ truncatedTools: [],
+ }
+ }
+
+ let totalRemoved = 0
+ let truncatedCount = 0
+ const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
+
+ for (const result of results) {
+ const part = toolPartsByKey.get(`${result.messageID}:${result.partId}`)
+ if (!part) continue
+
+ const truncateResult = await truncateToolResultAsync(
+ client,
+ sessionID,
+ result.messageID,
+ result.partId,
+ part
+ )
+ if (truncateResult.success) {
+ truncatedCount++
+ const removedSize = truncateResult.originalSize ?? result.outputSize
+ totalRemoved += removedSize
+ truncatedTools.push({
+ toolName: truncateResult.toolName ?? result.toolName,
+ originalSize: removedSize,
+ })
+
+ if (totalRemoved >= targetBytesToRemove) {
+ break
+ }
+ }
+ }
+
+ const sufficient = totalRemoved >= targetBytesToRemove
+
+ return {
+ success: truncatedCount > 0,
+ sufficient,
+ truncatedCount,
+ totalBytesRemoved: totalRemoved,
+ targetBytesToRemove,
+ truncatedTools,
+ }
+ }
+
const results = findToolResultsBySize(sessionID)
if (results.length === 0) {
diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts
new file mode 100644
index 00000000..c163a636
--- /dev/null
+++ b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts
@@ -0,0 +1,124 @@
+import type { PluginInput } from "@opencode-ai/plugin"
+import { getMessageIdsFromSDK } from "./message-storage-directory"
+import { TRUNCATION_MESSAGE } from "./storage-paths"
+import type { ToolResultInfo } from "./tool-part-types"
+import { patchPart } from "../../shared/opencode-http-api"
+import { log } from "../../shared/logger"
+import { normalizeSDKResponse } from "../../shared"
+
+type OpencodeClient = PluginInput["client"]
+
+interface SDKToolPart {
+ id: string
+ type: string
+ callID?: string
+ tool?: string
+ state?: {
+ status?: string
+ input?: Record
+ output?: string
+ error?: string
+ time?: { start?: number; end?: number; compacted?: number }
+ }
+}
+
+interface SDKMessage {
+ info?: { id?: string }
+ parts?: SDKToolPart[]
+}
+
+export async function findToolResultsBySizeFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+ const results: ToolResultInfo[] = []
+
+ for (const msg of messages) {
+ const messageID = msg.info?.id
+ if (!messageID || !msg.parts) continue
+
+ for (const part of msg.parts) {
+ if (part.type === "tool" && part.state?.output && !part.state?.time?.compacted && part.tool) {
+ results.push({
+ partPath: "",
+ partId: part.id,
+ messageID,
+ toolName: part.tool,
+ outputSize: part.state.output.length,
+ })
+ }
+ }
+ }
+
+ return results.sort((a, b) => b.outputSize - a.outputSize)
+ } catch {
+ return []
+ }
+}
+
+export async function truncateToolResultAsync(
+ client: OpencodeClient,
+ sessionID: string,
+ messageID: string,
+ partId: string,
+ part: SDKToolPart
+): Promise<{ success: boolean; toolName?: string; originalSize?: number }> {
+ if (!part.state?.output) return { success: false }
+
+ const originalSize = part.state.output.length
+ const toolName = part.tool
+
+ const updatedPart: Record = {
+ ...part,
+ state: {
+ ...part.state,
+ output: TRUNCATION_MESSAGE,
+ time: {
+ ...(part.state.time ?? { start: Date.now() }),
+ compacted: Date.now(),
+ },
+ },
+ }
+
+ try {
+ const patched = await patchPart(client, sessionID, messageID, partId, updatedPart)
+ if (!patched) return { success: false }
+ return { success: true, toolName, originalSize }
+ } catch (error) {
+ log("[context-window-recovery] truncateToolResultAsync failed", { error: String(error) })
+ return { success: false }
+ }
+}
+
+export async function countTruncatedResultsFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
+ let count = 0
+
+ for (const msg of messages) {
+ if (!msg.parts) continue
+ for (const part of msg.parts) {
+ if (part.type === "tool" && part.state?.time?.compacted) count++
+ }
+ }
+
+ return count
+ } catch {
+ return 0
+ }
+}
+
+export async function getTotalToolOutputSizeFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ const results = await findToolResultsBySizeFromSDK(client, sessionID)
+ return results.reduce((sum, result) => sum + result.outputSize, 0)
+}
diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts
index 70d9ffa5..c1af7df6 100644
--- a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts
+++ b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts
@@ -4,6 +4,10 @@ import { join } from "node:path"
import { getMessageIds } from "./message-storage-directory"
import { PART_STORAGE_DIR, TRUNCATION_MESSAGE } from "./storage-paths"
import type { StoredToolPart, ToolResultInfo } from "./tool-part-types"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { log } from "../../shared/logger"
+
+let hasLoggedTruncateWarning = false
export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
const messageIds = getMessageIds(sessionID)
@@ -48,6 +52,14 @@ export function truncateToolResult(partPath: string): {
toolName?: string
originalSize?: number
} {
+ if (isSqliteBackend()) {
+ if (!hasLoggedTruncateWarning) {
+ log("[context-window-recovery] Disabled on SQLite backend: truncateToolResult")
+ hasLoggedTruncateWarning = true
+ }
+ return { success: false }
+ }
+
try {
const content = readFileSync(partPath, "utf-8")
const part = JSON.parse(content) as StoredToolPart
diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts
index 5d8c47f4..94a6470e 100644
--- a/src/hooks/atlas/atlas-hook.ts
+++ b/src/hooks/atlas/atlas-hook.ts
@@ -19,7 +19,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
- "tool.execute.before": createToolExecuteBeforeHandler({ pendingFilePaths }),
+ "tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }),
}
}
diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts
index 0857c3db..76a3a500 100644
--- a/src/hooks/atlas/event-handler.ts
+++ b/src/hooks/atlas/event-handler.ts
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
+import { getAgentConfigKey } from "../../shared/agent-display-names"
import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
@@ -87,12 +88,13 @@ export function createAtlasEventHandler(input: {
return
}
- const lastAgent = getLastAgentFromSession(sessionID)
- const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase()
- const lastAgentMatchesRequired = lastAgent === requiredAgent
+ const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
+ const lastAgentKey = getAgentConfigKey(lastAgent ?? "")
+ const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
+ const lastAgentMatchesRequired = lastAgentKey === requiredAgent
const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
- const lastAgentIsSisyphus = lastAgent === "sisyphus"
+ const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
const allowSisyphusWhenDefaultAtlas = boulderAgentWasNotExplicitlySet && boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
const agentMatches = lastAgentMatchesRequired || allowSisyphusWhenDefaultAtlas
if (!agentMatches) {
diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts
index bf46e538..52025857 100644
--- a/src/hooks/atlas/index.test.ts
+++ b/src/hooks/atlas/index.test.ts
@@ -9,10 +9,31 @@ import {
readBoulderState,
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
-
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
-import { createAtlasHook } from "./index"
+
+const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
+const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
+const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
+
+mock.module("../../features/hook-message-injector/constants", () => ({
+ OPENCODE_STORAGE: TEST_STORAGE_ROOT,
+ MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
+ PART_STORAGE: TEST_PART_STORAGE,
+}))
+
+mock.module("../../shared/opencode-message-dir", () => ({
+ getMessageDir: (sessionID: string) => {
+ const dir = join(TEST_MESSAGE_STORAGE, sessionID)
+ return existsSync(dir) ? dir : null
+ },
+}))
+
+mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => false,
+}))
+
+const { createAtlasHook } = await import("./index")
+const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
describe("atlas hook", () => {
let TEST_DIR: string
diff --git a/src/hooks/atlas/recent-model-resolver.ts b/src/hooks/atlas/recent-model-resolver.ts
index 814e6af8..ba6018b2 100644
--- a/src/hooks/atlas/recent-model-resolver.ts
+++ b/src/hooks/atlas/recent-model-resolver.ts
@@ -1,6 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
-import { findNearestMessageWithFields } from "../../features/hook-message-injector"
-import { getMessageDir } from "../../shared/session-utils"
+import {
+ findNearestMessageWithFields,
+ findNearestMessageWithFieldsFromSDK,
+} from "../../features/hook-message-injector"
+import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
import type { ModelInfo } from "./types"
export async function resolveRecentModelForSession(
@@ -9,9 +12,9 @@ export async function resolveRecentModelForSession(
): Promise {
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
- const messages = (messagesResp.data ?? []) as Array<{
+ const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: { model?: ModelInfo; modelID?: string; providerID?: string }
- }>
+ }>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
@@ -28,8 +31,13 @@ export async function resolveRecentModelForSession(
// ignore - fallback to message storage
}
- const messageDir = getMessageDir(sessionID)
- const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
+ let currentMessage = null
+ if (isSqliteBackend()) {
+ currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
+ } else {
+ const messageDir = getMessageDir(sessionID)
+ currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
+ }
const model = currentMessage?.model
if (!model?.providerID || !model?.modelID) {
return undefined
diff --git a/src/hooks/atlas/session-last-agent.ts b/src/hooks/atlas/session-last-agent.ts
index 341eda6f..6ddbbacb 100644
--- a/src/hooks/atlas/session-last-agent.ts
+++ b/src/hooks/atlas/session-last-agent.ts
@@ -1,9 +1,24 @@
-import { findNearestMessageWithFields } from "../../features/hook-message-injector"
-import { getMessageDir } from "../../shared/session-utils"
+import type { PluginInput } from "@opencode-ai/plugin"
+
+import { findNearestMessageWithFields } from "../../features/hook-message-injector"
+import { findNearestMessageWithFieldsFromSDK } from "../../features/hook-message-injector"
+import { getMessageDir, isSqliteBackend } from "../../shared"
+
+type OpencodeClient = PluginInput["client"]
+
+export async function getLastAgentFromSession(
+ sessionID: string,
+ client?: OpencodeClient
+): Promise {
+ let nearest = null
+
+ if (isSqliteBackend() && client) {
+ nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID)
+ } else {
+ const messageDir = getMessageDir(sessionID)
+ if (!messageDir) return null
+ nearest = findNearestMessageWithFields(messageDir)
+ }
-export function getLastAgentFromSession(sessionID: string): string | null {
- const messageDir = getMessageDir(sessionID)
- if (!messageDir) return null
- const nearest = findNearestMessageWithFields(messageDir)
return nearest?.agent?.toLowerCase() ?? null
}
diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts
index f82f3e49..8a7240c4 100644
--- a/src/hooks/atlas/tool-execute-after.ts
+++ b/src/hooks/atlas/tool-execute-after.ts
@@ -23,7 +23,7 @@ export function createToolExecuteAfterHandler(input: {
return
}
- if (!isCallerOrchestrator(toolInput.sessionID)) {
+ if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
return
}
diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts
index 6fb6ba9d..51f67000 100644
--- a/src/hooks/atlas/tool-execute-before.ts
+++ b/src/hooks/atlas/tool-execute-before.ts
@@ -1,21 +1,23 @@
import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { isCallerOrchestrator } from "../../shared/session-utils"
+import type { PluginInput } from "@opencode-ai/plugin"
import { HOOK_NAME } from "./hook-name"
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
export function createToolExecuteBeforeHandler(input: {
+ ctx: PluginInput
pendingFilePaths: Map
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record; message?: string }
) => Promise {
- const { pendingFilePaths } = input
+ const { ctx, pendingFilePaths } = input
return async (toolInput, toolOutput): Promise => {
- if (!isCallerOrchestrator(toolInput.sessionID)) {
+ if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
return
}
diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts
index b15715cd..a89d182b 100644
--- a/src/hooks/category-skill-reminder/hook.ts
+++ b/src/hooks/category-skill-reminder/hook.ts
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared"
+import { getAgentConfigKey } from "../../shared/agent-display-names"
import { buildReminderMessage } from "./formatter"
/**
@@ -75,11 +76,11 @@ export function createCategorySkillReminderHook(
function isTargetAgent(sessionID: string, inputAgent?: string): boolean {
const agent = getSessionAgent(sessionID) ?? inputAgent
if (!agent) return false
- const agentLower = agent.toLowerCase()
+ const agentKey = getAgentConfigKey(agent)
return (
- TARGET_AGENTS.has(agentLower) ||
- agentLower.includes("sisyphus") ||
- agentLower.includes("atlas")
+ TARGET_AGENTS.has(agentKey) ||
+ agentKey.includes("sisyphus") ||
+ agentKey.includes("atlas")
)
}
diff --git a/src/hooks/claude-code-hooks/AGENTS.md b/src/hooks/claude-code-hooks/AGENTS.md
index e9204a18..46d0d01a 100644
--- a/src/hooks/claude-code-hooks/AGENTS.md
+++ b/src/hooks/claude-code-hooks/AGENTS.md
@@ -2,7 +2,7 @@
## OVERVIEW
-Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands.
+Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands defined in settings.json.
**Config Sources** (priority): `.claude/settings.local.json` > `.claude/settings.json` (project) > `~/.claude/settings.json` (global)
@@ -10,21 +10,26 @@ Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode e
```
claude-code-hooks/
├── index.ts # Barrel export
-├── claude-code-hooks-hook.ts # Main factory
-├── config.ts # Claude settings.json loader
-├── config-loader.ts # Extended plugin config
-├── pre-tool-use.ts # PreToolUse hook executor
-├── post-tool-use.ts # PostToolUse hook executor
-├── user-prompt-submit.ts # UserPromptSubmit executor
-├── stop.ts # Stop hook executor
-├── pre-compact.ts # PreCompact executor
-├── transcript.ts # Tool use recording
-├── tool-input-cache.ts # Pre→post input caching
+├── claude-code-hooks-hook.ts # Main factory (22 lines)
+├── config.ts # Claude settings.json loader (105 lines)
+├── config-loader.ts # Extended plugin config (107 lines)
+├── pre-tool-use.ts # PreToolUse hook executor (173 lines)
+├── post-tool-use.ts # PostToolUse hook executor (200 lines)
+├── user-prompt-submit.ts # UserPromptSubmit executor (125 lines)
+├── stop.ts # Stop hook executor (122 lines)
+├── pre-compact.ts # PreCompact executor (110 lines)
+├── transcript.ts # Tool use recording (235 lines)
+├── tool-input-cache.ts # Pre→post input caching (51 lines)
├── todo.ts # Todo integration
-├── session-hook-state.ts # Active state tracking
-├── types.ts # Hook & IO type definitions
-├── plugin-config.ts # Default config constants
+├── session-hook-state.ts # Active state tracking (11 lines)
+├── types.ts # Hook & IO type definitions (204 lines)
+├── plugin-config.ts # Default config constants (12 lines)
└── handlers/ # Event handlers (5 files)
+ ├── pre-compact-handler.ts
+ ├── tool-execute-before-handler.ts
+ ├── tool-execute-after-handler.ts
+ ├── chat-message-handler.ts
+ └── session-event-handler.ts
```
## HOOK LIFECYCLE
diff --git a/src/hooks/directory-agents-injector/constants.ts b/src/hooks/directory-agents-injector/constants.ts
index 3dc2e19f..4adda871 100644
--- a/src/hooks/directory-agents-injector/constants.ts
+++ b/src/hooks/directory-agents-injector/constants.ts
@@ -1,7 +1,5 @@
import { join } from "node:path";
-import { getOpenCodeStorageDir } from "../../shared/data-path";
-
-export const OPENCODE_STORAGE = getOpenCodeStorageDir();
+import { OPENCODE_STORAGE } from "../../shared";
export const AGENTS_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-agents",
diff --git a/src/hooks/directory-readme-injector/constants.ts b/src/hooks/directory-readme-injector/constants.ts
index f5d9f494..69e1fc5f 100644
--- a/src/hooks/directory-readme-injector/constants.ts
+++ b/src/hooks/directory-readme-injector/constants.ts
@@ -1,7 +1,5 @@
import { join } from "node:path";
-import { getOpenCodeStorageDir } from "../../shared/data-path";
-
-export const OPENCODE_STORAGE = getOpenCodeStorageDir();
+import { OPENCODE_STORAGE } from "../../shared";
export const README_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-readme",
diff --git a/src/hooks/interactive-bash-session/constants.ts b/src/hooks/interactive-bash-session/constants.ts
index 9b2ce382..2c820591 100644
--- a/src/hooks/interactive-bash-session/constants.ts
+++ b/src/hooks/interactive-bash-session/constants.ts
@@ -1,7 +1,5 @@
import { join } from "node:path";
-import { getOpenCodeStorageDir } from "../../shared/data-path";
-
-export const OPENCODE_STORAGE = getOpenCodeStorageDir();
+import { OPENCODE_STORAGE } from "../../shared";
export const INTERACTIVE_BASH_SESSION_STORAGE = join(
OPENCODE_STORAGE,
"interactive-bash-session",
diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts
index 81bb6bc7..4ef001e3 100644
--- a/src/hooks/preemptive-compaction.test.ts
+++ b/src/hooks/preemptive-compaction.test.ts
@@ -1,5 +1,12 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
-import { createPreemptiveCompactionHook } from "./preemptive-compaction"
+
+const logMock = mock(() => {})
+
+mock.module("../shared/logger", () => ({
+ log: logMock,
+}))
+
+const { createPreemptiveCompactionHook } = await import("./preemptive-compaction")
function createMockCtx() {
return {
@@ -21,6 +28,7 @@ describe("preemptive-compaction", () => {
beforeEach(() => {
ctx = createMockCtx()
+ logMock.mockClear()
})
// #given event caches token info from message.updated
@@ -152,4 +160,45 @@ describe("preemptive-compaction", () => {
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
})
+
+ it("should log summarize errors instead of swallowing them", async () => {
+ //#given
+ const hook = createPreemptiveCompactionHook(ctx as never)
+ const sessionID = "ses_log_error"
+ const summarizeError = new Error("summarize failed")
+ ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
+
+ await hook.event({
+ event: {
+ type: "message.updated",
+ properties: {
+ info: {
+ role: "assistant",
+ sessionID,
+ providerID: "anthropic",
+ modelID: "claude-sonnet-4-5",
+ finish: true,
+ tokens: {
+ input: 170000,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 10000, write: 0 },
+ },
+ },
+ },
+ },
+ })
+
+ //#when
+ await hook["tool.execute.after"](
+ { tool: "bash", sessionID, callID: "call_log" },
+ { title: "", output: "test", metadata: null }
+ )
+
+ //#then
+ expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
+ sessionID,
+ error: String(summarizeError),
+ })
+ })
})
diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts
index 87190415..fd617ccf 100644
--- a/src/hooks/preemptive-compaction.ts
+++ b/src/hooks/preemptive-compaction.ts
@@ -1,3 +1,5 @@
+import { log } from "../shared/logger"
+
const DEFAULT_ACTUAL_LIMIT = 200_000
const ANTHROPIC_ACTUAL_LIMIT =
@@ -76,8 +78,8 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
})
compactedSessions.add(sessionID)
- } catch {
- // best-effort; do not disrupt tool execution
+ } catch (error) {
+ log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
} finally {
compactionInProgress.delete(sessionID)
}
diff --git a/src/hooks/prometheus-md-only/agent-resolution.ts b/src/hooks/prometheus-md-only/agent-resolution.ts
index b59c5a3a..22dc9cae 100644
--- a/src/hooks/prometheus-md-only/agent-resolution.ts
+++ b/src/hooks/prometheus-md-only/agent-resolution.ts
@@ -1,24 +1,29 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
+import type { PluginInput } from "@opencode-ai/plugin"
+
+import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector"
+import {
+ findFirstMessageWithAgentFromSDK,
+ findNearestMessageWithFieldsFromSDK,
+} from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { readBoulderState } from "../../features/boulder-state"
+import { getMessageDir } from "../../shared/opencode-message-dir"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
-function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
+type OpencodeClient = PluginInput["client"]
- const directPath = join(MESSAGE_STORAGE, sessionID)
- if (existsSync(directPath)) return directPath
+async function getAgentFromMessageFiles(
+ sessionID: string,
+ client?: OpencodeClient
+): Promise {
+ if (isSqliteBackend() && client) {
+ const firstAgent = await findFirstMessageWithAgentFromSDK(client, sessionID)
+ if (firstAgent) return firstAgent
- for (const dir of readdirSync(MESSAGE_STORAGE)) {
- const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
- if (existsSync(sessionPath)) return sessionPath
+ const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID)
+ return nearest?.agent
}
- return null
-}
-
-function getAgentFromMessageFiles(sessionID: string): string | undefined {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return undefined
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
@@ -36,7 +41,11 @@ function getAgentFromMessageFiles(sessionID: string): string | undefined {
* - Message files return "prometheus" (oldest message from /plan)
* - But boulder.json has agent: "atlas" (set by /start-work)
*/
-export function getAgentFromSession(sessionID: string, directory: string): string | undefined {
+export async function getAgentFromSession(
+ sessionID: string,
+ directory: string,
+ client?: OpencodeClient
+): Promise {
// Check in-memory first (current session)
const memoryAgent = getSessionAgent(sessionID)
if (memoryAgent) return memoryAgent
@@ -48,5 +57,5 @@ export function getAgentFromSession(sessionID: string, directory: string): strin
}
// Fallback to message files
- return getAgentFromMessageFiles(sessionID)
+ return await getAgentFromMessageFiles(sessionID, client)
}
diff --git a/src/hooks/prometheus-md-only/hook.ts b/src/hooks/prometheus-md-only/hook.ts
index b0b5a01a..846238ba 100644
--- a/src/hooks/prometheus-md-only/hook.ts
+++ b/src/hooks/prometheus-md-only/hook.ts
@@ -15,7 +15,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
input: { tool: string; sessionID: string; callID: string },
output: { args: Record; message?: string }
): Promise => {
- const agentName = getAgentFromSession(input.sessionID, ctx.directory)
+ const agentName = await getAgentFromSession(input.sessionID, ctx.directory, ctx.client)
if (!isPrometheusAgent(agentName)) {
return
diff --git a/src/hooks/prometheus-md-only/index.test.ts b/src/hooks/prometheus-md-only/index.test.ts
index 54a839f9..cbb12208 100644
--- a/src/hooks/prometheus-md-only/index.test.ts
+++ b/src/hooks/prometheus-md-only/index.test.ts
@@ -1,16 +1,21 @@
-import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { clearSessionAgent } from "../../features/claude-code-session-state"
+// Force stable (JSON) mode for tests that rely on message file storage
+mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => false,
+ resetSqliteBackendCache: () => {},
+}))
-import { createPrometheusMdOnlyHook } from "./index"
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
+const { createPrometheusMdOnlyHook } = await import("./index")
+const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
describe("prometheus-md-only", () => {
- const TEST_SESSION_ID = "test-session-prometheus"
+ const TEST_SESSION_ID = "ses_test_prometheus"
let testMessageDir: string
function createMockPluginInput() {
@@ -546,7 +551,7 @@ describe("prometheus-md-only", () => {
writeFileSync(BOULDER_FILE, JSON.stringify({
active_plan: "/test/plan.md",
started_at: new Date().toISOString(),
- session_ids: ["other-session-id"],
+ session_ids: ["ses_other_session_id"],
plan_name: "test-plan",
agent: "atlas"
}))
@@ -578,7 +583,7 @@ describe("prometheus-md-only", () => {
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
- sessionID: "non-existent-session",
+ sessionID: "ses_non_existent_session",
callID: "call-1",
}
const output = {
diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts
index 84af442f..d476fb26 100644
--- a/src/hooks/ralph-loop/continuation-prompt-injector.ts
+++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts
@@ -3,6 +3,7 @@ import { log } from "../../shared/logger"
import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { getMessageDir } from "./message-storage-directory"
import { withTimeout } from "./with-timeout"
+import { normalizeSDKResponse } from "../../shared"
type MessageInfo = {
agent?: string
@@ -25,7 +26,7 @@ export async function injectContinuationPrompt(
}),
options.apiTimeoutMs,
)
- const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }>
+ const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i]?.info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
diff --git a/src/hooks/ralph-loop/message-storage-directory.ts b/src/hooks/ralph-loop/message-storage-directory.ts
index 7d4caca1..a9111f43 100644
--- a/src/hooks/ralph-loop/message-storage-directory.ts
+++ b/src/hooks/ralph-loop/message-storage-directory.ts
@@ -1,16 +1 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
-
-export function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
+export { getMessageDir } from "../../shared/opencode-message-dir"
diff --git a/src/hooks/rules-injector/constants.ts b/src/hooks/rules-injector/constants.ts
index 3f8b9f6f..6ac2cbbb 100644
--- a/src/hooks/rules-injector/constants.ts
+++ b/src/hooks/rules-injector/constants.ts
@@ -1,7 +1,5 @@
import { join } from "node:path";
-import { getOpenCodeStorageDir } from "../../shared/data-path";
-
-export const OPENCODE_STORAGE = getOpenCodeStorageDir();
+import { OPENCODE_STORAGE } from "../../shared";
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
export const PROJECT_MARKERS = [
diff --git a/src/hooks/session-recovery/constants.ts b/src/hooks/session-recovery/constants.ts
index a45b8026..8d5ea5e4 100644
--- a/src/hooks/session-recovery/constants.ts
+++ b/src/hooks/session-recovery/constants.ts
@@ -1,9 +1,4 @@
-import { join } from "node:path"
-import { getOpenCodeStorageDir } from "../../shared/data-path"
-
-export const OPENCODE_STORAGE = getOpenCodeStorageDir()
-export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
-export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
+export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
export const META_TYPES = new Set(["step-start", "step-finish"])
diff --git a/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts
new file mode 100644
index 00000000..acf178ec
--- /dev/null
+++ b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect, mock, beforeEach } from "bun:test"
+import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
+import type { MessageData } from "./types"
+
+function createMockClient(messages: MessageData[]) {
+ return {
+ session: {
+ messages: mock(() => Promise.resolve({ data: messages })),
+ },
+ } as never
+}
+
+function createDeps(overrides?: Partial[4]>) {
+ return {
+ placeholderText: "[recovered]",
+ replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)),
+ injectTextPartAsync: mock(() => Promise.resolve(false)),
+ findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve([] as string[])),
+ ...overrides,
+ }
+}
+
+const emptyMsg: MessageData = { info: { id: "msg_1", role: "assistant" }, parts: [] }
+const contentMsg: MessageData = { info: { id: "msg_2", role: "assistant" }, parts: [{ type: "text", text: "Hello" }] }
+const thinkingOnlyMsg: MessageData = { info: { id: "msg_3", role: "assistant" }, parts: [{ type: "thinking", text: "hmm" }] }
+
+describe("recoverEmptyContentMessageFromSDK", () => {
+ it("returns false when no empty messages exist", async () => {
+ //#given
+ const client = createMockClient([contentMsg])
+ const deps = createDeps()
+
+ //#when
+ const result = await recoverEmptyContentMessageFromSDK(
+ client, "ses_1", contentMsg, new Error("test"), deps,
+ )
+
+ //#then
+ expect(result).toBe(false)
+ })
+
+ it("fixes messages with empty text parts via replace", async () => {
+ //#given
+ const client = createMockClient([emptyMsg])
+ const deps = createDeps({
+ findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve(["msg_1"])),
+ replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)),
+ })
+
+ //#when
+ const result = await recoverEmptyContentMessageFromSDK(
+ client, "ses_1", emptyMsg, new Error("test"), deps,
+ )
+
+ //#then
+ expect(result).toBe(true)
+ })
+
+ it("injects text part into thinking-only messages", async () => {
+ //#given
+ const client = createMockClient([thinkingOnlyMsg])
+ const deps = createDeps({
+ injectTextPartAsync: mock(() => Promise.resolve(true)),
+ })
+
+ //#when
+ const result = await recoverEmptyContentMessageFromSDK(
+ client, "ses_1", thinkingOnlyMsg, new Error("test"), deps,
+ )
+
+ //#then
+ expect(result).toBe(true)
+ expect(deps.injectTextPartAsync).toHaveBeenCalledWith(
+ client, "ses_1", "msg_3", "[recovered]",
+ )
+ })
+
+ it("targets message by index from error", async () => {
+ //#given
+ const client = createMockClient([contentMsg, emptyMsg])
+ const error = new Error("messages: index 1 has empty content")
+ const deps = createDeps({
+ replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)),
+ })
+
+ //#when
+ const result = await recoverEmptyContentMessageFromSDK(
+ client, "ses_1", emptyMsg, error, deps,
+ )
+
+ //#then
+ expect(result).toBe(true)
+ })
+
+ it("falls back to failedID when targetIndex fix fails", async () => {
+ //#given
+ const failedMsg: MessageData = { info: { id: "msg_fail" }, parts: [] }
+ const client = createMockClient([contentMsg])
+ const deps = createDeps({
+ replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)),
+ injectTextPartAsync: mock(() => Promise.resolve(true)),
+ })
+
+ //#when
+ const result = await recoverEmptyContentMessageFromSDK(
+ client, "ses_1", failedMsg, new Error("test"), deps,
+ )
+
+ //#then
+ expect(result).toBe(true)
+ expect(deps.injectTextPartAsync).toHaveBeenCalledWith(
+ client, "ses_1", "msg_fail", "[recovered]",
+ )
+ })
+
+ it("returns false when SDK throws during message read", async () => {
+ //#given
+ const client = { session: { messages: mock(() => Promise.reject(new Error("SDK error"))) } } as never
+ const deps = createDeps()
+
+ //#when
+ const result = await recoverEmptyContentMessageFromSDK(
+ client, "ses_1", emptyMsg, new Error("test"), deps,
+ )
+
+ //#then
+ expect(result).toBe(false)
+ })
+
+ it("scans all empty messages when no target index available", async () => {
+ //#given
+ const empty1: MessageData = { info: { id: "e1" }, parts: [] }
+ const empty2: MessageData = { info: { id: "e2" }, parts: [] }
+ const client = createMockClient([empty1, empty2])
+ const replaceMock = mock(() => Promise.resolve(true))
+ const deps = createDeps({ replaceEmptyTextPartsAsync: replaceMock })
+
+ //#when
+ const result = await recoverEmptyContentMessageFromSDK(
+ client, "ses_1", empty1, new Error("test"), deps,
+ )
+
+ //#then
+ expect(result).toBe(true)
+ })
+})
diff --git a/src/hooks/session-recovery/recover-empty-content-message-sdk.ts b/src/hooks/session-recovery/recover-empty-content-message-sdk.ts
new file mode 100644
index 00000000..ee6ab54e
--- /dev/null
+++ b/src/hooks/session-recovery/recover-empty-content-message-sdk.ts
@@ -0,0 +1,201 @@
+import type { createOpencodeClient } from "@opencode-ai/sdk"
+import type { MessageData } from "./types"
+import { extractMessageIndex } from "./detect-error-type"
+import { META_TYPES, THINKING_TYPES } from "./constants"
+import { normalizeSDKResponse } from "../../shared"
+
+type Client = ReturnType
+
+type ReplaceEmptyTextPartsAsync = (
+ client: Client,
+ sessionID: string,
+ messageID: string,
+ replacementText: string
+) => Promise
+
+type InjectTextPartAsync = (
+ client: Client,
+ sessionID: string,
+ messageID: string,
+ text: string
+) => Promise
+
+type FindMessagesWithEmptyTextPartsFromSDK = (
+ client: Client,
+ sessionID: string
+) => Promise
+
+export async function recoverEmptyContentMessageFromSDK(
+ client: Client,
+ sessionID: string,
+ failedAssistantMsg: MessageData,
+ error: unknown,
+ dependencies: {
+ placeholderText: string
+ replaceEmptyTextPartsAsync: ReplaceEmptyTextPartsAsync
+ injectTextPartAsync: InjectTextPartAsync
+ findMessagesWithEmptyTextPartsFromSDK: FindMessagesWithEmptyTextPartsFromSDK
+ }
+): Promise {
+ const targetIndex = extractMessageIndex(error)
+ const failedID = failedAssistantMsg.info?.id
+ let anySuccess = false
+
+ const messagesWithEmptyText = await dependencies.findMessagesWithEmptyTextPartsFromSDK(client, sessionID)
+ for (const messageID of messagesWithEmptyText) {
+ if (
+ await dependencies.replaceEmptyTextPartsAsync(
+ client,
+ sessionID,
+ messageID,
+ dependencies.placeholderText
+ )
+ ) {
+ anySuccess = true
+ }
+ }
+
+ const messages = await readMessagesFromSDK(client, sessionID)
+
+ const thinkingOnlyIDs = findMessagesWithThinkingOnlyFromSDK(messages)
+ for (const messageID of thinkingOnlyIDs) {
+ if (await dependencies.injectTextPartAsync(client, sessionID, messageID, dependencies.placeholderText)) {
+ anySuccess = true
+ }
+ }
+
+ if (targetIndex !== null) {
+ const targetMessageID = findEmptyMessageByIndexFromSDK(messages, targetIndex)
+ if (targetMessageID) {
+ if (
+ await dependencies.replaceEmptyTextPartsAsync(
+ client,
+ sessionID,
+ targetMessageID,
+ dependencies.placeholderText
+ )
+ ) {
+ return true
+ }
+ if (await dependencies.injectTextPartAsync(client, sessionID, targetMessageID, dependencies.placeholderText)) {
+ return true
+ }
+ }
+ }
+
+ if (failedID) {
+ if (await dependencies.replaceEmptyTextPartsAsync(client, sessionID, failedID, dependencies.placeholderText)) {
+ return true
+ }
+ if (await dependencies.injectTextPartAsync(client, sessionID, failedID, dependencies.placeholderText)) {
+ return true
+ }
+ }
+
+ const freshMessages = await readMessagesFromSDK(client, sessionID)
+ const emptyMessageIDs = findEmptyMessagesFromSDK(freshMessages)
+ for (const messageID of emptyMessageIDs) {
+ if (
+ await dependencies.replaceEmptyTextPartsAsync(
+ client,
+ sessionID,
+ messageID,
+ dependencies.placeholderText
+ )
+ ) {
+ anySuccess = true
+ }
+ if (await dependencies.injectTextPartAsync(client, sessionID, messageID, dependencies.placeholderText)) {
+ anySuccess = true
+ }
+ }
+
+ return anySuccess
+}
+
+type SdkPart = NonNullable[number]
+
+function sdkPartHasContent(part: SdkPart): boolean {
+ if (THINKING_TYPES.has(part.type)) return false
+ if (META_TYPES.has(part.type)) return false
+
+ if (part.type === "text") {
+ return !!part.text?.trim()
+ }
+
+ if (part.type === "tool" || part.type === "tool_use" || part.type === "tool_result") {
+ return true
+ }
+
+ return true
+}
+
+function sdkMessageHasContent(message: MessageData): boolean {
+ return (message.parts ?? []).some(sdkPartHasContent)
+}
+
+async function readMessagesFromSDK(client: Client, sessionID: string): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ return normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+ } catch {
+ return []
+ }
+}
+
+function findMessagesWithThinkingOnlyFromSDK(messages: MessageData[]): string[] {
+ const result: string[] = []
+
+ for (const msg of messages) {
+ if (msg.info?.role !== "assistant") continue
+ if (!msg.info?.id) continue
+ if (!msg.parts || msg.parts.length === 0) continue
+
+ const hasThinking = msg.parts.some((part) => THINKING_TYPES.has(part.type))
+ const hasContent = msg.parts.some(sdkPartHasContent)
+
+ if (hasThinking && !hasContent) {
+ result.push(msg.info.id)
+ }
+ }
+
+ return result
+}
+
+function findEmptyMessagesFromSDK(messages: MessageData[]): string[] {
+ const emptyIds: string[] = []
+
+ for (const msg of messages) {
+ if (!msg.info?.id) continue
+ if (!sdkMessageHasContent(msg)) {
+ emptyIds.push(msg.info.id)
+ }
+ }
+
+ return emptyIds
+}
+
+function findEmptyMessageByIndexFromSDK(messages: MessageData[], targetIndex: number): string | null {
+ const indicesToTry = [
+ targetIndex,
+ targetIndex - 1,
+ targetIndex + 1,
+ targetIndex - 2,
+ targetIndex + 2,
+ targetIndex - 3,
+ targetIndex - 4,
+ targetIndex - 5,
+ ]
+
+ for (const index of indicesToTry) {
+ if (index < 0 || index >= messages.length) continue
+ const targetMessage = messages[index]
+ if (!targetMessage.info?.id) continue
+
+ if (!sdkMessageHasContent(targetMessage)) {
+ return targetMessage.info.id
+ }
+ }
+
+ return null
+}
diff --git a/src/hooks/session-recovery/recover-empty-content-message.ts b/src/hooks/session-recovery/recover-empty-content-message.ts
index f095eb2e..7b73f34f 100644
--- a/src/hooks/session-recovery/recover-empty-content-message.ts
+++ b/src/hooks/session-recovery/recover-empty-content-message.ts
@@ -1,6 +1,7 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
+import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
import {
findEmptyMessageByIndex,
findEmptyMessages,
@@ -9,18 +10,30 @@ import {
injectTextPart,
replaceEmptyTextParts,
} from "./storage"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { replaceEmptyTextPartsAsync, findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
+import { injectTextPartAsync } from "./storage/text-part-injector"
type Client = ReturnType
const PLACEHOLDER_TEXT = "[user interrupted]"
export async function recoverEmptyContentMessage(
- _client: Client,
+ client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise {
+ if (isSqliteBackend()) {
+ return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, {
+ placeholderText: PLACEHOLDER_TEXT,
+ replaceEmptyTextPartsAsync,
+ injectTextPartAsync,
+ findMessagesWithEmptyTextPartsFromSDK,
+ })
+ }
+
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false
diff --git a/src/hooks/session-recovery/recover-thinking-block-order.ts b/src/hooks/session-recovery/recover-thinking-block-order.ts
index f26bf4f1..cd62b97c 100644
--- a/src/hooks/session-recovery/recover-thinking-block-order.ts
+++ b/src/hooks/session-recovery/recover-thinking-block-order.ts
@@ -2,16 +2,24 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { prependThinkingPartAsync } from "./storage/thinking-prepend"
+import { THINKING_TYPES } from "./constants"
+import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType
export async function recoverThinkingBlockOrder(
- _client: Client,
+ client: Client,
sessionID: string,
_failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise {
+ if (isSqliteBackend()) {
+ return recoverThinkingBlockOrderFromSDK(client, sessionID, error)
+ }
+
const targetIndex = extractMessageIndex(error)
if (targetIndex !== null) {
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
@@ -34,3 +42,96 @@ export async function recoverThinkingBlockOrder(
return anySuccess
}
+
+async function recoverThinkingBlockOrderFromSDK(
+ client: Client,
+ sessionID: string,
+ error: unknown
+): Promise {
+ const targetIndex = extractMessageIndex(error)
+ if (targetIndex !== null) {
+ const targetMessageID = await findMessageByIndexNeedingThinkingFromSDK(client, sessionID, targetIndex)
+ if (targetMessageID) {
+ return prependThinkingPartAsync(client, sessionID, targetMessageID)
+ }
+ }
+
+ const orphanMessages = await findMessagesWithOrphanThinkingFromSDK(client, sessionID)
+ if (orphanMessages.length === 0) {
+ return false
+ }
+
+ let anySuccess = false
+ for (const messageID of orphanMessages) {
+ if (await prependThinkingPartAsync(client, sessionID, messageID)) {
+ anySuccess = true
+ }
+ }
+
+ return anySuccess
+}
+
+async function findMessagesWithOrphanThinkingFromSDK(
+ client: Client,
+ sessionID: string
+): Promise {
+ let messages: MessageData[]
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+ } catch {
+ return []
+ }
+
+ const result: string[] = []
+ for (const msg of messages) {
+ if (msg.info?.role !== "assistant") continue
+ if (!msg.info?.id) continue
+ if (!msg.parts || msg.parts.length === 0) continue
+
+ const partsWithIds = msg.parts.filter(
+ (part): part is { id: string; type: string } => typeof part.id === "string"
+ )
+ if (partsWithIds.length === 0) continue
+
+ const sortedParts = [...partsWithIds].sort((a, b) => a.id.localeCompare(b.id))
+ const firstPart = sortedParts[0]
+ if (!THINKING_TYPES.has(firstPart.type)) {
+ result.push(msg.info.id)
+ }
+ }
+
+ return result
+}
+
+async function findMessageByIndexNeedingThinkingFromSDK(
+ client: Client,
+ sessionID: string,
+ targetIndex: number
+): Promise {
+ let messages: MessageData[]
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+ } catch {
+ return null
+ }
+
+ if (targetIndex < 0 || targetIndex >= messages.length) return null
+
+ const targetMessage = messages[targetIndex]
+ if (targetMessage.info?.role !== "assistant") return null
+ if (!targetMessage.info?.id) return null
+ if (!targetMessage.parts || targetMessage.parts.length === 0) return null
+
+ const partsWithIds = targetMessage.parts.filter(
+ (part): part is { id: string; type: string } => typeof part.id === "string"
+ )
+ if (partsWithIds.length === 0) return null
+
+ const sortedParts = [...partsWithIds].sort((a, b) => a.id.localeCompare(b.id))
+ const firstPart = sortedParts[0]
+ const firstIsThinking = THINKING_TYPES.has(firstPart.type)
+
+ return firstIsThinking ? null : targetMessage.info.id
+}
diff --git a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts
index 6eeded93..751d9535 100644
--- a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts
+++ b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts
@@ -1,14 +1,23 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { stripThinkingPartsAsync } from "./storage/thinking-strip"
+import { THINKING_TYPES } from "./constants"
+import { log } from "../../shared/logger"
+import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType
export async function recoverThinkingDisabledViolation(
- _client: Client,
+ client: Client,
sessionID: string,
_failedAssistantMsg: MessageData
): Promise {
+ if (isSqliteBackend()) {
+ return recoverThinkingDisabledViolationFromSDK(client, sessionID)
+ }
+
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
if (messagesWithThinking.length === 0) {
return false
@@ -23,3 +32,44 @@ export async function recoverThinkingDisabledViolation(
return anySuccess
}
+
+async function recoverThinkingDisabledViolationFromSDK(
+ client: Client,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+
+ const messageIDsWithThinking: string[] = []
+ for (const msg of messages) {
+ if (msg.info?.role !== "assistant") continue
+ if (!msg.info?.id) continue
+ if (!msg.parts) continue
+
+ const hasThinking = msg.parts.some((part) => THINKING_TYPES.has(part.type))
+ if (hasThinking) {
+ messageIDsWithThinking.push(msg.info.id)
+ }
+ }
+
+ if (messageIDsWithThinking.length === 0) {
+ return false
+ }
+
+ let anySuccess = false
+ for (const messageID of messageIDsWithThinking) {
+ if (await stripThinkingPartsAsync(client, sessionID, messageID)) {
+ anySuccess = true
+ }
+ }
+
+ return anySuccess
+ } catch (error) {
+ log("[session-recovery] recoverThinkingDisabledViolationFromSDK failed", {
+ sessionID,
+ error: String(error),
+ })
+ return false
+ }
+}
diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts
index 1f114fe3..a1121fc6 100644
--- a/src/hooks/session-recovery/recover-tool-result-missing.ts
+++ b/src/hooks/session-recovery/recover-tool-result-missing.ts
@@ -1,6 +1,8 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { readParts } from "./storage"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType
@@ -20,6 +22,26 @@ function extractToolUseIds(parts: MessagePart[]): string[] {
return parts.filter((part): part is ToolUsePart => part.type === "tool_use" && !!part.id).map((part) => part.id)
}
+async function readPartsFromSDKFallback(
+ client: Client,
+ sessionID: string,
+ messageID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+ const target = messages.find((m) => m.info?.id === messageID)
+ if (!target?.parts) return []
+
+ return target.parts.map((part) => ({
+ type: part.type === "tool" ? "tool_use" : part.type,
+ id: "callID" in part ? (part as { callID?: string }).callID : part.id,
+ }))
+ } catch {
+ return []
+ }
+}
+
export async function recoverToolResultMissing(
client: Client,
sessionID: string,
@@ -27,11 +49,15 @@ export async function recoverToolResultMissing(
): Promise {
let parts = failedAssistantMsg.parts || []
if (parts.length === 0 && failedAssistantMsg.info?.id) {
- const storedParts = readParts(failedAssistantMsg.info.id)
- parts = storedParts.map((part) => ({
- type: part.type === "tool" ? "tool_use" : part.type,
- id: "callID" in part ? (part as { callID?: string }).callID : part.id,
- }))
+ if (isSqliteBackend()) {
+ parts = await readPartsFromSDKFallback(client, sessionID, failedAssistantMsg.info.id)
+ } else {
+ const storedParts = readParts(failedAssistantMsg.info.id)
+ parts = storedParts.map((part) => ({
+ type: part.type === "tool" ? "tool_use" : part.type,
+ id: "callID" in part ? (part as { callID?: string }).callID : part.id,
+ }))
+ }
}
const toolUseIds = extractToolUseIds(parts)
diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts
index b9dbccb9..741569bb 100644
--- a/src/hooks/session-recovery/storage.ts
+++ b/src/hooks/session-recovery/storage.ts
@@ -1,9 +1,12 @@
export { generatePartId } from "./storage/part-id"
export { getMessageDir } from "./storage/message-dir"
export { readMessages } from "./storage/messages-reader"
+export { readMessagesFromSDK } from "./storage/messages-reader"
export { readParts } from "./storage/parts-reader"
+export { readPartsFromSDK } from "./storage/parts-reader"
export { hasContent, messageHasContent } from "./storage/part-content"
export { injectTextPart } from "./storage/text-part-injector"
+export { injectTextPartAsync } from "./storage/text-part-injector"
export {
findEmptyMessages,
@@ -11,6 +14,7 @@ export {
findFirstEmptyMessage,
} from "./storage/empty-messages"
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
+export { findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
export {
findMessagesWithThinkingBlocks,
@@ -24,3 +28,7 @@ export {
export { prependThinkingPart } from "./storage/thinking-prepend"
export { stripThinkingParts } from "./storage/thinking-strip"
export { replaceEmptyTextParts } from "./storage/empty-text"
+
+export { prependThinkingPartAsync } from "./storage/thinking-prepend"
+export { stripThinkingPartsAsync } from "./storage/thinking-strip"
+export { replaceEmptyTextPartsAsync } from "./storage/empty-text"
diff --git a/src/hooks/session-recovery/storage/empty-text.ts b/src/hooks/session-recovery/storage/empty-text.ts
index aa6ff2eb..c9aa3493 100644
--- a/src/hooks/session-recovery/storage/empty-text.ts
+++ b/src/hooks/session-recovery/storage/empty-text.ts
@@ -1,11 +1,21 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE } from "../constants"
-import type { StoredPart, StoredTextPart } from "../types"
+import type { StoredPart, StoredTextPart, MessageData } from "../types"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
+import { log, isSqliteBackend, patchPart } from "../../../shared"
+import { normalizeSDKResponse } from "../../../shared"
+
+type OpencodeClient = PluginInput["client"]
export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
+ if (isSqliteBackend()) {
+ log("[session-recovery] Disabled on SQLite backend: replaceEmptyTextParts (use async variant)")
+ return false
+ }
+
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
@@ -34,6 +44,38 @@ export function replaceEmptyTextParts(messageID: string, replacementText: string
return anyReplaced
}
+export async function replaceEmptyTextPartsAsync(
+ client: OpencodeClient,
+ sessionID: string,
+ messageID: string,
+ replacementText: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+
+ const targetMsg = messages.find((m) => m.info?.id === messageID)
+ if (!targetMsg?.parts) return false
+
+ let anyReplaced = false
+ for (const part of targetMsg.parts) {
+ if (part.type === "text" && !part.text?.trim() && part.id) {
+ const patched = await patchPart(client, sessionID, messageID, part.id, {
+ ...part,
+ text: replacementText,
+ synthetic: true,
+ })
+ if (patched) anyReplaced = true
+ }
+ }
+
+ return anyReplaced
+ } catch (error) {
+ log("[session-recovery] replaceEmptyTextPartsAsync failed", { error: String(error) })
+ return false
+ }
+}
+
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
@@ -53,3 +95,24 @@ export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
return result
}
+
+export async function findMessagesWithEmptyTextPartsFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+ const result: string[] = []
+
+ for (const msg of messages) {
+ if (!msg.parts || !msg.info?.id) continue
+ const hasEmpty = msg.parts.some((p) => p.type === "text" && !p.text?.trim())
+ if (hasEmpty) result.push(msg.info.id)
+ }
+
+ return result
+ } catch {
+ return []
+ }
+}
diff --git a/src/hooks/session-recovery/storage/message-dir.ts b/src/hooks/session-recovery/storage/message-dir.ts
index 96f03a27..1a2ecaf0 100644
--- a/src/hooks/session-recovery/storage/message-dir.ts
+++ b/src/hooks/session-recovery/storage/message-dir.ts
@@ -1,21 +1 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { MESSAGE_STORAGE } from "../constants"
-
-export function getMessageDir(sessionID: string): string {
- if (!existsSync(MESSAGE_STORAGE)) return ""
-
- const directPath = join(MESSAGE_STORAGE, sessionID)
- if (existsSync(directPath)) {
- return directPath
- }
-
- for (const dir of readdirSync(MESSAGE_STORAGE)) {
- const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
- if (existsSync(sessionPath)) {
- return sessionPath
- }
- }
-
- return ""
-}
+export { getMessageDir } from "../../../shared/opencode-message-dir"
diff --git a/src/hooks/session-recovery/storage/messages-reader.ts b/src/hooks/session-recovery/storage/messages-reader.ts
index ad6c7783..ecedf240 100644
--- a/src/hooks/session-recovery/storage/messages-reader.ts
+++ b/src/hooks/session-recovery/storage/messages-reader.ts
@@ -1,9 +1,39 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import type { StoredMessageMeta } from "../types"
import { getMessageDir } from "./message-dir"
+import { isSqliteBackend, normalizeSDKResponse } from "../../../shared"
+import { isRecord } from "../../../shared/record-type-guard"
+
+type OpencodeClient = PluginInput["client"]
+
+function normalizeSDKMessage(
+ sessionID: string,
+ value: unknown
+): StoredMessageMeta | null {
+ if (!isRecord(value)) return null
+ if (typeof value.id !== "string") return null
+
+ const roleValue = value.role
+ const role: StoredMessageMeta["role"] = roleValue === "assistant" ? "assistant" : "user"
+
+ const created =
+ isRecord(value.time) && typeof value.time.created === "number"
+ ? value.time.created
+ : 0
+
+ return {
+ id: value.id,
+ sessionID,
+ role,
+ time: { created },
+ }
+}
export function readMessages(sessionID: string): StoredMessageMeta[] {
+ if (isSqliteBackend()) return []
+
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
@@ -25,3 +55,29 @@ export function readMessages(sessionID: string): StoredMessageMeta[] {
return a.id.localeCompare(b.id)
})
}
+
+export async function readMessagesFromSDK(
+ client: OpencodeClient,
+ sessionID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const data = normalizeSDKResponse(response, [] as unknown[], {
+ preferResponseOnMissingData: true,
+ })
+ if (!Array.isArray(data)) return []
+
+ const messages = data
+ .map((msg): StoredMessageMeta | null => normalizeSDKMessage(sessionID, msg))
+ .filter((msg): msg is StoredMessageMeta => msg !== null)
+
+ return messages.sort((a, b) => {
+ const aTime = a.time?.created ?? 0
+ const bTime = b.time?.created ?? 0
+ if (aTime !== bTime) return aTime - bTime
+ return a.id.localeCompare(b.id)
+ })
+ } catch {
+ return []
+ }
+}
diff --git a/src/hooks/session-recovery/storage/parts-reader.ts b/src/hooks/session-recovery/storage/parts-reader.ts
index c4110a59..287fd7b9 100644
--- a/src/hooks/session-recovery/storage/parts-reader.ts
+++ b/src/hooks/session-recovery/storage/parts-reader.ts
@@ -1,9 +1,26 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE } from "../constants"
import type { StoredPart } from "../types"
+import { isSqliteBackend } from "../../../shared"
+import { isRecord } from "../../../shared/record-type-guard"
+
+type OpencodeClient = PluginInput["client"]
+
+function isStoredPart(value: unknown): value is StoredPart {
+ if (!isRecord(value)) return false
+ return (
+ typeof value.id === "string" &&
+ typeof value.sessionID === "string" &&
+ typeof value.messageID === "string" &&
+ typeof value.type === "string"
+ )
+}
export function readParts(messageID: string): StoredPart[] {
+ if (isSqliteBackend()) return []
+
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
@@ -20,3 +37,30 @@ export function readParts(messageID: string): StoredPart[] {
return parts
}
+
+export async function readPartsFromSDK(
+ client: OpencodeClient,
+ sessionID: string,
+ messageID: string
+): Promise {
+ try {
+ const response = await client.session.message({
+ path: { id: sessionID, messageID },
+ })
+
+ const data: unknown = response.data
+ if (!isRecord(data)) return []
+
+ const rawParts = data.parts
+ if (!Array.isArray(rawParts)) return []
+
+ return rawParts
+ .map((part: unknown) => {
+ if (!isRecord(part) || typeof part.id !== "string" || typeof part.type !== "string") return null
+ return { ...part, sessionID, messageID } as StoredPart
+ })
+ .filter((part): part is StoredPart => part !== null)
+ } catch {
+ return []
+ }
+}
diff --git a/src/hooks/session-recovery/storage/readers-from-sdk.test.ts b/src/hooks/session-recovery/storage/readers-from-sdk.test.ts
new file mode 100644
index 00000000..e3194576
--- /dev/null
+++ b/src/hooks/session-recovery/storage/readers-from-sdk.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "bun:test"
+import { readMessagesFromSDK, readPartsFromSDK } from "../storage"
+import { readMessages } from "./messages-reader"
+import { readParts } from "./parts-reader"
+
+function createMockClient(handlers: {
+ messages?: (sessionID: string) => unknown[]
+ message?: (sessionID: string, messageID: string) => unknown
+}) {
+ return {
+ session: {
+ messages: async (opts: { path: { id: string } }) => {
+ if (handlers.messages) {
+ return { data: handlers.messages(opts.path.id) }
+ }
+ throw new Error("not implemented")
+ },
+ message: async (opts: { path: { id: string; messageID: string } }) => {
+ if (handlers.message) {
+ return { data: handlers.message(opts.path.id, opts.path.messageID) }
+ }
+ throw new Error("not implemented")
+ },
+ },
+ } as unknown
+}
+
+describe("session-recovery storage SDK readers", () => {
+ it("readPartsFromSDK returns empty array when fetch fails", async () => {
+ //#given a client that throws on request
+ const client = createMockClient({}) as Parameters[0]
+
+ //#when readPartsFromSDK is called
+ const result = await readPartsFromSDK(client, "ses_test", "msg_test")
+
+ //#then it returns empty array
+ expect(result).toEqual([])
+ })
+
+ it("readPartsFromSDK returns stored parts from SDK response", async () => {
+ //#given a client that returns a message with parts
+ const sessionID = "ses_test"
+ const messageID = "msg_test"
+ const storedParts = [
+ { id: "prt_1", sessionID, messageID, type: "text", text: "hello" },
+ ]
+
+ const client = createMockClient({
+ message: (_sid, _mid) => ({ parts: storedParts }),
+ }) as Parameters[0]
+
+ //#when readPartsFromSDK is called
+ const result = await readPartsFromSDK(client, sessionID, messageID)
+
+ //#then it returns the parts
+ expect(result).toEqual(storedParts)
+ })
+
+ it("readMessagesFromSDK normalizes and sorts messages", async () => {
+ //#given a client that returns messages list
+ const sessionID = "ses_test"
+ const client = createMockClient({
+ messages: () => [
+ { id: "msg_b", role: "assistant", time: { created: 2 } },
+ { id: "msg_a", role: "user", time: { created: 1 } },
+ { id: "msg_c" },
+ ],
+ }) as Parameters[0]
+
+ //#when readMessagesFromSDK is called
+ const result = await readMessagesFromSDK(client, sessionID)
+
+ //#then it returns sorted StoredMessageMeta with defaults
+ expect(result).toEqual([
+ { id: "msg_c", sessionID, role: "user", time: { created: 0 } },
+ { id: "msg_a", sessionID, role: "user", time: { created: 1 } },
+ { id: "msg_b", sessionID, role: "assistant", time: { created: 2 } },
+ ])
+ })
+
+ it("readParts returns empty array for nonexistent message", () => {
+ //#given a message ID that has no stored parts
+ //#when readParts is called
+ const parts = readParts("msg_nonexistent")
+
+ //#then it returns empty array
+ expect(parts).toEqual([])
+ })
+
+ it("readMessages returns empty array for nonexistent session", () => {
+ //#given a session ID that has no stored messages
+ //#when readMessages is called
+ const messages = readMessages("ses_nonexistent")
+
+ //#then it returns empty array
+ expect(messages).toEqual([])
+ })
+})
diff --git a/src/hooks/session-recovery/storage/text-part-injector.ts b/src/hooks/session-recovery/storage/text-part-injector.ts
index f729ca0f..d20800a9 100644
--- a/src/hooks/session-recovery/storage/text-part-injector.ts
+++ b/src/hooks/session-recovery/storage/text-part-injector.ts
@@ -1,10 +1,19 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE } from "../constants"
import type { StoredTextPart } from "../types"
import { generatePartId } from "./part-id"
+import { log, isSqliteBackend, patchPart } from "../../../shared"
+
+type OpencodeClient = PluginInput["client"]
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
+ if (isSqliteBackend()) {
+ log("[session-recovery] Disabled on SQLite backend: injectTextPart (use async variant)")
+ return false
+ }
+
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
@@ -28,3 +37,27 @@ export function injectTextPart(sessionID: string, messageID: string, text: strin
return false
}
}
+
+export async function injectTextPartAsync(
+ client: OpencodeClient,
+ sessionID: string,
+ messageID: string,
+ text: string
+): Promise {
+ const partId = generatePartId()
+ const part: Record = {
+ id: partId,
+ sessionID,
+ messageID,
+ type: "text",
+ text,
+ synthetic: true,
+ }
+
+ try {
+ return await patchPart(client, sessionID, messageID, partId, part)
+ } catch (error) {
+ log("[session-recovery] injectTextPartAsync failed", { error: String(error) })
+ return false
+ }
+}
diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts
index b8c1bd86..464898c9 100644
--- a/src/hooks/session-recovery/storage/thinking-prepend.ts
+++ b/src/hooks/session-recovery/storage/thinking-prepend.ts
@@ -1,8 +1,14 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE, THINKING_TYPES } from "../constants"
+import type { MessageData } from "../types"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
+import { log, isSqliteBackend, patchPart } from "../../../shared"
+import { normalizeSDKResponse } from "../../../shared"
+
+type OpencodeClient = PluginInput["client"]
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
const messages = readMessages(sessionID)
@@ -31,6 +37,11 @@ function findLastThinkingContent(sessionID: string, beforeMessageID: string): st
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
+ if (isSqliteBackend()) {
+ log("[session-recovery] Disabled on SQLite backend: prependThinkingPart (use async variant)")
+ return false
+ }
+
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
@@ -39,7 +50,7 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
const previousThinking = findLastThinkingContent(sessionID, messageID)
- const partId = "prt_0000000000_thinking"
+ const partId = `prt_0000000000_${messageID}_thinking`
const part = {
id: partId,
sessionID,
@@ -56,3 +67,58 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
return false
}
}
+
+async function findLastThinkingContentFromSDK(
+ client: OpencodeClient,
+ sessionID: string,
+ beforeMessageID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
+
+ const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID)
+ if (currentIndex === -1) return ""
+
+ for (let i = currentIndex - 1; i >= 0; i--) {
+ const msg = messages[i]
+ if (msg.info?.role !== "assistant") continue
+ if (!msg.parts) continue
+
+ for (const part of msg.parts) {
+ if (part.type && THINKING_TYPES.has(part.type)) {
+ const content = part.thinking || part.text
+ if (content && content.trim().length > 0) return content
+ }
+ }
+ }
+ } catch {
+ return ""
+ }
+ return ""
+}
+
+export async function prependThinkingPartAsync(
+ client: OpencodeClient,
+ sessionID: string,
+ messageID: string
+): Promise {
+ const previousThinking = await findLastThinkingContentFromSDK(client, sessionID, messageID)
+
+ const partId = `prt_0000000000_${messageID}_thinking`
+ const part: Record = {
+ id: partId,
+ sessionID,
+ messageID,
+ type: "thinking",
+ thinking: previousThinking || "[Continuing from previous reasoning]",
+ synthetic: true,
+ }
+
+ try {
+ return await patchPart(client, sessionID, messageID, partId, part)
+ } catch (error) {
+ log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) })
+ return false
+ }
+}
diff --git a/src/hooks/session-recovery/storage/thinking-strip.ts b/src/hooks/session-recovery/storage/thinking-strip.ts
index 8731508a..518ef1b0 100644
--- a/src/hooks/session-recovery/storage/thinking-strip.ts
+++ b/src/hooks/session-recovery/storage/thinking-strip.ts
@@ -1,9 +1,19 @@
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE, THINKING_TYPES } from "../constants"
import type { StoredPart } from "../types"
+import { log, isSqliteBackend, deletePart } from "../../../shared"
+import { normalizeSDKResponse } from "../../../shared"
+
+type OpencodeClient = PluginInput["client"]
export function stripThinkingParts(messageID: string): boolean {
+ if (isSqliteBackend()) {
+ log("[session-recovery] Disabled on SQLite backend: stripThinkingParts (use async variant)")
+ return false
+ }
+
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
@@ -25,3 +35,33 @@ export function stripThinkingParts(messageID: string): boolean {
return anyRemoved
}
+
+export async function stripThinkingPartsAsync(
+ client: OpencodeClient,
+ sessionID: string,
+ messageID: string
+): Promise {
+ try {
+ const response = await client.session.messages({ path: { id: sessionID } })
+ const messages = normalizeSDKResponse(response, [] as Array<{ parts?: Array<{ type: string; id: string }> }>, { preferResponseOnMissingData: true })
+
+ const targetMsg = messages.find((m) => {
+ const info = (m as Record)["info"] as Record | undefined
+ return info?.["id"] === messageID
+ })
+ if (!targetMsg?.parts) return false
+
+ let anyRemoved = false
+ for (const part of targetMsg.parts) {
+ if (THINKING_TYPES.has(part.type) && part.id) {
+ const deleted = await deletePart(client, sessionID, messageID, part.id)
+ if (deleted) anyRemoved = true
+ }
+ }
+
+ return anyRemoved
+ } catch (error) {
+ log("[session-recovery] stripThinkingPartsAsync failed", { error: String(error) })
+ return false
+ }
+}
diff --git a/src/hooks/session-todo-status.ts b/src/hooks/session-todo-status.ts
index cb2a28f2..c86752fe 100644
--- a/src/hooks/session-todo-status.ts
+++ b/src/hooks/session-todo-status.ts
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
+import { normalizeSDKResponse } from "../shared"
interface Todo {
content: string
@@ -10,7 +11,7 @@ interface Todo {
export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise {
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
- const todos = (response.data ?? response) as Todo[]
+ const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
if (!todos || todos.length === 0) return false
return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled")
} catch {
diff --git a/src/hooks/sisyphus-junior-notepad/hook.ts b/src/hooks/sisyphus-junior-notepad/hook.ts
index f80c0df0..28a284e6 100644
--- a/src/hooks/sisyphus-junior-notepad/hook.ts
+++ b/src/hooks/sisyphus-junior-notepad/hook.ts
@@ -5,7 +5,7 @@ import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { log } from "../../shared/logger"
import { HOOK_NAME, NOTEPAD_DIRECTIVE } from "./constants"
-export function createSisyphusJuniorNotepadHook(_ctx: PluginInput) {
+export function createSisyphusJuniorNotepadHook(ctx: PluginInput) {
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
@@ -17,7 +17,7 @@ export function createSisyphusJuniorNotepadHook(_ctx: PluginInput) {
}
// 2. Check if caller is Atlas (orchestrator)
- if (!isCallerOrchestrator(input.sessionID)) {
+ if (!(await isCallerOrchestrator(input.sessionID, ctx.client))) {
return
}
diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts
index b57a8164..db4d7b1c 100644
--- a/src/hooks/todo-continuation-enforcer/constants.ts
+++ b/src/hooks/todo-continuation-enforcer/constants.ts
@@ -18,3 +18,5 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500
export const ABORT_WINDOW_MS = 3000
export const CONTINUATION_COOLDOWN_MS = 30_000
+export const MAX_CONSECUTIVE_FAILURES = 5
+export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts
index 2c67fa78..a8e8586e 100644
--- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts
+++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts
@@ -1,11 +1,15 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
+import { normalizeSDKResponse } from "../../shared"
import {
findNearestMessageWithFields,
+ findNearestMessageWithFieldsFromSDK,
type ToolPermission,
} from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { getAgentConfigKey } from "../../shared/agent-display-names"
import {
CONTINUATION_PROMPT,
@@ -61,7 +65,7 @@ export async function injectContinuation(args: {
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
- todos = (response.data ?? response) as Todo[]
+ todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) })
return
@@ -78,8 +82,13 @@ export async function injectContinuation(args: {
let tools = resolvedInfo?.tools
if (!agentName || !model) {
- const messageDir = getMessageDir(sessionID)
- const previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
+ let previousMessage = null
+ if (isSqliteBackend()) {
+ previousMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
+ } else {
+ const messageDir = getMessageDir(sessionID)
+ previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
+ }
agentName = agentName ?? previousMessage?.agent
model =
model ??
@@ -95,7 +104,7 @@ export async function injectContinuation(args: {
tools = tools ?? previousMessage?.tools
}
- if (agentName && skipAgents.includes(agentName)) {
+ if (agentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(agentName))) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
return
}
@@ -141,11 +150,14 @@ ${todoList}`
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
+ injectionState.consecutiveFailures = 0
}
} catch (error) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
if (injectionState) {
injectionState.inFlight = false
+ injectionState.lastInjectedAt = Date.now()
+ injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
}
}
}
diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts
index 2bfb96bf..689672c0 100644
--- a/src/hooks/todo-continuation-enforcer/idle-event.ts
+++ b/src/hooks/todo-continuation-enforcer/idle-event.ts
@@ -2,13 +2,17 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { ToolPermission } from "../../features/hook-message-injector"
+import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
+import { getAgentConfigKey } from "../../shared/agent-display-names"
import {
ABORT_WINDOW_MS,
CONTINUATION_COOLDOWN_MS,
DEFAULT_SKIP_AGENTS,
+ FAILURE_RESET_WINDOW_MS,
HOOK_NAME,
+ MAX_CONSECUTIVE_FAILURES,
} from "./constants"
import { isLastAssistantMessageAborted } from "./abort-detection"
import { getIncompleteCount } from "./todo"
@@ -65,7 +69,7 @@ export async function handleSessionIdle(args: {
path: { id: sessionID },
query: { directory: ctx.directory },
})
- const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
+ const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>)
if (isLastAssistantMessageAborted(messages)) {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
return
@@ -77,7 +81,7 @@ export async function handleSessionIdle(args: {
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
- todos = (response.data ?? response) as Todo[]
+ todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
} catch (error) {
log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) })
return
@@ -99,8 +103,35 @@ export async function handleSessionIdle(args: {
return
}
- if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < CONTINUATION_COOLDOWN_MS) {
- log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID })
+ if (
+ state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES
+ && state.lastInjectedAt
+ && Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS
+ ) {
+ state.consecutiveFailures = 0
+ log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, {
+ sessionID,
+ failureResetWindowMs: FAILURE_RESET_WINDOW_MS,
+ })
+ }
+
+ if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
+ log(`[${HOOK_NAME}] Skipped: max consecutive failures reached`, {
+ sessionID,
+ consecutiveFailures: state.consecutiveFailures,
+ maxConsecutiveFailures: MAX_CONSECUTIVE_FAILURES,
+ })
+ return
+ }
+
+ const effectiveCooldown =
+ CONTINUATION_COOLDOWN_MS * Math.pow(2, Math.min(state.consecutiveFailures, 5))
+ if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < effectiveCooldown) {
+ log(`[${HOOK_NAME}] Skipped: cooldown active`, {
+ sessionID,
+ effectiveCooldown,
+ consecutiveFailures: state.consecutiveFailures,
+ })
return
}
@@ -110,7 +141,7 @@ export async function handleSessionIdle(args: {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
- const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }>
+ const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
@@ -132,8 +163,9 @@ export async function handleSessionIdle(args: {
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
- if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
- log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
+ const resolvedAgentName = resolvedInfo?.agent
+ if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) {
+ log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName })
return
}
if (hasCompactionMessage && !resolvedInfo?.agent) {
diff --git a/src/hooks/todo-continuation-enforcer/message-directory.ts b/src/hooks/todo-continuation-enforcer/message-directory.ts
index 85e68242..a9111f43 100644
--- a/src/hooks/todo-continuation-enforcer/message-directory.ts
+++ b/src/hooks/todo-continuation-enforcer/message-directory.ts
@@ -1,18 +1 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
-
-export function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
+export { getMessageDir } from "../../shared/opencode-message-dir"
diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts
index 16cbb782..a02a5e5a 100644
--- a/src/hooks/todo-continuation-enforcer/session-state.ts
+++ b/src/hooks/todo-continuation-enforcer/session-state.ts
@@ -45,7 +45,9 @@ export function createSessionStateStore(): SessionStateStore {
return existing.state
}
- const state: SessionState = {}
+ const state: SessionState = {
+ consecutiveFailures: 0,
+ }
sessions.set(sessionID, { state, lastAccessedAt: Date.now() })
return state
}
diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts
index 52343fb3..18a2aad6 100644
--- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts
+++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts
@@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "."
-import { CONTINUATION_COOLDOWN_MS } from "./constants"
+import {
+ CONTINUATION_COOLDOWN_MS,
+ FAILURE_RESET_WINDOW_MS,
+ MAX_CONSECUTIVE_FAILURES,
+} from "./constants"
type TimerCallback = (...args: any[]) => void
@@ -164,6 +168,15 @@ describe("todo-continuation-enforcer", () => {
}
}
+ interface PromptRequestOptions {
+ path: { id: string }
+ body: {
+ agent?: string
+ model?: { providerID?: string; modelID?: string }
+ parts: Array<{ text: string }>
+ }
+ }
+
let mockMessages: MockMessage[] = []
function createMockPluginInput() {
@@ -551,6 +564,164 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(2)
}, { timeout: 15000 })
+ test("should apply cooldown even after injection failure", async () => {
+ //#given
+ const sessionID = "main-failure-cooldown"
+ setMainSession(sessionID)
+ const mockInput = createMockPluginInput()
+ mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
+ promptCalls.push({
+ sessionID: opts.path.id,
+ agent: opts.body.agent,
+ model: opts.body.model,
+ text: opts.body.parts[0].text,
+ })
+ throw new Error("simulated auth failure")
+ }
+ const hook = createTodoContinuationEnforcer(mockInput, {})
+
+ //#when
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+
+ //#then
+ expect(promptCalls).toHaveLength(1)
+ })
+
+ test("should stop retries after max consecutive failures", async () => {
+ //#given
+ const sessionID = "main-max-consecutive-failures"
+ setMainSession(sessionID)
+ const mockInput = createMockPluginInput()
+ mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
+ promptCalls.push({
+ sessionID: opts.path.id,
+ agent: opts.body.agent,
+ model: opts.body.model,
+ text: opts.body.parts[0].text,
+ })
+ throw new Error("simulated auth failure")
+ }
+ const hook = createTodoContinuationEnforcer(mockInput, {})
+
+ //#when
+ for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+ if (index < MAX_CONSECUTIVE_FAILURES - 1) {
+ await fakeTimers.advanceClockBy(1_000_000)
+ }
+ }
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+
+ //#then
+ expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
+ }, { timeout: 30000 })
+
+ test("should resume retries after reset window when max failures reached", async () => {
+ //#given
+ const sessionID = "main-recovery-after-max-failures"
+ setMainSession(sessionID)
+ const mockInput = createMockPluginInput()
+ mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
+ promptCalls.push({
+ sessionID: opts.path.id,
+ agent: opts.body.agent,
+ model: opts.body.model,
+ text: opts.body.parts[0].text,
+ })
+ throw new Error("simulated auth failure")
+ }
+ const hook = createTodoContinuationEnforcer(mockInput, {})
+
+ //#when
+ for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+ if (index < MAX_CONSECUTIVE_FAILURES - 1) {
+ await fakeTimers.advanceClockBy(1_000_000)
+ }
+ }
+
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+
+ await fakeTimers.advanceClockBy(FAILURE_RESET_WINDOW_MS)
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+
+ //#then
+ expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES + 1)
+ }, { timeout: 30000 })
+
+ test("should increase cooldown exponentially after consecutive failures", async () => {
+ //#given
+ const sessionID = "main-exponential-backoff"
+ setMainSession(sessionID)
+ const mockInput = createMockPluginInput()
+ mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
+ promptCalls.push({
+ sessionID: opts.path.id,
+ agent: opts.body.agent,
+ model: opts.body.model,
+ text: opts.body.parts[0].text,
+ })
+ throw new Error("simulated auth failure")
+ }
+ const hook = createTodoContinuationEnforcer(mockInput, {})
+
+ //#when
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+ await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+ await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+
+ //#then
+ expect(promptCalls).toHaveLength(2)
+ }, { timeout: 30000 })
+
+ test("should reset consecutive failure count after successful injection", async () => {
+ //#given
+ const sessionID = "main-reset-consecutive-failures"
+ setMainSession(sessionID)
+ let shouldFail = true
+ const mockInput = createMockPluginInput()
+ mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
+ promptCalls.push({
+ sessionID: opts.path.id,
+ agent: opts.body.agent,
+ model: opts.body.model,
+ text: opts.body.parts[0].text,
+ })
+ if (shouldFail) {
+ shouldFail = false
+ throw new Error("simulated auth failure")
+ }
+ return {}
+ }
+ const hook = createTodoContinuationEnforcer(mockInput, {})
+
+ //#when
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+ await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS * 2)
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+ await fakeTimers.advanceClockBy(CONTINUATION_COOLDOWN_MS)
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
+ await fakeTimers.advanceBy(2500, true)
+
+ //#then
+ expect(promptCalls).toHaveLength(3)
+ }, { timeout: 30000 })
+
test("should keep injecting even when todos remain unchanged across cycles", async () => {
//#given
const sessionID = "main-no-stagnation-cap"
diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts
index 7d702b0e..20c28d6f 100644
--- a/src/hooks/todo-continuation-enforcer/types.ts
+++ b/src/hooks/todo-continuation-enforcer/types.ts
@@ -15,10 +15,10 @@ export interface TodoContinuationEnforcer {
}
export interface Todo {
- content: string
- status: string
- priority: string
- id: string
+ content: string;
+ status: string;
+ priority: string;
+ id?: string;
}
export interface SessionState {
@@ -29,6 +29,7 @@ export interface SessionState {
abortDetectedAt?: number
lastInjectedAt?: number
inFlight?: boolean
+ consecutiveFailures: number
}
export interface MessageInfo {
diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md
index 5b3af3e0..b8288e33 100644
--- a/src/plugin-handlers/AGENTS.md
+++ b/src/plugin-handlers/AGENTS.md
@@ -2,7 +2,7 @@
## OVERVIEW
-Configuration orchestration layer. Runs once at plugin init — transforms raw OpenCode config into resolved agent/tool/permission structures.
+Configuration orchestration layer. Runs once at plugin init — transforms raw OpenCode config into resolved agent/tool/permission structures via 6-phase sequential loading.
## STRUCTURE
```
diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts
index c2c993ba..101300a6 100644
--- a/src/plugin-handlers/agent-config-handler.ts
+++ b/src/plugin-handlers/agent-config-handler.ts
@@ -3,6 +3,7 @@ import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junio
import type { OhMyOpenCodeConfig } from "../config";
import { log, migrateAgentConfig } from "../shared";
import { AGENT_NAME_MAP } from "../shared/migration";
+import { getAgentDisplayName } from "../shared/agent-display-names";
import {
discoverConfigSourceSkills,
discoverOpencodeGlobalSkills,
@@ -13,6 +14,7 @@ import {
import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent-loader";
import type { PluginComponents } from "./plugin-components-loader";
import { reorderAgentsByPriority } from "./agent-priority-order";
+import { remapAgentKeysToDisplayNames } from "./agent-key-remapper";
import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder";
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
@@ -104,7 +106,7 @@ export async function applyAgentConfig(params: {
const configAgent = params.config.agent as AgentConfigRecord | undefined;
if (isSisyphusEnabled && builtinAgents.sisyphus) {
- (params.config as { default_agent?: string }).default_agent = "sisyphus";
+ (params.config as { default_agent?: string }).default_agent = getAgentDisplayName("sisyphus");
const agentConfig: Record = {
sisyphus: builtinAgents.sisyphus,
@@ -193,6 +195,9 @@ export async function applyAgentConfig(params: {
}
if (params.config.agent) {
+ params.config.agent = remapAgentKeysToDisplayNames(
+ params.config.agent as Record,
+ );
params.config.agent = reorderAgentsByPriority(
params.config.agent as Record,
);
diff --git a/src/plugin-handlers/agent-key-remapper.test.ts b/src/plugin-handlers/agent-key-remapper.test.ts
new file mode 100644
index 00000000..fe78ea73
--- /dev/null
+++ b/src/plugin-handlers/agent-key-remapper.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from "bun:test"
+import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"
+
+describe("remapAgentKeysToDisplayNames", () => {
+ it("remaps known agent keys to display names", () => {
+ // given agents with lowercase keys
+ const agents = {
+ sisyphus: { prompt: "test", mode: "primary" },
+ oracle: { prompt: "test", mode: "subagent" },
+ }
+
+ // when remapping
+ const result = remapAgentKeysToDisplayNames(agents)
+
+ // then known agents get display name keys
+ expect(result["Sisyphus (Ultraworker)"]).toBeDefined()
+ expect(result["oracle"]).toBeDefined()
+ expect(result["sisyphus"]).toBeUndefined()
+ })
+
+ it("preserves unknown agent keys unchanged", () => {
+ // given agents with a custom key
+ const agents = {
+ "custom-agent": { prompt: "custom" },
+ }
+
+ // when remapping
+ const result = remapAgentKeysToDisplayNames(agents)
+
+ // then custom key is unchanged
+ expect(result["custom-agent"]).toBeDefined()
+ })
+
+ it("remaps all core agents", () => {
+ // given all core agents
+ const agents = {
+ sisyphus: {},
+ hephaestus: {},
+ prometheus: {},
+ atlas: {},
+ metis: {},
+ momus: {},
+ "sisyphus-junior": {},
+ }
+
+ // when remapping
+ const result = remapAgentKeysToDisplayNames(agents)
+
+ // then all get display name keys
+ expect(Object.keys(result)).toEqual([
+ "Sisyphus (Ultraworker)",
+ "Hephaestus (Deep Agent)",
+ "Prometheus (Plan Builder)",
+ "Atlas (Plan Executor)",
+ "Metis (Plan Consultant)",
+ "Momus (Plan Critic)",
+ "Sisyphus-Junior",
+ ])
+ })
+})
diff --git a/src/plugin-handlers/agent-key-remapper.ts b/src/plugin-handlers/agent-key-remapper.ts
new file mode 100644
index 00000000..dd2a127e
--- /dev/null
+++ b/src/plugin-handlers/agent-key-remapper.ts
@@ -0,0 +1,18 @@
+import { AGENT_DISPLAY_NAMES } from "../shared/agent-display-names"
+
+export function remapAgentKeysToDisplayNames(
+ agents: Record,
+): Record {
+ const result: Record = {}
+
+ for (const [key, value] of Object.entries(agents)) {
+ const displayName = AGENT_DISPLAY_NAMES[key]
+ if (displayName && displayName !== key) {
+ result[displayName] = value
+ } else {
+ result[key] = value
+ }
+ }
+
+ return result
+}
diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts
index a87c0199..9ca88613 100644
--- a/src/plugin-handlers/agent-priority-order.ts
+++ b/src/plugin-handlers/agent-priority-order.ts
@@ -1,4 +1,11 @@
-const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const;
+import { getAgentDisplayName } from "../shared/agent-display-names";
+
+const CORE_AGENT_ORDER = [
+ getAgentDisplayName("sisyphus"),
+ getAgentDisplayName("hephaestus"),
+ getAgentDisplayName("prometheus"),
+ getAgentDisplayName("atlas"),
+] as const;
export function reorderAgentsByPriority(
agents: Record,
diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts
index bca4ce4d..cf6e2461 100644
--- a/src/plugin-handlers/config-handler.test.ts
+++ b/src/plugin-handlers/config-handler.test.ts
@@ -4,6 +4,7 @@ import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"
import { resolveCategoryConfig, createConfigHandler } from "./config-handler"
import type { CategoryConfig } from "../config/schema"
import type { OhMyOpenCodeConfig } from "../config"
+import { getAgentDisplayName } from "../shared/agent-display-names"
import * as agents from "../agents"
import * as sisyphusJunior from "../agents/sisyphus-junior"
@@ -123,7 +124,7 @@ describe("Sisyphus-Junior model inheritance", () => {
// #then
const agentConfig = config.agent as Record
- expect(agentConfig["sisyphus-junior"]?.model).toBe(
+ expect(agentConfig[getAgentDisplayName("sisyphus-junior")]?.model).toBe(
sisyphusJunior.SISYPHUS_JUNIOR_DEFAULTS.model
)
})
@@ -155,7 +156,7 @@ describe("Sisyphus-Junior model inheritance", () => {
// #then
const agentConfig = config.agent as Record
- expect(agentConfig["sisyphus-junior"]?.model).toBe(
+ expect(agentConfig[getAgentDisplayName("sisyphus-junior")]?.model).toBe(
"openai/gpt-5.3-codex"
)
})
@@ -196,7 +197,12 @@ describe("Plan agent demote behavior", () => {
// #then
const keys = Object.keys(config.agent as Record)
- const coreAgents = ["sisyphus", "hephaestus", "prometheus", "atlas"]
+ const coreAgents = [
+ getAgentDisplayName("sisyphus"),
+ getAgentDisplayName("hephaestus"),
+ getAgentDisplayName("prometheus"),
+ getAgentDisplayName("atlas"),
+ ]
const ordered = keys.filter((key) => coreAgents.includes(key))
expect(ordered).toEqual(coreAgents)
})
@@ -236,7 +242,7 @@ describe("Plan agent demote behavior", () => {
expect(agents.plan).toBeDefined()
expect(agents.plan.mode).toBe("subagent")
expect(agents.plan.prompt).toBeUndefined()
- expect(agents.prometheus?.prompt).toBeDefined()
+ expect(agents[getAgentDisplayName("prometheus")]?.prompt).toBeDefined()
})
test("plan agent remains unchanged when planner is disabled", async () => {
@@ -270,7 +276,7 @@ describe("Plan agent demote behavior", () => {
// #then - plan is not touched, prometheus is not created
const agents = config.agent as Record
- expect(agents.prometheus).toBeUndefined()
+ expect(agents[getAgentDisplayName("prometheus")]).toBeUndefined()
expect(agents.plan).toBeDefined()
expect(agents.plan.mode).toBe("primary")
expect(agents.plan.prompt).toBe("original plan prompt")
@@ -301,8 +307,9 @@ describe("Plan agent demote behavior", () => {
// then
const agents = config.agent as Record
- expect(agents.prometheus).toBeDefined()
- expect(agents.prometheus.mode).toBe("all")
+ const prometheusKey = getAgentDisplayName("prometheus")
+ expect(agents[prometheusKey]).toBeDefined()
+ expect(agents[prometheusKey].mode).toBe("all")
})
})
@@ -336,8 +343,9 @@ describe("Agent permission defaults", () => {
// #then
const agentConfig = config.agent as Record }>
- expect(agentConfig.hephaestus).toBeDefined()
- expect(agentConfig.hephaestus.permission?.task).toBe("allow")
+ const hephaestusKey = getAgentDisplayName("hephaestus")
+ expect(agentConfig[hephaestusKey]).toBeDefined()
+ expect(agentConfig[hephaestusKey].permission?.task).toBe("allow")
})
})
@@ -479,8 +487,9 @@ describe("Prometheus direct override priority over category", () => {
// then - direct override's reasoningEffort wins
const agents = config.agent as Record
- expect(agents.prometheus).toBeDefined()
- expect(agents.prometheus.reasoningEffort).toBe("low")
+ const pKey = getAgentDisplayName("prometheus")
+ expect(agents[pKey]).toBeDefined()
+ expect(agents[pKey].reasoningEffort).toBe("low")
})
test("category reasoningEffort applied when no direct override", async () => {
@@ -519,8 +528,9 @@ describe("Prometheus direct override priority over category", () => {
// then - category's reasoningEffort is applied
const agents = config.agent as Record
- expect(agents.prometheus).toBeDefined()
- expect(agents.prometheus.reasoningEffort).toBe("high")
+ const pKey = getAgentDisplayName("prometheus")
+ expect(agents[pKey]).toBeDefined()
+ expect(agents[pKey].reasoningEffort).toBe("high")
})
test("direct temperature takes priority over category temperature", async () => {
@@ -560,8 +570,9 @@ describe("Prometheus direct override priority over category", () => {
// then - direct temperature wins over category
const agents = config.agent as Record
- expect(agents.prometheus).toBeDefined()
- expect(agents.prometheus.temperature).toBe(0.1)
+ const pKey = getAgentDisplayName("prometheus")
+ expect(agents[pKey]).toBeDefined()
+ expect(agents[pKey].temperature).toBe(0.1)
})
test("prometheus prompt_append is appended to base prompt", async () => {
@@ -595,10 +606,11 @@ describe("Prometheus direct override priority over category", () => {
// #then - prompt_append is appended to base prompt, not overwriting it
const agents = config.agent as Record
- expect(agents.prometheus).toBeDefined()
- expect(agents.prometheus.prompt).toContain("Prometheus")
- expect(agents.prometheus.prompt).toContain(customInstructions)
- expect(agents.prometheus.prompt!.endsWith(customInstructions)).toBe(true)
+ const pKey = getAgentDisplayName("prometheus")
+ expect(agents[pKey]).toBeDefined()
+ expect(agents[pKey].prompt).toContain("Prometheus")
+ expect(agents[pKey].prompt).toContain(customInstructions)
+ expect(agents[pKey].prompt!.endsWith(customInstructions)).toBe(true)
})
})
@@ -947,7 +959,13 @@ describe("config-handler plugin loading error boundary (#1559)", () => {
})
describe("per-agent todowrite/todoread deny when task_system enabled", () => {
- const PRIMARY_AGENTS = ["sisyphus", "hephaestus", "atlas", "prometheus", "sisyphus-junior"]
+ const PRIMARY_AGENTS = [
+ getAgentDisplayName("sisyphus"),
+ getAgentDisplayName("hephaestus"),
+ getAgentDisplayName("atlas"),
+ getAgentDisplayName("prometheus"),
+ getAgentDisplayName("sisyphus-junior"),
+ ]
test("denies todowrite and todoread for primary agents when task_system is enabled", async () => {
//#given
@@ -1021,10 +1039,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
//#then
const agentResult = config.agent as Record }>
- expect(agentResult.sisyphus?.permission?.todowrite).toBeUndefined()
- expect(agentResult.sisyphus?.permission?.todoread).toBeUndefined()
- expect(agentResult.hephaestus?.permission?.todowrite).toBeUndefined()
- expect(agentResult.hephaestus?.permission?.todoread).toBeUndefined()
+ expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined()
+ expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined()
+ expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todowrite).toBeUndefined()
+ expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined()
})
test("does not deny todowrite/todoread when task_system is undefined", async () => {
@@ -1055,7 +1073,7 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
//#then
const agentResult = config.agent as Record }>
- expect(agentResult.sisyphus?.permission?.todowrite).toBeUndefined()
- expect(agentResult.sisyphus?.permission?.todoread).toBeUndefined()
+ expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined()
+ expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined()
})
})
diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts
index fa6c12a4..54fa0ddc 100644
--- a/src/plugin-handlers/prometheus-agent-config-builder.ts
+++ b/src/plugin-handlers/prometheus-agent-config-builder.ts
@@ -66,7 +66,6 @@ export async function buildPrometheusAgentConfig(params: {
params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens;
const base: Record = {
- name: "prometheus",
...(resolvedModel ? { model: resolvedModel } : {}),
...(variantToUse ? { variant: variantToUse } : {}),
mode: "all",
diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts
index d587bc97..1e0cdac9 100644
--- a/src/plugin-handlers/tool-config-handler.ts
+++ b/src/plugin-handlers/tool-config-handler.ts
@@ -1,7 +1,12 @@
import type { OhMyOpenCodeConfig } from "../config";
+import { getAgentDisplayName } from "../shared/agent-display-names";
type AgentWithPermission = { permission?: Record };
+function agentByKey(agentResult: Record, key: string): AgentWithPermission | undefined {
+ return agentResult[getAgentDisplayName(key)] as AgentWithPermission | undefined;
+}
+
export function applyToolConfig(params: {
config: Record;
pluginConfig: OhMyOpenCodeConfig;
@@ -27,18 +32,18 @@ export function applyToolConfig(params: {
const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true";
const questionPermission = isCliRunMode ? "deny" : "allow";
- if (params.agentResult.librarian) {
- const agent = params.agentResult.librarian as AgentWithPermission;
- agent.permission = { ...agent.permission, "grep_app_*": "allow" };
+ const librarian = agentByKey(params.agentResult, "librarian");
+ if (librarian) {
+ librarian.permission = { ...librarian.permission, "grep_app_*": "allow" };
}
- if (params.agentResult["multimodal-looker"]) {
- const agent = params.agentResult["multimodal-looker"] as AgentWithPermission;
- agent.permission = { ...agent.permission, task: "deny", look_at: "deny" };
+ const looker = agentByKey(params.agentResult, "multimodal-looker");
+ if (looker) {
+ looker.permission = { ...looker.permission, task: "deny", look_at: "deny" };
}
- if (params.agentResult["atlas"]) {
- const agent = params.agentResult["atlas"] as AgentWithPermission;
- agent.permission = {
- ...agent.permission,
+ const atlas = agentByKey(params.agentResult, "atlas");
+ if (atlas) {
+ atlas.permission = {
+ ...atlas.permission,
task: "allow",
call_omo_agent: "deny",
"task_*": "allow",
@@ -46,10 +51,10 @@ export function applyToolConfig(params: {
...denyTodoTools,
};
}
- if (params.agentResult.sisyphus) {
- const agent = params.agentResult.sisyphus as AgentWithPermission;
- agent.permission = {
- ...agent.permission,
+ const sisyphus = agentByKey(params.agentResult, "sisyphus");
+ if (sisyphus) {
+ sisyphus.permission = {
+ ...sisyphus.permission,
call_omo_agent: "deny",
task: "allow",
question: questionPermission,
@@ -58,20 +63,20 @@ export function applyToolConfig(params: {
...denyTodoTools,
};
}
- if (params.agentResult.hephaestus) {
- const agent = params.agentResult.hephaestus as AgentWithPermission;
- agent.permission = {
- ...agent.permission,
+ const hephaestus = agentByKey(params.agentResult, "hephaestus");
+ if (hephaestus) {
+ hephaestus.permission = {
+ ...hephaestus.permission,
call_omo_agent: "deny",
task: "allow",
question: questionPermission,
...denyTodoTools,
};
}
- if (params.agentResult["prometheus"]) {
- const agent = params.agentResult["prometheus"] as AgentWithPermission;
- agent.permission = {
- ...agent.permission,
+ const prometheus = agentByKey(params.agentResult, "prometheus");
+ if (prometheus) {
+ prometheus.permission = {
+ ...prometheus.permission,
call_omo_agent: "deny",
task: "allow",
question: questionPermission,
@@ -80,10 +85,10 @@ export function applyToolConfig(params: {
...denyTodoTools,
};
}
- if (params.agentResult["sisyphus-junior"]) {
- const agent = params.agentResult["sisyphus-junior"] as AgentWithPermission;
- agent.permission = {
- ...agent.permission,
+ const junior = agentByKey(params.agentResult, "sisyphus-junior");
+ if (junior) {
+ junior.permission = {
+ ...junior.permission,
task: "allow",
"task_*": "allow",
teammate: "allow",
diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts
new file mode 100644
index 00000000..4b5108ad
--- /dev/null
+++ b/src/plugin/chat-message.test.ts
@@ -0,0 +1,118 @@
+import { describe, test, expect } from "bun:test"
+
+import { createChatMessageHandler } from "./chat-message"
+
+type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
+type ChatMessageHandlerOutput = { message: Record; parts: ChatMessagePart[] }
+
+function createMockHandlerArgs(overrides?: {
+ pluginConfig?: Record
+ shouldOverride?: boolean
+}) {
+ const appliedSessions: string[] = []
+ return {
+ ctx: { client: { tui: { showToast: async () => {} } } } as any,
+ pluginConfig: (overrides?.pluginConfig ?? {}) as any,
+ firstMessageVariantGate: {
+ shouldOverride: () => overrides?.shouldOverride ?? false,
+ markApplied: (sessionID: string) => { appliedSessions.push(sessionID) },
+ },
+ hooks: {
+ stopContinuationGuard: null,
+ keywordDetector: null,
+ claudeCodeHooks: null,
+ autoSlashCommand: null,
+ startWork: null,
+ ralphLoop: null,
+ } as any,
+ _appliedSessions: appliedSessions,
+ }
+}
+
+function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) {
+ return {
+ sessionID: "test-session",
+ agent,
+ model,
+ }
+}
+
+function createMockOutput(variant?: string): ChatMessageHandlerOutput {
+ const message: Record = {}
+ if (variant !== undefined) {
+ message["variant"] = variant
+ }
+ return { message, parts: [] }
+}
+
+describe("createChatMessageHandler - first message variant", () => {
+ test("first message: sets variant from fallback chain when user has no selection", async () => {
+ //#given - first message, no user-selected variant, hephaestus with medium in chain
+ const args = createMockHandlerArgs({ shouldOverride: true })
+ const handler = createChatMessageHandler(args)
+ const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
+ const output = createMockOutput() // no variant set
+
+ //#when
+ await handler(input, output)
+
+ //#then - should set variant from fallback chain
+ expect(output.message["variant"]).toBeDefined()
+ })
+
+ test("first message: preserves user-selected variant when already set", async () => {
+ //#given - first message, user already selected "xhigh" variant in OpenCode UI
+ const args = createMockHandlerArgs({ shouldOverride: true })
+ const handler = createChatMessageHandler(args)
+ const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
+ const output = createMockOutput("xhigh") // user selected xhigh
+
+ //#when
+ await handler(input, output)
+
+ //#then - user's xhigh must be preserved, not overwritten to "medium"
+ expect(output.message["variant"]).toBe("xhigh")
+ })
+
+ test("first message: preserves user-selected 'high' variant", async () => {
+ //#given - user selected "high" variant
+ const args = createMockHandlerArgs({ shouldOverride: true })
+ const handler = createChatMessageHandler(args)
+ const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
+ const output = createMockOutput("high")
+
+ //#when
+ await handler(input, output)
+
+ //#then
+ expect(output.message["variant"]).toBe("high")
+ })
+
+ test("subsequent message: does not override existing variant", async () => {
+ //#given - not first message, variant already set
+ const args = createMockHandlerArgs({ shouldOverride: false })
+ const handler = createChatMessageHandler(args)
+ const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
+ const output = createMockOutput("xhigh")
+
+ //#when
+ await handler(input, output)
+
+ //#then
+ expect(output.message["variant"]).toBe("xhigh")
+ })
+
+ test("first message: marks gate as applied regardless of variant presence", async () => {
+ //#given - first message with user-selected variant
+ const args = createMockHandlerArgs({ shouldOverride: true })
+ const handler = createChatMessageHandler(args)
+ const input = createMockInput("hephaestus", { providerID: "openai", modelID: "gpt-5.3-codex" })
+ const output = createMockOutput("xhigh")
+
+ //#when
+ await handler(input, output)
+
+ //#then - gate should still be marked as applied
+ expect(args._appliedSessions).toContain("test-session")
+ })
+})
diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts
index 8cc1b394..e6720320 100644
--- a/src/plugin/chat-message.ts
+++ b/src/plugin/chat-message.ts
@@ -56,12 +56,14 @@ export function createChatMessageHandler(args: {
const message = output.message
if (firstMessageVariantGate.shouldOverride(input.sessionID)) {
- const variant =
- input.model && input.agent
- ? resolveVariantForModel(pluginConfig, input.agent, input.model)
- : resolveAgentVariant(pluginConfig, input.agent)
- if (variant !== undefined) {
- message["variant"] = variant
+ if (message["variant"] === undefined) {
+ const variant =
+ input.model && input.agent
+ ? resolveVariantForModel(pluginConfig, input.agent, input.model)
+ : resolveAgentVariant(pluginConfig, input.agent)
+ if (variant !== undefined) {
+ message["variant"] = variant
+ }
}
firstMessageVariantGate.markApplied(input.sessionID)
} else {
diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts
index 82a4379f..d93ec585 100644
--- a/src/plugin/hooks/create-session-hooks.ts
+++ b/src/plugin/hooks/create-session-hooks.ts
@@ -122,7 +122,7 @@ export function createSessionHooks(args: {
? safeHook("ralph-loop", () =>
createRalphLoopHook(ctx, {
config: pluginConfig.ralph_loop,
- checkSessionExists: async (sessionId) => sessionExists(sessionId),
+ checkSessionExists: async (sessionId) => await sessionExists(sessionId),
}))
: null
diff --git a/src/plugin/session-agent-resolver.ts b/src/plugin/session-agent-resolver.ts
index 8d9837ff..6cc12b8c 100644
--- a/src/plugin/session-agent-resolver.ts
+++ b/src/plugin/session-agent-resolver.ts
@@ -1,4 +1,5 @@
import { log } from "../shared"
+import { normalizeSDKResponse } from "../shared"
interface SessionMessage {
info?: {
@@ -19,7 +20,7 @@ export async function resolveSessionAgent(
): Promise {
try {
const messagesResp = await client.session.messages({ path: { id: sessionId } })
- const messages = (messagesResp.data ?? []) as SessionMessage[]
+ const messages = normalizeSDKResponse(messagesResp, [] as SessionMessage[])
for (const msg of messages) {
if (msg.info?.agent) {
diff --git a/src/shared/AGENTS.md b/src/shared/AGENTS.md
index db4e1253..b164fa0e 100644
--- a/src/shared/AGENTS.md
+++ b/src/shared/AGENTS.md
@@ -2,21 +2,21 @@
## OVERVIEW
-84 cross-cutting utilities across 6 subdirectories. Import via barrel: `import { log, deepMerge } from "../../shared"`
+96 cross-cutting utilities across 4 subdirectories. Import via barrel: `import { log, deepMerge } from "../../shared"`
## STRUCTURE
```
shared/
├── logger.ts # File logging (/tmp/oh-my-opencode.log) — 62 imports
-├── dynamic-truncator.ts # Token-aware context window management (201 lines)
-├── model-resolver.ts # 3-step resolution (Override → Fallback → Default)
-├── model-availability.ts # Provider model fetching & fuzzy matching (358 lines)
-├── model-requirements.ts # Agent/category fallback chains (160 lines)
-├── model-resolution-pipeline.ts # Pipeline orchestration (175 lines)
+├── dynamic-truncator.ts # Token-aware context window management (202 lines)
+├── model-resolver.ts # 3-step resolution entry point (65 lines)
+├── model-availability.ts # Provider model fetching & fuzzy matching (359 lines)
+├── model-requirements.ts # Agent/category fallback chains (161 lines) — 11 imports
+├── model-resolution-pipeline.ts # Pipeline orchestration (176 lines)
├── model-resolution-types.ts # Resolution request/provenance types
├── model-sanitizer.ts # Model name sanitization
├── model-name-matcher.ts # Model name matching (91 lines)
-├── model-suggestion-retry.ts # Suggest models on failure (129 lines)
+├── model-suggestion-retry.ts # Suggest models on failure (144 lines)
├── model-cache-availability.ts # Cache availability checking
├── fallback-model-availability.ts # Fallback model logic (67 lines)
├── available-models-fetcher.ts # Fetch models from providers (114 lines)
@@ -27,42 +27,34 @@ shared/
├── session-utils.ts # Session cursor, orchestrator detection
├── session-cursor.ts # Message cursor tracking (85 lines)
├── session-injected-paths.ts # Injected file path tracking
-├── permission-compat.ts # Tool restriction enforcement (86 lines)
+├── permission-compat.ts # Tool restriction enforcement (87 lines) — 9 imports
├── agent-tool-restrictions.ts # Tool restriction definitions
├── agent-variant.ts # Agent variant from config (91 lines)
├── agent-display-names.ts # Agent display name mapping
├── first-message-variant.ts # First message variant types
├── opencode-config-dir.ts # ~/.config/opencode resolution (138 lines)
├── claude-config-dir.ts # ~/.claude resolution
-├── data-path.ts # XDG-compliant storage (47 lines)
+├── data-path.ts # XDG-compliant storage (47 lines) — 11 imports
├── jsonc-parser.ts # JSONC with comment support (66 lines)
├── frontmatter.ts # YAML frontmatter extraction (31 lines) — 10 imports
├── deep-merge.ts # Recursive merge (proto-pollution safe, MAX_DEPTH=50)
├── shell-env.ts # Cross-platform shell environment (111 lines)
-├── opencode-version.ts # Semantic version comparison (74 lines)
+├── opencode-version.ts # Semantic version comparison (80 lines)
├── external-plugin-detector.ts # Plugin conflict detection (137 lines)
-├── opencode-server-auth.ts # Authentication utilities (69 lines)
+├── opencode-server-auth.ts # Authentication utilities (190 lines)
├── safe-create-hook.ts # Hook error wrapper (24 lines)
├── pattern-matcher.ts # Pattern matching (40 lines)
-├── file-utils.ts # File operations (40 lines) — 9 imports
+├── file-utils.ts # File operations (34 lines) — 9 imports
├── file-reference-resolver.ts # File reference resolution (85 lines)
├── snake-case.ts # Case conversion (44 lines)
├── tool-name.ts # Tool naming conventions
-├── truncate-description.ts # Description truncation
├── port-utils.ts # Port management (48 lines)
├── zip-extractor.ts # ZIP extraction (83 lines)
├── binary-downloader.ts # Binary download (60 lines)
-├── skill-path-resolver.ts # Skill path resolution
-├── hook-disabled.ts # Hook disable checking
-├── config-errors.ts # Config error types
-├── disabled-tools.ts # Disabled tools tracking
-├── record-type-guard.ts # Record type guard
-├── open-code-client-accessors.ts # Client accessor utilities
-├── open-code-client-shapes.ts # Client shape types
├── command-executor/ # Shell execution (6 files, 213 lines)
├── git-worktree/ # Git status/diff parsing (8 files, 311 lines)
├── migration/ # Legacy config migration (5 files, 341 lines)
-│ ├── config-migration.ts # Migration orchestration (126 lines)
+│ ├── config-migration.ts # Migration orchestration (133 lines)
│ ├── agent-names.ts # Agent name mapping (70 lines)
│ ├── hook-names.ts # Hook name mapping (36 lines)
│ └── model-versions.ts # Model version migration (49 lines)
@@ -86,9 +78,9 @@ shared/
## KEY PATTERNS
**3-Step Model Resolution** (Override → Fallback → Default):
-```typescript
-resolveModelWithFallback({ userModel, fallbackChain, availableModels })
-```
+1. **Override**: UI-selected or user-configured model
+2. **Fallback**: Provider/model chain with availability checking
+3. **Default**: System fallback when no matches found
**System Directive Filtering**:
```typescript
diff --git a/src/shared/agent-config-integration.test.ts b/src/shared/agent-config-integration.test.ts
index e663da9f..1760ca02 100644
--- a/src/shared/agent-config-integration.test.ts
+++ b/src/shared/agent-config-integration.test.ts
@@ -93,10 +93,10 @@ describe("Agent Config Integration", () => {
// then - display names are correct
expect(displayNames).toContain("Sisyphus (Ultraworker)")
- expect(displayNames).toContain("Atlas (Plan Execution Orchestrator)")
+ expect(displayNames).toContain("Atlas (Plan Executor)")
expect(displayNames).toContain("Prometheus (Plan Builder)")
expect(displayNames).toContain("Metis (Plan Consultant)")
- expect(displayNames).toContain("Momus (Plan Reviewer)")
+ expect(displayNames).toContain("Momus (Plan Critic)")
expect(displayNames).toContain("oracle")
expect(displayNames).toContain("librarian")
expect(displayNames).toContain("explore")
@@ -112,9 +112,9 @@ describe("Agent Config Integration", () => {
// then - correct display names are returned
expect(displayNames[0]).toBe("Sisyphus (Ultraworker)")
- expect(displayNames[1]).toBe("Atlas (Plan Execution Orchestrator)")
+ expect(displayNames[1]).toBe("Atlas (Plan Executor)")
expect(displayNames[2]).toBe("Sisyphus (Ultraworker)")
- expect(displayNames[3]).toBe("Atlas (Plan Execution Orchestrator)")
+ expect(displayNames[3]).toBe("Atlas (Plan Executor)")
expect(displayNames[4]).toBe("Prometheus (Plan Builder)")
expect(displayNames[5]).toBe("Prometheus (Plan Builder)")
})
@@ -218,7 +218,7 @@ describe("Agent Config Integration", () => {
// then - display names are correct
expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)")
- expect(atlasDisplay).toBe("Atlas (Plan Execution Orchestrator)")
+ expect(atlasDisplay).toBe("Atlas (Plan Executor)")
})
})
})
diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts
index 628de8b8..3d7276a1 100644
--- a/src/shared/agent-display-names.test.ts
+++ b/src/shared/agent-display-names.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
-import { AGENT_DISPLAY_NAMES, getAgentDisplayName } from "./agent-display-names"
+import { AGENT_DISPLAY_NAMES, getAgentDisplayName, getAgentConfigKey } from "./agent-display-names"
describe("getAgentDisplayName", () => {
it("returns display name for lowercase config key (new format)", () => {
@@ -42,8 +42,8 @@ describe("getAgentDisplayName", () => {
// when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
- // then returns "Atlas (Plan Execution Orchestrator)"
- expect(result).toBe("Atlas (Plan Execution Orchestrator)")
+ // then returns "Atlas (Plan Executor)"
+ expect(result).toBe("Atlas (Plan Executor)")
})
it("returns display name for prometheus", () => {
@@ -86,8 +86,8 @@ describe("getAgentDisplayName", () => {
// when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
- // then returns "Momus (Plan Reviewer)"
- expect(result).toBe("Momus (Plan Reviewer)")
+ // then returns "Momus (Plan Critic)"
+ expect(result).toBe("Momus (Plan Critic)")
})
it("returns display name for oracle", () => {
@@ -135,16 +135,58 @@ describe("getAgentDisplayName", () => {
})
})
+describe("getAgentConfigKey", () => {
+ it("resolves display name to config key", () => {
+ // given display name "Sisyphus (Ultraworker)"
+ // when getAgentConfigKey called
+ // then returns "sisyphus"
+ expect(getAgentConfigKey("Sisyphus (Ultraworker)")).toBe("sisyphus")
+ })
+
+ it("resolves display name case-insensitively", () => {
+ // given display name in different case
+ // when getAgentConfigKey called
+ // then returns "atlas"
+ expect(getAgentConfigKey("atlas (plan executor)")).toBe("atlas")
+ })
+
+ it("passes through lowercase config keys unchanged", () => {
+ // given lowercase config key "prometheus"
+ // when getAgentConfigKey called
+ // then returns "prometheus"
+ expect(getAgentConfigKey("prometheus")).toBe("prometheus")
+ })
+
+ it("returns lowercased unknown agents", () => {
+ // given unknown agent name
+ // when getAgentConfigKey called
+ // then returns lowercased
+ expect(getAgentConfigKey("Custom-Agent")).toBe("custom-agent")
+ })
+
+ it("resolves all core agent display names", () => {
+ // given all core display names
+ // when/then each resolves to its config key
+ expect(getAgentConfigKey("Hephaestus (Deep Agent)")).toBe("hephaestus")
+ expect(getAgentConfigKey("Prometheus (Plan Builder)")).toBe("prometheus")
+ expect(getAgentConfigKey("Atlas (Plan Executor)")).toBe("atlas")
+ expect(getAgentConfigKey("Metis (Plan Consultant)")).toBe("metis")
+ expect(getAgentConfigKey("Momus (Plan Critic)")).toBe("momus")
+ expect(getAgentConfigKey("Sisyphus-Junior")).toBe("sisyphus-junior")
+ })
+})
+
describe("AGENT_DISPLAY_NAMES", () => {
it("contains all expected agent mappings", () => {
// given expected mappings
const expectedMappings = {
sisyphus: "Sisyphus (Ultraworker)",
- atlas: "Atlas (Plan Execution Orchestrator)",
+ hephaestus: "Hephaestus (Deep Agent)",
prometheus: "Prometheus (Plan Builder)",
+ atlas: "Atlas (Plan Executor)",
"sisyphus-junior": "Sisyphus-Junior",
metis: "Metis (Plan Consultant)",
- momus: "Momus (Plan Reviewer)",
+ momus: "Momus (Plan Critic)",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts
index 82c08b2c..a0bda224 100644
--- a/src/shared/agent-display-names.ts
+++ b/src/shared/agent-display-names.ts
@@ -5,11 +5,12 @@
*/
export const AGENT_DISPLAY_NAMES: Record = {
sisyphus: "Sisyphus (Ultraworker)",
- atlas: "Atlas (Plan Execution Orchestrator)",
+ hephaestus: "Hephaestus (Deep Agent)",
prometheus: "Prometheus (Plan Builder)",
+ atlas: "Atlas (Plan Executor)",
"sisyphus-junior": "Sisyphus-Junior",
metis: "Metis (Plan Consultant)",
- momus: "Momus (Plan Reviewer)",
+ momus: "Momus (Plan Critic)",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
@@ -34,4 +35,20 @@ export function getAgentDisplayName(configKey: string): string {
// Unknown agent: return original key
return configKey
+}
+
+const REVERSE_DISPLAY_NAMES: Record = Object.fromEntries(
+ Object.entries(AGENT_DISPLAY_NAMES).map(([key, displayName]) => [displayName.toLowerCase(), key]),
+)
+
+/**
+ * Resolve an agent name (display name or config key) to its lowercase config key.
+ * "Atlas (Plan Executor)" → "atlas", "atlas" → "atlas", "unknown" → "unknown"
+ */
+export function getAgentConfigKey(agentName: string): string {
+ const lower = agentName.toLowerCase()
+ const reversed = REVERSE_DISPLAY_NAMES[lower]
+ if (reversed !== undefined) return reversed
+ if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower
+ return lower
}
\ No newline at end of file
diff --git a/src/shared/available-models-fetcher.ts b/src/shared/available-models-fetcher.ts
index b19defce..790ad77e 100644
--- a/src/shared/available-models-fetcher.ts
+++ b/src/shared/available-models-fetcher.ts
@@ -2,6 +2,7 @@ import { addModelsFromModelsJsonCache } from "./models-json-cache-reader"
import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors"
import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader"
import { log } from "./logger"
+import { normalizeSDKResponse } from "./normalize-sdk-response"
export async function getConnectedProviders(client: unknown): Promise {
const providerList = getProviderListFunction(client)
@@ -53,7 +54,7 @@ export async function fetchAvailableModels(
const modelSet = new Set()
try {
const modelsResult = await modelList()
- const models = modelsResult.data ?? []
+ const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
for (const model of models) {
if (model.provider && model.id) {
modelSet.add(`${model.provider}/${model.id}`)
@@ -92,7 +93,7 @@ export async function fetchAvailableModels(
if (modelList) {
try {
const modelsResult = await modelList()
- const models = modelsResult.data ?? []
+ const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
for (const model of models) {
if (!model.provider || !model.id) continue
diff --git a/src/shared/dynamic-truncator.ts b/src/shared/dynamic-truncator.ts
index 017bca16..dbd90466 100644
--- a/src/shared/dynamic-truncator.ts
+++ b/src/shared/dynamic-truncator.ts
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin";
+import { normalizeSDKResponse } from "./normalize-sdk-response"
const ANTHROPIC_ACTUAL_LIMIT =
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
@@ -119,7 +120,7 @@ export async function getContextWindowUsage(
path: { id: sessionID },
});
- const messages = (response.data ?? response) as MessageWrapper[];
+ const messages = normalizeSDKResponse(response, [] as MessageWrapper[], { preferResponseOnMissingData: true })
const assistantMessages = messages
.filter((m) => m.info.role === "assistant")
diff --git a/src/shared/index.ts b/src/shared/index.ts
index 07d8bd86..85a62b83 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -22,6 +22,7 @@ export type {
OpenCodeConfigPaths,
} from "./opencode-config-dir-types"
export * from "./opencode-version"
+export * from "./opencode-storage-detection"
export * from "./permission-compat"
export * from "./external-plugin-detector"
export * from "./zip-extractor"
@@ -37,7 +38,7 @@ export { resolveModelPipeline } from "./model-resolution-pipeline"
export type {
ModelResolutionRequest,
ModelResolutionProvenance,
- ModelResolutionResult as ModelResolutionPipelineResult,
+ ModelResolutionResult,
} from "./model-resolution-types"
export * from "./model-availability"
export * from "./connected-providers-cache"
@@ -45,7 +46,11 @@ export * from "./session-utils"
export * from "./tmux"
export * from "./model-suggestion-retry"
export * from "./opencode-server-auth"
+export * from "./opencode-http-api"
export * from "./port-utils"
export * from "./git-worktree"
export * from "./safe-create-hook"
export * from "./truncate-description"
+export * from "./opencode-storage-paths"
+export * from "./opencode-message-dir"
+export * from "./normalize-sdk-response"
diff --git a/src/shared/model-availability.ts b/src/shared/model-availability.ts
index 1ff696ee..0943ce85 100644
--- a/src/shared/model-availability.ts
+++ b/src/shared/model-availability.ts
@@ -3,6 +3,7 @@ import { join } from "path"
import { log } from "./logger"
import { getOpenCodeCacheDir } from "./data-path"
import * as connectedProvidersCache from "./connected-providers-cache"
+import { normalizeSDKResponse } from "./normalize-sdk-response"
/**
* Fuzzy match a target model name against available models
@@ -159,7 +160,7 @@ export async function fetchAvailableModels(
const modelSet = new Set()
try {
const modelsResult = await client.model.list()
- const models = modelsResult.data ?? []
+ const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
for (const model of models) {
if (model?.provider && model?.id) {
modelSet.add(`${model.provider}/${model.id}`)
@@ -261,7 +262,7 @@ export async function fetchAvailableModels(
if (client?.model?.list) {
try {
const modelsResult = await client.model.list()
- const models = modelsResult.data ?? []
+ const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
for (const model of models) {
if (!model?.provider || !model?.id) continue
diff --git a/src/shared/normalize-sdk-response.test.ts b/src/shared/normalize-sdk-response.test.ts
new file mode 100644
index 00000000..870519d7
--- /dev/null
+++ b/src/shared/normalize-sdk-response.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "bun:test"
+import { normalizeSDKResponse } from "./normalize-sdk-response"
+
+describe("normalizeSDKResponse", () => {
+ it("returns data array when response includes data", () => {
+ //#given
+ const response = { data: [{ id: "1" }] }
+
+ //#when
+ const result = normalizeSDKResponse(response, [] as Array<{ id: string }>)
+
+ //#then
+ expect(result).toEqual([{ id: "1" }])
+ })
+
+ it("returns fallback array when data is missing", () => {
+ //#given
+ const response = {}
+ const fallback = [{ id: "fallback" }]
+
+ //#when
+ const result = normalizeSDKResponse(response, fallback)
+
+ //#then
+ expect(result).toEqual(fallback)
+ })
+
+ it("returns response array directly when SDK returns plain array", () => {
+ //#given
+ const response = [{ id: "2" }]
+
+ //#when
+ const result = normalizeSDKResponse(response, [] as Array<{ id: string }>)
+
+ //#then
+ expect(result).toEqual([{ id: "2" }])
+ })
+
+ it("returns response when data missing and preferResponseOnMissingData is true", () => {
+ //#given
+ const response = { value: "legacy" }
+
+ //#when
+ const result = normalizeSDKResponse(response, { value: "fallback" }, { preferResponseOnMissingData: true })
+
+ //#then
+ expect(result).toEqual({ value: "legacy" })
+ })
+
+ it("returns fallback for null response", () => {
+ //#given
+ const response = null
+
+ //#when
+ const result = normalizeSDKResponse(response, [] as string[])
+
+ //#then
+ expect(result).toEqual([])
+ })
+
+ it("returns object fallback for direct data nullish pattern", () => {
+ //#given
+ const response = { data: undefined as { connected: string[] } | undefined }
+ const fallback = { connected: [] }
+
+ //#when
+ const result = normalizeSDKResponse(response, fallback)
+
+ //#then
+ expect(result).toEqual(fallback)
+ })
+})
diff --git a/src/shared/normalize-sdk-response.ts b/src/shared/normalize-sdk-response.ts
new file mode 100644
index 00000000..080cc992
--- /dev/null
+++ b/src/shared/normalize-sdk-response.ts
@@ -0,0 +1,36 @@
+export interface NormalizeSDKResponseOptions {
+ preferResponseOnMissingData?: boolean
+}
+
+export function normalizeSDKResponse(
+ response: unknown,
+ fallback: TData,
+ options?: NormalizeSDKResponseOptions,
+): TData {
+ if (response === null || response === undefined) {
+ return fallback
+ }
+
+ if (Array.isArray(response)) {
+ return response as TData
+ }
+
+ if (typeof response === "object" && "data" in response) {
+ const data = (response as { data?: unknown }).data
+ if (data !== null && data !== undefined) {
+ return data as TData
+ }
+
+ if (options?.preferResponseOnMissingData === true) {
+ return response as TData
+ }
+
+ return fallback
+ }
+
+ if (options?.preferResponseOnMissingData === true) {
+ return response as TData
+ }
+
+ return fallback
+}
diff --git a/src/shared/opencode-http-api.test.ts b/src/shared/opencode-http-api.test.ts
new file mode 100644
index 00000000..80b86bae
--- /dev/null
+++ b/src/shared/opencode-http-api.test.ts
@@ -0,0 +1,178 @@
+import { describe, it, expect, vi, beforeEach } from "bun:test"
+import { getServerBaseUrl, patchPart, deletePart } from "./opencode-http-api"
+
+// Mock fetch globally
+const mockFetch = vi.fn()
+global.fetch = mockFetch
+
+// Mock log
+vi.mock("./logger", () => ({
+ log: vi.fn(),
+}))
+
+import { log } from "./logger"
+
+describe("getServerBaseUrl", () => {
+ it("returns baseUrl from client._client.getConfig().baseUrl", () => {
+ // given
+ const mockClient = {
+ _client: {
+ getConfig: () => ({ baseUrl: "https://api.example.com" }),
+ },
+ }
+
+ // when
+ const result = getServerBaseUrl(mockClient)
+
+ // then
+ expect(result).toBe("https://api.example.com")
+ })
+
+ it("returns baseUrl from client.session._client.getConfig().baseUrl when first attempt fails", () => {
+ // given
+ const mockClient = {
+ _client: {
+ getConfig: () => ({}),
+ },
+ session: {
+ _client: {
+ getConfig: () => ({ baseUrl: "https://session.example.com" }),
+ },
+ },
+ }
+
+ // when
+ const result = getServerBaseUrl(mockClient)
+
+ // then
+ expect(result).toBe("https://session.example.com")
+ })
+
+ it("returns null for incompatible client", () => {
+ // given
+ const mockClient = {}
+
+ // when
+ const result = getServerBaseUrl(mockClient)
+
+ // then
+ expect(result).toBeNull()
+ })
+})
+
+describe("patchPart", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetch.mockResolvedValue({ ok: true })
+ process.env.OPENCODE_SERVER_PASSWORD = "testpassword"
+ process.env.OPENCODE_SERVER_USERNAME = "opencode"
+ })
+
+ it("constructs correct URL and sends PATCH with auth", async () => {
+ // given
+ const mockClient = {
+ _client: {
+ getConfig: () => ({ baseUrl: "https://api.example.com" }),
+ },
+ }
+ const sessionID = "ses123"
+ const messageID = "msg456"
+ const partID = "part789"
+ const body = { content: "test" }
+
+ // when
+ const result = await patchPart(mockClient, sessionID, messageID, partID, body)
+
+ // then
+ expect(result).toBe(true)
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://api.example.com/session/ses123/message/msg456/part/part789",
+ expect.objectContaining({
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Basic b3BlbmNvZGU6dGVzdHBhc3N3b3Jk",
+ },
+ body: JSON.stringify(body),
+ signal: expect.any(AbortSignal),
+ })
+ )
+ })
+
+ it("returns false on network error", async () => {
+ // given
+ const mockClient = {
+ _client: {
+ getConfig: () => ({ baseUrl: "https://api.example.com" }),
+ },
+ }
+ mockFetch.mockRejectedValue(new Error("Network error"))
+
+ // when
+ const result = await patchPart(mockClient, "ses123", "msg456", "part789", {})
+
+ // then
+ expect(result).toBe(false)
+ expect(log).toHaveBeenCalledWith("[opencode-http-api] PATCH error", {
+ message: "Network error",
+ url: "https://api.example.com/session/ses123/message/msg456/part/part789",
+ })
+ })
+})
+
+describe("deletePart", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetch.mockResolvedValue({ ok: true })
+ process.env.OPENCODE_SERVER_PASSWORD = "testpassword"
+ process.env.OPENCODE_SERVER_USERNAME = "opencode"
+ })
+
+ it("constructs correct URL and sends DELETE", async () => {
+ // given
+ const mockClient = {
+ _client: {
+ getConfig: () => ({ baseUrl: "https://api.example.com" }),
+ },
+ }
+ const sessionID = "ses123"
+ const messageID = "msg456"
+ const partID = "part789"
+
+ // when
+ const result = await deletePart(mockClient, sessionID, messageID, partID)
+
+ // then
+ expect(result).toBe(true)
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://api.example.com/session/ses123/message/msg456/part/part789",
+ expect.objectContaining({
+ method: "DELETE",
+ headers: {
+ "Authorization": "Basic b3BlbmNvZGU6dGVzdHBhc3N3b3Jk",
+ },
+ signal: expect.any(AbortSignal),
+ })
+ )
+ })
+
+ it("returns false on non-ok response", async () => {
+ // given
+ const mockClient = {
+ _client: {
+ getConfig: () => ({ baseUrl: "https://api.example.com" }),
+ },
+ }
+ mockFetch.mockResolvedValue({ ok: false, status: 404 })
+
+ // when
+ const result = await deletePart(mockClient, "ses123", "msg456", "part789")
+
+ // then
+ expect(result).toBe(false)
+ expect(log).toHaveBeenCalledWith("[opencode-http-api] DELETE failed", {
+ status: 404,
+ url: "https://api.example.com/session/ses123/message/msg456/part/part789",
+ })
+ })
+})
\ No newline at end of file
diff --git a/src/shared/opencode-http-api.ts b/src/shared/opencode-http-api.ts
new file mode 100644
index 00000000..451d98e6
--- /dev/null
+++ b/src/shared/opencode-http-api.ts
@@ -0,0 +1,140 @@
+import { getServerBasicAuthHeader } from "./opencode-server-auth"
+import { log } from "./logger"
+import { isRecord } from "./record-type-guard"
+
+type UnknownRecord = Record
+
+function getInternalClient(client: unknown): UnknownRecord | null {
+ if (!isRecord(client)) {
+ return null
+ }
+
+ const internal = client["_client"]
+ return isRecord(internal) ? internal : null
+}
+
+export function getServerBaseUrl(client: unknown): string | null {
+ // Try client._client.getConfig().baseUrl
+ const internal = getInternalClient(client)
+ if (internal) {
+ const getConfig = internal["getConfig"]
+ if (typeof getConfig === "function") {
+ const config = getConfig()
+ if (isRecord(config)) {
+ const baseUrl = config["baseUrl"]
+ if (typeof baseUrl === "string") {
+ return baseUrl
+ }
+ }
+ }
+ }
+
+ // Try client.session._client.getConfig().baseUrl
+ if (isRecord(client)) {
+ const session = client["session"]
+ if (isRecord(session)) {
+ const internal = session["_client"]
+ if (isRecord(internal)) {
+ const getConfig = internal["getConfig"]
+ if (typeof getConfig === "function") {
+ const config = getConfig()
+ if (isRecord(config)) {
+ const baseUrl = config["baseUrl"]
+ if (typeof baseUrl === "string") {
+ return baseUrl
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return null
+}
+
+export async function patchPart(
+ client: unknown,
+ sessionID: string,
+ messageID: string,
+ partID: string,
+ body: Record
+): Promise {
+ const baseUrl = getServerBaseUrl(client)
+ if (!baseUrl) {
+ log("[opencode-http-api] Could not extract baseUrl from client")
+ return false
+ }
+
+ const auth = getServerBasicAuthHeader()
+ if (!auth) {
+ log("[opencode-http-api] No auth header available")
+ return false
+ }
+
+ const url = `${baseUrl}/session/${encodeURIComponent(sessionID)}/message/${encodeURIComponent(messageID)}/part/${encodeURIComponent(partID)}`
+
+ try {
+ const response = await fetch(url, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": auth,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10_000),
+ })
+
+ if (!response.ok) {
+ log("[opencode-http-api] PATCH failed", { status: response.status, url })
+ return false
+ }
+
+ return true
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ log("[opencode-http-api] PATCH error", { message, url })
+ return false
+ }
+}
+
+export async function deletePart(
+ client: unknown,
+ sessionID: string,
+ messageID: string,
+ partID: string
+): Promise {
+ const baseUrl = getServerBaseUrl(client)
+ if (!baseUrl) {
+ log("[opencode-http-api] Could not extract baseUrl from client")
+ return false
+ }
+
+ const auth = getServerBasicAuthHeader()
+ if (!auth) {
+ log("[opencode-http-api] No auth header available")
+ return false
+ }
+
+ const url = `${baseUrl}/session/${encodeURIComponent(sessionID)}/message/${encodeURIComponent(messageID)}/part/${encodeURIComponent(partID)}`
+
+ try {
+ const response = await fetch(url, {
+ method: "DELETE",
+ headers: {
+ "Authorization": auth,
+ },
+ signal: AbortSignal.timeout(10_000),
+ })
+
+ if (!response.ok) {
+ log("[opencode-http-api] DELETE failed", { status: response.status, url })
+ return false
+ }
+
+ return true
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ log("[opencode-http-api] DELETE error", { message, url })
+ return false
+ }
+}
\ No newline at end of file
diff --git a/src/shared/opencode-message-dir.test.ts b/src/shared/opencode-message-dir.test.ts
new file mode 100644
index 00000000..521ddcdc
--- /dev/null
+++ b/src/shared/opencode-message-dir.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"
+import { mkdirSync, rmSync } from "node:fs"
+import { join } from "node:path"
+import { tmpdir } from "node:os"
+import { randomUUID } from "node:crypto"
+
+const TEST_STORAGE = join(tmpdir(), `omo-msgdir-test-${randomUUID()}`)
+const TEST_MESSAGE_STORAGE = join(TEST_STORAGE, "message")
+
+mock.module("./opencode-storage-paths", () => ({
+ OPENCODE_STORAGE: TEST_STORAGE,
+ MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
+ PART_STORAGE: join(TEST_STORAGE, "part"),
+ SESSION_STORAGE: join(TEST_STORAGE, "session"),
+}))
+
+mock.module("./opencode-storage-detection", () => ({
+ isSqliteBackend: () => false,
+ resetSqliteBackendCache: () => {},
+}))
+
+const { getMessageDir } = await import("./opencode-message-dir")
+
+describe("getMessageDir", () => {
+ beforeEach(() => {
+ mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true })
+ })
+
+ afterEach(() => {
+ try { rmSync(TEST_MESSAGE_STORAGE, { recursive: true, force: true }) } catch {}
+ })
+
+ afterAll(() => {
+ try { rmSync(TEST_STORAGE, { recursive: true, force: true }) } catch {}
+ })
+
+ it("returns null when sessionID does not start with ses_", () => {
+ //#given - sessionID without ses_ prefix
+ //#when
+ const result = getMessageDir("invalid")
+ //#then
+ expect(result).toBe(null)
+ })
+
+ it("returns null when MESSAGE_STORAGE does not exist", () => {
+ //#given
+ rmSync(TEST_MESSAGE_STORAGE, { recursive: true, force: true })
+ //#when
+ const result = getMessageDir("ses_123")
+ //#then
+ expect(result).toBe(null)
+ })
+
+ it("returns direct path when session exists directly", () => {
+ //#given
+ const sessionDir = join(TEST_MESSAGE_STORAGE, "ses_123")
+ mkdirSync(sessionDir, { recursive: true })
+ //#when
+ const result = getMessageDir("ses_123")
+ //#then
+ expect(result).toBe(sessionDir)
+ })
+
+ it("returns subdirectory path when session exists in subdirectory", () => {
+ //#given
+ const sessionDir = join(TEST_MESSAGE_STORAGE, "subdir", "ses_123")
+ mkdirSync(sessionDir, { recursive: true })
+ //#when
+ const result = getMessageDir("ses_123")
+ //#then
+ expect(result).toBe(sessionDir)
+ })
+
+ it("returns null for path traversal attempts with ..", () => {
+ //#given - sessionID containing path traversal
+ //#when
+ const result = getMessageDir("ses_../etc/passwd")
+ //#then
+ expect(result).toBe(null)
+ })
+
+ it("returns null for path traversal attempts with forward slash", () => {
+ //#given - sessionID containing forward slash
+ //#when
+ const result = getMessageDir("ses_foo/bar")
+ //#then
+ expect(result).toBe(null)
+ })
+
+ it("returns null for path traversal attempts with backslash", () => {
+ //#given - sessionID containing backslash
+ //#when
+ const result = getMessageDir("ses_foo\\bar")
+ //#then
+ expect(result).toBe(null)
+ })
+
+ it("returns null when session not found anywhere", () => {
+ //#given
+ mkdirSync(join(TEST_MESSAGE_STORAGE, "subdir1"), { recursive: true })
+ mkdirSync(join(TEST_MESSAGE_STORAGE, "subdir2"), { recursive: true })
+ //#when
+ const result = getMessageDir("ses_nonexistent")
+ //#then
+ expect(result).toBe(null)
+ })
+})
\ No newline at end of file
diff --git a/src/shared/opencode-message-dir.ts b/src/shared/opencode-message-dir.ts
new file mode 100644
index 00000000..c8d8e3b3
--- /dev/null
+++ b/src/shared/opencode-message-dir.ts
@@ -0,0 +1,31 @@
+import { existsSync, readdirSync } from "node:fs"
+import { join } from "node:path"
+import { MESSAGE_STORAGE } from "./opencode-storage-paths"
+import { isSqliteBackend } from "./opencode-storage-detection"
+import { log } from "./logger"
+
+export function getMessageDir(sessionID: string): string | null {
+ if (!sessionID.startsWith("ses_")) return null
+ if (/[/\\]|\.\./.test(sessionID)) return null
+ if (isSqliteBackend()) return null
+ if (!existsSync(MESSAGE_STORAGE)) return null
+
+ const directPath = join(MESSAGE_STORAGE, sessionID)
+ if (existsSync(directPath)) {
+ return directPath
+ }
+
+ try {
+ for (const dir of readdirSync(MESSAGE_STORAGE)) {
+ const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
+ if (existsSync(sessionPath)) {
+ return sessionPath
+ }
+ }
+ } catch (error) {
+ log("[opencode-message-dir] Failed to scan message directories", { sessionID, error: String(error) })
+ return null
+ }
+
+ return null
+}
\ No newline at end of file
diff --git a/src/shared/opencode-storage-detection.test.ts b/src/shared/opencode-storage-detection.test.ts
new file mode 100644
index 00000000..12238e50
--- /dev/null
+++ b/src/shared/opencode-storage-detection.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, beforeEach, mock } from "bun:test"
+import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
+import { join } from "node:path"
+import { tmpdir } from "node:os"
+import { randomUUID } from "node:crypto"
+
+const TEST_DATA_DIR = join(tmpdir(), `omo-sqlite-detect-${randomUUID()}`)
+const DB_PATH = join(TEST_DATA_DIR, "opencode", "opencode.db")
+
+let versionCheckCalls: string[] = []
+let versionReturnValue = true
+const SQLITE_VERSION = "1.1.53"
+
+// Inline isSqliteBackend implementation to avoid mock pollution from other test files.
+// Other files (e.g., opencode-message-dir.test.ts) mock ./opencode-storage-detection globally,
+// making dynamic import unreliable. By inlining, we test the actual logic with controlled deps.
+const NOT_CACHED = Symbol("NOT_CACHED")
+const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY")
+let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED
+
+function isSqliteBackend(): boolean {
+ if (cachedResult === true) return true
+ if (cachedResult === false) return false
+ if (cachedResult === FALSE_PENDING_RETRY) {
+ const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })()
+ const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db")
+ const dbExists = existsSync(dbPath)
+ const result = versionOk && dbExists
+ cachedResult = result
+ return result
+ }
+ const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })()
+ const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db")
+ const dbExists = existsSync(dbPath)
+ const result = versionOk && dbExists
+ if (result) { cachedResult = true }
+ else { cachedResult = FALSE_PENDING_RETRY }
+ return result
+}
+
+function resetSqliteBackendCache(): void {
+ cachedResult = NOT_CACHED
+}
+
+describe("isSqliteBackend", () => {
+ beforeEach(() => {
+ resetSqliteBackendCache()
+ versionCheckCalls = []
+ versionReturnValue = true
+ try { rmSync(TEST_DATA_DIR, { recursive: true, force: true }) } catch {}
+ })
+
+ it("returns false when version is below threshold", () => {
+ //#given
+ versionReturnValue = false
+ mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
+ writeFileSync(DB_PATH, "")
+
+ //#when
+ const result = isSqliteBackend()
+
+ //#then
+ expect(result).toBe(false)
+ expect(versionCheckCalls).toContain("1.1.53")
+ })
+
+ it("returns false when DB file does not exist", () => {
+ //#given
+ versionReturnValue = true
+
+ //#when
+ const result = isSqliteBackend()
+
+ //#then
+ expect(result).toBe(false)
+ })
+
+ it("returns true when version is at or above threshold and DB exists", () => {
+ //#given
+ versionReturnValue = true
+ mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
+ writeFileSync(DB_PATH, "")
+
+ //#when
+ const result = isSqliteBackend()
+
+ //#then
+ expect(result).toBe(true)
+ expect(versionCheckCalls).toContain("1.1.53")
+ })
+
+ it("caches true permanently and does not re-check", () => {
+ //#given
+ versionReturnValue = true
+ mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
+ writeFileSync(DB_PATH, "")
+
+ //#when
+ isSqliteBackend()
+ isSqliteBackend()
+ isSqliteBackend()
+
+ //#then
+ expect(versionCheckCalls.length).toBe(1)
+ })
+
+ it("retries once when first result is false, then caches permanently", () => {
+ //#given
+ versionReturnValue = true
+
+ //#when: first call — DB does not exist
+ const first = isSqliteBackend()
+
+ //#then
+ expect(first).toBe(false)
+ expect(versionCheckCalls.length).toBe(1)
+
+ //#when: second call — DB still does not exist (retry)
+ const second = isSqliteBackend()
+
+ //#then: retried once
+ expect(second).toBe(false)
+ expect(versionCheckCalls.length).toBe(2)
+
+ //#when: third call — no more retries
+ const third = isSqliteBackend()
+
+ //#then: no further checks
+ expect(third).toBe(false)
+ expect(versionCheckCalls.length).toBe(2)
+ })
+
+ it("recovers on retry when DB appears after first false", () => {
+ //#given
+ versionReturnValue = true
+
+ //#when: first call — DB does not exist
+ const first = isSqliteBackend()
+
+ //#then
+ expect(first).toBe(false)
+
+ //#given: DB appears before retry
+ mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
+ writeFileSync(DB_PATH, "")
+
+ //#when: second call — retry finds DB
+ const second = isSqliteBackend()
+
+ //#then: recovers to true and caches permanently
+ expect(second).toBe(true)
+ expect(versionCheckCalls.length).toBe(2)
+
+ //#when: third call — cached true
+ const third = isSqliteBackend()
+
+ //#then: no further checks
+ expect(third).toBe(true)
+ expect(versionCheckCalls.length).toBe(2)
+ })
+})
\ No newline at end of file
diff --git a/src/shared/opencode-storage-detection.ts b/src/shared/opencode-storage-detection.ts
new file mode 100644
index 00000000..930f9e1f
--- /dev/null
+++ b/src/shared/opencode-storage-detection.ts
@@ -0,0 +1,34 @@
+import { existsSync } from "node:fs"
+import { join } from "node:path"
+import { getDataDir } from "./data-path"
+import { isOpenCodeVersionAtLeast, OPENCODE_SQLITE_VERSION } from "./opencode-version"
+
+const NOT_CACHED = Symbol("NOT_CACHED")
+const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY")
+let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED
+
+export function isSqliteBackend(): boolean {
+ if (cachedResult === true) return true
+ if (cachedResult === false) return false
+
+ const check = (): boolean => {
+ const versionOk = isOpenCodeVersionAtLeast(OPENCODE_SQLITE_VERSION)
+ const dbPath = join(getDataDir(), "opencode", "opencode.db")
+ return versionOk && existsSync(dbPath)
+ }
+
+ if (cachedResult === FALSE_PENDING_RETRY) {
+ const result = check()
+ cachedResult = result
+ return result
+ }
+
+ const result = check()
+ if (result) { cachedResult = true }
+ else { cachedResult = FALSE_PENDING_RETRY }
+ return result
+}
+
+export function resetSqliteBackendCache(): void {
+ cachedResult = NOT_CACHED
+}
\ No newline at end of file
diff --git a/src/shared/opencode-storage-paths.ts b/src/shared/opencode-storage-paths.ts
new file mode 100644
index 00000000..baf1a4dc
--- /dev/null
+++ b/src/shared/opencode-storage-paths.ts
@@ -0,0 +1,7 @@
+import { join } from "node:path"
+import { getOpenCodeStorageDir } from "./data-path"
+
+export const OPENCODE_STORAGE = getOpenCodeStorageDir()
+export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
+export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
+export const SESSION_STORAGE = join(OPENCODE_STORAGE, "session")
\ No newline at end of file
diff --git a/src/shared/opencode-version.ts b/src/shared/opencode-version.ts
index f02161ac..e4eecd76 100644
--- a/src/shared/opencode-version.ts
+++ b/src/shared/opencode-version.ts
@@ -15,6 +15,12 @@ export const MINIMUM_OPENCODE_VERSION = "1.1.1"
*/
export const OPENCODE_NATIVE_AGENTS_INJECTION_VERSION = "1.1.37"
+/**
+ * OpenCode version that introduced SQLite backend for storage.
+ * When this version is detected AND opencode.db exists, SQLite backend is used.
+ */
+export const OPENCODE_SQLITE_VERSION = "1.1.53"
+
const NOT_CACHED = Symbol("NOT_CACHED")
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
diff --git a/src/shared/session-utils.ts b/src/shared/session-utils.ts
index eb983974..5884da78 100644
--- a/src/shared/session-utils.ts
+++ b/src/shared/session-utils.ts
@@ -1,27 +1,25 @@
-import * as path from "node:path"
-import * as os from "node:os"
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../features/hook-message-injector"
+import { findNearestMessageWithFields, findNearestMessageWithFieldsFromSDK } from "../features/hook-message-injector"
+import { getMessageDir } from "./opencode-message-dir"
+import { isSqliteBackend } from "./opencode-storage-detection"
+import { log } from "./logger"
+import { getAgentConfigKey } from "./agent-display-names"
+import type { PluginInput } from "@opencode-ai/plugin"
-export function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
+export async function isCallerOrchestrator(sessionID?: string, client?: PluginInput["client"]): Promise {
+ if (!sessionID) return false
- 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
+ if (isSqliteBackend() && client) {
+ try {
+ const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID)
+ return getAgentConfigKey(nearest?.agent ?? "") === "atlas"
+ } catch (error) {
+ log("[session-utils] SDK orchestrator check failed", { sessionID, error: String(error) })
+ return false
+ }
}
- return null
-}
-
-export function isCallerOrchestrator(sessionID?: string): boolean {
- if (!sessionID) return false
const messageDir = getMessageDir(sessionID)
if (!messageDir) return false
const nearest = findNearestMessageWithFields(messageDir)
- return nearest?.agent?.toLowerCase() === "atlas"
+ return getAgentConfigKey(nearest?.agent ?? "") === "atlas"
}
diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md
index ac6e359b..6c8731ca 100644
--- a/src/tools/AGENTS.md
+++ b/src/tools/AGENTS.md
@@ -2,19 +2,19 @@
## OVERVIEW
-24 tools across 14 directories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent).
+26 tools across 14 directories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent).
## STRUCTURE
```
tools/
├── delegate-task/ # Category routing (constants.ts 569 lines, tools.ts 213 lines)
-├── task/ # 4 individual tools: create, list, get, update (task-create.ts, task-list.ts, task-get.ts, task-update.ts)
+├── task/ # 4 tools: create, list, get, update (task-create.ts, task-list.ts, task-get.ts, task-update.ts)
├── lsp/ # 6 LSP tools: goto_definition, find_references, symbols, diagnostics, prepare_rename, rename
├── ast-grep/ # 2 tools: search, replace (25 languages)
-├── grep/ # Custom grep (60s timeout, 10MB limit)
-├── glob/ # File search (60s timeout, 100 file limit)
-├── session-manager/ # 4 tools: list, read, search, info (151 lines)
-├── call-omo-agent/ # Direct agent invocation (57 lines)
+├── grep/ # Content search (60s timeout, 10MB limit)
+├── glob/ # File pattern matching (60s timeout, 100 file limit)
+├── session-manager/ # 4 tools: list, read, search, info
+├── call-omo-agent/ # Direct agent invocation (explore/librarian)
├── background-task/ # background_output, background_cancel
├── interactive-bash/ # Tmux session management (135 lines)
├── look-at/ # Multimodal PDF/image analysis (156 lines)
@@ -27,13 +27,14 @@ tools/
| Tool | Category | Pattern | Key Logic |
|------|----------|---------|-----------|
-| `task_create` | Task | Factory | Create task with auto-generated T-{uuid} ID, threadID recording |
-| `task_list` | Task | Factory | List active tasks with summary (excludes completed/deleted) |
-| `task_get` | Task | Factory | Retrieve full task object by ID |
-| `task_update` | Task | Factory | Update task fields, supports addBlocks/addBlockedBy for dependencies |
+| `task_create` | Task | Factory | Auto-generated T-{uuid} ID, threadID recording, dependency management |
+| `task_list` | Task | Factory | Active tasks with summary (excludes completed/deleted), filters unresolved blockers |
+| `task_get` | Task | Factory | Full task object by ID |
+| `task_update` | Task | Factory | Status/field updates, additive addBlocks/addBlockedBy for dependencies |
+| `task` | Delegation | Factory | Category routing with skill injection, background execution |
| `call_omo_agent` | Agent | Factory | Direct explore/librarian invocation |
-| `background_output` | Background | Factory | Retrieve background task result |
-| `background_cancel` | Background | Factory | Cancel running background tasks |
+| `background_output` | Background | Factory | Retrieve background task result (block, timeout, full_session) |
+| `background_cancel` | Background | Factory | Cancel running/all background tasks |
| `lsp_goto_definition` | LSP | Direct | Jump to symbol definition |
| `lsp_find_references` | LSP | Direct | Find all usages across workspace |
| `lsp_symbols` | LSP | Direct | Document or workspace symbol search |
@@ -41,121 +42,33 @@ tools/
| `lsp_prepare_rename` | LSP | Direct | Validate rename is possible |
| `lsp_rename` | LSP | Direct | Rename symbol across workspace |
| `ast_grep_search` | Search | Factory | AST-aware code search (25 languages) |
-| `ast_grep_replace` | Search | Factory | AST-aware code replacement |
+| `ast_grep_replace` | Search | Factory | AST-aware code replacement (dry-run default) |
| `grep` | Search | Factory | Regex content search with safety limits |
| `glob` | Search | Factory | File pattern matching |
| `session_list` | Session | Factory | List all sessions |
-| `session_read` | Session | Factory | Read session messages |
+| `session_read` | Session | Factory | Read session messages with filters |
| `session_search` | Session | Factory | Search across sessions |
| `session_info` | Session | Factory | Session metadata and stats |
| `interactive_bash` | System | Direct | Tmux session management |
-| `look_at` | System | Factory | Multimodal PDF/image analysis |
-| `skill` | Skill | Factory | Execute skill with MCP capabilities |
-| `skill_mcp` | Skill | Factory | Call MCP tools/resources/prompts |
-| `slashcommand` | Command | Factory | Slash command dispatch |
-
-## TASK TOOLS
-
-Task management system with auto-generated T-{uuid} IDs, dependency tracking, and OpenCode Todo API sync.
-
-### task_create
-
-Create a new task with auto-generated ID and threadID recording.
-
-**Args:**
-| Arg | Type | Required | Description |
-|-----|------|----------|-------------|
-| `subject` | string | Yes | Task subject/title |
-| `description` | string | No | Task description |
-| `activeForm` | string | No | Active form (present continuous) |
-| `metadata` | Record | No | Task metadata |
-| `blockedBy` | string[] | No | Task IDs that must complete before this task |
-| `blocks` | string[] | No | Task IDs this task blocks |
-| `repoURL` | string | No | Repository URL |
-| `parentID` | string | No | Parent task ID |
-
-**Example:**
-```typescript
-task_create({
- subject: "Implement user authentication",
- description: "Add JWT-based auth to API endpoints",
- blockedBy: ["T-abc123"] // Wait for database migration
-})
-```
-
-**Returns:** `{ task: { id, subject } }`
-
-### task_list
-
-List all active tasks with summary information.
-
-**Args:** None
-
-**Returns:** Array of task summaries with id, subject, status, owner, blockedBy. Excludes completed and deleted tasks. The blockedBy field is filtered to only include unresolved (non-completed) blockers.
-
-**Example:**
-```typescript
-task_list() // Returns all active tasks
-```
-
-**Response includes reminder:** "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
-
-### task_get
-
-Retrieve a full task object by ID.
-
-**Args:**
-| Arg | Type | Required | Description |
-|-----|------|----------|-------------|
-| `id` | string | Yes | Task ID (format: T-{uuid}) |
-
-**Example:**
-```typescript
-task_get({ id: "T-2a200c59-1a36-4dad-a9c3-3064d180f694" })
-```
-
-**Returns:** `{ task: TaskObject | null }` with all fields: id, subject, description, status, activeForm, blocks, blockedBy, owner, metadata, repoURL, parentID, threadID.
-
-### task_update
-
-Update an existing task with new values. Supports additive updates for dependencies.
-
-**Args:**
-| Arg | Type | Required | Description |
-|-----|------|----------|-------------|
-| `id` | string | Yes | Task ID to update |
-| `subject` | string | No | New subject |
-| `description` | string | No | New description |
-| `status` | "pending" \| "in_progress" \| "completed" \| "deleted" | No | Task status |
-| `activeForm` | string | No | Active form (present continuous) |
-| `owner` | string | No | Task owner (agent name) |
-| `addBlocks` | string[] | No | Task IDs to add to blocks (additive) |
-| `addBlockedBy` | string[] | No | Task IDs to add to blockedBy (additive) |
-| `metadata` | Record | No | Metadata to merge (set key to null to delete) |
-
-**Example:**
-```typescript
-task_update({
- id: "T-2a200c59-1a36-4dad-a9c3-3064d180f694",
- status: "completed"
-})
-
-// Add dependencies
-task_update({
- id: "T-2a200c59-1a36-4dad-a9c3-3064d180f694",
- addBlockedBy: ["T-other-task"]
-})
-```
-
-**Returns:** `{ task: TaskObject }` with full updated task.
-
-**Dependency Management:** Use `addBlockedBy` to declare dependencies on other tasks. Properly managed dependencies enable maximum parallel execution.
+| `look_at` | System | Factory | Multimodal PDF/image analysis via dedicated agent |
+| `skill` | Skill | Factory | Load skill instructions with MCP support |
+| `skill_mcp` | Skill | Factory | Call MCP tools/resources/prompts from skill-embedded servers |
+| `slashcommand` | Command | Factory | Slash command dispatch with argument substitution |
## DELEGATION SYSTEM (delegate-task)
-8 built-in categories: `visual-engineering`, `ultrabrain`, `deep`, `artistry`, `quick`, `unspecified-low`, `unspecified-high`, `writing`
+8 built-in categories with domain-optimized models:
-Each category defines: model, variant, temperature, max tokens, thinking/reasoning config, prompt append, stability flag.
+| Category | Model | Domain |
+|----------|-------|--------|
+| `visual-engineering` | gemini-3-pro | UI/UX, design, styling |
+| `ultrabrain` | gpt-5.3-codex xhigh | Deep logic, architecture |
+| `deep` | gpt-5.3-codex medium | Autonomous problem-solving |
+| `artistry` | gemini-3-pro high | Creative, unconventional |
+| `quick` | claude-haiku-4-5 | Trivial tasks |
+| `unspecified-low` | claude-sonnet-4-5 | Moderate effort |
+| `unspecified-high` | claude-opus-4-6 max | High effort |
+| `writing` | kimi-k2p5 | Documentation, prose |
## HOW TO ADD
diff --git a/src/tools/background-task/create-background-task.test.ts b/src/tools/background-task/create-background-task.test.ts
index 5cfd07c4..2afc20a0 100644
--- a/src/tools/background-task/create-background-task.test.ts
+++ b/src/tools/background-task/create-background-task.test.ts
@@ -1,20 +1,32 @@
+///
+
import { describe, test, expect, mock } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
+import type { PluginInput } from "@opencode-ai/plugin"
import { createBackgroundTask } from "./create-background-task"
describe("createBackgroundTask", () => {
+ const launchMock = mock(() => Promise.resolve({
+ id: "test-task-id",
+ sessionID: null,
+ description: "Test task",
+ agent: "test-agent",
+ status: "pending",
+ }))
+ const getTaskMock = mock()
+
const mockManager = {
- launch: mock(() => Promise.resolve({
- id: "test-task-id",
- sessionID: null,
- description: "Test task",
- agent: "test-agent",
- status: "pending",
- })),
- getTask: mock(),
+ launch: launchMock,
+ getTask: getTaskMock,
} as unknown as BackgroundManager
- const tool = createBackgroundTask(mockManager)
+ const mockClient = {
+ session: {
+ messages: mock(() => Promise.resolve({ data: [] })),
+ },
+ } as unknown as PluginInput["client"]
+
+ const tool = createBackgroundTask(mockManager, mockClient)
const testContext = {
sessionID: "test-session",
@@ -31,14 +43,14 @@ describe("createBackgroundTask", () => {
test("detects interrupted task as failure", async () => {
//#given
- mockManager.launch.mockResolvedValueOnce({
+ launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
- mockManager.getTask.mockReturnValueOnce({
+ getTaskMock.mockReturnValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
@@ -53,4 +65,4 @@ describe("createBackgroundTask", () => {
expect(result).toContain("Task entered error state")
expect(result).toContain("test-task-id")
})
-})
\ No newline at end of file
+})
diff --git a/src/tools/background-task/create-background-task.ts b/src/tools/background-task/create-background-task.ts
index a7a365d2..9da0d5c5 100644
--- a/src/tools/background-task/create-background-task.ts
+++ b/src/tools/background-task/create-background-task.ts
@@ -1,8 +1,8 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin"
+import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { BackgroundTaskArgs } from "./types"
import { BACKGROUND_TASK_DESCRIPTION } from "./constants"
-import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector"
+import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { log } from "../../shared/logger"
@@ -18,7 +18,10 @@ type ToolContextWithMetadata = {
callID?: string
}
-export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
+export function createBackgroundTask(
+ manager: BackgroundManager,
+ client: PluginInput["client"]
+): ToolDefinition {
return tool({
description: BACKGROUND_TASK_DESCRIPTION,
args: {
@@ -35,8 +38,12 @@ export function createBackgroundTask(manager: BackgroundManager): ToolDefinition
try {
const messageDir = getMessageDir(ctx.sessionID)
- const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
- const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
+ const { prevMessage, firstMessageAgent } = await resolveMessageContext(
+ ctx.sessionID,
+ client,
+ messageDir
+ )
+
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
diff --git a/src/tools/background-task/message-dir.ts b/src/tools/background-task/message-dir.ts
index 74c49607..a9111f43 100644
--- a/src/tools/background-task/message-dir.ts
+++ b/src/tools/background-task/message-dir.ts
@@ -1,17 +1 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
-
-export function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
+export { getMessageDir } from "../../shared/opencode-message-dir"
diff --git a/src/tools/background-task/modules/utils.ts b/src/tools/background-task/modules/utils.ts
index bfc14c63..907f8eaf 100644
--- a/src/tools/background-task/modules/utils.ts
+++ b/src/tools/background-task/modules/utils.ts
@@ -1,20 +1,6 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { MESSAGE_STORAGE } from "../../../features/hook-message-injector"
+import { getMessageDir } from "../../../shared"
-export function getMessageDir(sessionID: string): string | null {
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
+export { getMessageDir }
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
diff --git a/src/tools/call-omo-agent/background-agent-executor.test.ts b/src/tools/call-omo-agent/background-agent-executor.test.ts
index 2c080e7e..d27575c1 100644
--- a/src/tools/call-omo-agent/background-agent-executor.test.ts
+++ b/src/tools/call-omo-agent/background-agent-executor.test.ts
@@ -1,17 +1,22 @@
+///
import { describe, test, expect, mock } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
+import type { PluginInput } from "@opencode-ai/plugin"
import { executeBackgroundAgent } from "./background-agent-executor"
describe("executeBackgroundAgent", () => {
+ const launchMock = mock(() => Promise.resolve({
+ id: "test-task-id",
+ sessionID: null,
+ description: "Test task",
+ agent: "test-agent",
+ status: "pending",
+ }))
+ const getTaskMock = mock()
+
const mockManager = {
- launch: mock(() => Promise.resolve({
- id: "test-task-id",
- sessionID: null,
- description: "Test task",
- agent: "test-agent",
- status: "pending",
- })),
- getTask: mock(),
+ launch: launchMock,
+ getTask: getTaskMock,
} as unknown as BackgroundManager
const testContext = {
@@ -25,18 +30,25 @@ describe("executeBackgroundAgent", () => {
description: "Test background task",
prompt: "Test prompt",
subagent_type: "test-agent",
+ run_in_background: true,
}
+ const mockClient = {
+ session: {
+ messages: mock(() => Promise.resolve({ data: [] })),
+ },
+ } as unknown as PluginInput["client"]
+
test("detects interrupted task as failure", async () => {
//#given
- mockManager.launch.mockResolvedValueOnce({
+ launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
- mockManager.getTask.mockReturnValueOnce({
+ getTaskMock.mockReturnValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
@@ -45,11 +57,11 @@ describe("executeBackgroundAgent", () => {
})
//#when
- const result = await executeBackgroundAgent(testArgs, testContext, mockManager)
+ const result = await executeBackgroundAgent(testArgs, testContext, mockManager, mockClient)
//#then
expect(result).toContain("Task failed to start")
expect(result).toContain("interrupt")
expect(result).toContain("test-task-id")
})
-})
\ No newline at end of file
+})
diff --git a/src/tools/call-omo-agent/background-agent-executor.ts b/src/tools/call-omo-agent/background-agent-executor.ts
index 7babb43c..c09f78df 100644
--- a/src/tools/call-omo-agent/background-agent-executor.ts
+++ b/src/tools/call-omo-agent/background-agent-executor.ts
@@ -1,5 +1,6 @@
import type { BackgroundManager } from "../../features/background-agent"
-import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector"
+import type { PluginInput } from "@opencode-ai/plugin"
+import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared"
import type { CallOmoAgentArgs } from "./types"
@@ -11,11 +12,16 @@ export async function executeBackgroundAgent(
args: CallOmoAgentArgs,
toolContext: ToolContextWithMetadata,
manager: BackgroundManager,
+ client: PluginInput["client"],
): Promise {
try {
const messageDir = getMessageDir(toolContext.sessionID)
- const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
- const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
+ const { prevMessage, firstMessageAgent } = await resolveMessageContext(
+ toolContext.sessionID,
+ client,
+ messageDir
+ )
+
const sessionAgent = getSessionAgent(toolContext.sessionID)
const parentAgent =
toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
diff --git a/src/tools/call-omo-agent/background-executor.test.ts b/src/tools/call-omo-agent/background-executor.test.ts
index 8323c651..970b9c13 100644
--- a/src/tools/call-omo-agent/background-executor.test.ts
+++ b/src/tools/call-omo-agent/background-executor.test.ts
@@ -1,17 +1,22 @@
+///
import { describe, test, expect, mock } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent"
+import type { PluginInput } from "@opencode-ai/plugin"
import { executeBackground } from "./background-executor"
describe("executeBackground", () => {
+ const launchMock = mock(() => Promise.resolve({
+ id: "test-task-id",
+ sessionID: null,
+ description: "Test task",
+ agent: "test-agent",
+ status: "pending",
+ }))
+ const getTaskMock = mock()
+
const mockManager = {
- launch: mock(() => Promise.resolve({
- id: "test-task-id",
- sessionID: null,
- description: "Test task",
- agent: "test-agent",
- status: "pending",
- })),
- getTask: mock(),
+ launch: launchMock,
+ getTask: getTaskMock,
} as unknown as BackgroundManager
const testContext = {
@@ -25,18 +30,25 @@ describe("executeBackground", () => {
description: "Test background task",
prompt: "Test prompt",
subagent_type: "test-agent",
+ run_in_background: true,
}
+ const mockClient = {
+ session: {
+ messages: mock(() => Promise.resolve({ data: [] })),
+ },
+ } as unknown as PluginInput["client"]
+
test("detects interrupted task as failure", async () => {
//#given
- mockManager.launch.mockResolvedValueOnce({
+ launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
- mockManager.getTask.mockReturnValueOnce({
+ getTaskMock.mockReturnValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
@@ -45,11 +57,11 @@ describe("executeBackground", () => {
})
//#when
- const result = await executeBackground(testArgs, testContext, mockManager)
+ const result = await executeBackground(testArgs, testContext, mockManager, mockClient)
//#then
expect(result).toContain("Task failed to start")
expect(result).toContain("interrupt")
expect(result).toContain("test-task-id")
})
-})
\ No newline at end of file
+})
diff --git a/src/tools/call-omo-agent/background-executor.ts b/src/tools/call-omo-agent/background-executor.ts
index 5751664a..c9eb9ef4 100644
--- a/src/tools/call-omo-agent/background-executor.ts
+++ b/src/tools/call-omo-agent/background-executor.ts
@@ -1,8 +1,9 @@
import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
+import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
-import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector"
+import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getMessageDir } from "./message-dir"
import { getSessionTools } from "../../shared/session-tools-store"
@@ -16,12 +17,17 @@ export async function executeBackground(
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record }) => void
},
- manager: BackgroundManager
+ manager: BackgroundManager,
+ client: PluginInput["client"]
): Promise {
try {
const messageDir = getMessageDir(toolContext.sessionID)
- const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
- const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
+ const { prevMessage, firstMessageAgent } = await resolveMessageContext(
+ toolContext.sessionID,
+ client,
+ messageDir
+ )
+
const sessionAgent = getSessionAgent(toolContext.sessionID)
const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
diff --git a/src/tools/call-omo-agent/completion-poller.ts b/src/tools/call-omo-agent/completion-poller.ts
index 0ca73e73..61f2829b 100644
--- a/src/tools/call-omo-agent/completion-poller.ts
+++ b/src/tools/call-omo-agent/completion-poller.ts
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
+import { normalizeSDKResponse } from "../../shared"
export async function waitForCompletion(
sessionID: string,
@@ -33,7 +34,7 @@ export async function waitForCompletion(
// Check session status
const statusResult = await ctx.client.session.status()
- const allStatuses = (statusResult.data ?? {}) as Record
+ const allStatuses = normalizeSDKResponse(statusResult, {} as Record)
const sessionStatus = allStatuses[sessionID]
// If session is actively running, reset stability counter
@@ -45,7 +46,9 @@ export async function waitForCompletion(
// Session is idle - check message stability
const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } })
- const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array
+ const msgs = normalizeSDKResponse(messagesCheck, [] as Array, {
+ preferResponseOnMissingData: true,
+ })
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
diff --git a/src/tools/call-omo-agent/message-dir.ts b/src/tools/call-omo-agent/message-dir.ts
index 01fa68fc..a9111f43 100644
--- a/src/tools/call-omo-agent/message-dir.ts
+++ b/src/tools/call-omo-agent/message-dir.ts
@@ -1,18 +1 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
-
-export function getMessageDir(sessionID: string): string | null {
- if (!sessionID.startsWith("ses_")) return null
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
+export { getMessageDir } from "../../shared/opencode-message-dir"
diff --git a/src/tools/call-omo-agent/message-storage-directory.ts b/src/tools/call-omo-agent/message-storage-directory.ts
index 30fecd6e..cf8b56ed 100644
--- a/src/tools/call-omo-agent/message-storage-directory.ts
+++ b/src/tools/call-omo-agent/message-storage-directory.ts
@@ -1,18 +1 @@
-import { existsSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
-
-export function getMessageDir(sessionID: string): string | null {
- if (!sessionID.startsWith("ses_")) return null
- if (!existsSync(MESSAGE_STORAGE)) return null
-
- 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 null
-}
+export { getMessageDir } from "../../shared"
diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts
index dbcfcf97..b773d21a 100644
--- a/src/tools/call-omo-agent/tools.ts
+++ b/src/tools/call-omo-agent/tools.ts
@@ -48,7 +48,7 @@ export function createCallOmoAgent(
if (args.session_id) {
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
}
- return await executeBackground(args, toolCtx, backgroundManager)
+ return await executeBackground(args, toolCtx, backgroundManager, ctx.client)
}
return await executeSync(args, toolCtx, ctx)
diff --git a/src/tools/delegate-task/parent-context-resolver.ts b/src/tools/delegate-task/parent-context-resolver.ts
index cf231783..2d831cda 100644
--- a/src/tools/delegate-task/parent-context-resolver.ts
+++ b/src/tools/delegate-task/parent-context-resolver.ts
@@ -1,14 +1,22 @@
import type { ToolContextWithMetadata } from "./types"
+import type { OpencodeClient } from "./types"
import type { ParentContext } from "./executor-types"
-import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector"
+import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
-import { getMessageDir } from "../../shared/session-utils"
+import { getMessageDir } from "../../shared/opencode-message-dir"
-export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext {
+export async function resolveParentContext(
+ ctx: ToolContextWithMetadata,
+ client: OpencodeClient
+): Promise {
const messageDir = getMessageDir(ctx.sessionID)
- const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
- const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
+ const { prevMessage, firstMessageAgent } = await resolveMessageContext(
+ ctx.sessionID,
+ client,
+ messageDir
+ )
+
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts
index 0447416d..5651fba2 100644
--- a/src/tools/delegate-task/subagent-resolver.ts
+++ b/src/tools/delegate-task/subagent-resolver.ts
@@ -4,6 +4,8 @@ import { isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { parseModelString } from "./model-string-parser"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
+import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
+import { normalizeSDKResponse } from "../../shared"
import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
@@ -47,17 +49,22 @@ Create the work plan directly - that's your job as the planning agent.`,
try {
const agentsResult = await client.app.agents()
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } }
- const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[]
+ const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
+ preferResponseOnMissingData: true,
+ })
const callableAgents = agents.filter((a) => a.mode !== "primary")
+ const resolvedDisplayName = getAgentDisplayName(agentToUse)
const matchedAgent = callableAgents.find(
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
+ || agent.name.toLowerCase() === resolvedDisplayName.toLowerCase()
)
if (!matchedAgent) {
const isPrimaryAgent = agents
.filter((a) => a.mode === "primary")
- .find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase())
+ .find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
+ || agent.name.toLowerCase() === resolvedDisplayName.toLowerCase())
if (isPrimaryAgent) {
return {
@@ -80,10 +87,10 @@ Create the work plan directly - that's your job as the planning agent.`,
agentToUse = matchedAgent.name
- const agentNameLower = agentToUse.toLowerCase()
- const agentOverride = agentOverrides?.[agentNameLower as keyof typeof agentOverrides]
- ?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentNameLower)?.[1] : undefined)
- const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentNameLower]
+ const agentConfigKey = getAgentConfigKey(agentToUse)
+ const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
+ ?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
+ const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
if (agentOverride?.model || agentRequirement || matchedAgent.model) {
const availableModels = await getAvailableModelsForDelegateTask(client)
diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts
index 72355982..b31e1950 100644
--- a/src/tools/delegate-task/sync-continuation.ts
+++ b/src/tools/delegate-task/sync-continuation.ts
@@ -4,12 +4,13 @@ import { isPlanFamily } from "./constants"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
-import { getMessageDir } from "../../shared/session-utils"
+import { getMessageDir } from "../../shared"
import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { formatDuration } from "./time-formatter"
import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps"
import { setSessionTools } from "../../shared/session-tools-store"
+import { normalizeSDKResponse } from "../../shared"
export async function executeSyncContinuation(
args: DelegateTaskArgs,
@@ -56,7 +57,7 @@ export async function executeSyncContinuation(
try {
try {
const messagesResp = await client.session.messages({ path: { id: args.session_id! } })
- const messages = (messagesResp.data ?? []) as SessionMessage[]
+ const messages = normalizeSDKResponse(messagesResp, [] as SessionMessage[])
anchorMessageCount = messages.length
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts
index 64d1a278..3eb454e5 100644
--- a/src/tools/delegate-task/sync-result-fetcher.ts
+++ b/src/tools/delegate-task/sync-result-fetcher.ts
@@ -1,5 +1,6 @@
import type { OpencodeClient } from "./types"
import type { SessionMessage } from "./executor-types"
+import { normalizeSDKResponse } from "../../shared"
export async function fetchSyncResult(
client: OpencodeClient,
@@ -14,7 +15,9 @@ export async function fetchSyncResult(
return { ok: false, error: `Error fetching result: ${(messagesResult as { error: unknown }).error}\n\nSession ID: ${sessionID}` }
}
- const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
+ const messages = normalizeSDKResponse(messagesResult, [] as SessionMessage[], {
+ preferResponseOnMissingData: true,
+ })
const messagesAfterAnchor = anchorMessageCount !== undefined ? messages.slice(anchorMessageCount) : messages
diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts
index 3f7b2fd9..9c8cb256 100644
--- a/src/tools/delegate-task/sync-session-poller.ts
+++ b/src/tools/delegate-task/sync-session-poller.ts
@@ -2,6 +2,7 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types"
import type { SessionMessage } from "./executor-types"
import { getTimingConfig } from "./timing"
import { log } from "../../shared/logger"
+import { normalizeSDKResponse } from "../../shared"
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"])
@@ -58,7 +59,7 @@ export async function pollSyncSession(
log("[task] Poll status fetch failed, retrying", { sessionID: input.sessionID, error: String(error) })
continue
}
- const allStatuses = (statusResult.data ?? {}) as Record
+ const allStatuses = normalizeSDKResponse(statusResult, {} as Record)
const sessionStatus = allStatuses[input.sessionID]
if (pollCount % 10 === 0) {
diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts
index 8c4c5735..c2cfff1a 100644
--- a/src/tools/delegate-task/tools.test.ts
+++ b/src/tools/delegate-task/tools.test.ts
@@ -1267,52 +1267,58 @@ describe("sisyphus-task", () => {
launch: async () => mockTask,
}
- let messagesCallCount = 0
+ let messagesCallCount = 0
- const mockClient = {
- session: {
- prompt: async () => ({ data: {} }),
- promptAsync: async () => ({ data: {} }),
- messages: async () => {
- messagesCallCount++
- const now = Date.now()
+ const mockClient = {
+ session: {
+ prompt: async () => ({ data: {} }),
+ promptAsync: async () => ({ data: {} }),
+ messages: async (args?: { path?: { id?: string } }) => {
+ const sessionID = args?.path?.id
+ // Only track calls for the target session (ses_continue_test),
+ // not for parent-session calls from resolveParentContext
+ if (sessionID !== "ses_continue_test") {
+ return { data: [] }
+ }
+ messagesCallCount++
+ const now = Date.now()
- const beforeContinuation = [
- {
- info: { id: "msg_001", role: "user", time: { created: now } },
- parts: [{ type: "text", text: "Previous context" }],
- },
- {
- info: { id: "msg_002", role: "assistant", time: { created: now + 1 }, finish: "end_turn" },
- parts: [{ type: "text", text: "Previous result" }],
- },
- ]
+ const beforeContinuation = [
+ {
+ info: { id: "msg_001", role: "user", time: { created: now } },
+ parts: [{ type: "text", text: "Previous context" }],
+ },
+ {
+ info: { id: "msg_002", role: "assistant", time: { created: now + 1 }, finish: "end_turn" },
+ parts: [{ type: "text", text: "Previous result" }],
+ },
+ ]
- if (messagesCallCount === 1) {
- return { data: beforeContinuation }
- }
+ if (messagesCallCount === 1) {
+ return { data: beforeContinuation }
+ }
- return {
- data: [
- ...beforeContinuation,
- {
- info: { id: "msg_003", role: "user", time: { created: now + 2 } },
- parts: [{ type: "text", text: "Continue the task" }],
- },
- {
- info: { id: "msg_004", role: "assistant", time: { created: now + 3 }, finish: "end_turn" },
- parts: [{ type: "text", text: "This is the continued task result" }],
- },
- ],
- }
- },
- status: async () => ({ data: { "ses_continue_test": { type: "idle" } } }),
+ return {
+ data: [
+ ...beforeContinuation,
+ {
+ info: { id: "msg_003", role: "user", time: { created: now + 2 } },
+ parts: [{ type: "text", text: "Continue the task" }],
+ },
+ {
+ info: { id: "msg_004", role: "assistant", time: { created: now + 3 }, finish: "end_turn" },
+ parts: [{ type: "text", text: "This is the continued task result" }],
+ },
+ ],
+ }
+ },
+ status: async () => ({ data: { "ses_continue_test": { type: "idle" } } }),
+ },
+ config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
+ app: {
+ agents: async () => ({ data: [] }),
},
- config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
- app: {
- agents: async () => ({ data: [] }),
- },
- }
+ }
const tool = createDelegateTask({
manager: mockManager,
@@ -1714,17 +1720,19 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
+ const launchedTask = {
+ id: "task-unstable",
+ sessionID: "ses_unstable_gemini",
+ description: "Unstable gemini task",
+ agent: "sisyphus-junior",
+ status: "running",
+ }
const mockManager = {
launch: async () => {
launchCalled = true
- return {
- id: "task-unstable",
- sessionID: "ses_unstable_gemini",
- description: "Unstable gemini task",
- agent: "sisyphus-junior",
- status: "running",
- }
+ return launchedTask
},
+ getTask: () => launchedTask,
}
const mockClient = {
@@ -1839,17 +1847,19 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
+ const launchedTask = {
+ id: "task-unstable-minimax",
+ sessionID: "ses_unstable_minimax",
+ description: "Unstable minimax task",
+ agent: "sisyphus-junior",
+ status: "running",
+ }
const mockManager = {
launch: async () => {
launchCalled = true
- return {
- id: "task-unstable-minimax",
- sessionID: "ses_unstable_minimax",
- description: "Unstable minimax task",
- agent: "sisyphus-junior",
- status: "running",
- }
+ return launchedTask
},
+ getTask: () => launchedTask,
}
const mockClient = {
@@ -1973,17 +1983,19 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
+ const launchedTask = {
+ id: "task-artistry",
+ sessionID: "ses_artistry_gemini",
+ description: "Artistry gemini task",
+ agent: "sisyphus-junior",
+ status: "running",
+ }
const mockManager = {
launch: async () => {
launchCalled = true
- return {
- id: "task-artistry",
- sessionID: "ses_artistry_gemini",
- description: "Artistry gemini task",
- agent: "sisyphus-junior",
- status: "running",
- }
+ return launchedTask
},
+ getTask: () => launchedTask,
}
const mockClient = {
@@ -2039,17 +2051,19 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
+ const launchedTask = {
+ id: "task-writing",
+ sessionID: "ses_writing_gemini",
+ description: "Writing gemini task",
+ agent: "sisyphus-junior",
+ status: "running",
+ }
const mockManager = {
launch: async () => {
launchCalled = true
- return {
- id: "task-writing",
- sessionID: "ses_writing_gemini",
- description: "Writing gemini task",
- agent: "sisyphus-junior",
- status: "running",
- }
+ return launchedTask
},
+ getTask: () => launchedTask,
}
const mockClient = {
@@ -2105,17 +2119,19 @@ describe("sisyphus-task", () => {
const { createDelegateTask } = require("./tools")
let launchCalled = false
+ const launchedTask = {
+ id: "task-custom-unstable",
+ sessionID: "ses_custom_unstable",
+ description: "Custom unstable task",
+ agent: "sisyphus-junior",
+ status: "running",
+ }
const mockManager = {
launch: async () => {
launchCalled = true
- return {
- id: "task-custom-unstable",
- sessionID: "ses_custom_unstable",
- description: "Custom unstable task",
- agent: "sisyphus-junior",
- status: "running",
- }
+ return launchedTask
},
+ getTask: () => launchedTask,
}
const mockClient = {
diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts
index cfa01ebe..763b09f0 100644
--- a/src/tools/delegate-task/tools.ts
+++ b/src/tools/delegate-task/tools.ts
@@ -129,7 +129,7 @@ Prompts MUST be in English.`
return skillError
}
- const parentContext = resolveParentContext(ctx)
+ const parentContext = await resolveParentContext(ctx, options.client)
if (args.session_id) {
if (runInBackground) {
diff --git a/src/tools/delegate-task/unstable-agent-task.test.ts b/src/tools/delegate-task/unstable-agent-task.test.ts
new file mode 100644
index 00000000..de5de840
--- /dev/null
+++ b/src/tools/delegate-task/unstable-agent-task.test.ts
@@ -0,0 +1,224 @@
+const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test")
+
+describe("executeUnstableAgentTask - interrupt detection", () => {
+ beforeEach(() => {
+ //#given - configure fast timing for all tests
+ const { __setTimingConfig } = require("./timing")
+ __setTimingConfig({
+ POLL_INTERVAL_MS: 10,
+ MIN_STABILITY_TIME_MS: 0,
+ STABILITY_POLLS_REQUIRED: 1,
+ MAX_POLL_TIME_MS: 500,
+ WAIT_FOR_SESSION_TIMEOUT_MS: 100,
+ WAIT_FOR_SESSION_INTERVAL_MS: 10,
+ })
+ })
+
+ afterEach(() => {
+ //#given - reset timing after each test
+ const { __resetTimingConfig } = require("./timing")
+ __resetTimingConfig()
+ mock.restore()
+ })
+
+ test("should return error immediately when background task becomes interrupted during polling", async () => {
+ //#given - a background task that gets interrupted on first poll check
+ const taskState = {
+ id: "bg_test_interrupt",
+ sessionID: "ses_test_interrupt",
+ status: "interrupt" as string,
+ description: "test interrupted task",
+ prompt: "test prompt",
+ agent: "sisyphus-junior",
+ error: "Agent not found" as string | undefined,
+ }
+
+ const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
+
+ const mockManager = {
+ launch: async () => launchState,
+ getTask: () => taskState,
+ }
+
+ const mockClient = {
+ session: {
+ status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
+ messages: async () => ({ data: [] }),
+ },
+ }
+
+ const { executeUnstableAgentTask } = require("./unstable-agent-task")
+
+ const args = {
+ prompt: "test prompt",
+ description: "test task",
+ category: "test",
+ load_skills: [],
+ run_in_background: false,
+ }
+
+ const mockCtx = {
+ sessionID: "parent-session",
+ callID: "call-123",
+ metadata: () => {},
+ }
+
+ const mockExecutorCtx = {
+ manager: mockManager,
+ client: mockClient,
+ directory: "/tmp",
+ }
+
+ const parentContext = {
+ sessionID: "parent-session",
+ messageID: "msg-123",
+ }
+
+ //#when - executeUnstableAgentTask encounters an interrupted task
+ const startTime = Date.now()
+ const result = await executeUnstableAgentTask(
+ args, mockCtx, mockExecutorCtx, parentContext,
+ "test-agent", undefined, undefined, "test-model"
+ )
+ const elapsed = Date.now() - startTime
+
+ //#then - should return quickly with interrupt error, not hang until MAX_POLL_TIME_MS
+ expect(result).toContain("interrupt")
+ expect(result.toLowerCase()).toContain("agent not found")
+ expect(elapsed).toBeLessThan(400)
+ })
+
+ test("should return error immediately when background task becomes errored during polling", async () => {
+ //#given - a background task that is already errored when poll checks
+ const taskState = {
+ id: "bg_test_error",
+ sessionID: "ses_test_error",
+ status: "error" as string,
+ description: "test error task",
+ prompt: "test prompt",
+ agent: "sisyphus-junior",
+ error: "Rate limit exceeded" as string | undefined,
+ }
+
+ const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
+
+ const mockManager = {
+ launch: async () => launchState,
+ getTask: () => taskState,
+ }
+
+ const mockClient = {
+ session: {
+ status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
+ messages: async () => ({ data: [] }),
+ },
+ }
+
+ const { executeUnstableAgentTask } = require("./unstable-agent-task")
+
+ const args = {
+ prompt: "test prompt",
+ description: "test task",
+ category: "test",
+ load_skills: [],
+ run_in_background: false,
+ }
+
+ const mockCtx = {
+ sessionID: "parent-session",
+ callID: "call-123",
+ metadata: () => {},
+ }
+
+ const mockExecutorCtx = {
+ manager: mockManager,
+ client: mockClient,
+ directory: "/tmp",
+ }
+
+ const parentContext = {
+ sessionID: "parent-session",
+ messageID: "msg-123",
+ }
+
+ //#when - executeUnstableAgentTask encounters an errored task
+ const startTime = Date.now()
+ const result = await executeUnstableAgentTask(
+ args, mockCtx, mockExecutorCtx, parentContext,
+ "test-agent", undefined, undefined, "test-model"
+ )
+ const elapsed = Date.now() - startTime
+
+ //#then - should return quickly with error, not hang until MAX_POLL_TIME_MS
+ expect(result).toContain("error")
+ expect(result.toLowerCase()).toContain("rate limit exceeded")
+ expect(elapsed).toBeLessThan(400)
+ })
+
+ test("should return error immediately when background task becomes cancelled during polling", async () => {
+ //#given - a background task that is already cancelled when poll checks
+ const taskState = {
+ id: "bg_test_cancel",
+ sessionID: "ses_test_cancel",
+ status: "cancelled" as string,
+ description: "test cancelled task",
+ prompt: "test prompt",
+ agent: "sisyphus-junior",
+ error: "Stale timeout" as string | undefined,
+ }
+
+ const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
+
+ const mockManager = {
+ launch: async () => launchState,
+ getTask: () => taskState,
+ }
+
+ const mockClient = {
+ session: {
+ status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
+ messages: async () => ({ data: [] }),
+ },
+ }
+
+ const { executeUnstableAgentTask } = require("./unstable-agent-task")
+
+ const args = {
+ prompt: "test prompt",
+ description: "test task",
+ category: "test",
+ load_skills: [],
+ run_in_background: false,
+ }
+
+ const mockCtx = {
+ sessionID: "parent-session",
+ callID: "call-123",
+ metadata: () => {},
+ }
+
+ const mockExecutorCtx = {
+ manager: mockManager,
+ client: mockClient,
+ directory: "/tmp",
+ }
+
+ const parentContext = {
+ sessionID: "parent-session",
+ messageID: "msg-123",
+ }
+
+ //#when - executeUnstableAgentTask encounters a cancelled task
+ const startTime = Date.now()
+ const result = await executeUnstableAgentTask(
+ args, mockCtx, mockExecutorCtx, parentContext,
+ "test-agent", undefined, undefined, "test-model"
+ )
+ const elapsed = Date.now() - startTime
+
+ //#then - should return quickly with cancel info, not hang until MAX_POLL_TIME_MS
+ expect(result).toContain("cancel")
+ expect(result.toLowerCase()).toContain("stale timeout")
+ expect(elapsed).toBeLessThan(400)
+ })
+})
diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts
index 9e0bf853..d806fd93 100644
--- a/src/tools/delegate-task/unstable-agent-task.ts
+++ b/src/tools/delegate-task/unstable-agent-task.ts
@@ -5,6 +5,7 @@ import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
+import { normalizeSDKResponse } from "../../shared"
export async function executeUnstableAgentTask(
args: DelegateTaskArgs,
@@ -77,6 +78,7 @@ export async function executeUnstableAgentTask(
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
+ let terminalStatus: { status: string; error?: string } | undefined
while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
@@ -85,8 +87,14 @@ export async function executeUnstableAgentTask(
await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS))
+ const currentTask = manager.getTask(task.id)
+ if (currentTask && (currentTask.status === "interrupt" || currentTask.status === "error" || currentTask.status === "cancelled")) {
+ terminalStatus = { status: currentTask.status, error: currentTask.error }
+ break
+ }
+
const statusResult = await client.session.status()
- const allStatuses = (statusResult.data ?? {}) as Record
+ const allStatuses = normalizeSDKResponse(statusResult, {} as Record)
const sessionStatus = allStatuses[sessionID]
if (sessionStatus && sessionStatus.type !== "idle") {
@@ -98,7 +106,9 @@ export async function executeUnstableAgentTask(
if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
- const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array
+ const msgs = normalizeSDKResponse(messagesCheck, [] as Array, {
+ preferResponseOnMissingData: true,
+ })
const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) {
@@ -110,8 +120,28 @@ export async function executeUnstableAgentTask(
}
}
+ if (terminalStatus) {
+ const duration = formatDuration(startTime)
+ return `SUPERVISED TASK FAILED (${terminalStatus.status})
+
+Task was interrupted/failed while running in monitored background mode.
+${terminalStatus.error ? `Error: ${terminalStatus.error}` : ""}
+
+Duration: ${duration}
+Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
+Model: ${actualModel}
+
+The task session may contain partial results.
+
+
+session_id: ${sessionID}
+`
+ }
+
const messagesResult = await client.session.messages({ path: { id: sessionID } })
- const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
+ const messages = normalizeSDKResponse(messagesResult, [] as SessionMessage[], {
+ preferResponseOnMissingData: true,
+ })
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
diff --git a/src/tools/session-manager/constants.ts b/src/tools/session-manager/constants.ts
index 5f079a1a..cdcb914c 100644
--- a/src/tools/session-manager/constants.ts
+++ b/src/tools/session-manager/constants.ts
@@ -1,11 +1,7 @@
import { join } from "node:path"
-import { getOpenCodeStorageDir } from "../../shared/data-path"
import { getClaudeConfigDir } from "../../shared"
-export const OPENCODE_STORAGE = getOpenCodeStorageDir()
-export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
-export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
-export const SESSION_STORAGE = join(OPENCODE_STORAGE, "session")
+export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE } from "../../shared"
export const TODO_DIR = join(getClaudeConfigDir(), "todos")
export const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts")
export const SESSION_LIST_DESCRIPTION = `List all OpenCode sessions with optional filtering.
diff --git a/src/tools/session-manager/session-formatter.ts b/src/tools/session-manager/session-formatter.ts
index 33faae9c..f1a359aa 100644
--- a/src/tools/session-manager/session-formatter.ts
+++ b/src/tools/session-manager/session-formatter.ts
@@ -44,7 +44,7 @@ export async function formatSessionList(sessionIDs: string[]): Promise {
export function formatSessionMessages(
messages: SessionMessage[],
includeTodos?: boolean,
- todos?: Array<{ id: string; content: string; status: string }>
+ todos?: Array<{ id?: string; content: string; status: string }>
): string {
if (messages.length === 0) {
return "No messages found in this session."
diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts
index 76507867..63d3eca2 100644
--- a/src/tools/session-manager/storage.test.ts
+++ b/src/tools/session-manager/storage.test.ts
@@ -26,6 +26,18 @@ mock.module("./constants", () => ({
TOOL_NAME_PREFIX: "session_",
}))
+mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => false,
+ resetSqliteBackendCache: () => {},
+}))
+
+mock.module("../../shared/opencode-storage-paths", () => ({
+ OPENCODE_STORAGE: TEST_DIR,
+ MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
+ PART_STORAGE: TEST_PART_STORAGE,
+ SESSION_STORAGE: TEST_SESSION_STORAGE,
+}))
+
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
await import("./storage")
@@ -73,15 +85,15 @@ describe("session-manager storage", () => {
expect(result).toBe(sessionPath)
})
- test("sessionExists returns false for non-existent session", () => {
+ test("sessionExists returns false for non-existent session", async () => {
// when
- const exists = sessionExists("ses_nonexistent")
+ const exists = await sessionExists("ses_nonexistent")
// then
expect(exists).toBe(false)
})
- test("sessionExists returns true for existing session", () => {
+ test("sessionExists returns true for existing session", async () => {
// given
const sessionID = "ses_exists"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
@@ -89,7 +101,7 @@ describe("session-manager storage", () => {
writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001" }))
// when
- const exists = sessionExists(sessionID)
+ const exists = await sessionExists(sessionID)
// then
expect(exists).toBe(true)
@@ -314,3 +326,168 @@ describe("session-manager storage - getMainSessions", () => {
expect(sessions.length).toBe(2)
})
})
+
+describe("session-manager storage - SDK path (beta mode)", () => {
+ const mockClient = {
+ session: {
+ list: mock(() => Promise.resolve({ data: [] })),
+ messages: mock(() => Promise.resolve({ data: [] })),
+ todo: mock(() => Promise.resolve({ data: [] })),
+ },
+ }
+
+ beforeEach(() => {
+ // Reset mocks
+ mockClient.session.list.mockClear()
+ mockClient.session.messages.mockClear()
+ mockClient.session.todo.mockClear()
+ })
+
+ test("getMainSessions uses SDK when beta mode is enabled", async () => {
+ // given
+ const mockSessions = [
+ { id: "ses_1", directory: "/test", parentID: null, time: { created: 1000, updated: 2000 } },
+ { id: "ses_2", directory: "/test", parentID: "ses_1", time: { created: 1000, updated: 1500 } },
+ ]
+ mockClient.session.list.mockImplementation(() => Promise.resolve({ data: mockSessions }))
+
+ // Mock isSqliteBackend to return true
+ mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => true,
+ resetSqliteBackendCache: () => {},
+ }))
+
+ // Re-import to get fresh module with mocked isSqliteBackend
+ const { setStorageClient, getMainSessions } = await import("./storage")
+ setStorageClient(mockClient as unknown as Parameters[0])
+
+ // when
+ const sessions = await getMainSessions({ directory: "/test" })
+
+ // then
+ expect(mockClient.session.list).toHaveBeenCalled()
+ expect(sessions.length).toBe(1)
+ expect(sessions[0].id).toBe("ses_1")
+ })
+
+ test("getAllSessions uses SDK when beta mode is enabled", async () => {
+ // given
+ const mockSessions = [
+ { id: "ses_1", directory: "/test", time: { created: 1000, updated: 2000 } },
+ { id: "ses_2", directory: "/test", time: { created: 1000, updated: 1500 } },
+ ]
+ mockClient.session.list.mockImplementation(() => Promise.resolve({ data: mockSessions }))
+
+ mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => true,
+ resetSqliteBackendCache: () => {},
+ }))
+
+ const { setStorageClient, getAllSessions } = await import("./storage")
+ setStorageClient(mockClient as unknown as Parameters[0])
+
+ // when
+ const sessionIDs = await getAllSessions()
+
+ // then
+ expect(mockClient.session.list).toHaveBeenCalled()
+ expect(sessionIDs).toEqual(["ses_1", "ses_2"])
+ })
+
+ test("readSessionMessages uses SDK when beta mode is enabled", async () => {
+ // given
+ const mockMessages = [
+ {
+ info: { id: "msg_1", role: "user", agent: "test", time: { created: 1000 } },
+ parts: [{ id: "part_1", type: "text", text: "Hello" }],
+ },
+ {
+ info: { id: "msg_2", role: "assistant", agent: "oracle", time: { created: 2000 } },
+ parts: [{ id: "part_2", type: "text", text: "Hi there" }],
+ },
+ ]
+ mockClient.session.messages.mockImplementation(() => Promise.resolve({ data: mockMessages }))
+
+ mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => true,
+ resetSqliteBackendCache: () => {},
+ }))
+
+ const { setStorageClient, readSessionMessages } = await import("./storage")
+ setStorageClient(mockClient as unknown as Parameters[0])
+
+ // when
+ const messages = await readSessionMessages("ses_test")
+
+ // then
+ expect(mockClient.session.messages).toHaveBeenCalledWith({ path: { id: "ses_test" } })
+ expect(messages.length).toBe(2)
+ expect(messages[0].id).toBe("msg_1")
+ expect(messages[1].id).toBe("msg_2")
+ expect(messages[0].role).toBe("user")
+ expect(messages[1].role).toBe("assistant")
+ })
+
+ test("readSessionTodos uses SDK when beta mode is enabled", async () => {
+ // given
+ const mockTodos = [
+ { id: "todo_1", content: "Task 1", status: "pending", priority: "high" },
+ { id: "todo_2", content: "Task 2", status: "completed", priority: "medium" },
+ ]
+ mockClient.session.todo.mockImplementation(() => Promise.resolve({ data: mockTodos }))
+
+ mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => true,
+ resetSqliteBackendCache: () => {},
+ }))
+
+ const { setStorageClient, readSessionTodos } = await import("./storage")
+ setStorageClient(mockClient as unknown as Parameters[0])
+
+ // when
+ const todos = await readSessionTodos("ses_test")
+
+ // then
+ expect(mockClient.session.todo).toHaveBeenCalledWith({ path: { id: "ses_test" } })
+ expect(todos.length).toBe(2)
+ expect(todos[0].content).toBe("Task 1")
+ expect(todos[1].content).toBe("Task 2")
+ expect(todos[0].status).toBe("pending")
+ expect(todos[1].status).toBe("completed")
+ })
+
+ test("SDK path returns empty array on error", async () => {
+ // given
+ mockClient.session.messages.mockImplementation(() => Promise.reject(new Error("API error")))
+
+ mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => true,
+ resetSqliteBackendCache: () => {},
+ }))
+
+ const { setStorageClient, readSessionMessages } = await import("./storage")
+ setStorageClient(mockClient as unknown as Parameters[0])
+
+ // when
+ const messages = await readSessionMessages("ses_test")
+
+ // then
+ expect(messages).toEqual([])
+ })
+
+ test("SDK path returns empty array when client is not set", async () => {
+ //#given beta mode enabled but no client set
+ mock.module("../../shared/opencode-storage-detection", () => ({
+ isSqliteBackend: () => true,
+ resetSqliteBackendCache: () => {},
+ }))
+
+ //#when client is explicitly cleared and messages are requested
+ const { resetStorageClient, readSessionMessages } = await import("./storage")
+ resetStorageClient()
+ const messages = await readSessionMessages("ses_test")
+
+ //#then should return empty array since no client and no JSON fallback
+ expect(messages).toEqual([])
+ })
+})
diff --git a/src/tools/session-manager/storage.ts b/src/tools/session-manager/storage.ts
index 8ed93f00..59fda3ff 100644
--- a/src/tools/session-manager/storage.ts
+++ b/src/tools/session-manager/storage.ts
@@ -1,14 +1,47 @@
-import { existsSync, readdirSync } from "node:fs"
+import { existsSync } from "node:fs"
import { readdir, readFile } from "node:fs/promises"
import { join } from "node:path"
+import type { PluginInput } from "@opencode-ai/plugin"
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
+import { isSqliteBackend } from "../../shared/opencode-storage-detection"
+import { getMessageDir } from "../../shared/opencode-message-dir"
import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types"
+import { normalizeSDKResponse } from "../../shared"
export interface GetMainSessionsOptions {
directory?: string
}
+// SDK client reference for beta mode
+let sdkClient: PluginInput["client"] | null = null
+
+export function setStorageClient(client: PluginInput["client"]): void {
+ sdkClient = client
+}
+
+export function resetStorageClient(): void {
+ sdkClient = null
+}
+
export async function getMainSessions(options: GetMainSessionsOptions): Promise {
+ // Beta mode: use SDK
+ if (isSqliteBackend() && sdkClient) {
+ try {
+ const response = await sdkClient.session.list()
+ const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
+ const mainSessions = sessions.filter((s) => !s.parentID)
+ if (options.directory) {
+ return mainSessions
+ .filter((s) => s.directory === options.directory)
+ .sort((a, b) => b.time.updated - a.time.updated)
+ }
+ return mainSessions.sort((a, b) => b.time.updated - a.time.updated)
+ } catch {
+ return []
+ }
+ }
+
+ // Stable mode: use JSON files
if (!existsSync(SESSION_STORAGE)) return []
const sessions: SessionMetadata[] = []
@@ -46,6 +79,18 @@ export async function getMainSessions(options: GetMainSessionsOptions): Promise<
}
export async function getAllSessions(): Promise {
+ // Beta mode: use SDK
+ if (isSqliteBackend() && sdkClient) {
+ try {
+ const response = await sdkClient.session.list()
+ const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
+ return sessions.map((s) => s.id)
+ } catch {
+ return []
+ }
+ }
+
+ // Stable mode: use JSON files
if (!existsSync(MESSAGE_STORAGE)) return []
const sessions: string[] = []
@@ -73,33 +118,78 @@ export async function getAllSessions(): Promise {
return [...new Set(sessions)]
}
-export function getMessageDir(sessionID: string): string {
- if (!existsSync(MESSAGE_STORAGE)) return ""
+export { getMessageDir } from "../../shared/opencode-message-dir"
- const directPath = join(MESSAGE_STORAGE, sessionID)
- if (existsSync(directPath)) {
- return directPath
+export async function sessionExists(sessionID: string): Promise {
+ if (isSqliteBackend() && sdkClient) {
+ const response = await sdkClient.session.list()
+ const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>)
+ return sessions.some((s) => s.id === sessionID)
}
-
- try {
- for (const dir of readdirSync(MESSAGE_STORAGE)) {
- const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
- if (existsSync(sessionPath)) {
- return sessionPath
- }
- }
- } catch {
- return ""
- }
-
- return ""
-}
-
-export function sessionExists(sessionID: string): boolean {
- return getMessageDir(sessionID) !== ""
+ return getMessageDir(sessionID) !== null
}
export async function readSessionMessages(sessionID: string): Promise {
+ // Beta mode: use SDK
+ if (isSqliteBackend() && sdkClient) {
+ try {
+ const response = await sdkClient.session.messages({ path: { id: sessionID } })
+ const rawMessages = normalizeSDKResponse(response, [] as Array<{
+ info?: {
+ id?: string
+ role?: string
+ agent?: string
+ time?: { created?: number; updated?: number }
+ }
+ parts?: Array<{
+ id?: string
+ type?: string
+ text?: string
+ thinking?: string
+ tool?: string
+ callID?: string
+ input?: Record
+ output?: string
+ error?: string
+ }>
+ }>)
+ const messages: SessionMessage[] = rawMessages
+ .filter((m) => m.info?.id)
+ .map((m) => ({
+ id: m.info!.id!,
+ role: (m.info!.role as "user" | "assistant") || "user",
+ agent: m.info!.agent,
+ time: m.info!.time?.created
+ ? {
+ created: m.info!.time.created,
+ updated: m.info!.time.updated,
+ }
+ : undefined,
+ parts:
+ m.parts?.map((p) => ({
+ id: p.id || "",
+ type: p.type || "text",
+ text: p.text,
+ thinking: p.thinking,
+ tool: p.tool,
+ callID: p.callID,
+ input: p.input,
+ output: p.output,
+ error: p.error,
+ })) || [],
+ }))
+ return messages.sort((a, b) => {
+ const aTime = a.time?.created ?? 0
+ const bTime = b.time?.created ?? 0
+ if (aTime !== bTime) return aTime - bTime
+ return a.id.localeCompare(b.id)
+ })
+ } catch {
+ return []
+ }
+ }
+
+ // Stable mode: use JSON files
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
@@ -161,6 +251,28 @@ async function readParts(messageID: string): Promise {
+ // Beta mode: use SDK
+ if (isSqliteBackend() && sdkClient) {
+ try {
+ const response = await sdkClient.session.todo({ path: { id: sessionID } })
+ const data = normalizeSDKResponse(response, [] as Array<{
+ id?: string
+ content?: string
+ status?: string
+ priority?: string
+ }>)
+ return data.map((item) => ({
+ id: item.id || "",
+ content: item.content || "",
+ status: (item.status as TodoItem["status"]) || "pending",
+ priority: item.priority,
+ }))
+ } catch {
+ return []
+ }
+ }
+
+ // Stable mode: use JSON files
if (!existsSync(TODO_DIR)) return []
try {
diff --git a/src/tools/session-manager/tools.ts b/src/tools/session-manager/tools.ts
index 7650013c..e620c55b 100644
--- a/src/tools/session-manager/tools.ts
+++ b/src/tools/session-manager/tools.ts
@@ -6,7 +6,7 @@ import {
SESSION_SEARCH_DESCRIPTION,
SESSION_INFO_DESCRIPTION,
} from "./constants"
-import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage"
+import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists, setStorageClient } from "./storage"
import {
filterSessionsByDate,
formatSessionInfo,
@@ -28,6 +28,9 @@ function withTimeout(promise: Promise, ms: number, operation: string): Pro
}
export function createSessionManagerTools(ctx: PluginInput): Record {
+ // Initialize storage client for SDK-based operations (beta mode)
+ setStorageClient(ctx.client)
+
const session_list: ToolDefinition = tool({
description: SESSION_LIST_DESCRIPTION,
args: {
@@ -67,12 +70,16 @@ export function createSessionManagerTools(ctx: PluginInput): Record {
try {
- if (!sessionExists(args.session_id)) {
+ if (!(await sessionExists(args.session_id))) {
return `Session not found: ${args.session_id}`
}
let messages = await readSessionMessages(args.session_id)
+ if (messages.length === 0) {
+ return `Session not found: ${args.session_id}`
+ }
+
if (args.limit && args.limit > 0) {
messages = messages.slice(0, args.limit)
}
diff --git a/src/tools/session-manager/types.ts b/src/tools/session-manager/types.ts
index becaf13b..635b9a75 100644
--- a/src/tools/session-manager/types.ts
+++ b/src/tools/session-manager/types.ts
@@ -34,10 +34,10 @@ export interface SessionInfo {
}
export interface TodoItem {
- id: string
- content: string
- status: "pending" | "in_progress" | "completed" | "cancelled"
- priority?: string
+ id?: string;
+ content: string;
+ status: "pending" | "in_progress" | "completed" | "cancelled";
+ priority?: string;
}
export interface SearchResult {
diff --git a/src/tools/task/todo-sync.test.ts b/src/tools/task/todo-sync.test.ts
index ed53f51d..e35d1978 100644
--- a/src/tools/task/todo-sync.test.ts
+++ b/src/tools/task/todo-sync.test.ts
@@ -418,12 +418,16 @@ describe("syncAllTasksToTodos", () => {
},
];
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
+ let writtenTodos: TodoInfo[] = [];
+ const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
+ writtenTodos = input.todos;
+ };
// when
- await syncAllTasksToTodos(mockCtx, tasks, "session-1");
+ await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
// then
- expect(mockCtx.client.session.todo).toHaveBeenCalled();
+ expect(writtenTodos.some((t: TodoInfo) => t.id === "T-1")).toBe(false);
});
it("preserves existing todos not in task list", async () => {
@@ -451,12 +455,17 @@ describe("syncAllTasksToTodos", () => {
},
];
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
+ let writtenTodos: TodoInfo[] = [];
+ const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
+ writtenTodos = input.todos;
+ };
// when
- await syncAllTasksToTodos(mockCtx, tasks, "session-1");
+ await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
// then
- expect(mockCtx.client.session.todo).toHaveBeenCalled();
+ expect(writtenTodos.some((t: TodoInfo) => t.id === "T-existing")).toBe(true);
+ expect(writtenTodos.some((t: TodoInfo) => t.content === "Task 1")).toBe(true);
});
it("handles empty task list", async () => {
@@ -471,7 +480,7 @@ describe("syncAllTasksToTodos", () => {
expect(mockCtx.client.session.todo).toHaveBeenCalled();
});
- it("handles undefined sessionID", async () => {
+ it("calls writer with final todos", async () => {
// given
const tasks: Task[] = [
{
@@ -484,13 +493,83 @@ describe("syncAllTasksToTodos", () => {
},
];
mockCtx.client.session.todo.mockResolvedValue([]);
+ let writerCalled = false;
+ const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
+ writerCalled = true;
+ expect(input.sessionID).toBe("session-1");
+ expect(input.todos.length).toBe(1);
+ expect(input.todos[0].content).toBe("Task 1");
+ };
// when
- await syncAllTasksToTodos(mockCtx, tasks);
+ await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
// then
- expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
- path: { id: "" },
- });
+ expect(writerCalled).toBe(true);
+ });
+
+ it("deduplicates no-id todos when task replaces existing content", async () => {
+ // given
+ const tasks: Task[] = [
+ {
+ id: "T-1",
+ subject: "Task 1 (updated)",
+ description: "Description 1",
+ status: "in_progress",
+ blocks: [],
+ blockedBy: [],
+ },
+ ];
+ const currentTodos: TodoInfo[] = [
+ {
+ content: "Task 1 (updated)",
+ status: "pending",
+ },
+ ];
+ mockCtx.client.session.todo.mockResolvedValue(currentTodos);
+ let writtenTodos: TodoInfo[] = [];
+ const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
+ writtenTodos = input.todos;
+ };
+
+ // when
+ await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
+
+ // then — no duplicates
+ const matching = writtenTodos.filter((t: TodoInfo) => t.content === "Task 1 (updated)");
+ expect(matching.length).toBe(1);
+ expect(matching[0].status).toBe("in_progress");
+ });
+
+ it("preserves todos without id field", async () => {
+ // given
+ const tasks: Task[] = [
+ {
+ id: "T-1",
+ subject: "Task 1",
+ description: "Description 1",
+ status: "pending",
+ blocks: [],
+ blockedBy: [],
+ },
+ ];
+ const currentTodos: TodoInfo[] = [
+ {
+ id: "T-1",
+ content: "Task 1",
+ status: "pending",
+ },
+ {
+ content: "Todo without id",
+ status: "pending",
+ },
+ ];
+ mockCtx.client.session.todo.mockResolvedValue(currentTodos);
+
+ // when
+ await syncAllTasksToTodos(mockCtx, tasks, "session-1");
+
+ // then
+ expect(mockCtx.client.session.todo).toHaveBeenCalled();
});
});
diff --git a/src/tools/task/todo-sync.ts b/src/tools/task/todo-sync.ts
index 3243e723..c11849f8 100644
--- a/src/tools/task/todo-sync.ts
+++ b/src/tools/task/todo-sync.ts
@@ -3,7 +3,7 @@ import { log } from "../../shared/logger";
import type { Task } from "../../features/claude-tasks/types.ts";
export interface TodoInfo {
- id: string;
+ id?: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
@@ -47,6 +47,13 @@ function extractPriority(
return undefined;
}
+function todosMatch(todo1: TodoInfo, todo2: TodoInfo): boolean {
+ if (todo1.id && todo2.id) {
+ return todo1.id === todo2.id;
+ }
+ return todo1.content === todo2.content;
+}
+
export function syncTaskToTodo(task: Task): TodoInfo | null {
const todoStatus = mapTaskStatusToTodoStatus(task.status);
@@ -100,8 +107,18 @@ export async function syncTaskTodoUpdate(
path: { id: sessionID },
});
const currentTodos = extractTodos(response);
- const nextTodos = currentTodos.filter((todo) => todo.id !== task.id);
- const todo = syncTaskToTodo(task);
+ const taskTodo = syncTaskToTodo(task);
+ const nextTodos = currentTodos.filter((todo) => {
+ if (taskTodo) {
+ return !todosMatch(todo, taskTodo);
+ }
+ // Deleted task: match by id if present, otherwise by content
+ if (todo.id) {
+ return todo.id !== task.id;
+ }
+ return todo.content !== task.subject;
+ });
+ const todo = taskTodo;
if (todo) {
nextTodos.push(todo);
@@ -122,6 +139,7 @@ export async function syncAllTasksToTodos(
ctx: PluginInput,
tasks: Task[],
sessionID?: string,
+ writer?: TodoWriter,
): Promise {
try {
let currentTodos: TodoInfo[] = [];
@@ -139,8 +157,10 @@ export async function syncAllTasksToTodos(
const newTodos: TodoInfo[] = [];
const tasksToRemove = new Set();
+ const allTaskSubjects = new Set();
for (const task of tasks) {
+ allTaskSubjects.add(task.subject);
const todo = syncTaskToTodo(task);
if (todo === null) {
tasksToRemove.add(task.id);
@@ -150,16 +170,28 @@ export async function syncAllTasksToTodos(
}
const finalTodos: TodoInfo[] = [];
- const newTodoIds = new Set(newTodos.map((t) => t.id));
+
+ const removedTaskSubjects = new Set(
+ tasks.filter((t) => t.status === "deleted").map((t) => t.subject),
+ );
for (const existing of currentTodos) {
- if (!newTodoIds.has(existing.id) && !tasksToRemove.has(existing.id)) {
+ const isInNewTodos = newTodos.some((newTodo) => todosMatch(existing, newTodo));
+ const isRemovedById = existing.id ? tasksToRemove.has(existing.id) : false;
+ const isRemovedByContent = !existing.id && removedTaskSubjects.has(existing.content);
+ const isReplacedByTask = !existing.id && allTaskSubjects.has(existing.content);
+ if (!isInNewTodos && !isRemovedById && !isRemovedByContent && !isReplacedByTask) {
finalTodos.push(existing);
}
}
finalTodos.push(...newTodos);
+ const resolvedWriter = writer ?? (await resolveTodoWriter());
+ if (resolvedWriter && sessionID) {
+ await resolvedWriter({ sessionID, todos: finalTodos });
+ }
+
log("[todo-sync] Synced todos", {
count: finalTodos.length,
sessionID,