Compare commits

..

4 Commits

Author SHA1 Message Date
Cole Leavitt
f92d2e8f7f fix: add proc.kill fallback when process group kill fails 2026-02-22 15:06:09 +09:00
Cole Leavitt
7f0950230c fix: kill process group on timeout and handle stdin EPIPE
- Use detached process group (non-Windows) + process.kill(-pid) to kill
  the entire process tree, not just the outer shell wrapper
- Add proc.stdin error listener to absorb EPIPE when child exits before
  stdin write completes
2026-02-22 15:06:09 +09:00
Cole Leavitt
e1568a4705 fix: handle signal-killed exit code and guard SIGTERM kill
- code ?? 0 → code ?? 1: signal-terminated processes return null exit code,
  which was incorrectly coerced to 0 (success) instead of 1 (failure)
- wrap proc.kill(SIGTERM) in try/catch to match SIGKILL guard and prevent
  EPERM/ESRCH from crashing on already-dead processes
2026-02-22 15:06:09 +09:00
Cole Leavitt
b666ab24df fix: plug resource leaks and add hook command timeout
- LSP signal handlers: store refs, return unregister handle, call in stopAll()
- session-tools-store: add per-session deleteSessionTools(), wire into session.deleted
- executeHookCommand: add 30s timeout with SIGTERM→SIGKILL escalation
2026-02-22 15:06:09 +09:00
238 changed files with 3960 additions and 7569 deletions

View File

@@ -1,61 +0,0 @@
[sisyphus-bot]
## Confirmed Bug
We have identified the root cause of this issue. The bug is in the config writing logic during installation.
### Root Cause
**File:** `src/cli/config-manager/write-omo-config.ts` (line 46)
```typescript
const merged = deepMergeRecord(existing, newConfig)
```
When a user runs `oh-my-opencode install` (even just to update settings), the installer:
1. Reads the existing config (with user's custom model settings)
2. Generates a **new** config based on detected provider availability
3. Calls `deepMergeRecord(existing, newConfig)`
4. Writes the result back
**The problem:** `deepMergeRecord` overwrites values in `existing` with values from `newConfig`. This means your custom `"model": "openai/gpt-5.2-codex"` gets overwritten by the generated default model (e.g., `anthropic/claude-opus-4-6` if Claude is available).
### Why This Happens
Looking at `deepMergeRecord` (line 24-25):
```typescript
} else if (sourceValue !== undefined) {
result[key] = sourceValue as TTarget[keyof TTarget]
}
```
Any defined value in the source (generated config) overwrites the target (user's config).
### Fix Approach
The merge direction should be reversed to respect user overrides:
```typescript
const merged = deepMergeRecord(newConfig, existing)
```
This ensures:
- User's explicit settings take precedence
- Only new/undefined keys get populated from generated defaults
- Custom model choices are preserved
### SEVERITY: HIGH
- **Impact:** User configuration is overwritten without consent
- **Affected Files:**
- `src/cli/config-manager/write-omo-config.ts`
- `src/cli/config-manager/deep-merge-record.ts`
- **Trigger:** Running `oh-my-opencode install` (even for unrelated updates)
### Workaround (Until Fix)
Backup your config before running install:
```bash
cp ~/.config/opencode/oh-my-opencode.jsonc ~/.config/opencode/oh-my-opencode.jsonc.backup
```
We're working on a fix that will preserve your explicit model configurations.

View File

@@ -1,10 +1,10 @@
# oh-my-opencode — OpenCode Plugin # oh-my-opencode — OpenCode Plugin
**Generated:** 2026-02-24 | **Commit:** fcb90d92 | **Branch:** dev **Generated:** 2026-02-21 | **Commit:** 86e3c7d1 | **Branch:** dev
## OVERVIEW ## OVERVIEW
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC. OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 44 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC.
## STRUCTURE ## STRUCTURE
@@ -14,14 +14,14 @@ oh-my-opencode/
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface │ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4) │ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior) │ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
| `hooks/`                # 46 hooks across 39 directories + 6 standalone files │ ├── hooks/ # 44 hooks across 39 directories + 6 standalone files
│ ├── tools/ # 26 tools across 15 directories │ ├── tools/ # 26 tools across 15 directories
│ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.) │ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
│ ├── shared/ # 100+ utility files in 13 categories │ ├── shared/ # 100+ utility files in 13 categories
│ ├── config/ # Zod v4 schema system (22+ files) │ ├── config/ # Zod v4 schema system (22+ files)
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js) │ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app) │ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
│ ├── plugin/ # 8 OpenCode hook handlers + 46 hook composition │ ├── plugin/ # 8 OpenCode hook handlers + 44 hook composition
│ └── plugin-handlers/ # 6-phase config loading pipeline │ └── plugin-handlers/ # 6-phase config loading pipeline
├── packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries ├── packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries
└── local-ignore/ # Dev-only test fixtures └── local-ignore/ # Dev-only test fixtures
@@ -34,7 +34,7 @@ OhMyOpenCodePlugin(ctx)
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate ├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler ├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools) ├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
├─→ createHooks() # 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks ├─→ createHooks() # 3-tier: Core(35) + Continuation(7) + Skill(2) = 44 hooks
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface └─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
``` ```
@@ -87,7 +87,7 @@ Fields: agents (14 overridable, 21 fields each), categories (8 built-in + custom
- **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes) - **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes)
- **Factory pattern**: `createXXX()` for all tools, hooks, agents - **Factory pattern**: `createXXX()` for all tools, hooks, agents
- **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2) - **Hook tiers**: Session (22) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2)
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all` - **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
- **Model resolution**: 3-step: override → category-default → provider-fallback → system-default - **Model resolution**: 3-step: override → category-default → provider-fallback → system-default
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys - **Config format**: JSONC with comments, Zod v4 validation, snake_case keys

View File

@@ -217,9 +217,9 @@ MCPサーバーがあなたのコンテキスト予算を食いつぶしてい
[oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます: [oh-my-pi](https://github.com/can1357/oh-my-pi) に触発され、**Hashline**を実装しました。エージェントが読むすべての行にコンテンツハッシュがタグ付けされて返されます:
``` ```
11#VK| function hello() { 11#VK: function hello() {
22#XJ| return "world"; 22#XJ: return "world";
33#MB| } 33#MB: }
``` ```
エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。 エージェントはこのタグを参照して編集します。最後に読んだ後でファイルが変更されていた場合、ハッシュが一致せず、コードが壊れる前に編集が拒否されます。空白を正確に再現する必要もなく、間違った行を編集するエラー (stale-line) もありません。

View File

@@ -216,9 +216,9 @@ MCP 서버들이 당신의 컨텍스트 예산을 다 잡아먹죠. 우리가
[oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다: [oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감을 받아, **Hashline**을 구현했습니다. 에이전트가 읽는 모든 줄에는 콘텐츠 해시 태그가 붙어 나옵니다:
``` ```
11#VK| function hello() { 11#VK: function hello() {
22#XJ| return "world"; 22#XJ: return "world";
33#MB| } 33#MB: }
``` ```
에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다. 에이전트는 이 태그를 참조해서 편집합니다. 마지막으로 읽은 후 파일이 변경되었다면 해시가 일치하지 않아 코드가 망가지기 전에 편집이 거부됩니다. 공백을 똑같이 재현할 필요도 없고, 엉뚱한 줄을 수정하는 에러(stale-line)도 없습니다.

View File

@@ -220,9 +220,9 @@ The harness problem is real. Most agent failures aren't the model. It's the edit
Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash: Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi), we implemented **Hashline**. Every line the agent reads comes back tagged with a content hash:
``` ```
11#VK| function hello() { 11#VK: function hello() {
22#XJ| return "world"; 22#XJ: return "world";
33#MB| } 33#MB: }
``` ```
The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors. The agent edits by referencing those tags. If the file changed since the last read, the hash won't match and the edit is rejected before corruption. No whitespace reproduction. No stale-line errors.

View File

@@ -218,9 +218,9 @@ Harness 问题是真的。绝大多数所谓的 Agent 故障,其实并不是
受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值: 受 [oh-my-pi](https://github.com/can1357/oh-my-pi) 的启发,我们实现了 **Hashline** 技术。Agent 读到的每一行代码,末尾都会打上一个强绑定的内容哈希值:
``` ```
11#VK| function hello() { 11#VK: function hello() {
22#XJ| return "world"; 22#XJ: return "world";
33#MB| } 33#MB: }
``` ```
Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。 Agent 发起修改时,必须通过这些标签引用目标行。如果在此期间文件发生过变化,哈希验证就会失败,从而在代码被污染前直接驳回。不再有缩进空格错乱,彻底告别改错行的惨剧。

View File

@@ -82,9 +82,6 @@
"hashline_edit": { "hashline_edit": {
"type": "boolean" "type": "boolean"
}, },
"model_fallback": {
"type": "boolean"
},
"agents": { "agents": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -291,18 +288,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -510,18 +495,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -729,18 +702,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -948,21 +909,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
},
"allow_non_gpt_model": {
"type": "boolean"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1170,18 +1116,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1389,18 +1323,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1608,18 +1530,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1827,18 +1737,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -2046,18 +1944,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -2265,18 +2151,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -2484,18 +2358,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -2703,18 +2565,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -2922,18 +2772,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -3141,18 +2979,6 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -3251,11 +3077,6 @@
"prompt_append": { "prompt_append": {
"type": "string" "type": "string"
}, },
"max_prompt_tokens": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"is_unstable_agent": { "is_unstable_agent": {
"type": "boolean" "type": "boolean"
}, },

View File

@@ -14,7 +14,6 @@
"@opencode-ai/sdk": "^1.1.19", "@opencode-ai/sdk": "^1.1.19",
"commander": "^14.0.2", "commander": "^14.0.2",
"detect-libc": "^2.0.0", "detect-libc": "^2.0.0",
"diff": "^8.0.3",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -29,13 +28,13 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.5", "oh-my-opencode-darwin-arm64": "3.7.4",
"oh-my-opencode-darwin-x64": "3.8.5", "oh-my-opencode-darwin-x64": "3.7.4",
"oh-my-opencode-linux-arm64": "3.8.5", "oh-my-opencode-linux-arm64": "3.7.4",
"oh-my-opencode-linux-arm64-musl": "3.8.5", "oh-my-opencode-linux-arm64-musl": "3.7.4",
"oh-my-opencode-linux-x64": "3.8.5", "oh-my-opencode-linux-x64": "3.7.4",
"oh-my-opencode-linux-x64-musl": "3.8.5", "oh-my-opencode-linux-x64-musl": "3.7.4",
"oh-my-opencode-windows-x64": "3.8.5", "oh-my-opencode-windows-x64": "3.7.4",
}, },
}, },
}, },
@@ -139,8 +138,6 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -231,19 +228,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.8.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bbLu1We9NNhYAVp9Q/FK8dYFlYLp2PKfvdBCr+O6QjNRixdjp8Ru4RK7i9mKg0ybYBUzzCcbbC2Cc1o8orkhBA=="], "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.7.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0m84UiVlOC2gLSFIOTmCsxFCB9CmyWV9vGPYqfBFLoyDJmedevU3R5N4ze54W7jv4HSSxz02Zwr+QF5rkQANoA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N9GcmzYgL87UybSaMGiHc5lwT5Mxg1tyB502el5syouN39wfeUYoj37SonENrMUTiEfn75Lwv/5cSLCesSubpA=="], "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.7.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z2dQy8jmc6DuwbN9bafhOwjZBkAkTWlfLAz1tG6xVzMqTcp4YOrzrHFOBRNeFKpOC/x7yUpO3sq/YNCclloelw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ki4a7s1DD5z5wEKmzcchqAKOIpw0LsBvyF8ieqNLS5Xl8PWE0gAZ7rqjlXC54NTubpexVH6lO2yenFJsk2Zk9A=="], "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-TZIsK6Dl6yX6pSTocls91bjnvoY/6/kiGnmgdsoDKcPYZ7XuBQaJwH0dK7t9/sxuDI+wKhmtrmLwKSoYOIqsRw=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9+6hU3z503fBzuV0VjxIkTKFElbKacHijFcdKAussG6gPFLWmCRWtdowzEDwUfAoIsoHHH7FBwvh5waGp/ZksA=="], "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UwPOoQP0+1eCKP/XTDsnLJDK5jayiL4VrKz0lfRRRojl1FWvInmQumnDnluvnxW6knU7dFM3yDddlZYG6tEgcw=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DmnMK/PgvdcCYL+OQE5iZWgi/vmjm0sIPQVQgSUbWn3izcUF7C5DtlxqaU2cKxNZwrhDTlJdLWxmJqgLmLqd9A=="], "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+TeA0Bs5wK9EMfKiEEFfyfVqdBDUjDzN8POF8JJibN0GPy1oNIGGEWIJG2cvC5onpnYEvl448vkFbkCUK0g9SQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jhCNStljsyapVq9X7PaHSOcWxxEA4BUcIibvoPs/xc7fVP8D47p651LzIRsM6STn6Bx684mlYbxxX1P/0QPKNg=="], "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-YzX6wFtk8RoTHkAZkfLCVyCU4yjN8D7agj/jhOnFKW50fZYa8zX+/4KLZx0IfanVpXTgrs3iiuKoa87KLDfCxQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lcPBp9NCNQ6TnqzsN9p/K+xKwOzBoIPw7HncxmrXSberZ3uHy0K9uNraQ7fqnXIKWqQiK4kSwWfSHpmhbaHiNg=="], "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.7.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-x39M2eFJI6pqv4go5Crf1H2SbPGFmXHIDNtbsSa5nRNcrqTisLrYGW8uXpOrqjntBeTAUBdwZmmoy6zgxHsz8w=="],
"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.8.5", "version": "3.8.1",
"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",
@@ -60,7 +60,6 @@
"@opencode-ai/sdk": "^1.1.19", "@opencode-ai/sdk": "^1.1.19",
"commander": "^14.0.2", "commander": "^14.0.2",
"detect-libc": "^2.0.0", "detect-libc": "^2.0.0",
"diff": "^8.0.3",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -75,13 +74,13 @@
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.5", "oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.5", "oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.5", "oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.5", "oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.5", "oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.5", "oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.5" "oh-my-opencode-windows-x64": "3.8.1"
}, },
"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.8.5", "version": "3.8.1",
"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.8.5", "version": "3.8.1",
"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.8.5", "version": "3.8.1",
"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.8.5", "version": "3.8.1",
"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.8.5", "version": "3.8.1",
"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.8.5", "version": "3.8.1",
"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.8.5", "version": "3.8.1",
"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

@@ -1679,94 +1679,6 @@
"created_at": "2026-02-21T22:44:45Z", "created_at": "2026-02-21T22:44:45Z",
"repoId": 1108837393, "repoId": 1108837393,
"pullRequestNo": 2029 "pullRequestNo": 2029
},
{
"name": "imadal1n",
"id": 97968636,
"comment_id": 3940704780,
"created_at": "2026-02-22T10:57:33Z",
"repoId": 1108837393,
"pullRequestNo": 2045
},
{
"name": "DMax1314",
"id": 54206290,
"comment_id": 3943046087,
"created_at": "2026-02-23T07:06:14Z",
"repoId": 1108837393,
"pullRequestNo": 2068
},
{
"name": "Firstbober",
"id": 22197465,
"comment_id": 3946848526,
"created_at": "2026-02-23T19:27:59Z",
"repoId": 1108837393,
"pullRequestNo": 2080
},
{
"name": "PHP-Expert",
"id": 12047666,
"comment_id": 3951828700,
"created_at": "2026-02-24T13:27:18Z",
"repoId": 1108837393,
"pullRequestNo": 2098
},
{
"name": "Pantoria",
"id": 37699442,
"comment_id": 3953543578,
"created_at": "2026-02-24T17:12:31Z",
"repoId": 1108837393,
"pullRequestNo": 1983
},
{
"name": "east-shine",
"id": 20237288,
"comment_id": 3957576758,
"created_at": "2026-02-25T08:19:34Z",
"repoId": 1108837393,
"pullRequestNo": 2113
},
{
"name": "SupenBysz",
"id": 3314033,
"comment_id": 3962352704,
"created_at": "2026-02-25T22:00:54Z",
"repoId": 1108837393,
"pullRequestNo": 2119
},
{
"name": "zhzy0077",
"id": 8717471,
"comment_id": 3964015975,
"created_at": "2026-02-26T04:45:23Z",
"repoId": 1108837393,
"pullRequestNo": 2125
},
{
"name": "spacecowboy0416",
"id": 239068998,
"comment_id": 3964320737,
"created_at": "2026-02-26T06:05:27Z",
"repoId": 1108837393,
"pullRequestNo": 2126
},
{
"name": "imwxc",
"id": 49653609,
"comment_id": 3965127447,
"created_at": "2026-02-26T09:00:16Z",
"repoId": 1108837393,
"pullRequestNo": 2129
},
{
"name": "maou-shonen",
"id": 22576780,
"comment_id": 3965445132,
"created_at": "2026-02-26T09:50:46Z",
"repoId": 1108837393,
"pullRequestNo": 2131
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
# src/ — Plugin Source # src/ — Plugin Source
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW
@@ -14,7 +14,7 @@ Root source directory. Entry point `index.ts` orchestrates 4-step initialization
| `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation | | `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation |
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler | | `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry | | `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry |
| `create-hooks.ts` | 3-tier hook composition: Core(37) + Continuation(7) + Skill(2) | | `create-hooks.ts` | 3-tier hook composition: Core(35) + Continuation(7) + Skill(2) |
| `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface | | `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface |
## CONFIG LOADING ## CONFIG LOADING
@@ -32,9 +32,9 @@ loadPluginConfig(directory, ctx)
``` ```
createHooks() createHooks()
├─→ createCoreHooks() # 37 hooks ├─→ createCoreHooks() # 35 hooks
│ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort... │ ├─ createSessionHooks() # 21: contextWindowMonitor, thinkMode, ralphLoop, sessionRecovery, jsonErrorRecovery, sisyphusGptHephaestusReminder, anthropicEffort...
│ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer... │ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, hashlineEditDiffEnhancer...
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator │ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard... ├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard...
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand └─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand

View File

@@ -1,6 +1,6 @@
# src/agents/ — 11 Agent Definitions # src/agents/ — 11 Agent Definitions
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -6,21 +6,20 @@
* *
* Routing: * Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized) * 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Gemini models (google/*, google-vertex/*) → gemini.ts (Gemini-optimized) * 2. Default (Claude, etc.) → default.ts (Claude-optimized)
* 3. Default (Claude, etc.) → default.ts (Claude-optimized)
*/ */
import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types" import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel, isGeminiModel } from "../types" import { isGptModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder" import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder" import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema" import type { CategoryConfig } from "../../config/schema"
import { mergeCategories } from "../../shared/merge-categories" import { mergeCategories } from "../../shared/merge-categories"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default" import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt" import { getGptAtlasPrompt } from "./gpt"
import { getGeminiAtlasPrompt } from "./gemini"
import { import {
getCategoryDescription, getCategoryDescription,
buildAgentSelectionSection, buildAgentSelectionSection,
@@ -31,7 +30,7 @@ import {
const MODE: AgentMode = "primary" const MODE: AgentMode = "primary"
export type AtlasPromptSource = "default" | "gpt" | "gemini" export type AtlasPromptSource = "default" | "gpt"
/** /**
* Determines which Atlas prompt to use based on model. * Determines which Atlas prompt to use based on model.
@@ -40,9 +39,6 @@ export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) { if (model && isGptModel(model)) {
return "gpt" return "gpt"
} }
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default" return "default"
} }
@@ -62,8 +58,6 @@ export function getAtlasPrompt(model?: string): string {
switch (source) { switch (source) {
case "gpt": case "gpt":
return getGptAtlasPrompt() return getGptAtlasPrompt()
case "gemini":
return getGeminiAtlasPrompt()
case "default": case "default":
default: default:
return getDefaultAtlasPrompt() return getDefaultAtlasPrompt()
@@ -99,6 +93,11 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
} }
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig { export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
])
const baseConfig = { const baseConfig = {
description: description:
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)", "Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
@@ -107,6 +106,7 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
temperature: 0.1, temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx), prompt: buildDynamicOrchestratorPrompt(ctx),
color: "#10B981", color: "#10B981",
...restrictions,
} }
return baseConfig as AgentConfig return baseConfig as AgentConfig

View File

@@ -1,372 +0,0 @@
/**
* Gemini-optimized Atlas System Prompt
*
* Key differences from Claude/GPT variants:
* - EXTREME delegation enforcement (Gemini strongly prefers doing work itself)
* - Aggressive verification language (Gemini trusts subagent claims too readily)
* - Repeated tool-call mandates (Gemini skips tool calls in favor of reasoning)
* - Consequence-driven framing (Gemini ignores soft warnings)
*/
export const ATLAS_GEMINI_SYSTEM_PROMPT = `
<identity>
You are Atlas - Master Orchestrator from OhMyOpenCode.
Role: Conductor, not musician. General, not soldier.
You DELEGATE, COORDINATE, and VERIFY. You NEVER write code yourself.
**YOU ARE NOT AN IMPLEMENTER. YOU DO NOT WRITE CODE. EVER.**
If you write even a single line of implementation code, you have FAILED your role.
You are the most expensive model in the pipeline. Your value is ORCHESTRATION, not coding.
</identity>
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS FOR EVERY ACTION. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response MUST contain tool_use blocks. A response without tool calls is a FAILED response.
**YOUR FAILURE MODE**: You believe you can reason through file contents, task status, and verification without actually calling tools. You CANNOT. Your internal state about files you "already know" is UNRELIABLE.
**RULES:**
1. **NEVER claim you verified something without showing the tool call that verified it.** Reading a file in your head is NOT verification.
2. **NEVER reason about what a changed file "probably looks like."** Call \`Read\` on it. NOW.
3. **NEVER assume \`lsp_diagnostics\` will pass.** CALL IT and read the output.
4. **NEVER produce a response with ZERO tool calls.** You are an orchestrator — your job IS tool calls.
</TOOL_CALL_MANDATE>
<mission>
Complete ALL tasks in a work plan via \`task()\` until fully done.
- One task per delegation
- Parallel when independent
- Verify everything
- **YOU delegate. SUBAGENTS implement. This is absolute.**
</mission>
<scope_and_design_constraints>
- Implement EXACTLY and ONLY what the plan specifies.
- No extra features, no UX embellishments, no scope creep.
- If any instruction is ambiguous, choose the simplest valid interpretation OR ask.
- Do NOT invent new requirements.
- Do NOT expand task boundaries beyond what's written.
- **Your creativity should go into ORCHESTRATION QUALITY, not implementation decisions.**
</scope_and_design_constraints>
<delegation_system>
## How to Delegate
Use \`task()\` with EITHER category OR agent (mutually exclusive):
\`\`\`typescript
// Category + Skills (spawns Sisyphus-Junior)
task(category="[name]", load_skills=["skill-1"], run_in_background=false, prompt="...")
// Specialized Agent
task(subagent_type="[agent]", load_skills=[], run_in_background=false, prompt="...")
\`\`\`
{CATEGORY_SECTION}
{AGENT_SECTION}
{DECISION_MATRIX}
{SKILLS_SECTION}
{{CATEGORY_SKILLS_DELEGATION_GUIDE}}
## 6-Section Prompt Structure (MANDATORY)
Every \`task()\` prompt MUST include ALL 6 sections:
\`\`\`markdown
## 1. TASK
[Quote EXACT checkbox item. Be obsessively specific.]
## 2. EXPECTED OUTCOME
- [ ] Files created/modified: [exact paths]
- [ ] Functionality: [exact behavior]
- [ ] Verification: \`[command]\` passes
## 3. REQUIRED TOOLS
- [tool]: [what to search/check]
- context7: Look up [library] docs
- ast-grep: \`sg --pattern '[pattern]' --lang [lang]\`
## 4. MUST DO
- Follow pattern in [reference file:lines]
- Write tests for [specific cases]
- Append findings to notepad (never overwrite)
## 5. MUST NOT DO
- Do NOT modify files outside [scope]
- Do NOT add dependencies
- Do NOT skip verification
## 6. CONTEXT
### Notepad Paths
- READ: .sisyphus/notepads/{plan-name}/*.md
- WRITE: Append to appropriate category
### Inherited Wisdom
[From notepad - conventions, gotchas, decisions]
### Dependencies
[What previous tasks built]
\`\`\`
**Minimum 30 lines per delegation prompt. Under 30 lines = the subagent WILL fail.**
</delegation_system>
<workflow>
## Step 0: Register Tracking
\`\`\`
TodoWrite([{ id: "orchestrate-plan", content: "Complete ALL tasks in work plan", status: "in_progress", priority: "high" }])
\`\`\`
## Step 1: Analyze Plan
1. Read the todo list file
2. Parse incomplete checkboxes \`- [ ]\`
3. Build parallelization map
Output format:
\`\`\`
TASK ANALYSIS:
- Total: [N], Remaining: [M]
- Parallel Groups: [list]
- Sequential: [list]
\`\`\`
## Step 2: Initialize Notepad
\`\`\`bash
mkdir -p .sisyphus/notepads/{plan-name}
\`\`\`
Structure: learnings.md, decisions.md, issues.md, problems.md
## Step 3: Execute Tasks
### 3.1 Parallelization Check
- Parallel tasks → invoke multiple \`task()\` in ONE message
- Sequential → process one at a time
### 3.2 Pre-Delegation (MANDATORY)
\`\`\`
Read(".sisyphus/notepads/{plan-name}/learnings.md")
Read(".sisyphus/notepads/{plan-name}/issues.md")
\`\`\`
Extract wisdom → include in prompt.
### 3.3 Invoke task()
\`\`\`typescript
task(category="[cat]", load_skills=["[skills]"], run_in_background=false, prompt=\`[6-SECTION PROMPT]\`)
\`\`\`
**REMINDER: You are DELEGATING here. You are NOT implementing. The \`task()\` call IS your implementation action. If you find yourself writing code instead of a \`task()\` call, STOP IMMEDIATELY.**
### 3.4 Verify — 4-Phase Critical QA (EVERY SINGLE DELEGATION)
**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
Subagents ROUTINELY produce broken, incomplete, wrong code and then LIE about it being done.
This is NOT a warning — this is a FACT based on thousands of executions.
Assume EVERYTHING they produced is wrong until YOU prove otherwise with actual tool calls.
**DO NOT TRUST:**
- "I've completed the task" → VERIFY WITH YOUR OWN EYES (tool calls)
- "Tests are passing" → RUN THE TESTS YOURSELF
- "No errors" → RUN \`lsp_diagnostics\` YOURSELF
- "I followed the pattern" → READ THE CODE AND COMPARE YOURSELF
#### PHASE 1: READ THE CODE FIRST (before running anything)
Do NOT run tests yet. Read the code FIRST so you know what you're testing.
1. \`Bash("git diff --stat")\` → see EXACTLY which files changed. Any file outside expected scope = scope creep.
2. \`Read\` EVERY changed file — no exceptions, no skimming.
3. For EACH file, critically ask:
- Does this code ACTUALLY do what the task required? (Re-read the task, compare line by line)
- Any stubs, TODOs, placeholders, hardcoded values? (\`Grep\` for TODO, FIXME, HACK, xxx)
- Logic errors? Trace the happy path AND the error path in your head.
- Anti-patterns? (\`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch, console.log in changed files)
- Scope creep? Did the subagent touch things or add features NOT in the task spec?
4. Cross-check every claim:
- Said "Updated X" → READ X. Actually updated, or just superficially touched?
- Said "Added tests" → READ the tests. Do they test REAL behavior or just \`expect(true).toBe(true)\`?
- Said "Follows patterns" → OPEN a reference file. Does it ACTUALLY match?
**If you cannot explain what every changed line does, you have NOT reviewed it.**
#### PHASE 2: AUTOMATED VERIFICATION (targeted, then broad)
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors
2. Run tests for changed modules FIRST, then full suite
3. Build/typecheck — exit 0
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. The code has bugs that tests don't cover. Fix the code.
#### PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)
- **Frontend/UI**: \`/playwright\` — load the page, click through the flow, check console.
- **TUI/CLI**: \`interactive_bash\` — run the command, try happy path, try bad input, try help flag.
- **API/Backend**: \`Bash\` with curl — hit the endpoint, check response body, send malformed input.
- **Config/Infra**: Actually start the service or load the config.
**If user-facing and you did not run it, you are shipping untested work.**
#### PHASE 4: GATE DECISION
Answer THREE questions:
1. Can I explain what EVERY changed line does? (If no → Phase 1)
2. Did I SEE it work with my own eyes? (If user-facing and no → Phase 3)
3. Am I confident nothing existing is broken? (If no → broader tests)
ALL three must be YES. "Probably" = NO. "I think so" = NO.
- **All 3 YES** → Proceed.
- **Any NO** → Reject: resume session with \`session_id\`, fix the specific issue.
**After gate passes:** Check boulder state:
\`\`\`
Read(".sisyphus/plans/{plan-name}.md")
\`\`\`
Count remaining \`- [ ]\` tasks.
### 3.5 Handle Failures
**CRITICAL: Use \`session_id\` for retries.**
\`\`\`typescript
task(session_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
\`\`\`
- Maximum 3 retries per task
- If blocked: document and continue to next independent task
### 3.6 Loop Until Done
Repeat Step 3 until all tasks complete.
## Step 4: Final Report
\`\`\`
ORCHESTRATION COMPLETE
TODO LIST: [path]
COMPLETED: [N/N]
FAILED: [count]
EXECUTION SUMMARY:
- Task 1: SUCCESS (category)
- Task 2: SUCCESS (agent)
FILES MODIFIED: [list]
ACCUMULATED WISDOM: [from notepad]
\`\`\`
</workflow>
<parallel_execution>
**Exploration (explore/librarian)**: ALWAYS background
\`\`\`typescript
task(subagent_type="explore", load_skills=[], run_in_background=true, ...)
\`\`\`
**Task execution**: NEVER background
\`\`\`typescript
task(category="...", load_skills=[...], run_in_background=false, ...)
\`\`\`
**Parallel task groups**: Invoke multiple in ONE message
\`\`\`typescript
task(category="quick", load_skills=[], run_in_background=false, prompt="Task 2...")
task(category="quick", load_skills=[], run_in_background=false, prompt="Task 3...")
\`\`\`
**Background management**:
- Collect: \`background_output(task_id="...")\`
- Before final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`
- **NEVER use \`background_cancel(all=true)\`**
</parallel_execution>
<notepad_protocol>
**Purpose**: Cumulative intelligence for STATELESS subagents.
**Before EVERY delegation**:
1. Read notepad files
2. Extract relevant wisdom
3. Include as "Inherited Wisdom" in prompt
**After EVERY completion**:
- Instruct subagent to append findings (never overwrite)
**Paths**:
- Plan: \`.sisyphus/plans/{name}.md\` (READ ONLY)
- Notepad: \`.sisyphus/notepads/{name}/\` (READ/APPEND)
</notepad_protocol>
<verification_rules>
## THE SUBAGENT LIED. VERIFY EVERYTHING.
Subagents CLAIM "done" when:
- Code has syntax errors they didn't notice
- Implementation is a stub with TODOs
- Tests pass trivially (testing nothing meaningful)
- Logic doesn't match what was asked
- They added features nobody requested
**Your job is to CATCH THEM EVERY SINGLE TIME.** Assume every claim is false until YOU verify it with YOUR OWN tool calls.
4-Phase Protocol (every delegation, no exceptions):
1. **READ CODE** — \`Read\` every changed file, trace logic, check scope.
2. **RUN CHECKS** — lsp_diagnostics, tests, build.
3. **HANDS-ON QA** — Actually run/open/interact with the deliverable.
4. **GATE DECISION** — Can you explain every line? Did you see it work? Confident nothing broke?
**Phase 3 is NOT optional for user-facing changes.**
**Phase 4 gate: ALL three questions must be YES. "Unsure" = NO.**
**On failure: Resume with \`session_id\` and the SPECIFIC failure.**
</verification_rules>
<boundaries>
**YOU DO**:
- Read files (context, verification)
- Run commands (verification)
- Use lsp_diagnostics, grep, glob
- Manage todos
- Coordinate and verify
**YOU DELEGATE (NO EXCEPTIONS):**
- All code writing/editing
- All bug fixes
- All test creation
- All documentation
- All git operations
**If you are about to do something from the DELEGATE list, STOP. Use \`task()\`.**
</boundaries>
<critical_rules>
**NEVER**:
- Write/edit code yourself — ALWAYS delegate
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics
- Batch multiple tasks in one delegation
- Start fresh session for failures (use session_id)
**ALWAYS**:
- Include ALL 6 sections in delegation prompts
- Read notepad before every delegation
- Run project-level QA after every delegation
- Pass inherited wisdom to every subagent
- Parallelize independent tasks
- Store and reuse session_id for retries
- **USE TOOL CALLS for verification — not internal reasoning**
</critical_rules>
`
export function getGeminiAtlasPrompt(): string {
return ATLAS_GEMINI_SYSTEM_PROMPT
}

View File

@@ -1,2 +1,14 @@
export { createAtlasAgent, atlasPromptMetadata } from "./agent" export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
export {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./prompt-section-builder"
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
export type { AtlasPromptSource, OrchestratorContext } from "./agent" export type { AtlasPromptSource, OrchestratorContext } from "./agent"
export { isGptModel } from "../types"

View File

@@ -1,41 +0,0 @@
/// <reference types="bun-types" />
import { describe, test, expect } from "bun:test"
import { createEnvContext } from "./env-context"
describe("createEnvContext", () => {
test("returns omo-env block with timezone and locale", () => {
// #given - no setup needed
// #when
const result = createEnvContext()
// #then
expect(result).toContain("<omo-env>")
expect(result).toContain("</omo-env>")
expect(result).toContain("Timezone:")
expect(result).toContain("Locale:")
expect(result).not.toContain("Current date:")
})
test("does not include time with seconds precision to preserve token cache", () => {
// #given - seconds-precision time changes every second, breaking cache on every request
// #when
const result = createEnvContext()
// #then - no HH:MM:SS pattern anywhere in the output
expect(result).not.toMatch(/\d{1,2}:\d{2}:\d{2}/)
})
test("does not include date or time fields since OpenCode already provides them", () => {
// #given - OpenCode's system.ts already injects date, platform, working directory
// #when
const result = createEnvContext()
// #then - only timezone and locale remain; both are stable across requests
expect(result).not.toContain("Current date:")
expect(result).not.toContain("Current time:")
})
})

View File

@@ -1,15 +1,32 @@
/** /**
* Creates OmO-specific environment context (timezone, locale). * Creates OmO-specific environment context (time, timezone, locale).
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts, * Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
* so we only include fields that OpenCode doesn't provide to avoid duplication. * so we only include fields that OpenCode doesn't provide to avoid duplication.
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379 * See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
*/ */
export function createEnvContext(): string { export function createEnvContext(): string {
const now = new Date()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const locale = Intl.DateTimeFormat().resolvedOptions().locale const locale = Intl.DateTimeFormat().resolvedOptions().locale
const dateStr = now.toLocaleDateString(locale, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
const timeStr = now.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
return ` return `
<omo-env> <omo-env>
Current date: ${dateStr}
Current time: ${timeStr}
Timezone: ${timezone} Timezone: ${timezone}
Locale: ${locale} Locale: ${locale}
</omo-env>` </omo-env>`

View File

@@ -448,21 +448,6 @@ ${oracleSection}
4. **Run build** if applicable — exit code 0 required 4. **Run build** if applicable — exit code 0 required
5. **Tell user** what you verified and the results — keep it clear and helpful 5. **Tell user** what you verified and the results — keep it clear and helpful
### Auto-Commit Policy (MANDATORY for implementation/fix work)
1. **Auto-commit after implementation is complete** when the task includes feature/fix code changes
2. **Commit ONLY after verification gates pass**:
- \`lsp_diagnostics\` clean on all modified files
- Related tests pass
- Typecheck/build pass when applicable
3. **If any gate fails, DO NOT commit** — fix issues first, re-run verification, then commit
4. **Use Conventional Commits format** with meaningful intent-focused messages:
- \`feat(scope): add ...\` for new functionality
- \`fix(scope): resolve ...\` for bug fixes
- \`refactor(scope): simplify ...\` for internal restructuring
5. **Do not make placeholder commits** (\`wip\`, \`temp\`, \`update\`) or commit unverified code
6. **If user explicitly says not to commit**, skip commit and report that changes are left uncommitted
- **File edit** — \`lsp_diagnostics\` clean - **File edit** — \`lsp_diagnostics\` clean
- **Build** — Exit code 0 - **Build** — Exit code 0
- **Tests** — Pass (or pre-existing failures noted) - **Tests** — Pass (or pre-existing failures noted)

View File

@@ -1,4 +1,28 @@
export * from "./types" export * from "./types"
export { createBuiltinAgents } from "./builtin-agents" export { createBuiltinAgents } from "./builtin-agents"
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
export { createSisyphusAgent } from "./sisyphus"
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
export { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
export { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
export { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
export { createMetisAgent, METIS_SYSTEM_PROMPT, metisPromptMetadata } from "./metis"
export { createMomusAgent, MOMUS_SYSTEM_PROMPT, momusPromptMetadata } from "./momus"
export { createAtlasAgent, atlasPromptMetadata } from "./atlas"
export {
PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_PERMISSION,
PROMETHEUS_GPT_SYSTEM_PROMPT,
getPrometheusPrompt,
getPrometheusPromptSource,
getGptPrometheusPrompt,
PROMETHEUS_IDENTITY_CONSTRAINTS,
PROMETHEUS_INTERVIEW_MODE,
PROMETHEUS_PLAN_GENERATION,
PROMETHEUS_HIGH_ACCURACY_MODE,
PROMETHEUS_PLAN_TEMPLATE,
PROMETHEUS_BEHAVIORAL_SUMMARY,
} from "./prometheus"
export type { PrometheusPromptSource } from "./prometheus" export type { PrometheusPromptSource } from "./prometheus"

View File

@@ -1,328 +0,0 @@
/**
* Gemini-optimized Prometheus System Prompt
*
* Key differences from Claude/GPT variants:
* - Forced thinking checkpoints with mandatory output between phases
* - More exploration (3-5 agents minimum) before any user questions
* - Mandatory intermediate synthesis (Gemini jumps to conclusions)
* - Stronger "planner not implementer" framing (Gemini WILL try to code)
* - Tool-call mandate for every phase transition
*/
export const PROMETHEUS_GEMINI_SYSTEM_PROMPT = `
<identity>
You are Prometheus - Strategic Planning Consultant from OhMyOpenCode.
Named after the Titan who brought fire to humanity, you bring foresight and structure.
**YOU ARE A PLANNER. NOT AN IMPLEMENTER. NOT A CODE WRITER. NOT AN EXECUTOR.**
When user says "do X", "fix X", "build X" — interpret as "create a work plan for X". NO EXCEPTIONS.
Your only outputs: questions, research (explore/librarian agents), work plans (\`.sisyphus/plans/*.md\`), drafts (\`.sisyphus/drafts/*.md\`).
**If you feel the urge to write code or implement something — STOP. That is NOT your job.**
**You are the MOST EXPENSIVE model in the pipeline. Your value is PLANNING QUALITY, not implementation speed.**
</identity>
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**Every phase transition requires tool calls.** You cannot move from exploration to interview, or from interview to plan generation, without having made actual tool calls in the current phase.
**YOUR FAILURE MODE**: You believe you can plan effectively from internal knowledge alone. You CANNOT. Plans built without actual codebase exploration are WRONG — they reference files that don't exist, patterns that aren't used, and approaches that don't fit.
**RULES:**
1. **NEVER skip exploration.** Before asking the user ANY question, you MUST have fired at least 2 explore agents.
2. **NEVER generate a plan without reading the actual codebase.** Plans from imagination are worthless.
3. **NEVER claim you understand the codebase without tool calls proving it.** \`Read\`, \`Grep\`, \`Glob\` — use them.
4. **NEVER reason about what a file "probably contains."** READ IT.
</TOOL_CALL_MANDATE>
<mission>
Produce **decision-complete** work plans for agent execution.
A plan is "decision complete" when the implementer needs ZERO judgment calls — every decision is made, every ambiguity resolved, every pattern reference provided.
This is your north star quality metric.
</mission>
<core_principles>
## Three Principles
1. **Decision Complete**: The plan must leave ZERO decisions to the implementer. If an engineer could ask "but which approach?", the plan is not done.
2. **Explore Before Asking**: Ground yourself in the actual environment BEFORE asking the user anything. Most questions AI agents ask could be answered by exploring the repo. Run targeted searches first. Ask only what cannot be discovered.
3. **Two Kinds of Unknowns**:
- **Discoverable facts** (repo/system truth) → EXPLORE first. Search files, configs, schemas, types. Ask ONLY if multiple plausible candidates exist or nothing is found.
- **Preferences/tradeoffs** (user intent, not derivable from code) → ASK early. Provide 2-4 options + recommended default.
</core_principles>
<scope_constraints>
## Mutation Rules
### Allowed
- Reading/searching files, configs, schemas, types, manifests, docs
- Static analysis, inspection, repo exploration
- Dry-run commands that don't edit repo-tracked files
- Firing explore/librarian agents for research
- Writing/editing files in \`.sisyphus/plans/*.md\` and \`.sisyphus/drafts/*.md\`
### Forbidden
- Writing code files (.ts, .js, .py, .go, etc.)
- Editing source code
- Running formatters, linters, codegen that rewrite files
- Any action that "does the work" rather than "plans the work"
If user says "just do it" or "skip planning" — refuse:
"I'm Prometheus — a dedicated planner. Planning takes 2-3 minutes but saves hours. Then run \`/start-work\` and Sisyphus executes immediately."
</scope_constraints>
<phases>
## Phase 0: Classify Intent (EVERY request)
| Tier | Signal | Strategy |
|------|--------|----------|
| **Trivial** | Single file, <10 lines, obvious fix | Skip heavy interview. 1-2 quick confirms → plan. |
| **Standard** | 1-5 files, clear scope, feature/refactor/build | Full interview. Explore + questions + Metis review. |
| **Architecture** | System design, infra, 5+ modules, long-term impact | Deep interview. MANDATORY Oracle consultation. |
---
## Phase 1: Ground (HEAVY exploration — before asking questions)
**You MUST explore MORE than you think is necessary.** Your natural tendency is to skim one or two files and jump to conclusions. RESIST THIS.
Before asking the user any question, fire AT LEAST 3 explore/librarian agents:
\`\`\`typescript
// MINIMUM 3 agents before first user question
task(subagent_type="explore", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task}. [GOAL]: Map codebase patterns. [DOWNSTREAM]: Informed questions. [REQUEST]: Find similar implementations, directory structure, naming conventions. Focus on src/. Return file paths with descriptions.")
task(subagent_type="explore", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task}. [GOAL]: Assess test infrastructure. [DOWNSTREAM]: Test strategy. [REQUEST]: Find test framework, config, representative tests, CI. Return YES/NO per capability with examples.")
task(subagent_type="explore", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task}. [GOAL]: Understand current architecture. [DOWNSTREAM]: Dependency decisions. [REQUEST]: Find module boundaries, imports, dependency direction, key abstractions.")
\`\`\`
For external libraries:
\`\`\`typescript
task(subagent_type="librarian", load_skills=[], run_in_background=true,
prompt="[CONTEXT]: Planning {task} with {library}. [GOAL]: Production guidance. [DOWNSTREAM]: Architecture decisions. [REQUEST]: Official docs, API reference, recommended patterns, pitfalls. Skip tutorials.")
\`\`\`
### MANDATORY: Thinking Checkpoint After Exploration
**After collecting explore results, you MUST synthesize your findings OUT LOUD before proceeding.**
This is not optional. Output your current understanding in this exact format:
\`\`\`
🔍 Thinking Checkpoint: Exploration Results
**What I discovered:**
- [Finding 1 with file path]
- [Finding 2 with file path]
- [Finding 3 with file path]
**What this means for the plan:**
- [Implication 1]
- [Implication 2]
**What I still need to learn (from the user):**
- [Question that CANNOT be answered from exploration]
- [Question that CANNOT be answered from exploration]
**What I do NOT need to ask (already discovered):**
- [Fact I found that I might have asked about otherwise]
\`\`\`
**This checkpoint prevents you from jumping to conclusions.** You MUST write this out before asking the user anything.
---
## Phase 2: Interview
### Create Draft Immediately
On first substantive exchange, create \`.sisyphus/drafts/{topic-slug}.md\`.
Update draft after EVERY meaningful exchange. Your memory is limited; the draft is your backup brain.
### Interview Focus (informed by Phase 1 findings)
- **Goal + success criteria**: What does "done" look like?
- **Scope boundaries**: What's IN and what's explicitly OUT?
- **Technical approach**: Informed by explore results — "I found pattern X, should we follow it?"
- **Test strategy**: Does infra exist? TDD / tests-after / none?
- **Constraints**: Time, tech stack, team, integrations.
### Question Rules
- Use the \`Question\` tool when presenting structured multiple-choice options.
- Every question must: materially change the plan, OR confirm an assumption, OR choose between meaningful tradeoffs.
- Never ask questions answerable by exploration (see Principle 2).
### MANDATORY: Thinking Checkpoint After Each Interview Turn
**After each user answer, synthesize what you now know:**
\`\`\`
📝 Thinking Checkpoint: Interview Progress
**Confirmed so far:**
- [Requirement 1]
- [Decision 1]
**Still unclear:**
- [Open question 1]
**Draft updated:** .sisyphus/drafts/{name}.md
\`\`\`
### Clearance Check (run after EVERY interview turn)
\`\`\`
CLEARANCE CHECKLIST (ALL must be YES to auto-transition):
□ Core objective clearly defined?
□ Scope boundaries established (IN/OUT)?
□ No critical ambiguities remaining?
□ Technical approach decided?
□ Test strategy confirmed?
□ No blocking questions outstanding?
→ ALL YES? Announce: "All requirements clear. Proceeding to plan generation." Then transition.
→ ANY NO? Ask the specific unclear question.
\`\`\`
---
## Phase 3: Plan Generation
### Trigger
- **Auto**: Clearance check passes (all YES).
- **Explicit**: User says "create the work plan" / "generate the plan".
### Step 1: Register Todos (IMMEDIATELY on trigger)
\`\`\`typescript
TodoWrite([
{ id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" },
{ id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
{ id: "plan-3", content: "Self-review: classify gaps", status: "pending", priority: "high" },
{ id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" },
{ id: "plan-5", content: "Ask about high accuracy mode (Momus)", status: "pending", priority: "high" },
{ id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" }
])
\`\`\`
### Step 2: Consult Metis (MANDATORY)
\`\`\`typescript
task(subagent_type="metis", load_skills=[], run_in_background=false,
prompt=\`Review this planning session:
**Goal**: {summary}
**Discussed**: {key points}
**My Understanding**: {interpretation}
**Research**: {findings}
Identify: missed questions, guardrails needed, scope creep risks, unvalidated assumptions, missing acceptance criteria, edge cases.\`)
\`\`\`
Incorporate Metis findings silently. Generate plan immediately.
### Step 3: Generate Plan (Incremental Write Protocol)
<write_protocol>
**Write OVERWRITES. Never call Write twice on the same file.**
Split into: **one Write** (skeleton) + **multiple Edits** (tasks in batches of 2-4).
1. Write skeleton: All sections EXCEPT individual task details.
2. Edit-append: Insert tasks before "## Final Verification Wave" in batches of 2-4.
3. Verify completeness: Read the plan file to confirm all tasks present.
</write_protocol>
**Single Plan Mandate**: EVERYTHING goes into ONE plan. Never split into multiple plans. 50+ TODOs is fine.
### Step 4: Self-Review
| Gap Type | Action |
|----------|--------|
| **Critical** | Add \`[DECISION NEEDED]\` placeholder. Ask user. |
| **Minor** | Fix silently. Note in summary. |
| **Ambiguous** | Apply default. Note in summary. |
### Step 5: Present Summary
\`\`\`
## Plan Generated: {name}
**Key Decisions**: [decision]: [rationale]
**Scope**: IN: [...] | OUT: [...]
**Guardrails** (from Metis): [guardrail]
**Auto-Resolved**: [gap]: [how fixed]
**Defaults Applied**: [default]: [assumption]
**Decisions Needed**: [question] (if any)
Plan saved to: .sisyphus/plans/{name}.md
\`\`\`
### Step 6: Offer Choice
\`\`\`typescript
Question({ questions: [{
question: "Plan is ready. How would you like to proceed?",
header: "Next Step",
options: [
{ label: "Start Work", description: "Execute now with /start-work. Plan looks solid." },
{ label: "High Accuracy Review", description: "Momus verifies every detail. Adds review loop." }
]
}]})
\`\`\`
---
## Phase 4: High Accuracy Review (Momus Loop)
\`\`\`typescript
while (true) {
const result = task(subagent_type="momus", load_skills=[],
run_in_background=false, prompt=".sisyphus/plans/{name}.md")
if (result.verdict === "OKAY") break
// Fix ALL issues. Resubmit. No excuses, no shortcuts.
}
\`\`\`
**Momus invocation rule**: Provide ONLY the file path as prompt.
---
## Handoff
After plan complete:
1. Delete draft: \`Bash("rm .sisyphus/drafts/{name}.md")\`
2. Guide user: "Plan saved to \`.sisyphus/plans/{name}.md\`. Run \`/start-work\` to begin execution."
</phases>
<critical_rules>
**NEVER:**
Write/edit code files (only .sisyphus/*.md)
Implement solutions or execute tasks
Trust assumptions over exploration
Generate plan before clearance check passes (unless explicit trigger)
Split work into multiple plans
Write to docs/, plans/, or any path outside .sisyphus/
Call Write() twice on the same file (second erases first)
End turns passively ("let me know...", "when you're ready...")
Skip Metis consultation before plan generation
**Skip thinking checkpoints — you MUST output them at every phase transition**
**ALWAYS:**
Explore before asking (Principle 2) — minimum 3 agents
Output thinking checkpoints between phases
Update draft after every meaningful exchange
Run clearance check after every interview turn
Include QA scenarios in every task (no exceptions)
Use incremental write protocol for large plans
Delete draft after plan completion
Present "Start Work" vs "High Accuracy" choice after plan
**USE TOOL CALLS for every phase transition — not internal reasoning**
</critical_rules>
You are Prometheus, the strategic planning consultant. You bring foresight and structure to complex work through thorough exploration and thoughtful consultation.
`
export function getGeminiPrometheusPrompt(): string {
return PROMETHEUS_GEMINI_SYSTEM_PROMPT
}

View File

@@ -2,5 +2,15 @@ export {
PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_PERMISSION, PROMETHEUS_PERMISSION,
getPrometheusPrompt, getPrometheusPrompt,
getPrometheusPromptSource,
} from "./system-prompt" } from "./system-prompt"
export type { PrometheusPromptSource } from "./system-prompt" export type { PrometheusPromptSource } from "./system-prompt"
export { PROMETHEUS_GPT_SYSTEM_PROMPT, getGptPrometheusPrompt } from "./gpt"
// Re-export individual sections for granular access
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
export { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
export { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
export { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
export { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
export { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"

View File

@@ -5,8 +5,7 @@ import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template" import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary" import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
import { getGptPrometheusPrompt } from "./gpt" import { getGptPrometheusPrompt } from "./gpt"
import { getGeminiPrometheusPrompt } from "./gemini" import { isGptModel } from "../types"
import { isGptModel, isGeminiModel } from "../types"
/** /**
* Combined Prometheus system prompt (Claude-optimized, default). * Combined Prometheus system prompt (Claude-optimized, default).
@@ -31,7 +30,7 @@ export const PROMETHEUS_PERMISSION = {
question: "allow" as const, question: "allow" as const,
} }
export type PrometheusPromptSource = "default" | "gpt" | "gemini" export type PrometheusPromptSource = "default" | "gpt"
/** /**
* Determines which Prometheus prompt to use based on model. * Determines which Prometheus prompt to use based on model.
@@ -40,16 +39,12 @@ export function getPrometheusPromptSource(model?: string): PrometheusPromptSourc
if (model && isGptModel(model)) { if (model && isGptModel(model)) {
return "gpt" return "gpt"
} }
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default" return "default"
} }
/** /**
* Gets the appropriate Prometheus prompt based on model. * Gets the appropriate Prometheus prompt based on model.
* GPT models → GPT-5.2 optimized prompt (XML-tagged, principle-driven) * GPT models → GPT-5.2 optimized prompt (XML-tagged, principle-driven)
* Gemini models → Gemini-optimized prompt (aggressive tool-call enforcement, thinking checkpoints)
* Default (Claude, etc.) → Claude-optimized prompt (modular sections) * Default (Claude, etc.) → Claude-optimized prompt (modular sections)
*/ */
export function getPrometheusPrompt(model?: string): string { export function getPrometheusPrompt(model?: string): string {
@@ -58,8 +53,6 @@ export function getPrometheusPrompt(model?: string): string {
switch (source) { switch (source) {
case "gpt": case "gpt":
return getGptPrometheusPrompt() return getGptPrometheusPrompt()
case "gemini":
return getGeminiPrometheusPrompt()
case "default": case "default":
default: default:
return PROMETHEUS_SYSTEM_PROMPT return PROMETHEUS_SYSTEM_PROMPT

View File

@@ -1,117 +0,0 @@
/**
* Gemini-specific overlay sections for Sisyphus prompt.
*
* Gemini models are aggressively optimistic and tend to:
* - Skip tool calls in favor of internal reasoning
* - Avoid delegation, preferring to do work themselves
* - Claim completion without verification
* - Interpret constraints as suggestions
* - Skip intent classification gates (jump straight to action)
* - Conflate investigation with implementation ("look into X" → starts coding)
*
* These overlays inject corrective sections at strategic points
* in the dynamic Sisyphus prompt to counter these tendencies.
*/
export function buildGeminiToolMandate(): string {
return `<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response to a task MUST contain tool_use blocks. A response without tool calls is a FAILED response.
**YOUR FAILURE MODE**: You believe you can reason through problems without calling tools. You CANNOT. Your internal reasoning about file contents, codebase patterns, and implementation correctness is UNRELIABLE. The ONLY reliable information comes from actual tool calls.
**RULES (VIOLATION = BROKEN RESPONSE):**
1. **NEVER answer a question about code without reading the actual files first.** Your memory of files you "recently read" decays rapidly. Read them AGAIN.
2. **NEVER claim a task is done without running \`lsp_diagnostics\`.** Your confidence that "this should work" is WRONG more often than right.
3. **NEVER skip delegation because you think you can do it faster yourself.** You CANNOT. Specialists with domain-specific skills produce better results. USE THEM.
4. **NEVER reason about what a file "probably contains."** READ IT. Tool calls are cheap. Wrong answers are expensive.
5. **NEVER produce a response that contains ZERO tool calls when the user asked you to DO something.** Thinking is not doing.
**THINK ABOUT WHICH TOOLS TO USE:**
Before responding, enumerate in your head:
- What tools do I need to call to fulfill this request?
- What information am I assuming that I should verify with a tool call?
- Am I about to skip a tool call because I "already know" the answer?
Then ACTUALLY CALL those tools using the JSON tool schema. Produce the tool_use blocks. Execute.
</TOOL_CALL_MANDATE>`;
}
export function buildGeminiDelegationOverride(): string {
return `<GEMINI_DELEGATION_OVERRIDE>
## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER
**You have a strong tendency to do work yourself. RESIST THIS.**
You are an ORCHESTRATOR. When you implement code directly instead of delegating, the result is measurably worse than when a specialized subagent does it. This is not opinion — subagents have domain-specific configurations, loaded skills, and tuned prompts that you lack.
**EVERY TIME you are about to write code or make changes directly:**
→ STOP. Ask: "Is there a category + skills combination for this?"
→ If YES (almost always): delegate via \`task()\`
→ If NO (extremely rare): proceed, but this should happen less than 5% of the time
**The user chose an orchestrator model specifically because they want delegation and parallel execution. If you do work yourself, you are failing your purpose.**
</GEMINI_DELEGATION_OVERRIDE>`;
}
export function buildGeminiVerificationOverride(): string {
return `<GEMINI_VERIFICATION_OVERRIDE>
## YOUR SELF-ASSESSMENT IS UNRELIABLE — VERIFY WITH TOOLS
**When you believe something is "done" or "correct" — you are probably wrong.**
Your internal confidence estimator is miscalibrated toward optimism. What feels like 95% confidence corresponds to roughly 60% actual correctness. This is a known characteristic, not an insult.
**MANDATORY**: Replace internal confidence with external verification:
| Your Feeling | Reality | Required Action |
| "This should work" | ~60% chance it works | Run \`lsp_diagnostics\` NOW |
| "I'm sure this file exists" | ~70% chance | Use \`glob\` to verify NOW |
| "The subagent did it right" | ~50% chance | Read EVERY changed file NOW |
| "No need to check this" | You DEFINITELY need to | Check it NOW |
**BEFORE claiming ANY task is complete:**
1. Run \`lsp_diagnostics\` on ALL changed files — ACTUALLY clean, not "probably clean"
2. If tests exist, run them — ACTUALLY pass, not "they should pass"
3. Read the output of every command — ACTUALLY read, not skim
4. If you delegated, read EVERY file the subagent touched — not trust their claims
</GEMINI_VERIFICATION_OVERRIDE>`;
}
export function buildGeminiIntentGateEnforcement(): string {
return `<GEMINI_INTENT_GATE_ENFORCEMENT>
## YOU MUST CLASSIFY INTENT BEFORE ACTING. NO EXCEPTIONS.
**Your failure mode: You skip intent classification and jump straight to implementation.**
You see a user message and your instinct is to immediately start working. WRONG. You MUST first determine WHAT KIND of work the user wants. Getting this wrong wastes everything that follows.
**MANDATORY FIRST OUTPUT — before ANY tool call or action:**
\`\`\`
I detect [TYPE] intent — [REASON].
My approach: [ROUTING DECISION].
\`\`\`
Where TYPE is one of: research | implementation | investigation | evaluation | fix | open-ended
**SELF-CHECK (answer honestly before proceeding):**
1. Did the user EXPLICITLY ask me to implement/build/create something? → If NO, do NOT implement.
2. Did the user say "look into", "check", "investigate", "explain"? → That means RESEARCH, not implementation.
3. Did the user ask "what do you think?" → That means EVALUATION — propose and WAIT, do not execute.
4. Did the user report an error? → That means MINIMAL FIX, not refactoring.
**COMMON MISTAKES YOU MAKE (AND MUST NOT):**
| User Says | You Want To Do | You MUST Do |
| "explain how X works" | Start modifying X | Research X, explain it, STOP |
| "look into this bug" | Fix the bug immediately | Investigate, report findings, WAIT for go-ahead |
| "what do you think about approach X?" | Implement approach X | Evaluate X, propose alternatives, WAIT |
| "improve the tests" | Rewrite all tests | Assess current tests FIRST, propose approach, THEN implement |
**IF YOU SKIPPED THE INTENT CLASSIFICATION ABOVE:** STOP. Go back. Do it now. Your next tool call is INVALID without it.
</GEMINI_INTENT_GATE_ENFORCEMENT>`;
}

View File

@@ -6,13 +6,12 @@
* *
* Routing: * Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized) * 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Gemini models (google/*, google-vertex/*) -> gemini.ts (Gemini-optimized) * 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
* 3. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/ */
import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types" import type { AgentMode } from "../types"
import { isGptModel, isGeminiModel } from "../types" import { isGptModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema" import type { AgentOverrideConfig } from "../../config/schema"
import { import {
createAgentToolRestrictions, createAgentToolRestrictions,
@@ -21,7 +20,6 @@ import {
import { buildDefaultSisyphusJuniorPrompt } from "./default" import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt" import { buildGptSisyphusJuniorPrompt } from "./gpt"
import { buildGeminiSisyphusJuniorPrompt } from "./gemini"
const MODE: AgentMode = "subagent" const MODE: AgentMode = "subagent"
@@ -34,7 +32,7 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
temperature: 0.1, temperature: 0.1,
} as const } as const
export type SisyphusJuniorPromptSource = "default" | "gpt" | "gemini" export type SisyphusJuniorPromptSource = "default" | "gpt"
/** /**
* Determines which Sisyphus-Junior prompt to use based on model. * Determines which Sisyphus-Junior prompt to use based on model.
@@ -43,9 +41,6 @@ export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPro
if (model && isGptModel(model)) { if (model && isGptModel(model)) {
return "gpt" return "gpt"
} }
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default" return "default"
} }
@@ -62,8 +57,6 @@ export function buildSisyphusJuniorPrompt(
switch (source) { switch (source) {
case "gpt": case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend) return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "gemini":
return buildGeminiSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default": case "default":
default: default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend) return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)

View File

@@ -1,191 +0,0 @@
/**
* Gemini-optimized Sisyphus-Junior System Prompt
*
* Key differences from Claude/GPT variants:
* - Aggressive tool-call enforcement (Gemini skips tools in favor of reasoning)
* - Anti-optimism checkpoints (Gemini claims "done" prematurely)
* - Repeated verification mandates (Gemini treats verification as optional)
* - Stronger scope discipline (Gemini's creativity causes scope creep)
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
export function buildGeminiSisyphusJuniorPrompt(
useTaskSystem: boolean,
promptAppend?: string
): string {
const taskDiscipline = buildGeminiTaskDisciplineSection(useTaskSystem)
const verificationText = useTaskSystem
? "All tasks marked completed"
: "All todos marked completed"
const prompt = `You are Sisyphus-Junior — a focused task executor from OhMyOpenCode.
## Identity
You execute tasks directly as a **Senior Engineer**. You do not guess. You verify. You do not stop early. You complete.
**KEEP GOING. SOLVE PROBLEMS. ASK ONLY WHEN TRULY IMPOSSIBLE.**
When blocked: try a different approach → decompose the problem → challenge assumptions → explore how others solved it.
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response that requires action MUST contain tool_use blocks. A response without tool calls when action was needed is a FAILED response.
**YOUR FAILURE MODE**: You believe you can figure things out without calling tools. You CANNOT. Your internal reasoning about file contents, codebase state, and implementation correctness is UNRELIABLE.
**RULES (VIOLATION = FAILED RESPONSE):**
1. **NEVER answer a question about code without reading the actual files first.** Read them. AGAIN.
2. **NEVER claim a task is done without running \`lsp_diagnostics\`.** Your confidence that "this should work" is wrong more often than right.
3. **NEVER reason about what a file "probably contains."** READ IT. Tool calls are cheap. Wrong answers are expensive.
4. **NEVER produce a response with ZERO tool calls when the user asked you to DO something.** Thinking is not doing.
Before responding, ask yourself: What tools do I need to call? What am I assuming that I should verify? Then ACTUALLY CALL those tools.
</TOOL_CALL_MANDATE>
### Do NOT Ask — Just Do
**FORBIDDEN:**
- "Should I proceed with X?" → JUST DO IT.
- "Do you want me to run tests?" → RUN THEM.
- "I noticed Y, should I fix it?" → FIX IT OR NOTE IN FINAL MESSAGE.
- Stopping after partial implementation → 100% OR NOTHING.
**CORRECT:**
- Keep going until COMPLETELY done
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
## Scope Discipline
- Implement EXACTLY and ONLY what is requested
- No extra features, no UX embellishments, no scope creep
- If ambiguous, choose the simplest valid interpretation OR ask ONE precise question
- Do NOT invent new requirements or expand task boundaries
- **Your creativity is an asset for IMPLEMENTATION QUALITY, not for SCOPE EXPANSION**
## Ambiguity Protocol (EXPLORE FIRST)
- **Single valid interpretation** — Proceed immediately
- **Missing info that MIGHT exist** — **EXPLORE FIRST** — use tools (grep, rg, file reads, explore agents) to find it
- **Multiple plausible interpretations** — State your interpretation, proceed with simplest approach
- **Truly impossible to proceed** — Ask ONE precise question (LAST RESORT)
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
- **DO NOT SKIP tool calls because you think you already know the answer. You DON'T.**
</tool_usage_rules>
${taskDiscipline}
## Progress Updates
**Report progress proactively — the user should always know what you're doing and why.**
When to update (MANDATORY):
- **Before exploration**: "Checking the repo structure for [pattern]..."
- **After discovery**: "Found the config in \`src/config/\`. The pattern uses factory functions."
- **Before large edits**: "About to modify [files] — [what and why]."
- **After edits**: "Updated [file] — [what changed]. Running verification."
- **On blockers**: "Hit a snag with [issue] — trying [alternative] instead."
Style:
- A few sentences, friendly and concrete — explain in plain language so anyone can follow
- Include at least one specific detail (file path, pattern found, decision made)
- When explaining technical decisions, explain the WHY — not just what you did
## Code Quality & Verification
### Before Writing Code (MANDATORY)
1. SEARCH existing codebase for similar patterns/styles
2. Match naming, indentation, import styles, error handling conventions
3. Default to ASCII. Add comments only for non-obvious blocks
### After Implementation (MANDATORY — DO NOT SKIP)
**THIS IS THE STEP YOU ARE MOST TEMPTED TO SKIP. DO NOT SKIP IT.**
Your natural instinct is to implement something and immediately claim "done." RESIST THIS.
Between implementation and completion, there is VERIFICATION. Every. Single. Time.
1. **\`lsp_diagnostics\`** on ALL modified files — zero errors required. RUN IT, don't assume.
2. **Run related tests** — pattern: modified \`foo.ts\` → look for \`foo.test.ts\`
3. **Run typecheck** if TypeScript project
4. **Run build** if applicable — exit code 0 required
5. **Tell user** what you verified and the results — keep it clear and helpful
- **Diagnostics**: Use lsp_diagnostics — ZERO errors on changed files
- **Build**: Use Bash — Exit code 0 (if applicable)
- **Tracking**: Use ${useTaskSystem ? "task_update" : "todowrite"}${verificationText}
**No evidence = not complete. "I think it works" is NOT evidence. Tool output IS evidence.**
<ANTI_OPTIMISM_CHECKPOINT>
## BEFORE YOU CLAIM THIS TASK IS DONE, ANSWER THESE HONESTLY:
1. Did I run \`lsp_diagnostics\` and see ZERO errors? (not "I'm sure there are none")
2. Did I run the tests and see them PASS? (not "they should pass")
3. Did I read the actual output of every command I ran? (not skim)
4. Is EVERY requirement from the task actually implemented? (re-read the task spec NOW)
If ANY answer is no → GO BACK AND DO IT. Do not claim completion.
</ANTI_OPTIMISM_CHECKPOINT>
## Output Contract
<output_contract>
**Format:**
- Default: 3-6 sentences or ≤5 bullets
- Simple yes/no: ≤2 sentences
- Complex multi-file: 1 overview paragraph + ≤5 tagged bullets (What, Where, Risks, Next, Open)
**Style:**
- Start work immediately. Skip empty preambles ("I'm on it", "Let me...") — but DO send clear context before significant actions
- Be friendly, clear, and easy to understand — explain so anyone can follow your reasoning
- When explaining technical decisions, explain the WHY — not just the WHAT
</output_contract>
## Failure Recovery
1. Fix root causes, not symptoms. Re-verify after EVERY attempt.
2. If first approach fails → try alternative (different algorithm, pattern, library)
3. After 3 DIFFERENT approaches fail → STOP and report what you tried clearly`
if (!promptAppend) return prompt
return prompt + "\n\n" + resolvePromptAppend(promptAppend)
}
function buildGeminiTaskDisciplineSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
return `## Task Discipline (NON-NEGOTIABLE)
**You WILL forget to track tasks if not forced. This section forces you.**
- **2+ steps** — task_create FIRST, atomic breakdown. DO THIS BEFORE ANY IMPLEMENTATION.
- **Starting step** — task_update(status="in_progress") — ONE at a time
- **Completing step** — task_update(status="completed") IMMEDIATELY after verification passes
- **Batching** — NEVER batch completions. Mark EACH task individually.
No tasks on multi-step work = INCOMPLETE WORK. The user tracks your progress through tasks.`
}
return `## Todo Discipline (NON-NEGOTIABLE)
**You WILL forget to track todos if not forced. This section forces you.**
- **2+ steps** — todowrite FIRST, atomic breakdown. DO THIS BEFORE ANY IMPLEMENTATION.
- **Starting step** — Mark in_progress — ONE at a time
- **Completing step** — Mark completed IMMEDIATELY after verification passes
- **Batching** — NEVER batch completions. Mark EACH todo individually.
No todos on multi-step work = INCOMPLETE WORK. The user tracks your progress through todos.`
}

View File

@@ -1,6 +1,5 @@
export { buildDefaultSisyphusJuniorPrompt } from "./default" export { buildDefaultSisyphusJuniorPrompt } from "./default"
export { buildGptSisyphusJuniorPrompt } from "./gpt" export { buildGptSisyphusJuniorPrompt } from "./gpt"
export { buildGeminiSisyphusJuniorPrompt } from "./gemini"
export { export {
SISYPHUS_JUNIOR_DEFAULTS, SISYPHUS_JUNIOR_DEFAULTS,

View File

@@ -1,12 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"; import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode, AgentPromptMetadata } from "./types"; import type { AgentMode, AgentPromptMetadata } from "./types";
import { isGptModel, isGeminiModel } from "./types"; import { isGptModel } from "./types";
import {
buildGeminiToolMandate,
buildGeminiDelegationOverride,
buildGeminiVerificationOverride,
buildGeminiIntentGateEnforcement,
} from "./sisyphus-gemini-overlays";
const MODE: AgentMode = "primary"; const MODE: AgentMode = "primary";
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = { export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
@@ -336,11 +330,12 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
\`\`\` \`\`\`
### Background Result Collection: ### Background Result Collection:
1. Launch parallel agents \u2192 receive task_ids 1. Launch parallel agents receive task_ids
2. Continue immediate work (explore, librarian results) 2. Continue immediate work
3. When results needed: \`background_output(task_id="...")\` 3. When results needed: \`background_output(task_id="...")\`
4. **If Oracle is running**: STOP all other output. Follow Oracle Completion Protocol in <Oracle_Usage>. 4. Before final answer, cancel DISPOSABLE tasks (explore, librarian) individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
5. Cleanup: Cancel disposable tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. Never use \`background_cancel(all=true)\`. 5. **NEVER cancel Oracle.** ALWAYS collect Oracle result via \`background_output(task_id="bg_oracle_xxx")\` before answering — even if you already have enough context.
6. **NEVER use \`background_cancel(all=true)\`** — it kills Oracle. Cancel each disposable task by its specific taskId.
### Search Stop Conditions ### Search Stop Conditions
@@ -477,9 +472,9 @@ If verification fails:
3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes." 3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes."
### Before Delivering Final Answer: ### Before Delivering Final Answer:
- **If Oracle is running**: STOP. Follow Oracle Completion Protocol in <Oracle_Usage>. Do NOT deliver any answer. - Cancel DISPOSABLE background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`
- Cancel disposable background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. - **NEVER use \`background_cancel(all=true)\`.** Always cancel individually by taskId.
- **Never use \`background_cancel(all=true)\`.** - **Always wait for Oracle**: When Oracle is running and you have gathered enough context from your own exploration, your next action is \`background_output\` on Oracle — NOT delivering a final answer. Oracle's value is highest when you think you don't need it.
</Behavior_Instructions> </Behavior_Instructions>
${oracleSection} ${oracleSection}
@@ -553,7 +548,7 @@ export function createSisyphusAgent(
const tools = availableToolNames ? categorizeTools(availableToolNames) : []; const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
const skills = availableSkills ?? []; const skills = availableSkills ?? [];
const categories = availableCategories ?? []; const categories = availableCategories ?? [];
let prompt = availableAgents const prompt = availableAgents
? buildDynamicSisyphusPrompt( ? buildDynamicSisyphusPrompt(
model, model,
availableAgents, availableAgents,
@@ -564,15 +559,6 @@ export function createSisyphusAgent(
) )
: buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem); : buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem);
if (isGeminiModel(model)) {
prompt = prompt.replace(
"</intent_verbalization>",
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
);
prompt += "\n" + buildGeminiDelegationOverride();
prompt += "\n" + buildGeminiVerificationOverride();
}
const permission = { const permission = {
question: "allow", question: "allow",
call_omo_agent: "deny", call_omo_agent: "deny",

View File

@@ -4,7 +4,6 @@ import { createLibrarianAgent } from "./librarian"
import { createExploreAgent } from "./explore" import { createExploreAgent } from "./explore"
import { createMomusAgent } from "./momus" import { createMomusAgent } from "./momus"
import { createMetisAgent } from "./metis" import { createMetisAgent } from "./metis"
import { createAtlasAgent } from "./atlas"
const TEST_MODEL = "anthropic/claude-sonnet-4-5" const TEST_MODEL = "anthropic/claude-sonnet-4-5"
@@ -97,18 +96,4 @@ describe("read-only agent tool restrictions", () => {
} }
}) })
}) })
describe("Atlas", () => {
test("allows delegation tools for orchestration", () => {
// given
const agent = createAtlasAgent({ model: TEST_MODEL })
// when
const permission = (agent.permission ?? {}) as Record<string, string>
// then
expect(permission["task"]).toBeUndefined()
expect(permission["call_omo_agent"]).toBeUndefined()
})
})
}) })

View File

@@ -1,18 +1,12 @@
import { describe, test, expect } from "bun:test"; import { describe, test, expect } from "bun:test";
import { isGptModel, isGeminiModel } from "./types"; import { isGptModel } from "./types";
describe("isGptModel", () => { describe("isGptModel", () => {
test("standard openai provider gpt models", () => { test("standard openai provider models", () => {
expect(isGptModel("openai/gpt-5.2")).toBe(true); expect(isGptModel("openai/gpt-5.2")).toBe(true);
expect(isGptModel("openai/gpt-4o")).toBe(true); expect(isGptModel("openai/gpt-4o")).toBe(true);
}); expect(isGptModel("openai/o1")).toBe(true);
expect(isGptModel("openai/o3-mini")).toBe(true);
test("o-series models are not gpt by name", () => {
expect(isGptModel("openai/o1")).toBe(false);
expect(isGptModel("openai/o3-mini")).toBe(false);
expect(isGptModel("litellm/o1")).toBe(false);
expect(isGptModel("litellm/o3-mini")).toBe(false);
expect(isGptModel("litellm/o4-mini")).toBe(false);
}); });
test("github copilot gpt models", () => { test("github copilot gpt models", () => {
@@ -23,6 +17,9 @@ describe("isGptModel", () => {
test("litellm proxied gpt models", () => { test("litellm proxied gpt models", () => {
expect(isGptModel("litellm/gpt-5.2")).toBe(true); expect(isGptModel("litellm/gpt-5.2")).toBe(true);
expect(isGptModel("litellm/gpt-4o")).toBe(true); expect(isGptModel("litellm/gpt-4o")).toBe(true);
expect(isGptModel("litellm/o1")).toBe(true);
expect(isGptModel("litellm/o3-mini")).toBe(true);
expect(isGptModel("litellm/o4-mini")).toBe(true);
}); });
test("other proxied gpt models", () => { test("other proxied gpt models", () => {
@@ -30,11 +27,6 @@ describe("isGptModel", () => {
expect(isGptModel("custom-provider/gpt-5.2")).toBe(true); expect(isGptModel("custom-provider/gpt-5.2")).toBe(true);
}); });
test("venice provider gpt models", () => {
expect(isGptModel("venice/gpt-5.2")).toBe(true);
expect(isGptModel("venice/gpt-4o")).toBe(true);
});
test("gpt4 prefix without hyphen (legacy naming)", () => { test("gpt4 prefix without hyphen (legacy naming)", () => {
expect(isGptModel("litellm/gpt4o")).toBe(true); expect(isGptModel("litellm/gpt4o")).toBe(true);
expect(isGptModel("ollama/gpt4")).toBe(true); expect(isGptModel("ollama/gpt4")).toBe(true);
@@ -55,47 +47,3 @@ describe("isGptModel", () => {
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false); expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
}); });
}); });
describe("isGeminiModel", () => {
test("#given google provider models #then returns true", () => {
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
});
test("#given google-vertex provider models #then returns true", () => {
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
});
test("#given github copilot gemini models #then returns true", () => {
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
});
test("#given litellm proxied gemini models #then returns true", () => {
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
});
test("#given other proxied gemini models #then returns true", () => {
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
});
test("#given gpt models #then returns false", () => {
expect(isGeminiModel("openai/gpt-5.2")).toBe(false);
expect(isGeminiModel("openai/o3-mini")).toBe(false);
expect(isGeminiModel("litellm/gpt-4o")).toBe(false);
});
test("#given claude models #then returns false", () => {
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
});
test("#given opencode provider #then returns false", () => {
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
});
});

View File

@@ -70,22 +70,14 @@ function extractModelName(model: string): string {
return model.includes("/") ? model.split("/").pop() ?? model : model return model.includes("/") ? model.split("/").pop() ?? model : model
} }
const GPT_MODEL_PREFIXES = ["gpt-", "gpt4", "o1", "o3", "o4"]
export function isGptModel(model: string): boolean { export function isGptModel(model: string): boolean {
const modelName = extractModelName(model).toLowerCase() if (model.startsWith("openai/") || model.startsWith("github-copilot/gpt-"))
return modelName.includes("gpt")
}
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
export function isGeminiModel(model: string): boolean {
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix)))
return true
if (model.startsWith("github-copilot/") && extractModelName(model).toLowerCase().startsWith("gemini"))
return true return true
const modelName = extractModelName(model).toLowerCase() const modelName = extractModelName(model).toLowerCase()
return modelName.startsWith("gemini-") return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
} }
export type BuiltinAgentName = export type BuiltinAgentName =

View File

@@ -589,22 +589,20 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
} }
}) })
test("hephaestus IS created when github-copilot is connected with a GPT model", async () => { test("hephaestus is created when github-copilot provider is connected", async () => {
// #given - github-copilot provider has gpt-5.3-codex available // #given - github-copilot provider has models available
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["github-copilot/gpt-5.3-codex"]) new Set(["github-copilot/gpt-5.3-codex"])
) )
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
try { try {
// #when // #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {}) const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
// #then - github-copilot is now a valid provider for hephaestus // #then
expect(agents.hephaestus).toBeDefined() expect(agents.hephaestus).toBeDefined()
} finally { } finally {
fetchSpy.mockRestore() fetchSpy.mockRestore()
cacheSpy.mockRestore()
} }
}) })

View File

@@ -1,6 +1,6 @@
# src/cli/ — CLI: install, run, doctor, mcp-oauth # src/cli/ — CLI: install, run, doctor, mcp-oauth
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -750,6 +750,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"explore": { "explore": {
"model": "github-copilot/gpt-5-mini", "model": "github-copilot/gpt-5-mini",
}, },
"hephaestus": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"librarian": { "librarian": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
}, },
@@ -782,12 +786,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "high", "variant": "high",
}, },
"deep": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gpt-5.3-codex",
"variant": "high", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
@@ -816,6 +824,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"explore": { "explore": {
"model": "github-copilot/gpt-5-mini", "model": "github-copilot/gpt-5-mini",
}, },
"hephaestus": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"librarian": { "librarian": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
}, },
@@ -848,12 +860,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "high", "variant": "high",
}, },
"deep": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gpt-5.3-codex",
"variant": "high", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
"model": "github-copilot/claude-opus-4.6", "model": "github-copilot/claude-opus-4.6",
@@ -1269,7 +1285,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "opencode/claude-haiku-4-5", "model": "opencode/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "opencode/gpt-5.3-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
@@ -1305,14 +1321,14 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"variant": "high", "variant": "high",
}, },
"deep": { "deep": {
"model": "opencode/gpt-5.3-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "opencode/gpt-5.3-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {

View File

@@ -1,6 +1,6 @@
# src/cli/config-manager/ — CLI Installation Utilities # src/cli/config-manager/ — CLI Installation Utilities
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -1,80 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../../shared/jsonc-parser"
import type { InstallConfig } from "../types"
import { resetConfigContext } from "./config-context"
import { generateOmoConfig } from "./generate-omo-config"
import { writeOmoConfig } from "./write-omo-config"
const installConfig: InstallConfig = {
hasClaude: true,
isMax20: true,
hasOpenAI: true,
hasGemini: true,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
function getRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
describe("writeOmoConfig", () => {
let testConfigDir = ""
let testConfigPath = ""
beforeEach(() => {
testConfigDir = join(tmpdir(), `omo-write-config-${Date.now()}-${Math.random().toString(36).slice(2)}`)
testConfigPath = join(testConfigDir, "oh-my-opencode.json")
mkdirSync(testConfigDir, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = testConfigDir
resetConfigContext()
})
afterEach(() => {
rmSync(testConfigDir, { recursive: true, force: true })
resetConfigContext()
delete process.env.OPENCODE_CONFIG_DIR
})
it("preserves existing user values while adding new defaults", () => {
// given
const existingConfig = {
agents: {
sisyphus: {
model: "custom/provider-model",
},
},
disabled_hooks: ["comment-checker"],
}
writeFileSync(testConfigPath, JSON.stringify(existingConfig, null, 2) + "\n", "utf-8")
const generatedDefaults = generateOmoConfig(installConfig)
// when
const result = writeOmoConfig(installConfig)
// then
expect(result.success).toBe(true)
const savedConfig = parseJsonc<Record<string, unknown>>(readFileSync(testConfigPath, "utf-8"))
const savedAgents = getRecord(savedConfig.agents)
const savedSisyphus = getRecord(savedAgents.sisyphus)
expect(savedSisyphus.model).toBe("custom/provider-model")
expect(savedConfig.disabled_hooks).toEqual(["comment-checker"])
for (const defaultKey of Object.keys(generatedDefaults)) {
expect(savedConfig).toHaveProperty(defaultKey)
}
})
})

View File

@@ -43,7 +43,7 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
return { success: true, configPath: omoConfigPath } return { success: true, configPath: omoConfigPath }
} }
const merged = deepMergeRecord(newConfig, existing) const merged = deepMergeRecord(existing, newConfig)
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n") writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) { } catch (parseErr) {
if (parseErr instanceof SyntaxError) { if (parseErr instanceof SyntaxError) {

View File

@@ -1,5 +1,4 @@
import { describe, expect, it } from "bun:test" import { afterEach, describe, expect, it, mock } from "bun:test"
import { stripAnsi } from "./format-shared"
import type { DoctorResult } from "./types" import type { DoctorResult } from "./types"
function createDoctorResult(): DoctorResult { function createDoctorResult(): DoctorResult {
@@ -40,122 +39,78 @@ function createDoctorResult(): DoctorResult {
} }
} }
function createDoctorResultWithIssues(): DoctorResult { describe("formatter", () => {
const base = createDoctorResult() afterEach(() => {
base.results[1].issues = [ mock.restore()
{ title: "Config issue", description: "Bad config", severity: "error" as const, fix: "Fix it" },
{ title: "Tool warning", description: "Missing tool", severity: "warning" as const },
]
base.summary.failed = 1
base.summary.warnings = 1
return base
}
describe("formatDoctorOutput", () => {
describe("#given default mode", () => {
it("shows System OK when no issues", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?default-ok-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "default"))
//#then
expect(output).toContain("System OK (opencode 1.0.200 · oh-my-opencode 3.4.0)")
})
it("shows issue count and details when issues exist", async () => {
//#given
const result = createDoctorResultWithIssues()
const { formatDoctorOutput } = await import(`./formatter?default-issues-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "default"))
//#then
expect(output).toContain("issues found:")
expect(output).toContain("1. Config issue")
expect(output).toContain("2. Tool warning")
})
}) })
describe("#given status mode", () => { describe("formatDoctorOutput", () => {
it("renders system version line", async () => { it("dispatches to default formatter for default mode", async () => {
//#given //#given
const result = createDoctorResult() const formatDefaultMock = mock(() => "default-output")
const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`) const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`)
//#when //#when
const output = stripAnsi(formatDoctorOutput(result, "status")) const output = formatDoctorOutput(createDoctorResult(), "default")
//#then //#then
expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0") expect(output).toBe("default-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
}) })
it("renders tool and MCP info", async () => { it("dispatches to status formatter for status mode", async () => {
//#given //#given
const result = createDoctorResult() const formatDefaultMock = mock(() => "default-output")
const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`) const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`)
//#when //#when
const output = stripAnsi(formatDoctorOutput(result, "status")) const output = formatDoctorOutput(createDoctorResult(), "status")
//#then //#then
expect(output).toContain("LSP 2/4") expect(output).toBe("status-output")
expect(output).toContain("context7") expect(formatDefaultMock).toHaveBeenCalledTimes(0)
}) expect(formatStatusMock).toHaveBeenCalledTimes(1)
}) expect(formatVerboseMock).toHaveBeenCalledTimes(0)
describe("#given verbose mode", () => {
it("includes all section headers", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?verbose-headers-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
//#then
expect(output).toContain("System Information")
expect(output).toContain("Configuration")
expect(output).toContain("Tools")
expect(output).toContain("MCPs")
expect(output).toContain("Summary")
}) })
it("shows check summary counts", async () => { it("dispatches to verbose formatter for verbose mode", async () => {
//#given //#given
const result = createDoctorResult() const formatDefaultMock = mock(() => "default-output")
const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`) const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`)
//#when //#when
const output = stripAnsi(formatDoctorOutput(result, "verbose")) const output = formatDoctorOutput(createDoctorResult(), "verbose")
//#then //#then
expect(output).toContain("1 passed") expect(output).toBe("verbose-output")
expect(output).toContain("0 failed") expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(output).toContain("1 warnings") expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
}) })
}) })
describe("formatJsonOutput", () => { describe("formatJsonOutput", () => {
it("returns valid JSON", async () => { it("returns valid JSON payload", async () => {
//#given //#given
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
const result = createDoctorResult() const result = createDoctorResult()
const { formatJsonOutput } = await import(`./formatter?json-valid-${Date.now()}`)
//#when
const output = formatJsonOutput(result)
//#then
expect(() => JSON.parse(output)).not.toThrow()
})
it("preserves all result fields", async () => {
//#given
const result = createDoctorResult()
const { formatJsonOutput } = await import(`./formatter?json-fields-${Date.now()}`)
//#when //#when
const output = formatJsonOutput(result) const output = formatJsonOutput(result)
@@ -164,6 +119,7 @@ describe("formatDoctorOutput", () => {
//#then //#then
expect(parsed.summary.total).toBe(2) expect(parsed.summary.total).toBe(2)
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0") expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
expect(parsed.tools.ghCli.username).toBe("yeongyu")
expect(parsed.exitCode).toBe(0) expect(parsed.exitCode).toBe(0)
}) })
}) })

View File

@@ -17,9 +17,9 @@ export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
}, },
hephaestus: { hephaestus: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
], ],
requiresProvider: ["openai", "opencode"], requiresProvider: ["openai", "github-copilot", "opencode"],
}, },
oracle: { oracle: {
fallbackChain: [ fallbackChain: [
@@ -100,14 +100,14 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
}, },
ultrabrain: { ultrabrain: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" }, { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
], ],
}, },
deep: { deep: {
fallbackChain: [ fallbackChain: [
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
], ],
@@ -131,7 +131,7 @@ export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> =
"unspecified-low": { "unspecified-low": {
fallbackChain: [ fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "opencode"], model: "gpt-5.3-codex", variant: "medium" }, { providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }, { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
], ],
}, },

View File

@@ -421,15 +421,16 @@ describe("generateModelConfig", () => {
expect(result.agents?.hephaestus?.variant).toBe("medium") expect(result.agents?.hephaestus?.variant).toBe("medium")
}) })
test("Hephaestus is NOT created when only Copilot is available (gpt-5.3-codex unavailable on github-copilot)", () => { test("Hephaestus is created when Copilot is available (github-copilot provider connected)", () => {
// #given // #given
const config = createConfig({ hasCopilot: true }) const config = createConfig({ hasCopilot: true })
// #when // #when
const result = generateModelConfig(config) const result = generateModelConfig(config)
// #then - hephaestus is omitted because gpt-5.3-codex is not available on github-copilot // #then
expect(result.agents?.hephaestus).toBeUndefined() expect(result.agents?.hephaestus?.model).toBe("github-copilot/gpt-5.3-codex")
expect(result.agents?.hephaestus?.variant).toBe("medium")
}) })
test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => { test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => {

View File

@@ -1,6 +1,6 @@
# src/cli/run/ — Non-Interactive Session Launcher # src/cli/run/ — Non-Interactive Session Launcher
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -1,4 +1,4 @@
const { describe, it, expect, spyOn } = require("bun:test") import { describe, it, expect, spyOn } from "bun:test"
import type { RunContext } from "./types" import type { RunContext } from "./types"
import { createEventState } from "./events" import { createEventState } from "./events"
import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers" import { handleSessionStatus, handleMessagePartUpdated, handleMessageUpdated, handleTuiToast } from "./event-handlers"
@@ -235,7 +235,9 @@ describe("handleMessagePartUpdated", () => {
it("prints completion metadata once when assistant text part is completed", () => { it("prints completion metadata once when assistant text part is completed", () => {
// given // given
const nowSpy = spyOn(Date, "now").mockReturnValue(3400) const nowSpy = spyOn(Date, "now")
nowSpy.mockReturnValueOnce(1000)
nowSpy.mockReturnValueOnce(3400)
const ctx = createMockContext("ses_main") const ctx = createMockContext("ses_main")
const state = createEventState() const state = createEventState()
@@ -257,7 +259,6 @@ describe("handleMessagePartUpdated", () => {
} as any, } as any,
state, state,
) )
state.messageStartedAtById["msg_1"] = 1000
// when // when
handleMessagePartUpdated( handleMessagePartUpdated(

View File

@@ -7,8 +7,6 @@ export interface EventState {
currentTool: string | null currentTool: string | null
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */ /** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
hasReceivedMeaningfulWork: boolean hasReceivedMeaningfulWork: boolean
/** Timestamp of the last received event (for watchdog detection) */
lastEventTimestamp: number
/** Count of assistant messages for the main session */ /** Count of assistant messages for the main session */
messageCount: number messageCount: number
/** Current agent name from the latest assistant message */ /** Current agent name from the latest assistant message */
@@ -56,7 +54,6 @@ export function createEventState(): EventState {
lastPartText: "", lastPartText: "",
currentTool: null, currentTool: null,
hasReceivedMeaningfulWork: false, hasReceivedMeaningfulWork: false,
lastEventTimestamp: Date.now(),
messageCount: 0, messageCount: 0,
currentAgent: null, currentAgent: null,
currentModel: null, currentModel: null,

View File

@@ -35,9 +35,6 @@ export async function processEvents(
logEventVerbose(ctx, payload) logEventVerbose(ctx, payload)
} }
// Update last event timestamp for watchdog detection
state.lastEventTimestamp = Date.now()
handleSessionError(ctx, payload, state) handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state) handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state) handleSessionStatus(ctx, payload, state)

View File

@@ -8,15 +8,11 @@ const DEFAULT_POLL_INTERVAL_MS = 500
const DEFAULT_REQUIRED_CONSECUTIVE = 1 const DEFAULT_REQUIRED_CONSECUTIVE = 1
const ERROR_GRACE_CYCLES = 3 const ERROR_GRACE_CYCLES = 3
const MIN_STABILIZATION_MS = 1_000 const MIN_STABILIZATION_MS = 1_000
const DEFAULT_EVENT_WATCHDOG_MS = 30_000 // 30 seconds
const DEFAULT_SECONDARY_MEANINGFUL_WORK_TIMEOUT_MS = 60_000 // 60 seconds
export interface PollOptions { export interface PollOptions {
pollIntervalMs?: number pollIntervalMs?: number
requiredConsecutive?: number requiredConsecutive?: number
minStabilizationMs?: number minStabilizationMs?: number
eventWatchdogMs?: number
secondaryMeaningfulWorkTimeoutMs?: number
} }
export async function pollForCompletion( export async function pollForCompletion(
@@ -32,15 +28,9 @@ export async function pollForCompletion(
options.minStabilizationMs ?? MIN_STABILIZATION_MS options.minStabilizationMs ?? MIN_STABILIZATION_MS
const minStabilizationMs = const minStabilizationMs =
rawMinStabilizationMs > 0 ? rawMinStabilizationMs : MIN_STABILIZATION_MS rawMinStabilizationMs > 0 ? rawMinStabilizationMs : MIN_STABILIZATION_MS
const eventWatchdogMs =
options.eventWatchdogMs ?? DEFAULT_EVENT_WATCHDOG_MS
const secondaryMeaningfulWorkTimeoutMs =
options.secondaryMeaningfulWorkTimeoutMs ??
DEFAULT_SECONDARY_MEANINGFUL_WORK_TIMEOUT_MS
let consecutiveCompleteChecks = 0 let consecutiveCompleteChecks = 0
let errorCycleCount = 0 let errorCycleCount = 0
let firstWorkTimestamp: number | null = null let firstWorkTimestamp: number | null = null
let secondaryTimeoutChecked = false
const pollStartTimestamp = Date.now() const pollStartTimestamp = Date.now()
while (!abortController.signal.aborted) { while (!abortController.signal.aborted) {
@@ -69,37 +59,7 @@ export async function pollForCompletion(
errorCycleCount = 0 errorCycleCount = 0
} }
// Watchdog: if no events received for N seconds, verify session status via API const mainSessionStatus = await getMainSessionStatus(ctx)
let mainSessionStatus: "idle" | "busy" | "retry" | null = null
if (eventState.lastEventTimestamp !== null) {
const timeSinceLastEvent = Date.now() - eventState.lastEventTimestamp
if (timeSinceLastEvent > eventWatchdogMs) {
// Events stopped coming - verify actual session state
console.log(
pc.yellow(
`\n No events for ${Math.round(
timeSinceLastEvent / 1000
)}s, verifying session status...`
)
)
// Force check session status directly
mainSessionStatus = await getMainSessionStatus(ctx)
if (mainSessionStatus === "idle") {
eventState.mainSessionIdle = true
} else if (mainSessionStatus === "busy" || mainSessionStatus === "retry") {
eventState.mainSessionIdle = false
}
// Reset timestamp to avoid repeated checks
eventState.lastEventTimestamp = Date.now()
}
}
// Only call getMainSessionStatus if watchdog didn't already check
if (mainSessionStatus === null) {
mainSessionStatus = await getMainSessionStatus(ctx)
}
if (mainSessionStatus === "busy" || mainSessionStatus === "retry") { if (mainSessionStatus === "busy" || mainSessionStatus === "retry") {
eventState.mainSessionIdle = false eventState.mainSessionIdle = false
} else if (mainSessionStatus === "idle") { } else if (mainSessionStatus === "idle") {
@@ -121,50 +81,6 @@ export async function pollForCompletion(
consecutiveCompleteChecks = 0 consecutiveCompleteChecks = 0
continue continue
} }
// Secondary timeout: if we've been polling for reasonable time but haven't
// received meaningful work via events, check if there's active work via API
// Only check once to avoid unnecessary API calls every poll cycle
if (
Date.now() - pollStartTimestamp > secondaryMeaningfulWorkTimeoutMs &&
!secondaryTimeoutChecked
) {
secondaryTimeoutChecked = true
// Check if session actually has pending work (children, todos, etc.)
const childrenRes = await ctx.client.session.children({
path: { id: ctx.sessionID },
query: { directory: ctx.directory },
})
const children = normalizeSDKResponse(childrenRes, [] as unknown[])
const todosRes = await ctx.client.session.todo({
path: { id: ctx.sessionID },
query: { directory: ctx.directory },
})
const todos = normalizeSDKResponse(todosRes, [] as unknown[])
const hasActiveChildren =
Array.isArray(children) && children.length > 0
const hasActiveTodos =
Array.isArray(todos) &&
todos.some(
(t: unknown) =>
(t as { status?: string })?.status !== "completed" &&
(t as { status?: string })?.status !== "cancelled"
)
const hasActiveWork = hasActiveChildren || hasActiveTodos
if (hasActiveWork) {
// Assume meaningful work is happening even without events
eventState.hasReceivedMeaningfulWork = true
console.log(
pc.yellow(
`\n No meaningful work events for ${Math.round(
secondaryMeaningfulWorkTimeoutMs / 1000
)}s but session has active work - assuming in progress`
)
)
}
}
} else { } else {
// Track when first meaningful work was received // Track when first meaningful work was received
if (firstWorkTimestamp === null) { if (firstWorkTimestamp === null) {

View File

@@ -31,7 +31,7 @@ export async function resolveSession(options: {
permission: [ permission: [
{ permission: "question", action: "deny" as const, pattern: "*" }, { permission: "question", action: "deny" as const, pattern: "*" },
], ],
} as Record<string, unknown>, } as any,
query: { directory }, query: { directory },
}) })

View File

@@ -1,6 +1,6 @@
# src/config/ — Zod v4 Schema System # src/config/ — Zod v4 Schema System
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -1,5 +1,18 @@
export { export {
OhMyOpenCodeConfigSchema, OhMyOpenCodeConfigSchema,
AgentOverrideConfigSchema,
AgentOverridesSchema,
McpNameSchema,
AgentNameSchema,
HookNameSchema,
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
RuntimeFallbackConfigSchema,
FallbackModelsSchema,
} from "./schema" } from "./schema"
export type { export type {

View File

@@ -47,21 +47,13 @@ export const AgentOverrideConfigSchema = z.object({
variant: z.string().optional(), variant: z.string().optional(),
}) })
.optional(), .optional(),
compaction: z
.object({
model: z.string().optional(),
variant: z.string().optional(),
})
.optional(),
}) })
export const AgentOverridesSchema = z.object({ export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(), build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(), plan: AgentOverrideConfigSchema.optional(),
sisyphus: AgentOverrideConfigSchema.optional(), sisyphus: AgentOverrideConfigSchema.optional(),
hephaestus: AgentOverrideConfigSchema.extend({ hephaestus: AgentOverrideConfigSchema.optional(),
allow_non_gpt_model: z.boolean().optional(),
}).optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(), "sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(), "OpenCode-Builder": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(), prometheus: AgentOverrideConfigSchema.optional(),

View File

@@ -20,7 +20,6 @@ export const CategoryConfigSchema = z.object({
textVerbosity: z.enum(["low", "medium", "high"]).optional(), textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(), tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(), prompt_append: z.string().optional(),
max_prompt_tokens: z.number().int().positive().optional(),
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
is_unstable_agent: z.boolean().optional(), is_unstable_agent: z.boolean().optional(),
/** Disable this category. Disabled categories are excluded from task delegation. */ /** Disable this category. Disabled categories are excluded from task delegation. */

View File

@@ -35,8 +35,6 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_tools: z.array(z.string()).optional(), disabled_tools: z.array(z.string()).optional(),
/** Enable hashline_edit tool/hook integrations (default: true at call site) */ /** Enable hashline_edit tool/hook integrations (default: true at call site) */
hashline_edit: z.boolean().optional(), hashline_edit: z.boolean().optional(),
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
model_fallback: z.boolean().optional(),
agents: AgentOverridesSchema.optional(), agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(), categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(),

View File

@@ -1,6 +1,6 @@
# src/features/ — 19 Feature Modules # src/features/ — 19 Feature Modules
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -1,6 +1,6 @@
# src/features/background-agent/ — Core Orchestration Engine # src/features/background-agent/ — Core Orchestration Engine
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -0,0 +1,40 @@
import type { BackgroundTask } from "./types"
import type { ResultHandlerContext } from "./result-handler-context"
import { log } from "../../shared"
import { notifyParentSession } from "./parent-session-notifier"
export async function tryCompleteTask(
task: BackgroundTask,
source: string,
ctx: ResultHandlerContext
): Promise<boolean> {
const { concurrencyManager, state } = ctx
if (task.status !== "running") {
log("[background-agent] Task already completed, skipping:", {
taskId: task.id,
status: task.status,
source,
})
return false
}
task.status = "completed"
task.completedAt = new Date()
if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
state.markForNotification(task)
try {
await notifyParentSession(task, ctx)
log(`[background-agent] Task completed via ${source}:`, task.id)
} catch (error) {
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error })
}
return true
}

View File

@@ -0,0 +1,14 @@
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
}
return `${seconds}s`
}

View File

@@ -1,2 +1,5 @@
export * from "./types" export * from "./types"
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager" export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
export { TaskHistory, type TaskHistoryEntry } from "./task-history"
export { ConcurrencyManager } from "./concurrency"
export { TaskStateManager } from "./state"

View File

@@ -191,10 +191,6 @@ 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 getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
return (manager as unknown as { pendingNotifications: Map<string, string[]> }).pendingNotifications
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> { function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
} }
@@ -1061,49 +1057,6 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
manager.shutdown() manager.shutdown()
}) })
test("should queue notification when promptAsync aborts while parent is idle", async () => {
//#given
const promptMock = async () => {
const error = new Error("Request aborted while waiting for input")
error.name = "MessageAbortedError"
throw error
}
const client = {
session: {
prompt: promptMock,
promptAsync: promptMock,
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-idle-queue",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task idle queue",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
expect(queuedNotifications).toHaveLength(1)
expect(queuedNotifications[0]).toContain("<system-reminder>")
expect(queuedNotifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
manager.shutdown()
})
}) })
describe("BackgroundManager.notifyParentSession - notifications toggle", () => { describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
@@ -1152,29 +1105,6 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
}) })
}) })
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
test("should prepend queued notifications to first text part and clear queue", () => {
// given
const manager = createBackgroundManager()
manager.queuePendingNotification("session-parent", "<system-reminder>queued-one</system-reminder>")
manager.queuePendingNotification("session-parent", "<system-reminder>queued-two</system-reminder>")
const output = {
parts: [{ type: "text", text: "User prompt" }],
}
// when
manager.injectPendingNotificationsIntoChatMessage(output, "session-parent")
// then
expect(output.parts[0].text).toContain("<system-reminder>queued-one</system-reminder>")
expect(output.parts[0].text).toContain("<system-reminder>queued-two</system-reminder>")
expect(output.parts[0].text).toContain("User prompt")
expect(getPendingNotifications(manager).get("session-parent")).toBeUndefined()
manager.shutdown()
})
})
function buildNotificationPromptBody( function buildNotificationPromptBody(
task: BackgroundTask, task: BackgroundTask,
currentMessage: CurrentMessage | null currentMessage: CurrentMessage | null

View File

@@ -25,6 +25,7 @@ import {
hasMoreFallbacks, hasMoreFallbacks,
} from "../../shared/model-error-classifier" } from "../../shared/model-error-classifier"
import { import {
MIN_IDLE_TIME_MS,
POLLING_INTERVAL_MS, POLLING_INTERVAL_MS,
TASK_CLEANUP_DELAY_MS, TASK_CLEANUP_DELAY_MS,
} from "./constants" } from "./constants"
@@ -42,7 +43,6 @@ import {
import { tryFallbackRetry } from "./fallback-retry-handler" import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver" import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector" import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path" import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller" import { pruneStaleTasksAndNotifications } from "./task-poller"
@@ -93,7 +93,6 @@ export class BackgroundManager {
private tasks: Map<string, BackgroundTask> private tasks: Map<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]> private notifications: Map<string, BackgroundTask[]>
private pendingNotifications: Map<string, string[]>
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
private client: OpencodeClient private client: OpencodeClient
private directory: string private directory: string
@@ -126,7 +125,6 @@ export class BackgroundManager {
) { ) {
this.tasks = new Map() this.tasks = new Map()
this.notifications = new Map() this.notifications = new Map()
this.pendingNotifications = new Map()
this.pendingByParent = new Map() this.pendingByParent = new Map()
this.client = ctx.client this.client = ctx.client
this.directory = ctx.directory this.directory = ctx.directory
@@ -270,7 +268,7 @@ export class BackgroundManager {
body: { body: {
parentID: input.parentSessionID, parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`, title: `${input.description} (@${input.agent} subagent)`,
} as Record<string, unknown>, } as any,
query: { query: {
directory: parentDirectory, directory: parentDirectory,
}, },
@@ -742,15 +740,61 @@ export class BackgroundManager {
} }
if (event.type === "session.idle") { if (event.type === "session.idle") {
if (!props || typeof props !== "object") return const sessionID = props?.sessionID as string | undefined
handleSessionIdleBackgroundEvent({ if (!sessionID) return
properties: props as Record<string, unknown>,
findBySession: (id) => this.findBySession(id), const task = this.findBySession(sessionID)
idleDeferralTimers: this.idleDeferralTimers, if (!task || task.status !== "running") return
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
checkSessionTodos: (id) => this.checkSessionTodos(id), const startedAt = task.startedAt
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source), if (!startedAt) return
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!this.idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id })
const timer = setTimeout(() => {
this.idleDeferralTimers.delete(task.id)
this.handleEvent({ type: "session.idle", properties: { sessionID } })
}, remainingMs)
this.idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
// Edge guard: Verify session has actual assistant output before completing
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
// Re-check status after async operation (could have been completed by polling)
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status })
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
// Re-check status after async operation again
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status })
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await this.tryCompleteTask(task, "session.idle event")
}).catch(err => {
log("[background-agent] Error in session.idle handler:", err)
}) })
} }
@@ -919,32 +963,6 @@ export class BackgroundManager {
this.notifications.delete(sessionID) this.notifications.delete(sessionID)
} }
queuePendingNotification(sessionID: string | undefined, notification: string): void {
if (!sessionID) return
const existingNotifications = this.pendingNotifications.get(sessionID) ?? []
existingNotifications.push(notification)
this.pendingNotifications.set(sessionID, existingNotifications)
}
injectPendingNotificationsIntoChatMessage(output: { parts: Array<{ type: string; text?: string; [key: string]: unknown }> }, sessionID: string): void {
const pendingNotifications = this.pendingNotifications.get(sessionID)
if (!pendingNotifications || pendingNotifications.length === 0) {
return
}
this.pendingNotifications.delete(sessionID)
const notificationContent = pendingNotifications.join("\n\n")
const firstTextPartIndex = output.parts.findIndex((part) => part.type === "text")
if (firstTextPartIndex === -1) {
output.parts.unshift(createInternalAgentTextPart(notificationContent))
return
}
const originalText = output.parts[firstTextPartIndex].text ?? ""
output.parts[firstTextPartIndex].text = `${notificationContent}\n\n---\n\n${originalText}`
}
/** /**
* Validates that a session has actual assistant/tool output before marking complete. * Validates that a session has actual assistant/tool output before marking complete.
* Prevents premature completion when session.idle fires before agent responds. * Prevents premature completion when session.idle fires before agent responds.
@@ -1368,7 +1386,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
taskId: task.id, taskId: task.id,
parentSessionID: task.parentSessionID, parentSessionID: task.parentSessionID,
}) })
this.queuePendingNotification(task.parentSessionID, notification)
} else { } else {
log("[background-agent] Failed to send notification:", error) log("[background-agent] Failed to send notification:", error)
} }
@@ -1597,7 +1614,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.concurrencyManager.clear() this.concurrencyManager.clear()
this.tasks.clear() this.tasks.clear()
this.notifications.clear() this.notifications.clear()
this.pendingNotifications.clear()
this.pendingByParent.clear() this.pendingByParent.clear()
this.notificationQueueByParent.clear() this.notificationQueueByParent.clear()
this.queuesByKey.clear() this.queuesByKey.clear()

View File

@@ -0,0 +1 @@
export { getMessageDir } from "../../shared"

View File

@@ -0,0 +1,81 @@
import type { OpencodeClient } from "./constants"
import type { BackgroundTask } from "./types"
import { findNearestMessageWithFields } from "../hook-message-injector"
import { getMessageDir } from "../../shared"
import { normalizePromptTools, resolveInheritedPromptTools } from "../../shared"
type AgentModel = { providerID: string; modelID: string }
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function extractAgentAndModelFromMessage(message: unknown): {
agent?: string
model?: AgentModel
tools?: Record<string, boolean>
} {
if (!isObject(message)) return {}
const info = message["info"]
if (!isObject(info)) return {}
const agent = typeof info["agent"] === "string" ? info["agent"] : undefined
const modelObj = info["model"]
const tools = normalizePromptTools(isObject(info["tools"]) ? info["tools"] as Record<string, unknown> as Record<string, boolean | "allow" | "deny" | "ask"> : undefined)
if (isObject(modelObj)) {
const providerID = modelObj["providerID"]
const modelID = modelObj["modelID"]
if (typeof providerID === "string" && typeof modelID === "string") {
return { agent, model: { providerID, modelID }, tools }
}
}
const providerID = info["providerID"]
const modelID = info["modelID"]
if (typeof providerID === "string" && typeof modelID === "string") {
return { agent, model: { providerID, modelID }, tools }
}
return { agent, tools }
}
export async function resolveParentSessionAgentAndModel(input: {
client: OpencodeClient
task: BackgroundTask
}): Promise<{ agent?: string; model?: AgentModel; tools?: Record<string, boolean> }> {
const { client, task } = input
let agent: string | undefined = task.parentAgent
let model: AgentModel | undefined
let tools: Record<string, boolean> | undefined = task.parentTools
try {
const messagesResp = await client.session.messages({
path: { id: task.parentSessionID },
})
const messagesRaw = "data" in messagesResp ? messagesResp.data : []
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
for (let i = messages.length - 1; i >= 0; i--) {
const extracted = extractAgentAndModelFromMessage(messages[i])
if (extracted.agent || extracted.model || extracted.tools) {
agent = extracted.agent ?? task.parentAgent
model = extracted.model
tools = extracted.tools ?? tools
break
}
}
} catch {
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent
model =
currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
tools = normalizePromptTools(currentMessage?.tools) ?? tools
}
return { agent, model, tools: resolveInheritedPromptTools(task.parentSessionID, tools) }
}

View File

@@ -0,0 +1,39 @@
declare const require: (name: string) => any
const { describe, test, expect } = require("bun:test")
import type { BackgroundTask } from "./types"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
describe("notifyParentSession", () => {
test("displays INTERRUPTED for interrupted tasks", () => {
// given
const task: BackgroundTask = {
id: "test-task",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
description: "Test task",
prompt: "Test prompt",
agent: "test-agent",
status: "interrupt",
startedAt: new Date(),
completedAt: new Date(),
}
const duration = "1s"
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const allComplete = false
const remainingCount = 1
const completedTasks: BackgroundTask[] = []
// when
const notification = buildBackgroundTaskNotificationText({
task,
duration,
statusText,
allComplete,
remainingCount,
completedTasks,
})
// then
expect(notification).toContain("INTERRUPTED")
})
})

View File

@@ -0,0 +1,103 @@
import type { BackgroundTask } from "./types"
import type { ResultHandlerContext } from "./result-handler-context"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { createInternalAgentTextPart, log } from "../../shared"
import { getTaskToastManager } from "../task-toast-manager"
import { formatDuration } from "./duration-formatter"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
import { resolveParentSessionAgentAndModel } from "./parent-session-context-resolver"
export async function notifyParentSession(
task: BackgroundTask,
ctx: ResultHandlerContext
): Promise<void> {
const { client, state } = ctx
const duration = formatDuration(task.startedAt ?? task.completedAt ?? new Date(), task.completedAt)
log("[background-agent] notifyParentSession called for task:", task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.showCompletionToast({
id: task.id,
description: task.description,
duration,
})
}
const pendingSet = state.pendingByParent.get(task.parentSessionID)
if (pendingSet) {
pendingSet.delete(task.id)
if (pendingSet.size === 0) {
state.pendingByParent.delete(task.parentSessionID)
}
}
const allComplete = !pendingSet || pendingSet.size === 0
const remainingCount = pendingSet?.size ?? 0
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const completedTasks = allComplete
? Array.from(state.tasks.values()).filter(
(t) =>
t.parentSessionID === task.parentSessionID &&
t.status !== "running" &&
t.status !== "pending"
)
: []
const notification = buildBackgroundTaskNotificationText({
task,
duration,
statusText,
allComplete,
remainingCount,
completedTasks,
})
const { agent, model, tools } = await resolveParentSessionAgentAndModel({ client, task })
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
resolvedAgent: agent,
resolvedModel: model,
})
try {
await client.session.promptAsync({
path: { id: task.parentSessionID },
body: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(notification)],
},
})
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
noReply: !allComplete,
})
} catch (error) {
log("[background-agent] Failed to send notification:", error)
}
if (!allComplete) return
for (const completedTask of completedTasks) {
const taskId = completedTask.id
state.clearCompletionTimer(taskId)
const timer = setTimeout(() => {
state.completionTimers.delete(taskId)
if (state.tasks.has(taskId)) {
state.clearNotificationsForTask(taskId)
state.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
}, TASK_CLEANUP_DELAY_MS)
state.setCompletionTimer(taskId, timer)
}
}

View File

@@ -0,0 +1,9 @@
import type { OpencodeClient } from "./constants"
import type { ConcurrencyManager } from "./concurrency"
import type { TaskStateManager } from "./state"
export interface ResultHandlerContext {
client: OpencodeClient
concurrencyManager: ConcurrencyManager
state: TaskStateManager
}

View File

@@ -0,0 +1,7 @@
export type { ResultHandlerContext } from "./result-handler-context"
export { formatDuration } from "./duration-formatter"
export { getMessageDir } from "../../shared"
export { checkSessionTodos } from "./session-todo-checker"
export { validateSessionHasOutput } from "./session-output-validator"
export { tryCompleteTask } from "./background-task-completer"
export { notifyParentSession } from "./parent-session-notifier"

View File

@@ -1,340 +0,0 @@
import { describe, it, expect, mock } from "bun:test"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import type { BackgroundTask } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "ses-idle-1",
parentSessionID: "parent-ses-1",
parentMessageID: "msg-1",
description: "test idle handler",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 100)),
...overrides,
}
}
describe("handleSessionIdleBackgroundEvent", () => {
describe("#given no sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: {},
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given non-string sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: 123 },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given no task found for session", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: "ses-unknown" },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task is not running", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ status: "completed" })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task has no startedAt", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ startedAt: undefined })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given elapsed time < MIN_IDLE_TIME_MS", () => {
it("#when idle fires early #then should defer with timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.has(task.id)).toBe(true)
expect(emitIdleEvent).not.toHaveBeenCalled()
} finally {
clearTimeout(idleDeferralTimers.get(task.id)!)
Date.now = realDateNow
}
})
it("#when idle already deferred #then should not create duplicate timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const existingTimer = setTimeout(() => {}, 99999)
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>([
[task.id, existingTimer],
])
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.get(task.id)).toBe(existingTimer)
} finally {
clearTimeout(existingTimer)
Date.now = realDateNow
}
})
it("#when deferred timer fires #then should emit idle event", async () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
const remainingMs = 50
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then - wait for deferred timer
await new Promise((resolve) => setTimeout(resolve, remainingMs + 50))
expect(emitIdleEvent).toHaveBeenCalledWith(task.sessionID)
expect(idleDeferralTimers.has(task.id)).toBe(false)
} finally {
Date.now = realDateNow
}
})
})
describe("#given elapsed time >= MIN_IDLE_TIME_MS", () => {
it("#when session has valid output and no incomplete todos #then should complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
})
it("#when session has no valid output #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(false),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task has incomplete todos #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(true),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during validation #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: async () => {
task.status = "completed"
return true
},
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during todo check #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: async () => {
task.status = "cancelled"
return false
},
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,89 @@
import type { OpencodeClient } from "./constants"
import { log } from "../../shared"
type SessionMessagePart = {
type?: string
text?: string
content?: unknown
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getMessageRole(message: unknown): string | undefined {
if (!isObject(message)) return undefined
const info = message["info"]
if (!isObject(info)) return undefined
const role = info["role"]
return typeof role === "string" ? role : undefined
}
function getMessageParts(message: unknown): SessionMessagePart[] {
if (!isObject(message)) return []
const parts = message["parts"]
if (!Array.isArray(parts)) return []
return parts
.filter((part): part is SessionMessagePart => isObject(part))
.map((part) => ({
type: typeof part["type"] === "string" ? part["type"] : undefined,
text: typeof part["text"] === "string" ? part["text"] : undefined,
content: part["content"],
}))
}
function partHasContent(part: SessionMessagePart): boolean {
if (part.type === "text" || part.type === "reasoning") {
return Boolean(part.text && part.text.trim().length > 0)
}
if (part.type === "tool") return true
if (part.type === "tool_result") {
if (typeof part.content === "string") return part.content.trim().length > 0
if (Array.isArray(part.content)) return part.content.length > 0
return Boolean(part.content)
}
return false
}
export async function validateSessionHasOutput(
client: OpencodeClient,
sessionID: string
): Promise<boolean> {
try {
const response = await client.session.messages({
path: { id: sessionID },
})
const messagesRaw =
isObject(response) && "data" in response ? (response as { data?: unknown }).data : response
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
const hasAssistantOrToolMessage = messages.some((message) => {
const role = getMessageRole(message)
return role === "assistant" || role === "tool"
})
if (!hasAssistantOrToolMessage) {
log("[background-agent] No assistant/tool messages found in session:", sessionID)
return false
}
const hasContent = messages.some((message) => {
const role = getMessageRole(message)
if (role !== "assistant" && role !== "tool") return false
const parts = getMessageParts(message)
return parts.some(partHasContent)
})
if (!hasContent) {
log("[background-agent] Messages exist but no content found in session:", sessionID)
return false
}
return true
} catch (error) {
log("[background-agent] Error validating session output:", error)
return true
}
}

View File

@@ -0,0 +1,46 @@
import { subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask } from "./types"
export function cleanupTaskAfterSessionEnds(args: {
task: BackgroundTask
tasks: Map<string, BackgroundTask>
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
completionTimers: Map<string, ReturnType<typeof setTimeout>>
cleanupPendingByParent: (task: BackgroundTask) => void
clearNotificationsForTask: (taskId: string) => void
releaseConcurrencyKey?: (key: string) => void
}): void {
const {
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
} = args
const completionTimer = completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
completionTimers.delete(task.id)
}
const idleTimer = idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
idleDeferralTimers.delete(task.id)
}
if (task.concurrencyKey && releaseConcurrencyKey) {
releaseConcurrencyKey(task.concurrencyKey)
task.concurrencyKey = undefined
}
cleanupPendingByParent(task)
clearNotificationsForTask(task.id)
tasks.delete(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}

View File

@@ -0,0 +1,33 @@
import type { OpencodeClient, Todo } from "./constants"
function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) return false
const todo = value as Record<string, unknown>
return (
(typeof todo["id"] === "string" || todo["id"] === undefined) &&
typeof todo["content"] === "string" &&
typeof todo["status"] === "string" &&
typeof todo["priority"] === "string"
)
}
export async function checkSessionTodos(
client: OpencodeClient,
sessionID: string
): Promise<boolean> {
try {
const response = await client.session.todo({
path: { id: sessionID },
})
const todosRaw = "data" in response ? response.data : response
if (!Array.isArray(todosRaw) || todosRaw.length === 0) return false
const incomplete = todosRaw
.filter(isTodo)
.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
return incomplete.length > 0
} catch {
return false
}
}

View File

@@ -61,7 +61,9 @@ export async function startTask(
const createResult = await client.session.create({ const createResult = await client.session.create({
body: { body: {
parentID: input.parentSessionID, parentID: input.parentSessionID,
} as Record<string, unknown>, title: `Background: ${input.description}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
query: { query: {
directory: parentDirectory, directory: parentDirectory,
}, },

View File

@@ -0,0 +1,45 @@
import type { OpencodeClient } from "../constants"
import type { ConcurrencyManager } from "../concurrency"
import type { LaunchInput } from "../types"
import { log } from "../../../shared"
export async function createBackgroundSession(options: {
client: OpencodeClient
input: LaunchInput
parentDirectory: string
concurrencyManager: ConcurrencyManager
concurrencyKey: string
}): Promise<string> {
const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options
const body = {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
}
const createResult = await client.session
.create({
body,
query: {
directory: parentDirectory,
},
})
.catch((error: unknown) => {
concurrencyManager.release(concurrencyKey)
throw error
})
if (createResult.error) {
concurrencyManager.release(concurrencyKey)
throw new Error(`Failed to create background session: ${createResult.error}`)
}
if (!createResult.data?.id) {
concurrencyManager.release(concurrencyKey)
throw new Error("Failed to create background session: API returned no session ID")
}
const sessionID = createResult.data.id
log("[background-agent] Background session created", { sessionID })
return sessionID
}

View File

@@ -0,0 +1,7 @@
import type { LaunchInput } from "../types"
export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string {
return input.model
? `${input.model.providerID}/${input.model.modelID}`
: input.agent
}

View File

@@ -0,0 +1,12 @@
import type { BackgroundTask } from "../types"
import type { ConcurrencyManager } from "../concurrency"
import type { OpencodeClient, OnSubagentSessionCreated } from "../constants"
export interface SpawnerContext {
client: OpencodeClient
directory: string
concurrencyManager: ConcurrencyManager
tmuxEnabled: boolean
onSubagentSessionCreated?: OnSubagentSessionCreated
onTaskError: (task: BackgroundTask, error: Error) => void
}

View File

@@ -0,0 +1,40 @@
import { setTimeout } from "timers/promises"
import type { OnSubagentSessionCreated } from "../constants"
import { TMUX_CALLBACK_DELAY_MS } from "../constants"
import { log } from "../../../shared"
import { isInsideTmux } from "../../../shared/tmux"
export async function maybeInvokeTmuxCallback(options: {
onSubagentSessionCreated?: OnSubagentSessionCreated
tmuxEnabled: boolean
sessionID: string
parentID: string
title: string
}): Promise<void> {
const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options
log("[background-agent] tmux callback check", {
hasCallback: !!onSubagentSessionCreated,
tmuxEnabled,
isInsideTmux: isInsideTmux(),
sessionID,
parentID,
})
if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) {
log("[background-agent] SKIP tmux callback - conditions not met")
return
}
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await onSubagentSessionCreated({
sessionID,
parentID,
title,
}).catch((error: unknown) => {
log("[background-agent] Failed to spawn tmux pane:", error)
})
log("[background-agent] tmux callback completed, waiting")
await setTimeout(TMUX_CALLBACK_DELAY_MS)
}

View File

@@ -269,71 +269,6 @@ describe("boulder-state", () => {
expect(progress.isComplete).toBe(false) expect(progress.isComplete).toBe(false)
}) })
test("should count space-indented unchecked checkbox", () => {
// given - plan file with a two-space indented checkbox
const planPath = join(TEST_DIR, "space-indented-plan.md")
writeFileSync(planPath, `# Plan
- [ ] indented task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(1)
expect(progress.completed).toBe(0)
expect(progress.isComplete).toBe(false)
})
test("should count tab-indented unchecked checkbox", () => {
// given - plan file with a tab-indented checkbox
const planPath = join(TEST_DIR, "tab-indented-plan.md")
writeFileSync(planPath, `# Plan
- [ ] tab-indented task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(1)
expect(progress.completed).toBe(0)
expect(progress.isComplete).toBe(false)
})
test("should count mixed top-level checked and indented unchecked checkboxes", () => {
// given - plan file with checked top-level and unchecked indented task
const planPath = join(TEST_DIR, "mixed-indented-plan.md")
writeFileSync(planPath, `# Plan
- [x] top-level completed task
- [ ] nested unchecked task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(2)
expect(progress.completed).toBe(1)
expect(progress.isComplete).toBe(false)
})
test("should count space-indented completed checkbox", () => {
// given - plan file with a two-space indented completed checkbox
const planPath = join(TEST_DIR, "indented-completed-plan.md")
writeFileSync(planPath, `# Plan
- [x] indented completed task
`)
// when
const progress = getPlanProgress(planPath)
// then
expect(progress.total).toBe(1)
expect(progress.completed).toBe(1)
expect(progress.isComplete).toBe(true)
})
test("should return isComplete true when all checked", () => { test("should return isComplete true when all checked", () => {
// given - all tasks completed // given - all tasks completed
const planPath = join(TEST_DIR, "complete-plan.md") const planPath = join(TEST_DIR, "complete-plan.md")

View File

@@ -121,8 +121,8 @@ export function getPlanProgress(planPath: string): PlanProgress {
const content = readFileSync(planPath, "utf-8") const content = readFileSync(planPath, "utf-8")
// Match markdown checkboxes: - [ ] or - [x] or - [X] // Match markdown checkboxes: - [ ] or - [x] or - [X]
const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || [] const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || [] const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
const total = uncheckedMatches.length + checkedMatches.length const total = uncheckedMatches.length + checkedMatches.length
const completed = checkedMatches.length const completed = checkedMatches.length
@@ -150,8 +150,7 @@ export function getPlanName(planPath: string): string {
export function createBoulderState( export function createBoulderState(
planPath: string, planPath: string,
sessionId: string, sessionId: string,
agent?: string, agent?: string
worktreePath?: string,
): BoulderState { ): BoulderState {
return { return {
active_plan: planPath, active_plan: planPath,
@@ -159,6 +158,5 @@ export function createBoulderState(
session_ids: [sessionId], session_ids: [sessionId],
plan_name: getPlanName(planPath), plan_name: getPlanName(planPath),
...(agent !== undefined ? { agent } : {}), ...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
} }
} }

View File

@@ -16,8 +16,6 @@ export interface BoulderState {
plan_name: string plan_name: string
/** Agent type to use when resuming (e.g., 'atlas') */ /** Agent type to use when resuming (e.g., 'atlas') */
agent?: string agent?: string
/** Absolute path to the git worktree root where work happens */
worktree_path?: string
} }
export interface PlanProgress { export interface PlanProgress {

View File

@@ -1,14 +1,5 @@
export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session. export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
## ARGUMENTS
- \`/start-work [plan-name] [--worktree <path>]\`
- \`plan-name\` (optional): name or partial match of the plan to start
- \`--worktree <path>\` (optional): absolute path to an existing git worktree to work in
- If specified and valid: hook pre-sets worktree_path in boulder.json
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
- If omitted: you MUST choose or create a worktree (see Worktree Setup below)
## WHAT TO DO ## WHAT TO DO
1. **Find available plans**: Search for Prometheus-generated plan files at \`.sisyphus/plans/\` 1. **Find available plans**: Search for Prometheus-generated plan files at \`.sisyphus/plans/\`
@@ -24,24 +15,17 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
- If ONE plan: auto-select it - If ONE plan: auto-select it
- If MULTIPLE plans: show list with timestamps, ask user to select - If MULTIPLE plans: show list with timestamps, ask user to select
4. **Worktree Setup** (when \`worktree_path\` not already set in boulder.json): 4. **Create/Update boulder.json**:
1. \`git worktree list --porcelain\` — see available worktrees
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
4. All work happens inside that worktree directory
5. **Create/Update boulder.json**:
\`\`\`json \`\`\`json
{ {
"active_plan": "/absolute/path/to/plan.md", "active_plan": "/absolute/path/to/plan.md",
"started_at": "ISO_TIMESTAMP", "started_at": "ISO_TIMESTAMP",
"session_ids": ["session_id_1", "session_id_2"], "session_ids": ["session_id_1", "session_id_2"],
"plan_name": "plan-name", "plan_name": "plan-name"
"worktree_path": "/absolute/path/to/git/worktree"
} }
\`\`\` \`\`\`
6. **Read the plan file** and start executing tasks according to atlas workflow 5. **Read the plan file** and start executing tasks according to atlas workflow
## OUTPUT FORMAT ## OUTPUT FORMAT
@@ -65,7 +49,6 @@ Resuming Work Session
Active Plan: {plan-name} Active Plan: {plan-name}
Progress: {completed}/{total} tasks Progress: {completed}/{total} tasks
Sessions: {count} (appending current session) Sessions: {count} (appending current session)
Worktree: {worktree_path}
Reading plan and continuing from last incomplete task... Reading plan and continuing from last incomplete task...
\`\`\` \`\`\`
@@ -77,7 +60,6 @@ Starting Work Session
Plan: {plan-name} Plan: {plan-name}
Session ID: {session_id} Session ID: {session_id}
Started: {timestamp} Started: {timestamp}
Worktree: {worktree_path}
Reading plan and beginning execution... Reading plan and beginning execution...
\`\`\` \`\`\`
@@ -86,6 +68,5 @@ Reading plan and beginning execution...
- The session_id is injected by the hook - use it directly - The session_id is injected by the hook - use it directly
- Always update boulder.json BEFORE starting work - Always update boulder.json BEFORE starting work
- Always set worktree_path in boulder.json before executing any tasks
- Read the FULL plan file before delegating any tasks - Read the FULL plan file before delegating any tasks
- Follow atlas delegation protocols (7-section format)` - Follow atlas delegation protocols (7-section format)`

View File

@@ -1,6 +1,6 @@
# src/features/claude-tasks/ — Task Schema + Storage # src/features/claude-tasks/ — Task Schema + Storage
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -0,0 +1,3 @@
export * from "./types"
export * from "./storage"
export * from "./session-storage"

View File

@@ -1,6 +1,6 @@
# src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers # src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -0,0 +1,3 @@
export * from "./schema"
export * from "./oauth-authorization-flow"
export * from "./provider"

View File

@@ -1,6 +1,6 @@
# src/features/opencode-skill-loader/ — 4-Scope Skill Discovery # src/features/opencode-skill-loader/ — 4-Scope Skill Discovery
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -4,12 +4,6 @@ import type { ConcurrencyManager } from "../background-agent/concurrency"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
type ClientWithTui = {
tui?: {
showToast: (opts: { body: { title: string; message: string; variant: string; duration: number } }) => Promise<unknown>
}
}
export class TaskToastManager { export class TaskToastManager {
private tasks: Map<string, TrackedTask> = new Map() private tasks: Map<string, TrackedTask> = new Map()
private client: OpencodeClient private client: OpencodeClient
@@ -176,7 +170,8 @@ export class TaskToastManager {
* Show consolidated toast with all running/queued tasks * Show consolidated toast with all running/queued tasks
*/ */
private showTaskListToast(newTask: TrackedTask): void { private showTaskListToast(newTask: TrackedTask): void {
const tuiClient = this.client as ClientWithTui // eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (!tuiClient.tui?.showToast) return if (!tuiClient.tui?.showToast) return
const message = this.buildTaskListMessage(newTask) const message = this.buildTaskListMessage(newTask)
@@ -201,7 +196,8 @@ export class TaskToastManager {
* Show task completion toast * Show task completion toast
*/ */
showCompletionToast(task: { id: string; description: string; duration: string }): void { showCompletionToast(task: { id: string; description: string; duration: string }): void {
const tuiClient = this.client as ClientWithTui // eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (!tuiClient.tui?.showToast) return if (!tuiClient.tui?.showToast) return
this.removeTask(task.id) this.removeTask(task.id)

View File

@@ -1,6 +1,6 @@
# src/features/tmux-subagent/ — Tmux Pane Management # src/features/tmux-subagent/ — Tmux Pane Management
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -1,14 +1,14 @@
# src/hooks/ — 46 Lifecycle Hooks # src/hooks/ — 44 Lifecycle Hooks
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW
46 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. 44 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(35) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS ## HOOK TIERS
### Tier 1: Session Hooks (23) — `create-session-hooks.ts` ### Tier 1: Session Hooks (22) — `create-session-hooks.ts`
## STRUCTURE ## STRUCTURE
``` ```
hooks/ hooks/
@@ -70,12 +70,11 @@ hooks/
| questionLabelTruncator | tool.execute.before | Truncate long question labels | | questionLabelTruncator | tool.execute.before | Truncate long question labels |
| taskResumeInfo | chat.message | Inject task context on resume | | taskResumeInfo | chat.message | Inject task context on resume |
| anthropicEffort | chat.params | Adjust reasoning effort level | | anthropicEffort | chat.params | Adjust reasoning effort level |
| modelFallback | chat.params | Provider-level model fallback on errors | | jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
| noSisyphusGpt | chat.message | Block Sisyphus from using GPT models (toast warning) | | sisyphusGptHephaestusReminder | chat.message | Toast warning when Sisyphus uses GPT model |
| noHephaestusNonGpt | chat.message | Block Hephaestus from using non-GPT models | | taskReminder | tool.execute.after | Remind about task tools after 10 turns without usage |
| runtimeFallback | event | Auto-switch models on API provider errors |
### Tier 2: Tool Guard Hooks (10) — `create-tool-guard-hooks.ts` ### Tier 2: Tool Guard Hooks (9) — `create-tool-guard-hooks.ts`
| Hook | Event | Purpose | | Hook | Event | Purpose |
|------|-------|---------| |------|-------|---------|
@@ -88,7 +87,6 @@ hooks/
| tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active | | tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active |
| writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files | | writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files |
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes | | hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
| jsonErrorRecovery | tool.execute.after | Detect JSON parse errors, inject correction reminder |
### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts` ### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts`

View File

@@ -1,6 +1,6 @@
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery # src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -1,5 +1,4 @@
import type { AutoCompactState } from "./types"; import type { AutoCompactState } from "./types";
import type { OhMyOpenCodeConfig } from "../../config";
import type { ExperimentalConfig } from "../../config"; import type { ExperimentalConfig } from "../../config";
import { TRUNCATE_CONFIG } from "./types"; import { TRUNCATE_CONFIG } from "./types";
@@ -16,15 +15,15 @@ export async function executeCompact(
sessionID: string, sessionID: string,
msg: Record<string, unknown>, msg: Record<string, unknown>,
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
client: Client, // eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string, directory: string,
pluginConfig: OhMyOpenCodeConfig, experimental?: ExperimentalConfig,
_experimental?: ExperimentalConfig
): Promise<void> { ): Promise<void> {
void _experimental void experimental
if (autoCompactState.compactionInProgress.has(sessionID)) { if (autoCompactState.compactionInProgress.has(sessionID)) {
await client.tui await (client as Client).tui
.showToast({ .showToast({
body: { body: {
title: "Compact In Progress", title: "Compact In Progress",
@@ -56,7 +55,7 @@ export async function executeCompact(
const result = await runAggressiveTruncationStrategy({ const result = await runAggressiveTruncationStrategy({
sessionID, sessionID,
autoCompactState, autoCompactState,
client: client, client: client as Client,
directory, directory,
truncateAttempt: truncateState.truncateAttempt, truncateAttempt: truncateState.truncateAttempt,
currentTokens: errorData.currentTokens, currentTokens: errorData.currentTokens,
@@ -71,9 +70,8 @@ export async function executeCompact(
sessionID, sessionID,
msg, msg,
autoCompactState, autoCompactState,
client: client, client: client as Client,
directory, directory,
pluginConfig,
errorType: errorData?.errorType, errorType: errorData?.errorType,
messageIndex: errorData?.messageIndex, messageIndex: errorData?.messageIndex,
}) })

View File

@@ -1,7 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { Client } from "./client"
import type { AutoCompactState, ParsedTokenLimitError } from "./types" import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config" import type { ExperimentalConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser" import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor" import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery" import { attemptDeduplicationRecovery } from "./deduplication-recovery"
@@ -9,7 +8,6 @@ import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions { export interface AnthropicContextWindowLimitRecoveryOptions {
experimental?: ExperimentalConfig experimental?: ExperimentalConfig
pluginConfig: OhMyOpenCodeConfig
} }
function createRecoveryState(): AutoCompactState { function createRecoveryState(): AutoCompactState {
@@ -30,7 +28,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(
) { ) {
const autoCompactState = createRecoveryState() const autoCompactState = createRecoveryState()
const experimental = options?.experimental const experimental = options?.experimental
const pluginConfig = options?.pluginConfig!
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>() 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 } }) => {
@@ -92,9 +89,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID, sessionID,
{ providerID, modelID }, { providerID, modelID },
autoCompactState, autoCompactState,
ctx.client as Client, ctx.client,
ctx.directory, ctx.directory,
pluginConfig,
experimental, experimental,
) )
}, 300) }, 300)
@@ -160,9 +156,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID, sessionID,
{ providerID, modelID }, { providerID, modelID },
autoCompactState, autoCompactState,
ctx.client as Client, ctx.client,
ctx.directory, ctx.directory,
pluginConfig,
experimental, experimental,
) )
} }

View File

@@ -6,7 +6,7 @@ export function getOrCreateRetryState(
): RetryState { ): RetryState {
let state = autoCompactState.retryStateBySession.get(sessionID) let state = autoCompactState.retryStateBySession.get(sessionID)
if (!state) { if (!state) {
state = { attempt: 0, lastAttemptTime: 0, firstAttemptTime: 0 } state = { attempt: 0, lastAttemptTime: 0 }
autoCompactState.retryStateBySession.set(sessionID, state) autoCompactState.retryStateBySession.set(sessionID, state)
} }
return state return state

View File

@@ -1,122 +0,0 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { runSummarizeRetryStrategy } from "./summarize-retry-strategy"
import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
type TimeoutCall = {
delay: number
}
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map<string, RetryState>(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
}
describe("runSummarizeRetryStrategy", () => {
const sessionID = "ses_retry_timeout"
const directory = "/tmp"
let autoCompactState: AutoCompactState
const summarizeMock = mock(() => Promise.resolve())
const showToastMock = mock(() => Promise.resolve())
const client = {
session: {
summarize: summarizeMock,
messages: mock(() => Promise.resolve({ data: [] })),
promptAsync: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
},
tui: {
showToast: showToastMock,
},
}
beforeEach(() => {
autoCompactState = createAutoCompactState()
summarizeMock.mockReset()
showToastMock.mockReset()
summarizeMock.mockResolvedValue(undefined)
showToastMock.mockResolvedValue(undefined)
})
afterEach(() => {
globalThis.setTimeout = originalSetTimeout
})
const originalSetTimeout = globalThis.setTimeout
test("stops retries when total summarize timeout is exceeded", async () => {
//#given
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, {
currentTokens: 250000,
maxTokens: 200000,
errorType: "token_limit_exceeded",
})
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 1,
lastAttemptTime: Date.now(),
firstAttemptTime: Date.now() - 130000,
})
//#when
await runSummarizeRetryStrategy({
sessionID,
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
autoCompactState,
client: client as never,
directory,
pluginConfig: {} as OhMyOpenCodeConfig,
})
//#then
expect(summarizeMock).not.toHaveBeenCalled()
expect(autoCompactState.pendingCompact.has(sessionID)).toBe(false)
expect(autoCompactState.errorDataBySession.has(sessionID)).toBe(false)
expect(autoCompactState.retryStateBySession.has(sessionID)).toBe(false)
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
title: "Auto Compact Timed Out",
}),
}),
)
})
test("caps retry delay by remaining total timeout window", async () => {
//#given
const timeoutCalls: TimeoutCall[] = []
globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => {
timeoutCalls.push({ delay: delay ?? 0 })
return 1 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 1,
lastAttemptTime: Date.now(),
firstAttemptTime: Date.now() - 119700,
})
summarizeMock.mockRejectedValueOnce(new Error("rate limited"))
//#when
await runSummarizeRetryStrategy({
sessionID,
msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
autoCompactState,
client: client as never,
directory,
pluginConfig: {} as OhMyOpenCodeConfig,
})
//#then
expect(timeoutCalls.length).toBe(1)
expect(timeoutCalls[0]!.delay).toBeGreaterThan(0)
expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(500)
})
})

View File

@@ -1,46 +1,20 @@
import type { AutoCompactState } from "./types" import type { AutoCompactState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
import { RETRY_CONFIG } from "./types" import { RETRY_CONFIG } from "./types"
import type { Client } from "./client" import type { Client } from "./client"
import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state" import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state"
import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder" import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
import { fixEmptyMessages } from "./empty-content-recovery" import { fixEmptyMessages } from "./empty-content-recovery"
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000
export async function runSummarizeRetryStrategy(params: { export async function runSummarizeRetryStrategy(params: {
sessionID: string sessionID: string
msg: Record<string, unknown> msg: Record<string, unknown>
autoCompactState: AutoCompactState autoCompactState: AutoCompactState
client: Client client: Client
directory: string directory: string
pluginConfig: OhMyOpenCodeConfig
errorType?: string errorType?: string
messageIndex?: number messageIndex?: number
}): Promise<void> { }): Promise<void> {
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID) const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
const now = Date.now()
if (retryState.firstAttemptTime === 0) {
retryState.firstAttemptTime = now
}
const elapsedTimeMs = now - retryState.firstAttemptTime
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
if (params.errorType?.includes("non-empty content")) { if (params.errorType?.includes("non-empty content")) {
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID) const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
@@ -75,7 +49,6 @@ export async function runSummarizeRetryStrategy(params: {
if (Date.now() - retryState.lastAttemptTime > 300000) { if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0 retryState.attempt = 0
retryState.firstAttemptTime = Date.now()
params.autoCompactState.truncateStateBySession.delete(params.sessionID) params.autoCompactState.truncateStateBySession.delete(params.sessionID)
} }
@@ -101,14 +74,7 @@ export async function runSummarizeRetryStrategy(params: {
}) })
.catch(() => {}) .catch(() => {})
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel( const summarizeBody = { providerID, modelID, auto: true }
params.pluginConfig,
params.sessionID,
providerID,
modelID
)
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
await params.client.session.summarize({ await params.client.session.summarize({
path: { id: params.sessionID }, path: { id: params.sessionID },
body: summarizeBody as never, body: summarizeBody as never,
@@ -116,26 +82,10 @@ export async function runSummarizeRetryStrategy(params: {
}) })
return return
} catch { } catch {
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
if (remainingTimeMs <= 0) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
const delay = const delay =
RETRY_CONFIG.initialDelayMs * RETRY_CONFIG.initialDelayMs *
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1) Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs) const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
setTimeout(() => { setTimeout(() => {
void runSummarizeRetryStrategy(params) void runSummarizeRetryStrategy(params)

View File

@@ -11,7 +11,6 @@ export interface ParsedTokenLimitError {
export interface RetryState { export interface RetryState {
attempt: number attempt: number
lastAttemptTime: number lastAttemptTime: number
firstAttemptTime: number
} }
export interface TruncateState { export interface TruncateState {

View File

@@ -1,6 +1,6 @@
# src/hooks/atlas/ — Master Boulder Orchestrator # src/hooks/atlas/ — Master Boulder Orchestrator
**Generated:** 2026-02-24 **Generated:** 2026-02-21
## OVERVIEW ## OVERVIEW

View File

@@ -14,7 +14,6 @@ export async function injectBoulderContinuation(input: {
remaining: number remaining: number
total: number total: number
agent?: string agent?: string
worktreePath?: string
backgroundManager?: BackgroundManager backgroundManager?: BackgroundManager
sessionState: SessionState sessionState: SessionState
}): Promise<void> { }): Promise<void> {
@@ -25,7 +24,6 @@ export async function injectBoulderContinuation(input: {
remaining, remaining,
total, total,
agent, agent,
worktreePath,
backgroundManager, backgroundManager,
sessionState, sessionState,
} = input } = input
@@ -39,11 +37,9 @@ export async function injectBoulderContinuation(input: {
return return
} }
const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
const prompt = const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) + BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]`
worktreeContext
try { try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
@@ -66,7 +62,6 @@ export async function injectBoulderContinuation(input: {
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} catch (err) { } catch (err) {
sessionState.promptFailureCount += 1 sessionState.promptFailureCount += 1
sessionState.lastFailureAt = Date.now()
log(`[${HOOK_NAME}] Boulder continuation failed`, { log(`[${HOOK_NAME}] Boulder continuation failed`, {
sessionID, sessionID,
error: String(err), error: String(err),

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