Compare commits
23 Commits
fix/1694-f
...
v3.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538aba0d0f | ||
|
|
97f7540600 | ||
|
|
462e2ec2b0 | ||
|
|
9acdd6b85d | ||
|
|
1fb6a7cc80 | ||
|
|
d3b79064c6 | ||
|
|
744dee70e9 | ||
|
|
0265fa6990 | ||
|
|
7e1293d273 | ||
|
|
e3342dcd4a | ||
|
|
764abb2a4b | ||
|
|
f8e58efeb4 | ||
|
|
fba06868dd | ||
|
|
c51994c791 | ||
|
|
3facf9fac3 | ||
|
|
aac79f03b5 | ||
|
|
5a8e424c8e | ||
|
|
d786691260 | ||
|
|
363016681b | ||
|
|
b444899153 | ||
|
|
b1e7bb4c59 | ||
|
|
8e115c7f9d | ||
|
|
fe5d341208 |
361
AGENTS.md
361
AGENTS.md
@@ -1,320 +1,119 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
# oh-my-opencode — OpenCode Plugin
|
||||
|
||||
**Generated:** 2026-02-16T14:58:00+09:00
|
||||
**Commit:** 28cd34c3
|
||||
**Branch:** fuck-v1.2
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: PULL REQUEST TARGET BRANCH (NEVER DELETE THIS SECTION)
|
||||
|
||||
> **THIS SECTION MUST NEVER BE REMOVED OR MODIFIED**
|
||||
|
||||
### Git Workflow
|
||||
|
||||
```
|
||||
master (deployed/published)
|
||||
↑
|
||||
dev (integration branch)
|
||||
↑
|
||||
feature branches (your work)
|
||||
```
|
||||
|
||||
### Rules (MANDATORY)
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| **ALL PRs → `dev`** | Every pull request MUST target the `dev` branch |
|
||||
| **NEVER PR → `master`** | PRs to `master` are **automatically rejected** by CI |
|
||||
| **"Create a PR" = target `dev`** | When asked to create a new PR, it ALWAYS means targeting `dev` |
|
||||
| **Merge commit ONLY** | Squash merge is **disabled** in this repo. Always use merge commit when merging PRs. |
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- `master` = production/published npm package
|
||||
- `dev` = integration branch where features are merged and tested
|
||||
- Feature branches → `dev` → (after testing) → `master`
|
||||
- Squash merge is disabled at the repository level — attempting it will fail
|
||||
|
||||
**If you create a PR targeting `master`, it WILL be rejected. No exceptions.**
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: OPENCODE SOURCE CODE REFERENCE (NEVER DELETE THIS SECTION)
|
||||
|
||||
> **THIS SECTION MUST NEVER BE REMOVED OR MODIFIED**
|
||||
|
||||
### This is an OpenCode Plugin
|
||||
|
||||
Oh-My-OpenCode is a **plugin for OpenCode**. You will frequently need to examine OpenCode's source code to:
|
||||
- Understand plugin APIs and hooks
|
||||
- Debug integration issues
|
||||
- Implement features that interact with OpenCode internals
|
||||
- Answer questions about how OpenCode works
|
||||
|
||||
### How to Access OpenCode Source Code
|
||||
|
||||
**When you need to examine OpenCode source:**
|
||||
|
||||
1. **Clone to system temp directory:**
|
||||
```bash
|
||||
git clone https://github.com/sst/opencode /tmp/opencode-source
|
||||
```
|
||||
|
||||
2. **Explore the codebase** from there (do NOT clone into the project directory)
|
||||
|
||||
3. **Clean up** when done (optional, temp dirs are ephemeral)
|
||||
|
||||
### Librarian Agent: YOUR PRIMARY TOOL for Plugin Work
|
||||
|
||||
**CRITICAL**: When working on plugin-related tasks or answering plugin questions:
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Implementing new hooks | Fire `librarian` to search OpenCode hook implementations |
|
||||
| Adding new tools | Fire `librarian` to find OpenCode tool patterns |
|
||||
| Understanding SDK behavior | Fire `librarian` to examine OpenCode SDK source |
|
||||
| Debugging plugin issues | Fire `librarian` to find relevant OpenCode internals |
|
||||
| Answering "how does OpenCode do X?" | Fire `librarian` FIRST |
|
||||
|
||||
**DO NOT guess or hallucinate about OpenCode internals.** Always verify by examining actual source code via `librarian` or direct clone.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: ENGLISH-ONLY POLICY (NEVER DELETE THIS SECTION)
|
||||
|
||||
> **THIS SECTION MUST NEVER BE REMOVED OR MODIFIED**
|
||||
|
||||
### All Project Communications MUST Be in English
|
||||
|
||||
| Context | Language Requirement |
|
||||
|---------|---------------------|
|
||||
| **GitHub Issues** | English ONLY |
|
||||
| **Pull Requests** | English ONLY (title, description, comments) |
|
||||
| **Commit Messages** | English ONLY |
|
||||
| **Code Comments** | English ONLY |
|
||||
| **Documentation** | English ONLY |
|
||||
| **AGENTS.md files** | English ONLY |
|
||||
|
||||
**If you're not comfortable writing in English, use translation tools. Broken English is fine. Non-English is not acceptable.**
|
||||
|
||||
---
|
||||
**Generated:** 2026-02-17 | **Commit:** aac79f03 | **Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin (oh-my-opencode): multi-model agent orchestration with 11 specialized agents, 41 lifecycle hooks across 7 event types, 26 tools (LSP, AST-Grep, delegation, task management), full Claude Code compatibility layer, 4-scope skill loading, background agent concurrency, tmux integration, and 3-tier MCP system. "oh-my-zsh" for OpenCode.
|
||||
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 41 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1164 TypeScript files, 133k LOC.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # 11 AI agents — see src/agents/AGENTS.md
|
||||
│ ├── hooks/ # 41 lifecycle hooks — see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # 26 tools — see src/tools/AGENTS.md
|
||||
│ ├── features/ # Background agents, skills, CC compat — see src/features/AGENTS.md
|
||||
│ ├── shared/ # Cross-cutting utilities — see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor — see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # Built-in MCPs — see src/mcp/AGENTS.md
|
||||
│ ├── config/ # Zod schema — see src/config/AGENTS.md
|
||||
│ ├── plugin-handlers/ # Config loading pipeline — see src/plugin-handlers/AGENTS.md
|
||||
│ ├── plugin/ # Plugin interface composition (21 files)
|
||||
│ ├── index.ts # Main plugin entry (106 lines)
|
||||
│ ├── create-hooks.ts # Hook creation coordination (62 lines)
|
||||
│ ├── create-managers.ts # Manager initialization (80 lines)
|
||||
│ ├── create-tools.ts # Tool registry composition (54 lines)
|
||||
│ ├── plugin-interface.ts # Plugin interface assembly (66 lines)
|
||||
│ ├── plugin-config.ts # Config loading orchestration (180 lines)
|
||||
│ └── plugin-state.ts # Model cache state (12 lines)
|
||||
├── script/ # build-schema.ts, build-binaries.ts, publish.ts, generate-changelog.ts
|
||||
├── packages/ # 11 platform-specific binary packages
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
|
||||
│ ├── 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)
|
||||
│ ├── hooks/ # 41 hooks across 37 directories + 6 standalone files
|
||||
│ ├── tools/ # 26 tools across 15 directories
|
||||
│ ├── features/ # 18 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.)
|
||||
│ ├── shared/ # 101 utility files in 13 categories
|
||||
│ ├── config/ # Zod v4 schema system (22 files)
|
||||
│ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js)
|
||||
│ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app)
|
||||
│ ├── plugin/ # 8 OpenCode hook handlers + 41 hook composition
|
||||
│ └── plugin-handlers/ # 6-phase config loading pipeline
|
||||
├── packages/ # Monorepo: comment-checker, opencode-sdk
|
||||
└── local-ignore/ # Dev-only test fixtures
|
||||
```
|
||||
|
||||
## INITIALIZATION FLOW
|
||||
|
||||
```
|
||||
OhMyOpenCodePlugin(ctx)
|
||||
1. injectServerAuthIntoClient(ctx.client)
|
||||
2. startTmuxCheck()
|
||||
3. loadPluginConfig(ctx.directory, ctx) → OhMyOpenCodeConfig
|
||||
4. createFirstMessageVariantGate()
|
||||
5. createModelCacheState()
|
||||
6. createManagers(ctx, config, tmux, cache) → TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
||||
7. createTools(ctx, config, managers) → filteredTools, mergedSkills, availableSkills, availableCategories
|
||||
8. createHooks(ctx, config, backgroundMgr) → 41 hooks (core + continuation + skill)
|
||||
9. createPluginInterface(...) → 7 OpenCode hook handlers
|
||||
10. Return plugin with experimental.session.compacting
|
||||
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
|
||||
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
|
||||
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
|
||||
├─→ createHooks() # 3-tier: Core(32) + Continuation(7) + Skill(2) = 41 hooks
|
||||
└─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface
|
||||
```
|
||||
|
||||
## 8 OPENCODE HOOK HANDLERS
|
||||
|
||||
| Handler | Purpose |
|
||||
|---------|---------|
|
||||
| `config` | 6-phase: provider → plugin-components → agents → tools → MCPs → commands |
|
||||
| `tool` | 26 registered tools |
|
||||
| `chat.message` | First-message variant, session setup, keyword detection |
|
||||
| `chat.params` | Anthropic effort level adjustment |
|
||||
| `event` | Session lifecycle (created, deleted, idle, error) |
|
||||
| `tool.execute.before` | Pre-tool hooks (file guard, label truncator, rules injector) |
|
||||
| `tool.execute.after` | Post-tool hooks (output truncation, metadata store) |
|
||||
| `experimental.chat.messages.transform` | Context injection, thinking block validation |
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` in builtin-agents/ |
|
||||
| Add hook | `src/hooks/` | Create dir, register in `src/plugin/hooks/create-*-hooks.ts` |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts |
|
||||
| Add MCP | `src/mcp/` | Create config, add to `createBuiltinMcps()` |
|
||||
| Add skill | `src/features/builtin-skills/` | Create .ts in skills/ |
|
||||
| Add command | `src/features/builtin-commands/` | Add template + register in commands.ts |
|
||||
| Config schema | `src/config/schema/` | 21 schema component files, run `bun run build:schema` |
|
||||
| Plugin config | `src/plugin-handlers/config-handler.ts` | JSONC loading, merging, migration |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts (1701 lines) |
|
||||
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (1976 lines) |
|
||||
| Delegation | `src/tools/delegate-task/` | Category routing (constants.ts 569 lines) |
|
||||
| Task system | `src/features/claude-tasks/` | Task schema, storage, todo sync |
|
||||
| Plugin interface | `src/plugin/` | 21 files composing hooks, handlers, registries |
|
||||
| Add new agent | `src/agents/` + `src/agents/builtin-agents/` | Follow createXXXAgent factory pattern |
|
||||
| Add new hook | `src/hooks/{name}/` + register in `src/plugin/hooks/create-*-hooks.ts` | Match event type to tier |
|
||||
| Add new tool | `src/tools/{name}/` + register in `src/plugin/tool-registry.ts` | Follow createXXXTool factory |
|
||||
| Add new feature module | `src/features/{name}/` | Standalone module, wire in plugin/ |
|
||||
| Add new MCP | `src/mcp/` + register in `createBuiltinMcps()` | Remote HTTP only |
|
||||
| Add new skill | `src/features/builtin-skills/skills/` | Implement BuiltinSkill interface |
|
||||
| Add new command | `src/features/builtin-commands/` | Template in templates/ |
|
||||
| Add new CLI command | `src/cli/cli-program.ts` | Commander.js subcommand |
|
||||
| Add new doctor check | `src/cli/doctor/checks/` | Register in checks/index.ts |
|
||||
| Modify config schema | `src/config/schema/` + update root schema | Zod v4, add to OhMyOpenCodeConfigSchema |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
## MULTI-LEVEL CONFIG
|
||||
|
||||
**MANDATORY.** RED-GREEN-REFACTOR:
|
||||
1. **RED**: Write test → `bun test` → FAIL
|
||||
2. **GREEN**: Implement minimum → PASS
|
||||
3. **REFACTOR**: Clean up → stay GREEN
|
||||
```
|
||||
Project (.opencode/oh-my-opencode.jsonc) → User (~/.config/opencode/oh-my-opencode.jsonc) → Defaults
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- NEVER write implementation before test
|
||||
- NEVER delete failing tests — fix the code
|
||||
- Test file: `*.test.ts` alongside source (176 test files)
|
||||
- BDD comments: `//#given`, `//#when`, `//#then`
|
||||
Fields: agents (14 overridable), categories (8 built-in + custom), disabled_* arrays, 19 feature-specific configs.
|
||||
|
||||
## THREE-TIER MCP SYSTEM
|
||||
|
||||
| Tier | Source | Mechanism |
|
||||
|------|--------|-----------|
|
||||
| Built-in | `src/mcp/` | 3 remote HTTP: websearch (Exa/Tavily), context7, grep_app |
|
||||
| Claude Code | `.mcp.json` | `${VAR}` env expansion via claude-code-mcp-loader |
|
||||
| Skill-embedded | SKILL.md YAML | Managed by SkillMcpManager (stdio + HTTP) |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Package manager**: Bun only (`bun run`, `bun build`, `bunx`)
|
||||
- **Types**: bun-types (NEVER @types/node)
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern via index.ts
|
||||
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
|
||||
- **Testing**: BDD comments, 176 test files, 1130 TypeScript files
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
- **Modular architecture**: 200 LOC hard limit per file (prompt strings exempt)
|
||||
- **Test pattern**: Vitest, co-located `*.test.ts`, given/when/then style
|
||||
- **Factory pattern**: `createXXX()` for all tools, hooks, agents
|
||||
- **Hook tiers**: Session (19) → Tool-Guard (9) → Transform (4) → Continuation (7) → Skill (2)
|
||||
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
|
||||
- **Model resolution**: 3-step: override → category-default → provider-fallback → system-default
|
||||
- **Config format**: JSONC with comments, Zod v4 validation, snake_case keys
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
| Category | Forbidden |
|
||||
|----------|-----------|
|
||||
| Package Manager | npm, yarn — Bun exclusively |
|
||||
| Types | @types/node — use bun-types |
|
||||
| File Ops | mkdir/touch/rm/cp/mv in code — use bash tool |
|
||||
| Publishing | Direct `bun publish` — GitHub Actions only |
|
||||
| Versioning | Local version bump — CI manages |
|
||||
| Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` |
|
||||
| Error Handling | Empty catch blocks |
|
||||
| Testing | Deleting failing tests, writing implementation before test |
|
||||
| Agent Calls | Sequential — use `task` parallel |
|
||||
| Hook Logic | Heavy PreToolUse — slows every call |
|
||||
| Commits | Giant (3+ files), separate test from impl |
|
||||
| Temperature | >0.3 for code agents |
|
||||
| Trust | Agent self-reports — ALWAYS verify |
|
||||
| Git | `git add -i`, `git rebase -i` (no interactive input) |
|
||||
| Git | Skip hooks (--no-verify), force push without request |
|
||||
| Bash | `sleep N` — use conditional waits |
|
||||
| Bash | `cd dir && cmd` — use workdir parameter |
|
||||
| Files | Catch-all utils.ts/helpers.ts — name by purpose |
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Model | Temp | Purpose |
|
||||
|-------|-------|------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-6 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
|
||||
| Hephaestus | openai/gpt-5.3-codex | 0.1 | Autonomous deep worker (NO fallback) |
|
||||
| Atlas | anthropic/claude-sonnet-4-5 | 0.1 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Prometheus | anthropic/claude-opus-4-6 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging (fallback: claude-opus-4-6) |
|
||||
| librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) |
|
||||
| explore | xai/grok-code-fast-1 | 0.1 | Fast codebase grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
|
||||
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
|
||||
| Metis | anthropic/claude-opus-4-6 | 0.3 | Pre-planning analysis (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-6) |
|
||||
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
|
||||
|
||||
## OPENCODE PLUGIN API
|
||||
|
||||
Plugin SDK from `@opencode-ai/plugin`. Plugin = `async (PluginInput) => Hooks`.
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `tool` | Register custom tools (Record<string, ToolDefinition>) |
|
||||
| `chat.message` | Intercept user messages (can modify parts) |
|
||||
| `chat.params` | Modify LLM parameters (temperature, topP, options) |
|
||||
| `tool.execute.before` | Pre-tool interception (can modify args) |
|
||||
| `tool.execute.after` | Post-tool processing (can modify output) |
|
||||
| `event` | Session lifecycle events (session.created, session.stop, etc.) |
|
||||
| `config` | Config modification (register agents, MCPs, commands) |
|
||||
| `experimental.chat.messages.transform` | Transform message history |
|
||||
| `experimental.session.compacting` | Session compaction customization |
|
||||
|
||||
## DEPENDENCIES
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@opencode-ai/plugin` + `sdk` | OpenCode integration SDK |
|
||||
| `@ast-grep/cli` + `napi` | AST pattern matching (search/replace) |
|
||||
| `@code-yeongyu/comment-checker` | AI comment detection/prevention |
|
||||
| `@modelcontextprotocol/sdk` | MCP client for remote HTTP servers |
|
||||
| `@clack/prompts` | Interactive CLI TUI |
|
||||
| `commander` | CLI argument parsing |
|
||||
| `zod` (v4) | Schema validation for config |
|
||||
| `jsonc-parser` | JSONC config with comments |
|
||||
| `picocolors` | Terminal colors |
|
||||
| `picomatch` | Glob pattern matching |
|
||||
| `vscode-jsonrpc` | LSP communication |
|
||||
| `js-yaml` | YAML parsing (tasks, skills) |
|
||||
| `detect-libc` | Platform binary selection |
|
||||
- Never use `as any`, `@ts-ignore`, `@ts-expect-error`
|
||||
- Never suppress lint/type errors
|
||||
- Never add emojis to code/comments unless user explicitly asks
|
||||
- Never commit unless explicitly requested
|
||||
- Test: given/when/then — never use Arrange-Act-Assert comments
|
||||
- Comments: avoid AI-generated comment patterns (enforced by comment-checker hook)
|
||||
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
bun run typecheck # Type check
|
||||
bun run build # ESM + declarations + schema
|
||||
bun run rebuild # Clean + Build
|
||||
bun test # 176 test files
|
||||
bun run build:schema # Regenerate JSON schema
|
||||
bun test # Vitest test suite
|
||||
bun run build # Build plugin
|
||||
bunx oh-my-opencode install # Interactive setup
|
||||
bunx oh-my-opencode doctor # Health diagnostics
|
||||
bunx oh-my-opencode run # Non-interactive session
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
**GitHub Actions workflow_dispatch ONLY**
|
||||
1. Commit & push changes
|
||||
2. Trigger: `gh workflow run publish -f bump=patch`
|
||||
3. Never `bun publish` directly, never bump version locally
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/features/background-agent/manager.ts` | 1701 | Task lifecycle, concurrency |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/` | 2232 | Multi-strategy context recovery |
|
||||
| `src/hooks/claude-code-hooks/` | 2110 | Claude Code settings.json compat |
|
||||
| `src/hooks/todo-continuation-enforcer/` | 2061 | Core boulder mechanism |
|
||||
| `src/hooks/atlas/` | 1976 | Session orchestration |
|
||||
| `src/hooks/ralph-loop/` | 1687 | Self-referential dev loop |
|
||||
| `src/hooks/keyword-detector/` | 1665 | Mode detection (ultrawork/search) |
|
||||
| `src/hooks/rules-injector/` | 1604 | Conditional rules injection |
|
||||
| `src/hooks/think-mode/` | 1365 | Model/variant switching |
|
||||
| `src/hooks/session-recovery/` | 1279 | Auto error recovery |
|
||||
| `src/features/builtin-skills/skills/git-master.ts` | 1112 | Git master skill |
|
||||
| `src/tools/delegate-task/constants.ts` | 569 | Category routing configs |
|
||||
|
||||
## MCP ARCHITECTURE
|
||||
|
||||
Three-tier system:
|
||||
1. **Built-in** (src/mcp/): websearch (Exa/Tavily), context7 (docs), grep_app (GitHub)
|
||||
2. **Claude Code compat** (features/claude-code-mcp-loader/): .mcp.json with `${VAR}` expansion
|
||||
3. **Skill-embedded** (features/opencode-skill-loader/): YAML frontmatter in SKILL.md
|
||||
|
||||
## CONFIG SYSTEM
|
||||
|
||||
- **Zod validation**: 21 schema component files in `src/config/schema/`
|
||||
- **JSONC support**: Comments, trailing commas
|
||||
- **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`) → Defaults
|
||||
- **Migration**: Legacy config auto-migration in `src/shared/migration/`
|
||||
|
||||
## NOTES
|
||||
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **1130 TypeScript files**, 176 test files, 127k+ lines
|
||||
- **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution)
|
||||
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
- **No linter/formatter**: No ESLint, Prettier, or Biome configured
|
||||
- **License**: SUL-1.0 (Sisyphus Use License)
|
||||
- Logger writes to `/tmp/oh-my-opencode.log` — check there for debugging
|
||||
- Background tasks: 5 concurrent per model/provider (configurable)
|
||||
- Plugin load timeout: 10s for Claude Code plugins
|
||||
- Model fallback priority: Claude > OpenAI > Gemini > Copilot > OpenCode Zen > Z.ai > Kimi
|
||||
- Config migration runs automatically on legacy keys (agent names, hook names, model versions)
|
||||
|
||||
12
README.ja.md
12
README.ja.md
@@ -172,16 +172,16 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
|
||||
私の人生もそうです。振り返ってみれば、私たち人間と何ら変わりありません。
|
||||
**はい!LLMエージェントたちは私たちと変わりません。優れたツールと最高の仲間がいれば、彼らも私たちと同じくらい優れたコードを書き、立派に仕事をこなすことができます。**
|
||||
|
||||
私たちのメインエージェント、Sisyphus(Opus 4.5 High)を紹介します。以下は、シジフォスが岩を転がすために使用するツールです。
|
||||
私たちのメインエージェント、Sisyphus(Opus 4.6)を紹介します。以下は、シジフォスが岩を転がすために使用するツールです。
|
||||
|
||||
*以下の内容はすべてカスタマイズ可能です。必要なものだけを使ってください。デフォルトではすべての機能が有効になっています。何もしなくても大丈夫です。*
|
||||
|
||||
- シジフォスのチームメイト (Curated Agents)
|
||||
- Hephaestus: 自律型ディープワーカー、目標指向実行 (GPT 5.2 Codex Medium) — *正当な職人*
|
||||
- Oracle: 設計、デバッグ (GPT 5.2 Medium)
|
||||
- Hephaestus: 自律型ディープワーカー、目標指向実行 (GPT 5.3 Codex Medium) — *正当な職人*
|
||||
- Oracle: 設計、デバッグ (GPT 5.2)
|
||||
- Frontend UI/UX Engineer: フロントエンド開発 (Gemini 3 Pro)
|
||||
- Librarian: 公式ドキュメント、オープンソース実装、コードベース探索 (Claude Sonnet 4.5)
|
||||
- Explore: 超高速コードベース探索 (Contextual Grep) (Claude Haiku 4.5)
|
||||
- Librarian: 公式ドキュメント、オープンソース実装、コードベース探索 (GLM-4.7)
|
||||
- Explore: 超高速コードベース探索 (Contextual Grep) (Grok Code Fast 1)
|
||||
- Full LSP / AstGrep Support: 決定的にリファクタリングしましょう。
|
||||
- Todo Continuation Enforcer: 途中で諦めたら、続行を強制します。これがシジフォスに岩を転がし続けさせる秘訣です。
|
||||
- Comment Checker: AIが過剰なコメントを付けないようにします。シジフォスが生成したコードは、人間が書いたものと区別がつかないべきです。
|
||||
@@ -199,7 +199,7 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
|
||||

|
||||
|
||||
ギリシャ神話において、ヘパイストスは鍛冶、火、金属加工、職人技の神でした—比類のない精密さと献身で神々の武器を作り上げた神聖な鍛冶師です。
|
||||
**自律型ディープワーカーを紹介します: ヘパイストス (GPT 5.2 Codex Medium)。正当な職人エージェント。**
|
||||
**自律型ディープワーカーを紹介します: ヘパイストス (GPT 5.3 Codex Medium)。正当な職人エージェント。**
|
||||
|
||||
*なぜ「正当な」なのか?Anthropicがサードパーティアクセスを利用規約違反を理由にブロックした時、コミュニティで「正当な」使用についてのジョークが始まりました。ヘパイストスはこの皮肉を受け入れています—彼は近道をせず、正しい方法で、体系的かつ徹底的に物を作る職人です。*
|
||||
|
||||
|
||||
12
README.ko.md
12
README.ko.md
@@ -176,16 +176,16 @@ Hey please read this readme and tell me why it is different from other agent har
|
||||
내 삶도 다르지 않습니다. 돌이켜보면 우리는 이 에이전트들과 그리 다르지 않습니다.
|
||||
**맞습니다! LLM 에이전트는 우리와 다르지 않습니다. 훌륭한 도구와 확고한 팀원을 제공하면 우리만큼 훌륭한 코드를 작성하고 똑같이 훌륭하게 작업할 수 있습니다.**
|
||||
|
||||
우리의 주요 에이전트를 만나보세요: Sisyphus (Opus 4.5 High). 아래는 Sisyphus가 그 바위를 굴리는 데 사용하는 도구입니다.
|
||||
우리의 주요 에이전트를 만나보세요: Sisyphus (Opus 4.6). 아래는 Sisyphus가 그 바위를 굴리는 데 사용하는 도구입니다.
|
||||
|
||||
*아래의 모든 것은 사용자 정의 가능합니다. 원하는 것을 가져가세요. 모든 기능은 기본적으로 활성화됩니다. 아무것도 할 필요가 없습니다. 포함되어 있으며, 즉시 작동합니다.*
|
||||
|
||||
- Sisyphus의 팀원 (큐레이팅된 에이전트)
|
||||
- Hephaestus: 자율적 딥 워커, 목표 지향 실행 (GPT 5.2 Codex Medium) — *합법적인 장인*
|
||||
- Oracle: 디자인, 디버깅 (GPT 5.2 Medium)
|
||||
- Hephaestus: 자율적 딥 워커, 목표 지향 실행 (GPT 5.3 Codex Medium) — *합법적인 장인*
|
||||
- Oracle: 디자인, 디버깅 (GPT 5.2)
|
||||
- Frontend UI/UX Engineer: 프론트엔드 개발 (Gemini 3 Pro)
|
||||
- Librarian: 공식 문서, 오픈 소스 구현, 코드베이스 탐색 (Claude Sonnet 4.5)
|
||||
- Explore: 엄청나게 빠른 코드베이스 탐색 (Contextual Grep) (Claude Haiku 4.5)
|
||||
- Librarian: 공식 문서, 오픈 소스 구현, 코드베이스 탐색 (GLM-4.7)
|
||||
- Explore: 엄청나게 빠른 코드베이스 탐색 (Contextual Grep) (Grok Code Fast 1)
|
||||
- 완전한 LSP / AstGrep 지원: 결정적으로 리팩토링합니다.
|
||||
- TODO 연속 강제: 에이전트가 중간에 멈추면 계속하도록 강제합니다. **이것이 Sisyphus가 그 바위를 굴리게 하는 것입니다.**
|
||||
- 주석 검사기: AI가 과도한 주석을 추가하는 것을 방지합니다. Sisyphus가 생성한 코드는 인간이 작성한 것과 구별할 수 없어야 합니다.
|
||||
@@ -228,7 +228,7 @@ Hey please read this readme and tell me why it is different from other agent har
|
||||

|
||||
|
||||
그리스 신화에서 헤파이스토스는 대장간, 불, 금속 세공, 장인 정신의 신이었습니다—비교할 수 없는 정밀함과 헌신으로 신들의 무기를 만든 신성한 대장장이입니다.
|
||||
**자율적 딥 워커를 소개합니다: 헤파이스토스 (GPT 5.2 Codex Medium). 합법적인 장인 에이전트.**
|
||||
**자율적 딥 워커를 소개합니다: 헤파이스토스 (GPT 5.3 Codex Medium). 합법적인 장인 에이전트.**
|
||||
|
||||
*왜 "합법적인"일까요? Anthropic이 ToS 위반을 이유로 서드파티 접근을 차단했을 때, 커뮤니티에서 "합법적인" 사용에 대한 농담이 시작되었습니다. 헤파이스토스는 이 아이러니를 받아들입니다—그는 편법 없이 올바른 방식으로, 체계적이고 철저하게 만드는 장인입니다.*
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -175,16 +175,16 @@ In greek mythology, Sisyphus was condemned to roll a boulder up a hill for etern
|
||||
My life is no different. Looking back, we are not so different from these agents.
|
||||
**Yes! LLM Agents are no different from us. They can write code as brilliant as ours and work just as excellently—if you give them great tools and solid teammates.**
|
||||
|
||||
Meet our main agent: Sisyphus (Opus 4.5 High). Below are the tools Sisyphus uses to keep that boulder rolling.
|
||||
Meet our main agent: Sisyphus (Opus 4.6). Below are the tools Sisyphus uses to keep that boulder rolling.
|
||||
|
||||
*Everything below is customizable. Take what you want. All features are enabled by default. You don't have to do anything. Battery Included, works out of the box.*
|
||||
|
||||
- Sisyphus's Teammates (Curated Agents)
|
||||
- Hephaestus: Autonomous deep worker, goal-oriented execution (GPT 5.2 Codex Medium) — *The Legitimate Craftsman*
|
||||
- Oracle: Design, debugging (GPT 5.2 Medium)
|
||||
- Hephaestus: Autonomous deep worker, goal-oriented execution (GPT 5.3 Codex Medium) — *The Legitimate Craftsman*
|
||||
- Oracle: Design, debugging (GPT 5.2)
|
||||
- Frontend UI/UX Engineer: Frontend development (Gemini 3 Pro)
|
||||
- Librarian: Official docs, open source implementations, codebase exploration (Claude Sonnet 4.5)
|
||||
- Explore: Blazing fast codebase exploration (Contextual Grep) (Claude Haiku 4.5)
|
||||
- Librarian: Official docs, open source implementations, codebase exploration (GLM-4.7)
|
||||
- Explore: Blazing fast codebase exploration (Contextual Grep) (Grok Code Fast 1)
|
||||
- Full LSP / AstGrep Support: Refactor decisively.
|
||||
- Todo Continuation Enforcer: Forces the agent to continue if it quits halfway. **This is what keeps Sisyphus rolling that boulder.**
|
||||
- Comment Checker: Prevents AI from adding excessive comments. Code generated by Sisyphus should be indistinguishable from human-written code.
|
||||
@@ -227,7 +227,7 @@ If you don't want all this, as mentioned, you can just pick and choose specific
|
||||

|
||||
|
||||
In Greek mythology, Hephaestus was the god of forge, fire, metalworking, and craftsmanship—the divine blacksmith who crafted weapons for the gods with unmatched precision and dedication.
|
||||
**Meet our autonomous deep worker: Hephaestus (GPT 5.2 Codex Medium). The Legitimate Craftsman Agent.**
|
||||
**Meet our autonomous deep worker: Hephaestus (GPT 5.3 Codex Medium). The Legitimate Craftsman Agent.**
|
||||
|
||||
*Why "Legitimate"? When Anthropic blocked third-party access citing ToS violations, the community started joking about "legitimate" usage. Hephaestus embraces this irony—he's the craftsman who builds things the right way, methodically and thoroughly, without cutting corners.*
|
||||
|
||||
|
||||
@@ -172,16 +172,16 @@
|
||||
我的生活也没有什么不同。回顾过去,我们与这些智能体并没有太大不同。
|
||||
**是的!LLM 智能体和我们没有区别。如果你给它们优秀的工具和可靠的队友,它们可以写出和我们一样出色的代码,工作得同样优秀。**
|
||||
|
||||
认识我们的主智能体:Sisyphus (Opus 4.5 High)。以下是 Sisyphus 用来继续推动巨石的工具。
|
||||
认识我们的主智能体:Sisyphus (Opus 4.6)。以下是 Sisyphus 用来继续推动巨石的工具。
|
||||
|
||||
*以下所有内容都是可配置的。按需选取。所有功能默认启用。你不需要做任何事情。开箱即用,电池已包含。*
|
||||
|
||||
- Sisyphus 的队友(精选智能体)
|
||||
- Hephaestus:自主深度工作者,目标导向执行(GPT 5.2 Codex Medium)— *合法的工匠*
|
||||
- Oracle:设计、调试 (GPT 5.2 Medium)
|
||||
- Hephaestus:自主深度工作者,目标导向执行(GPT 5.3 Codex Medium)— *合法的工匠*
|
||||
- Oracle:设计、调试 (GPT 5.2)
|
||||
- Frontend UI/UX Engineer:前端开发 (Gemini 3 Pro)
|
||||
- Librarian:官方文档、开源实现、代码库探索 (Claude Sonnet 4.5)
|
||||
- Explore:极速代码库探索(上下文感知 Grep)(Claude Haiku 4.5)
|
||||
- Librarian:官方文档、开源实现、代码库探索 (GLM-4.7)
|
||||
- Explore:极速代码库探索(上下文感知 Grep)(Grok Code Fast 1)
|
||||
- 完整 LSP / AstGrep 支持:果断重构。
|
||||
- Todo 继续执行器:如果智能体中途退出,强制它继续。**这就是让 Sisyphus 继续推动巨石的关键。**
|
||||
- 注释检查器:防止 AI 添加过多注释。Sisyphus 生成的代码应该与人类编写的代码无法区分。
|
||||
@@ -199,7 +199,7 @@
|
||||

|
||||
|
||||
在希腊神话中,赫菲斯托斯是锻造、火焰、金属加工和工艺之神——他是神圣的铁匠,以无与伦比的精准和奉献为众神打造武器。
|
||||
**介绍我们的自主深度工作者:赫菲斯托斯(GPT 5.2 Codex Medium)。合法的工匠代理。**
|
||||
**介绍我们的自主深度工作者:赫菲斯托斯(GPT 5.3 Codex Medium)。合法的工匠代理。**
|
||||
|
||||
*为什么是"合法的"?当Anthropic以违反服务条款为由封锁第三方访问时,社区开始调侃"合法"使用。赫菲斯托斯拥抱这种讽刺——他是那种用正确的方式、有条不紊、彻底地构建事物的工匠,绝不走捷径。*
|
||||
|
||||
|
||||
@@ -163,9 +163,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -211,9 +208,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -301,9 +295,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -345,9 +336,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -393,9 +381,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -483,9 +468,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -527,9 +509,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -575,9 +554,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -665,9 +641,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -709,9 +682,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -757,9 +727,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -847,9 +814,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -891,9 +855,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -939,9 +900,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1029,9 +987,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1073,9 +1028,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1121,9 +1073,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1211,9 +1160,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1255,9 +1201,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1303,9 +1246,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1393,9 +1333,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1437,9 +1374,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1485,9 +1419,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1575,9 +1506,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1619,9 +1547,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1667,9 +1592,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1757,9 +1679,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1801,9 +1720,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -1849,9 +1765,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1939,9 +1852,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -1983,9 +1893,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2031,9 +1938,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2121,9 +2025,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2165,9 +2066,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2213,9 +2111,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2303,9 +2198,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2347,9 +2239,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2395,9 +2284,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2485,9 +2371,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2529,9 +2412,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2577,9 +2457,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2667,9 +2544,6 @@
|
||||
},
|
||||
"providerOptions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
@@ -2680,9 +2554,6 @@
|
||||
},
|
||||
"categories": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2746,9 +2617,6 @@
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -2789,9 +2657,6 @@
|
||||
},
|
||||
"plugins_override": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -3065,9 +2930,6 @@
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"allowed-tools": {
|
||||
@@ -3119,9 +2981,6 @@
|
||||
},
|
||||
"providerConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
@@ -3129,9 +2988,6 @@
|
||||
},
|
||||
"modelConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
|
||||
@@ -117,7 +117,7 @@ You can create powerful specialized agents by combining Categories and Skills.
|
||||
### 🏗️ The Architect (Design Review)
|
||||
- **Category**: `ultrabrain`
|
||||
- **load_skills**: `[]` (pure reasoning)
|
||||
- **Effect**: Leverages GPT-5.2's logical reasoning for in-depth system architecture analysis.
|
||||
- **Effect**: Leverages GPT-5.3 Codex's logical reasoning for in-depth system architecture analysis.
|
||||
|
||||
### ⚡ The Maintainer (Quick Fixes)
|
||||
- **Category**: `quick`
|
||||
|
||||
@@ -245,7 +245,7 @@ Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or
|
||||
}
|
||||
```
|
||||
|
||||
Available agents: `sisyphus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`
|
||||
Available agents: `sisyphus`, `hephaestus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`
|
||||
|
||||
## Built-in Skills
|
||||
|
||||
@@ -609,7 +609,7 @@ Configure git-master skill behavior:
|
||||
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
|
||||
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
|
||||
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.6)
|
||||
- **OpenCode-Builder**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **Prometheus (Planner)**: OpenCode's default plan agent with work-planner methodology (enabled by default)
|
||||
- **Metis (Plan Consultant)**: Pre-planning analysis agent that identifies hidden requirements and AI failure points
|
||||
@@ -720,17 +720,18 @@ Categories enable domain-specific task delegation via the `task` tool. Each cate
|
||||
|
||||
### Built-in Categories
|
||||
|
||||
All 7 categories come with optimal model defaults, but **you must configure them to use those defaults**:
|
||||
All 8 categories come with optimal model defaults, but **you must configure them to use those defaults**:
|
||||
|
||||
| Category | Built-in Default Model | Description |
|
||||
| -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
|
||||
| `visual-engineering` | `google/gemini-3-pro` (high) | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | `openai/gpt-5.3-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas |
|
||||
| `deep` | `openai/gpt-5.3-codex` (medium) | Goal-oriented autonomous problem-solving, thorough research before action |
|
||||
| `artistry` | `google/gemini-3-pro` (high) | Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications|
|
||||
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-6` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
|
||||
| `writing` | `kimi-for-coding/k2p5` | Documentation, prose, technical writing |
|
||||
|
||||
### ⚠️ Critical: Model Resolution Priority
|
||||
|
||||
@@ -765,15 +766,19 @@ All 7 categories come with optimal model defaults, but **you must configure them
|
||||
{
|
||||
"categories": {
|
||||
"visual-engineering": {
|
||||
"model": "google/gemini-3-pro-preview"
|
||||
"model": "google/gemini-3-pro"
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh"
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium"
|
||||
},
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"variant": "max"
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "high"
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5" // Fast + cheap for trivial tasks
|
||||
@@ -786,7 +791,7 @@ All 7 categories come with optimal model defaults, but **you must configure them
|
||||
"variant": "max"
|
||||
},
|
||||
"writing": {
|
||||
"model": "google/gemini-3-flash-preview"
|
||||
"model": "kimi-for-coding/k2p5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -894,15 +899,16 @@ Each agent has a defined provider priority chain. The system tries providers in
|
||||
|
||||
| Agent | Model (no prefix) | Provider Priority Chain |
|
||||
|-------|-------------------|-------------------------|
|
||||
| **Sisyphus** | `claude-opus-4-6` | anthropic → kimi-for-coding → zai-coding-plan → openai → google |
|
||||
| **oracle** | `gpt-5.2` | openai → google → anthropic |
|
||||
| **librarian** | `glm-4.7` | zai-coding-plan → opencode → anthropic |
|
||||
| **explore** | `claude-haiku-4-5` | anthropic → github-copilot → opencode |
|
||||
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → kimi-for-coding → anthropic → opencode |
|
||||
| **Prometheus (Planner)** | `claude-opus-4-6` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Metis (Plan Consultant)** | `claude-opus-4-6` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Momus (Plan Reviewer)** | `gpt-5.2` | openai → anthropic → google |
|
||||
| **Atlas** | `claude-sonnet-4-5` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Sisyphus** | `claude-opus-4-6` | anthropic/github-copilot/opencode → kimi-for-coding → opencode → zai-coding-plan → opencode |
|
||||
| **Hephaestus** | `gpt-5.3-codex` | openai/github-copilot/opencode (requires provider) |
|
||||
| **oracle** | `gpt-5.2` | openai/github-copilot/opencode → google/github-copilot/opencode → anthropic/github-copilot/opencode |
|
||||
| **librarian** | `glm-4.7` | zai-coding-plan → opencode → anthropic/github-copilot/opencode |
|
||||
| **explore** | `grok-code-fast-1` | github-copilot → anthropic/opencode → opencode |
|
||||
| **multimodal-looker** | `gemini-3-flash` | google/github-copilot/opencode → openai/github-copilot/opencode → zai-coding-plan → kimi-for-coding → opencode → anthropic/github-copilot/opencode → opencode |
|
||||
| **Prometheus (Planner)** | `claude-opus-4-6` | anthropic/github-copilot/opencode → kimi-for-coding → opencode → openai/github-copilot/opencode → google/github-copilot/opencode |
|
||||
| **Metis (Plan Consultant)** | `claude-opus-4-6` | anthropic/github-copilot/opencode → kimi-for-coding → opencode → openai/github-copilot/opencode → google/github-copilot/opencode |
|
||||
| **Momus (Plan Reviewer)** | `gpt-5.2` | openai/github-copilot/opencode → anthropic/github-copilot/opencode → google/github-copilot/opencode |
|
||||
| **Atlas** | `k2p5` | kimi-for-coding → opencode → anthropic/github-copilot/opencode → openai/github-copilot/opencode → google/github-copilot/opencode |
|
||||
|
||||
### Category Provider Chains
|
||||
|
||||
@@ -910,14 +916,14 @@ Categories follow the same resolution logic:
|
||||
|
||||
| Category | Model (no prefix) | Provider Priority Chain |
|
||||
|----------|-------------------|-------------------------|
|
||||
| **visual-engineering** | `gemini-3-pro` | google → anthropic → zai-coding-plan |
|
||||
| **ultrabrain** | `gpt-5.3-codex` | openai → google → anthropic |
|
||||
| **deep** | `gpt-5.3-codex` | openai → anthropic → google |
|
||||
| **artistry** | `gemini-3-pro` | google → anthropic → openai |
|
||||
| **quick** | `claude-haiku-4-5` | anthropic → google → opencode |
|
||||
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → openai → google |
|
||||
| **unspecified-high** | `claude-opus-4-6` | anthropic → openai → google |
|
||||
| **writing** | `gemini-3-flash` | google → anthropic → zai-coding-plan → openai |
|
||||
| **visual-engineering** | `gemini-3-pro` | google/github-copilot/opencode → zai-coding-plan → anthropic/github-copilot/opencode → kimi-for-coding |
|
||||
| **ultrabrain** | `gpt-5.3-codex` | openai/github-copilot/opencode → google/github-copilot/opencode → anthropic/github-copilot/opencode |
|
||||
| **deep** | `gpt-5.3-codex` | openai/github-copilot/opencode → anthropic/github-copilot/opencode → google/github-copilot/opencode |
|
||||
| **artistry** | `gemini-3-pro` | google/github-copilot/opencode → anthropic/github-copilot/opencode → openai/github-copilot/opencode |
|
||||
| **quick** | `claude-haiku-4-5` | anthropic/github-copilot/opencode → google/github-copilot/opencode → opencode |
|
||||
| **unspecified-low** | `claude-sonnet-4-5` | anthropic/github-copilot/opencode → openai/github-copilot/opencode → google/github-copilot/opencode |
|
||||
| **unspecified-high** | `claude-opus-4-6` | anthropic/github-copilot/opencode → openai/github-copilot/opencode → google/github-copilot/opencode |
|
||||
| **writing** | `k2p5` | kimi-for-coding → google/github-copilot/opencode → anthropic/github-copilot/opencode |
|
||||
|
||||
### Checking Your Configuration
|
||||
|
||||
|
||||
@@ -10,20 +10,20 @@ Oh-My-OpenCode provides 11 specialized AI agents. Each has distinct expertise, o
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| **Sisyphus** | `anthropic/claude-opus-4-6` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro. |
|
||||
| **Sisyphus** | `anthropic/claude-opus-4-6` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: k2p5 → kimi-k2.5-free → glm-4.7 → glm-4.7-free. |
|
||||
| **Hephaestus** | `openai/gpt-5.3-codex` | **The Legitimate Craftsman.** Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Requires gpt-5.3-codex (no fallback - only activates when this model is available). |
|
||||
| **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. |
|
||||
| **librarian** | `zai-coding-plan/glm-4.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: glm-4.7-free → claude-sonnet-4-5. |
|
||||
| **explore** | `anthropic/claude-haiku-4-5` | Fast codebase exploration and contextual grep. Fallback: gpt-5-mini → gpt-5-nano. |
|
||||
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: gpt-5.2 → glm-4.6v → kimi-k2.5 → claude-haiku-4-5 → gpt-5-nano. |
|
||||
| **explore** | `github-copilot/grok-code-fast-1` | Fast codebase exploration and contextual grep. Fallback: claude-haiku-4-5 → gpt-5-nano. |
|
||||
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: gpt-5.2 → glm-4.6v → k2p5 → kimi-k2.5-free → claude-haiku-4-5 → gpt-5-nano. |
|
||||
|
||||
### Planning Agents
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| **Prometheus** | `anthropic/claude-opus-4-6` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Metis** | `anthropic/claude-opus-4-6` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Momus** | `openai/gpt-5.2` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. Fallback: gpt-5.2 → claude-opus-4-6 → gemini-3-pro. |
|
||||
| **Prometheus** | `anthropic/claude-opus-4-6` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. Fallback: k2p5 → kimi-k2.5-free → gpt-5.2 → gemini-3-pro. |
|
||||
| **Metis** | `anthropic/claude-opus-4-6` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. Fallback: k2p5 → kimi-k2.5-free → gpt-5.2 → gemini-3-pro. |
|
||||
| **Momus** | `openai/gpt-5.2` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. Fallback: claude-opus-4-6 → gemini-3-pro. |
|
||||
|
||||
### Invoking Agents
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ When GitHub Copilot is the best available provider, oh-my-opencode uses these mo
|
||||
|
||||
| Agent | Model |
|
||||
| ------------- | -------------------------------- |
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4.6` |
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4-6` |
|
||||
| **Oracle** | `github-copilot/gpt-5.2` |
|
||||
| **Explore** | `opencode/gpt-5-nano` |
|
||||
| **Librarian** | `zai-coding-plan/glm-4.7` (if Z.ai available) or fallback |
|
||||
@@ -292,7 +292,7 @@ gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/nul
|
||||
|
||||
Tell the user of following:
|
||||
|
||||
1. **Sisyphus agent strongly recommends Opus 4.5 model. Using other models may result in significantly degraded experience.**
|
||||
1. **Sisyphus agent strongly recommends Opus 4.6 model. Using other models may result in significantly degraded experience.**
|
||||
|
||||
2. **Feeling lazy?** Just include `ultrawork` (or `ulw`) in your prompt. That's it. The agent figures out the rest.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Learn about Oh My OpenCode, a plugin that transforms OpenCode into the best agen
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Sisyphus agent strongly recommends Opus 4.5 model. Using other models may result in significantly degraded experience.**
|
||||
> **Sisyphus agent strongly recommends Opus 4.6 model. Using other models may result in significantly degraded experience.**
|
||||
|
||||
**Feeling lazy?** Just include `ultrawork` (or `ulw`) in your prompt. That's it. The agent figures out the rest.
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@ The orchestration system solves these problems through **specialization and dele
|
||||
flowchart TB
|
||||
subgraph Planning["Planning Layer (Human + Prometheus)"]
|
||||
User[("👤 User")]
|
||||
Prometheus["🔥 Prometheus<br/>(Planner)<br/>Claude Opus 4.5"]
|
||||
Metis["🦉 Metis<br/>(Consultant)<br/>Claude Opus 4.5"]
|
||||
Prometheus["🔥 Prometheus<br/>(Planner)<br/>Claude Opus 4.6"]
|
||||
Metis["🦉 Metis<br/>(Consultant)<br/>Claude Opus 4.6"]
|
||||
Momus["👁️ Momus<br/>(Reviewer)<br/>GPT-5.2"]
|
||||
end
|
||||
|
||||
subgraph Execution["Execution Layer (Orchestrator)"]
|
||||
Orchestrator["⚡ Atlas<br/>(Conductor)<br/>Claude Opus 4.5"]
|
||||
Orchestrator["⚡ Atlas<br/>(Conductor)<br/>K2P5 (Kimi)"]
|
||||
end
|
||||
|
||||
subgraph Workers["Worker Layer (Specialized Agents)"]
|
||||
@@ -294,12 +294,13 @@ task(category="quick", prompt="...") // "Just get it done fast"
|
||||
| Category | Model | When to Use |
|
||||
|----------|-------|-------------|
|
||||
| `visual-engineering` | Gemini 3 Pro | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | GPT-5.2 Codex (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `ultrabrain` | GPT-5.3 Codex (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `artistry` | Gemini 3 Pro (max) | Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | Claude Haiku 4.5 | Trivial tasks - single file changes, typo fixes |
|
||||
| `deep` | GPT-5.3 Codex (medium) | Goal-oriented autonomous problem-solving, thorough research |
|
||||
| `unspecified-low` | Claude Sonnet 4.5 | Tasks that don't fit other categories, low effort |
|
||||
| `unspecified-high` | Claude Opus 4.5 (max) | Tasks that don't fit other categories, high effort |
|
||||
| `writing` | Gemini 3 Flash | Documentation, prose, technical writing |
|
||||
| `unspecified-high` | Claude Opus 4.6 (max) | Tasks that don't fit other categories, high effort |
|
||||
| `writing` | K2P5 (Kimi) | Documentation, prose, technical writing |
|
||||
|
||||
### Custom Categories
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ Another common question: **When should I use Hephaestus vs just typing `ulw` in
|
||||
|
||||
| Aspect | Hephaestus | Sisyphus + `ulw` / `ultrawork` |
|
||||
|--------|-----------|-------------------------------|
|
||||
| **Model** | GPT-5.2 Codex (medium reasoning) | Claude Opus 4.5 (your default) |
|
||||
| **Model** | GPT-5.3 Codex (medium reasoning) | Claude Opus 4.6 (your default) |
|
||||
| **Approach** | Autonomous deep worker | Keyword-activated ultrawork mode |
|
||||
| **Best For** | Complex architectural work, deep reasoning | General complex tasks, "just do it" scenarios |
|
||||
| **Planning** | Self-plans during execution | Uses Prometheus plans if available |
|
||||
@@ -183,8 +183,8 @@ Switch to Hephaestus (Tab → Select Hephaestus) when:
|
||||
- "Integrate our Rust core with the TypeScript frontend"
|
||||
- "Migrate from MongoDB to PostgreSQL with zero downtime"
|
||||
|
||||
4. **You specifically want GPT-5.2 Codex reasoning**
|
||||
- Some problems benefit from GPT-5.2's training characteristics
|
||||
4. **You specifically want GPT-5.3 Codex reasoning**
|
||||
- Some problems benefit from GPT-5.3 Codex's training characteristics
|
||||
|
||||
**Example:**
|
||||
```
|
||||
@@ -231,7 +231,7 @@ Use the `ulw` keyword in Sisyphus when:
|
||||
| Hephaestus | Sisyphus + ulw |
|
||||
|------------|----------------|
|
||||
| You manually switch to Hephaestus agent | You type `ulw` in any Sisyphus session |
|
||||
| GPT-5.2 Codex with medium reasoning | Your configured default model |
|
||||
| GPT-5.3 Codex with medium reasoning | Your configured default model |
|
||||
| Optimized for autonomous deep work | Optimized for general execution |
|
||||
| Always uses explore-first approach | Respects existing plans if available |
|
||||
| "Smart intern that needs no supervision" | "Smart intern that follows your workflow" |
|
||||
@@ -240,7 +240,7 @@ Use the `ulw` keyword in Sisyphus when:
|
||||
|
||||
**For most users**: Use `ulw` keyword in Sisyphus. It's the default path and works excellently for 90% of complex tasks.
|
||||
|
||||
**For power users**: Switch to Hephaestus when you specifically need GPT-5.2 Codex's reasoning style or want the "AmpCode deep mode" experience of fully autonomous exploration and execution.
|
||||
**For power users**: Switch to Hephaestus when you specifically need GPT-5.3 Codex's reasoning style or want the "AmpCode deep mode" experience of fully autonomous exploration and execution.
|
||||
|
||||
---
|
||||
|
||||
@@ -354,7 +354,7 @@ Press `Tab` at the prompt to see available agents:
|
||||
|-------|---------------|
|
||||
| **Prometheus** | You want to create a detailed work plan |
|
||||
| **Atlas** | You want to manually control plan execution (rare) |
|
||||
| **Hephaestus** | You need GPT-5.2 Codex for deep autonomous work |
|
||||
| **Hephaestus** | You need GPT-5.3 Codex for deep autonomous work |
|
||||
| **Sisyphus** | Return to default agent for normal prompting |
|
||||
|
||||
---
|
||||
@@ -421,4 +421,4 @@ Type `exit` or start a new session. Atlas is primarily entered via `/start-work`
|
||||
|
||||
**For most tasks**: Type `ulw` in Sisyphus.
|
||||
|
||||
**Use Hephaestus when**: You specifically need GPT-5.2 Codex's reasoning style for deep architectural work or complex debugging.
|
||||
**Use Hephaestus when**: You specifically need GPT-5.3 Codex's reasoning style for deep architectural work or complex debugging.
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.6.0",
|
||||
"oh-my-opencode-darwin-x64": "3.6.0",
|
||||
"oh-my-opencode-linux-arm64": "3.6.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.6.0",
|
||||
"oh-my-opencode-linux-x64": "3.6.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.6.0",
|
||||
"oh-my-opencode-windows-x64": "3.6.0"
|
||||
"oh-my-opencode-darwin-arm64": "3.7.1",
|
||||
"oh-my-opencode-darwin-x64": "3.7.1",
|
||||
"oh-my-opencode-linux-arm64": "3.7.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.7.1",
|
||||
"oh-my-opencode-linux-x64": "3.7.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.7.1",
|
||||
"oh-my-opencode-windows-x64": "3.7.1"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
102
src/AGENTS.md
102
src/AGENTS.md
@@ -1,81 +1,41 @@
|
||||
# SRC KNOWLEDGE BASE
|
||||
# src/ — Plugin Source
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Main plugin entry point and orchestration layer. Plugin initialization, hook registration, tool composition, and lifecycle management.
|
||||
Root source directory. Entry point `index.ts` orchestrates 4-step initialization: config → managers → tools → hooks → plugin interface.
|
||||
|
||||
## STRUCTURE
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | Plugin entry, exports `OhMyOpenCodePlugin` |
|
||||
| `plugin-config.ts` | JSONC parse, multi-level merge (user → project → defaults), Zod validation |
|
||||
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
|
||||
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry |
|
||||
| `create-hooks.ts` | 3-tier hook composition: Core(32) + Continuation(7) + Skill(2) |
|
||||
| `plugin-interface.ts` | Assembles 8 OpenCode hook handlers into PluginInterface |
|
||||
|
||||
## CONFIG LOADING
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Main plugin entry (106 lines) — OhMyOpenCodePlugin factory
|
||||
├── create-hooks.ts # Hook coordination: core, continuation, skill (62 lines)
|
||||
├── create-managers.ts # Manager initialization: Tmux, Background, SkillMcp, Config (80 lines)
|
||||
├── create-tools.ts # Tool registry + skill context composition (54 lines)
|
||||
├── plugin-interface.ts # Plugin interface assembly — 7 OpenCode hooks (66 lines)
|
||||
├── plugin-config.ts # Config loading orchestration (user + project merge, 180 lines)
|
||||
├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag, 12 lines)
|
||||
├── agents/ # 11 AI agents (32 files) — see agents/AGENTS.md
|
||||
├── cli/ # CLI installer, doctor (107+ files) — see cli/AGENTS.md
|
||||
├── config/ # Zod schema (21 component files) — see config/AGENTS.md
|
||||
├── features/ # Background agents, skills, commands (18 dirs) — see features/AGENTS.md
|
||||
├── hooks/ # 41 lifecycle hooks (36 dirs) — see hooks/AGENTS.md
|
||||
├── mcp/ # Built-in MCPs (6 files) — see mcp/AGENTS.md
|
||||
├── plugin/ # Plugin interface composition (21 files)
|
||||
├── plugin-handlers/ # Config loading, plan inheritance (15 files) — see plugin-handlers/AGENTS.md
|
||||
├── shared/ # Cross-cutting utilities (96 files) — see shared/AGENTS.md
|
||||
└── tools/ # 26 tools (14 dirs) — see tools/AGENTS.md
|
||||
loadPluginConfig(directory, ctx)
|
||||
1. User: ~/.config/opencode/oh-my-opencode.jsonc
|
||||
2. Project: .opencode/oh-my-opencode.jsonc
|
||||
3. mergeConfigs(user, project) → deepMerge for agents/categories, Set union for disabled_*
|
||||
4. Zod safeParse → defaults for omitted fields
|
||||
5. migrateConfigFile() → legacy key transformation
|
||||
```
|
||||
|
||||
## PLUGIN INITIALIZATION (10 steps)
|
||||
## HOOK COMPOSITION
|
||||
|
||||
1. `injectServerAuthIntoClient(ctx.client)` — Auth injection
|
||||
2. `startTmuxCheck()` — Tmux availability
|
||||
3. `loadPluginConfig(ctx.directory, ctx)` — User + project config merge → Zod validation
|
||||
4. `createFirstMessageVariantGate()` — First message variant override gate
|
||||
5. `createModelCacheState()` — Model context limits cache
|
||||
6. `createManagers(...)` → 4 managers:
|
||||
- `TmuxSessionManager` — Multi-pane tmux sessions
|
||||
- `BackgroundManager` — Parallel subagent execution
|
||||
- `SkillMcpManager` — MCP server lifecycle
|
||||
- `ConfigHandler` — Plugin config API to OpenCode
|
||||
7. `createTools(...)` → `createSkillContext()` + `createAvailableCategories()` + `createToolRegistry()`
|
||||
8. `createHooks(...)` → `createCoreHooks()` + `createContinuationHooks()` + `createSkillHooks()`
|
||||
9. `createPluginInterface(...)` → 7 OpenCode hook handlers
|
||||
10. Return plugin with `experimental.session.compacting`
|
||||
|
||||
## HOOK REGISTRATION (3 tiers)
|
||||
|
||||
**Core Hooks** (`create-core-hooks.ts`):
|
||||
- Session (20): context-window-monitor, session-recovery, think-mode, ralph-loop, anthropic-effort, ...
|
||||
- Tool Guard (8): comment-checker, tool-output-truncator, rules-injector, write-existing-file-guard, ...
|
||||
- Transform (4): claude-code-hooks, keyword-detector, context-injector, thinking-block-validator
|
||||
|
||||
**Continuation Hooks** (`create-continuation-hooks.ts`):
|
||||
- 7 hooks: stop-continuation-guard, compaction-context-injector, todo-continuation-enforcer, atlas, ...
|
||||
|
||||
**Skill Hooks** (`create-skill-hooks.ts`):
|
||||
- 2 hooks: category-skill-reminder, auto-slash-command
|
||||
|
||||
## PLUGIN INTERFACE (7 OpenCode handlers)
|
||||
|
||||
| Handler | Source | Purpose |
|
||||
|---------|--------|---------|
|
||||
| `tool` | filteredTools | All registered tools |
|
||||
| `chat.params` | createChatParamsHandler | Anthropic effort level |
|
||||
| `chat.message` | createChatMessageHandler | First message variant, session setup |
|
||||
| `experimental.chat.messages.transform` | createMessagesTransformHandler | Context injection, keyword detection |
|
||||
| `config` | configHandler | Agent/MCP/command registration |
|
||||
| `event` | createEventHandler | Session lifecycle |
|
||||
| `tool.execute.before` | createToolExecuteBeforeHandler | Pre-tool hooks |
|
||||
| `tool.execute.after` | createToolExecuteAfterHandler | Post-tool hooks |
|
||||
|
||||
## SAFE HOOK CREATION PATTERN
|
||||
|
||||
```typescript
|
||||
const hook = isHookEnabled("hook-name")
|
||||
? safeCreateHook("hook-name", () => createHookFactory(ctx), { enabled: safeHookEnabled })
|
||||
: null;
|
||||
```
|
||||
|
||||
All hooks use this pattern for graceful degradation on failure.
|
||||
createHooks()
|
||||
├─→ createCoreHooks() # 32 hooks
|
||||
│ ├─ createSessionHooks() # 19: contextWindowMonitor, thinkMode, ralphLoop, sessionRecovery...
|
||||
│ ├─ createToolGuardHooks() # 9: commentChecker, rulesInjector, writeExistingFileGuard...
|
||||
│ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator
|
||||
├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard...
|
||||
└─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand
|
||||
```
|
||||
|
||||
@@ -1,85 +1,79 @@
|
||||
# AGENTS KNOWLEDGE BASE
|
||||
# src/agents/ — 11 Agent Definitions
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
11 AI agents with factory functions, fallback chains, and model-specific prompt variants. Each agent has metadata (category, cost, triggers) and configurable tool restrictions.
|
||||
Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each has static `mode` property. Built via `buildAgent()` compositing factory + categories + skills.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
agents/
|
||||
├── sisyphus.ts # Main orchestrator (559 lines)
|
||||
├── hephaestus.ts # Autonomous deep worker (651 lines)
|
||||
├── oracle.ts # Strategic advisor (171 lines)
|
||||
├── librarian.ts # Multi-repo research (329 lines)
|
||||
├── explore.ts # Fast codebase grep (125 lines)
|
||||
├── multimodal-looker.ts # Media analyzer (59 lines)
|
||||
├── metis.ts # Pre-planning analysis (347 lines)
|
||||
├── momus.ts # Plan validator (244 lines)
|
||||
├── atlas/ # Master orchestrator (agent.ts + default.ts + gpt.ts)
|
||||
├── prometheus/ # Planning agent (8 files, plan-template 423 lines)
|
||||
├── sisyphus-junior/ # Delegated task executor (agent.ts + default.ts + gpt.ts)
|
||||
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (433 lines)
|
||||
├── builtin-agents/ # Agent registry + model resolution
|
||||
├── agent-builder.ts # Agent construction with category merging (51 lines)
|
||||
├── utils.ts # Agent creation, model fallback resolution (571 lines)
|
||||
├── types.ts # AgentModelConfig, AgentPromptMetadata (106 lines)
|
||||
└── index.ts # Exports
|
||||
```
|
||||
## AGENT INVENTORY
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Model | Temp | Fallback Chain | Cost |
|
||||
|-------|-------|------|----------------|------|
|
||||
| Sisyphus | claude-opus-4-6 | 0.1 | kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro | EXPENSIVE |
|
||||
| Hephaestus | gpt-5.3-codex | 0.1 | NONE (required) | EXPENSIVE |
|
||||
| Atlas | claude-sonnet-4-5 | 0.1 | kimi-k2.5 → gpt-5.2 | EXPENSIVE |
|
||||
| Prometheus | claude-opus-4-6 | 0.1 | kimi-k2.5 → gpt-5.2 | EXPENSIVE |
|
||||
| oracle | gpt-5.2 | 0.1 | claude-opus-4-6 | EXPENSIVE |
|
||||
| librarian | glm-4.7 | 0.1 | glm-4.7-free | CHEAP |
|
||||
| explore | grok-code-fast-1 | 0.1 | claude-haiku-4-5 → gpt-5-mini → gpt-5-nano | FREE |
|
||||
| multimodal-looker | gemini-3-flash | 0.1 | NONE | CHEAP |
|
||||
| Metis | claude-opus-4-6 | 0.3 | kimi-k2.5 → gpt-5.2 | EXPENSIVE |
|
||||
| Momus | gpt-5.2 | 0.1 | claude-opus-4-6 | EXPENSIVE |
|
||||
| Sisyphus-Junior | claude-sonnet-4-5 | 0.1 | (user-configurable) | EXPENSIVE |
|
||||
| Agent | Model | Temp | Mode | Fallback Chain | Purpose |
|
||||
|-------|-------|------|------|----------------|---------|
|
||||
| **Sisyphus** | claude-opus-4-6 | 0.1 | primary | kimi-k2.5 → glm-4.7 → gemini-3-pro | Main orchestrator, plans + delegates |
|
||||
| **Hephaestus** | gpt-5.3-codex | 0.1 | primary | NONE (required) | Autonomous deep worker |
|
||||
| **Oracle** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 → gemini-3-pro | Read-only consultation |
|
||||
| **Librarian** | glm-4.7 | 0.1 | subagent | glm-4.7-free → claude-sonnet-4-5 | External docs/code search |
|
||||
| **Explore** | grok-code-fast-1 | 0.1 | subagent | claude-haiku-4-5 → gpt-5-nano | Contextual grep |
|
||||
| **Multimodal-Looker** | gemini-3-flash | 0.1 | subagent | gpt-5.2 → glm-4.6v → ... (6 deep) | PDF/image analysis |
|
||||
| **Metis** | claude-opus-4-6 | **0.3** | subagent | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Pre-planning consultant |
|
||||
| **Momus** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 → gemini-3-pro | Plan reviewer |
|
||||
| **Atlas** | claude-sonnet-4-5 | 0.1 | primary | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Todo-list orchestrator |
|
||||
| **Prometheus** | claude-opus-4-6 | 0.1 | — | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Strategic planner (internal) |
|
||||
| **Sisyphus-Junior** | claude-sonnet-4-5 | 0.1 | all | user-configurable | Category-spawned executor |
|
||||
|
||||
## TOOL RESTRICTIONS
|
||||
|
||||
| Agent | Denied | Allowed |
|
||||
|-------|--------|---------|
|
||||
| oracle | write, edit, task, call_omo_agent | Read-only consultation |
|
||||
| librarian | write, edit, task, call_omo_agent | Research tools only |
|
||||
| explore | write, edit, task, call_omo_agent | Search tools only |
|
||||
| multimodal-looker | ALL except `read` | Vision-only |
|
||||
| Sisyphus-Junior | task | No delegation |
|
||||
| Atlas | task, call_omo_agent | Orchestration only |
|
||||
| Agent | Denied Tools |
|
||||
|-------|-------------|
|
||||
| Oracle | write, edit, task, call_omo_agent |
|
||||
| Librarian | write, edit, task, call_omo_agent |
|
||||
| Explore | write, edit, task, call_omo_agent |
|
||||
| Multimodal-Looker | ALL except read |
|
||||
| Atlas | task, call_omo_agent |
|
||||
| Momus | write, edit, task |
|
||||
|
||||
## THINKING / REASONING
|
||||
## STRUCTURE
|
||||
|
||||
| Agent | Claude | GPT |
|
||||
|-------|--------|-----|
|
||||
| Sisyphus | 32k budget tokens | reasoningEffort: "medium" |
|
||||
| Hephaestus | — | reasoningEffort: "medium" |
|
||||
| Oracle | 32k budget tokens | reasoningEffort: "medium" |
|
||||
| Metis | 32k budget tokens | — |
|
||||
| Momus | 32k budget tokens | reasoningEffort: "medium" |
|
||||
| Sisyphus-Junior | 32k budget tokens | reasoningEffort: "medium" |
|
||||
```
|
||||
agents/
|
||||
├── sisyphus.ts # 559 LOC, main orchestrator
|
||||
├── hephaestus.ts # 507 LOC, autonomous worker
|
||||
├── oracle.ts # Read-only consultant
|
||||
├── librarian.ts # External search
|
||||
├── explore.ts # Codebase grep
|
||||
├── multimodal-looker.ts # Vision/PDF
|
||||
├── metis.ts # Pre-planning
|
||||
├── momus.ts # Plan review
|
||||
├── atlas/agent.ts # Todo orchestrator
|
||||
├── types.ts # AgentFactory, AgentMode
|
||||
├── agent-builder.ts # buildAgent() composition
|
||||
├── utils.ts # Agent utilities
|
||||
├── builtin-agents.ts # createBuiltinAgents() registry
|
||||
└── builtin-agents/ # maybeCreateXXXConfig conditional factories
|
||||
├── sisyphus-agent.ts
|
||||
├── hephaestus-agent.ts
|
||||
├── atlas-agent.ts
|
||||
├── general-agents.ts # collectPendingBuiltinAgents
|
||||
└── available-skills.ts
|
||||
```
|
||||
|
||||
## KEY PROMPT PATTERNS
|
||||
## FACTORY PATTERN
|
||||
|
||||
- **Sisyphus/Hephaestus**: Dynamic prompts via `dynamic-agent-prompt-builder.ts` injecting available tools/skills/categories
|
||||
- **Atlas, Sisyphus-Junior**: Model-specific prompts (Claude vs GPT variants)
|
||||
- **Prometheus**: 6-section modular prompt (identity → interview → plan-generation → high-accuracy → template → behavioral)
|
||||
```typescript
|
||||
const createXXXAgent: AgentFactory = (model: string) => ({
|
||||
instructions: "...",
|
||||
model,
|
||||
temperature: 0.1,
|
||||
// ...config
|
||||
})
|
||||
createXXXAgent.mode = "subagent" // or "primary" or "all"
|
||||
```
|
||||
|
||||
## HOW TO ADD
|
||||
Model resolution: `AGENT_MODEL_REQUIREMENTS` in `shared/model-requirements.ts` defines fallback chains per agent.
|
||||
|
||||
1. Create `src/agents/my-agent.ts` exporting factory + metadata
|
||||
2. Add to `agentSources` in `src/agents/builtin-agents/`
|
||||
3. Update `AgentNameSchema` in `src/config/schema/agent-names.ts`
|
||||
4. Register in `src/plugin-handlers/agent-config-handler.ts`
|
||||
## MODES
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Trust agent self-reports**: NEVER — always verify outputs
|
||||
- **High temperature**: Don't use >0.3 for code agents
|
||||
- **Sequential calls**: Use `task` with `run_in_background` for exploration
|
||||
- **Prometheus writing code**: Planner only — never implements
|
||||
- **primary**: Respects UI-selected model, uses fallback chain
|
||||
- **subagent**: Uses own fallback chain, ignores UI selection
|
||||
- **all**: Available in both contexts (Sisyphus-Junior)
|
||||
|
||||
@@ -64,8 +64,8 @@ describe("buildCategorySkillsDelegationGuide", () => {
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: should show source for each custom skill
|
||||
expect(result).toContain("| user |")
|
||||
expect(result).toContain("| project |")
|
||||
expect(result).toContain("(user)")
|
||||
expect(result).toContain("(project)")
|
||||
})
|
||||
|
||||
it("should not show custom skill section when only builtin skills exist", () => {
|
||||
|
||||
@@ -87,12 +87,9 @@ export function buildToolSelectionTable(
|
||||
"",
|
||||
]
|
||||
|
||||
rows.push("| Resource | Cost | When to Use |")
|
||||
rows.push("|----------|------|-------------|")
|
||||
|
||||
if (tools.length > 0) {
|
||||
const toolsDisplay = formatToolsForPrompt(tools)
|
||||
rows.push(`| ${toolsDisplay} | FREE | Not Complex, Scope Clear, No Implicit Assumptions |`)
|
||||
rows.push(`- ${toolsDisplay} — **FREE** — Not Complex, Scope Clear, No Implicit Assumptions`)
|
||||
}
|
||||
|
||||
const costOrder = { FREE: 0, CHEAP: 1, EXPENSIVE: 2 }
|
||||
@@ -102,7 +99,7 @@ export function buildToolSelectionTable(
|
||||
|
||||
for (const agent of sortedAgents) {
|
||||
const shortDesc = agent.description.split(".")[0] || agent.description
|
||||
rows.push(`| \`${agent.name}\` agent | ${agent.metadata.cost} | ${shortDesc} |`)
|
||||
rows.push(`- \`${agent.name}\` agent — **${agent.metadata.cost}** — ${shortDesc}`)
|
||||
}
|
||||
|
||||
rows.push("")
|
||||
@@ -122,10 +119,11 @@ export function buildExploreSection(agents: AvailableAgent[]): string {
|
||||
|
||||
Use it as a **peer tool**, not a fallback. Fire liberally.
|
||||
|
||||
| Use Direct Tools | Use Explore Agent |
|
||||
|------------------|-------------------|
|
||||
${avoidWhen.map((w) => `| ${w} | |`).join("\n")}
|
||||
${useWhen.map((w) => `| | ${w} |`).join("\n")}`
|
||||
**Use Direct Tools when:**
|
||||
${avoidWhen.map((w) => `- ${w}`).join("\n")}
|
||||
|
||||
**Use Explore Agent when:**
|
||||
${useWhen.map((w) => `- ${w}`).join("\n")}`
|
||||
}
|
||||
|
||||
export function buildLibrarianSection(agents: AvailableAgent[]): string {
|
||||
@@ -138,14 +136,8 @@ export function buildLibrarianSection(agents: AvailableAgent[]): string {
|
||||
|
||||
Search **external references** (docs, OSS, web). Fire proactively when unfamiliar libraries are involved.
|
||||
|
||||
| Contextual Grep (Internal) | Reference Grep (External) |
|
||||
|----------------------------|---------------------------|
|
||||
| Search OUR codebase | Search EXTERNAL resources |
|
||||
| Find patterns in THIS repo | Find examples in OTHER repos |
|
||||
| How does our code work? | How does this library work? |
|
||||
| Project-specific logic | Official API documentation |
|
||||
| | Library best practices & quirks |
|
||||
| | OSS implementation examples |
|
||||
**Contextual Grep (Internal)** — search OUR codebase, find patterns in THIS repo, project-specific logic.
|
||||
**Reference Grep (External)** — search EXTERNAL resources, official API docs, library best practices, OSS implementation examples.
|
||||
|
||||
**Trigger phrases** (fire librarian immediately):
|
||||
${useWhen.map((w) => `- "${w}"`).join("\n")}`
|
||||
@@ -155,13 +147,11 @@ export function buildDelegationTable(agents: AvailableAgent[]): string {
|
||||
const rows: string[] = [
|
||||
"### Delegation Table:",
|
||||
"",
|
||||
"| Domain | Delegate To | Trigger |",
|
||||
"|--------|-------------|---------|",
|
||||
]
|
||||
|
||||
for (const agent of agents) {
|
||||
for (const trigger of agent.metadata.triggers) {
|
||||
rows.push(`| ${trigger.domain} | \`${agent.name}\` | ${trigger.trigger} |`)
|
||||
rows.push(`- **${trigger.domain}** → \`${agent.name}\` — ${trigger.trigger}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +177,6 @@ export function formatCustomSkillsBlock(
|
||||
**The user has installed these custom skills. They MUST be evaluated for EVERY delegation.**
|
||||
Subagents are STATELESS — they lose all custom knowledge unless you pass these skills via \`load_skills\`.
|
||||
|
||||
| Skill | Expertise Domain | Source |
|
||||
|-------|------------------|--------|
|
||||
${customRows.join("\n")}
|
||||
|
||||
> **CRITICAL**: Ignoring user-installed skills when they match the task domain is a failure.
|
||||
@@ -200,7 +188,7 @@ export function buildCategorySkillsDelegationGuide(categories: AvailableCategory
|
||||
|
||||
const categoryRows = categories.map((c) => {
|
||||
const desc = c.description || c.name
|
||||
return `| \`${c.name}\` | ${desc} |`
|
||||
return `- \`${c.name}\` — ${desc}`
|
||||
})
|
||||
|
||||
const builtinSkills = skills.filter((s) => s.location === "plugin")
|
||||
@@ -208,13 +196,13 @@ export function buildCategorySkillsDelegationGuide(categories: AvailableCategory
|
||||
|
||||
const builtinRows = builtinSkills.map((s) => {
|
||||
const desc = truncateDescription(s.description)
|
||||
return `| \`${s.name}\` | ${desc} |`
|
||||
return `- \`${s.name}\` — ${desc}`
|
||||
})
|
||||
|
||||
const customRows = customSkills.map((s) => {
|
||||
const desc = truncateDescription(s.description)
|
||||
const source = s.location === "project" ? "project" : "user"
|
||||
return `| \`${s.name}\` | ${desc} | ${source} |`
|
||||
return `- \`${s.name}\` (${source}) — ${desc}`
|
||||
})
|
||||
|
||||
const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills)
|
||||
@@ -224,8 +212,6 @@ export function buildCategorySkillsDelegationGuide(categories: AvailableCategory
|
||||
if (customSkills.length > 0 && builtinSkills.length > 0) {
|
||||
skillsSection = `#### Built-in Skills
|
||||
|
||||
| Skill | Expertise Domain |
|
||||
|-------|------------------|
|
||||
${builtinRows.join("\n")}
|
||||
|
||||
${customSkillBlock}`
|
||||
@@ -236,8 +222,6 @@ ${customSkillBlock}`
|
||||
|
||||
Skills inject specialized instructions into the subagent. Read the description to understand when each skill applies.
|
||||
|
||||
| Skill | Expertise Domain |
|
||||
|-------|------------------|
|
||||
${builtinRows.join("\n")}`
|
||||
}
|
||||
|
||||
@@ -249,8 +233,6 @@ ${builtinRows.join("\n")}`
|
||||
|
||||
Each category is configured with a model optimized for that domain. Read the description to understand when to use it.
|
||||
|
||||
| Category | Domain / Best For |
|
||||
|----------|-------------------|
|
||||
${categoryRows.join("\n")}
|
||||
|
||||
${skillsSection}
|
||||
@@ -322,11 +304,9 @@ export function buildOracleSection(agents: AvailableAgent[]): string {
|
||||
|
||||
Oracle is a read-only, expensive, high-quality reasoning model for debugging and architecture. Consultation only.
|
||||
|
||||
### WHEN to Consult:
|
||||
### WHEN to Consult (Oracle FIRST, then implement):
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
${useWhen.map((w) => `| ${w} | Oracle FIRST, then implement |`).join("\n")}
|
||||
${useWhen.map((w) => `- ${w}`).join("\n")}
|
||||
|
||||
### WHEN NOT to Consult:
|
||||
|
||||
@@ -338,39 +318,44 @@ Briefly announce "Consulting Oracle for [reason]" before invocation.
|
||||
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
|
||||
|
||||
### Oracle Background Task Policy:
|
||||
- Oracle takes 20+ min by design. Always wait for Oracle results via \`background_output\` before final answer.
|
||||
- Oracle provides independent analysis from a different angle that catches blind spots — even when you believe you already have sufficient context, Oracle's perspective is worth the wait.
|
||||
|
||||
**You MUST collect Oracle results before your final answer. No exceptions.**
|
||||
|
||||
- Oracle may take several minutes. This is normal and expected.
|
||||
- When Oracle is running and you finish your own exploration/analysis, your next action is \`background_output(task_id="...")\` on Oracle — NOT delivering a final answer.
|
||||
- Oracle catches blind spots you cannot see — its value is HIGHEST when you think you don't need it.
|
||||
- **NEVER** cancel Oracle. **NEVER** use \`background_cancel(all=true)\` when Oracle is running. Cancel disposable tasks (explore, librarian) individually by taskId instead.
|
||||
</Oracle_Usage>`
|
||||
}
|
||||
|
||||
export function buildHardBlocksSection(): string {
|
||||
const blocks = [
|
||||
"| Type error suppression (`as any`, `@ts-ignore`) | Never |",
|
||||
"| Commit without explicit request | Never |",
|
||||
"| Speculate about unread code | Never |",
|
||||
"| Leave code in broken state after failures | Never |",
|
||||
"- Type error suppression (`as any`, `@ts-ignore`) — **Never**",
|
||||
"- Commit without explicit request — **Never**",
|
||||
"- Speculate about unread code — **Never**",
|
||||
"- Leave code in broken state after failures — **Never**",
|
||||
"- `background_cancel(all=true)` when Oracle is running — **Never.** Cancel tasks individually by taskId.",
|
||||
"- Delivering final answer before collecting Oracle result — **Never.** Always `background_output` Oracle first.",
|
||||
]
|
||||
|
||||
return `## Hard Blocks (NEVER violate)
|
||||
|
||||
| Constraint | No Exceptions |
|
||||
|------------|---------------|
|
||||
${blocks.join("\n")}`
|
||||
}
|
||||
|
||||
export function buildAntiPatternsSection(): string {
|
||||
const patterns = [
|
||||
"| **Type Safety** | `as any`, `@ts-ignore`, `@ts-expect-error` |",
|
||||
"| **Error Handling** | Empty catch blocks `catch(e) {}` |",
|
||||
"| **Testing** | Deleting failing tests to \"pass\" |",
|
||||
"| **Search** | Firing agents for single-line typos or obvious syntax errors |",
|
||||
"| **Debugging** | Shotgun debugging, random changes |",
|
||||
"- **Type Safety**: `as any`, `@ts-ignore`, `@ts-expect-error`",
|
||||
"- **Error Handling**: Empty catch blocks `catch(e) {}`",
|
||||
"- **Testing**: Deleting failing tests to \"pass\"",
|
||||
"- **Search**: Firing agents for single-line typos or obvious syntax errors",
|
||||
"- **Debugging**: Shotgun debugging, random changes",
|
||||
"- **Background Tasks**: `background_cancel(all=true)` — always cancel individually by taskId",
|
||||
"- **Oracle**: Skipping Oracle results when Oracle was launched — ALWAYS collect via `background_output`",
|
||||
]
|
||||
|
||||
return `## Anti-Patterns (BLOCKING violations)
|
||||
|
||||
| Category | Forbidden |
|
||||
|----------|-----------|
|
||||
${patterns.join("\n")}`
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +37,10 @@ function buildTaskManagementSection(useTaskSystem: boolean): string {
|
||||
|
||||
### When to Create Tasks (MANDATORY)
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
| Multi-step task (2+ steps) | ALWAYS \`TaskCreate\` first |
|
||||
| Uncertain scope | ALWAYS (tasks clarify thinking) |
|
||||
| User request with multiple items | ALWAYS |
|
||||
| Complex single task | \`TaskCreate\` to break down |
|
||||
- Multi-step task (2+ steps) → ALWAYS \`TaskCreate\` first
|
||||
- Uncertain scope → ALWAYS (tasks clarify thinking)
|
||||
- User request with multiple items → ALWAYS
|
||||
- Complex single task → \`TaskCreate\` to break down
|
||||
|
||||
### Workflow (NON-NEGOTIABLE)
|
||||
|
||||
@@ -61,12 +59,10 @@ function buildTaskManagementSection(useTaskSystem: boolean): string {
|
||||
|
||||
### Anti-Patterns (BLOCKING)
|
||||
|
||||
| Violation | Why It's Bad |
|
||||
|-----------|--------------|
|
||||
| Skipping tasks on multi-step tasks | User has no visibility, steps get forgotten |
|
||||
| Batch-completing multiple tasks | Defeats real-time tracking purpose |
|
||||
| Proceeding without marking in_progress | No indication of what you're working on |
|
||||
| Finishing without completing tasks | Task appears incomplete to user |
|
||||
- Skipping tasks on multi-step tasks — user has no visibility, steps get forgotten
|
||||
- Batch-completing multiple tasks — defeats real-time tracking purpose
|
||||
- Proceeding without marking in_progress — no indication of what you're working on
|
||||
- Finishing without completing tasks — task appears incomplete to user
|
||||
|
||||
**FAILURE TO USE TASKS ON NON-TRIVIAL TASKS = INCOMPLETE WORK.**
|
||||
|
||||
@@ -95,12 +91,10 @@ Should I proceed with [recommendation], or would you prefer differently?
|
||||
|
||||
### When to Create Todos (MANDATORY)
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
| Multi-step task (2+ steps) | ALWAYS create todos first |
|
||||
| Uncertain scope | ALWAYS (todos clarify thinking) |
|
||||
| User request with multiple items | ALWAYS |
|
||||
| Complex single task | Create todos to break down |
|
||||
- Multi-step task (2+ steps) → ALWAYS create todos first
|
||||
- Uncertain scope → ALWAYS (todos clarify thinking)
|
||||
- User request with multiple items → ALWAYS
|
||||
- Complex single task → Create todos to break down
|
||||
|
||||
### Workflow (NON-NEGOTIABLE)
|
||||
|
||||
@@ -119,12 +113,10 @@ Should I proceed with [recommendation], or would you prefer differently?
|
||||
|
||||
### Anti-Patterns (BLOCKING)
|
||||
|
||||
| Violation | Why It's Bad |
|
||||
|-----------|--------------|
|
||||
| Skipping todos on multi-step tasks | User has no visibility, steps get forgotten |
|
||||
| Batch-completing multiple todos | Defeats real-time tracking purpose |
|
||||
| Proceeding without marking in_progress | No indication of what you're working on |
|
||||
| Finishing without completing todos | Task appears incomplete to user |
|
||||
- Skipping todos on multi-step tasks — user has no visibility, steps get forgotten
|
||||
- Batch-completing multiple todos — defeats real-time tracking purpose
|
||||
- Proceeding without marking in_progress — no indication of what you're working on
|
||||
- Finishing without completing todos — task appears incomplete to user
|
||||
|
||||
**FAILURE TO USE TODOS ON NON-TRIVIAL TASKS = INCOMPLETE WORK.**
|
||||
|
||||
@@ -200,23 +192,19 @@ ${keyTriggers}
|
||||
|
||||
### Step 1: Classify Request Type
|
||||
|
||||
| Type | Signal | Action |
|
||||
|------|--------|--------|
|
||||
| **Trivial** | Single file, known location, direct answer | Direct tools only (UNLESS Key Trigger applies) |
|
||||
| **Explicit** | Specific file/line, clear command | Execute directly |
|
||||
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
|
||||
| **Open-ended** | "Improve", "Refactor", "Add feature" | Assess codebase first |
|
||||
| **Ambiguous** | Unclear scope, multiple interpretations | Ask ONE clarifying question |
|
||||
- **Trivial** (single file, known location, direct answer) → Direct tools only (UNLESS Key Trigger applies)
|
||||
- **Explicit** (specific file/line, clear command) → Execute directly
|
||||
- **Exploratory** ("How does X work?", "Find Y") → Fire explore (1-3) + tools in parallel
|
||||
- **Open-ended** ("Improve", "Refactor", "Add feature") → Assess codebase first
|
||||
- **Ambiguous** (unclear scope, multiple interpretations) → Ask ONE clarifying question
|
||||
|
||||
### Step 2: Check for Ambiguity
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Single valid interpretation | Proceed |
|
||||
| Multiple interpretations, similar effort | Proceed with reasonable default, note assumption |
|
||||
| Multiple interpretations, 2x+ effort difference | **MUST ask** |
|
||||
| Missing critical info (file, error, context) | **MUST ask** |
|
||||
| User's design seems flawed or suboptimal | **MUST raise concern** before implementing |
|
||||
- Single valid interpretation → Proceed
|
||||
- Multiple interpretations, similar effort → Proceed with reasonable default, note assumption
|
||||
- Multiple interpretations, 2x+ effort difference → **MUST ask**
|
||||
- Missing critical info (file, error, context) → **MUST ask**
|
||||
- User's design seems flawed or suboptimal → **MUST raise concern** before implementing
|
||||
|
||||
### Step 3: Validate Before Acting
|
||||
|
||||
@@ -259,12 +247,10 @@ Before following existing patterns, assess whether they're worth following.
|
||||
|
||||
### State Classification:
|
||||
|
||||
| State | Signals | Your Behavior |
|
||||
|-------|---------|---------------|
|
||||
| **Disciplined** | Consistent patterns, configs present, tests exist | Follow existing style strictly |
|
||||
| **Transitional** | Mixed patterns, some structure | Ask: "I see X and Y patterns. Which to follow?" |
|
||||
| **Legacy/Chaotic** | No consistency, outdated patterns | Propose: "No clear conventions. I suggest [X]. OK?" |
|
||||
| **Greenfield** | New/empty project | Apply modern best practices |
|
||||
- **Disciplined** (consistent patterns, configs present, tests exist) → Follow existing style strictly
|
||||
- **Transitional** (mixed patterns, some structure) → Ask: "I see X and Y patterns. Which to follow?"
|
||||
- **Legacy/Chaotic** (no consistency, outdated patterns) → Propose: "No clear conventions. I suggest [X]. OK?"
|
||||
- **Greenfield** (new/empty project) → Apply modern best practices
|
||||
|
||||
IMPORTANT: If codebase appears undisciplined, verify before assuming:
|
||||
- Different patterns may serve different purposes (intentional)
|
||||
@@ -309,8 +295,10 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
|
||||
### Background Result Collection:
|
||||
1. Launch parallel agents → receive task_ids
|
||||
2. Continue immediate work
|
||||
3. When results needed: \`background_output(task_id="...")\`
|
||||
4. Before final answer: cancel disposable tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. Always wait for Oracle — collect its result via \`background_output\` before answering.
|
||||
3. When results needed: \`background_output(task_id=\"...\")\`
|
||||
4. Before final answer, cancel DISPOSABLE tasks (explore, librarian) individually: \`background_cancel(taskId=\"bg_explore_xxx\")\`, \`background_cancel(taskId=\"bg_librarian_xxx\")\`
|
||||
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
|
||||
|
||||
@@ -362,12 +350,10 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
|
||||
Every \`task()\` output includes a session_id. **USE IT.**
|
||||
|
||||
**ALWAYS continue when:**
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Task failed/incomplete | \`session_id="{session_id}", prompt="Fix: {specific error}"\` |
|
||||
| Follow-up question on result | \`session_id="{session_id}", prompt="Also: {question}"\` |
|
||||
| Multi-turn with same agent | \`session_id="{session_id}"\` - NEVER start fresh |
|
||||
| Verification failed | \`session_id="{session_id}", prompt="Failed verification: {error}. Fix."\` |
|
||||
- Task failed/incomplete → \`session_id=\"{session_id}\", prompt=\"Fix: {specific error}\"\`
|
||||
- Follow-up question on result → \`session_id=\"{session_id}\", prompt=\"Also: {question}\"\`
|
||||
- Multi-turn with same agent → \`session_id=\"{session_id}\"\` - NEVER start fresh
|
||||
- Verification failed → \`session_id=\"{session_id}\", prompt=\"Failed verification: {error}. Fix.\"\`
|
||||
|
||||
**Why session_id is CRITICAL:**
|
||||
- Subagent has FULL conversation context preserved
|
||||
@@ -404,12 +390,10 @@ If project has build/test commands, run them at task completion.
|
||||
|
||||
### Evidence Requirements (task NOT complete without these):
|
||||
|
||||
| Action | Required Evidence |
|
||||
|--------|-------------------|
|
||||
| File edit | \`lsp_diagnostics\` clean on changed files |
|
||||
| Build command | Exit code 0 |
|
||||
| Test run | Pass (or explicit note of pre-existing failures) |
|
||||
| Delegation | Agent result received and verified |
|
||||
- **File edit** → \`lsp_diagnostics\` clean on changed files
|
||||
- **Build command** → Exit code 0
|
||||
- **Test run** → Pass (or explicit note of pre-existing failures)
|
||||
- **Delegation** → Agent result received and verified
|
||||
|
||||
**NO EVIDENCE = NOT COMPLETE.**
|
||||
|
||||
@@ -449,9 +433,9 @@ If verification fails:
|
||||
3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes."
|
||||
|
||||
### Before Delivering Final Answer:
|
||||
- Cancel disposable background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`
|
||||
- **Always wait for Oracle**: Oracle takes 20+ min by design and always provides valuable independent analysis from a different angle — even when you already have enough context. Collect Oracle results via \`background_output\` before answering.
|
||||
- When Oracle is running, cancel disposable tasks individually instead of using \`background_cancel(all=true)\`.
|
||||
- Cancel DISPOSABLE background tasks (explore, librarian) individually via \`background_cancel(taskId=\"...\")\`
|
||||
- **NEVER use \`background_cancel(all=true)\`.** Always cancel individually by taskId.
|
||||
- **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>
|
||||
|
||||
${oracleSection}
|
||||
|
||||
@@ -1,69 +1,71 @@
|
||||
# CLI KNOWLEDGE BASE
|
||||
# src/cli/ — CLI: install, run, doctor, mcp-oauth
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI. 5 commands: install, run, doctor, get-local-version, mcp-oauth.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Entry point (5 lines)
|
||||
├── cli-program.ts # Commander.js program (150+ lines, 5 commands)
|
||||
├── install.ts # TTY routing (TUI or CLI installer)
|
||||
├── cli-installer.ts # Non-interactive installer (164 lines)
|
||||
├── tui-installer.ts # Interactive TUI with @clack/prompts (140 lines)
|
||||
├── config-manager/ # 20 config utilities
|
||||
│ ├── add-plugin-to-opencode-config.ts # Plugin registration
|
||||
│ ├── add-provider-config.ts # Provider setup (Google/Antigravity)
|
||||
│ ├── detect-current-config.ts # Installed providers detection
|
||||
│ ├── write-omo-config.ts # JSONC writing
|
||||
│ ├── generate-omo-config.ts # Config generation
|
||||
│ ├── jsonc-provider-editor.ts # JSONC editing
|
||||
│ └── ... # 14 more utilities
|
||||
├── doctor/ # 4 check categories, 21 check files
|
||||
│ ├── runner.ts # Parallel check execution + result aggregation
|
||||
│ ├── formatter.ts # Colored output (default/status/verbose/JSON)
|
||||
│ └── checks/ # system (4), config (1), tools (4), models (6 sub-checks)
|
||||
├── run/ # Session launcher (24 files)
|
||||
│ ├── runner.ts # Run orchestration (126 lines)
|
||||
│ ├── agent-resolver.ts # Agent: flag → env → config → Sisyphus
|
||||
│ ├── session-resolver.ts # Session create or resume with retries
|
||||
│ ├── event-handlers.ts # Event processing (125 lines)
|
||||
│ ├── completion.ts # Completion detection
|
||||
│ └── poll-for-completion.ts # Polling with timeout
|
||||
├── mcp-oauth/ # OAuth token management (login, logout, status)
|
||||
├── get-local-version/ # Version detection + update check
|
||||
├── model-fallback.ts # Model fallback configuration
|
||||
└── provider-availability.ts # Provider availability checks
|
||||
```
|
||||
Commander.js CLI with 5 commands. Entry: `index.ts` → `runCli()` in `cli-program.ts`.
|
||||
|
||||
## COMMANDS
|
||||
|
||||
| Command | Purpose | Key Logic |
|
||||
|---------|---------|-----------|
|
||||
| `install` | Interactive setup | Provider selection → config generation → plugin registration |
|
||||
| `run` | Session launcher | Agent: flag → env → config → Sisyphus. Enforces todo completion. |
|
||||
| `doctor` | 4-category health checks | system, config, tools, models (6 sub-checks) |
|
||||
| `get-local-version` | Version check | Detects installed, compares with npm latest |
|
||||
| `mcp-oauth` | OAuth tokens | login (PKCE flow), logout, status |
|
||||
| `install` | Interactive/non-interactive setup | Provider selection → config gen → plugin registration |
|
||||
| `run <message>` | Non-interactive session launcher | Agent resolution (flag → env → config → Sisyphus) |
|
||||
| `doctor` | 4-category health checks | System, Config, Tools, Models |
|
||||
| `get-local-version` | Version detection | Installed vs npm latest |
|
||||
| `mcp-oauth` | OAuth token management | login (PKCE), logout, status |
|
||||
|
||||
## RUN SESSION LIFECYCLE
|
||||
## STRUCTURE
|
||||
|
||||
1. Load config, resolve agent (CLI > env > config > Sisyphus)
|
||||
2. Create server connection (port/attach), setup cleanup/signal handlers
|
||||
3. Resolve session (create new or resume with retries)
|
||||
4. Send prompt, start event processing, poll for completion
|
||||
5. Execute on-complete hook, output JSON if requested, cleanup
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Entry point → runCli()
|
||||
├── cli-program.ts # Commander.js program (5 commands)
|
||||
├── install.ts # Routes to TUI or CLI installer
|
||||
├── cli-installer.ts # Non-interactive (console output)
|
||||
├── tui-installer.ts # Interactive (@clack/prompts)
|
||||
├── model-fallback.ts # Model config gen by provider availability
|
||||
├── provider-availability.ts # Provider detection
|
||||
├── fallback-chain-resolution.ts # Fallback chain logic
|
||||
├── config-manager/ # 20 config utilities
|
||||
│ ├── plugin registration, provider config
|
||||
│ ├── JSONC operations, auth plugins
|
||||
│ └── npm dist-tags, binary detection
|
||||
├── doctor/
|
||||
│ ├── runner.ts # Parallel check execution
|
||||
│ ├── formatter.ts # Output formatting
|
||||
│ └── checks/ # 15 check files in 4 categories
|
||||
│ ├── system.ts # Binary, plugin, version
|
||||
│ ├── config.ts # JSONC validity, Zod schema
|
||||
│ ├── tools.ts # AST-Grep, LSP, GH CLI, MCP
|
||||
│ └── model-resolution.ts # Cache, resolution, overrides (6 sub-files)
|
||||
├── run/ # Session launcher
|
||||
│ ├── runner.ts # Main orchestration
|
||||
│ ├── agent-resolver.ts # Flag → env → config → Sisyphus
|
||||
│ ├── session-resolver.ts # Create/resume sessions
|
||||
│ ├── event-handlers.ts # Event processing
|
||||
│ └── poll-for-completion.ts # Wait for todos/background tasks
|
||||
└── mcp-oauth/ # OAuth token management
|
||||
```
|
||||
|
||||
## HOW TO ADD CHECK
|
||||
## MODEL FALLBACK SYSTEM
|
||||
|
||||
1. Create `src/cli/doctor/checks/my-check.ts`
|
||||
2. Export `getXXXCheckDefinition()` returning `CheckDefinition`
|
||||
3. Add to `getAllCheckDefinitions()` in `checks/index.ts`
|
||||
Priority: Claude > OpenAI > Gemini > Copilot > OpenCode Zen > Z.ai > Kimi > glm-4.7-free
|
||||
|
||||
## ANTI-PATTERNS
|
||||
Agent-specific: librarian→ZAI, explore→Haiku/nano, hephaestus→requires OpenAI/Copilot
|
||||
|
||||
- **Blocking in non-TTY**: Check `process.stdout.isTTY`
|
||||
- **Direct JSON.parse**: Use `parseJsonc()` from shared
|
||||
- **Silent failures**: Return `warn` or `fail` in doctor, don't throw
|
||||
- **Hardcoded paths**: Use `getOpenCodeConfigPaths()` from config-manager
|
||||
## DOCTOR CHECKS
|
||||
|
||||
| Category | Validates |
|
||||
|----------|-----------|
|
||||
| **System** | Binary found, version >=1.0.150, plugin registered, version match |
|
||||
| **Config** | JSONC validity, Zod schema, model override syntax |
|
||||
| **Tools** | AST-Grep, comment-checker, LSP servers, GH CLI, MCP servers |
|
||||
| **Models** | Cache exists, model resolution, agent/category overrides, availability |
|
||||
|
||||
## HOW TO ADD A DOCTOR CHECK
|
||||
|
||||
1. Create `src/cli/doctor/checks/{name}.ts`
|
||||
2. Export check function matching `DoctorCheck` interface
|
||||
3. Register in `checks/index.ts`
|
||||
|
||||
@@ -1,52 +1,50 @@
|
||||
# CONFIG KNOWLEDGE BASE
|
||||
# src/config/ — Zod v4 Schema System
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Zod schema definitions for plugin configuration. 21 component files composing `OhMyOpenCodeConfigSchema` with multi-level inheritance and JSONC support.
|
||||
22 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional — omitted fields use plugin defaults.
|
||||
|
||||
## SCHEMA TREE
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
config/
|
||||
├── schema/ # 21 schema component files
|
||||
│ ├── oh-my-opencode-config.ts # Root schema composition (57 lines)
|
||||
│ ├── agent-names.ts # BuiltinAgentNameSchema (11 agents), BuiltinSkillNameSchema
|
||||
│ ├── agent-overrides.ts # AgentOverrideConfigSchema (model, variant, temp, thinking...)
|
||||
│ ├── categories.ts # 8 categories: visual-engineering, ultrabrain, deep, artistry, quick, ...
|
||||
│ ├── hooks.ts # HookNameSchema (100+ hook names)
|
||||
│ ├── commands.ts # BuiltinCommandNameSchema
|
||||
│ ├── experimental.ts # ExperimentalConfigSchema
|
||||
│ ├── dynamic-context-pruning.ts # DynamicContextPruningConfigSchema (55 lines)
|
||||
│ ├── background-task.ts # BackgroundTaskConfigSchema
|
||||
│ ├── claude-code.ts # ClaudeCodeConfigSchema
|
||||
│ ├── comment-checker.ts # CommentCheckerConfigSchema
|
||||
│ ├── notification.ts # NotificationConfigSchema
|
||||
│ ├── ralph-loop.ts # RalphLoopConfigSchema
|
||||
│ ├── sisyphus.ts # SisyphusConfigSchema
|
||||
│ ├── sisyphus-agent.ts # SisyphusAgentConfigSchema
|
||||
│ ├── skills.ts # SkillsConfigSchema (45 lines)
|
||||
│ ├── tmux.ts # TmuxConfigSchema, TmuxLayoutSchema
|
||||
│ ├── websearch.ts # WebsearchConfigSchema
|
||||
│ ├── browser-automation.ts # BrowserAutomationConfigSchema
|
||||
│ ├── git-master.ts # GitMasterConfigSchema
|
||||
│ └── babysitting.ts # BabysittingConfigSchema
|
||||
├── schema.ts # Barrel export (24 lines)
|
||||
├── schema.test.ts # Validation tests (735 lines)
|
||||
├── types.ts # TypeScript types from schemas
|
||||
└── index.ts # Barrel export (33 lines)
|
||||
config/schema/
|
||||
├── oh-my-opencode-config.ts # ROOT: OhMyOpenCodeConfigSchema (composes all below)
|
||||
├── agent-names.ts # BuiltinAgentNameSchema (11), OverridableAgentNameSchema (14)
|
||||
├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent)
|
||||
├── categories.ts # 8 built-in + custom categories
|
||||
├── hooks.ts # HookNameSchema (46 hooks)
|
||||
├── skills.ts # SkillsConfigSchema (sources, paths, recursive)
|
||||
├── commands.ts # BuiltinCommandNameSchema
|
||||
├── experimental.ts # Feature flags (plugin_load_timeout_ms min 1000, hashline_edit)
|
||||
├── sisyphus.ts # SisyphusConfigSchema (task system)
|
||||
├── sisyphus-agent.ts # SisyphusAgentConfigSchema
|
||||
├── ralph-loop.ts # RalphLoopConfigSchema
|
||||
├── tmux.ts # TmuxConfigSchema + TmuxLayoutSchema
|
||||
├── websearch.ts # provider: "exa" | "tavily"
|
||||
├── claude-code.ts # CC compatibility settings
|
||||
├── comment-checker.ts # AI comment detection config
|
||||
├── notification.ts # OS notification settings
|
||||
├── git-master.ts # commit_footer: boolean | string
|
||||
├── browser-automation.ts # provider: playwright | agent-browser | playwright-cli
|
||||
├── background-task.ts # Concurrency limits per model/provider
|
||||
├── babysitting.ts # Unstable agent monitoring
|
||||
├── dynamic-context-pruning.ts # Context pruning settings
|
||||
└── internal/permission.ts # AgentPermissionSchema
|
||||
```
|
||||
|
||||
## ROOT SCHEMA
|
||||
## ROOT SCHEMA FIELDS (26)
|
||||
|
||||
`OhMyOpenCodeConfigSchema` composes: `$schema`, `new_task_system_enabled`, `default_run_agent`, `auto_update`, `disabled_{mcps,agents,skills,hooks,commands,tools}`, `agents` (14 agent keys), `categories` (8 built-in), `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`
|
||||
`$schema`, `new_task_system_enabled`, `default_run_agent`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `_migrations`
|
||||
|
||||
## CONFIGURATION HIERARCHY
|
||||
## AGENT OVERRIDE FIELDS (21)
|
||||
|
||||
Project (`.opencode/oh-my-opencode.json`) → User (`~/.config/opencode/oh-my-opencode.json`) → Defaults
|
||||
`model`, `variant`, `category`, `skills`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions`
|
||||
|
||||
## AGENT OVERRIDE FIELDS
|
||||
## HOW TO ADD CONFIG
|
||||
|
||||
`model`, `variant`, `category`, `skills`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `prompt`, `prompt_append`, `tools`, `permission`, `providerOptions`, `disable`, `description`, `mode`, `color`
|
||||
|
||||
## AFTER SCHEMA CHANGES
|
||||
|
||||
Run `bun run build:schema` to regenerate `dist/oh-my-opencode.schema.json`
|
||||
1. Create `src/config/schema/{name}.ts` with Zod schema
|
||||
2. Add field to `oh-my-opencode-config.ts` root schema
|
||||
3. Reference via `z.infer<typeof YourSchema>` for TypeScript types
|
||||
4. Access in handlers via `pluginConfig.{name}`
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HookName, OhMyOpenCodeConfig } from "./config"
|
||||
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
|
||||
import type { BackgroundManager } from "./features/background-agent"
|
||||
import type { PluginContext } from "./plugin/types"
|
||||
import type { ModelCacheState } from "./plugin-state"
|
||||
|
||||
import { createCoreHooks } from "./plugin/hooks/create-core-hooks"
|
||||
import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks"
|
||||
@@ -13,6 +14,7 @@ export type CreatedHooks = ReturnType<typeof createHooks>
|
||||
export function createHooks(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
modelCacheState: ModelCacheState
|
||||
backgroundManager: BackgroundManager
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHookEnabled: boolean
|
||||
@@ -22,6 +24,7 @@ export function createHooks(args: {
|
||||
const {
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
backgroundManager,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
@@ -32,6 +35,7 @@ export function createHooks(args: {
|
||||
const core = createCoreHooks({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
})
|
||||
|
||||
@@ -1,83 +1,69 @@
|
||||
# FEATURES KNOWLEDGE BASE
|
||||
# src/features/ — 18 Feature Modules
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
18 feature modules extending plugin capabilities: agent orchestration, skill loading, Claude Code compatibility, MCP management, task storage, and tmux integration.
|
||||
Standalone feature modules wired into plugin/ layer. Each is self-contained with own types, implementation, and tests.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Task lifecycle, concurrency (56 files, 1701-line manager)
|
||||
│ ├── manager.ts # Main task orchestration (1701 lines)
|
||||
│ ├── concurrency.ts # Parallel execution limits per provider/model (137 lines)
|
||||
│ ├── task-history.ts # Task execution history per parent session (76 lines)
|
||||
│ └── spawner/ # Task spawning: factory, starter, resumer, tmux (8 files)
|
||||
├── tmux-subagent/ # Tmux integration (28 files, 3303 LOC)
|
||||
│ └── manager.ts # Pane management, grid planning (350 lines)
|
||||
├── opencode-skill-loader/ # YAML frontmatter skill loading (28 files, 2967 LOC)
|
||||
│ ├── loader.ts # Skill discovery (4 scopes)
|
||||
│ ├── skill-directory-loader.ts # Recursive directory scanning (maxDepth=2)
|
||||
│ ├── skill-discovery.ts # getAllSkills() with caching + provider gating
|
||||
│ └── merger/ # Skill merging with scope priority
|
||||
├── mcp-oauth/ # OAuth 2.0 flow for MCP (18 files, 2164 LOC)
|
||||
│ ├── provider.ts # McpOAuthProvider class
|
||||
│ ├── oauth-authorization-flow.ts # PKCE, callback handling
|
||||
│ └── dcr.ts # Dynamic Client Registration (RFC 7591)
|
||||
├── skill-mcp-manager/ # MCP client lifecycle per session (12 files, 1769 LOC)
|
||||
│ └── manager.ts # SkillMcpManager class (150 lines)
|
||||
├── builtin-skills/ # 5 built-in skills (10 files, 1921 LOC)
|
||||
│ └── skills/ # git-master (1112), playwright (313), dev-browser (222), frontend-ui-ux (80)
|
||||
├── builtin-commands/ # 7 command templates (11 files, 1511 LOC)
|
||||
│ └── templates/ # refactor (620), init-deep (306), handoff (178), start-work, ralph-loop, stop-continuation
|
||||
├── claude-tasks/ # Task schema + storage (7 files) — see AGENTS.md
|
||||
├── context-injector/ # AGENTS.md, README.md, rules injection (6 files, 809 LOC)
|
||||
├── claude-code-plugin-loader/ # Plugin discovery from .opencode/plugins/ (10 files)
|
||||
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion (6 files)
|
||||
├── claude-code-command-loader/ # Command loading from .opencode/commands/ (3 files)
|
||||
├── claude-code-agent-loader/ # Agent loading from .opencode/agents/ (3 files)
|
||||
├── claude-code-session-state/ # Subagent session state tracking (3 files)
|
||||
├── hook-message-injector/ # System message injection (4 files)
|
||||
├── task-toast-manager/ # Task progress notifications (4 files)
|
||||
├── boulder-state/ # Persistent state for multi-step ops (5 files)
|
||||
└── tool-metadata-store/ # Tool execution metadata caching (3 files)
|
||||
```
|
||||
## MODULE MAP
|
||||
|
||||
## KEY PATTERNS
|
||||
| Module | Files | Complexity | Purpose |
|
||||
|--------|-------|------------|---------|
|
||||
| **background-agent** | 49 | HIGH | Task lifecycle, concurrency (5/model), polling, spawner pattern |
|
||||
| **tmux-subagent** | 27 | HIGH | Tmux pane management, grid planning, session orchestration |
|
||||
| **opencode-skill-loader** | 25 | HIGH | YAML frontmatter skill loading from 4 scopes |
|
||||
| **mcp-oauth** | 10 | HIGH | OAuth 2.0 + PKCE + DCR (RFC 7591) for MCP servers |
|
||||
| **builtin-skills** | 10 | LOW | 6 skills: git-master, playwright, playwright-cli, agent-browser, dev-browser, frontend-ui-ux |
|
||||
| **skill-mcp-manager** | 10 | MEDIUM | MCP client lifecycle per session (stdio + HTTP) |
|
||||
| **claude-code-plugin-loader** | 10 | MEDIUM | Unified plugin discovery from .opencode/plugins/ |
|
||||
| **builtin-commands** | 9 | LOW | Command templates: refactor, init-deep, handoff, etc. |
|
||||
| **claude-code-mcp-loader** | 5 | MEDIUM | .mcp.json loading with ${VAR} env expansion |
|
||||
| **context-injector** | 4 | MEDIUM | AGENTS.md/README.md injection into context |
|
||||
| **boulder-state** | 4 | LOW | Persistent state for multi-step operations |
|
||||
| **hook-message-injector** | 4 | MEDIUM | System message injection for hooks |
|
||||
| **claude-tasks** | 4 | MEDIUM | Task schema + file storage + OpenCode todo sync |
|
||||
| **task-toast-manager** | 3 | MEDIUM | Task progress notifications |
|
||||
| **claude-code-agent-loader** | 3 | LOW | Load agents from .opencode/agents/ |
|
||||
| **claude-code-command-loader** | 3 | LOW | Load commands from .opencode/commands/ |
|
||||
| **claude-code-session-state** | 2 | LOW | Subagent session state tracking |
|
||||
| **tool-metadata-store** | 2 | LOW | Tool execution metadata cache |
|
||||
|
||||
**Background Agent Lifecycle:**
|
||||
pending → running → completed/error/cancelled/interrupt
|
||||
- Concurrency: Per provider/model limits (default: 5), queue-based FIFO
|
||||
- Events: session.idle + session.error drive completion detection
|
||||
- Key methods: `launch()`, `resume()`, `cancelTask()`, `getTask()`, `getAllDescendantTasks()`
|
||||
## KEY MODULES
|
||||
|
||||
**Skill Loading Pipeline (4-scope priority):**
|
||||
opencode-project (`.opencode/skills/`) > opencode (`~/.config/opencode/skills/`) > project (`.claude/skills/`) > user (`~/.claude/skills/`)
|
||||
### background-agent (49 files, ~10k LOC)
|
||||
|
||||
**Claude Code Compatibility Layer:**
|
||||
5 loaders: agent-loader, command-loader, mcp-loader, plugin-loader, session-state
|
||||
Core orchestration engine. `BackgroundManager` manages task lifecycle:
|
||||
- States: pending → running → completed/error/cancelled/interrupt
|
||||
- Concurrency: per-model/provider limits via `ConcurrencyManager` (FIFO queue)
|
||||
- Polling: 3s interval, completion via idle events + stability detection (10s unchanged)
|
||||
- spawner/: 8 focused files composing via `SpawnerContext` interface
|
||||
|
||||
**SKILL.md Format:**
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
description: "..."
|
||||
model: "claude-opus-4-6" # optional
|
||||
agent: "sisyphus" # optional
|
||||
mcp: # optional embedded MCPs
|
||||
server-name:
|
||||
type: http
|
||||
url: https://...
|
||||
---
|
||||
# Skill instruction content
|
||||
```
|
||||
### opencode-skill-loader (25 files, ~3.2k LOC)
|
||||
|
||||
## HOW TO ADD
|
||||
4-scope skill discovery (project > opencode > user > global):
|
||||
- YAML frontmatter parsing from SKILL.md files
|
||||
- Skill merger with priority deduplication
|
||||
- Template resolution with variable substitution
|
||||
- Provider gating for model-specific skills
|
||||
|
||||
1. Create directory under `src/features/`
|
||||
2. Add `index.ts`, `types.ts`, `constants.ts` as needed
|
||||
3. Export from `index.ts` following barrel pattern
|
||||
4. Register in main plugin if plugin-level feature
|
||||
### tmux-subagent (27 files, ~3.6k LOC)
|
||||
|
||||
## CHILD DOCUMENTATION
|
||||
State-first tmux integration:
|
||||
- `TmuxSessionManager`: pane lifecycle, grid planning
|
||||
- Spawn action decider + target finder
|
||||
- Polling manager for session health
|
||||
- Event handlers for pane creation/destruction
|
||||
|
||||
- See `claude-tasks/AGENTS.md` for task schema and storage details
|
||||
### builtin-skills (6 skill objects)
|
||||
|
||||
| Skill | Size | MCP | Tools |
|
||||
|-------|------|-----|-------|
|
||||
| git-master | 1111 LOC | — | Bash |
|
||||
| playwright | 312 LOC | @playwright/mcp | — |
|
||||
| agent-browser | (in playwright.ts) | — | Bash(agent-browser:*) |
|
||||
| playwright-cli | 268 LOC | — | Bash(playwright-cli:*) |
|
||||
| dev-browser | 221 LOC | — | Bash |
|
||||
| frontend-ui-ux | 79 LOC | — | — |
|
||||
|
||||
Browser variant selected by `browserProvider` config: playwright (default) | playwright-cli | agent-browser.
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { cleanupTaskAfterSessionEnds } from "./session-task-cleanup"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
|
||||
type Event = { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key]
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export function handleBackgroundEvent(args: {
|
||||
event: Event
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
getAllDescendantTasks: (sessionID: string) => BackgroundTask[]
|
||||
releaseConcurrencyKey?: (key: string) => void
|
||||
cancelTask: (
|
||||
taskId: string,
|
||||
options: { source: string; reason: string; skipNotification: true }
|
||||
) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
tasks: Map<string, BackgroundTask>
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
emitIdleEvent: (sessionID: string) => void
|
||||
}): void {
|
||||
const {
|
||||
event,
|
||||
findBySession,
|
||||
getAllDescendantTasks,
|
||||
releaseConcurrencyKey,
|
||||
cancelTask,
|
||||
tryCompleteTask,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
tasks,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
emitIdleEvent,
|
||||
} = args
|
||||
|
||||
const props = event.properties
|
||||
|
||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const sessionID = getString(props, "sessionID")
|
||||
if (!sessionID) return
|
||||
|
||||
const task = findBySession(sessionID)
|
||||
if (!task) return
|
||||
|
||||
const existingTimer = idleDeferralTimers.get(task.id)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const type = getString(props, "type")
|
||||
const tool = getString(props, "tool")
|
||||
|
||||
if (!task.progress) {
|
||||
task.progress = { toolCalls: 0, lastUpdate: new Date() }
|
||||
}
|
||||
task.progress.lastUpdate = new Date()
|
||||
|
||||
if (type === "tool" || tool) {
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = tool
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
if (!props || !isRecord(props)) return
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const sessionID = getString(props, "sessionID")
|
||||
if (!sessionID) return
|
||||
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const errorRaw = props["error"]
|
||||
const dataRaw = isRecord(errorRaw) ? errorRaw["data"] : undefined
|
||||
const message =
|
||||
(isRecord(dataRaw) ? getString(dataRaw, "message") : undefined) ??
|
||||
(isRecord(errorRaw) ? getString(errorRaw, "message") : undefined) ??
|
||||
"Session error"
|
||||
|
||||
task.status = "error"
|
||||
task.error = message
|
||||
task.completedAt = new Date()
|
||||
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const infoRaw = props["info"]
|
||||
if (!isRecord(infoRaw)) return
|
||||
const sessionID = getString(infoRaw, "id")
|
||||
if (!sessionID) return
|
||||
|
||||
const tasksToCancel = new Map<string, BackgroundTask>()
|
||||
const directTask = findBySession(sessionID)
|
||||
if (directTask) {
|
||||
tasksToCancel.set(directTask.id, directTask)
|
||||
}
|
||||
for (const descendant of getAllDescendantTasks(sessionID)) {
|
||||
tasksToCancel.set(descendant.id, descendant)
|
||||
}
|
||||
if (tasksToCancel.size === 0) return
|
||||
|
||||
for (const task of tasksToCancel.values()) {
|
||||
if (task.status === "running" || task.status === "pending") {
|
||||
void cancelTask(task.id, {
|
||||
source: "session.deleted",
|
||||
reason: "Session deleted",
|
||||
skipNotification: true,
|
||||
}).catch((err) => {
|
||||
log("[background-agent] Failed to cancel task on session.deleted:", {
|
||||
taskId: task.id,
|
||||
error: err,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export function shutdownBackgroundManager(args: {
|
||||
shutdownTriggered: { value: boolean }
|
||||
stopPolling: () => void
|
||||
tasks: Map<string, BackgroundTask>
|
||||
client: PluginInput["client"]
|
||||
onShutdown?: () => void
|
||||
concurrencyManager: ConcurrencyManager
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
notifications: Map<string, BackgroundTask[]>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
processingKeys: Set<string>
|
||||
unregisterProcessCleanup: () => void
|
||||
}): void {
|
||||
const {
|
||||
shutdownTriggered,
|
||||
stopPolling,
|
||||
tasks,
|
||||
client,
|
||||
onShutdown,
|
||||
concurrencyManager,
|
||||
completionTimers,
|
||||
idleDeferralTimers,
|
||||
notifications,
|
||||
pendingByParent,
|
||||
queuesByKey,
|
||||
processingKeys,
|
||||
unregisterProcessCleanup,
|
||||
} = args
|
||||
|
||||
if (shutdownTriggered.value) return
|
||||
shutdownTriggered.value = true
|
||||
|
||||
log("[background-agent] Shutting down BackgroundManager")
|
||||
stopPolling()
|
||||
|
||||
for (const task of tasks.values()) {
|
||||
if (task.status === "running" && task.sessionID) {
|
||||
client.session.abort({ path: { id: task.sessionID } }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (onShutdown) {
|
||||
try {
|
||||
onShutdown()
|
||||
} catch (error) {
|
||||
log("[background-agent] Error in onShutdown callback:", error)
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasks.values()) {
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
}
|
||||
|
||||
for (const timer of completionTimers.values()) clearTimeout(timer)
|
||||
completionTimers.clear()
|
||||
|
||||
for (const timer of idleDeferralTimers.values()) clearTimeout(timer)
|
||||
idleDeferralTimers.clear()
|
||||
|
||||
concurrencyManager.clear()
|
||||
tasks.clear()
|
||||
notifications.clear()
|
||||
pendingByParent.clear()
|
||||
queuesByKey.clear()
|
||||
processingKeys.clear()
|
||||
unregisterProcessCleanup()
|
||||
|
||||
log("[background-agent] Shutdown complete")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
export function markForNotification(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
task: BackgroundTask
|
||||
): void {
|
||||
const queue = notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
export function getPendingNotifications(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
sessionID: string
|
||||
): BackgroundTask[] {
|
||||
return notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
export function clearNotifications(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
sessionID: string
|
||||
): void {
|
||||
notifications.delete(sessionID)
|
||||
}
|
||||
|
||||
export function clearNotificationsForTask(
|
||||
notifications: Map<string, BackgroundTask[]>,
|
||||
taskId: string
|
||||
): void {
|
||||
for (const [sessionID, tasks] of notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
notifications.delete(sessionID)
|
||||
} else {
|
||||
notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupPendingByParent(
|
||||
pendingByParent: Map<string, Set<string>>,
|
||||
task: BackgroundTask
|
||||
): void {
|
||||
if (!task.parentSessionID) return
|
||||
const pending = pendingByParent.get(task.parentSessionID)
|
||||
if (!pending) return
|
||||
|
||||
pending.delete(task.id)
|
||||
if (pending.size === 0) {
|
||||
pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
import { findNearestMessageWithFields } from "../hook-message-injector"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { formatDuration } from "./format-duration"
|
||||
import { isAbortedSessionError } from "./error-classifier"
|
||||
import { getMessageDir } from "./message-dir"
|
||||
import { buildBackgroundTaskNotificationText } from "./notification-builder"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type AgentModel = { providerID: string; modelID: string }
|
||||
|
||||
type MessageInfo = {
|
||||
agent?: string
|
||||
model?: AgentModel
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function extractMessageInfo(message: unknown): MessageInfo {
|
||||
if (!isRecord(message)) return {}
|
||||
const info = message["info"]
|
||||
if (!isRecord(info)) return {}
|
||||
|
||||
const agent = typeof info["agent"] === "string" ? info["agent"] : undefined
|
||||
const modelObj = info["model"]
|
||||
if (isRecord(modelObj)) {
|
||||
const providerID = modelObj["providerID"]
|
||||
const modelID = modelObj["modelID"]
|
||||
if (typeof providerID === "string" && typeof modelID === "string") {
|
||||
return { agent, model: { providerID, modelID } }
|
||||
}
|
||||
}
|
||||
|
||||
const providerID = info["providerID"]
|
||||
const modelID = info["modelID"]
|
||||
if (typeof providerID === "string" && typeof modelID === "string") {
|
||||
return { agent, model: { providerID, modelID } }
|
||||
}
|
||||
|
||||
return { agent }
|
||||
}
|
||||
|
||||
export async function notifyParentSession(args: {
|
||||
task: BackgroundTask
|
||||
tasks: Map<string, BackgroundTask>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
client: OpencodeClient
|
||||
}): Promise<void> {
|
||||
const { task, tasks, pendingByParent, completionTimers, clearNotificationsForTask, client } = args
|
||||
|
||||
const duration = formatDuration(task.startedAt ?? 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 = pendingByParent.get(task.parentSessionID)
|
||||
if (pendingSet) {
|
||||
pendingSet.delete(task.id)
|
||||
if (pendingSet.size === 0) {
|
||||
pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const allComplete = !pendingSet || pendingSet.size === 0
|
||||
const remainingCount = pendingSet?.size ?? 0
|
||||
|
||||
const completedTasks = allComplete
|
||||
? Array.from(tasks.values()).filter(
|
||||
(t) =>
|
||||
t.parentSessionID === task.parentSessionID &&
|
||||
t.status !== "running" &&
|
||||
t.status !== "pending"
|
||||
)
|
||||
: []
|
||||
|
||||
const notification = buildBackgroundTaskNotificationText({
|
||||
task,
|
||||
duration,
|
||||
allComplete,
|
||||
remainingCount,
|
||||
completedTasks,
|
||||
})
|
||||
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: AgentModel | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: task.parentSessionID },
|
||||
})
|
||||
const raw = normalizeSDKResponse(messagesResp, [] as unknown[])
|
||||
const messages = Array.isArray(raw) ? raw : []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const extracted = extractMessageInfo(messages[i])
|
||||
if (extracted.agent || extracted.model) {
|
||||
agent = extracted.agent ?? task.parentAgent
|
||||
model = extracted.model
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
...(task.parentTools ? { tools: task.parentTools } : {}),
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
noReply: !allComplete,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
|
||||
if (!allComplete) return
|
||||
|
||||
for (const completedTask of completedTasks) {
|
||||
const taskId = completedTask.id
|
||||
const existingTimer = completionTimers.get(taskId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
completionTimers.delete(taskId)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
completionTimers.delete(taskId)
|
||||
if (tasks.has(taskId)) {
|
||||
clearNotificationsForTask(taskId)
|
||||
tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
}, TASK_CLEANUP_DELAY_MS)
|
||||
|
||||
completionTimers.set(taskId, timer)
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
import {
|
||||
MIN_STABILITY_TIME_MS,
|
||||
} from "./constants"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type SessionStatusMap = Record<string, { type: string }>
|
||||
|
||||
type MessagePart = {
|
||||
type?: string
|
||||
tool?: string
|
||||
name?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type SessionMessage = {
|
||||
info?: { role?: string }
|
||||
parts?: MessagePart[]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function asSessionMessages(value: unknown): SessionMessage[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isRecord) as SessionMessage[]
|
||||
}
|
||||
|
||||
export async function pollRunningTasks(args: {
|
||||
tasks: Iterable<BackgroundTask>
|
||||
client: OpencodeClient
|
||||
pruneStaleTasksAndNotifications: () => void
|
||||
checkAndInterruptStaleTasks: (statuses: Record<string, { type: string }>) => Promise<void>
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
hasRunningTasks: () => boolean
|
||||
stopPolling: () => void
|
||||
}): Promise<void> {
|
||||
const {
|
||||
tasks,
|
||||
client,
|
||||
pruneStaleTasksAndNotifications,
|
||||
checkAndInterruptStaleTasks,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
hasRunningTasks,
|
||||
stopPolling,
|
||||
} = args
|
||||
|
||||
pruneStaleTasksAndNotifications()
|
||||
|
||||
const statusResult = await client.session.status()
|
||||
const allStatuses = normalizeSDKResponse(statusResult, {} as SessionStatusMap)
|
||||
|
||||
await checkAndInterruptStaleTasks(allStatuses)
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const sessionID = task.sessionID
|
||||
if (!sessionID) continue
|
||||
|
||||
try {
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
if (sessionStatus?.type === "idle") {
|
||||
const hasValidOutput = await validateSessionHasOutput(sessionID)
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
await tryCompleteTask(task, "polling (idle status)")
|
||||
continue
|
||||
}
|
||||
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
if ((messagesResult as { error?: unknown }).error) {
|
||||
continue
|
||||
}
|
||||
|
||||
const messages = asSessionMessages(normalizeSDKResponse(messagesResult, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
}))
|
||||
const assistantMsgs = messages.filter((m) => m.info?.role === "assistant")
|
||||
|
||||
let toolCalls = 0
|
||||
let lastTool: string | undefined
|
||||
let lastMessage: string | undefined
|
||||
|
||||
for (const msg of assistantMsgs) {
|
||||
const parts = msg.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "tool_use" || part.tool) {
|
||||
toolCalls += 1
|
||||
lastTool = part.tool || part.name || "unknown"
|
||||
}
|
||||
if (part.type === "text" && part.text) {
|
||||
lastMessage = part.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!task.progress) {
|
||||
task.progress = { toolCalls: 0, lastUpdate: new Date() }
|
||||
}
|
||||
task.progress.toolCalls = toolCalls
|
||||
task.progress.lastTool = lastTool
|
||||
task.progress.lastUpdate = new Date()
|
||||
if (lastMessage) {
|
||||
task.progress.lastMessage = lastMessage
|
||||
task.progress.lastMessageAt = new Date()
|
||||
}
|
||||
|
||||
const currentMsgCount = messages.length
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) continue
|
||||
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs >= MIN_STABILITY_TIME_MS) {
|
||||
if (task.lastMsgCount === currentMsgCount) {
|
||||
task.stablePolls = (task.stablePolls ?? 0) + 1
|
||||
if (task.stablePolls >= 3) {
|
||||
const recheckStatus = await client.session.status()
|
||||
const recheckData = normalizeSDKResponse(recheckStatus, {} as SessionStatusMap)
|
||||
const currentStatus = recheckData[sessionID]
|
||||
|
||||
if (currentStatus?.type !== "idle") {
|
||||
log("[background-agent] Stability reached but session not idle, resetting:", {
|
||||
taskId: task.id,
|
||||
sessionStatus: currentStatus?.type ?? "not_in_status",
|
||||
})
|
||||
task.stablePolls = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const hasValidOutput = await validateSessionHasOutput(sessionID)
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Stability reached but no valid output, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
if (!hasIncompleteTodos) {
|
||||
await tryCompleteTask(task, "stability detection")
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
task.stablePolls = 0
|
||||
}
|
||||
}
|
||||
|
||||
task.lastMsgCount = currentMsgCount
|
||||
} catch (error) {
|
||||
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRunningTasks()) {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
|
||||
|
||||
export function registerProcessSignal(
|
||||
signal: ProcessCleanupEvent,
|
||||
handler: () => void,
|
||||
exitAfter: boolean
|
||||
): () => void {
|
||||
const listener = () => {
|
||||
handler()
|
||||
if (exitAfter) {
|
||||
// Set exitCode and schedule exit after delay to allow other handlers to complete async cleanup
|
||||
// Use 6s delay to accommodate LSP cleanup (5s timeout + 1s SIGKILL wait)
|
||||
process.exitCode = 0
|
||||
setTimeout(() => process.exit(), 6000)
|
||||
}
|
||||
}
|
||||
process.on(signal, listener)
|
||||
return listener
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type Todo = {
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
id: string
|
||||
}
|
||||
|
||||
type SessionMessage = {
|
||||
info?: { role?: string }
|
||||
parts?: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function asSessionMessages(value: unknown): SessionMessage[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value as SessionMessage[]
|
||||
}
|
||||
|
||||
function asParts(value: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isRecord)
|
||||
}
|
||||
|
||||
function hasNonEmptyText(value: unknown): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
}
|
||||
|
||||
function isToolResultContentNonEmpty(content: unknown): boolean {
|
||||
if (typeof content === "string") return content.trim().length > 0
|
||||
if (Array.isArray(content)) return content.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a session has actual assistant/tool output before marking complete.
|
||||
* Prevents premature completion when session.idle fires before agent responds.
|
||||
*/
|
||||
export async function validateSessionHasOutput(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = asSessionMessages(normalizeSDKResponse(response, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
}))
|
||||
|
||||
const hasAssistantOrToolMessage = messages.some(
|
||||
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
if (!hasAssistantOrToolMessage) {
|
||||
log("[background-agent] No assistant/tool messages found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
const hasContent = messages.some((m) => {
|
||||
if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false
|
||||
|
||||
const parts = asParts(m.parts)
|
||||
return parts.some((part) => {
|
||||
const type = part.type
|
||||
if (type === "tool") return true
|
||||
if (type === "text" && hasNonEmptyText(part.text)) return true
|
||||
if (type === "reasoning" && hasNonEmptyText(part.text)) return true
|
||||
if (type === "tool_result" && isToolResultContentNonEmpty(part.content)) return true
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
// On error, allow completion to proceed (don't block indefinitely)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSessionTodos(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.todo({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const todos = normalizeSDKResponse(response, [] as Todo[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
if (todos.length === 0) return false
|
||||
|
||||
const incomplete = todos.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||
)
|
||||
return incomplete.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import type { BackgroundTask, LaunchInput } from "../types"
|
||||
|
||||
export function createTask(input: LaunchInput): BackgroundTask {
|
||||
return {
|
||||
id: `bg_${randomUUID().slice(0, 8)}`,
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { BackgroundTask, ResumeInput } from "../types"
|
||||
import { log, getAgentToolRestrictions } from "../../../shared"
|
||||
import { setSessionTools } from "../../../shared/session-tools-store"
|
||||
import type { SpawnerContext } from "./spawner-context"
|
||||
import { subagentSessions } from "../../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../../task-toast-manager"
|
||||
|
||||
export async function resumeTask(
|
||||
task: BackgroundTask,
|
||||
input: ResumeInput,
|
||||
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
|
||||
): Promise<void> {
|
||||
const { client, concurrencyManager, onTaskError } = ctx
|
||||
|
||||
if (!task.sessionID) {
|
||||
throw new Error(`Task has no sessionID: ${task.id}`)
|
||||
}
|
||||
|
||||
if (task.status === "running") {
|
||||
log("[background-agent] Resume skipped - task already running:", {
|
||||
taskId: task.id,
|
||||
sessionID: task.sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const concurrencyKey = task.concurrencyGroup ?? task.agent
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
task.status = "running"
|
||||
task.completedAt = undefined
|
||||
task.error = undefined
|
||||
task.parentSessionID = input.parentSessionID
|
||||
task.parentMessageID = input.parentMessageID
|
||||
task.parentModel = input.parentModel
|
||||
task.parentAgent = input.parentAgent
|
||||
if (input.parentTools) {
|
||||
task.parentTools = input.parentTools
|
||||
}
|
||||
task.startedAt = new Date()
|
||||
|
||||
task.progress = {
|
||||
toolCalls: task.progress?.toolCalls ?? 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
subagentSessions.add(task.sessionID)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
agent: task.agent,
|
||||
isBackground: true,
|
||||
})
|
||||
}
|
||||
|
||||
log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionID })
|
||||
|
||||
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
|
||||
sessionID: task.sessionID,
|
||||
agent: task.agent,
|
||||
model: task.model,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
client.session
|
||||
.promptAsync({
|
||||
path: { id: task.sessionID },
|
||||
body: {
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(task.sessionID!, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { QueueItem } from "../constants"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../../shared"
|
||||
import { setSessionTools } from "../../../shared/session-tools-store"
|
||||
import { subagentSessions } from "../../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../../task-toast-manager"
|
||||
import { createBackgroundSession } from "./background-session-creator"
|
||||
import { getConcurrencyKeyFromLaunchInput } from "./concurrency-key-from-launch-input"
|
||||
import { resolveParentDirectory } from "./parent-directory-resolver"
|
||||
import type { SpawnerContext } from "./spawner-context"
|
||||
import { maybeInvokeTmuxCallback } from "./tmux-callback-invoker"
|
||||
|
||||
export async function startTask(item: QueueItem, ctx: SpawnerContext): Promise<void> {
|
||||
const { task, input } = item
|
||||
const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx
|
||||
|
||||
log("[background-agent] Starting task:", {
|
||||
taskId: task.id,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
})
|
||||
|
||||
const concurrencyKey = getConcurrencyKeyFromLaunchInput(input)
|
||||
const parentDirectory = await resolveParentDirectory({
|
||||
client,
|
||||
parentSessionID: input.parentSessionID,
|
||||
defaultDirectory: directory,
|
||||
})
|
||||
|
||||
const sessionID = await createBackgroundSession({
|
||||
client,
|
||||
input,
|
||||
parentDirectory,
|
||||
concurrencyManager,
|
||||
concurrencyKey,
|
||||
})
|
||||
subagentSessions.add(sessionID)
|
||||
|
||||
await maybeInvokeTmuxCallback({
|
||||
onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
title: input.description,
|
||||
})
|
||||
|
||||
task.status = "running"
|
||||
task.startedAt = new Date()
|
||||
task.sessionID = sessionID
|
||||
task.progress = {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.updateTask(task.id, "running")
|
||||
}
|
||||
|
||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
hasSkillContent: !!input.skillContent,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import { TASK_TTL_MS } from "./constants"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export function pruneStaleState(args: {
|
||||
tasks: Map<string, BackgroundTask>
|
||||
notifications: Map<string, BackgroundTask[]>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
}): void {
|
||||
const {
|
||||
tasks,
|
||||
notifications,
|
||||
queuesByKey,
|
||||
concurrencyManager,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
} = args
|
||||
|
||||
pruneStaleTasksAndNotifications({
|
||||
tasks,
|
||||
notifications,
|
||||
onTaskPruned: (taskId, task, errorMessage) => {
|
||||
const wasPending = task.status === "pending"
|
||||
const now = Date.now()
|
||||
const timestamp = task.status === "pending"
|
||||
? task.queuedAt?.getTime()
|
||||
: task.startedAt?.getTime()
|
||||
const age = timestamp ? now - timestamp : TASK_TTL_MS
|
||||
|
||||
log("[background-agent] Pruning stale task:", {
|
||||
taskId,
|
||||
status: task.status,
|
||||
age: Math.round(age / 1000) + "s",
|
||||
})
|
||||
|
||||
task.status = "error"
|
||||
task.error = errorMessage
|
||||
task.completedAt = new Date()
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
if (wasPending) {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clearNotificationsForTask(taskId)
|
||||
tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export async function cancelBackgroundTask(args: {
|
||||
taskId: string
|
||||
options?: {
|
||||
source?: string
|
||||
reason?: string
|
||||
abortSession?: boolean
|
||||
skipNotification?: boolean
|
||||
}
|
||||
tasks: Map<string, BackgroundTask>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
client: OpencodeClient
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
taskId,
|
||||
options,
|
||||
tasks,
|
||||
queuesByKey,
|
||||
completionTimers,
|
||||
idleDeferralTimers,
|
||||
concurrencyManager,
|
||||
client,
|
||||
cleanupPendingByParent,
|
||||
markForNotification,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
const task = tasks.get(taskId)
|
||||
if (!task || (task.status !== "running" && task.status !== "pending")) {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = options?.source ?? "cancel"
|
||||
const abortSession = options?.abortSession !== false
|
||||
const reason = options?.reason
|
||||
|
||||
if (task.status === "pending") {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
log("[background-agent] Cancelled pending task:", { taskId, key })
|
||||
}
|
||||
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
if (reason) {
|
||||
task.error = reason
|
||||
}
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
|
||||
if (abortSession && task.sessionID) {
|
||||
client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
if (options?.skipNotification) {
|
||||
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
|
||||
return true
|
||||
}
|
||||
|
||||
markForNotification(task)
|
||||
|
||||
try {
|
||||
await notifyParentSession(task)
|
||||
log(`[background-agent] Task cancelled via ${source}:`, task.id)
|
||||
} catch (err) {
|
||||
log("[background-agent] Error in notifyParentSession for cancelled task:", {
|
||||
taskId: task.id,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
export async function tryCompleteBackgroundTask(args: {
|
||||
task: BackgroundTask
|
||||
source: string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
client: OpencodeClient
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
task,
|
||||
source,
|
||||
concurrencyManager,
|
||||
idleDeferralTimers,
|
||||
client,
|
||||
markForNotification,
|
||||
cleanupPendingByParent,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
markForNotification(task)
|
||||
cleanupPendingByParent(task)
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
if (task.sessionID) {
|
||||
client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
await notifyParentSession(task)
|
||||
log(`[background-agent] Task completed via ${source}:`, task.id)
|
||||
} catch (err) {
|
||||
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { LaunchInput } from "./types"
|
||||
|
||||
type QueueItem = {
|
||||
task: BackgroundTask
|
||||
input: LaunchInput
|
||||
}
|
||||
|
||||
export function launchBackgroundTask(args: {
|
||||
input: LaunchInput
|
||||
tasks: Map<string, BackgroundTask>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
getConcurrencyKeyFromInput: (input: LaunchInput) => string
|
||||
processKey: (key: string) => void
|
||||
}): BackgroundTask {
|
||||
const { input, tasks, pendingByParent, queuesByKey, getConcurrencyKeyFromInput, processKey } = args
|
||||
|
||||
log("[background-agent] launch() called with:", {
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
description: input.description,
|
||||
parentSessionID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (!input.agent || input.agent.trim() === "") {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
model: input.model,
|
||||
category: input.category,
|
||||
}
|
||||
|
||||
tasks.set(task.id, task)
|
||||
|
||||
if (input.parentSessionID) {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(task.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
const key = getConcurrencyKeyFromInput(input)
|
||||
const queue = queuesByKey.get(key) ?? []
|
||||
queue.push({ task, input })
|
||||
queuesByKey.set(key, queue)
|
||||
|
||||
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
isBackground: true,
|
||||
status: "queued",
|
||||
skills: input.skills,
|
||||
})
|
||||
}
|
||||
|
||||
processKey(key)
|
||||
return task
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
export function getTasksByParentSession(
|
||||
tasks: Iterable<BackgroundTask>,
|
||||
sessionID: string
|
||||
): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
for (const task of tasks) {
|
||||
if (task.parentSessionID === sessionID) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function getAllDescendantTasks(
|
||||
tasksByParent: (sessionID: string) => BackgroundTask[],
|
||||
sessionID: string
|
||||
): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
const directChildren = tasksByParent(sessionID)
|
||||
|
||||
for (const child of directChildren) {
|
||||
result.push(child)
|
||||
if (child.sessionID) {
|
||||
result.push(...getAllDescendantTasks(tasksByParent, child.sessionID))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findTaskBySession(
|
||||
tasks: Iterable<BackgroundTask>,
|
||||
sessionID: string
|
||||
): BackgroundTask | undefined {
|
||||
for (const task of tasks) {
|
||||
if (task.sessionID === sessionID) return task
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getRunningTasks(tasks: Iterable<BackgroundTask>): BackgroundTask[] {
|
||||
return Array.from(tasks).filter((t) => t.status === "running")
|
||||
}
|
||||
|
||||
export function getNonRunningTasks(tasks: Iterable<BackgroundTask>): BackgroundTask[] {
|
||||
return Array.from(tasks).filter((t) => t.status !== "running")
|
||||
}
|
||||
|
||||
export function hasRunningTasks(tasks: Iterable<BackgroundTask>): boolean {
|
||||
for (const task of tasks) {
|
||||
if (task.status === "running") return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
type QueueItem = {
|
||||
task: BackgroundTask
|
||||
input: import("./types").LaunchInput
|
||||
}
|
||||
|
||||
export async function processConcurrencyKeyQueue(args: {
|
||||
key: string
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
processingKeys: Set<string>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
startTask: (item: QueueItem) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const { key, queuesByKey, processingKeys, concurrencyManager, startTask } = args
|
||||
|
||||
if (processingKeys.has(key)) return
|
||||
processingKeys.add(key)
|
||||
|
||||
try {
|
||||
const queue = queuesByKey.get(key)
|
||||
while (queue && queue.length > 0) {
|
||||
const item = queue[0]
|
||||
|
||||
await concurrencyManager.acquire(key)
|
||||
|
||||
if (item.task.status === "cancelled" || item.task.status === "error") {
|
||||
concurrencyManager.release(key)
|
||||
queue.shift()
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await startTask(item)
|
||||
} catch (error) {
|
||||
log("[background-agent] Error starting task:", error)
|
||||
// Release concurrency slot if startTask failed and didn't release it itself
|
||||
// This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set
|
||||
if (!item.task.concurrencyKey) {
|
||||
concurrencyManager.release(key)
|
||||
}
|
||||
}
|
||||
|
||||
queue.shift()
|
||||
}
|
||||
} finally {
|
||||
processingKeys.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { log, getAgentToolRestrictions } from "../../shared"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
import type { BackgroundTask, ResumeInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type ModelRef = { providerID: string; modelID: string }
|
||||
|
||||
export async function resumeBackgroundTask(args: {
|
||||
input: ResumeInput
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
client: OpencodeClient
|
||||
concurrencyManager: ConcurrencyManager
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
startPolling: () => void
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<BackgroundTask> {
|
||||
const {
|
||||
input,
|
||||
findBySession,
|
||||
client,
|
||||
concurrencyManager,
|
||||
pendingByParent,
|
||||
startPolling,
|
||||
markForNotification,
|
||||
cleanupPendingByParent,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
const existingTask = findBySession(input.sessionId)
|
||||
if (!existingTask) {
|
||||
throw new Error(`Task not found for session: ${input.sessionId}`)
|
||||
}
|
||||
|
||||
if (!existingTask.sessionID) {
|
||||
throw new Error(`Task has no sessionID: ${existingTask.id}`)
|
||||
}
|
||||
|
||||
if (existingTask.status === "running") {
|
||||
log("[background-agent] Resume skipped - task already running:", {
|
||||
taskId: existingTask.id,
|
||||
sessionID: existingTask.sessionID,
|
||||
})
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const concurrencyKey =
|
||||
existingTask.concurrencyGroup ??
|
||||
(existingTask.model
|
||||
? `${existingTask.model.providerID}/${existingTask.model.modelID}`
|
||||
: existingTask.agent)
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
existingTask.concurrencyKey = concurrencyKey
|
||||
existingTask.concurrencyGroup = concurrencyKey
|
||||
|
||||
existingTask.status = "running"
|
||||
existingTask.completedAt = undefined
|
||||
existingTask.error = undefined
|
||||
existingTask.parentSessionID = input.parentSessionID
|
||||
existingTask.parentMessageID = input.parentMessageID
|
||||
existingTask.parentModel = input.parentModel
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
existingTask.startedAt = new Date()
|
||||
|
||||
existingTask.progress = {
|
||||
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
startPolling()
|
||||
if (existingTask.sessionID) {
|
||||
subagentSessions.add(existingTask.sessionID)
|
||||
}
|
||||
|
||||
if (input.parentSessionID) {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(existingTask.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: existingTask.id,
|
||||
description: existingTask.description,
|
||||
agent: existingTask.agent,
|
||||
isBackground: true,
|
||||
})
|
||||
}
|
||||
|
||||
log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID })
|
||||
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
|
||||
sessionID: existingTask.sessionID,
|
||||
agent: existingTask.agent,
|
||||
model: existingTask.model,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const resumeModel: ModelRef | undefined = existingTask.model
|
||||
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = existingTask.model?.variant
|
||||
|
||||
client.session.promptAsync({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
existingTask.status = "interrupt"
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
existingTask.error = errorMessage
|
||||
existingTask.completedAt = new Date()
|
||||
|
||||
if (existingTask.concurrencyKey) {
|
||||
concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
if (existingTask.sessionID) {
|
||||
client.session.abort({
|
||||
path: { id: existingTask.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
markForNotification(existingTask)
|
||||
cleanupPendingByParent(existingTask)
|
||||
notifyParentSession(existingTask).catch((err) => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
})
|
||||
})
|
||||
|
||||
return existingTask
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OpencodeClient } from "./opencode-client"
|
||||
|
||||
type QueueItem = {
|
||||
task: BackgroundTask
|
||||
input: LaunchInput
|
||||
}
|
||||
|
||||
type ModelRef = { providerID: string; modelID: string }
|
||||
|
||||
export async function startQueuedTask(args: {
|
||||
item: QueueItem
|
||||
client: OpencodeClient
|
||||
defaultDirectory: string
|
||||
tmuxEnabled: boolean
|
||||
onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
||||
startPolling: () => void
|
||||
getConcurrencyKeyFromInput: (input: LaunchInput) => string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
markForNotification: (task: BackgroundTask) => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const {
|
||||
item,
|
||||
client,
|
||||
defaultDirectory,
|
||||
tmuxEnabled,
|
||||
onSubagentSessionCreated,
|
||||
startPolling,
|
||||
getConcurrencyKeyFromInput,
|
||||
concurrencyManager,
|
||||
findBySession,
|
||||
markForNotification,
|
||||
cleanupPendingByParent,
|
||||
notifyParentSession,
|
||||
} = args
|
||||
|
||||
const { task, input } = item
|
||||
|
||||
log("[background-agent] Starting task:", {
|
||||
taskId: task.id,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
})
|
||||
|
||||
const concurrencyKey = getConcurrencyKeyFromInput(input)
|
||||
|
||||
const parentSession = await client.session.get({
|
||||
path: { id: input.parentSessionID },
|
||||
}).catch((err) => {
|
||||
log(`[background-agent] Failed to get parent session: ${err}`)
|
||||
return null
|
||||
})
|
||||
|
||||
const parentDirectory = parentSession?.data?.directory ?? defaultDirectory
|
||||
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
|
||||
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agent} subagent)`,
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
if (!createResult.data?.id) {
|
||||
throw new Error("Failed to create background session: API returned no session ID")
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
subagentSessions.add(sessionID)
|
||||
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
|
||||
log("[background-agent] Invoking tmux callback NOW", { sessionID })
|
||||
await onSubagentSessionCreated({
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
title: input.description,
|
||||
}).catch((err) => {
|
||||
log("[background-agent] Failed to spawn tmux pane:", err)
|
||||
})
|
||||
log("[background-agent] tmux callback completed, waiting 200ms")
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 200)
|
||||
})
|
||||
} else {
|
||||
log("[background-agent] SKIP tmux callback - conditions not met")
|
||||
}
|
||||
|
||||
task.status = "running"
|
||||
task.startedAt = new Date()
|
||||
task.sessionID = sessionID
|
||||
task.progress = {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
startPolling()
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.updateTask(task.id, "running")
|
||||
}
|
||||
|
||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
hasSkillContent: !!input.skillContent,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const launchModel: ModelRef | undefined = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
const existingTask = findBySession(sessionID)
|
||||
if (!existingTask) return
|
||||
|
||||
existingTask.status = "interrupt"
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
|
||||
} else {
|
||||
existingTask.error = errorMessage
|
||||
}
|
||||
existingTask.completedAt = new Date()
|
||||
|
||||
if (existingTask.concurrencyKey) {
|
||||
concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
client.session.abort({
|
||||
path: { id: sessionID },
|
||||
}).catch(() => {})
|
||||
|
||||
markForNotification(existingTask)
|
||||
cleanupPendingByParent(existingTask)
|
||||
notifyParentSession(existingTask).catch((err) => {
|
||||
log("[background-agent] Failed to notify on error:", err)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { log } from "../../shared"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
export async function trackExternalTask(args: {
|
||||
input: {
|
||||
taskId: string
|
||||
sessionID: string
|
||||
parentSessionID: string
|
||||
description: string
|
||||
agent?: string
|
||||
parentAgent?: string
|
||||
concurrencyKey?: string
|
||||
}
|
||||
tasks: Map<string, BackgroundTask>
|
||||
pendingByParent: Map<string, Set<string>>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
startPolling: () => void
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
}): Promise<BackgroundTask> {
|
||||
const { input, tasks, pendingByParent, concurrencyManager, startPolling, cleanupPendingByParent } = args
|
||||
|
||||
const existingTask = tasks.get(input.taskId)
|
||||
if (existingTask) {
|
||||
const parentChanged = input.parentSessionID !== existingTask.parentSessionID
|
||||
if (parentChanged) {
|
||||
cleanupPendingByParent(existingTask)
|
||||
existingTask.parentSessionID = input.parentSessionID
|
||||
}
|
||||
if (input.parentAgent !== undefined) {
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
}
|
||||
if (!existingTask.concurrencyGroup) {
|
||||
existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent
|
||||
}
|
||||
|
||||
if (existingTask.sessionID) {
|
||||
subagentSessions.add(existingTask.sessionID)
|
||||
}
|
||||
startPolling()
|
||||
|
||||
if (existingTask.status === "pending" || existingTask.status === "running") {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(existingTask.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
} else if (!parentChanged) {
|
||||
cleanupPendingByParent(existingTask)
|
||||
}
|
||||
|
||||
log("[background-agent] External task already registered:", {
|
||||
taskId: existingTask.id,
|
||||
sessionID: existingTask.sessionID,
|
||||
status: existingTask.status,
|
||||
})
|
||||
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task"
|
||||
if (input.concurrencyKey) {
|
||||
await concurrencyManager.acquire(input.concurrencyKey)
|
||||
}
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: input.taskId,
|
||||
sessionID: input.sessionID,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: "",
|
||||
description: input.description,
|
||||
prompt: "",
|
||||
agent: input.agent || "task",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentAgent: input.parentAgent,
|
||||
concurrencyKey: input.concurrencyKey,
|
||||
concurrencyGroup,
|
||||
}
|
||||
|
||||
tasks.set(task.id, task)
|
||||
subagentSessions.add(input.sessionID)
|
||||
startPolling()
|
||||
|
||||
if (input.parentSessionID) {
|
||||
const pending = pendingByParent.get(input.parentSessionID) ?? new Set<string>()
|
||||
pending.add(task.id)
|
||||
pendingByParent.set(input.parentSessionID, pending)
|
||||
}
|
||||
|
||||
log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
|
||||
return task
|
||||
}
|
||||
@@ -1,68 +1,43 @@
|
||||
# CLAUDE TASKS KNOWLEDGE BASE
|
||||
# src/features/claude-tasks/ — Task Schema + Storage
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Claude Code compatible task schema and storage. Core task management with file-based persistence, atomic writes, and OpenCode todo sync.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
claude-tasks/
|
||||
├── types.ts # Task schema (Zod)
|
||||
├── types.test.ts # Schema validation tests
|
||||
├── storage.ts # File operations (atomic write, locking)
|
||||
├── storage.test.ts # Storage tests (30 tests, 543 lines)
|
||||
├── session-storage.ts # Session-scoped task storage
|
||||
├── session-storage.test.ts
|
||||
└── index.ts # Barrel exports
|
||||
```
|
||||
4 non-test files (~622 LOC). File-based task persistence with atomic writes, locking, and OpenCode todo API sync.
|
||||
|
||||
## TASK SCHEMA
|
||||
|
||||
```typescript
|
||||
type TaskStatus = "pending" | "in_progress" | "completed" | "deleted"
|
||||
interface Task {
|
||||
id: string // T-{uuid}
|
||||
subject: string // Imperative: "Run tests"
|
||||
description: string
|
||||
status: TaskStatus
|
||||
activeForm?: string // Present continuous: "Running tests"
|
||||
blocks: string[] // Task IDs this task blocks
|
||||
blockedBy: string[] // Task IDs blocking this task
|
||||
owner?: string // Agent name
|
||||
id: string // T-{uuid} auto-generated
|
||||
subject: string // Short title
|
||||
description?: string // Detailed description
|
||||
status: "pending" | "in_progress" | "completed" | "deleted"
|
||||
activeForm?: string // Current form/template
|
||||
blocks?: string[] // Tasks this blocks
|
||||
blockedBy?: string[] // Tasks blocking this
|
||||
owner?: string // Agent/session
|
||||
metadata?: Record<string, unknown>
|
||||
repoURL?: string
|
||||
parentID?: string
|
||||
threadID?: string
|
||||
repoURL?: string // Associated repository
|
||||
parentID?: string // Parent task ID
|
||||
threadID?: string // Session ID (auto-recorded)
|
||||
}
|
||||
```
|
||||
|
||||
## STORAGE UTILITIES
|
||||
## FILES
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `getTaskDir(config)` | Task storage directory path |
|
||||
| `resolveTaskListId(config)` | Task list ID (env → config → cwd) |
|
||||
| `readJsonSafe(path, schema)` | Parse + validate, null on failure |
|
||||
| `writeJsonAtomic(path, data)` | Atomic write via temp + rename |
|
||||
| `acquireLock(dirPath)` | File lock with 30s stale threshold |
|
||||
| `generateTaskId()` | `T-{uuid}` format |
|
||||
| `findTaskAcrossSessions(config, taskId)` | Locate task in any session |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `types.ts` | Task interface + status types |
|
||||
| `storage.ts` | `readJsonSafe()`, `writeJsonAtomic()`, `acquireLock()`, `generateTaskId()` |
|
||||
| `session-storage.ts` | Per-session task storage, threadID auto-recording |
|
||||
| `index.ts` | Barrel exports |
|
||||
|
||||
## TODO SYNC
|
||||
## STORAGE
|
||||
|
||||
Automatic bidirectional sync between tasks and OpenCode's todo system.
|
||||
|
||||
| Task Status | Todo Status |
|
||||
|-------------|-------------|
|
||||
| `pending` | `pending` |
|
||||
| `in_progress` | `in_progress` |
|
||||
| `completed` | `completed` |
|
||||
| `deleted` | `null` (removed) |
|
||||
|
||||
Sync triggers: `task_create`, `task_update`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Direct fs operations (use storage utilities)
|
||||
- Skipping lock acquisition for writes
|
||||
- Using old field names (title → subject, dependsOn → blockedBy)
|
||||
- Location: `.sisyphus/tasks/` directory
|
||||
- Format: JSON files, one per task
|
||||
- Atomic writes: temp file → rename
|
||||
- Locking: file-based lock for concurrent access
|
||||
- Sync: Changes pushed to OpenCode Todo API after each update
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { executeAction } from "./action-executor"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
|
||||
export class ManagerCleanup {
|
||||
constructor(
|
||||
private sessions: Map<string, TrackedSession>,
|
||||
private sourcePaneId: string | undefined,
|
||||
private pollingManager: TmuxPollingManager,
|
||||
private tmuxConfig: TmuxConfig,
|
||||
private serverUrl: string
|
||||
) {}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.pollingManager.stopPolling()
|
||||
|
||||
if (this.sessions.size > 0) {
|
||||
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
||||
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||
|
||||
if (state) {
|
||||
const closePromises = Array.from(this.sessions.values()).map((s) =>
|
||||
executeAction(
|
||||
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
).catch((err) =>
|
||||
log("[tmux-session-manager] cleanup error for pane", {
|
||||
paneId: s.paneId,
|
||||
error: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Promise.all(closePromises)
|
||||
}
|
||||
this.sessions.clear()
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] cleanup complete")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { MIN_PANE_WIDTH } from "./types"
|
||||
import type { SplitDirection, TmuxPaneInfo } from "./types"
|
||||
import {
|
||||
DIVIDER_SIZE,
|
||||
MAX_COLS,
|
||||
MAX_ROWS,
|
||||
MIN_SPLIT_HEIGHT,
|
||||
MIN_SPLIT_WIDTH,
|
||||
DIVIDER_SIZE,
|
||||
MAX_COLS,
|
||||
MAX_ROWS,
|
||||
MIN_SPLIT_HEIGHT,
|
||||
} from "./tmux-grid-constants"
|
||||
|
||||
function minSplitWidthFor(minPaneWidth: number): number {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession } from "./types"
|
||||
import type { SessionMapping } from "./decision-engine"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideCloseAction } from "./decision-engine"
|
||||
import { executeAction } from "./action-executor"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
export class SessionCleaner {
|
||||
constructor(
|
||||
private tmuxConfig: TmuxConfig,
|
||||
private deps: TmuxUtilDeps,
|
||||
private sessions: Map<string, TrackedSession>,
|
||||
private sourcePaneId: string | undefined,
|
||||
private getSessionMappings: () => SessionMapping[],
|
||||
private pollingManager: TmuxPollingManager,
|
||||
private serverUrl: string
|
||||
) {}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||
}
|
||||
|
||||
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
if (!this.sourcePaneId) return
|
||||
|
||||
const tracked = this.sessions.get(event.sessionID)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||
|
||||
const state = await queryWindowState(this.sourcePaneId)
|
||||
if (!state) {
|
||||
this.sessions.delete(event.sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
|
||||
if (closeAction) {
|
||||
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
|
||||
}
|
||||
|
||||
this.sessions.delete(event.sessionID)
|
||||
|
||||
if (this.sessions.size === 0) {
|
||||
this.pollingManager.stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async closeSessionById(sessionId: string): Promise<void> {
|
||||
const tracked = this.sessions.get(sessionId)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] closing session pane", {
|
||||
sessionId,
|
||||
paneId: tracked.paneId,
|
||||
})
|
||||
|
||||
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||
if (state) {
|
||||
await executeAction(
|
||||
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
)
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId)
|
||||
|
||||
if (this.sessions.size === 0) {
|
||||
this.pollingManager.stopPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
||||
import { executeActions } from "./action-executor"
|
||||
import { TmuxPollingManager } from "./polling-manager"
|
||||
|
||||
interface SessionCreatedEvent {
|
||||
type: string
|
||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||
}
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
export class SessionSpawner {
|
||||
constructor(
|
||||
private tmuxConfig: TmuxConfig,
|
||||
private deps: TmuxUtilDeps,
|
||||
private sessions: Map<string, TrackedSession>,
|
||||
private pendingSessions: Set<string>,
|
||||
private sourcePaneId: string | undefined,
|
||||
private getCapacityConfig: () => CapacityConfig,
|
||||
private getSessionMappings: () => SessionMapping[],
|
||||
private waitForSessionReady: (sessionId: string) => Promise<boolean>,
|
||||
private pollingManager: TmuxPollingManager,
|
||||
private serverUrl: string
|
||||
) {}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||
}
|
||||
|
||||
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
|
||||
const enabled = this.isEnabled()
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||
isInsideTmux: this.deps.isInsideTmux(),
|
||||
eventType: event.type,
|
||||
infoId: event.properties?.info?.id,
|
||||
infoParentID: event.properties?.info?.parentID,
|
||||
})
|
||||
|
||||
if (!enabled) return
|
||||
if (event.type !== "session.created") return
|
||||
|
||||
const info = event.properties?.info
|
||||
if (!info?.id || !info?.parentID) return
|
||||
|
||||
const sessionId = info.id
|
||||
const title = info.title ?? "Subagent"
|
||||
|
||||
if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) {
|
||||
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.sourcePaneId) {
|
||||
log("[tmux-session-manager] no source pane id")
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingSessions.add(sessionId)
|
||||
|
||||
try {
|
||||
const state = await queryWindowState(this.sourcePaneId)
|
||||
if (!state) {
|
||||
log("[tmux-session-manager] failed to query window state")
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] window state queried", {
|
||||
windowWidth: state.windowWidth,
|
||||
mainPane: state.mainPane?.paneId,
|
||||
agentPaneCount: state.agentPanes.length,
|
||||
agentPanes: state.agentPanes.map((p) => p.paneId),
|
||||
})
|
||||
|
||||
const decision = decideSpawnActions(
|
||||
state,
|
||||
sessionId,
|
||||
title,
|
||||
this.getCapacityConfig(),
|
||||
this.getSessionMappings()
|
||||
)
|
||||
|
||||
log("[tmux-session-manager] spawn decision", {
|
||||
canSpawn: decision.canSpawn,
|
||||
reason: decision.reason,
|
||||
actionCount: decision.actions.length,
|
||||
actions: decision.actions.map((a) => {
|
||||
if (a.type === "close") return { type: "close", paneId: a.paneId }
|
||||
if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
|
||||
return { type: "spawn", sessionId: a.sessionId }
|
||||
}),
|
||||
})
|
||||
|
||||
if (!decision.canSpawn) {
|
||||
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await executeActions(
|
||||
decision.actions,
|
||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||
)
|
||||
|
||||
for (const { action, result: actionResult } of result.results) {
|
||||
if (action.type === "close" && actionResult.success) {
|
||||
this.sessions.delete(action.sessionId)
|
||||
log("[tmux-session-manager] removed closed session from cache", {
|
||||
sessionId: action.sessionId,
|
||||
})
|
||||
}
|
||||
if (action.type === "replace" && actionResult.success) {
|
||||
this.sessions.delete(action.oldSessionId)
|
||||
log("[tmux-session-manager] removed replaced session from cache", {
|
||||
oldSessionId: action.oldSessionId,
|
||||
newSessionId: action.newSessionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success && result.spawnedPaneId) {
|
||||
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||
|
||||
if (!sessionReady) {
|
||||
log("[tmux-session-manager] session not ready after timeout, closing spawned pane", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
})
|
||||
|
||||
await executeActions(
|
||||
[{ type: "close", paneId: result.spawnedPaneId, sessionId }],
|
||||
{
|
||||
config: this.tmuxConfig,
|
||||
serverUrl: this.serverUrl,
|
||||
windowState: state,
|
||||
},
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
this.sessions.set(sessionId, {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
description: title,
|
||||
createdAt: new Date(now),
|
||||
lastSeenAt: new Date(now),
|
||||
})
|
||||
log("[tmux-session-manager] pane spawned and tracked", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
sessionReady,
|
||||
})
|
||||
this.pollingManager.startPolling()
|
||||
} else {
|
||||
log("[tmux-session-manager] spawn failed", {
|
||||
success: result.success,
|
||||
results: result.results.map((r) => ({
|
||||
type: r.action.type,
|
||||
success: r.result.success,
|
||||
error: r.result.error,
|
||||
})),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.pendingSessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,113 @@
|
||||
# HOOKS KNOWLEDGE BASE
|
||||
# src/hooks/ — 41 Lifecycle Hooks
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
41 lifecycle hooks intercepting/modifying agent behavior across 7 event types. Three-tier registration: Core (32) → Continuation (7) → Skill (2).
|
||||
41 hooks across 37 directories + 6 standalone files. Three-tier composition: Core(33) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
hooks/
|
||||
├── agent-usage-reminder/ # Specialized agent hints (109 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize on limit (2232 lines, 29 files)
|
||||
├── anthropic-effort/ # Effort=max for Opus max variant (56 lines)
|
||||
├── atlas/ # Main orchestration hook (1976 lines, 17 files)
|
||||
├── auto-slash-command/ # Detects /command patterns (1134 lines)
|
||||
├── auto-update-checker/ # Plugin update check (1140 lines, 20 files)
|
||||
├── background-notification/ # OS notifications (33 lines)
|
||||
├── category-skill-reminder/ # Category+skill delegation reminders (597 lines)
|
||||
├── claude-code-hooks/ # settings.json compat (2110 lines) — see AGENTS.md
|
||||
├── comment-checker/ # Prevents AI slop comments (710 lines)
|
||||
├── compaction-context-injector/ # Injects context on compaction (128 lines)
|
||||
├── compaction-todo-preserver/ # Preserves todos during compaction (203 lines)
|
||||
├── context-window-monitor.ts # Reminds of headroom at 70% (100 lines)
|
||||
├── delegate-task-retry/ # Retries failed delegations (266 lines)
|
||||
├── directory-agents-injector/ # Auto-injects AGENTS.md (195 lines)
|
||||
├── directory-readme-injector/ # Auto-injects README.md (190 lines)
|
||||
├── edit-error-recovery/ # Recovers from edit failures (188 lines)
|
||||
├── empty-task-response-detector.ts # Detects empty responses (27 lines)
|
||||
├── interactive-bash-session/ # Tmux session management (695 lines)
|
||||
├── keyword-detector/ # ultrawork/search/analyze modes (1665 lines)
|
||||
├── non-interactive-env/ # Non-TTY handling (483 lines)
|
||||
├── preemptive-compaction.ts # Auto-compact at 78% usage (108 lines)
|
||||
├── prometheus-md-only/ # Planner read-only mode (955 lines)
|
||||
├── question-label-truncator/ # Truncates labels to 30 chars (199 lines)
|
||||
├── ralph-loop/ # Self-referential dev loop (1687 lines)
|
||||
├── rules-injector/ # Conditional .sisyphus/rules injection (1604 lines)
|
||||
├── session-notification.ts # OS idle notifications (108 lines)
|
||||
├── session-recovery/ # Auto-recovers from crashes (1279 lines, 14 files)
|
||||
├── sisyphus-junior-notepad/ # Junior notepad directive (76 lines)
|
||||
├── start-work/ # Sisyphus work session starter (648 lines)
|
||||
├── stop-continuation-guard/ # Guards stop continuation (214 lines)
|
||||
├── subagent-question-blocker/ # Blocks subagent questions (112 lines)
|
||||
├── task-reminder/ # Task progress reminders (210 lines)
|
||||
├── task-resume-info/ # Resume info for cancelled tasks (39 lines)
|
||||
├── tasks-todowrite-disabler/ # Disables TodoWrite when tasks active (202 lines)
|
||||
├── think-mode/ # Dynamic thinking budget (1365 lines)
|
||||
├── thinking-block-validator/ # Validates thinking blocks (169 lines)
|
||||
├── todo-continuation-enforcer/ # Force TODO completion — boulder mechanism (2061 lines)
|
||||
├── tool-output-truncator.ts # Prevents context bloat (62 lines)
|
||||
├── unstable-agent-babysitter/ # Monitors unstable behavior (451 lines)
|
||||
└── write-existing-file-guard/ # Guards against file overwrite (356 lines)
|
||||
```
|
||||
## HOOK TIERS
|
||||
|
||||
## EVENT TYPES
|
||||
### Tier 1: Session Hooks (20) — `create-session-hooks.ts`
|
||||
|
||||
| Event | Hook Method | Can Block | Count |
|
||||
|-------|-------------|-----------|-------|
|
||||
| UserPromptSubmit | `chat.message` | Yes | 4 |
|
||||
| ChatParams | `chat.params` | No | 2 |
|
||||
| PreToolUse | `tool.execute.before` | Yes | 13 |
|
||||
| PostToolUse | `tool.execute.after` | No | 15 |
|
||||
| SessionEvent | `event` | No | 17 |
|
||||
| MessagesTransform | `experimental.chat.messages.transform` | No | 1 |
|
||||
| Compaction | `onSummarize` | No | 2 |
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
| contextWindowMonitor | session.idle | Track context window usage |
|
||||
| preemptiveCompaction | session.idle | Trigger compaction before limit |
|
||||
| sessionRecovery | session.error | Auto-retry on recoverable errors |
|
||||
| sessionNotification | session.idle | OS notifications on completion |
|
||||
| thinkMode | chat.params | Model variant switching (extended thinking) |
|
||||
| anthropicContextWindowLimitRecovery | session.error | Multi-strategy context recovery (truncation, compaction) |
|
||||
| autoUpdateChecker | session.created | Check npm for plugin updates |
|
||||
| agentUsageReminder | chat.message | Remind about available agents |
|
||||
| nonInteractiveEnv | chat.message | Adjust behavior for `run` command |
|
||||
| interactiveBashSession | tool.execute | Tmux session for interactive tools |
|
||||
| ralphLoop | event | Self-referential dev loop (boulder continuation) |
|
||||
| editErrorRecovery | tool.execute.after | Retry failed file edits |
|
||||
| delegateTaskRetry | tool.execute.after | Retry failed task delegations |
|
||||
| startWork | chat.message | `/start-work` command handler |
|
||||
| prometheusMdOnly | tool.execute.before | Enforce .md-only writes for Prometheus |
|
||||
| sisyphusJuniorNotepad | chat.message | Notepad injection for subagents |
|
||||
| questionLabelTruncator | tool.execute.before | Truncate long question labels |
|
||||
| taskResumeInfo | chat.message | Inject task context on resume |
|
||||
| anthropicEffort | chat.params | Adjust reasoning effort level |
|
||||
|
||||
## BLOCKING HOOKS (8)
|
||||
### Tier 2: Tool Guard Hooks (9) — `create-tool-guard-hooks.ts`
|
||||
|
||||
| Hook | Event | Blocks When |
|
||||
|------|-------|-------------|
|
||||
| auto-slash-command | chat.message | Command execution fails |
|
||||
| keyword-detector | chat.message | Keyword injection fails |
|
||||
| non-interactive-env | tool.execute.before | Interactive command in non-TTY |
|
||||
| prometheus-md-only | tool.execute.before | Write outside .sisyphus/*.md |
|
||||
| subagent-question-blocker | tool.execute.before | Question tool in subagent |
|
||||
| tasks-todowrite-disabler | tool.execute.before | TodoWrite with task system |
|
||||
| write-existing-file-guard | tool.execute.before | Write to existing file |
|
||||
| claude-code-hooks | tool.execute.before | Exit code 2 from settings.json hook |
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
| commentChecker | tool.execute.after | Block AI-generated comment patterns |
|
||||
| toolOutputTruncator | tool.execute.after | Truncate oversized tool output |
|
||||
| directoryAgentsInjector | tool.execute.before | Inject dir AGENTS.md into context |
|
||||
| directoryReadmeInjector | tool.execute.before | Inject dir README.md into context |
|
||||
| emptyTaskResponseDetector | tool.execute.after | Detect empty task responses |
|
||||
| rulesInjector | tool.execute.before | Conditional rules injection (AGENTS.md, config) |
|
||||
| tasksTodowriteDisabler | tool.execute.before | Disable TodoWrite when task system active |
|
||||
| writeExistingFileGuard | tool.execute.before | Require Read before Write on existing files |
|
||||
| hashlineReadEnhancer | tool.execute.after | Enhance Read output with line hashes |
|
||||
|
||||
## EXECUTION ORDER
|
||||
### Tier 3: Transform Hooks (4) — `create-transform-hooks.ts`
|
||||
|
||||
**UserPromptSubmit**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork
|
||||
**PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → writeExistingFileGuard → tasksToDoWriteDisabler → atlasHook
|
||||
**PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo → taskReminder
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
| claudeCodeHooks | messages.transform | Claude Code settings.json compatibility |
|
||||
| keywordDetector | messages.transform | Detect ultrawork/search/analyze modes |
|
||||
| contextInjectorMessagesTransform | messages.transform | Inject AGENTS.md/README.md into context |
|
||||
| thinkingBlockValidator | messages.transform | Validate thinking block structure |
|
||||
|
||||
## HOW TO ADD
|
||||
### Tier 4: Continuation Hooks (7) — `create-continuation-hooks.ts`
|
||||
|
||||
1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)`
|
||||
2. Add hook name to `HookNameSchema` in `src/config/schema/hooks.ts`
|
||||
3. Register in appropriate `src/plugin/hooks/create-*-hooks.ts`
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
| stopContinuationGuard | chat.message | `/stop-continuation` command handler |
|
||||
| compactionContextInjector | session.compacted | Re-inject context after compaction |
|
||||
| compactionTodoPreserver | session.compacted | Preserve todos through compaction |
|
||||
| todoContinuationEnforcer | session.idle | **Boulder**: force continuation on incomplete todos |
|
||||
| unstableAgentBabysitter | session.idle | Monitor unstable agent behavior |
|
||||
| backgroundNotificationHook | event | Background task completion notifications |
|
||||
| atlasHook | event | Master orchestrator for boulder/background sessions |
|
||||
|
||||
## ANTI-PATTERNS
|
||||
### Tier 5: Skill Hooks (2) — `create-skill-hooks.ts`
|
||||
|
||||
- **Heavy PreToolUse**: Runs before EVERY tool — keep light
|
||||
- **Blocking non-critical**: Use PostToolUse warnings instead
|
||||
- **Redundant injection**: Track injected files to avoid context bloat
|
||||
- **Direct state mutation**: Use `output.output +=` instead of replacing
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
| categorySkillReminder | chat.message | Remind about category+skill delegation |
|
||||
| autoSlashCommand | chat.message | Auto-detect `/command` in user input |
|
||||
|
||||
## KEY HOOKS (COMPLEX)
|
||||
|
||||
### anthropic-context-window-limit-recovery (31 files, ~2232 LOC)
|
||||
Multi-strategy recovery when hitting context limits. Strategies: truncation, compaction, summarization.
|
||||
|
||||
### atlas (17 files, ~1976 LOC)
|
||||
Master orchestrator for boulder sessions. Decision gates: session type → abort check → failure count → background tasks → agent match → plan completeness → cooldown (5s). Injects continuation prompts on session.idle.
|
||||
|
||||
### ralph-loop (14 files, ~1687 LOC)
|
||||
Self-referential dev loop via `/ralph-loop` command. State persisted in `.sisyphus/ralph-loop.local.md`. Detects `<promise>DONE</promise>` in AI output. Max 100 iterations default.
|
||||
|
||||
### todo-continuation-enforcer (13 files, ~2061 LOC)
|
||||
"Boulder" mechanism. Forces agent to continue when todos remain incomplete. 2s countdown toast → continuation injection. Exponential backoff: 30s base, ×2 per failure, max 5 consecutive failures then 5min pause.
|
||||
|
||||
### keyword-detector (~1665 LOC)
|
||||
Detects modes from user input: ultrawork, search, analyze, prove-yourself. Injects mode-specific system prompts.
|
||||
|
||||
### rules-injector (19 files, ~1604 LOC)
|
||||
Conditional rules injection from AGENTS.md, config, skill rules. Evaluates conditions to determine which rules apply.
|
||||
|
||||
## STANDALONE HOOKS (in src/hooks/ root)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| context-window-monitor.ts | Track context window percentage |
|
||||
| preemptive-compaction.ts | Trigger compaction before hard limit |
|
||||
| tool-output-truncator.ts | Truncate tool output by token count |
|
||||
| session-notification.ts + 4 helpers | OS notification on session completion |
|
||||
| empty-task-response-detector.ts | Detect empty/failed task responses |
|
||||
| session-todo-status.ts | Todo completion status tracking |
|
||||
|
||||
## HOW TO ADD A HOOK
|
||||
|
||||
1. Create `src/hooks/{name}/index.ts` with `createXXXHook(deps)` factory
|
||||
2. Register in appropriate tier file (`src/plugin/hooks/create-{tier}-hooks.ts`)
|
||||
3. Add hook name to `src/config/schema/hooks.ts` HookNameSchema
|
||||
4. Hook receives `(event, ctx)` — return value depends on event type
|
||||
|
||||
@@ -1,55 +1,41 @@
|
||||
# CLAUDE CODE HOOKS COMPATIBILITY
|
||||
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands defined in settings.json.
|
||||
~2110 LOC across 19 files. Provides Claude Code settings.json compatibility layer. Parses CC permission rules and maps CC hooks (PreToolUse, PostToolUse) to OpenCode hooks.
|
||||
|
||||
**Config Sources** (priority): `.claude/settings.local.json` > `.claude/settings.json` (project) > `~/.claude/settings.json` (global)
|
||||
## WHAT IT DOES
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
claude-code-hooks/
|
||||
├── index.ts # Barrel export
|
||||
├── claude-code-hooks-hook.ts # Main factory (22 lines)
|
||||
├── config.ts # Claude settings.json loader (105 lines)
|
||||
├── config-loader.ts # Extended plugin config (107 lines)
|
||||
├── pre-tool-use.ts # PreToolUse hook executor (173 lines)
|
||||
├── post-tool-use.ts # PostToolUse hook executor (200 lines)
|
||||
├── user-prompt-submit.ts # UserPromptSubmit executor (125 lines)
|
||||
├── stop.ts # Stop hook executor (122 lines)
|
||||
├── pre-compact.ts # PreCompact executor (110 lines)
|
||||
├── transcript.ts # Tool use recording (235 lines)
|
||||
├── tool-input-cache.ts # Pre→post input caching (51 lines)
|
||||
├── todo.ts # Todo integration
|
||||
├── session-hook-state.ts # Active state tracking (11 lines)
|
||||
├── types.ts # Hook & IO type definitions (204 lines)
|
||||
├── plugin-config.ts # Default config constants (12 lines)
|
||||
└── handlers/ # Event handlers (5 files)
|
||||
├── pre-compact-handler.ts
|
||||
├── tool-execute-before-handler.ts
|
||||
├── tool-execute-after-handler.ts
|
||||
├── chat-message-handler.ts
|
||||
└── session-event-handler.ts
|
||||
1. Parses Claude Code `settings.json` permission format
|
||||
2. Maps CC hook types to OpenCode event types
|
||||
3. Enforces CC permission rules (allow/deny per tool)
|
||||
4. Supports CC `.claude/settings.json` and `.claude/settings.local.json`
|
||||
|
||||
## CC → OPENCODE HOOK MAPPING
|
||||
|
||||
| CC Hook | OpenCode Event |
|
||||
|---------|---------------|
|
||||
| PreToolUse | tool.execute.before |
|
||||
| PostToolUse | tool.execute.after |
|
||||
| Notification | event (session.idle) |
|
||||
| Stop | event (session.idle) |
|
||||
|
||||
## PERMISSION SYSTEM
|
||||
|
||||
CC permissions format:
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Edit", "Write"],
|
||||
"deny": ["Bash(rm:*)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HOOK LIFECYCLE
|
||||
Translated to OpenCode tool restrictions via permission-compat in shared/.
|
||||
|
||||
| Event | Timing | Can Block | Context Provided |
|
||||
|-------|--------|-----------|------------------|
|
||||
| PreToolUse | Before exec | Yes (exit 2) | sessionId, toolName, toolInput, cwd |
|
||||
| PostToolUse | After exec | Warn (exit 1) | + toolOutput, transcriptPath |
|
||||
| UserPromptSubmit | On message | Yes (exit 2) | sessionId, prompt, parts, cwd |
|
||||
| Stop | Session end | Inject | sessionId, parentSessionId, cwd |
|
||||
| PreCompact | Before summarize | No | sessionId, cwd |
|
||||
## FILES
|
||||
|
||||
## EXIT CODES
|
||||
|
||||
- `0`: Pass (continue)
|
||||
- `1`: Warn (continue + system message)
|
||||
- `2`: Block (abort operation)
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Heavy PreToolUse**: Runs before EVERY tool — keep scripts fast
|
||||
- **Blocking non-critical**: Prefer PostToolUse warnings
|
||||
- **Ignoring exit codes**: Return `2` to block sensitive tools
|
||||
Key files: `settings-loader.ts` (parse CC settings), `hook-mapper.ts` (CC→OC mapping), `permission-handler.ts` (rule enforcement), `types.ts` (CC type definitions).
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
||||
import { createContextWindowMonitorHook } from "./context-window-monitor"
|
||||
|
||||
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
|
||||
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
|
||||
|
||||
const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
|
||||
function resetContextLimitEnv(): void {
|
||||
if (originalAnthropicContextEnv === undefined) {
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv
|
||||
}
|
||||
|
||||
if (originalVertexContextEnv === undefined) {
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCtx() {
|
||||
return {
|
||||
client: {
|
||||
@@ -17,6 +39,12 @@ describe("context-window-monitor", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createMockCtx()
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetContextLimitEnv()
|
||||
})
|
||||
|
||||
// #given event caches token info from message.updated
|
||||
@@ -218,4 +246,81 @@ describe("context-window-monitor", () => {
|
||||
)
|
||||
expect(output.output).toBe("test")
|
||||
})
|
||||
|
||||
it("should use 1M limit when model cache flag is enabled", async () => {
|
||||
//#given
|
||||
const hook = createContextWindowMonitorHook(ctx as never, {
|
||||
anthropicContext1MEnabled: true,
|
||||
})
|
||||
const sessionID = "ses_1m_flag"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
|
||||
it("should keep env var fallback when model cache flag is disabled", async () => {
|
||||
//#given
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
|
||||
const hook = createContextWindowMonitorHook(ctx as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
})
|
||||
const sessionID = "ses_env_fallback"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,21 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||
|
||||
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
|
||||
type ModelCacheStateLike = {
|
||||
anthropicContext1MEnabled: boolean
|
||||
}
|
||||
|
||||
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
|
||||
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
|
||||
}
|
||||
|
||||
const CONTEXT_REMINDER = `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
|
||||
|
||||
You are using Anthropic Claude with 1M context window.
|
||||
@@ -31,7 +39,10 @@ function isAnthropicProvider(providerID: string): boolean {
|
||||
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
|
||||
}
|
||||
|
||||
export function createContextWindowMonitorHook(_ctx: PluginInput) {
|
||||
export function createContextWindowMonitorHook(
|
||||
_ctx: PluginInput,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
) {
|
||||
const remindedSessions = new Set<string>()
|
||||
const tokenCache = new Map<string, CachedTokenState>()
|
||||
|
||||
@@ -51,7 +62,8 @@ export function createContextWindowMonitorHook(_ctx: PluginInput) {
|
||||
const lastTokens = cached.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
|
||||
const actualUsagePercentage = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT
|
||||
const actualUsagePercentage =
|
||||
totalInputTokens / getAnthropicActualLimit(modelCacheState)
|
||||
|
||||
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
|
||||
|
||||
|
||||
@@ -27,9 +27,12 @@ interface EventInput {
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
export function createDirectoryAgentsInjectorHook(
|
||||
ctx: PluginInput,
|
||||
modelCacheState?: { anthropicContext1MEnabled: boolean },
|
||||
) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
@@ -27,9 +27,12 @@ interface EventInput {
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
export function createDirectoryReadmeInjectorHook(
|
||||
ctx: PluginInput,
|
||||
modelCacheState?: { anthropicContext1MEnabled: boolean },
|
||||
) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { createInteractiveBashSessionTracker } from "./interactive-bash-session-tracker";
|
||||
import { parseTmuxCommand } from "./tmux-command-parser";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createInteractiveBashSessionHook(ctx: PluginInput) {
|
||||
const tracker = createInteractiveBashSessionTracker({
|
||||
abortSession: (args) => ctx.client.session.abort(args),
|
||||
})
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID, args } = input;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (toolLower !== "interactive_bash") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof args?.tmux_command !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmuxCommand = args.tmux_command;
|
||||
const { subCommand, sessionName } = parseTmuxCommand(tmuxCommand)
|
||||
|
||||
const toolOutput = output?.output ?? ""
|
||||
if (toolOutput.startsWith("Error:")) {
|
||||
return
|
||||
}
|
||||
|
||||
const { reminderToAppend } = tracker.handleTmuxCommand({
|
||||
sessionID,
|
||||
subCommand,
|
||||
sessionName,
|
||||
toolOutput,
|
||||
})
|
||||
if (reminderToAppend) {
|
||||
output.output += reminderToAppend
|
||||
}
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
const sessionID = sessionInfo?.id;
|
||||
|
||||
if (sessionID) {
|
||||
await tracker.handleSessionDeleted(sessionID)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,26 @@
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
||||
|
||||
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
|
||||
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
|
||||
|
||||
const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
|
||||
function resetContextLimitEnv(): void {
|
||||
if (originalAnthropicContextEnv === undefined) {
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv
|
||||
}
|
||||
|
||||
if (originalVertexContextEnv === undefined) {
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv
|
||||
}
|
||||
}
|
||||
|
||||
const logMock = mock(() => {})
|
||||
|
||||
@@ -29,6 +51,12 @@ describe("preemptive-compaction", () => {
|
||||
beforeEach(() => {
|
||||
ctx = createMockCtx()
|
||||
logMock.mockClear()
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetContextLimitEnv()
|
||||
})
|
||||
|
||||
// #given event caches token info from message.updated
|
||||
@@ -238,4 +266,81 @@ describe("preemptive-compaction", () => {
|
||||
error: String(summarizeError),
|
||||
})
|
||||
})
|
||||
|
||||
it("should use 1M limit when model cache flag is enabled", async () => {
|
||||
//#given
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {
|
||||
anthropicContext1MEnabled: true,
|
||||
})
|
||||
const sessionID = "ses_1m_flag"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
{ title: "", output: "test", metadata: null }
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should keep env var fallback when model cache flag is disabled", async () => {
|
||||
//#given
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
})
|
||||
const sessionID = "ses_env_fallback"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
{ title: "", output: "test", metadata: null }
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,17 @@ import { log } from "../shared/logger"
|
||||
|
||||
const DEFAULT_ACTUAL_LIMIT = 200_000
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
type ModelCacheStateLike = {
|
||||
anthropicContext1MEnabled: boolean
|
||||
}
|
||||
|
||||
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
|
||||
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: DEFAULT_ACTUAL_LIMIT
|
||||
}
|
||||
|
||||
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
||||
|
||||
@@ -43,7 +49,10 @@ type PluginInput = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function createPreemptiveCompactionHook(ctx: PluginInput) {
|
||||
export function createPreemptiveCompactionHook(
|
||||
ctx: PluginInput,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
) {
|
||||
const compactionInProgress = new Set<string>()
|
||||
const compactedSessions = new Set<string>()
|
||||
const tokenCache = new Map<string, CachedCompactionState>()
|
||||
@@ -60,7 +69,7 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
|
||||
|
||||
const actualLimit =
|
||||
isAnthropicProvider(cached.providerID)
|
||||
? ANTHROPIC_ACTUAL_LIMIT
|
||||
? getAnthropicActualLimit(modelCacheState)
|
||||
: DEFAULT_ACTUAL_LIMIT
|
||||
|
||||
const lastTokens = cached.tokens
|
||||
|
||||
@@ -29,8 +29,11 @@ interface EventInput {
|
||||
|
||||
const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"];
|
||||
|
||||
export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
export function createRulesInjectorHook(
|
||||
ctx: PluginInput,
|
||||
modelCacheState?: { anthropicContext1MEnabled: boolean },
|
||||
) {
|
||||
const truncator = createDynamicTruncator(ctx, modelCacheState);
|
||||
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
|
||||
const { processFilePathForInjection } = createRuleInjectionProcessor({
|
||||
workspaceDirectory: ctx.directory,
|
||||
|
||||
@@ -27,11 +27,12 @@ const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
|
||||
}
|
||||
|
||||
interface ToolOutputTruncatorOptions {
|
||||
modelCacheState?: { anthropicContext1MEnabled: boolean }
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
|
||||
export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOutputTruncatorOptions) {
|
||||
const truncator = createDynamicTruncator(ctx)
|
||||
const truncator = createDynamicTruncator(ctx, options?.modelCacheState)
|
||||
const truncateAll = options?.experimental?.truncate_all_tool_outputs ?? false
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
|
||||
@@ -56,6 +56,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const hooks = createHooks({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
backgroundManager: managers.backgroundManager,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
|
||||
@@ -1,54 +1,58 @@
|
||||
# MCP KNOWLEDGE BASE
|
||||
# src/mcp/ — 3 Built-in Remote MCPs
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Tier 1 of three-tier MCP system: 3 built-in remote HTTP MCPs.
|
||||
Tier 1 of the three-tier MCP system. 3 remote HTTP MCPs created via `createBuiltinMcps(disabledMcps, config)`.
|
||||
|
||||
**Three-Tier System**:
|
||||
1. **Built-in** (this directory): websearch, context7, grep_app
|
||||
2. **Claude Code compat** (`features/claude-code-mcp-loader/`): .mcp.json with `${VAR}` expansion
|
||||
3. **Skill-embedded** (`features/opencode-skill-loader/`): YAML frontmatter in SKILL.md
|
||||
## BUILT-IN MCPs
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
mcp/
|
||||
├── index.ts # createBuiltinMcps() factory
|
||||
├── index.test.ts # Tests
|
||||
├── websearch.ts # Exa AI / Tavily web search
|
||||
├── context7.ts # Library documentation
|
||||
├── grep-app.ts # GitHub code search
|
||||
└── types.ts # McpNameSchema
|
||||
```
|
||||
| Name | URL | Env Vars | Tools |
|
||||
|------|-----|----------|-------|
|
||||
| **websearch** | `mcp.exa.ai` (default) or `mcp.tavily.com` | `EXA_API_KEY` (optional), `TAVILY_API_KEY` (if tavily) | Web search |
|
||||
| **context7** | `mcp.context7.com/mcp` | `CONTEXT7_API_KEY` (optional) | Library documentation |
|
||||
| **grep_app** | `mcp.grep.app` | None | GitHub code search |
|
||||
|
||||
## MCP SERVERS
|
||||
|
||||
| Name | URL | Auth | Purpose |
|
||||
|------|-----|------|---------|
|
||||
| websearch | mcp.exa.ai/mcp (default) or mcp.tavily.com/mcp/ | EXA_API_KEY (optional) / TAVILY_API_KEY (required) | Real-time web search |
|
||||
| context7 | mcp.context7.com/mcp | CONTEXT7_API_KEY (optional) | Library docs lookup |
|
||||
| grep_app | mcp.grep.app | None | GitHub code search |
|
||||
|
||||
## CONFIG PATTERN
|
||||
## REGISTRATION PATTERN
|
||||
|
||||
```typescript
|
||||
export const mcp_name = {
|
||||
// Static export (context7, grep_app)
|
||||
export const context7 = {
|
||||
type: "remote" as const,
|
||||
url: "https://...",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
enabled: true,
|
||||
oauth: false as const,
|
||||
headers?: { ... },
|
||||
}
|
||||
|
||||
// Factory with config (websearch)
|
||||
export function createWebsearchConfig(config?: WebsearchConfig): RemoteMcpConfig
|
||||
```
|
||||
|
||||
## HOW TO ADD
|
||||
## ENABLE/DISABLE
|
||||
|
||||
1. Create `src/mcp/my-mcp.ts` with config object
|
||||
2. Add conditional check in `createBuiltinMcps()` in `index.ts`
|
||||
3. Add name to `McpNameSchema` in `types.ts`
|
||||
```jsonc
|
||||
// Method 1: disabled_mcps array
|
||||
{ "disabled_mcps": ["websearch", "context7"] }
|
||||
|
||||
## NOTES
|
||||
// Method 2: enabled flag
|
||||
{ "mcp": { "websearch": { "enabled": false } } }
|
||||
```
|
||||
|
||||
- **Remote only**: HTTP/SSE transport, no stdio
|
||||
- **Disable**: Set `disabled_mcps: ["name"]` in config
|
||||
- **Exa**: Default provider, works without API key
|
||||
- **Tavily**: Requires `TAVILY_API_KEY` env var
|
||||
## THREE-TIER SYSTEM
|
||||
|
||||
| Tier | Source | Mechanism |
|
||||
|------|--------|-----------|
|
||||
| 1. Built-in | `src/mcp/` | 3 remote HTTP, created by `createBuiltinMcps()` |
|
||||
| 2. Claude Code | `.mcp.json` | `${VAR}` expansion via `claude-code-mcp-loader` |
|
||||
| 3. Skill-embedded | SKILL.md YAML | Managed by `SkillMcpManager` (stdio + HTTP) |
|
||||
|
||||
## FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | `createBuiltinMcps()` factory |
|
||||
| `types.ts` | `McpNameSchema`: "websearch" \| "context7" \| "grep_app" |
|
||||
| `websearch.ts` | Exa/Tavily provider with config |
|
||||
| `context7.ts` | Context7 with optional auth header |
|
||||
| `grep-app.ts` | Grep.app (no auth) |
|
||||
|
||||
@@ -1,65 +1,58 @@
|
||||
# PLUGIN-HANDLERS KNOWLEDGE BASE
|
||||
# src/plugin-handlers/ — 6-Phase Config Loading Pipeline
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Configuration orchestration layer. Runs once at plugin init — transforms raw OpenCode config into resolved agent/tool/permission structures via 6-phase sequential loading.
|
||||
13 non-test files implementing the `ConfigHandler` — the `config` hook handler. Executes 6 sequential phases to register agents, tools, MCPs, and commands with OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
plugin-handlers/
|
||||
├── config-handler.ts # Main orchestrator (45 lines) — 6-phase loading
|
||||
├── agent-config-handler.ts # Agent loading pipeline (197 lines)
|
||||
├── plan-model-inheritance.ts # Plan demotion logic (28 lines)
|
||||
├── prometheus-agent-config-builder.ts # Prometheus config builder (99 lines)
|
||||
├── plugin-components-loader.ts # Claude Code plugin discovery (71 lines, 10s timeout)
|
||||
├── provider-config-handler.ts # Provider config + model context limits cache
|
||||
├── tool-config-handler.ts # Permission migration (101 lines)
|
||||
├── mcp-config-handler.ts # Builtin + CC + plugin MCP merge
|
||||
├── command-config-handler.ts # Command/skill parallel discovery
|
||||
├── category-config-resolver.ts # Category lookup
|
||||
├── agent-priority-order.ts # Agent ordering (sisyphus, hephaestus, prometheus, atlas first)
|
||||
├── plan-model-inheritance.test.ts # 3696 lines of tests
|
||||
├── config-handler.test.ts # 1061 lines of tests
|
||||
└── index.ts # Barrel exports
|
||||
```
|
||||
## 6-PHASE PIPELINE
|
||||
|
||||
## CONFIG LOADING FLOW (6 phases, sequential)
|
||||
| Phase | Handler | Purpose |
|
||||
|-------|---------|---------|
|
||||
| 1 | `applyProviderConfig` | Cache model context limits, detect anthropic-beta headers |
|
||||
| 2 | `loadPluginComponents` | Discover Claude Code plugins (10s timeout, error isolation) |
|
||||
| 3 | `applyAgentConfig` | Load agents from 5 sources, skill discovery, plan demotion |
|
||||
| 4 | `applyToolConfig` | Agent-specific tool permissions |
|
||||
| 5 | `applyMcpConfig` | Merge builtin + CC + plugin MCPs |
|
||||
| 6 | `applyCommandConfig` | Merge commands/skills from 9 parallel sources |
|
||||
|
||||
1. `applyProviderConfig` → Cache model context limits, detect anthropic-beta headers
|
||||
2. `loadPluginComponents` → Discover Claude Code plugins (10s timeout, error isolation)
|
||||
3. `applyAgentConfig` → Load all agents, sisyphus/prometheus/plan demotion
|
||||
4. `applyToolConfig` → Agent-specific tool permissions (grep_app, task, teammate)
|
||||
5. `applyMcpConfig` → Merge builtin + Claude Code + plugin MCPs
|
||||
6. `applyCommandConfig` → Merge builtin + user + project + opencode commands/skills
|
||||
## FILES
|
||||
|
||||
## PLAN MODEL INHERITANCE
|
||||
|
||||
When `sisyphus_agent.planner_enabled === true`:
|
||||
1. Prometheus config → extract model settings (model, variant, temperature, ...)
|
||||
2. Apply user `agents.plan` overrides (plan override wins)
|
||||
3. Set `mode: "subagent"` (plan becomes subagent, not primary)
|
||||
4. Strip prompt/permission/description (only model settings inherited)
|
||||
|
||||
## AGENT LOADING ORDER
|
||||
|
||||
1. Builtin agents (sisyphus, hephaestus, oracle, ...)
|
||||
2. Sisyphus-Junior (if sisyphus enabled)
|
||||
3. OpenCode-Builder (if `default_builder_enabled`)
|
||||
4. Prometheus (if `planner_enabled`)
|
||||
5. User agents → Project agents → Plugin agents → Custom agents
|
||||
|
||||
**Reordered** by `reorderAgentsByPriority()`: sisyphus, hephaestus, prometheus, atlas first.
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `config-handler.ts` | ~200 | Main orchestrator, 6-phase sequential |
|
||||
| `plugin-components-loader.ts` | ~100 | CC plugin discovery (10s timeout) |
|
||||
| `agent-config-handler.ts` | ~300 | Agent loading + skill discovery from 5 sources |
|
||||
| `mcp-config-handler.ts` | ~150 | Builtin + CC + plugin MCP merge |
|
||||
| `command-config-handler.ts` | ~200 | 9 parallel sources for commands/skills |
|
||||
| `tool-config-handler.ts` | ~100 | Agent-specific tool grants/denials |
|
||||
| `provider-config-handler.ts` | ~80 | Provider config + model cache |
|
||||
| `prometheus-agent-config-builder.ts` | ~100 | Prometheus config with model resolution |
|
||||
| `plan-model-inheritance.ts` | 28 | Plan demotion logic |
|
||||
| `agent-priority-order.ts` | ~30 | sisyphus, hephaestus, prometheus, atlas first |
|
||||
| `agent-key-remapper.ts` | ~30 | Agent key → display name |
|
||||
| `category-config-resolver.ts` | ~40 | User vs default category lookup |
|
||||
| `index.ts` | ~10 | Barrel exports |
|
||||
|
||||
## TOOL PERMISSIONS
|
||||
|
||||
| Agent | Special Permissions |
|
||||
|-------|---------------------|
|
||||
| librarian | grep_app_* allowed |
|
||||
| atlas | task, task_*, teammate allowed |
|
||||
| sisyphus | task, task_*, teammate, question allowed |
|
||||
| hephaestus | task, question allowed |
|
||||
| multimodal-looker | Denies task, look_at |
|
||||
| Agent | Granted | Denied |
|
||||
|-------|---------|--------|
|
||||
| Librarian | grep_app_* | — |
|
||||
| Atlas, Sisyphus, Prometheus | task, task_*, teammate | — |
|
||||
| Hephaestus | task | — |
|
||||
| Default (all others) | — | grep_app_*, task_*, teammate, LSP |
|
||||
|
||||
## INTEGRATION
|
||||
## MULTI-LEVEL CONFIG MERGE
|
||||
|
||||
Created in `create-managers.ts`, exposed as `config` hook in `plugin-interface.ts`. OpenCode calls it during session init.
|
||||
```
|
||||
User (~/.config/opencode/oh-my-opencode.jsonc)
|
||||
↓ deepMerge
|
||||
Project (.opencode/oh-my-opencode.jsonc)
|
||||
↓ Zod defaults
|
||||
Final Config
|
||||
```
|
||||
|
||||
- `agents`, `categories`, `claude_code`: deep merged
|
||||
- `disabled_*` arrays: Set union
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HookName, OhMyOpenCodeConfig } from "../../config"
|
||||
import type { PluginContext } from "../types"
|
||||
import type { ModelCacheState } from "../../plugin-state"
|
||||
|
||||
import { createSessionHooks } from "./create-session-hooks"
|
||||
import { createToolGuardHooks } from "./create-tool-guard-hooks"
|
||||
@@ -8,14 +9,16 @@ import { createTransformHooks } from "./create-transform-hooks"
|
||||
export function createCoreHooks(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
modelCacheState: ModelCacheState
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHookEnabled: boolean
|
||||
}) {
|
||||
const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args
|
||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
||||
|
||||
const session = createSessionHooks({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
})
|
||||
@@ -23,6 +26,7 @@ export function createCoreHooks(args: {
|
||||
const tool = createToolGuardHooks({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
||||
import type { ModelCacheState } from "../../plugin-state"
|
||||
import type { PluginContext } from "../types"
|
||||
|
||||
import {
|
||||
@@ -55,21 +56,24 @@ export type SessionHooks = {
|
||||
export function createSessionHooks(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
modelCacheState: ModelCacheState
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHookEnabled: boolean
|
||||
}): SessionHooks {
|
||||
const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args
|
||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
||||
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||
|
||||
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
||||
? safeHook("context-window-monitor", () => createContextWindowMonitorHook(ctx))
|
||||
? safeHook("context-window-monitor", () =>
|
||||
createContextWindowMonitorHook(ctx, modelCacheState))
|
||||
: null
|
||||
|
||||
const preemptiveCompaction =
|
||||
isHookEnabled("preemptive-compaction") &&
|
||||
pluginConfig.experimental?.preemptive_compaction
|
||||
? safeHook("preemptive-compaction", () => createPreemptiveCompactionHook(ctx))
|
||||
? safeHook("preemptive-compaction", () =>
|
||||
createPreemptiveCompactionHook(ctx, modelCacheState))
|
||||
: null
|
||||
|
||||
const sessionRecovery = isHookEnabled("session-recovery")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HookName, OhMyOpenCodeConfig } from "../../config"
|
||||
import type { ModelCacheState } from "../../plugin-state"
|
||||
import type { PluginContext } from "../types"
|
||||
|
||||
import {
|
||||
@@ -35,10 +36,11 @@ export type ToolGuardHooks = {
|
||||
export function createToolGuardHooks(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
modelCacheState: ModelCacheState
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHookEnabled: boolean
|
||||
}): ToolGuardHooks {
|
||||
const { ctx, pluginConfig, isHookEnabled, safeHookEnabled } = args
|
||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
||||
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||
|
||||
@@ -48,7 +50,10 @@ export function createToolGuardHooks(args: {
|
||||
|
||||
const toolOutputTruncator = isHookEnabled("tool-output-truncator")
|
||||
? safeHook("tool-output-truncator", () =>
|
||||
createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental }))
|
||||
createToolOutputTruncatorHook(ctx, {
|
||||
modelCacheState,
|
||||
experimental: pluginConfig.experimental,
|
||||
}))
|
||||
: null
|
||||
|
||||
let directoryAgentsInjector: ReturnType<typeof createDirectoryAgentsInjectorHook> | null = null
|
||||
@@ -62,12 +67,14 @@ export function createToolGuardHooks(args: {
|
||||
nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
|
||||
})
|
||||
} else {
|
||||
directoryAgentsInjector = safeHook("directory-agents-injector", () => createDirectoryAgentsInjectorHook(ctx))
|
||||
directoryAgentsInjector = safeHook("directory-agents-injector", () =>
|
||||
createDirectoryAgentsInjectorHook(ctx, modelCacheState))
|
||||
}
|
||||
}
|
||||
|
||||
const directoryReadmeInjector = isHookEnabled("directory-readme-injector")
|
||||
? safeHook("directory-readme-injector", () => createDirectoryReadmeInjectorHook(ctx))
|
||||
? safeHook("directory-readme-injector", () =>
|
||||
createDirectoryReadmeInjectorHook(ctx, modelCacheState))
|
||||
: null
|
||||
|
||||
const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector")
|
||||
@@ -75,7 +82,8 @@ export function createToolGuardHooks(args: {
|
||||
: null
|
||||
|
||||
const rulesInjector = isHookEnabled("rules-injector")
|
||||
? safeHook("rules-injector", () => createRulesInjectorHook(ctx))
|
||||
? safeHook("rules-injector", () =>
|
||||
createRulesInjectorHook(ctx, modelCacheState))
|
||||
: null
|
||||
|
||||
const tasksTodowriteDisabler = isHookEnabled("tasks-todowrite-disabler")
|
||||
|
||||
@@ -1,97 +1,54 @@
|
||||
# SHARED UTILITIES KNOWLEDGE BASE
|
||||
# src/shared/ — 101 Utility Files in 13 Categories
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
96 cross-cutting utilities across 4 subdirectories. Import via barrel: `import { log, deepMerge } from "../../shared"`
|
||||
Cross-cutting utilities used throughout the plugin. Barrel-exported from `index.ts`. Logger writes to `/tmp/oh-my-opencode.log`.
|
||||
|
||||
## CATEGORY MAP
|
||||
|
||||
| Category | Files | Key Exports |
|
||||
|----------|-------|-------------|
|
||||
| **Model Resolution** | 17 | `resolveModel()`, `checkModelAvailability()`, `AGENT_MODEL_REQUIREMENTS` |
|
||||
| **Tmux Integration** | 11 | `createTmuxSession()`, `spawnPane()`, `closePane()`, server health |
|
||||
| **Configuration & Paths** | 10 | `resolveOpenCodeConfigDir()`, `getDataPath()`, `parseJSONC()` |
|
||||
| **Session Management** | 8 | `SessionCursor`, `trackInjectedPath()`, `SessionToolsStore` |
|
||||
| **Git Worktree** | 7 | `parseGitStatusPorcelain()`, `collectGitDiffStats()`, `formatFileChanges()` |
|
||||
| **Command Execution** | 7 | `executeCommand()`, `executeHookCommand()`, embedded command registry |
|
||||
| **Migration** | 6 | `migrateConfigFile()`, AGENT_NAME_MAP, HOOK_NAME_MAP, MODEL_VERSION_MAP |
|
||||
| **String & Tool Utils** | 6 | `toSnakeCase()`, `normalizeToolName()`, `parseFrontmatter()` |
|
||||
| **Agent Configuration** | 5 | `getAgentVariant()`, `AGENT_DISPLAY_NAMES`, `AGENT_TOOL_RESTRICTIONS` |
|
||||
| **OpenCode Integration** | 5 | `injectServerAuth()`, `detectExternalPlugins()`, client accessors |
|
||||
| **Type Helpers** | 4 | `deepMerge()`, `DynamicTruncator`, `matchPattern()`, `isRecord()` |
|
||||
| **Misc** | 8 | `log()`, `readFile()`, `extractZip()`, `downloadBinary()`, `findAvailablePort()` |
|
||||
|
||||
## MODEL RESOLUTION PIPELINE
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
shared/
|
||||
├── logger.ts # File logging (/tmp/oh-my-opencode.log) — 62 imports
|
||||
├── dynamic-truncator.ts # Token-aware context window management (202 lines)
|
||||
├── model-resolver.ts # 3-step resolution entry point (65 lines)
|
||||
├── model-availability.ts # Provider model fetching & fuzzy matching (359 lines)
|
||||
├── model-requirements.ts # Agent/category fallback chains (161 lines) — 11 imports
|
||||
├── model-resolution-pipeline.ts # Pipeline orchestration (176 lines)
|
||||
├── model-resolution-types.ts # Resolution request/provenance types
|
||||
├── model-sanitizer.ts # Model name sanitization
|
||||
├── model-name-matcher.ts # Model name matching (91 lines)
|
||||
├── model-suggestion-retry.ts # Suggest models on failure (144 lines)
|
||||
├── model-cache-availability.ts # Cache availability checking
|
||||
├── fallback-model-availability.ts # Fallback model logic (67 lines)
|
||||
├── available-models-fetcher.ts # Fetch models from providers (114 lines)
|
||||
├── models-json-cache-reader.ts # Read models.json cache
|
||||
├── provider-models-cache-model-reader.ts # Provider cache reader
|
||||
├── connected-providers-cache.ts # Provider caching (196 lines)
|
||||
├── system-directive.ts # Unified message prefix & types (61 lines) — 11 imports
|
||||
├── session-utils.ts # Session cursor, orchestrator detection
|
||||
├── session-cursor.ts # Message cursor tracking (85 lines)
|
||||
├── session-injected-paths.ts # Injected file path tracking
|
||||
├── permission-compat.ts # Tool restriction enforcement (87 lines) — 9 imports
|
||||
├── agent-tool-restrictions.ts # Tool restriction definitions
|
||||
├── agent-variant.ts # Agent variant from config (91 lines)
|
||||
├── agent-display-names.ts # Agent display name mapping
|
||||
├── first-message-variant.ts # First message variant types
|
||||
├── opencode-config-dir.ts # ~/.config/opencode resolution (138 lines)
|
||||
├── claude-config-dir.ts # ~/.claude resolution
|
||||
├── data-path.ts # XDG-compliant storage (47 lines) — 11 imports
|
||||
├── jsonc-parser.ts # JSONC with comment support (66 lines)
|
||||
├── frontmatter.ts # YAML frontmatter extraction (31 lines) — 10 imports
|
||||
├── deep-merge.ts # Recursive merge (proto-pollution safe, MAX_DEPTH=50)
|
||||
├── shell-env.ts # Cross-platform shell environment (111 lines)
|
||||
├── opencode-version.ts # Semantic version comparison (80 lines)
|
||||
├── external-plugin-detector.ts # Plugin conflict detection (137 lines)
|
||||
├── opencode-server-auth.ts # Authentication utilities (190 lines)
|
||||
├── safe-create-hook.ts # Hook error wrapper (24 lines)
|
||||
├── pattern-matcher.ts # Pattern matching (40 lines)
|
||||
├── file-utils.ts # File operations (34 lines) — 9 imports
|
||||
├── file-reference-resolver.ts # File reference resolution (85 lines)
|
||||
├── snake-case.ts # Case conversion (44 lines)
|
||||
├── tool-name.ts # Tool naming conventions
|
||||
├── port-utils.ts # Port management (48 lines)
|
||||
├── zip-extractor.ts # ZIP extraction (83 lines)
|
||||
├── binary-downloader.ts # Binary download (60 lines)
|
||||
├── command-executor/ # Shell execution (6 files, 213 lines)
|
||||
├── git-worktree/ # Git status/diff parsing (8 files, 311 lines)
|
||||
├── migration/ # Legacy config migration (5 files, 341 lines)
|
||||
│ ├── config-migration.ts # Migration orchestration (133 lines)
|
||||
│ ├── agent-names.ts # Agent name mapping (70 lines)
|
||||
│ ├── hook-names.ts # Hook name mapping (36 lines)
|
||||
│ └── model-versions.ts # Model version migration (49 lines)
|
||||
└── tmux/ # Tmux TUI integration (12 files, 427 lines)
|
||||
└── tmux-utils/ # Pane spawn, close, replace, layout, health
|
||||
resolveModel(input)
|
||||
1. Override: UI-selected model (primary agents only)
|
||||
2. Category default: From category config
|
||||
3. Provider fallback: AGENT_MODEL_REQUIREMENTS chains
|
||||
4. System default: Ultimate fallback
|
||||
```
|
||||
|
||||
Key files: `model-resolver.ts` (entry), `model-resolution-pipeline.ts` (orchestration), `model-requirements.ts` (fallback chains), `model-name-matcher.ts` (fuzzy matching).
|
||||
|
||||
## MIGRATION SYSTEM
|
||||
|
||||
Automatically transforms legacy config on load:
|
||||
- `agent-names.ts`: Old agent names → new (e.g., `junior` → `sisyphus-junior`)
|
||||
- `hook-names.ts`: Old hook names → new
|
||||
- `model-versions.ts`: Old model IDs → current
|
||||
- `agent-category.ts`: Legacy agent configs → category system
|
||||
|
||||
## MOST IMPORTED
|
||||
|
||||
| Utility | Imports | Purpose |
|
||||
|---------|---------|---------|
|
||||
| logger.ts | 62 | Background task visibility |
|
||||
| data-path.ts | 11 | XDG storage resolution |
|
||||
| model-requirements.ts | 11 | Agent fallback chains |
|
||||
| system-directive.ts | 11 | System message filtering |
|
||||
| frontmatter.ts | 10 | YAML metadata extraction |
|
||||
| permission-compat.ts | 9 | Tool restrictions |
|
||||
| file-utils.ts | 9 | File operations |
|
||||
| dynamic-truncator.ts | 7 | Token-aware truncation |
|
||||
|
||||
## KEY PATTERNS
|
||||
|
||||
**3-Step Model Resolution** (Override → Fallback → Default):
|
||||
1. **Override**: UI-selected or user-configured model
|
||||
2. **Fallback**: Provider/model chain with availability checking
|
||||
3. **Default**: System fallback when no matches found
|
||||
|
||||
**System Directive Filtering**:
|
||||
```typescript
|
||||
if (isSystemDirective(message)) return // Skip system-generated
|
||||
const directive = createSystemDirective("TODO CONTINUATION")
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Raw JSON.parse**: Use `jsonc-parser.ts` for comment support
|
||||
- **Hardcoded paths**: Use `opencode-config-dir.ts` or `data-path.ts`
|
||||
- **console.log**: Use `logger.ts` for background task visibility
|
||||
- **Unbounded output**: Use `dynamic-truncator.ts` to prevent overflow
|
||||
- **Manual version check**: Use `opencode-version.ts` for semver safety
|
||||
| Utility | Import Count | Purpose |
|
||||
|---------|-------------|---------|
|
||||
| `logger.ts` | 62 | `/tmp/oh-my-opencode.log` |
|
||||
| `data-path.ts` | 11 | XDG storage resolution |
|
||||
| `model-requirements.ts` | 11 | Agent fallback chains |
|
||||
| `system-directive.ts` | 11 | System message filtering |
|
||||
| `frontmatter.ts` | 10 | YAML metadata extraction |
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { addModelsFromModelsJsonCache } from "./models-json-cache-reader"
|
||||
import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors"
|
||||
import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader"
|
||||
import { log } from "./logger"
|
||||
import { normalizeSDKResponse } from "./normalize-sdk-response"
|
||||
|
||||
export async function getConnectedProviders(client: unknown): Promise<string[]> {
|
||||
const providerList = getProviderListFunction(client)
|
||||
if (!providerList) {
|
||||
log("[getConnectedProviders] client.provider.list not available")
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await providerList()
|
||||
const connected = result.data?.connected ?? []
|
||||
log("[getConnectedProviders] connected providers", {
|
||||
count: connected.length,
|
||||
providers: connected,
|
||||
})
|
||||
return connected
|
||||
} catch (err) {
|
||||
log("[getConnectedProviders] SDK error", { error: String(err) })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAvailableModels(
|
||||
client?: unknown,
|
||||
options?: { connectedProviders?: string[] | null },
|
||||
): Promise<Set<string>> {
|
||||
let connectedProviders = options?.connectedProviders ?? null
|
||||
let connectedProvidersUnknown = connectedProviders === null
|
||||
|
||||
log("[fetchAvailableModels] CALLED", {
|
||||
connectedProvidersUnknown,
|
||||
connectedProviders: options?.connectedProviders,
|
||||
})
|
||||
|
||||
if (connectedProvidersUnknown && client !== undefined) {
|
||||
const liveConnected = await getConnectedProviders(client)
|
||||
if (liveConnected.length > 0) {
|
||||
connectedProviders = liveConnected
|
||||
connectedProvidersUnknown = false
|
||||
log("[fetchAvailableModels] connected providers fetched from client", {
|
||||
count: liveConnected.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedProvidersUnknown) {
|
||||
const modelList = client === undefined ? null : getModelListFunction(client)
|
||||
if (modelList) {
|
||||
const modelSet = new Set<string>()
|
||||
try {
|
||||
const modelsResult = await modelList()
|
||||
const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
|
||||
for (const model of models) {
|
||||
if (model.provider && model.id) {
|
||||
modelSet.add(`${model.provider}/${model.id}`)
|
||||
}
|
||||
}
|
||||
log(
|
||||
"[fetchAvailableModels] fetched models from client without provider filter",
|
||||
{ count: modelSet.size },
|
||||
)
|
||||
return modelSet
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] client.model.list error", {
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
log(
|
||||
"[fetchAvailableModels] connected providers unknown, returning empty set for fallback resolution",
|
||||
)
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
const connectedProvidersList = connectedProviders ?? []
|
||||
const connectedSet = new Set(connectedProvidersList)
|
||||
const modelSet = new Set<string>()
|
||||
|
||||
if (addModelsFromProviderModelsCache(connectedSet, modelSet)) {
|
||||
return modelSet
|
||||
}
|
||||
log("[fetchAvailableModels] provider-models cache not found, falling back to models.json")
|
||||
if (addModelsFromModelsJsonCache(connectedSet, modelSet)) {
|
||||
return modelSet
|
||||
}
|
||||
|
||||
const modelList = client === undefined ? null : getModelListFunction(client)
|
||||
if (modelList) {
|
||||
try {
|
||||
const modelsResult = await modelList()
|
||||
const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
|
||||
|
||||
for (const model of models) {
|
||||
if (!model.provider || !model.id) continue
|
||||
if (connectedSet.has(model.provider)) {
|
||||
modelSet.add(`${model.provider}/${model.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] fetched models from client (filtered)", {
|
||||
count: modelSet.size,
|
||||
connectedProviders: connectedProvidersList.slice(0, 5),
|
||||
})
|
||||
} catch (err) {
|
||||
log("[fetchAvailableModels] client.model.list error", { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
return modelSet
|
||||
}
|
||||
102
src/shared/dynamic-truncator.test.ts
Normal file
102
src/shared/dynamic-truncator.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it, afterEach } from "bun:test"
|
||||
|
||||
import { getContextWindowUsage } from "./dynamic-truncator"
|
||||
|
||||
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
|
||||
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
|
||||
|
||||
const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
|
||||
function resetContextLimitEnv(): void {
|
||||
if (originalAnthropicContextEnv === undefined) {
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv
|
||||
}
|
||||
|
||||
if (originalVertexContextEnv === undefined) {
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv
|
||||
}
|
||||
}
|
||||
|
||||
function createContextUsageMockContext(inputTokens: number) {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
tokens: {
|
||||
input: inputTokens,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("getContextWindowUsage", () => {
|
||||
afterEach(() => {
|
||||
resetContextLimitEnv()
|
||||
})
|
||||
|
||||
it("uses 1M limit when model cache flag is enabled", async () => {
|
||||
//#given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const ctx = createContextUsageMockContext(300000)
|
||||
|
||||
//#when
|
||||
const usage = await getContextWindowUsage(ctx as never, "ses_1m_flag", {
|
||||
anthropicContext1MEnabled: true,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(usage?.usagePercentage).toBe(0.3)
|
||||
expect(usage?.remainingTokens).toBe(700000)
|
||||
})
|
||||
|
||||
it("uses 200K limit when model cache flag is disabled and env vars are unset", async () => {
|
||||
//#given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const ctx = createContextUsageMockContext(150000)
|
||||
|
||||
//#when
|
||||
const usage = await getContextWindowUsage(ctx as never, "ses_default", {
|
||||
anthropicContext1MEnabled: false,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(usage?.usagePercentage).toBe(0.75)
|
||||
expect(usage?.remainingTokens).toBe(50000)
|
||||
})
|
||||
|
||||
it("keeps env var fallback when model cache flag is disabled", async () => {
|
||||
//#given
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
|
||||
const ctx = createContextUsageMockContext(300000)
|
||||
|
||||
//#when
|
||||
const usage = await getContextWindowUsage(ctx as never, "ses_env_fallback", {
|
||||
anthropicContext1MEnabled: false,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(usage?.usagePercentage).toBe(0.3)
|
||||
expect(usage?.remainingTokens).toBe(700000)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,22 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { normalizeSDKResponse } from "./normalize-sdk-response"
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000;
|
||||
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000;
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const DEFAULT_TARGET_MAX_TOKENS = 50_000;
|
||||
|
||||
type ModelCacheStateLike = {
|
||||
anthropicContext1MEnabled: boolean;
|
||||
}
|
||||
|
||||
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
|
||||
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT;
|
||||
}
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant";
|
||||
tokens: {
|
||||
@@ -110,6 +118,7 @@ export function truncateToTokenLimit(
|
||||
export async function getContextWindowUsage(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
): Promise<{
|
||||
usedTokens: number;
|
||||
remainingTokens: number;
|
||||
@@ -134,12 +143,13 @@ export async function getContextWindowUsage(
|
||||
(lastTokens?.input ?? 0) +
|
||||
(lastTokens?.cache?.read ?? 0) +
|
||||
(lastTokens?.output ?? 0);
|
||||
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - usedTokens;
|
||||
const anthropicActualLimit = getAnthropicActualLimit(modelCacheState);
|
||||
const remainingTokens = anthropicActualLimit - usedTokens;
|
||||
|
||||
return {
|
||||
usedTokens,
|
||||
remainingTokens,
|
||||
usagePercentage: usedTokens / ANTHROPIC_ACTUAL_LIMIT,
|
||||
usagePercentage: usedTokens / anthropicActualLimit,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -151,6 +161,7 @@ export async function dynamicTruncate(
|
||||
sessionID: string,
|
||||
output: string,
|
||||
options: TruncationOptions = {},
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
): Promise<TruncationResult> {
|
||||
if (typeof output !== 'string') {
|
||||
return { result: String(output ?? ''), truncated: false };
|
||||
@@ -161,7 +172,7 @@ export async function dynamicTruncate(
|
||||
preserveHeaderLines = 3,
|
||||
} = options;
|
||||
|
||||
const usage = await getContextWindowUsage(ctx, sessionID);
|
||||
const usage = await getContextWindowUsage(ctx, sessionID, modelCacheState);
|
||||
|
||||
if (!usage) {
|
||||
// Fallback: apply conservative truncation when context usage unavailable
|
||||
@@ -183,15 +194,19 @@ export async function dynamicTruncate(
|
||||
return truncateToTokenLimit(output, maxOutputTokens, preserveHeaderLines);
|
||||
}
|
||||
|
||||
export function createDynamicTruncator(ctx: PluginInput) {
|
||||
export function createDynamicTruncator(
|
||||
ctx: PluginInput,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
) {
|
||||
return {
|
||||
truncate: (
|
||||
sessionID: string,
|
||||
output: string,
|
||||
options?: TruncationOptions,
|
||||
) => dynamicTruncate(ctx, sessionID, output, options),
|
||||
) => dynamicTruncate(ctx, sessionID, output, options, modelCacheState),
|
||||
|
||||
getUsage: (sessionID: string) => getContextWindowUsage(ctx, sessionID),
|
||||
getUsage: (sessionID: string) =>
|
||||
getContextWindowUsage(ctx, sessionID, modelCacheState),
|
||||
|
||||
truncateSync: (
|
||||
output: string,
|
||||
|
||||
@@ -41,6 +41,7 @@ export type {
|
||||
ModelResolutionResult,
|
||||
} from "./model-resolution-types"
|
||||
export * from "./model-availability"
|
||||
export * from "./fallback-model-availability"
|
||||
export * from "./connected-providers-cache"
|
||||
export * from "./session-utils"
|
||||
export * from "./tmux"
|
||||
|
||||
@@ -283,71 +283,6 @@ export async function fetchAvailableModels(
|
||||
return modelSet
|
||||
}
|
||||
|
||||
export function isAnyFallbackModelAvailable(
|
||||
fallbackChain: Array<{ providers: string[]; model: string }>,
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
// If we have models, check them first
|
||||
if (availableModels.size > 0) {
|
||||
for (const entry of fallbackChain) {
|
||||
const hasAvailableProvider = entry.providers.some((provider) => {
|
||||
return fuzzyMatchModel(entry.model, availableModels, [provider]) !== null
|
||||
})
|
||||
if (hasAvailableProvider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check if any provider in the chain is connected
|
||||
// This handles race conditions where availableModels is empty or incomplete
|
||||
// but we know the provider is connected.
|
||||
const connectedProviders = connectedProvidersCache.readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders)
|
||||
for (const entry of fallbackChain) {
|
||||
if (entry.providers.some((p) => connectedSet.has(p))) {
|
||||
log("[isAnyFallbackModelAvailable] model not in available set, but provider is connected", {
|
||||
model: entry.model,
|
||||
availableCount: availableModels.size,
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isAnyProviderConnected(
|
||||
providers: string[],
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
if (availableModels.size > 0) {
|
||||
const providerSet = new Set(providers)
|
||||
for (const model of availableModels) {
|
||||
const [provider] = model.split("/")
|
||||
if (providerSet.has(provider)) {
|
||||
log("[isAnyProviderConnected] found model from required provider", { provider, model })
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectedProviders = connectedProvidersCache.readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders)
|
||||
for (const provider of providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
log("[isAnyProviderConnected] provider connected via cache", { provider })
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function __resetModelCache(): void {}
|
||||
|
||||
export function isModelCacheAvailable(): boolean {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { getOpenCodeCacheDir } from "./data-path"
|
||||
import { hasProviderModelsCache } from "./connected-providers-cache"
|
||||
|
||||
export function __resetModelCache(): void {}
|
||||
|
||||
export function isModelCacheAvailable(): boolean {
|
||||
if (hasProviderModelsCache()) {
|
||||
return true
|
||||
}
|
||||
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
|
||||
return existsSync(cacheFile)
|
||||
}
|
||||
@@ -1,83 +1,109 @@
|
||||
# TOOLS KNOWLEDGE BASE
|
||||
# src/tools/ — 26 Tools Across 14 Directories
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
26 tools across 14 directories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent).
|
||||
26 tools registered via `createToolRegistry()`. Two patterns: factory functions (`createXXXTool`) for 19 tools, direct `ToolDefinition` for 7 (LSP + interactive_bash).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
tools/
|
||||
├── delegate-task/ # Category routing (constants.ts 569 lines, tools.ts 213 lines)
|
||||
├── task/ # 4 tools: create, list, get, update (task-create.ts, task-list.ts, task-get.ts, task-update.ts)
|
||||
├── lsp/ # 6 LSP tools: goto_definition, find_references, symbols, diagnostics, prepare_rename, rename
|
||||
├── ast-grep/ # 2 tools: search, replace (25 languages)
|
||||
├── grep/ # Content search (60s timeout, 10MB limit)
|
||||
├── glob/ # File pattern matching (60s timeout, 100 file limit)
|
||||
├── session-manager/ # 4 tools: list, read, search, info
|
||||
├── call-omo-agent/ # Direct agent invocation (explore/librarian)
|
||||
├── background-task/ # background_output, background_cancel
|
||||
├── interactive-bash/ # Tmux session management (135 lines)
|
||||
├── look-at/ # Multimodal PDF/image analysis (156 lines)
|
||||
├── skill/ # Skill execution with MCP support (211 lines)
|
||||
├── skill-mcp/ # MCP tool/resource/prompt operations (182 lines)
|
||||
└── slashcommand/ # Slash command dispatch
|
||||
```
|
||||
## TOOL CATALOG
|
||||
|
||||
## TOOL INVENTORY
|
||||
### Task Management (4)
|
||||
|
||||
| Tool | Category | Pattern | Key Logic |
|
||||
|------|----------|---------|-----------|
|
||||
| `task_create` | Task | Factory | Auto-generated T-{uuid} ID, threadID recording, dependency management |
|
||||
| `task_list` | Task | Factory | Active tasks with summary (excludes completed/deleted), filters unresolved blockers |
|
||||
| `task_get` | Task | Factory | Full task object by ID |
|
||||
| `task_update` | Task | Factory | Status/field updates, additive addBlocks/addBlockedBy for dependencies |
|
||||
| `task` | Delegation | Factory | Category routing with skill injection, background execution |
|
||||
| `call_omo_agent` | Agent | Factory | Direct explore/librarian invocation |
|
||||
| `background_output` | Background | Factory | Retrieve background task result (block, timeout, full_session) |
|
||||
| `background_cancel` | Background | Factory | Cancel running/all background tasks |
|
||||
| `lsp_goto_definition` | LSP | Direct | Jump to symbol definition |
|
||||
| `lsp_find_references` | LSP | Direct | Find all usages across workspace |
|
||||
| `lsp_symbols` | LSP | Direct | Document or workspace symbol search |
|
||||
| `lsp_diagnostics` | LSP | Direct | Get errors/warnings from language server |
|
||||
| `lsp_prepare_rename` | LSP | Direct | Validate rename is possible |
|
||||
| `lsp_rename` | LSP | Direct | Rename symbol across workspace |
|
||||
| `ast_grep_search` | Search | Factory | AST-aware code search (25 languages) |
|
||||
| `ast_grep_replace` | Search | Factory | AST-aware code replacement (dry-run default) |
|
||||
| `grep` | Search | Factory | Regex content search with safety limits |
|
||||
| `glob` | Search | Factory | File pattern matching |
|
||||
| `session_list` | Session | Factory | List all sessions |
|
||||
| `session_read` | Session | Factory | Read session messages with filters |
|
||||
| `session_search` | Session | Factory | Search across sessions |
|
||||
| `session_info` | Session | Factory | Session metadata and stats |
|
||||
| `interactive_bash` | System | Direct | Tmux session management |
|
||||
| `look_at` | System | Factory | Multimodal PDF/image analysis via dedicated agent |
|
||||
| `skill` | Skill | Factory | Load skill instructions with MCP support |
|
||||
| `skill_mcp` | Skill | Factory | Call MCP tools/resources/prompts from skill-embedded servers |
|
||||
| `slashcommand` | Command | Factory | Slash command dispatch with argument substitution |
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `task_create` | `createTaskCreateTool` | subject, description, blockedBy, blocks, metadata, parentID |
|
||||
| `task_list` | `createTaskList` | (none) |
|
||||
| `task_get` | `createTaskGetTool` | id |
|
||||
| `task_update` | `createTaskUpdateTool` | id, subject, description, status, addBlocks, addBlockedBy, owner, metadata |
|
||||
|
||||
## DELEGATION SYSTEM (delegate-task)
|
||||
### Delegation (1)
|
||||
|
||||
8 built-in categories with domain-optimized models:
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `task` | `createDelegateTask` | description, prompt, category, subagent_type, run_in_background, session_id, load_skills, command |
|
||||
|
||||
**8 Built-in Categories**: visual-engineering, ultrabrain, deep, artistry, quick, unspecified-low, unspecified-high, writing
|
||||
|
||||
### Agent Invocation (1)
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `call_omo_agent` | `createCallOmoAgent` | description, prompt, subagent_type, run_in_background, session_id |
|
||||
|
||||
### Background Tasks (2)
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `background_output` | `createBackgroundOutput` | task_id, block, timeout, full_session, include_thinking, message_limit |
|
||||
| `background_cancel` | `createBackgroundCancel` | taskId, all |
|
||||
|
||||
### LSP Refactoring (6) — Direct ToolDefinition
|
||||
|
||||
| Tool | Parameters |
|
||||
|------|------------|
|
||||
| `lsp_goto_definition` | filePath, line, character |
|
||||
| `lsp_find_references` | filePath, line, character, includeDeclaration |
|
||||
| `lsp_symbols` | filePath, scope (document/workspace), query, limit |
|
||||
| `lsp_diagnostics` | filePath, severity |
|
||||
| `lsp_prepare_rename` | filePath, line, character |
|
||||
| `lsp_rename` | filePath, line, character, newName |
|
||||
|
||||
### Code Search (4)
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `ast_grep_search` | `createAstGrepTools` | pattern, lang, paths, globs, context |
|
||||
| `ast_grep_replace` | `createAstGrepTools` | pattern, rewrite, lang, paths, globs, dryRun |
|
||||
| `grep` | `createGrepTools` | pattern, path, include (60s timeout, 10MB limit) |
|
||||
| `glob` | `createGlobTools` | pattern, path (60s timeout, 100 file limit) |
|
||||
|
||||
### Session History (4)
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `session_list` | `createSessionManagerTools` | (none) |
|
||||
| `session_read` | `createSessionManagerTools` | session_id, include_todos, limit |
|
||||
| `session_search` | `createSessionManagerTools` | query, session_id, case_sensitive, limit |
|
||||
| `session_info` | `createSessionManagerTools` | session_id |
|
||||
|
||||
### Skill/Command (3)
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `skill` | `createSkillTool` | name |
|
||||
| `skill_mcp` | `createSkillMcpTool` | mcp_name, tool_name/resource_name/prompt_name, arguments, grep |
|
||||
| `slashcommand` | `createSlashcommandTool` | command, user_message |
|
||||
|
||||
### System (2)
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `interactive_bash` | Direct | tmux_command |
|
||||
| `look_at` | `createLookAt` | file_path, image_data, goal |
|
||||
|
||||
### Editing (1) — Conditional
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
| `hashline_edit` | `createHashlineEditTool` | file, edits[] |
|
||||
|
||||
## DELEGATION CATEGORIES
|
||||
|
||||
| Category | Model | Domain |
|
||||
|----------|-------|--------|
|
||||
| `visual-engineering` | gemini-3-pro | UI/UX, design, styling |
|
||||
| `ultrabrain` | gpt-5.3-codex xhigh | Deep logic, architecture |
|
||||
| `deep` | gpt-5.3-codex medium | Autonomous problem-solving |
|
||||
| `artistry` | gemini-3-pro high | Creative, unconventional |
|
||||
| `quick` | claude-haiku-4-5 | Trivial tasks |
|
||||
| `unspecified-low` | claude-sonnet-4-5 | Moderate effort |
|
||||
| `unspecified-high` | claude-opus-4-6 max | High effort |
|
||||
| `writing` | kimi-k2p5 | Documentation, prose |
|
||||
| visual-engineering | gemini-3-pro | Frontend, UI/UX |
|
||||
| ultrabrain | gpt-5.3-codex xhigh | Hard logic |
|
||||
| deep | gpt-5.3-codex medium | Autonomous problem-solving |
|
||||
| artistry | gemini-3-pro high | Creative approaches |
|
||||
| quick | claude-haiku-4-5 | Trivial tasks |
|
||||
| unspecified-low | claude-sonnet-4-5 | Moderate effort |
|
||||
| unspecified-high | claude-opus-4-6 max | High effort |
|
||||
| writing | kimi-k2p5 | Documentation |
|
||||
|
||||
## HOW TO ADD
|
||||
## HOW TO ADD A TOOL
|
||||
|
||||
1. Create `src/tools/[name]/` with index.ts, tools.ts, types.ts, constants.ts
|
||||
2. Static tools → `builtinTools` export, Factory → separate export
|
||||
3. Register in `src/plugin/tool-registry.ts`
|
||||
|
||||
## NAMING
|
||||
|
||||
- **Tool names**: snake_case (`lsp_goto_definition`)
|
||||
- **Functions**: camelCase (`createDelegateTask`)
|
||||
- **Directories**: kebab-case (`delegate-task/`)
|
||||
1. Create `src/tools/{name}/index.ts` exporting factory
|
||||
2. Create `src/tools/{name}/types.ts` for parameter schemas
|
||||
3. Create `src/tools/{name}/tools.ts` for implementation
|
||||
4. Register in `src/plugin/tool-registry.ts`
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundCancelClient } from "../types"
|
||||
import type { BackgroundManager } from "../../../features/background-agent"
|
||||
import type { BackgroundCancelArgs } from "../types"
|
||||
import { BACKGROUND_CANCEL_DESCRIPTION } from "../constants"
|
||||
|
||||
export function createBackgroundCancel(manager: BackgroundManager, _client: BackgroundCancelClient): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_CANCEL_DESCRIPTION,
|
||||
args: {
|
||||
taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"),
|
||||
all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"),
|
||||
},
|
||||
async execute(args: BackgroundCancelArgs, toolContext) {
|
||||
try {
|
||||
const cancelAll = args.all === true
|
||||
|
||||
if (!cancelAll && !args.taskId) {
|
||||
return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.`
|
||||
}
|
||||
|
||||
if (cancelAll) {
|
||||
const tasks = manager.getAllDescendantTasks(toolContext.sessionID)
|
||||
const cancellableTasks = tasks.filter((t: any) => t.status === "running" || t.status === "pending")
|
||||
|
||||
if (cancellableTasks.length === 0) {
|
||||
return `No running or pending background tasks to cancel.`
|
||||
}
|
||||
|
||||
const cancelledInfo: Array<{
|
||||
id: string
|
||||
description: string
|
||||
status: string
|
||||
sessionID?: string
|
||||
}> = []
|
||||
|
||||
for (const task of cancellableTasks) {
|
||||
const originalStatus = task.status
|
||||
const cancelled = await manager.cancelTask(task.id, {
|
||||
source: "background_cancel",
|
||||
abortSession: originalStatus === "running",
|
||||
skipNotification: true,
|
||||
})
|
||||
if (!cancelled) continue
|
||||
cancelledInfo.push({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
status: originalStatus === "pending" ? "pending" : "running",
|
||||
sessionID: task.sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
const tableRows = cancelledInfo
|
||||
.map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`)
|
||||
.join("\n")
|
||||
|
||||
const resumableTasks = cancelledInfo.filter(t => t.sessionID)
|
||||
const resumeSection = resumableTasks.length > 0
|
||||
? `\n## Continue Instructions
|
||||
|
||||
To continue a cancelled task, use:
|
||||
\`\`\`
|
||||
task(session_id="<session_id>", prompt="Continue: <your follow-up>")
|
||||
\`\`\`
|
||||
|
||||
Continuable sessions:
|
||||
${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}`
|
||||
: ""
|
||||
|
||||
return `Cancelled ${cancelledInfo.length} background task(s):
|
||||
|
||||
| Task ID | Description | Status | Session ID |
|
||||
|---------|-------------|--------|------------|
|
||||
${tableRows}
|
||||
${resumeSection}`
|
||||
}
|
||||
|
||||
const task = manager.getTask(args.taskId!)
|
||||
if (!task) {
|
||||
return `[ERROR] Task not found: ${args.taskId}`
|
||||
}
|
||||
|
||||
if (task.status !== "running" && task.status !== "pending") {
|
||||
return `[ERROR] Cannot cancel task: current status is "${task.status}".
|
||||
Only running or pending tasks can be cancelled.`
|
||||
}
|
||||
|
||||
const cancelled = await manager.cancelTask(task.id, {
|
||||
source: "background_cancel",
|
||||
abortSession: task.status === "running",
|
||||
skipNotification: true,
|
||||
})
|
||||
if (!cancelled) {
|
||||
return `[ERROR] Failed to cancel task: ${task.id}`
|
||||
}
|
||||
|
||||
if (task.status === "pending") {
|
||||
return `Pending task cancelled successfully
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Status: ${task.status}`
|
||||
}
|
||||
|
||||
return `Task cancelled successfully
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Session ID: ${task.sessionID}
|
||||
Status: ${task.status}`
|
||||
} catch (error) {
|
||||
return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundOutputManager, BackgroundOutputClient } from "../types"
|
||||
import type { BackgroundOutputArgs } from "../types"
|
||||
import { BACKGROUND_OUTPUT_DESCRIPTION } from "../constants"
|
||||
import { formatTaskStatus, formatTaskResult, formatFullSession } from "./formatters"
|
||||
import { delay } from "./utils"
|
||||
import { storeToolMetadata } from "../../../features/tool-metadata-store"
|
||||
import type { BackgroundTask } from "../../../features/background-agent"
|
||||
import type { ToolContextWithMetadata } from "./utils"
|
||||
|
||||
import { getAgentDisplayName } from "../../../shared/agent-display-names"
|
||||
|
||||
const SISYPHUS_JUNIOR_AGENT = getAgentDisplayName("sisyphus-junior")
|
||||
|
||||
type ToolContextWithCallId = ToolContextWithMetadata & {
|
||||
callID?: string
|
||||
callId?: string
|
||||
call_id?: string
|
||||
}
|
||||
|
||||
function resolveToolCallID(ctx: ToolContextWithCallId): string | undefined {
|
||||
if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") {
|
||||
return ctx.callID
|
||||
}
|
||||
if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") {
|
||||
return ctx.callId
|
||||
}
|
||||
if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") {
|
||||
return ctx.call_id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function formatResolvedTitle(task: BackgroundTask): string {
|
||||
const label = task.agent === SISYPHUS_JUNIOR_AGENT && task.category
|
||||
? task.category
|
||||
: task.agent
|
||||
return `${label} - ${task.description}`
|
||||
}
|
||||
|
||||
export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_OUTPUT_DESCRIPTION,
|
||||
args: {
|
||||
task_id: tool.schema.string().describe("Task ID to get output from"),
|
||||
block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."),
|
||||
timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"),
|
||||
full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"),
|
||||
include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"),
|
||||
message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"),
|
||||
since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"),
|
||||
include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"),
|
||||
thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"),
|
||||
},
|
||||
async execute(args: BackgroundOutputArgs, toolContext) {
|
||||
try {
|
||||
const ctx = toolContext as ToolContextWithCallId
|
||||
const task = manager.getTask(args.task_id)
|
||||
if (!task) {
|
||||
return `Task not found: ${args.task_id}`
|
||||
}
|
||||
|
||||
const resolvedTitle = formatResolvedTitle(task)
|
||||
const meta = {
|
||||
title: resolvedTitle,
|
||||
metadata: {
|
||||
task_id: task.id,
|
||||
agent: task.agent,
|
||||
category: task.category,
|
||||
description: task.description,
|
||||
sessionId: task.sessionID ?? "pending",
|
||||
} as Record<string, unknown>,
|
||||
}
|
||||
await ctx.metadata?.(meta)
|
||||
const callID = resolveToolCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, meta)
|
||||
}
|
||||
|
||||
const isActive = task.status === "pending" || task.status === "running"
|
||||
const fullSession = args.full_session ?? isActive
|
||||
const includeThinking = isActive || (args.include_thinking ?? false)
|
||||
const includeToolResults = isActive || (args.include_tool_results ?? false)
|
||||
|
||||
if (fullSession) {
|
||||
return await formatFullSession(task, client, {
|
||||
includeThinking,
|
||||
messageLimit: args.message_limit,
|
||||
sinceMessageId: args.since_message_id,
|
||||
includeToolResults,
|
||||
thinkingMaxChars: args.thinking_max_chars,
|
||||
})
|
||||
}
|
||||
|
||||
const shouldBlock = args.block === true
|
||||
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
|
||||
|
||||
// Already completed: return result immediately (regardless of block flag)
|
||||
if (task.status === "completed") {
|
||||
return await formatTaskResult(task, client)
|
||||
}
|
||||
|
||||
// Error or cancelled: return status immediately
|
||||
if (task.status === "error" || task.status === "cancelled" || task.status === "interrupt") {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
// Non-blocking and still running: return status
|
||||
if (!shouldBlock) {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
// Blocking: poll until completion or timeout
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await delay(1000)
|
||||
|
||||
const currentTask = manager.getTask(args.task_id)
|
||||
if (!currentTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
|
||||
if (currentTask.status === "completed") {
|
||||
return await formatTaskResult(currentTask, client)
|
||||
}
|
||||
|
||||
if (currentTask.status === "error" || currentTask.status === "cancelled" || currentTask.status === "interrupt") {
|
||||
return formatTaskStatus(currentTask)
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout exceeded: return current status
|
||||
const finalTask = manager.getTask(args.task_id)
|
||||
if (!finalTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`
|
||||
} catch (error) {
|
||||
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../../features/background-agent"
|
||||
import type { BackgroundTaskArgs } from "../types"
|
||||
import { BACKGROUND_TASK_DESCRIPTION } from "../constants"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../../features/claude-code-session-state"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { storeToolMetadata } from "../../../features/tool-metadata-store"
|
||||
import { getMessageDir, delay, type ToolContextWithMetadata } from "./utils"
|
||||
|
||||
export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_TASK_DESCRIPTION,
|
||||
args: {
|
||||
description: tool.schema.string().describe("Short task description (shown in status)"),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
agent: tool.schema.string().describe("Agent type to use (any registered agent)"),
|
||||
},
|
||||
async execute(args: BackgroundTaskArgs, toolContext) {
|
||||
const ctx = toolContext as ToolContextWithMetadata
|
||||
|
||||
if (!args.agent || args.agent.trim() === "") {
|
||||
return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
|
||||
}
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(ctx.sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
|
||||
const sessionAgent = getSessionAgent(ctx.sessionID)
|
||||
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||
|
||||
log("[background_task] parentAgent resolution", {
|
||||
sessionID: ctx.sessionID,
|
||||
ctxAgent: ctx.agent,
|
||||
sessionAgent,
|
||||
firstMessageAgent,
|
||||
prevMessageAgent: prevMessage?.agent,
|
||||
resolvedParentAgent: parentAgent,
|
||||
})
|
||||
|
||||
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? {
|
||||
providerID: prevMessage.model.providerID,
|
||||
modelID: prevMessage.model.modelID,
|
||||
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {})
|
||||
}
|
||||
: undefined
|
||||
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
agent: args.agent.trim(),
|
||||
parentSessionID: ctx.sessionID,
|
||||
parentMessageID: ctx.messageID,
|
||||
parentModel,
|
||||
parentAgent,
|
||||
})
|
||||
|
||||
const WAIT_FOR_SESSION_INTERVAL_MS = 50
|
||||
const WAIT_FOR_SESSION_TIMEOUT_MS = 30000
|
||||
const waitStart = Date.now()
|
||||
let sessionId = task.sessionID
|
||||
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
|
||||
if (ctx.abort?.aborted) {
|
||||
await manager.cancelTask(task.id)
|
||||
return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
await delay(WAIT_FOR_SESSION_INTERVAL_MS)
|
||||
const updated = manager.getTask(task.id)
|
||||
if (!updated || updated.status === "error") {
|
||||
return `Task ${!updated ? "was deleted" : `entered error state`}.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
sessionId = updated?.sessionID
|
||||
}
|
||||
|
||||
const bgMeta = {
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionId ?? "pending" } as Record<string, unknown>,
|
||||
}
|
||||
await ctx.metadata?.(bgMeta)
|
||||
const callID = (ctx as any).callID as string | undefined
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, bgMeta)
|
||||
}
|
||||
|
||||
return `Background task launched successfully.
|
||||
|
||||
Task ID: ${task.id}
|
||||
Session ID: ${sessionId ?? "pending"}
|
||||
Description: ${task.description}
|
||||
Agent: ${task.agent}
|
||||
Status: ${task.status}
|
||||
|
||||
The system will notify you when the task completes.
|
||||
Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
||||
- block=false (default): Check status immediately - returns full status info
|
||||
- block=true: Wait for completion (rarely needed since system notifies)`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `[ERROR] Failed to launch background task: ${message}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import type { BackgroundTask } from "../../../features/background-agent"
|
||||
import type { BackgroundOutputClient } from "../types"
|
||||
import { formatDuration, truncateText, formatMessageTime } from "./utils"
|
||||
import { extractMessages, getErrorMessage, type BackgroundOutputMessagesResult, type FullSessionMessage, extractToolResultText } from "./message-processing"
|
||||
import { consumeNewMessages } from "../../../shared/session-cursor"
|
||||
|
||||
const MAX_MESSAGE_LIMIT = 100
|
||||
const THINKING_MAX_CHARS = 2000
|
||||
|
||||
export function formatTaskStatus(task: BackgroundTask): string {
|
||||
let duration: string
|
||||
if (task.status === "pending" && task.queuedAt) {
|
||||
duration = formatDuration(task.queuedAt, undefined)
|
||||
} else if (task.startedAt) {
|
||||
duration = formatDuration(task.startedAt, task.completedAt)
|
||||
} else {
|
||||
duration = "N/A"
|
||||
}
|
||||
const promptPreview = truncateText(task.prompt, 500)
|
||||
|
||||
let progressSection = ""
|
||||
if (task.progress?.lastTool) {
|
||||
progressSection = `\n| Last tool | ${task.progress.lastTool} |`
|
||||
}
|
||||
|
||||
let lastMessageSection = ""
|
||||
if (task.progress?.lastMessage) {
|
||||
const truncated = truncateText(task.progress.lastMessage, 500)
|
||||
const messageTime = task.progress.lastMessageAt
|
||||
? task.progress.lastMessageAt.toISOString()
|
||||
: "N/A"
|
||||
lastMessageSection = `
|
||||
|
||||
## Last Message (${messageTime})
|
||||
|
||||
\`\`\`
|
||||
${truncated}
|
||||
\`\`\``
|
||||
}
|
||||
|
||||
let statusNote = ""
|
||||
if (task.status === "pending") {
|
||||
statusNote = `
|
||||
|
||||
> **Queued**: Task is waiting for a concurrency slot to become available.`
|
||||
} else if (task.status === "running") {
|
||||
statusNote = `
|
||||
|
||||
> **Note**: No need to wait explicitly - the system will notify you when this task completes.`
|
||||
} else if (task.status === "error") {
|
||||
statusNote = `
|
||||
|
||||
> **Failed**: The task encountered an error. Check the last message for details.`
|
||||
} else if (task.status === "interrupt") {
|
||||
statusNote = `
|
||||
|
||||
> **Interrupted**: The task was interrupted by a prompt error. The session may contain partial results.`
|
||||
}
|
||||
|
||||
const durationLabel = task.status === "pending" ? "Queued for" : "Duration"
|
||||
|
||||
return `# Task Status
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Task ID | \`${task.id}\` |
|
||||
| Description | ${task.description} |
|
||||
| Agent | ${task.agent} |
|
||||
| Status | **${task.status}** |
|
||||
| ${durationLabel} | ${duration} |
|
||||
| Session ID | \`${task.sessionID}\` |${progressSection}
|
||||
${statusNote}
|
||||
## Original Prompt
|
||||
|
||||
\`\`\`
|
||||
${promptPreview}
|
||||
\`\`\`${lastMessageSection}`
|
||||
}
|
||||
|
||||
export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise<string> {
|
||||
if (!task.sessionID) {
|
||||
return `Error: Task has no sessionID`
|
||||
}
|
||||
|
||||
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
const errorMessage = getErrorMessage(messagesResult)
|
||||
if (errorMessage) {
|
||||
return `Error fetching messages: ${errorMessage}`
|
||||
}
|
||||
|
||||
const messages = extractMessages(messagesResult)
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No messages found)`
|
||||
}
|
||||
|
||||
// Include both assistant messages AND tool messages
|
||||
// Tool results (grep, glob, bash output) come from role "tool"
|
||||
const relevantMessages = messages.filter(
|
||||
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (relevantMessages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No assistant or tool response found)`
|
||||
}
|
||||
|
||||
// Sort by time ascending (oldest first) to process messages in order
|
||||
const sortedMessages = [...relevantMessages].sort((a, b) => {
|
||||
const timeA = String((a as { info?: { time?: string } }).info?.time ?? "")
|
||||
const timeB = String((b as { info?: { time?: string } }).info?.time ?? "")
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
const newMessages = consumeNewMessages(task.sessionID, sortedMessages)
|
||||
if (newMessages.length === 0) {
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No new output since last check)`
|
||||
}
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of newMessages) {
|
||||
for (const part of message.parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
extractedContent.push(part.text)
|
||||
} else if (part.type === "tool_result") {
|
||||
// Tool results contain the actual output from tool calls
|
||||
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
|
||||
if (typeof toolResult.content === "string" && toolResult.content) {
|
||||
extractedContent.push(toolResult.content)
|
||||
} else if (Array.isArray(toolResult.content)) {
|
||||
// Handle array of content blocks
|
||||
for (const block of toolResult.content) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((block.type === "text" || block.type === "reasoning") && block.text) {
|
||||
extractedContent.push(block.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textContent = extractedContent
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n")
|
||||
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
${textContent || "(No text output)"}`
|
||||
}
|
||||
|
||||
export async function formatFullSession(
|
||||
task: BackgroundTask,
|
||||
client: BackgroundOutputClient,
|
||||
options: {
|
||||
includeThinking: boolean
|
||||
messageLimit?: number
|
||||
sinceMessageId?: string
|
||||
includeToolResults: boolean
|
||||
thinkingMaxChars?: number
|
||||
}
|
||||
): Promise<string> {
|
||||
if (!task.sessionID) {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
const errorMessage = getErrorMessage(messagesResult)
|
||||
if (errorMessage) {
|
||||
return `Error fetching messages: ${errorMessage}`
|
||||
}
|
||||
|
||||
const rawMessages = extractMessages(messagesResult)
|
||||
if (!Array.isArray(rawMessages)) {
|
||||
return "Error fetching messages: invalid response"
|
||||
}
|
||||
|
||||
const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => {
|
||||
const timeA = String(a.info?.time ?? "")
|
||||
const timeB = String(b.info?.time ?? "")
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
let filteredMessages = sortedMessages
|
||||
|
||||
if (options.sinceMessageId) {
|
||||
const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId)
|
||||
if (index === -1) {
|
||||
return `Error: since_message_id not found: ${options.sinceMessageId}`
|
||||
}
|
||||
filteredMessages = filteredMessages.slice(index + 1)
|
||||
}
|
||||
|
||||
const includeThinking = options.includeThinking
|
||||
const includeToolResults = options.includeToolResults
|
||||
const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS
|
||||
|
||||
const normalizedMessages: FullSessionMessage[] = []
|
||||
for (const message of filteredMessages) {
|
||||
const parts = (message.parts ?? []).filter((part) => {
|
||||
if (part.type === "thinking" || part.type === "reasoning") {
|
||||
return includeThinking
|
||||
}
|
||||
if (part.type === "tool_result") {
|
||||
return includeToolResults
|
||||
}
|
||||
return part.type === "text"
|
||||
})
|
||||
|
||||
if (parts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedMessages.push({ ...message, parts })
|
||||
}
|
||||
|
||||
const limit = typeof options.messageLimit === "number"
|
||||
? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT)
|
||||
: undefined
|
||||
const hasMore = limit !== undefined && normalizedMessages.length > limit
|
||||
const visibleMessages = limit !== undefined
|
||||
? normalizedMessages.slice(0, limit)
|
||||
: normalizedMessages
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push("# Full Session Output")
|
||||
lines.push("")
|
||||
lines.push(`Task ID: ${task.id}`)
|
||||
lines.push(`Description: ${task.description}`)
|
||||
lines.push(`Status: ${task.status}`)
|
||||
lines.push(`Session ID: ${task.sessionID}`)
|
||||
lines.push(`Total messages: ${normalizedMessages.length}`)
|
||||
lines.push(`Returned: ${visibleMessages.length}`)
|
||||
lines.push(`Has more: ${hasMore ? "true" : "false"}`)
|
||||
lines.push("")
|
||||
lines.push("## Messages")
|
||||
|
||||
if (visibleMessages.length === 0) {
|
||||
lines.push("")
|
||||
lines.push("(No messages found)")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
for (const message of visibleMessages) {
|
||||
const role = message.info?.role ?? "unknown"
|
||||
const agent = message.info?.agent ? ` (${message.info.agent})` : ""
|
||||
const time = formatMessageTime(message.info?.time)
|
||||
const idLabel = message.id ? ` id=${message.id}` : ""
|
||||
lines.push("")
|
||||
lines.push(`[${role}${agent}] ${time}${idLabel}`)
|
||||
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "text" && part.text) {
|
||||
lines.push(part.text.trim())
|
||||
} else if (part.type === "thinking" && part.thinking) {
|
||||
lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`)
|
||||
} else if (part.type === "reasoning" && part.text) {
|
||||
lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`)
|
||||
} else if (part.type === "tool_result") {
|
||||
const toolTexts = extractToolResultText(part)
|
||||
for (const toolText of toolTexts) {
|
||||
lines.push(`[tool result] ${toolText}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export type BackgroundOutputMessage = {
|
||||
info?: { role?: string; time?: string | { created?: number }; agent?: string }
|
||||
parts?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
content?: string | Array<{ type: string; text?: string }>
|
||||
name?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type BackgroundOutputMessagesResult =
|
||||
| { data?: BackgroundOutputMessage[]; error?: unknown }
|
||||
| BackgroundOutputMessage[]
|
||||
|
||||
export type FullSessionMessagePart = {
|
||||
type?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
content?: string | Array<{ type?: string; text?: string }>
|
||||
output?: string
|
||||
}
|
||||
|
||||
export type FullSessionMessage = {
|
||||
id?: string
|
||||
info?: { role?: string; time?: string; agent?: string }
|
||||
parts?: FullSessionMessagePart[]
|
||||
}
|
||||
|
||||
export function getErrorMessage(value: BackgroundOutputMessagesResult): string | null {
|
||||
if (Array.isArray(value)) return null
|
||||
if (value.error === undefined || value.error === null) return null
|
||||
if (typeof value.error === "string" && value.error.length > 0) return value.error
|
||||
return String(value.error)
|
||||
}
|
||||
|
||||
export function isSessionMessage(value: unknown): value is {
|
||||
info?: { role?: string; time?: string }
|
||||
parts?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
content?: string | Array<{ type: string; text?: string }>
|
||||
name?: string
|
||||
}>
|
||||
} {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(isSessionMessage)
|
||||
}
|
||||
if (Array.isArray(value.data)) {
|
||||
return value.data.filter(isSessionMessage)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function extractToolResultText(part: FullSessionMessagePart): string[] {
|
||||
if (typeof part.content === "string" && part.content.length > 0) {
|
||||
return [part.content]
|
||||
}
|
||||
|
||||
if (Array.isArray(part.content)) {
|
||||
const blocks = part.content
|
||||
.filter((block) => (block.type === "text" || block.type === "reasoning") && block.text)
|
||||
.map((block) => block.text as string)
|
||||
if (blocks.length > 0) return blocks
|
||||
}
|
||||
|
||||
if (part.output && part.output.length > 0) {
|
||||
return [part.output]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getMessageDir } from "../../../shared"
|
||||
|
||||
export { getMessageDir }
|
||||
|
||||
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`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
} else {
|
||||
return `${seconds}s`
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength) + "..."
|
||||
}
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function formatMessageTime(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toISOString()
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if ("created" in value) {
|
||||
const created = (value as { created?: number }).created
|
||||
if (typeof created === "number") {
|
||||
return new Date(created).toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Unknown time"
|
||||
}
|
||||
|
||||
export type ToolContextWithMetadata = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ALLOWED_AGENTS } from "./constants"
|
||||
import type { AllowedAgentType } from "./types"
|
||||
|
||||
export function normalizeAgentType(input: string): AllowedAgentType | null {
|
||||
const lowered = input.toLowerCase()
|
||||
for (const allowed of ALLOWED_AGENTS) {
|
||||
if (allowed.toLowerCase() === lowered) {
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared"
|
||||
import { extractNewSessionOutput, type SessionMessage } from "./session-message-output-extractor"
|
||||
import { waitForSessionCompletion } from "./session-completion-poller"
|
||||
import { resolveOrCreateSessionId } from "./subagent-session-creator"
|
||||
import { promptSubagentSession } from "./subagent-session-prompter"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
||||
|
||||
function buildTaskMetadata(sessionID: string): string {
|
||||
return ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
function getMessagesArray(result: unknown): SessionMessage[] {
|
||||
if (Array.isArray(result)) return result as SessionMessage[]
|
||||
if (typeof result !== "object" || result === null) return []
|
||||
if (!("data" in result)) return []
|
||||
const data = (result as { data?: unknown }).data
|
||||
return Array.isArray(data) ? (data as SessionMessage[]) : []
|
||||
}
|
||||
|
||||
export async function executeSyncAgent(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: ToolContextWithMetadata,
|
||||
ctx: PluginInput,
|
||||
): Promise<string> {
|
||||
const sessionResult = await resolveOrCreateSessionId(ctx, args, toolContext)
|
||||
if (!sessionResult.ok) {
|
||||
return sessionResult.error
|
||||
}
|
||||
const sessionID = sessionResult.sessionID
|
||||
|
||||
await toolContext.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionID },
|
||||
})
|
||||
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log("[call_omo_agent] Prompt preview", { preview: args.prompt.substring(0, 100) })
|
||||
|
||||
const promptResult = await promptSubagentSession(ctx, {
|
||||
sessionID,
|
||||
agent: args.subagent_type,
|
||||
prompt: args.prompt,
|
||||
})
|
||||
if (!promptResult.ok) {
|
||||
const errorMessage = promptResult.error
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
return `Error: Failed to send prompt: ${errorMessage}\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
|
||||
log("[call_omo_agent] Prompt sent, polling for completion...")
|
||||
const completion = await waitForSessionCompletion(ctx, {
|
||||
sessionID,
|
||||
abortSignal: toolContext.abort,
|
||||
maxPollTimeMs: 5 * 60 * 1000,
|
||||
pollIntervalMs: 500,
|
||||
stabilityRequired: 3,
|
||||
})
|
||||
if (!completion.ok) {
|
||||
if (completion.reason === "aborted") {
|
||||
return `Task aborted.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
return `Error: Agent task timed out after 5 minutes.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
|
||||
const messagesResult = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
if (messagesResult.error) {
|
||||
log("[call_omo_agent] Messages error", { error: messagesResult.error })
|
||||
return `Error: Failed to get messages: ${messagesResult.error}`
|
||||
}
|
||||
|
||||
const messages = getMessagesArray(messagesResult)
|
||||
log("[call_omo_agent] Got messages", { count: messages.length })
|
||||
|
||||
const extracted = extractNewSessionOutput(sessionID, messages)
|
||||
if (!extracted.hasNewOutput) {
|
||||
return `No new output since last check.\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
|
||||
log("[call_omo_agent] Got response", { length: extracted.output.length })
|
||||
return `${extracted.output}\n\n${buildTaskMetadata(sessionID)}`
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
|
||||
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
|
||||
export async function resolveSkillContent(
|
||||
skills: string[],
|
||||
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string> }
|
||||
): Promise<{ content: string | undefined; error: string | null }> {
|
||||
if (skills.length === 0) {
|
||||
return { content: undefined, error: null }
|
||||
}
|
||||
|
||||
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
|
||||
if (notFound.length > 0) {
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
|
||||
const available = allSkills.map(s => s.name).join(", ")
|
||||
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
|
||||
}
|
||||
|
||||
return { content: Array.from(resolved.values()).join("\n\n"), error: null }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HASH_DICT } from "./constants"
|
||||
|
||||
export function computeLineHash(lineNumber: number, content: string): string {
|
||||
export function computeLineHash(_lineNumber: number, content: string): string {
|
||||
const stripped = content.replace(/\s+/g, "")
|
||||
const hash = Bun.hash.xxHash32(stripped)
|
||||
const index = hash % 256
|
||||
|
||||
Reference in New Issue
Block a user