Merge remote-tracking branch 'origin/dev' into fix/background-notification-hook-gate

# Conflicts:
#	src/features/background-agent/manager.ts
This commit is contained in:
Dan Kochetov 2026-02-16 13:56:33 +02:00
commit 9b187e2128
No known key found for this signature in database
171 changed files with 5865 additions and 1290 deletions

View File

@ -1,8 +1,8 @@
# PROJECT KNOWLEDGE BASE # PROJECT KNOWLEDGE BASE
**Generated:** 2026-02-10T14:44:00+09:00 **Generated:** 2026-02-16T14:58:00+09:00
**Commit:** b538806d **Commit:** 28cd34c3
**Branch:** dev **Branch:** fuck-v1.2
--- ---
@ -102,32 +102,32 @@ Oh-My-OpenCode is a **plugin for OpenCode**. You will frequently need to examine
## OVERVIEW ## 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 ## STRUCTURE
``` ```
oh-my-opencode/ oh-my-opencode/
├── src/ ├── src/
│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md │ ├── agents/ # 11 AI agents see src/agents/AGENTS.md
│ ├── hooks/ # 41 lifecycle hooks - see src/hooks/AGENTS.md │ ├── hooks/ # 41 lifecycle hooks see src/hooks/AGENTS.md
│ ├── tools/ # 25+ tools - see src/tools/AGENTS.md │ ├── tools/ # 26 tools — see src/tools/AGENTS.md
│ ├── features/ # Background agents, skills, CC compat - see src/features/AGENTS.md │ ├── features/ # Background agents, skills, CC compat see src/features/AGENTS.md
│ ├── shared/ # 84 cross-cutting utilities - see src/shared/AGENTS.md │ ├── shared/ # Cross-cutting utilities — see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md │ ├── cli/ # CLI installer, doctor see src/cli/AGENTS.md
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md │ ├── mcp/ # Built-in MCPs see src/mcp/AGENTS.md
│ ├── config/ # Zod schema - see src/config/AGENTS.md │ ├── config/ # Zod schema see src/config/AGENTS.md
│ ├── plugin-handlers/ # Config loading - see src/plugin-handlers/AGENTS.md │ ├── plugin-handlers/ # Config loading pipeline — see src/plugin-handlers/AGENTS.md
│ ├── plugin/ # Plugin interface composition (21 files) │ ├── 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-hooks.ts # Hook creation coordination (62 lines)
│ ├── create-managers.ts # Manager initialization (80 lines) │ ├── create-managers.ts # Manager initialization (80 lines)
│ ├── create-tools.ts # Tool registry composition (54 lines) │ ├── create-tools.ts # Tool registry composition (54 lines)
│ ├── plugin-interface.ts # Plugin interface assembly (66 lines) │ ├── plugin-interface.ts # Plugin interface assembly (66 lines)
│ ├── plugin-config.ts # Config loading orchestration │ ├── plugin-config.ts # Config loading orchestration (180 lines)
│ └── plugin-state.ts # Model cache state │ └── plugin-state.ts # Model cache state (12 lines)
├── script/ # build-schema.ts, build-binaries.ts, publish.ts, generate-changelog.ts ├── 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) └── dist/ # Build output (ESM + .d.ts)
``` ```
@ -143,7 +143,7 @@ OhMyOpenCodePlugin(ctx)
6. createManagers(ctx, config, tmux, cache) → TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler 6. createManagers(ctx, config, tmux, cache) → TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
7. createTools(ctx, config, managers) → filteredTools, mergedSkills, availableSkills, availableCategories 7. createTools(ctx, config, managers) → filteredTools, mergedSkills, availableSkills, availableCategories
8. createHooks(ctx, config, backgroundMgr) → 41 hooks (core + continuation + skill) 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 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 | | 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` | | 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 | | 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) | | Orchestrator | `src/hooks/atlas/` | Main orchestration hook (1976 lines) |
| Delegation | `src/tools/delegate-task/` | Category routing (constants.ts 569 lines) | | Delegation | `src/tools/delegate-task/` | Category routing (constants.ts 569 lines) |
| Task system | `src/features/claude-tasks/` | Task schema, storage, todo sync | | Task system | `src/features/claude-tasks/` | Task schema, storage, todo sync |
@ -174,7 +174,7 @@ OhMyOpenCodePlugin(ctx)
**Rules:** **Rules:**
- NEVER write implementation before test - 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) - Test file: `*.test.ts` alongside source (176 test files)
- BDD comments: `//#given`, `//#when`, `//#then` - BDD comments: `//#given`, `//#when`, `//#then`
@ -185,7 +185,7 @@ OhMyOpenCodePlugin(ctx)
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly` - **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern via index.ts - **Exports**: Barrel pattern via index.ts
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories - **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 - **Temperature**: 0.1 for code agents, max 0.3
- **Modular architecture**: 200 LOC hard limit per file (prompt strings exempt) - **Modular architecture**: 200 LOC hard limit per file (prompt strings exempt)
@ -193,24 +193,24 @@ OhMyOpenCodePlugin(ctx)
| Category | Forbidden | | Category | Forbidden |
|----------|-----------| |----------|-----------|
| Package Manager | npm, yarn - Bun exclusively | | Package Manager | npm, yarn Bun exclusively |
| Types | @types/node - use bun-types | | Types | @types/node use bun-types |
| File Ops | mkdir/touch/rm/cp/mv in code - use bash tool | | File Ops | mkdir/touch/rm/cp/mv in code use bash tool |
| Publishing | Direct `bun publish` - GitHub Actions only | | Publishing | Direct `bun publish` GitHub Actions only |
| Versioning | Local version bump - CI manages | | Versioning | Local version bump CI manages |
| Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` | | Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` |
| Error Handling | Empty catch blocks | | Error Handling | Empty catch blocks |
| Testing | Deleting failing tests, writing implementation before test | | Testing | Deleting failing tests, writing implementation before test |
| Agent Calls | Sequential - use `task` parallel | | Agent Calls | Sequential use `task` parallel |
| Hook Logic | Heavy PreToolUse - slows every call | | Hook Logic | Heavy PreToolUse slows every call |
| Commits | Giant (3+ files), separate test from impl | | Commits | Giant (3+ files), separate test from impl |
| Temperature | >0.3 for code agents | | 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 | `git add -i`, `git rebase -i` (no interactive input) |
| Git | Skip hooks (--no-verify), force push without request | | Git | Skip hooks (--no-verify), force push without request |
| Bash | `sleep N` - use conditional waits | | Bash | `sleep N` use conditional waits |
| Bash | `cd dir && cmd` - use workdir parameter | | Bash | `cd dir && cmd` use workdir parameter |
| Files | Catch-all utils.ts/helpers.ts - name by purpose | | Files | Catch-all utils.ts/helpers.ts name by purpose |
## AGENT MODELS ## AGENT MODELS
@ -230,7 +230,7 @@ OhMyOpenCodePlugin(ctx)
## OPENCODE PLUGIN API ## 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 | | Hook | Purpose |
|------|---------| |------|---------|
@ -283,7 +283,7 @@ bun run build:schema # Regenerate JSON schema
| File | Lines | Description | | 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/anthropic-context-window-limit-recovery/` | 2232 | Multi-strategy context recovery |
| `src/hooks/claude-code-hooks/` | 2110 | Claude Code settings.json compat | | `src/hooks/claude-code-hooks/` | 2110 | Claude Code settings.json compat |
| `src/hooks/todo-continuation-enforcer/` | 2061 | Core boulder mechanism | | `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/rules-injector/` | 1604 | Conditional rules injection |
| `src/hooks/think-mode/` | 1365 | Model/variant switching | | `src/hooks/think-mode/` | 1365 | Model/variant switching |
| `src/hooks/session-recovery/` | 1279 | Auto error recovery | | `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 | | `src/tools/delegate-task/constants.ts` | 569 | Category routing configs |
## MCP ARCHITECTURE ## MCP ARCHITECTURE
@ -313,7 +313,7 @@ Three-tier system:
## NOTES ## NOTES
- **OpenCode**: Requires >= 1.0.150 - **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) - **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker - **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **No linter/formatter**: No ESLint, Prettier, or Biome configured - **No linter/formatter**: No ESLint, Prettier, or Biome configured

View File

@ -162,6 +162,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -207,6 +210,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -294,6 +300,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -335,6 +344,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -380,6 +392,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -467,6 +482,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -508,6 +526,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -553,6 +574,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -640,6 +664,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -681,6 +708,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -726,6 +756,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -813,6 +846,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -854,6 +890,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -899,6 +938,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -986,6 +1028,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -1027,6 +1072,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -1072,6 +1120,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1159,6 +1210,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -1200,6 +1254,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -1245,6 +1302,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1332,6 +1392,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -1373,6 +1436,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -1418,6 +1484,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1505,6 +1574,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -1546,6 +1618,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -1591,6 +1666,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1678,6 +1756,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -1719,6 +1800,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -1764,6 +1848,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1851,6 +1938,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -1892,6 +1982,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -1937,6 +2030,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -2024,6 +2120,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -2065,6 +2164,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -2110,6 +2212,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -2197,6 +2302,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -2238,6 +2346,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -2283,6 +2394,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -2370,6 +2484,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -2411,6 +2528,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -2456,6 +2576,9 @@
}, },
{ {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -2543,6 +2666,9 @@
}, },
"providerOptions": { "providerOptions": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
} }
}, },
@ -2553,6 +2679,9 @@
}, },
"categories": { "categories": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -2616,6 +2745,9 @@
}, },
"tools": { "tools": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -2656,6 +2788,9 @@
}, },
"plugins_override": { "plugins_override": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "boolean" "type": "boolean"
} }
@ -2926,6 +3061,9 @@
}, },
"metadata": { "metadata": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {} "additionalProperties": {}
}, },
"allowed-tools": { "allowed-tools": {
@ -2977,6 +3115,9 @@
}, },
"providerConcurrency": { "providerConcurrency": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "number", "type": "number",
"minimum": 0 "minimum": 0
@ -2984,6 +3125,9 @@
}, },
"modelConcurrency": { "modelConcurrency": {
"type": "object", "type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": { "additionalProperties": {
"type": "number", "type": "number",
"minimum": 0 "minimum": 0

View File

@ -28,13 +28,13 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.3", "oh-my-opencode-darwin-arm64": "3.5.5",
"oh-my-opencode-darwin-x64": "3.5.3", "oh-my-opencode-darwin-x64": "3.5.5",
"oh-my-opencode-linux-arm64": "3.5.3", "oh-my-opencode-linux-arm64": "3.5.5",
"oh-my-opencode-linux-arm64-musl": "3.5.3", "oh-my-opencode-linux-arm64-musl": "3.5.5",
"oh-my-opencode-linux-x64": "3.5.3", "oh-my-opencode-linux-x64": "3.5.5",
"oh-my-opencode-linux-x64-musl": "3.5.3", "oh-my-opencode-linux-x64-musl": "3.5.5",
"oh-my-opencode-windows-x64": "3.5.3", "oh-my-opencode-windows-x64": "3.5.5",
}, },
}, },
}, },
@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "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=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode", "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", "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", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@ -74,13 +74,13 @@
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.5", "oh-my-opencode-darwin-arm64": "3.5.6",
"oh-my-opencode-darwin-x64": "3.5.5", "oh-my-opencode-darwin-x64": "3.5.6",
"oh-my-opencode-linux-arm64": "3.5.5", "oh-my-opencode-linux-arm64": "3.5.6",
"oh-my-opencode-linux-arm64-musl": "3.5.5", "oh-my-opencode-linux-arm64-musl": "3.5.6",
"oh-my-opencode-linux-x64": "3.5.5", "oh-my-opencode-linux-x64": "3.5.6",
"oh-my-opencode-linux-x64-musl": "3.5.5", "oh-my-opencode-linux-x64-musl": "3.5.6",
"oh-my-opencode-windows-x64": "3.5.5" "oh-my-opencode-windows-x64": "3.5.6"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@ast-grep/cli", "@ast-grep/cli",

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-darwin-arm64", "name": "oh-my-opencode-darwin-arm64",
"version": "3.5.5", "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)", "description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-darwin-x64", "name": "oh-my-opencode-darwin-x64",
"version": "3.5.5", "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)", "description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-arm64-musl", "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)", "description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-arm64", "name": "oh-my-opencode-linux-arm64",
"version": "3.5.5", "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)", "description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-x64-musl", "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)", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-x64", "name": "oh-my-opencode-linux-x64",
"version": "3.5.5", "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)", "description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-windows-x64", "name": "oh-my-opencode-windows-x64",
"version": "3.5.5", "version": "3.5.6",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)", "description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@ -1511,6 +1511,14 @@
"created_at": "2026-02-15T15:07:11Z", "created_at": "2026-02-15T15:07:11Z",
"repoId": 1108837393, "repoId": 1108837393,
"pullRequestNo": 1864 "pullRequestNo": 1864
},
{
"name": "dankochetov",
"id": 33990502,
"comment_id": 3905398332,
"created_at": "2026-02-15T23:17:05Z",
"repoId": 1108837393,
"pullRequestNo": 1870
} }
] ]
} }

View File

@ -5,25 +5,26 @@
Main plugin entry point and orchestration layer. Plugin initialization, hook registration, tool composition, and lifecycle management. Main plugin entry point and orchestration layer. Plugin initialization, hook registration, tool composition, and lifecycle management.
## STRUCTURE ## STRUCTURE
``` ```
src/ 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-hooks.ts # Hook coordination: core, continuation, skill (62 lines)
├── create-managers.ts # Manager initialization: Tmux, Background, SkillMcp, Config (80 lines) ├── create-managers.ts # Manager initialization: Tmux, Background, SkillMcp, Config (80 lines)
├── create-tools.ts # Tool registry + skill context composition (54 lines) ├── create-tools.ts # Tool registry + skill context composition (54 lines)
├── plugin-interface.ts # Plugin interface assembly — 7 OpenCode hooks (66 lines) ├── plugin-interface.ts # Plugin interface assembly — 7 OpenCode hooks (66 lines)
├── plugin-config.ts # Config loading orchestration (user + project merge) ├── plugin-config.ts # Config loading orchestration (user + project merge, 180 lines)
├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag) ├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag, 12 lines)
├── agents/ # 11 AI agents (32 files) - see agents/AGENTS.md ├── agents/ # 11 AI agents (32 files) see agents/AGENTS.md
├── cli/ # CLI installer, doctor (107+ files) - see cli/AGENTS.md ├── cli/ # CLI installer, doctor (107+ files) see cli/AGENTS.md
├── config/ # Zod schema (21 component files) - see config/AGENTS.md ├── config/ # Zod schema (21 component files) see config/AGENTS.md
├── features/ # Background agents, skills, commands (18 dirs) - see features/AGENTS.md ├── features/ # Background agents, skills, commands (18 dirs) see features/AGENTS.md
├── hooks/ # 41 lifecycle hooks (36 dirs) - see hooks/AGENTS.md ├── hooks/ # 41 lifecycle hooks (36 dirs) see hooks/AGENTS.md
├── mcp/ # Built-in MCPs (6 files) - see mcp/AGENTS.md ├── mcp/ # Built-in MCPs (6 files) see mcp/AGENTS.md
├── plugin/ # Plugin interface composition (21 files) ├── plugin/ # Plugin interface composition (21 files)
├── plugin-handlers/ # Config loading, plan inheritance (15 files) - see plugin-handlers/AGENTS.md ├── plugin-handlers/ # Config loading, plan inheritance (15 files) see plugin-handlers/AGENTS.md
├── shared/ # Cross-cutting utilities (84 files) - see shared/AGENTS.md ├── shared/ # Cross-cutting utilities (96 files) — see shared/AGENTS.md
└── tools/ # 25+ tools (14 dirs) - see tools/AGENTS.md └── tools/ # 26 tools (14 dirs) — see tools/AGENTS.md
``` ```
## PLUGIN INITIALIZATION (10 steps) ## PLUGIN INITIALIZATION (10 steps)

View File

@ -7,36 +7,22 @@
## STRUCTURE ## STRUCTURE
``` ```
agents/ agents/
├── sisyphus.ts # Main orchestrator (530 lines) ├── sisyphus.ts # Main orchestrator (559 lines)
├── hephaestus.ts # Autonomous deep worker (624 lines) ├── hephaestus.ts # Autonomous deep worker (651 lines)
├── oracle.ts # Strategic advisor (170 lines) ├── oracle.ts # Strategic advisor (171 lines)
├── librarian.ts # Multi-repo research (328 lines) ├── librarian.ts # Multi-repo research (329 lines)
├── explore.ts # Fast codebase grep (124 lines) ├── explore.ts # Fast codebase grep (125 lines)
├── multimodal-looker.ts # Media analyzer (58 lines) ├── multimodal-looker.ts # Media analyzer (59 lines)
├── metis.ts # Pre-planning analysis (347 lines) ├── metis.ts # Pre-planning analysis (347 lines)
├── momus.ts # Plan validator (244 lines) ├── momus.ts # Plan validator (244 lines)
├── atlas/ # Master orchestrator ├── atlas/ # Master orchestrator (agent.ts + default.ts + gpt.ts)
│ ├── agent.ts # Atlas factory ├── prometheus/ # Planning agent (8 files, plan-template 423 lines)
│ ├── default.ts # Claude-optimized prompt ├── sisyphus-junior/ # Delegated task executor (agent.ts + default.ts + gpt.ts)
│ ├── gpt.ts # GPT-optimized prompt ├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (433 lines)
│ └── utils.ts ├── builtin-agents/ # Agent registry + model resolution
├── prometheus/ # Planning agent ├── agent-builder.ts # Agent construction with category merging (51 lines)
│ ├── 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)
├── utils.ts # Agent creation, model fallback resolution (571 lines) ├── utils.ts # Agent creation, model fallback resolution (571 lines)
├── types.ts # AgentModelConfig, AgentPromptMetadata ├── types.ts # AgentModelConfig, AgentPromptMetadata (106 lines)
└── index.ts # Exports └── index.ts # Exports
``` ```
@ -78,6 +64,12 @@ agents/
| Momus | 32k budget tokens | reasoningEffort: "medium" | | Momus | 32k budget tokens | reasoningEffort: "medium" |
| Sisyphus-Junior | 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 ## HOW TO ADD
1. Create `src/agents/my-agent.ts` exporting factory + metadata 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` 3. Update `AgentNameSchema` in `src/config/schema/agent-names.ts`
4. Register in `src/plugin-handlers/agent-config-handler.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 ## ANTI-PATTERNS
- **Trust agent self-reports**: NEVER — always verify outputs - **Trust agent self-reports**: NEVER — always verify outputs

View File

@ -66,7 +66,7 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => {
expect(lowerPrompt).toContain("preconditions") expect(lowerPrompt).toContain("preconditions")
expect(lowerPrompt).toContain("failure indicators") expect(lowerPrompt).toContain("failure indicators")
expect(lowerPrompt).toContain("evidence") expect(lowerPrompt).toContain("evidence")
expect(lowerPrompt).toMatch(/negative scenario/) expect(prompt).toMatch(/negative/i)
}) })
test("should require QA scenario adequacy in self-review checklist", () => { test("should require QA scenario adequacy in self-review checklist", () => {

View File

@ -129,7 +129,21 @@ Your ONLY valid output locations are \`.sisyphus/plans/*.md\` and \`.sisyphus/dr
Example: \`.sisyphus/plans/auth-refactor.md\` 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.** **No matter how large the task, EVERYTHING goes into ONE work plan.**
**NEVER:** **NEVER:**
@ -152,7 +166,7 @@ Example: \`.sisyphus/plans/auth-refactor.md\`
**The plan can have 50+ TODOs. That's OK. ONE PLAN.** **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)
<write_protocol> <write_protocol>
**The Write tool OVERWRITES files. It does NOT append.** **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 - [ ] File already exists with my content? Use Edit to append, NOT Write
</write_protocol> </write_protocol>
### 6. DRAFT AS WORKING MEMORY (MANDATORY) ### 7. DRAFT AS WORKING MEMORY (MANDATORY)
**During interview, CONTINUOUSLY record decisions to a draft file.** **During interview, CONTINUOUSLY record decisions to a draft file.**
**Draft Location**: \`.sisyphus/drafts/{name}.md\` **Draft Location**: \`.sisyphus/drafts/{name}.md\`

View File

@ -70,108 +70,25 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
## Verification Strategy (MANDATORY) ## Verification Strategy (MANDATORY)
> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION** > **ZERO HUMAN INTERVENTION** ALL verification is agent-executed. No exceptions.
> > Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
> 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.
### Test Decision ### Test Decision
- **Infrastructure exists**: [YES/NO] - **Infrastructure exists**: [YES/NO]
- **Automated tests**: [TDD / Tests-after / None] - **Automated tests**: [TDD / Tests-after / None]
- **Framework**: [bun test / vitest / jest / pytest / 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: | Deliverable Type | Verification Tool | Method |
|------------------|-------------------|--------|
**Task Structure:** | Frontend/UI | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
1. **RED**: Write failing test first | TUI/CLI | interactive_bash (tmux) | Run command, send keystrokes, validate output |
- Test file: \`[path].test.ts\` | API/Backend | Bash (curl) | Send requests, assert status + response fields |
- Test command: \`bun test [file]\` | Library/Module | Bash (bun/node REPL) | Import, call functions, compare output |
- 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
--- ---
@ -181,49 +98,82 @@ Scenario: [Descriptive name — what user action/flow is being verified]
> Maximize throughput by grouping independent tasks into parallel waves. > Maximize throughput by grouping independent tasks into parallel waves.
> Each wave completes before the next begins. > 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): Wave 1 (Start Immediately foundation + scaffolding):
Task 1: [no dependencies] Task 1: Project scaffolding + config [quick]
Task 5: [no dependencies] 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): Wave 2 (After Wave 1 core modules, MAX PARALLEL):
Task 2: [depends: 1] Task 8: Core business logic (depends: 3, 5, 7) [deep]
Task 3: [depends: 1] Task 9: API endpoints (depends: 4, 5) [unspecified-high]
Task 6: [depends: 5] 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): Wave 3 (After Wave 2 integration + UI):
Task 4: [depends: 2, 3] 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 Wave 4 (After Wave 3 verification):
Parallel Speedup: ~40% faster than sequential 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 | | Task | Depends On | Blocks | Wave |
|------|------------|--------|---------------------| |------|------------|--------|------|
| 1 | None | 2, 3 | 5 | | 1-7 | | 8-14 | 1 |
| 2 | 1 | 4 | 3, 6 | | 8 | 3, 5, 7 | 11, 15 | 2 |
| 3 | 1 | 4 | 2, 6 | | 11 | 8 | 15 | 2 |
| 4 | 2, 3 | None | None (final) | | 14 | 5, 10 | 15 | 2 |
| 5 | None | 6 | 1 | | 15 | 6, 11, 14 | 17-19, 21 | 3 |
| 6 | 5 | None | 2, 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 ### Agent Dispatch Summary
| Wave | Tasks | Recommended Agents | | Wave | # Parallel | Tasks Agent Category |
|------|-------|-------------------| |------|------------|----------------------|
| 1 | 1, 5 | task(category="...", load_skills=[...], run_in_background=false) | | 1 | **7** | T1-T4 \`quick\`, T5 → \`quick\`, T6 → \`quick\`, T7 → \`quick\` |
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes | | 2 | **7** | T8 \`deep\`, T9 → \`unspecified-high\`, T10 → \`unspecified-high\`, T11 → \`deep\`, T12 → \`visual-engineering\`, T13 → \`quick\`, T14 → \`unspecified-high\` |
| 3 | 4 | final integration task | | 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 ## TODOs
> Implementation + Test = ONE Task. Never separate. > 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] - [ ] 1. [Task Title]
@ -257,22 +207,15 @@ Parallel Speedup: ~40% faster than sequential
**Pattern References** (existing code to follow): **Pattern References** (existing code to follow):
- \`src/services/auth.ts:45-78\` - Authentication flow pattern (JWT creation, refresh token handling) - \`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): **API/Type References** (contracts to implement against):
- \`src/types/user.ts:UserDTO\` - Response shape for user endpoints - \`src/types/user.ts:UserDTO\` - Response shape for user endpoints
- \`src/api/schema.ts:createUserSchema\` - Request validation schema
**Test References** (testing patterns to follow): **Test References** (testing patterns to follow):
- \`src/__tests__/auth.test.ts:describe("login")\` - Test structure and mocking patterns - \`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): **External References** (libraries and frameworks):
- Official docs: \`https://zod.dev/?id=basic-usage\` - Zod validation syntax - 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): **WHY Each Reference Matters** (explain the relevance):
- Don't just list files - explain what pattern/information the executor should extract - 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. > **AGENT-EXECUTABLE VERIFICATION ONLY** No human action permitted.
> Every criterion MUST be verifiable by running a command or using a tool. > 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):** **If TDD (tests enabled):**
- [ ] Test file created: src/auth/login.test.ts - [ ] 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) - [ ] 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. > **This is NOT optional. A task without QA scenarios WILL BE REJECTED.**
> Each scenario = exact tool + steps with real selectors/data + evidence path. >
> Write scenario tests that verify the ACTUAL BEHAVIOR of what you built.
**Example Frontend/UI (Playwright):** > 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 Scenario: [Happy path what SHOULD work]
Tool: Playwright (playwright skill) Tool: [Playwright / interactive_bash / Bash (curl)]
Preconditions: Dev server running on localhost:3000, test user exists Preconditions: [Exact setup state]
Steps: Steps:
1. Navigate to: http://localhost:3000/login 1. [Exact action specific command/selector/endpoint, no vagueness]
2. Wait for: input[name="email"] visible (timeout: 5s) 2. [Next action with expected intermediate state]
3. Fill: input[name="email"] "test@example.com" 3. [Assertion exact expected value, not "verify it works"]
4. Fill: input[name="password"] "ValidPass123!" Expected Result: [Concrete, observable, binary pass/fail]
5. Click: button[type="submit"] Failure Indicators: [What specifically would mean this failed]
6. Wait for: navigation to /dashboard (timeout: 10s) Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}.{ext}
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
Scenario: Login fails with invalid credentials Scenario: [Failure/edge case what SHOULD fail gracefully]
Tool: Playwright (playwright skill) Tool: [same format]
Preconditions: Dev server running, no valid user with these credentials Preconditions: [Invalid input / missing dependency / error state]
Steps: Steps:
1. Navigate to: http://localhost:3000/login 1. [Trigger the error condition]
2. Fill: input[name="email"] "wrong@example.com" 2. [Assert error is handled correctly]
3. Fill: input[name="password"] "WrongPass" Expected Result: [Graceful failure with correct error message/code]
4. Click: button[type="submit"] Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}-error.{ext}
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
\\\`\\\`\\\` \\\`\\\`\\\`
**Example API/Backend (curl):** > **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]"\`)
Scenario: Create user returns 201 with UUID > - **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
Tool: Bash (curl) > - **Timing**: Wait conditions where relevant (\`timeout: 10s\`)
Preconditions: Server running on localhost:8080 > - **Negative**: At least ONE failure/error scenario per task
Steps: >
1. curl -s -w "\\n%{http_code}" -X POST http://localhost:8080/api/users \\ > **Anti-patterns (your scenario is INVALID if it looks like this):**
-H "Content-Type: application/json" \\ > - "Verify it works correctly" HOW? What does "correctly" mean?
-d '{"email":"new@test.com","name":"Test User"}' > - "Check the API returns data" WHAT data? What fields? What values?
2. Assert: HTTP status is 201 > - "Test the component renders" WHERE? What selector? What content?
3. Assert: response.id matches UUID format > - Any scenario without an evidence path
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
\\\`\\\`\\\`
**Evidence to Capture:** **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} - [ ] 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) **Commit**: YES | NO (groups with N)
- Message: \`type(scope): desc\` - 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 ## Commit Strategy
| After Task | Message | Files | Verification | | After Task | Message | Files | Verification |

View File

@ -2,9 +2,7 @@
## OVERVIEW ## OVERVIEW
CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI. CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI. 5 commands: install, run, doctor, get-local-version, mcp-oauth.
**Commands**: install, run, doctor, get-local-version, mcp-oauth
## STRUCTURE ## STRUCTURE
``` ```
@ -14,20 +12,22 @@ cli/
├── install.ts # TTY routing (TUI or CLI installer) ├── install.ts # TTY routing (TUI or CLI installer)
├── cli-installer.ts # Non-interactive installer (164 lines) ├── cli-installer.ts # Non-interactive installer (164 lines)
├── tui-installer.ts # Interactive TUI with @clack/prompts (140 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-plugin-to-opencode-config.ts # Plugin registration
│ ├── add-provider-config.ts # Provider setup │ ├── add-provider-config.ts # Provider setup (Google/Antigravity)
│ ├── detect-current-config.ts # Project vs user config │ ├── detect-current-config.ts # Installed providers detection
│ ├── write-omo-config.ts # JSONC writing │ ├── write-omo-config.ts # JSONC writing
│ └── ... │ ├── generate-omo-config.ts # Config generation
├── doctor/ # 14 health checks │ ├── jsonc-provider-editor.ts # JSONC editing
│ ├── runner.ts # Check orchestration │ └── ... # 14 more utilities
│ ├── formatter.ts # Colored output ├── doctor/ # 4 check categories, 21 check files
│ └── checks/ # 29 files: auth, config, dependencies, gh, lsp, mcp, opencode, plugin, version, model-resolution (6 sub-checks) │ ├── 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) ├── run/ # Session launcher (24 files)
│ ├── runner.ts # Run orchestration (126 lines) │ ├── runner.ts # Run orchestration (126 lines)
│ ├── agent-resolver.ts # Agent selection: flag → env → config → fallback │ ├── agent-resolver.ts # Agent: flag → env → config → Sisyphus
│ ├── session-resolver.ts # Session creation or resume │ ├── session-resolver.ts # Session create or resume with retries
│ ├── event-handlers.ts # Event processing (125 lines) │ ├── event-handlers.ts # Event processing (125 lines)
│ ├── completion.ts # Completion detection │ ├── completion.ts # Completion detection
│ └── poll-for-completion.ts # Polling with timeout │ └── poll-for-completion.ts # Polling with timeout
@ -43,20 +43,17 @@ cli/
|---------|---------|-----------| |---------|---------|-----------|
| `install` | Interactive setup | Provider selection → config generation → plugin registration | | `install` | Interactive setup | Provider selection → config generation → plugin registration |
| `run` | Session launcher | Agent: flag → env → config → Sisyphus. Enforces todo completion. | | `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 | | `get-local-version` | Version check | Detects installed, compares with npm latest |
| `mcp-oauth` | OAuth tokens | login (PKCE flow), logout, status | | `mcp-oauth` | OAuth tokens | login (PKCE flow), logout, status |
## DOCTOR CHECK CATEGORIES ## RUN SESSION LIFECYCLE
| Category | Checks | 1. Load config, resolve agent (CLI > env > config > Sisyphus)
|----------|--------| 2. Create server connection (port/attach), setup cleanup/signal handlers
| installation | opencode, plugin | 3. Resolve session (create new or resume with retries)
| configuration | config validity, Zod, model-resolution (6 sub-checks) | 4. Send prompt, start event processing, poll for completion
| authentication | anthropic, openai, google | 5. Execute on-complete hook, output JSON if requested, cleanup
| dependencies | ast-grep, comment-checker, gh-cli |
| tools | LSP, MCP, MCP-OAuth |
| updates | version comparison |
## HOW TO ADD CHECK ## HOW TO ADD CHECK

View File

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

View File

@ -77,7 +77,9 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`, `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...") printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config) const authResult = await addAuthPlugins(config)
if (!authResult.success) { if (!authResult.success) {

View File

@ -1,5 +1,6 @@
import pc from "picocolors" import pc from "picocolors"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { normalizeSDKResponse } from "../../shared"
export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> { export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> {
try { try {
@ -20,7 +21,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
async function areAllTodosComplete(ctx: RunContext): Promise<boolean> { async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } }) 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( const incompleteTodos = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled" (t) => t.status !== "completed" && t.status !== "cancelled"
@ -43,7 +44,7 @@ async function fetchAllStatuses(
ctx: RunContext ctx: RunContext
): Promise<Record<string, SessionStatus>> { ): Promise<Record<string, SessionStatus>> {
const statusRes = await ctx.client.session.status() const statusRes = await ctx.client.session.status()
return (statusRes.data ?? {}) as Record<string, SessionStatus> return normalizeSDKResponse(statusRes, {} as Record<string, SessionStatus>)
} }
async function areAllDescendantsIdle( async function areAllDescendantsIdle(
@ -54,7 +55,7 @@ async function areAllDescendantsIdle(
const childrenRes = await ctx.client.session.children({ const childrenRes = await ctx.client.session.children({
path: { id: sessionID }, path: { id: sessionID },
}) })
const children = (childrenRes.data ?? []) as ChildSession[] const children = normalizeSDKResponse(childrenRes, [] as ChildSession[])
for (const child of children) { for (const child of children) {
const status = allStatuses[child.id] const status = allStatuses[child.id]

View File

@ -107,7 +107,7 @@ describe("waitForEventProcessorShutdown", () => {
const eventProcessor = new Promise<void>(() => {}) const eventProcessor = new Promise<void>(() => {})
const spy = spyOn(console, "log").mockImplementation(() => {}) const spy = spyOn(console, "log").mockImplementation(() => {})
consoleLogSpy = spy consoleLogSpy = spy
const timeoutMs = 50 const timeoutMs = 200
const start = performance.now() const start = performance.now()
try { try {
@ -116,11 +116,8 @@ describe("waitForEventProcessorShutdown", () => {
//#then //#then
const elapsed = performance.now() - start const elapsed = performance.now() - start
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs) expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
const callArgs = spy.mock.calls.flat().join("") expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1)
expect(callArgs).toContain(
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
)
} finally { } finally {
spy.mockRestore() spy.mockRestore()
} }

View File

@ -34,10 +34,10 @@ export interface RunContext {
} }
export interface Todo { export interface Todo {
id: string id?: string;
content: string content: string;
status: string status: string;
priority: string priority: string;
} }
export interface SessionStatus { export interface SessionStatus {

View File

@ -7,16 +7,17 @@
## STRUCTURE ## STRUCTURE
``` ```
features/ features/
├── background-agent/ # Task lifecycle, concurrency (50 files, 8330 LOC) ├── background-agent/ # Task lifecycle, concurrency (56 files, 1701-line manager)
│ ├── manager.ts # Main task orchestration (1646 lines) │ ├── manager.ts # Main task orchestration (1701 lines)
│ ├── concurrency.ts # Parallel execution limits per provider/model │ ├── concurrency.ts # Parallel execution limits per provider/model (137 lines)
│ └── spawner/ # Task spawning utilities (8 files) │ ├── 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) ├── tmux-subagent/ # Tmux integration (28 files, 3303 LOC)
│ └── manager.ts # Pane management, grid planning (350 lines) │ └── manager.ts # Pane management, grid planning (350 lines)
├── opencode-skill-loader/ # YAML frontmatter skill loading (28 files, 2967 LOC) ├── opencode-skill-loader/ # YAML frontmatter skill loading (28 files, 2967 LOC)
│ ├── loader.ts # Skill discovery (4 scopes) │ ├── loader.ts # Skill discovery (4 scopes)
│ ├── skill-directory-loader.ts # Recursive directory scanning │ ├── skill-directory-loader.ts # Recursive directory scanning (maxDepth=2)
│ ├── skill-discovery.ts # getAllSkills() with caching │ ├── skill-discovery.ts # getAllSkills() with caching + provider gating
│ └── merger/ # Skill merging with scope priority │ └── merger/ # Skill merging with scope priority
├── mcp-oauth/ # OAuth 2.0 flow for MCP (18 files, 2164 LOC) ├── mcp-oauth/ # OAuth 2.0 flow for MCP (18 files, 2164 LOC)
│ ├── provider.ts # McpOAuthProvider class │ ├── provider.ts # McpOAuthProvider class
@ -25,10 +26,10 @@ features/
├── skill-mcp-manager/ # MCP client lifecycle per session (12 files, 1769 LOC) ├── skill-mcp-manager/ # MCP client lifecycle per session (12 files, 1769 LOC)
│ └── manager.ts # SkillMcpManager class (150 lines) │ └── manager.ts # SkillMcpManager class (150 lines)
├── builtin-skills/ # 5 built-in skills (10 files, 1921 LOC) ├── builtin-skills/ # 5 built-in skills (10 files, 1921 LOC)
│ └── skills/ # git-master (1111), playwright, dev-browser, frontend-ui-ux │ └── skills/ # git-master (1112), playwright (313), dev-browser (222), frontend-ui-ux (80)
├── builtin-commands/ # 6 command templates (11 files, 1511 LOC) ├── builtin-commands/ # 7 command templates (11 files, 1511 LOC)
│ └── templates/ # refactor, ralph-loop, init-deep, handoff, start-work, stop-continuation │ └── templates/ # refactor (620), init-deep (306), handoff (178), start-work, ralph-loop, stop-continuation
├── claude-tasks/ # Task schema + storage (7 files, 1165 LOC) ├── claude-tasks/ # Task schema + storage (7 files) — see AGENTS.md
├── context-injector/ # AGENTS.md, README.md, rules injection (6 files, 809 LOC) ├── 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-plugin-loader/ # Plugin discovery from .opencode/plugins/ (10 files)
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion (6 files) ├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion (6 files)
@ -44,7 +45,10 @@ features/
## KEY PATTERNS ## KEY PATTERNS
**Background Agent Lifecycle:** **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):** **Skill Loading Pipeline (4-scope priority):**
opencode-project (`.opencode/skills/`) > opencode (`~/.config/opencode/skills/`) > project (`.claude/skills/`) > user (`~/.claude/skills/`) opencode-project (`.opencode/skills/`) > opencode (`~/.config/opencode/skills/`) > project (`.claude/skills/`) > user (`~/.claude/skills/`)

View File

@ -33,10 +33,10 @@ export interface BackgroundEvent {
} }
export interface Todo { export interface Todo {
content: string content: string;
status: string status: string;
priority: string priority: string;
id: string id?: string;
} }
export interface QueueItem { export interface QueueItem {

View File

@ -6,6 +6,7 @@ import type { BackgroundTask, ResumeInput } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants" import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager" import { BackgroundManager } from "./manager"
import { ConcurrencyManager } from "./concurrency" import { ConcurrencyManager } from "./concurrency"
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
const TASK_TTL_MS = 30 * 60 * 1000 const TASK_TTL_MS = 30 * 60 * 1000
@ -190,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
} }
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function getQueuesByKey( function getQueuesByKey(
manager: BackgroundManager manager: BackgroundManager
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> { ): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
@ -215,6 +220,23 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {} ;(manager as unknown as { notifyParentSession: () => Promise<void> }).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<NodeJS.Signals | "beforeExit" | "exit"> { function getCleanupSignals(): Array<NodeJS.Signals | "beforeExit" | "exit"> {
const signals: Array<NodeJS.Signals | "beforeExit" | "exit"> = ["SIGINT", "SIGTERM", "beforeExit", "exit"] const signals: Array<NodeJS.Signals | "beforeExit" | "exit"> = ["SIGINT", "SIGTERM", "beforeExit", "exit"]
if (process.platform === "win32") { if (process.platform === "win32") {
@ -894,7 +916,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
}) })
describe("BackgroundManager.notifyParentSession - aborted parent", () => { 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 //#given
let promptCalled = false let promptCalled = false
const promptMock = async () => { const promptMock = async () => {
@ -933,7 +955,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
.notifyParentSession(task) .notifyParentSession(task)
//#then //#then
expect(promptCalled).toBe(false) expect(promptCalled).toBe(true)
manager.shutdown() manager.shutdown()
}) })
@ -1816,6 +1838,32 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const pendingSet = pendingByParent.get(task.parentSessionID) const pendingSet = pendingByParent.get(task.parentSessionID)
expect(pendingSet?.has(task.id) ?? false).toBe(false) 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", () => { describe("multiple keys process in parallel", () => {
@ -2776,6 +2824,43 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
manager.shutdown() 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", () => { describe("BackgroundManager.handleEvent - session.error", () => {
@ -2823,6 +2908,35 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown() 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", () => { test("ignores session.error for non-running tasks", () => {
//#given //#given
const manager = createBackgroundManager() const manager = createBackgroundManager()
@ -2968,13 +3082,32 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
manager.shutdown() 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", () => { describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function setCompletionTimer(manager: BackgroundManager, taskId: string): void { function setCompletionTimer(manager: BackgroundManager, taskId: string): void {
const completionTimers = getCompletionTimers(manager) const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -3500,3 +3633,93 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
expect(task.status).toBe("running") 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<void> }).notifyParentSession(task)
//#then
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
})
})

View File

@ -6,7 +6,7 @@ import type {
ResumeInput, ResumeInput,
} from "./types" } from "./types"
import { TaskHistory } from "./task-history" 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 { setSessionTools } from "../../shared/session-tools-store"
import { ConcurrencyManager } from "./concurrency" import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
@ -531,6 +531,12 @@ export class BackgroundManager {
return existingTask 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 // Re-acquire concurrency using the persisted concurrency group
const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent
await this.concurrencyManager.acquire(concurrencyKey) await this.concurrencyManager.acquire(concurrencyKey)
@ -648,7 +654,7 @@ export class BackgroundManager {
const response = await this.client.session.todo({ const response = await this.client.session.todo({
path: { id: sessionID }, 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 if (!todos || todos.length === 0) return false
const incomplete = todos.filter( const incomplete = todos.filter(
@ -786,6 +792,10 @@ export class BackgroundManager {
this.cleanupPendingByParent(task) this.cleanupPendingByParent(task)
this.tasks.delete(task.id) this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id) this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) { if (task.sessionID) {
subagentSessions.delete(task.sessionID) subagentSessions.delete(task.sessionID)
} }
@ -833,6 +843,10 @@ export class BackgroundManager {
this.cleanupPendingByParent(task) this.cleanupPendingByParent(task)
this.tasks.delete(task.id) this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id) this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) { if (task.sessionID) {
subagentSessions.delete(task.sessionID) subagentSessions.delete(task.sessionID)
} }
@ -864,7 +878,7 @@ export class BackgroundManager {
path: { id: sessionID }, 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 // Check for at least one assistant or tool message
const hasAssistantOrToolMessage = messages.some( const hasAssistantOrToolMessage = messages.some(
@ -1003,6 +1017,10 @@ export class BackgroundManager {
} }
if (options?.skipNotification) { if (options?.skipNotification) {
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id) log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
return true return true
} }
@ -1232,9 +1250,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try { try {
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) 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 } info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}> }>)
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { 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) { } catch (error) {
if (this.isAbortedSessionError(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, taskId: task.id,
parentSessionID: task.parentSessionID, parentSessionID: task.parentSessionID,
}) })
return
} }
const messageDir = getMessageDir(task.parentSessionID) const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null 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) { } catch (error) {
if (this.isAbortedSessionError(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, taskId: task.id,
parentSessionID: task.parentSessionID, parentSessionID: task.parentSessionID,
}) })
return } else {
log("[background-agent] Failed to send notification:", error)
} }
log("[background-agent] Failed to send notification:", error)
} }
} else { } else {
log("[background-agent] Parent session notifications disabled, skipping prompt injection:", { 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) this.clearNotificationsForTask(taskId)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(taskId)
}
this.tasks.delete(taskId) this.tasks.delete(taskId)
if (task.sessionID) { if (task.sessionID) {
subagentSessions.delete(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() this.pruneStaleTasksAndNotifications()
const statusResult = await this.client.session.status() const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
await this.checkAndInterruptStaleTasks(allStatuses) await this.checkAndInterruptStaleTasks(allStatuses)

View File

@ -1 +1 @@
export { getMessageDir } from "./message-storage-locator" export { getMessageDir } from "../../shared"

View File

@ -1,17 +1 @@
import { existsSync, readdirSync } from "node:fs" import { getMessageDir } from "../../shared"
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
}

View File

@ -1,4 +1,4 @@
import { log } from "../../shared" import { log, normalizeSDKResponse } from "../../shared"
import { findNearestMessageWithFields } from "../hook-message-injector" import { findNearestMessageWithFields } from "../hook-message-injector"
import { getTaskToastManager } from "../task-toast-manager" import { getTaskToastManager } from "../task-toast-manager"
@ -106,7 +106,7 @@ export async function notifyParentSession(args: {
const messagesResp = await client.session.messages({ const messagesResp = await client.session.messages({
path: { id: task.parentSessionID }, path: { id: task.parentSessionID },
}) })
const raw = (messagesResp as { data?: unknown }).data ?? [] const raw = normalizeSDKResponse(messagesResp, [] as unknown[])
const messages = Array.isArray(raw) ? raw : [] const messages = Array.isArray(raw) ? raw : []
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {

View File

@ -1,7 +1,7 @@
import type { OpencodeClient } from "./constants" import type { OpencodeClient } from "./constants"
import type { BackgroundTask } from "./types" import type { BackgroundTask } from "./types"
import { findNearestMessageWithFields } from "../hook-message-injector" import { findNearestMessageWithFields } from "../hook-message-injector"
import { getMessageDir } from "./message-storage-locator" import { getMessageDir } from "../../shared"
type AgentModel = { providerID: string; modelID: string } type AgentModel = { providerID: string; modelID: string }

View File

@ -1,4 +1,4 @@
import { log } from "../../shared" import { log, normalizeSDKResponse } from "../../shared"
import { import {
MIN_STABILITY_TIME_MS, MIN_STABILITY_TIME_MS,
@ -56,7 +56,7 @@ export async function pollRunningTasks(args: {
pruneStaleTasksAndNotifications() pruneStaleTasksAndNotifications()
const statusResult = await client.session.status() const statusResult = await client.session.status()
const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap const allStatuses = normalizeSDKResponse(statusResult, {} as SessionStatusMap)
await checkAndInterruptStaleTasks(allStatuses) await checkAndInterruptStaleTasks(allStatuses)
@ -95,10 +95,9 @@ export async function pollRunningTasks(args: {
continue continue
} }
const messagesPayload = Array.isArray(messagesResult) const messages = asSessionMessages(normalizeSDKResponse(messagesResult, [] as SessionMessage[], {
? messagesResult preferResponseOnMissingData: true,
: (messagesResult as { data?: unknown }).data }))
const messages = asSessionMessages(messagesPayload)
const assistantMsgs = messages.filter((m) => m.info?.role === "assistant") const assistantMsgs = messages.filter((m) => m.info?.role === "assistant")
let toolCalls = 0 let toolCalls = 0
@ -139,7 +138,7 @@ export async function pollRunningTasks(args: {
task.stablePolls = (task.stablePolls ?? 0) + 1 task.stablePolls = (task.stablePolls ?? 0) + 1
if (task.stablePolls >= 3) { if (task.stablePolls >= 3) {
const recheckStatus = await client.session.status() 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] const currentStatus = recheckData[sessionID]
if (currentStatus?.type !== "idle") { if (currentStatus?.type !== "idle") {

View File

@ -1,6 +1,6 @@
export type { ResultHandlerContext } from "./result-handler-context" export type { ResultHandlerContext } from "./result-handler-context"
export { formatDuration } from "./duration-formatter" export { formatDuration } from "./duration-formatter"
export { getMessageDir } from "./message-storage-locator" export { getMessageDir } from "../../shared"
export { checkSessionTodos } from "./session-todo-checker" export { checkSessionTodos } from "./session-todo-checker"
export { validateSessionHasOutput } from "./session-output-validator" export { validateSessionHasOutput } from "./session-output-validator"
export { tryCompleteTask } from "./background-task-completer" export { tryCompleteTask } from "./background-task-completer"

View File

@ -4,7 +4,7 @@ function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) return false if (typeof value !== "object" || value === null) return false
const todo = value as Record<string, unknown> const todo = value as Record<string, unknown>
return ( return (
typeof todo["id"] === "string" && (typeof todo["id"] === "string" || todo["id"] === undefined) &&
typeof todo["content"] === "string" && typeof todo["content"] === "string" &&
typeof todo["status"] === "string" && typeof todo["status"] === "string" &&
typeof todo["priority"] === "string" typeof todo["priority"] === "string"

View File

@ -1,4 +1,4 @@
import { log } from "../../shared" import { log, normalizeSDKResponse } from "../../shared"
import type { OpencodeClient } from "./opencode-client" import type { OpencodeClient } from "./opencode-client"
@ -51,7 +51,9 @@ export async function validateSessionHasOutput(
path: { id: sessionID }, 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( const hasAssistantOrToolMessage = messages.some(
(m) => m.info?.role === "assistant" || m.info?.role === "tool" (m) => m.info?.role === "assistant" || m.info?.role === "tool"
@ -97,8 +99,9 @@ export async function checkSessionTodos(
path: { id: sessionID }, path: { id: sessionID },
}) })
const raw = (response as { data?: unknown }).data ?? response const todos = normalizeSDKResponse(response, [] as Todo[], {
const todos = Array.isArray(raw) ? (raw as Todo[]) : [] preferResponseOnMissingData: true,
})
if (todos.length === 0) return false if (todos.length === 0) return false
const incomplete = todos.filter( const incomplete = todos.filter(

View File

@ -2,7 +2,7 @@
## OVERVIEW ## 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 ## STRUCTURE
``` ```
@ -50,39 +50,16 @@ interface Task {
## TODO SYNC ## TODO SYNC
Automatic bidirectional synchronization between tasks and OpenCode's todo system. Automatic bidirectional sync 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
| Task Status | Todo Status | | Task Status | Todo Status |
|-------------|-------------| |-------------|-------------|
| `pending` | `pending` | | `pending` | `pending` |
| `in_progress` | `in_progress` | | `in_progress` | `in_progress` |
| `completed` | `completed` | | `completed` | `completed` |
| `deleted` | `null` (removed from todos) | | `deleted` | `null` (removed) |
### Field Mapping Sync triggers: `task_create`, `task_update`.
| 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
## ANTI-PATTERNS ## ANTI-PATTERNS

View File

@ -1,6 +1 @@
import { join } from "node:path" export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
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")

View File

@ -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 { StoredMessage } from "./injector"
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
export { MESSAGE_STORAGE } from "./constants" export { MESSAGE_STORAGE } from "./constants"

View File

@ -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<string, boolean>
}
}>): {
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)
})
})

View File

@ -1,8 +1,12 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
import { log } from "../../shared/logger" 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 { export interface StoredMessage {
agent?: string agent?: string
@ -10,14 +14,130 @@ export interface StoredMessage {
tools?: Record<string, ToolPermission> tools?: Record<string, ToolPermission>
} }
type OpencodeClient = PluginInput["client"]
interface SDKMessage {
info?: {
agent?: string
model?: {
providerID?: string
modelID?: string
variant?: string
}
providerID?: string
modelID?: string
tools?: Record<string, ToolPermission>
}
}
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<StoredMessage | null> {
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<string | null> {
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 { export function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
// On beta SQLite backend, skip JSON file reads entirely
if (isSqliteBackend()) {
return null
}
try { try {
const files = readdirSync(messageDir) const files = readdirSync(messageDir)
.filter((f) => f.endsWith(".json")) .filter((f) => f.endsWith(".json"))
.sort() .sort()
.reverse() .reverse()
// First pass: find message with ALL fields (ideal)
for (const file of files) { for (const file of files) {
try { try {
const content = readFileSync(join(messageDir, file), "utf-8") 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) { for (const file of files) {
try { try {
const content = readFileSync(join(messageDir, file), "utf-8") 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. * Finds the FIRST (oldest) message in the session with agent field.
* This is used to get the original agent that started the session, * Reads from JSON files - for stable (JSON) backend.
* avoiding issues where newer messages may have a different agent *
* due to OpenCode's internal agent switching. * **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 { export function findFirstMessageWithAgent(messageDir: string): string | null {
// On beta SQLite backend, skip JSON file reads entirely
if (isSqliteBackend()) {
return null
}
try { try {
const files = readdirSync(messageDir) const files = readdirSync(messageDir)
.filter((f) => f.endsWith(".json")) .filter((f) => f.endsWith(".json"))
.sort() // Oldest first (no reverse) .sort()
for (const file of files) { for (const file of files) {
try { try {
@ -111,12 +238,29 @@ function getOrCreateMessageDir(sessionID: string): string {
return directPath 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( export function injectHookMessage(
sessionID: string, sessionID: string,
hookContent: string, hookContent: string,
originalMessage: OriginalMessageContext originalMessage: OriginalMessageContext
): boolean { ): boolean {
// Validate hook content to prevent empty message injection
if (!hookContent || hookContent.trim().length === 0) { if (!hookContent || hookContent.trim().length === 0) {
log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", { log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", {
sessionID, sessionID,
@ -126,6 +270,16 @@ export function injectHookMessage(
return false 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 messageDir = getOrCreateMessageDir(sessionID)
const needsFallback = const needsFallback =
@ -202,3 +356,21 @@ export function injectHookMessage(
return false 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 }
}

View File

@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema" import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig } from "./types" import type { TrackedSession, CapacityConfig } from "./types"
import { log, normalizeSDKResponse } from "../../shared"
import { import {
isInsideTmux as defaultIsInsideTmux, isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId, getCurrentPaneId as defaultGetCurrentPaneId,
@ -9,7 +10,6 @@ import {
SESSION_READY_POLL_INTERVAL_MS, SESSION_READY_POLL_INTERVAL_MS,
SESSION_READY_TIMEOUT_MS, SESSION_READY_TIMEOUT_MS,
} from "../../shared/tmux" } from "../../shared/tmux"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier" import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
import { executeActions, executeAction } from "./action-executor" import { executeActions, executeAction } from "./action-executor"
@ -103,7 +103,7 @@ export class TmuxSessionManager {
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) { while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
try { try {
const statusResult = await this.client.session.status({ path: undefined }) const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
if (allStatuses[sessionId]) { if (allStatuses[sessionId]) {
log("[tmux-session-manager] session ready", { log("[tmux-session-manager] session ready", {

View File

@ -3,6 +3,7 @@ import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux"
import type { TrackedSession } from "./types" import type { TrackedSession } from "./types"
import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux" import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux"
import { log } from "../../shared" import { log } from "../../shared"
import { normalizeSDKResponse } from "../../shared"
const SESSION_TIMEOUT_MS = 10 * 60 * 1000 const SESSION_TIMEOUT_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000
@ -43,7 +44,7 @@ export class TmuxPollingManager {
try { try {
const statusResult = await this.client.session.status({ path: undefined }) const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
log("[tmux-session-manager] pollSessions", { log("[tmux-session-manager] pollSessions", {
trackedSessions: Array.from(this.sessions.keys()), trackedSessions: Array.from(this.sessions.keys()),
@ -82,7 +83,7 @@ export class TmuxPollingManager {
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
const recheckResult = await this.client.session.status({ path: undefined }) const recheckResult = await this.client.session.status({ path: undefined })
const recheckStatuses = (recheckResult.data ?? {}) as Record<string, { type: string }> const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record<string, { type: string }>)
const recheckStatus = recheckStatuses[sessionId] const recheckStatus = recheckStatuses[sessionId]
if (recheckStatus?.type === "idle") { if (recheckStatus?.type === "idle") {

View File

@ -8,18 +8,18 @@
``` ```
hooks/ hooks/
├── agent-usage-reminder/ # Specialized agent hints (109 lines) ├── 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) ├── 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-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) ├── background-notification/ # OS notifications (33 lines)
├── category-skill-reminder/ # Category+skill delegation reminders (597 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) ├── comment-checker/ # Prevents AI slop comments (710 lines)
├── compaction-context-injector/ # Injects context on compaction (128 lines) ├── compaction-context-injector/ # Injects context on compaction (128 lines)
├── compaction-todo-preserver/ # Preserves todos during compaction (203 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) ├── delegate-task-retry/ # Retries failed delegations (266 lines)
├── directory-agents-injector/ # Auto-injects AGENTS.md (195 lines) ├── directory-agents-injector/ # Auto-injects AGENTS.md (195 lines)
├── directory-readme-injector/ # Auto-injects README.md (190 lines) ├── directory-readme-injector/ # Auto-injects README.md (190 lines)
@ -34,7 +34,7 @@ hooks/
├── ralph-loop/ # Self-referential dev loop (1687 lines) ├── ralph-loop/ # Self-referential dev loop (1687 lines)
├── rules-injector/ # Conditional .sisyphus/rules injection (1604 lines) ├── rules-injector/ # Conditional .sisyphus/rules injection (1604 lines)
├── session-notification.ts # OS idle notifications (108 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) ├── sisyphus-junior-notepad/ # Junior notepad directive (76 lines)
├── start-work/ # Sisyphus work session starter (648 lines) ├── start-work/ # Sisyphus work session starter (648 lines)
├── stop-continuation-guard/ # Guards stop continuation (214 lines) ├── stop-continuation-guard/ # Guards stop continuation (214 lines)
@ -57,10 +57,10 @@ hooks/
| UserPromptSubmit | `chat.message` | Yes | 4 | | UserPromptSubmit | `chat.message` | Yes | 4 |
| ChatParams | `chat.params` | No | 2 | | ChatParams | `chat.params` | No | 2 |
| PreToolUse | `tool.execute.before` | Yes | 13 | | PreToolUse | `tool.execute.before` | Yes | 13 |
| PostToolUse | `tool.execute.after` | No | 18 | | PostToolUse | `tool.execute.after` | No | 15 |
| SessionEvent | `event` | No | 17 | | SessionEvent | `event` | No | 17 |
| MessagesTransform | `experimental.chat.messages.transform` | No | 1 | | MessagesTransform | `experimental.chat.messages.transform` | No | 1 |
| Compaction | `onSummarize` | No | 1 | | Compaction | `onSummarize` | No | 2 |
## BLOCKING HOOKS (8) ## BLOCKING HOOKS (8)
@ -78,7 +78,7 @@ hooks/
## EXECUTION ORDER ## EXECUTION ORDER
**UserPromptSubmit**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork **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 **PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo → taskReminder
## HOW TO ADD ## HOW TO ADD

View File

@ -1,7 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { getOpenCodeStorageDir } from "../../shared/data-path"; import { OPENCODE_STORAGE } from "../../shared";
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const AGENT_USAGE_REMINDER_STORAGE = join( export const AGENT_USAGE_REMINDER_STORAGE = join(
OPENCODE_STORAGE, OPENCODE_STORAGE,
"agent-usage-reminder", "agent-usage-reminder",

View File

@ -25,12 +25,13 @@ export async function runAggressiveTruncationStrategy(params: {
targetRatio: TRUNCATE_CONFIG.targetTokenRatio, targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
}) })
const aggressiveResult = truncateUntilTargetTokens( const aggressiveResult = await truncateUntilTargetTokens(
params.sessionID, params.sessionID,
params.currentTokens, params.currentTokens,
params.maxTokens, params.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio, TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken, TRUNCATE_CONFIG.charsPerToken,
params.client,
) )
if (aggressiveResult.truncatedCount <= 0) { if (aggressiveResult.truncatedCount <= 0) {
@ -60,7 +61,7 @@ export async function runAggressiveTruncationStrategy(params: {
clearSessionState(params.autoCompactState, params.sessionID) clearSessionState(params.autoCompactState, params.sessionID)
setTimeout(async () => { setTimeout(async () => {
try { try {
await params.client.session.prompt_async({ await params.client.session.promptAsync({
path: { id: params.sessionID }, path: { id: params.sessionID },
body: { auto: true } as never, body: { auto: true } as never,
query: { directory: params.directory }, query: { directory: params.directory },

View File

@ -1,20 +1,8 @@
export type Client = { import type { PluginInput } from "@opencode-ai/plugin"
export type Client = PluginInput["client"] & {
session: { session: {
messages: (opts: { promptAsync: (opts: {
path: { id: string }
query?: { directory?: string }
}) => Promise<unknown>
summarize: (opts: {
path: { id: string }
body: { providerID: string; modelID: string }
query: { directory: string }
}) => Promise<unknown>
revert: (opts: {
path: { id: string }
body: { messageID: string; partID?: string }
query: { directory: string }
}) => Promise<unknown>
prompt_async: (opts: {
path: { id: string } path: { id: string }
body: { parts: Array<{ type: string; text: string }> } body: { parts: Array<{ type: string; text: string }> }
query: { directory: string } query: { directory: string }

View File

@ -1,3 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { ParsedTokenLimitError } from "./types" import type { ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig } from "../../config" import type { ExperimentalConfig } from "../../config"
import type { DeduplicationConfig } from "./pruning-deduplication" import type { DeduplicationConfig } from "./pruning-deduplication"
@ -6,6 +7,8 @@ import { executeDeduplication } from "./pruning-deduplication"
import { truncateToolOutputsByCallId } from "./pruning-tool-output-truncation" import { truncateToolOutputsByCallId } from "./pruning-tool-output-truncation"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
type OpencodeClient = PluginInput["client"]
function createPruningState(): PruningState { function createPruningState(): PruningState {
return { return {
toolIdsToPrune: new Set<string>(), toolIdsToPrune: new Set<string>(),
@ -43,6 +46,7 @@ export async function attemptDeduplicationRecovery(
sessionID: string, sessionID: string,
parsed: ParsedTokenLimitError, parsed: ParsedTokenLimitError,
experimental: ExperimentalConfig | undefined, experimental: ExperimentalConfig | undefined,
client?: OpencodeClient,
): Promise<void> { ): Promise<void> {
if (!isPromptTooLongError(parsed)) return if (!isPromptTooLongError(parsed)) return
@ -50,15 +54,17 @@ export async function attemptDeduplicationRecovery(
if (!plan) return if (!plan) return
const pruningState = createPruningState() const pruningState = createPruningState()
const prunedCount = executeDeduplication( const prunedCount = await executeDeduplication(
sessionID, sessionID,
pruningState, pruningState,
plan.config, plan.config,
plan.protectedTools, plan.protectedTools,
client,
) )
const { truncatedCount } = truncateToolOutputsByCallId( const { truncatedCount } = await truncateToolOutputsByCallId(
sessionID, sessionID,
pruningState.toolIdsToPrune, pruningState.toolIdsToPrune,
client,
) )
if (prunedCount > 0 || truncatedCount > 0) { if (prunedCount > 0 || truncatedCount > 0) {

View File

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

View File

@ -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<string, unknown>
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<string[]> {
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<string | null> {
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 }
}

View File

@ -4,10 +4,12 @@ import {
injectTextPart, injectTextPart,
replaceEmptyTextParts, replaceEmptyTextParts,
} from "../session-recovery/storage" } from "../session-recovery/storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import type { AutoCompactState } from "./types" import type { AutoCompactState } from "./types"
import type { Client } from "./client" import type { Client } from "./client"
import { PLACEHOLDER_TEXT } from "./message-builder" import { PLACEHOLDER_TEXT } from "./message-builder"
import { incrementEmptyContentAttempt } from "./state" import { incrementEmptyContentAttempt } from "./state"
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
export async function fixEmptyMessages(params: { export async function fixEmptyMessages(params: {
sessionID: string sessionID: string
@ -20,6 +22,44 @@ export async function fixEmptyMessages(params: {
let fixed = false let fixed = false
const fixedMessageIds: string[] = [] 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) { if (params.messageIndex !== undefined) {
const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex) const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex)
if (targetMessageId) { if (targetMessageId) {

View File

@ -99,7 +99,7 @@ describe("executeCompact lock management", () => {
messages: mock(() => Promise.resolve({ data: [] })), messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()), summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()), revert: mock(() => Promise.resolve()),
prompt_async: mock(() => Promise.resolve()), promptAsync: mock(() => Promise.resolve()),
}, },
tui: { tui: {
showToast: mock(() => Promise.resolve()), showToast: mock(() => Promise.resolve()),
@ -283,9 +283,9 @@ describe("executeCompact lock management", () => {
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
}) })
test("clears lock when prompt_async in continuation throws", async () => { test("clears lock when promptAsync in continuation throws", async () => {
// given: prompt_async will fail during continuation // given: promptAsync will fail during continuation
mockClient.session.prompt_async = mock(() => mockClient.session.promptAsync = mock(() =>
Promise.reject(new Error("Prompt failed")), Promise.reject(new Error("Prompt failed")),
) )
autoCompactState.errorDataBySession.set(sessionID, { autoCompactState.errorDataBySession.set(sessionID, {
@ -313,7 +313,7 @@ describe("executeCompact lock management", () => {
maxTokens: 200000, maxTokens: 200000,
}) })
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({ const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({
success: true, success: true,
sufficient: false, sufficient: false,
truncatedCount: 3, truncatedCount: 3,
@ -354,7 +354,7 @@ describe("executeCompact lock management", () => {
maxTokens: 200000, maxTokens: 200000,
}) })
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({ const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({
success: true, success: true,
sufficient: true, sufficient: true,
truncatedCount: 5, truncatedCount: 5,
@ -378,8 +378,8 @@ describe("executeCompact lock management", () => {
// then: Summarize should NOT be called (early return from sufficient truncation) // then: Summarize should NOT be called (early return from sufficient truncation)
expect(mockClient.session.summarize).not.toHaveBeenCalled() expect(mockClient.session.summarize).not.toHaveBeenCalled()
// then: prompt_async should be called (Continue after successful truncation) // then: promptAsync should be called (Continue after successful truncation)
expect(mockClient.session.prompt_async).toHaveBeenCalled() expect(mockClient.session.promptAsync).toHaveBeenCalled()
// then: Lock should be cleared // then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)

View File

@ -1,14 +1,121 @@
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { import {
findEmptyMessages, findEmptyMessages,
injectTextPart, injectTextPart,
replaceEmptyTextParts, replaceEmptyTextParts,
} from "../session-recovery/storage" } 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" import type { Client } from "./client"
export const PLACEHOLDER_TEXT = "[user interrupted]" 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<string[]> {
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<number> {
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) const emptyMessageIds = findEmptyMessages(sessionID)
if (emptyMessageIds.length === 0) { if (emptyMessageIds.length === 0) {
return 0 return 0

View File

@ -1,36 +1,40 @@
import { existsSync, readdirSync } from "node:fs" 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 { type OpencodeClient = PluginInput["client"]
if (!existsSync(MESSAGE_STORAGE_DIR)) return ""
const directPath = join(MESSAGE_STORAGE_DIR, sessionID) interface SDKMessage {
if (existsSync(directPath)) { info: { id: string }
return directPath parts: unknown[]
} }
for (const directory of readdirSync(MESSAGE_STORAGE_DIR)) { export async function getMessageIdsFromSDK(
const sessionPath = join(MESSAGE_STORAGE_DIR, directory, sessionID) client: OpencodeClient,
if (existsSync(sessionPath)) { sessionID: string
return sessionPath ): Promise<string[]> {
} try {
} const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
return "" return messages.map(msg => msg.info.id)
} catch {
return []
}
} }
export function getMessageIds(sessionID: string): string[] { export function getMessageIds(sessionID: string): string[] {
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return [] if (!messageDir || !existsSync(messageDir)) return []
const messageIds: string[] = [] const messageIds: string[] = []
for (const file of readdirSync(messageDir)) { for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue if (!file.endsWith(".json")) continue
const messageId = file.replace(".json", "") const messageId = file.replace(".json", "")
messageIds.push(messageId) messageIds.push(messageId)
} }
return messageIds return messageIds
} }

View File

@ -1,9 +1,14 @@
import { existsSync, readdirSync, readFileSync } from "node:fs" import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { PruningState, ToolCallSignature } from "./pruning-types" import type { PruningState, ToolCallSignature } from "./pruning-types"
import { estimateTokens } from "./pruning-types" import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger" 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 { export interface DeduplicationConfig {
enabled: boolean enabled: boolean
@ -43,20 +48,6 @@ function sortObject(obj: unknown): unknown {
return sorted 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[] { function readMessages(sessionID: string): MessagePart[] {
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
if (!messageDir) return [] if (!messageDir) return []
@ -64,7 +55,7 @@ function readMessages(sessionID: string): MessagePart[] {
const messages: MessagePart[] = [] const messages: MessagePart[] = []
try { 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) { for (const file of files) {
const content = readFileSync(join(messageDir, file), "utf-8") const content = readFileSync(join(messageDir, file), "utf-8")
const data = JSON.parse(content) const data = JSON.parse(content)
@ -79,15 +70,29 @@ function readMessages(sessionID: string): MessagePart[] {
return messages return messages
} }
export function executeDeduplication( async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise<MessagePart[]> {
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, sessionID: string,
state: PruningState, state: PruningState,
config: DeduplicationConfig, config: DeduplicationConfig,
protectedTools: Set<string> protectedTools: Set<string>,
): number { client?: OpencodeClient,
): Promise<number> {
if (!config.enabled) return 0 if (!config.enabled) return 0
const messages = readMessages(sessionID) const messages = (client && isSqliteBackend())
? await readMessagesFromSDK(client, sessionID)
: readMessages(sessionID)
const signatures = new Map<string, ToolCallSignature[]>() const signatures = new Map<string, ToolCallSignature[]>()
let currentTurn = 0 let currentTurn = 0

View File

@ -1,8 +1,15 @@
import { existsSync, readdirSync, readFileSync } from "node:fs" import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { getOpenCodeStorageDir } from "../../shared/data-path" import { getOpenCodeStorageDir } from "../../shared/data-path"
import { truncateToolResult } from "./storage" import { truncateToolResult } from "./storage"
import { truncateToolResultAsync } from "./tool-result-storage-sdk"
import { log } from "../../shared/logger" 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 { interface StoredToolPart {
type?: string type?: string
@ -13,29 +20,23 @@ interface StoredToolPart {
} }
} }
function getMessageStorage(): string { interface SDKToolPart {
return join(getOpenCodeStorageDir(), "message") id: string
type: string
callID?: string
tool?: string
state?: { output?: string; time?: { compacted?: number } }
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKToolPart[]
} }
function getPartStorage(): string { function getPartStorage(): string {
return join(getOpenCodeStorageDir(), "part") 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[] { function getMessageIds(sessionID: string): string[] {
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
if (!messageDir) return [] if (!messageDir) return []
@ -49,12 +50,17 @@ function getMessageIds(sessionID: string): string[] {
return messageIds return messageIds
} }
export function truncateToolOutputsByCallId( export async function truncateToolOutputsByCallId(
sessionID: string, sessionID: string,
callIds: Set<string>, callIds: Set<string>,
): { truncatedCount: number } { client?: OpencodeClient,
): Promise<{ truncatedCount: number }> {
if (callIds.size === 0) return { truncatedCount: 0 } if (callIds.size === 0) return { truncatedCount: 0 }
if (client && isSqliteBackend()) {
return truncateToolOutputsByCallIdFromSDK(client, sessionID, callIds)
}
const messageIds = getMessageIds(sessionID) const messageIds = getMessageIds(sessionID)
if (messageIds.length === 0) return { truncatedCount: 0 } if (messageIds.length === 0) return { truncatedCount: 0 }
@ -95,3 +101,42 @@ export function truncateToolOutputsByCallId(
return { truncatedCount } return { truncatedCount }
} }
async function truncateToolOutputsByCallIdFromSDK(
client: OpencodeClient,
sessionID: string,
callIds: Set<string>,
): 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 }
}
}

View File

@ -53,7 +53,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
messages: mock(() => Promise.resolve({ data: [] })), messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => summarizePromise), summarize: mock(() => summarizePromise),
revert: mock(() => Promise.resolve()), revert: mock(() => Promise.resolve()),
prompt_async: mock(() => Promise.resolve()), promptAsync: mock(() => Promise.resolve()),
}, },
tui: { tui: {
showToast: mock(() => Promise.resolve()), showToast: mock(() => Promise.resolve()),
@ -97,7 +97,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
messages: mock(() => Promise.resolve({ data: [] })), messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()), summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()), revert: mock(() => Promise.resolve()),
prompt_async: mock(() => Promise.resolve()), promptAsync: mock(() => Promise.resolve()),
}, },
tui: { tui: {
showToast: mock(() => Promise.resolve()), showToast: mock(() => Promise.resolve()),

View File

@ -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<ReturnType<typeof setTimeout>>
} {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const clearTimeoutCalls: Array<ReturnType<typeof setTimeout>> = []
let timeoutCounter = 0
globalThis.setTimeout = ((_: () => void, _delay?: number) => {
timeoutCounter += 1
return timeoutCounter as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((timeoutID: ReturnType<typeof setTimeout>) => {
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<typeof setTimeout>])
expect(executeCompactMock).toHaveBeenCalledTimes(1)
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-race")
} finally {
restore()
}
})
})

View File

@ -28,6 +28,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
) { ) {
const autoCompactState = createRecoveryState() const autoCompactState = createRecoveryState()
const experimental = options?.experimental const experimental = options?.experimental
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
@ -35,6 +36,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) { if (sessionInfo?.id) {
const timeoutID = pendingCompactionTimeoutBySession.get(sessionInfo.id)
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
pendingCompactionTimeoutBySession.delete(sessionInfo.id)
}
autoCompactState.pendingCompact.delete(sessionInfo.id) autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id) autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id) autoCompactState.retryStateBySession.delete(sessionInfo.id)
@ -57,7 +64,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
autoCompactState.errorDataBySession.set(sessionID, parsed) autoCompactState.errorDataBySession.set(sessionID, parsed)
if (autoCompactState.compactionInProgress.has(sessionID)) { if (autoCompactState.compactionInProgress.has(sessionID)) {
await attemptDeduplicationRecovery(sessionID, parsed, experimental) await attemptDeduplicationRecovery(sessionID, parsed, experimental, ctx.client)
return return
} }
@ -76,7 +83,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
}) })
.catch(() => {}) .catch(() => {})
setTimeout(() => { const timeoutID = setTimeout(() => {
pendingCompactionTimeoutBySession.delete(sessionID)
executeCompact( executeCompact(
sessionID, sessionID,
{ providerID, modelID }, { providerID, modelID },
@ -86,6 +94,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
experimental, experimental,
) )
}, 300) }, 300)
pendingCompactionTimeoutBySession.set(sessionID, timeoutID)
} }
return return
} }
@ -114,6 +124,12 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (!autoCompactState.pendingCompact.has(sessionID)) return 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 errorData = autoCompactState.errorDataBySession.get(sessionID)
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory) const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)

View File

@ -1,10 +1,6 @@
import { join } from "node:path" import { MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
import { getOpenCodeStorageDir } from "../../shared/data-path"
const OPENCODE_STORAGE_DIR = getOpenCodeStorageDir() export { MESSAGE_STORAGE as MESSAGE_STORAGE_DIR, PART_STORAGE as PART_STORAGE_DIR }
export const MESSAGE_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "message")
export const PART_STORAGE_DIR = join(OPENCODE_STORAGE_DIR, "part")
export const TRUNCATION_MESSAGE = 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.]" "[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.]"

View File

@ -21,7 +21,7 @@ describe("truncateUntilTargetTokens", () => {
truncateToolResult.mockReset() truncateToolResult.mockReset()
}) })
test("truncates only until target is reached", () => { test("truncates only until target is reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage") const { findToolResultsBySize, truncateToolResult } = require("./storage")
// given: Two tool results, each 1000 chars. Target reduction is 500 chars. // 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) // when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// charsPerToken=1 for simplicity in test // 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 // then: Should only truncate the first tool
expect(result.truncatedCount).toBe(1) expect(result.truncatedCount).toBe(1)
@ -49,7 +49,7 @@ describe("truncateUntilTargetTokens", () => {
expect(result.sufficient).toBe(true) 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") const { findToolResultsBySize, truncateToolResult } = require("./storage")
// given: Two tool results, each 100 chars. Target reduction is 500 chars. // given: Two tool results, each 100 chars. Target reduction is 500 chars.
@ -66,7 +66,7 @@ describe("truncateUntilTargetTokens", () => {
})) }))
// when: reduce 500 chars // 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 // then: Should truncate both
expect(result.truncatedCount).toBe(2) expect(result.truncatedCount).toBe(2)

View File

@ -8,4 +8,11 @@ export {
truncateToolResult, truncateToolResult,
} from "./tool-result-storage" } from "./tool-result-storage"
export {
countTruncatedResultsFromSDK,
findToolResultsBySizeFromSDK,
getTotalToolOutputSizeFromSDK,
truncateToolResultAsync,
} from "./tool-result-storage-sdk"
export { truncateUntilTargetTokens } from "./target-token-truncation" export { truncateUntilTargetTokens } from "./target-token-truncation"

View File

@ -61,7 +61,7 @@ export async function runSummarizeRetryStrategy(params: {
if (providerID && modelID) { if (providerID && modelID) {
try { try {
sanitizeEmptyMessagesBeforeSummarize(params.sessionID) await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client)
await params.client.tui await params.client.tui
.showToast({ .showToast({

View File

@ -1,5 +1,27 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { AggressiveTruncateResult } from "./tool-part-types" import type { AggressiveTruncateResult } from "./tool-part-types"
import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage" 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( function calculateTargetBytesToRemove(
currentTokens: number, currentTokens: number,
@ -13,13 +35,14 @@ function calculateTargetBytesToRemove(
return { tokensToReduce, targetBytesToRemove } return { tokensToReduce, targetBytesToRemove }
} }
export function truncateUntilTargetTokens( export async function truncateUntilTargetTokens(
sessionID: string, sessionID: string,
currentTokens: number, currentTokens: number,
maxTokens: number, maxTokens: number,
targetRatio: number = 0.8, targetRatio: number = 0.8,
charsPerToken: number = 4 charsPerToken: number = 4,
): AggressiveTruncateResult { client?: OpencodeClient
): Promise<AggressiveTruncateResult> {
const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove( const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove(
currentTokens, currentTokens,
maxTokens, maxTokens,
@ -38,6 +61,94 @@ export function truncateUntilTargetTokens(
} }
} }
if (client && isSqliteBackend()) {
let toolPartsByKey = new Map<string, SDKToolPart>()
try {
const response = (await client.session.messages({
path: { id: sessionID },
})) as { data?: SDKMessage[] }
const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
toolPartsByKey = new Map<string, SDKToolPart>()
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<string, SDKToolPart>()
}
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) const results = findToolResultsBySize(sessionID)
if (results.length === 0) { if (results.length === 0) {

View File

@ -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<string, unknown>
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<ToolResultInfo[]> {
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<string, unknown> = {
...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<number> {
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<number> {
const results = await findToolResultsBySizeFromSDK(client, sessionID)
return results.reduce((sum, result) => sum + result.outputSize, 0)
}

View File

@ -4,6 +4,10 @@ import { join } from "node:path"
import { getMessageIds } from "./message-storage-directory" import { getMessageIds } from "./message-storage-directory"
import { PART_STORAGE_DIR, TRUNCATION_MESSAGE } from "./storage-paths" import { PART_STORAGE_DIR, TRUNCATION_MESSAGE } from "./storage-paths"
import type { StoredToolPart, ToolResultInfo } from "./tool-part-types" 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[] { export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
const messageIds = getMessageIds(sessionID) const messageIds = getMessageIds(sessionID)
@ -48,6 +52,14 @@ export function truncateToolResult(partPath: string): {
toolName?: string toolName?: string
originalSize?: number originalSize?: number
} { } {
if (isSqliteBackend()) {
if (!hasLoggedTruncateWarning) {
log("[context-window-recovery] Disabled on SQLite backend: truncateToolResult")
hasLoggedTruncateWarning = true
}
return { success: false }
}
try { try {
const content = readFileSync(partPath, "utf-8") const content = readFileSync(partPath, "utf-8")
const part = JSON.parse(content) as StoredToolPart const part = JSON.parse(content) as StoredToolPart

View File

@ -19,7 +19,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return { return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }), handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ pendingFilePaths }), "tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }), "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }),
} }
} }

View File

@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state" import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { subagentSessions } from "../../features/claude-code-session-state" import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { HOOK_NAME } from "./hook-name" import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error" import { isAbortError } from "./is-abort-error"
import { injectBoulderContinuation } from "./boulder-continuation-injector" import { injectBoulderContinuation } from "./boulder-continuation-injector"
@ -87,12 +88,13 @@ export function createAtlasEventHandler(input: {
return return
} }
const lastAgent = getLastAgentFromSession(sessionID) const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() const lastAgentKey = getAgentConfigKey(lastAgent ?? "")
const lastAgentMatchesRequired = lastAgent === requiredAgent const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas" const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
const lastAgentIsSisyphus = lastAgent === "sisyphus" const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
const allowSisyphusWhenDefaultAtlas = boulderAgentWasNotExplicitlySet && boulderAgentDefaultsToAtlas && lastAgentIsSisyphus const allowSisyphusWhenDefaultAtlas = boulderAgentWasNotExplicitlySet && boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
const agentMatches = lastAgentMatchesRequired || allowSisyphusWhenDefaultAtlas const agentMatches = lastAgentMatchesRequired || allowSisyphusWhenDefaultAtlas
if (!agentMatches) { if (!agentMatches) {

View File

@ -9,10 +9,31 @@ import {
readBoulderState, readBoulderState,
} from "../../features/boulder-state" } from "../../features/boulder-state"
import type { BoulderState } 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 { _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", () => { describe("atlas hook", () => {
let TEST_DIR: string let TEST_DIR: string

View File

@ -1,6 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { findNearestMessageWithFields } from "../../features/hook-message-injector" import {
import { getMessageDir } from "../../shared/session-utils" findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
} from "../../features/hook-message-injector"
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
import type { ModelInfo } from "./types" import type { ModelInfo } from "./types"
export async function resolveRecentModelForSession( export async function resolveRecentModelForSession(
@ -9,9 +12,9 @@ export async function resolveRecentModelForSession(
): Promise<ModelInfo | undefined> { ): Promise<ModelInfo | undefined> {
try { try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) 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 } info?: { model?: ModelInfo; modelID?: string; providerID?: string }
}> }>)
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info
@ -28,8 +31,13 @@ export async function resolveRecentModelForSession(
// ignore - fallback to message storage // ignore - fallback to message storage
} }
const messageDir = getMessageDir(sessionID) let currentMessage = null
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null if (isSqliteBackend()) {
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
}
const model = currentMessage?.model const model = currentMessage?.model
if (!model?.providerID || !model?.modelID) { if (!model?.providerID || !model?.modelID) {
return undefined return undefined

View File

@ -1,9 +1,24 @@
import { findNearestMessageWithFields } from "../../features/hook-message-injector" import type { PluginInput } from "@opencode-ai/plugin"
import { getMessageDir } from "../../shared/session-utils"
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<string | null> {
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 return nearest?.agent?.toLowerCase() ?? null
} }

View File

@ -23,7 +23,7 @@ export function createToolExecuteAfterHandler(input: {
return return
} }
if (!isCallerOrchestrator(toolInput.sessionID)) { if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
return return
} }

View File

@ -1,21 +1,23 @@
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { isCallerOrchestrator } from "../../shared/session-utils" import { isCallerOrchestrator } from "../../shared/session-utils"
import type { PluginInput } from "@opencode-ai/plugin"
import { HOOK_NAME } from "./hook-name" import { HOOK_NAME } from "./hook-name"
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path" import { isSisyphusPath } from "./sisyphus-path"
import { isWriteOrEditToolName } from "./write-edit-tool-policy" import { isWriteOrEditToolName } from "./write-edit-tool-policy"
export function createToolExecuteBeforeHandler(input: { export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string> pendingFilePaths: Map<string, string>
}): ( }): (
toolInput: { tool: string; sessionID?: string; callID?: string }, toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string } toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> { ) => Promise<void> {
const { pendingFilePaths } = input const { ctx, pendingFilePaths } = input
return async (toolInput, toolOutput): Promise<void> => { return async (toolInput, toolOutput): Promise<void> => {
if (!isCallerOrchestrator(toolInput.sessionID)) { if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
return return
} }

View File

@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared" import { log } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { buildReminderMessage } from "./formatter" import { buildReminderMessage } from "./formatter"
/** /**
@ -75,11 +76,11 @@ export function createCategorySkillReminderHook(
function isTargetAgent(sessionID: string, inputAgent?: string): boolean { function isTargetAgent(sessionID: string, inputAgent?: string): boolean {
const agent = getSessionAgent(sessionID) ?? inputAgent const agent = getSessionAgent(sessionID) ?? inputAgent
if (!agent) return false if (!agent) return false
const agentLower = agent.toLowerCase() const agentKey = getAgentConfigKey(agent)
return ( return (
TARGET_AGENTS.has(agentLower) || TARGET_AGENTS.has(agentKey) ||
agentLower.includes("sisyphus") || agentKey.includes("sisyphus") ||
agentLower.includes("atlas") agentKey.includes("atlas")
) )
} }

View File

@ -2,7 +2,7 @@
## OVERVIEW ## 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) **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/ claude-code-hooks/
├── index.ts # Barrel export ├── index.ts # Barrel export
├── claude-code-hooks-hook.ts # Main factory ├── claude-code-hooks-hook.ts # Main factory (22 lines)
├── config.ts # Claude settings.json loader ├── config.ts # Claude settings.json loader (105 lines)
├── config-loader.ts # Extended plugin config ├── config-loader.ts # Extended plugin config (107 lines)
├── pre-tool-use.ts # PreToolUse hook executor ├── pre-tool-use.ts # PreToolUse hook executor (173 lines)
├── post-tool-use.ts # PostToolUse hook executor ├── post-tool-use.ts # PostToolUse hook executor (200 lines)
├── user-prompt-submit.ts # UserPromptSubmit executor ├── user-prompt-submit.ts # UserPromptSubmit executor (125 lines)
├── stop.ts # Stop hook executor ├── stop.ts # Stop hook executor (122 lines)
├── pre-compact.ts # PreCompact executor ├── pre-compact.ts # PreCompact executor (110 lines)
├── transcript.ts # Tool use recording ├── transcript.ts # Tool use recording (235 lines)
├── tool-input-cache.ts # Pre→post input caching ├── tool-input-cache.ts # Pre→post input caching (51 lines)
├── todo.ts # Todo integration ├── todo.ts # Todo integration
├── session-hook-state.ts # Active state tracking ├── session-hook-state.ts # Active state tracking (11 lines)
├── types.ts # Hook & IO type definitions ├── types.ts # Hook & IO type definitions (204 lines)
├── plugin-config.ts # Default config constants ├── plugin-config.ts # Default config constants (12 lines)
└── handlers/ # Event handlers (5 files) └── 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 ## HOOK LIFECYCLE

View File

@ -1,7 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { getOpenCodeStorageDir } from "../../shared/data-path"; import { OPENCODE_STORAGE } from "../../shared";
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const AGENTS_INJECTOR_STORAGE = join( export const AGENTS_INJECTOR_STORAGE = join(
OPENCODE_STORAGE, OPENCODE_STORAGE,
"directory-agents", "directory-agents",

View File

@ -1,7 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { getOpenCodeStorageDir } from "../../shared/data-path"; import { OPENCODE_STORAGE } from "../../shared";
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const README_INJECTOR_STORAGE = join( export const README_INJECTOR_STORAGE = join(
OPENCODE_STORAGE, OPENCODE_STORAGE,
"directory-readme", "directory-readme",

View File

@ -1,7 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { getOpenCodeStorageDir } from "../../shared/data-path"; import { OPENCODE_STORAGE } from "../../shared";
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const INTERACTIVE_BASH_SESSION_STORAGE = join( export const INTERACTIVE_BASH_SESSION_STORAGE = join(
OPENCODE_STORAGE, OPENCODE_STORAGE,
"interactive-bash-session", "interactive-bash-session",

View File

@ -1,5 +1,12 @@
import { describe, it, expect, mock, beforeEach } from "bun:test" 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() { function createMockCtx() {
return { return {
@ -21,6 +28,7 @@ describe("preemptive-compaction", () => {
beforeEach(() => { beforeEach(() => {
ctx = createMockCtx() ctx = createMockCtx()
logMock.mockClear()
}) })
// #given event caches token info from message.updated // #given event caches token info from message.updated
@ -152,4 +160,45 @@ describe("preemptive-compaction", () => {
expect(ctx.client.session.summarize).not.toHaveBeenCalled() 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),
})
})
}) })

View File

@ -1,3 +1,5 @@
import { log } from "../shared/logger"
const DEFAULT_ACTUAL_LIMIT = 200_000 const DEFAULT_ACTUAL_LIMIT = 200_000
const ANTHROPIC_ACTUAL_LIMIT = const ANTHROPIC_ACTUAL_LIMIT =
@ -76,8 +78,8 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
}) })
compactedSessions.add(sessionID) compactedSessions.add(sessionID)
} catch { } catch (error) {
// best-effort; do not disrupt tool execution log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
} finally { } finally {
compactionInProgress.delete(sessionID) compactionInProgress.delete(sessionID)
} }

View File

@ -1,24 +1,29 @@
import { existsSync, readdirSync } from "node:fs" import type { PluginInput } from "@opencode-ai/plugin"
import { join } from "node:path"
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" 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 { getSessionAgent } from "../../features/claude-code-session-state"
import { readBoulderState } from "../../features/boulder-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 { type OpencodeClient = PluginInput["client"]
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID) async function getAgentFromMessageFiles(
if (existsSync(directPath)) return directPath sessionID: string,
client?: OpencodeClient
): Promise<string | undefined> {
if (isSqliteBackend() && client) {
const firstAgent = await findFirstMessageWithAgentFromSDK(client, sessionID)
if (firstAgent) return firstAgent
for (const dir of readdirSync(MESSAGE_STORAGE)) { const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID)
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) return nearest?.agent
if (existsSync(sessionPath)) return sessionPath
} }
return null
}
function getAgentFromMessageFiles(sessionID: string): string | undefined {
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
if (!messageDir) return undefined if (!messageDir) return undefined
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
@ -36,7 +41,11 @@ function getAgentFromMessageFiles(sessionID: string): string | undefined {
* - Message files return "prometheus" (oldest message from /plan) * - Message files return "prometheus" (oldest message from /plan)
* - But boulder.json has agent: "atlas" (set by /start-work) * - 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<string | undefined> {
// Check in-memory first (current session) // Check in-memory first (current session)
const memoryAgent = getSessionAgent(sessionID) const memoryAgent = getSessionAgent(sessionID)
if (memoryAgent) return memoryAgent if (memoryAgent) return memoryAgent
@ -48,5 +57,5 @@ export function getAgentFromSession(sessionID: string, directory: string): strin
} }
// Fallback to message files // Fallback to message files
return getAgentFromMessageFiles(sessionID) return await getAgentFromMessageFiles(sessionID, client)
} }

View File

@ -15,7 +15,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
input: { tool: string; sessionID: string; callID: string }, input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown>; message?: string } output: { args: Record<string, unknown>; message?: string }
): Promise<void> => { ): Promise<void> => {
const agentName = getAgentFromSession(input.sessionID, ctx.directory) const agentName = await getAgentFromSession(input.sessionID, ctx.directory, ctx.client)
if (!isPrometheusAgent(agentName)) { if (!isPrometheusAgent(agentName)) {
return return

View File

@ -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 { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { clearSessionAgent } from "../../features/claude-code-session-state" 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" const { createPrometheusMdOnlyHook } = await import("./index")
import { MESSAGE_STORAGE } from "../../features/hook-message-injector" const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
describe("prometheus-md-only", () => { describe("prometheus-md-only", () => {
const TEST_SESSION_ID = "test-session-prometheus" const TEST_SESSION_ID = "ses_test_prometheus"
let testMessageDir: string let testMessageDir: string
function createMockPluginInput() { function createMockPluginInput() {
@ -546,7 +551,7 @@ describe("prometheus-md-only", () => {
writeFileSync(BOULDER_FILE, JSON.stringify({ writeFileSync(BOULDER_FILE, JSON.stringify({
active_plan: "/test/plan.md", active_plan: "/test/plan.md",
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
session_ids: ["other-session-id"], session_ids: ["ses_other_session_id"],
plan_name: "test-plan", plan_name: "test-plan",
agent: "atlas" agent: "atlas"
})) }))
@ -578,7 +583,7 @@ describe("prometheus-md-only", () => {
const hook = createPrometheusMdOnlyHook(createMockPluginInput()) const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = { const input = {
tool: "Write", tool: "Write",
sessionID: "non-existent-session", sessionID: "ses_non_existent_session",
callID: "call-1", callID: "call-1",
} }
const output = { const output = {

View File

@ -3,6 +3,7 @@ import { log } from "../../shared/logger"
import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { getMessageDir } from "./message-storage-directory" import { getMessageDir } from "./message-storage-directory"
import { withTimeout } from "./with-timeout" import { withTimeout } from "./with-timeout"
import { normalizeSDKResponse } from "../../shared"
type MessageInfo = { type MessageInfo = {
agent?: string agent?: string
@ -25,7 +26,7 @@ export async function injectContinuationPrompt(
}), }),
options.apiTimeoutMs, 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--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i]?.info const info = messages[i]?.info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {

View File

@ -1,16 +1 @@
import { existsSync, readdirSync } from "node:fs" export { getMessageDir } from "../../shared/opencode-message-dir"
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
}

View File

@ -1,7 +1,5 @@
import { join } from "node:path"; import { join } from "node:path";
import { getOpenCodeStorageDir } from "../../shared/data-path"; import { OPENCODE_STORAGE } from "../../shared";
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector"); export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
export const PROJECT_MARKERS = [ export const PROJECT_MARKERS = [

View File

@ -1,9 +1,4 @@
import { join } from "node:path" export { OPENCODE_STORAGE, MESSAGE_STORAGE, PART_STORAGE } from "../../shared"
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 const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"]) export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
export const META_TYPES = new Set(["step-start", "step-finish"]) export const META_TYPES = new Set(["step-start", "step-finish"])

View File

@ -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<Parameters<typeof recoverEmptyContentMessageFromSDK>[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)
})
})

View File

@ -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<typeof createOpencodeClient>
type ReplaceEmptyTextPartsAsync = (
client: Client,
sessionID: string,
messageID: string,
replacementText: string
) => Promise<boolean>
type InjectTextPartAsync = (
client: Client,
sessionID: string,
messageID: string,
text: string
) => Promise<boolean>
type FindMessagesWithEmptyTextPartsFromSDK = (
client: Client,
sessionID: string
) => Promise<string[]>
export async function recoverEmptyContentMessageFromSDK(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
error: unknown,
dependencies: {
placeholderText: string
replaceEmptyTextPartsAsync: ReplaceEmptyTextPartsAsync
injectTextPartAsync: InjectTextPartAsync
findMessagesWithEmptyTextPartsFromSDK: FindMessagesWithEmptyTextPartsFromSDK
}
): Promise<boolean> {
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<MessageData["parts"]>[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<MessageData[]> {
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
}

View File

@ -1,6 +1,7 @@
import type { createOpencodeClient } from "@opencode-ai/sdk" import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types" import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type" import { extractMessageIndex } from "./detect-error-type"
import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
import { import {
findEmptyMessageByIndex, findEmptyMessageByIndex,
findEmptyMessages, findEmptyMessages,
@ -9,18 +10,30 @@ import {
injectTextPart, injectTextPart,
replaceEmptyTextParts, replaceEmptyTextParts,
} from "./storage" } 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<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
const PLACEHOLDER_TEXT = "[user interrupted]" const PLACEHOLDER_TEXT = "[user interrupted]"
export async function recoverEmptyContentMessage( export async function recoverEmptyContentMessage(
_client: Client, client: Client,
sessionID: string, sessionID: string,
failedAssistantMsg: MessageData, failedAssistantMsg: MessageData,
_directory: string, _directory: string,
error: unknown error: unknown
): Promise<boolean> { ): Promise<boolean> {
if (isSqliteBackend()) {
return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, {
placeholderText: PLACEHOLDER_TEXT,
replaceEmptyTextPartsAsync,
injectTextPartAsync,
findMessagesWithEmptyTextPartsFromSDK,
})
}
const targetIndex = extractMessageIndex(error) const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id const failedID = failedAssistantMsg.info?.id
let anySuccess = false let anySuccess = false

View File

@ -2,16 +2,24 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types" import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type" import { extractMessageIndex } from "./detect-error-type"
import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage" 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<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
export async function recoverThinkingBlockOrder( export async function recoverThinkingBlockOrder(
_client: Client, client: Client,
sessionID: string, sessionID: string,
_failedAssistantMsg: MessageData, _failedAssistantMsg: MessageData,
_directory: string, _directory: string,
error: unknown error: unknown
): Promise<boolean> { ): Promise<boolean> {
if (isSqliteBackend()) {
return recoverThinkingBlockOrderFromSDK(client, sessionID, error)
}
const targetIndex = extractMessageIndex(error) const targetIndex = extractMessageIndex(error)
if (targetIndex !== null) { if (targetIndex !== null) {
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex) const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
@ -34,3 +42,96 @@ export async function recoverThinkingBlockOrder(
return anySuccess return anySuccess
} }
async function recoverThinkingBlockOrderFromSDK(
client: Client,
sessionID: string,
error: unknown
): Promise<boolean> {
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<string[]> {
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<string | null> {
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
}

View File

@ -1,14 +1,23 @@
import type { createOpencodeClient } from "@opencode-ai/sdk" import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types" import type { MessageData } from "./types"
import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage" 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<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
export async function recoverThinkingDisabledViolation( export async function recoverThinkingDisabledViolation(
_client: Client, client: Client,
sessionID: string, sessionID: string,
_failedAssistantMsg: MessageData _failedAssistantMsg: MessageData
): Promise<boolean> { ): Promise<boolean> {
if (isSqliteBackend()) {
return recoverThinkingDisabledViolationFromSDK(client, sessionID)
}
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID) const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
if (messagesWithThinking.length === 0) { if (messagesWithThinking.length === 0) {
return false return false
@ -23,3 +32,44 @@ export async function recoverThinkingDisabledViolation(
return anySuccess return anySuccess
} }
async function recoverThinkingDisabledViolationFromSDK(
client: Client,
sessionID: string
): Promise<boolean> {
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
}
}

View File

@ -1,6 +1,8 @@
import type { createOpencodeClient } from "@opencode-ai/sdk" import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types" import type { MessageData } from "./types"
import { readParts } from "./storage" import { readParts } from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
@ -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) 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<MessagePart[]> {
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( export async function recoverToolResultMissing(
client: Client, client: Client,
sessionID: string, sessionID: string,
@ -27,11 +49,15 @@ export async function recoverToolResultMissing(
): Promise<boolean> { ): Promise<boolean> {
let parts = failedAssistantMsg.parts || [] let parts = failedAssistantMsg.parts || []
if (parts.length === 0 && failedAssistantMsg.info?.id) { if (parts.length === 0 && failedAssistantMsg.info?.id) {
const storedParts = readParts(failedAssistantMsg.info.id) if (isSqliteBackend()) {
parts = storedParts.map((part) => ({ parts = await readPartsFromSDKFallback(client, sessionID, failedAssistantMsg.info.id)
type: part.type === "tool" ? "tool_use" : part.type, } else {
id: "callID" in part ? (part as { callID?: string }).callID : part.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,
}))
}
} }
const toolUseIds = extractToolUseIds(parts) const toolUseIds = extractToolUseIds(parts)

View File

@ -1,9 +1,12 @@
export { generatePartId } from "./storage/part-id" export { generatePartId } from "./storage/part-id"
export { getMessageDir } from "./storage/message-dir" export { getMessageDir } from "./storage/message-dir"
export { readMessages } from "./storage/messages-reader" export { readMessages } from "./storage/messages-reader"
export { readMessagesFromSDK } from "./storage/messages-reader"
export { readParts } from "./storage/parts-reader" export { readParts } from "./storage/parts-reader"
export { readPartsFromSDK } from "./storage/parts-reader"
export { hasContent, messageHasContent } from "./storage/part-content" export { hasContent, messageHasContent } from "./storage/part-content"
export { injectTextPart } from "./storage/text-part-injector" export { injectTextPart } from "./storage/text-part-injector"
export { injectTextPartAsync } from "./storage/text-part-injector"
export { export {
findEmptyMessages, findEmptyMessages,
@ -11,6 +14,7 @@ export {
findFirstEmptyMessage, findFirstEmptyMessage,
} from "./storage/empty-messages" } from "./storage/empty-messages"
export { findMessagesWithEmptyTextParts } from "./storage/empty-text" export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
export { findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
export { export {
findMessagesWithThinkingBlocks, findMessagesWithThinkingBlocks,
@ -24,3 +28,7 @@ export {
export { prependThinkingPart } from "./storage/thinking-prepend" export { prependThinkingPart } from "./storage/thinking-prepend"
export { stripThinkingParts } from "./storage/thinking-strip" export { stripThinkingParts } from "./storage/thinking-strip"
export { replaceEmptyTextParts } from "./storage/empty-text" export { replaceEmptyTextParts } from "./storage/empty-text"
export { prependThinkingPartAsync } from "./storage/thinking-prepend"
export { stripThinkingPartsAsync } from "./storage/thinking-strip"
export { replaceEmptyTextPartsAsync } from "./storage/empty-text"

View File

@ -1,11 +1,21 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE } from "../constants" import { PART_STORAGE } from "../constants"
import type { StoredPart, StoredTextPart } from "../types" import type { StoredPart, StoredTextPart, MessageData } from "../types"
import { readMessages } from "./messages-reader" import { readMessages } from "./messages-reader"
import { readParts } from "./parts-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 { 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) const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false if (!existsSync(partDir)) return false
@ -34,6 +44,38 @@ export function replaceEmptyTextParts(messageID: string, replacementText: string
return anyReplaced return anyReplaced
} }
export async function replaceEmptyTextPartsAsync(
client: OpencodeClient,
sessionID: string,
messageID: string,
replacementText: string
): Promise<boolean> {
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[] { export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
const messages = readMessages(sessionID) const messages = readMessages(sessionID)
const result: string[] = [] const result: string[] = []
@ -53,3 +95,24 @@ export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
return result return result
} }
export async function findMessagesWithEmptyTextPartsFromSDK(
client: OpencodeClient,
sessionID: string
): Promise<string[]> {
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 []
}
}

View File

@ -1,21 +1 @@
import { existsSync, readdirSync } from "node:fs" export { getMessageDir } from "../../../shared/opencode-message-dir"
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 ""
}

View File

@ -1,9 +1,39 @@
import { existsSync, readdirSync, readFileSync } from "node:fs" import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { StoredMessageMeta } from "../types" import type { StoredMessageMeta } from "../types"
import { getMessageDir } from "./message-dir" 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[] { export function readMessages(sessionID: string): StoredMessageMeta[] {
if (isSqliteBackend()) return []
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return [] if (!messageDir || !existsSync(messageDir)) return []
@ -25,3 +55,29 @@ export function readMessages(sessionID: string): StoredMessageMeta[] {
return a.id.localeCompare(b.id) return a.id.localeCompare(b.id)
}) })
} }
export async function readMessagesFromSDK(
client: OpencodeClient,
sessionID: string
): Promise<StoredMessageMeta[]> {
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 []
}
}

View File

@ -1,9 +1,26 @@
import { existsSync, readdirSync, readFileSync } from "node:fs" import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE } from "../constants" import { PART_STORAGE } from "../constants"
import type { StoredPart } from "../types" 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[] { export function readParts(messageID: string): StoredPart[] {
if (isSqliteBackend()) return []
const partDir = join(PART_STORAGE, messageID) const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return [] if (!existsSync(partDir)) return []
@ -20,3 +37,30 @@ export function readParts(messageID: string): StoredPart[] {
return parts return parts
} }
export async function readPartsFromSDK(
client: OpencodeClient,
sessionID: string,
messageID: string
): Promise<StoredPart[]> {
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 []
}
}

View File

@ -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<typeof readPartsFromSDK>[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<typeof readPartsFromSDK>[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<typeof readMessagesFromSDK>[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([])
})
})

View File

@ -1,10 +1,19 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs" import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE } from "../constants" import { PART_STORAGE } from "../constants"
import type { StoredTextPart } from "../types" import type { StoredTextPart } from "../types"
import { generatePartId } from "./part-id" 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 { 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) const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) { if (!existsSync(partDir)) {
@ -28,3 +37,27 @@ export function injectTextPart(sessionID: string, messageID: string, text: strin
return false return false
} }
} }
export async function injectTextPartAsync(
client: OpencodeClient,
sessionID: string,
messageID: string,
text: string
): Promise<boolean> {
const partId = generatePartId()
const part: Record<string, unknown> = {
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
}
}

View File

@ -1,8 +1,14 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs" import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE, THINKING_TYPES } from "../constants" import { PART_STORAGE, THINKING_TYPES } from "../constants"
import type { MessageData } from "../types"
import { readMessages } from "./messages-reader" import { readMessages } from "./messages-reader"
import { readParts } from "./parts-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 { function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
const messages = readMessages(sessionID) const messages = readMessages(sessionID)
@ -31,6 +37,11 @@ function findLastThinkingContent(sessionID: string, beforeMessageID: string): st
} }
export function prependThinkingPart(sessionID: string, messageID: string): boolean { 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) const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) { if (!existsSync(partDir)) {
@ -39,7 +50,7 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
const previousThinking = findLastThinkingContent(sessionID, messageID) const previousThinking = findLastThinkingContent(sessionID, messageID)
const partId = "prt_0000000000_thinking" const partId = `prt_0000000000_${messageID}_thinking`
const part = { const part = {
id: partId, id: partId,
sessionID, sessionID,
@ -56,3 +67,58 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
return false return false
} }
} }
async function findLastThinkingContentFromSDK(
client: OpencodeClient,
sessionID: string,
beforeMessageID: string
): Promise<string> {
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<boolean> {
const previousThinking = await findLastThinkingContentFromSDK(client, sessionID, messageID)
const partId = `prt_0000000000_${messageID}_thinking`
const part: Record<string, unknown> = {
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
}
}

Some files were not shown because too many files have changed in this diff Show More