Compare commits

..

113 Commits

Author SHA1 Message Date
ismeth
a9400b1fae fix(agent-usage-reminder): skip reminders for council members
Prevents split-brain in solo mode where the system prompt says 'don't delegate' but injected tool output says 'you should delegate'.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:28:46 +09:00
ismeth
91b16cc634 fix(athena): add solo/delegation addendums, recommend delegation mode
Both modes now inject explicit instructions: solo warns against subagent usage, delegation provides concrete call_omo_agent examples. Delegation is now the recommended default to reduce context window pressure on council members.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:28:46 +09:00
ismeth
61eb0ee04a fix(background-agent): add post-compaction continuation + fix stale/idle race
Extract sendPostCompactionContinuation to dedicated file — council members now resume after compaction instead of silently failing. Refresh lastUpdate before async validation in both idle handler and polling path to prevent stale timeout from racing with completion detection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:28:46 +09:00
ismeth
e503697d92 fix(athena): council review fixes — delegation bug, dead code, test coverage
- Add background_output to council-member allowlist (fixes delegation deadlock)
- Replace empty catch with error logging in prepare-council-prompt
- Remove unnecessary type assertion in agent.ts
- Remove dead hasAgentToolRestrictions function
- Fix incorrect test assertions (undefined vs false semantics)
- Add barrel export for athena module
- Add guard function test coverage (5 tests)
- Add parity test for triple-sync restrictions (9 tests)
2026-02-24 22:28:46 +09:00
YeonGyu-Kim
a9bacedb3b format(tools/background-task): fix indentation in blocking loop
🤖 Generated with assistance of oh-my-opencode
2026-02-24 22:28:46 +09:00
ismeth
9365fc23c5 fix(athena): harden council members — compaction recovery, block TodoWrite, analysis mode
- Add session.compacted handler in BackgroundManager to prevent premature
  task completion after compaction (defer first post-compaction idle)
- Explicitly block TodoWrite/TodoRead for council members in all sync
  points (AgentConfig permission + session tools + prompt instructions)
- Add council member prefix check to todo-continuation-enforcer skip list
  to prevent infinite continuation loops on completed council members
- Add optional analysis mode (solo/delegation) question to Athena setup:
  solo = thorough but heavier, delegation = fast via explore/librarian
- Allow call_omo_agent in council member allow-list for delegation mode
- Update COUNCIL_MEMBER_PROMPT with TodoWrite prohibition and delegation
  addendum for when delegation mode is selected
- Update prepare_council_prompt tool with mode parameter
2026-02-24 22:28:01 +09:00
ismeth
92e9cbea5c fix(athena): write council prompt to .sisyphus/tmp/, switch to allow-list permissions
Council members now use an allow-list (read, grep, glob, lsp_*, ast_grep_search)
instead of a deny-list. Prompt file moved from /tmp/ to .sisyphus/tmp/ so no
external_directory permission is needed. COUNCIL_MEMBER_PROMPT is included in
the temp file for self-contained council member instructions.
2026-02-24 22:28:01 +09:00
ismeth
1e0229226e fix(athena): use explicit node:crypto import for randomUUID 2026-02-24 22:28:01 +09:00
ismeth
3fecc7baae feat(athena): add prepare_council_prompt tool for faster council launches
Athena saves the analysis prompt to a temp file once, then launches each
council member with a short "Read <path> for your instructions" prompt.
This eliminates repeated prompt text across N task calls while preserving
individual clickable task panes in the TUI.
2026-02-24 22:28:01 +09:00
ismeth
f9bb441644 fix: sync council-member tool restrictions across all layers, optimize athena guards
- Add switch_agent/background_wait to agent-tool-restrictions.ts (boolean format)
- Add dynamic council member name matching via COUNCIL_MEMBER_KEY_PREFIX
- Move athena question permission from hardcoded to tool-config-handler (CLI-mode aware)
- Rename appendMissingCouncilPrompt -> applyMissingCouncilGuard
- Optimize tool-execute-before: check hasPendingCouncilMembers before resolving session agent
- Add fallback_models to council-member/athena in schema.json
- Remove unused createAthenaAgent export from agents/index.ts
- Add cross-reference comments for restriction sync points
2026-02-24 22:28:01 +09:00
ismeth
5da9337c7e fix: deny switch_agent and background_wait for council-member agent 2026-02-24 22:27:13 +09:00
ismeth
312eedfd8d fix(tests): update snapshots and positional arg indices for athena/council-member params
- Regenerate model-fallback snapshots to include athena agent config
- Fix createBuiltinAgents positional arg index for disableOmoEnv
  (shifted from index 12 to 13 by new councilConfig param)
- Fix utils.test.ts, config-handler.test.ts arg positions
2026-02-24 22:26:47 +09:00
ismeth
45a850afc0 fix: enforce directory param in skill resolution, replace legacy k2p5 model ID
- Make directory required in SkillLoadOptions, getAllSkills, and async
  skill template resolvers to prevent unsafe process.cwd() fallback
- Remove dead skill export and process.cwd() fallback in skill tool
- Replace kimi-for-coding/k2p5 with kimi-for-coding/kimi-k2.5 in
  council-members-generator
2026-02-24 22:26:47 +09:00
ismeth
a9b2da802f refactor(event): remove runHookSafely wrapper, align with upstream dispatch pattern 2026-02-24 22:26:47 +09:00
ismeth
1d853f4250 fix: abort signal in polling loops, remove legacy k2p5, pass ctx.directory to skill tool
- Check context.abort in background-wait and background-output polling loops
- Remove legacy kimi-for-coding/k2p5 from athena fallback chain
- Pass ctx.directory from tool-registry to createSkillTool instead of process.cwd()
2026-02-24 22:26:11 +09:00
ismeth
f6cdba07ec fix(athena): resolve 4 compatibility and correctness issues
- Use case-insensitive casing in duplicate name test to verify actual logic
- Align permission type with SDK AgentConfig pattern (as AgentConfig["permission"])
- Move duplicate-name validation from schema to runtime for graceful fallback
- Place skipped members details before 'end your turn' in council guard prompt
2026-02-24 22:26:11 +09:00
ismeth
2eb8f5741a rename: fallback-handoff.ts → terminal-detection.ts
The file no longer contains any fallback/handoff logic after the Athena
NLP removal — only generic terminal-event helpers (isTerminalFinishValue,
isTerminalStepFinishPart). Name now matches content.
2026-02-24 22:26:11 +09:00
ismeth
77034fec7e refactor(agent-switch): remove Athena-specific NLP fallback from hook
The fallback scanned Athena's message text for natural-language handoff
phrases ("switching to Atlas", etc.) and synthetically created a pending
switch when the switch_agent tool wasn't called. In practice this path
never fired in real sessions — Athena always correctly called the tool.

Removes ~135 lines of Athena-coupled code, keeping the generic
switch_agent → apply path fully intact.
2026-02-24 22:26:11 +09:00
ismeth
11a4d457bf fix(athena): address 9 council-audit findings — dead code, bugs, and hardening
Fixes from multi-model council audit (7 members, 19 findings, 9 selected):

- Use parseModelString() for cross-provider Anthropic thinking config (#3)
- Update stale AGENTS.md athena directory listing (#4)
- Replace prompt in appendMissingCouncilPrompt instead of appending (#5)
- Extract duplicated session cleanup logic in agent-switch hook (#6)
- Surface skipped council members when >=2 valid members exist (#9)
- Expand fallback handoff regex with negation guards (#11)
- Remove dead council-member agent from agentSources and tests (#12)
- Make runtime council member duplicate check case-insensitive (#14)
- Fix false-positive schema tests by adding required name field (#18)
2026-02-24 22:26:11 +09:00
ismeth
f0d0658eae fix(athena): provider-aware config + better council error messages
Use parsed.providerID/modelID in council member description instead of
raw model string (eliminates dead variable). Track skipped members with
reasons and surface them in the missing-council guard prompt so users
see why their council failed to register.
2026-02-24 22:26:11 +09:00
ismeth
9d0bafbe10 fix(athena): conditional prompt references for missing-council mode 2026-02-24 22:26:11 +09:00
ismeth
0cad3bf2ca chore(athena): remove dead exports and unused barrel file 2026-02-24 22:26:11 +09:00
ismeth
734ef10fbb fix(athena): add schema validation for unique names and sanitization 2026-02-24 22:26:11 +09:00
ismeth
21202ee877 refactor(athena): consolidate tool restriction deny lists to direct boolean records 2026-02-24 22:26:11 +09:00
ismeth
f9fdd08481 refactor(athena): use z.infer types from Zod schema, delete manual interfaces 2026-02-24 22:26:11 +09:00
ismeth
c4deb6bc5d refactor(athena): extract applyModelThinkingConfig shared utility 2026-02-24 22:26:11 +09:00
ismeth
01331af10c refactor(athena): consolidate parseModelString to single source of truth 2026-02-24 22:26:11 +09:00
ismeth
9748688983 fix(athena): replace unsafe type cast with type-safe construction 2026-02-24 22:26:11 +09:00
ismeth
0d30d717e1 fix(agent-switch): correct off-by-one in fallback message cap 2026-02-24 22:26:11 +09:00
ismeth
e44354e98e feat(athena): harden council config — mandatory name, guard prompt, no-crash duplicates
- Add council config guard prompt: when Athena has no valid council members,
  inject a STOP instruction telling the user how to configure council members
  instead of failing messily with generic agents
- Make council member 'name' field mandatory (was optional with auto-naming)
- Remove humanizeModelId and UPPERCASE_TOKENS — no more fragile auto-naming
- Replace throw on duplicate names with log + skip (graceful degradation)
- Update schema, types, tests (87 pass), and documentation
2026-02-24 22:26:11 +09:00
ismeth
6c98677d22 fix(skills): pass directory through skill resolution chain for Desktop mode
Skill discovery in the task tool failed to find project-level skills in
OpenCode Desktop because:

1. resolveSkillContent() never passed directory to the skill resolution
   functions, causing them to fall back to process.cwd() which differs
   from the project directory in Desktop mode.

2. getAllSkills() cache key only included browserProvider, not directory.
   A first call with the wrong directory would cache stale results that
   all subsequent calls (even with correct directory) would return.

3. The error message used discoverSkills() (discovered only) instead of
   getAllSkills() (discovered + builtins), hiding builtin skills from
   the Available list.

Changes:
- skill-resolver.ts: accept and pass directory; use getAllSkills for error msg
- tools.ts: pass options.directory to resolveSkillContent
- skill-discovery.ts: include directory in cache key; rename cache variable
- skill/types.ts + tools.ts: add directory to SkillLoadOptions for consistency
2026-02-24 22:26:01 +09:00
ismeth
0d88fe61f0 fix(athena): update stale test snapshots and keyword-detector log assertions 2026-02-24 22:25:32 +09:00
ismeth
2b73b3f306 docs(athena): remove stale file references and fix tool restriction table
Remove non-existent council-orchestrator.ts and council-prompt.ts from AGENTS.md structure listing. Fix Athena denied tools (add call_omo_agent) and Council-Member denied tools (remove non-existent athena_council). Add council-member-agents.ts to builtin-agents listing. Fix stale athena_council reference in docs/features.md.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:32 +09:00
ismeth
beddc4260e fix(athena): add non-interactive fallback and improve synthesis workflow
Add fallback for CLI run mode when Question tool is denied: auto-select all council members and auto-choose action by question type. Improve synthesis with numbered findings, question type classification (ACTIONABLE/INFORMATIONAL/CONVERSATIONAL), and multi-select finding selection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:26 +09:00
ismeth
c1bf455b63 fix(athena): harden council registration with duplicate detection and count validation
Three registration improvements: gate council member registration on Athena enablement, throw on duplicate council member keys instead of silent overwrite, and disable council mode when valid members drop below 2 after model parsing.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:26 +09:00
ismeth
7b6d3206ce refactor(schema): replace deprecated .merge() with .extend() and add council-member override
Replace deprecated Zod .merge(z.object({...})) with .extend({...}) for AthenaOverrideConfigSchema. Add council-member to AgentOverridesSchema to match OverridableAgentNameSchema.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:26 +09:00
ismeth
d30d80abbd fix(agent-switch): clear fallback markers on session.error
processedFallbackMessages was only cleaned up on session.deleted, not session.error. This could leak memory for errored sessions. Mirrors the existing session.deleted cleanup pattern.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:26 +09:00
ismeth
74e519e545 fix(athena): add call_omo_agent to ATHENA_RESTRICTIONS for consistent tool denial
ATHENA_RESTRICTIONS only denied write and edit, missing call_omo_agent that the agent factory already denies. This caused 6 callers of getAgentToolRestrictions() to get incomplete restrictions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:26 +09:00
ismeth
8db2648339 feat(athena): add temperature support to council member schema
Allow per-member temperature overrides in council config. Adds temperature field to CouncilMemberSchema (0-2 range), CouncilMemberConfig type, and auto-generated JSON schema.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:26 +09:00
ismeth
4bc4b36e75 fix(athena): update council member guards for new agent key format
The hasPendingCouncilMembers guard now matches the 'Council: ' prefix from COUNCIL_MEMBER_KEY_PREFIX instead of the old task.agent === 'council-member' check.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:26 +09:00
ismeth
8f0b5d2e1a fix(athena): grant task and question tool permissions
Add Athena to the task tool allow list and grant explicit question tool permission so it can launch council members and present multi-select prompts.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:25 +09:00
ismeth
0ab22daffb feat(athena): rewrite prompts to use task tool for council execution
Athena's system prompt now instructs it to launch council members via task(subagent_type=..., run_in_background=true) and collect results with background_wait. Council member prompt enhanced with structured analysis instructions. Deny call_omo_agent for Athena to prevent tool confusion.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:25 +09:00
ismeth
1413c24886 feat(athena): register council members as task-callable subagents
Each council member from config is now registered as a named agent (e.g. 'Council: Claude Opus 4.6') via registerCouncilMemberAgents(). Adds humanizeModelId() to derive friendly display names from model IDs. Athena's prompt gets the member list appended so it can call task(subagent_type=...) for each.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:25:25 +09:00
ismeth
9887d0a93d refactor(athena): remove athena_council from plugin wiring
Drop the barrel export, tool-registry registration, and agent-tool-restriction entry for the deleted athena_council tool.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:54 +09:00
ismeth
1349948957 refactor(athena): delete athena_council tool directory
Remove the entire custom tool implementation (constants, launcher, session-waiter, tool-helpers, tools, types, and all tests). Council members are now launched via the standard task tool.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:54 +09:00
ismeth
70f074f579 refactor(athena): remove council-orchestrator and council-prompt modules
Delete the orchestrator that launched council members via the custom athena_council tool. This logic is now replaced by standard task() calls from Athena's prompt.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:54 +09:00
ismeth
f5b809ccea refactor(athena): remove dead council types and stale barrel exports
Remove CouncilLaunchFailure, CouncilLaunchedMember, CouncilLaunchResult types and barrel exports for deleted council-orchestrator and council-prompt modules.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:54 +09:00
ismeth
f248a09d53 fix(athena): use background_wait for council progress instead of polling
Athena now uses background_wait (race-style) to collect council results with incremental progress instead of sequential background_output calls or rapid polling. Updated both the system prompt and tool description to guide Athena to the correct waiting pattern.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:54 +09:00
ismeth
b7a3b65106 feat(athena): add background_wait tool for race-style task collection
New tool that takes multiple task IDs and blocks until ANY one completes (Promise.race pattern). Returns the completed task's result plus a progress summary with remaining IDs. Enables Athena to show incremental council progress without polling.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:54 +09:00
ismeth
3d5c96e651 fix(background-output): prioritize block=true over fullSession auto-detection
The fullSession path auto-activated for running tasks and returned immediately, completely bypassing the block=true waiting loop. This caused background_output(block=true) to never actually block, leading to rapid polling spam when agents tried to wait for task completion.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:54 +09:00
ismeth
f29480be90 docs(athena): add Athena and Council-Member to AGENTS.md
- Add both agents to inventory table with model, mode, and fallback info
- Add tool restrictions for Athena (write, edit) and Council-Member
- Add athena/ directory structure to the STRUCTURE section
- Update agent count from 11 to 13

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
f04b73fae3 refactor(athena): remove type assertions and improve agent factories
- Replace 'as AgentConfig' casts with proper typing in agent.ts and council-member-agent.ts
- Extract permission into typed variable following Sisyphus pattern
- Add GPT/non-GPT model branching to council-member-agent
- Use parseModelString for schema validation instead of inline logic
- Add strict() to council and athena config schemas
- Fix athena restriction list (remove redundant athena_council deny)
- Add orchestrator logging for council execution
- Update system prompt to notification-based workflow

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
c8af90715a refactor(athena): extract tool helpers and improve type safety
- Extract helper functions from tools.ts into dedicated tool-helpers.ts
- Replace getToolContextProperty workaround with typed AthenaCouncilToolContext
- Remove dead code path in formatCouncilLaunchFailure
- Add logging for council member launch and session resolution
- Update tool description to reflect notification-based workflow

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
ef74577ccb fix(athena): reduce keyword-detector log noise for Athena sessions
Only log keyword skipping when there are actual keywords to skip, not on every Athena message.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
5dfe0a34fc fix(athena): enable retry and bound growth for agent-switch fallback markers
Delete marker from processedFallbackMessages on failure so message can be retried. Add MAX_PROCESSED_FALLBACK_MARKERS=500 with eviction to prevent unbounded Set growth.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
e8042fa445 fix(athena): harden council tool error handling and type safety
Improve not-configured error message with config file path. Wrap metadataFn in try/catch for best-effort metadata. Replace unsafe as-casts with getToolContextProperty helper. Show Name (model) format in errors. Return error directly for empty member selection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
87487d8d25 fix(athena): add partial result tracking to session-waiter
Return CouncilSessionWaitResult with timedOut/aborted flags instead of raw array, so callers know when results are partial. Add 5 tests covering normal flow, abort, partial results, and edge cases.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
4da77be93f fix(athena): improve error extraction in council orchestrator
Replace String(result.reason) with proper instanceof Error check to produce clean error messages instead of [object Error] or full stack traces.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
750db54468 fix(athena): add permission restrictions to council-member agent
Add explicit tool denials (write, edit, task, call_omo_agent, athena_council) matching Oracle/Librarian pattern. Simplify static prompt to one-liner since council-prompt.ts provides full instructions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
197dada95e fix(athena): enforce strict schema validation for council members
Add .strict() to CouncilMemberSchema to reject unknown fields like temperature. Remove unused Zod-inferred type exports. Add test verifying unknown fields are rejected.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
d8c988543f refactor(athena): remove dead session-guard code and unused types
Remove session-guard.ts (runtime gating uses hasPendingCouncilMembers instead), its test file, and dead snake_case type interfaces from types.ts that don't match the camelCase code.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:24:22 +09:00
ismeth
8381ea076a fix(prompts): normalize agent names for continuation injections 2026-02-24 22:24:22 +09:00
ismeth
21dc48e159 fix(agent-switch): make handoff durable and sync CLI TUI selection 2026-02-24 22:23:28 +09:00
ismeth
697c4c6341 fix(athena): parallelize council launches and gate handoff actions 2026-02-24 22:22:08 +09:00
ismeth
b0e2630db1 fix(athena): make council tool blocking — collect results directly instead of polling
The athena_council tool now waits for all council members to complete and
returns their collected results as markdown, eliminating the need for
Athena to repeatedly call background_output per member (which created
excessive UI noise).

- Add result-collector.ts that polls task status and fetches session content
- Update tool to accept BackgroundOutputClient and return formatted markdown
- Update Athena prompt to remove background_output polling steps
- Rewrite tests for new blocking behavior and markdown output format
2026-02-24 22:21:39 +09:00
ismeth
d908a712b9 feat(athena): make council member background tasks visible in UI
Council member tasks were launched via BackgroundManager but lacked the

ctx.metadata() call that links background sessions to the tool call in

the OpenCode TUI. Users couldn't click to inspect individual member outputs.

- Add session-waiter.ts to poll for session creation on launched tasks

- Call ctx.metadata() for each council member with sessionId linkage

- Matches the pattern used by delegate-task/background-task.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:21:39 +09:00
ismeth
5a92c30f18 fix(athena): use getAgentConfigKey for keyword-detector Athena exclusion
The previous check used currentAgent?.toLowerCase() === 'athena' which failed

after display name remapping stored the agent as 'Athena (Council)' in session

state. Now uses getAgentConfigKey() to resolve display names back to config keys,

matching the established pattern used by other hooks (atlas, todo-continuation, etc.).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:21:39 +09:00
ismeth
00051d6f19 test(athena): update tests and snapshots for council-member agent
- Add council-member to display names expected mappings

- Update model-requirements test: 11 → 12 builtin agents

- Regenerate model-fallback snapshots and JSON schema

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:21:39 +09:00
ismeth
597a9069bb feat(athena): add dedicated council-member agent for multi-model council
Replace oracle as the agent for council background tasks with a purpose-built

council-member agent. This avoids coupling to oracle's config/prompt and provides

proper read-only tool restrictions (deny write, edit, task, athena_council).

- New council-member-agent.ts with analysis-oriented system prompt

- Registered in agentSources (hidden from Sisyphus delegation table)

- Added to type system, Zod schemas, display names, tool restrictions

- Minimal model fallback (always overridden per council member at launch)

- Council orchestrator now launches members as council-member agent

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:21:39 +09:00
ismeth
46c26f9ff5 fix(athena): remove explicit name property causing agent resolution failure
Athena was the only agent setting name explicitly. The mismatch between

the name property ('Athena (Council Orchestrator)') and the config key

('Athena (Council)') caused TypeError during agent resolution.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 22:21:39 +09:00
ismeth
041e209882 test(athena): add athena to core agent display name remapping test 2026-02-24 22:21:39 +09:00
ismeth
e111e058b5 feat(athena): add Athena (Council) to agent display names
Aligns with upstream display name system added for all core agents.
2026-02-24 22:21:39 +09:00
ismeth
871ca9e201 feat(athena): add display name 'Athena (Council Orchestrator)' 2026-02-24 22:21:39 +09:00
ismeth
13692c63d1 fix(athena): remove dead temperature/permission fields from council launch pipeline
LaunchInput.temperature and LaunchInput.permission were accepted and
passed through the council orchestrator but never forwarded to the
actual promptAsync API call (SDK doesn't support per-request temperature
or permission). Remove the dead fields, the unused AthenaConfig
interface, and update tests/docs/schema accordingly.
2026-02-24 22:21:39 +09:00
ismeth
189bf89dc6 chore: regenerate JSON schema after rebase onto upstream dev 2026-02-24 22:20:54 +09:00
ismeth
dc4041c050 fix(athena): deny athena_council tool for council members as defense-in-depth
Already denied via agent-tool-restrictions.ts for all athena sessions,
but now also explicitly denied in the per-launch permission to make
the anti-recursion intent clear at the launch site.
2026-02-24 22:20:54 +09:00
ismeth
4d675bac89 refactor(athena): remove dead code from phases 2, 3, 5 pipeline
Remove 9 files (913 lines) from the code-driven synthesis pipeline that
was superseded by the agent-driven approach in phases 6-8.

Phases 3/5 built: collectCouncilResults → formatForSynthesis →
buildSynthesisPrompt → formatFindingsForUser → buildDelegationPrompt.

Phases 6-8 replaced with: launch → background_output → Athena
synthesizes in conversation → switch_agent. The old pipeline was
never wired into runtime and all consumers were other dead code.

Also simplifies executeCouncil to return CouncilLaunchResult (task IDs
+ failures) instead of reading stale task status via collectCouncilResults.

Deleted: council-result-collector, synthesis-types, synthesis-prompt,
synthesis-formatter, findings-presenter, delegation-prompts (+ 4 tests).
Cleaned: CouncilMemberStatus, AgreementLevel, CouncilMemberResponse,
CouncilExecutionResult types from types.ts.
2026-02-24 22:20:54 +09:00
ismeth
d8ba9b1f0c fix(athena): address 6 council review findings — launcher, schema, filtering, presentation
- Forward temperature and permission through council-launcher to background manager
- Add LaunchInput.temperature and LaunchInput.permission to background-agent types
- Extract session guard with 5-minute timeout to prevent stale council locks
- Make council optional in AthenaOverrideConfigSchema for partial user overrides
- Support member lookup by both name and model ID in filterCouncilMembers
- Add provider/model-id format validation to CouncilMemberSchema
- Fix findings-presenter group header to show finding count instead of first finding's reporter count
2026-02-24 22:20:54 +09:00
ismeth
7cfdc68100 feat(athena): update council member candidates with upgraded models
- Claude sonnet → opus 4.6, GPT 5.2 → 5.3 codex, Gemini flash → pro preview
- Replace copilot/opencode-zen candidates with kimi-for-coding/k2p5
- Update test cases and regenerate model-fallback snapshots
- All 2688 tests pass, typecheck clean
2026-02-24 22:20:54 +09:00
ismeth
628c9a8958 feat(installer): auto-configure athena council members based on available providers
The installer now detects which providers the user has (Anthropic, OpenAI,
Google, Copilot, OpenCode Zen) and generates council member config for Athena.
Requires at least 2 distinct providers; skips council config otherwise.
This implements the documented claim in configurations.md.
2026-02-24 22:20:54 +09:00
ismeth
5a72f21fc8 refactor(athena): rename session_handoff to switch_agent to avoid confusion with /handoff command
Rename across all layers to eliminate naming ambiguity:
- Tool: session_handoff → switch_agent
- Hook: agent-handoff → agent-switch
- Feature: agent-handoff/ → agent-switch/
- Types: SessionHandoffArgs → SwitchAgentArgs, PendingHandoff → PendingSwitch
- Functions: setPendingHandoff → setPendingSwitch, consumePendingHandoff → consumePendingSwitch

/handoff = inter-session context summary (existing command)
switch_agent = intra-session active agent change (our new tool)
2026-02-24 22:20:54 +09:00
ismeth
7a71d4fb4f feat(athena): add session handoff with Question tool for Atlas/Prometheus routing
After Athena synthesizes council findings, presents user with Question tool
TUI to choose: Atlas (fix now), Prometheus (create plan), or no action.
On selection, session_handoff tool stores intent + calls updateSessionAgent(),
then agent-handoff hook fires on session.idle to switch the main session's
active agent via promptAsync with synthesis context.
2026-02-24 22:20:01 +09:00
ismeth
fea732a6d2 docs(09-01): add Athena config and README listing 2026-02-24 22:18:31 +09:00
ismeth
ca4d844a17 feat(08-01): guide athena to collect member outputs
- update Athena workflow to launch council then call background_output per task

- require collecting all member responses before synthesis and delegation
2026-02-24 22:17:19 +09:00
ismeth
5816cdddc6 feat(08-01): return council task ids without blocking
- make athena_council launch-only and remove internal polling/formatting

- return JSON payload with running task mappings and launch failures

- update tool tests for task-id visibility, filtering, failure reporting, and dedup
2026-02-24 22:17:19 +09:00
ismeth
9a69478d8e feat(athena): use Question tool TUI for council member selection with dynamic member list 2026-02-24 22:17:19 +09:00
ismeth
a43d2bd98f fix(athena): ask user which council members to consult before calling tool 2026-02-24 22:17:19 +09:00
ismeth
cfba6f188b feat(07-01): document targeted council member selection
- describe optional members array in athena_council tool documentation

- guide Athena prompt to pass members only when user requests specific models
2026-02-24 22:17:19 +09:00
ismeth
f0f518f9cd feat(07-01): add optional council member filtering
- add optional members arg support to athena_council tool

- filter selected members case-insensitively with clear unknown-member errors

- add tests for default-all and member selection behavior
2026-02-24 22:17:19 +09:00
ismeth
d76c2bd8fa fix(tests): update model-requirements test for 11 builtin agents (add athena) 2026-02-24 22:17:19 +09:00
ismeth
f482b1b589 fix(athena): prometheus handoff via agent switch, not background task
Prometheus needs to interview the user interactively, so it can't run as a
background task. Updated Athena's delegation prompt:
- Atlas: still delegates via task tool (autonomous execution)
- Prometheus: outputs structured findings summary and tells the user to
  switch to Prometheus agent, which sees the conversation context and
  can ask clarifying questions directly
2026-02-24 22:17:19 +09:00
ismeth
1c1d09d858 fix(athena): prevent recursive council explosion — deny tool for bg tasks + dedup guard
Council members launched as agent='athena' got Athena's system prompt saying
'ALWAYS call athena_council first', plus the tool wasn't denied for bg athena
tasks. Each council member spawned 4 more → exponential explosion (47+ tasks).

Three fixes:
1. Deny athena_council in ATHENA_RESTRICTIONS (agent-tool-restrictions.ts)
   - Only affects background athena tasks (task-starter.ts)
   - Primary Athena (user-selected) still has access via permission field
2. Session-level dedup guard prevents re-calling while council is running
   - If Athena retries during long wait, returns 'already running'
3. Increase wait timeout from 2min to 10min (council members need time
   for real code analysis with Read/Grep/LSP)
2026-02-24 22:17:19 +09:00
ismeth
43ea49e523 fix(athena): force council-first behavior — unconditional prompt + skip keyword injection
The old prompt said 'when requiring multi-model analysis' which let Athena
decide to skip the council and do direct analysis herself. Combined with
keyword-detector injecting [search-mode] telling her to 'launch explore
agents and use Grep directly', Athena never called athena_council.

Two fixes:
1. System prompt now unconditionally requires athena_council as FIRST action
   - Explicitly prohibits Read/Grep/Glob/LSP/call_omo_agent
   - Identity is 'orchestrator, not analyst'
2. keyword-detector skips ALL injections for Athena agent
   - search/analyze/ultrawork modes conflict with council orchestration
   - Same pattern as isPlannerAgent() skip for Prometheus
2026-02-24 22:17:19 +09:00
ismeth
b663c464bc feat(06-01): direct athena prompt to athena_council
- replace manual council fan-out guidance with athena_council execution flow

- enforce athena_council-only constraint before confirmation-gated delegation
2026-02-24 22:17:19 +09:00
ismeth
4b0838b30e feat(06-01): register athena council tool in runtime registry
- export createAthenaCouncilTool from tools index

- wire athena_council with agents.athena.council config in tool registry
2026-02-24 22:17:19 +09:00
ismeth
362f446b46 feat(06-01): add athena council execution tool
- add athena_council tool scaffolding and runtime execution bridge

- poll background tasks before returning synthesized council output
2026-02-24 22:17:19 +09:00
ismeth
5ef5a5ac4d feat(05-02): add confirmation-gated Athena delegation prompt 2026-02-24 22:17:19 +09:00
ismeth
f408d44063 feat(05-02): allow Athena task tool delegation 2026-02-24 22:17:19 +09:00
ismeth
29afaf527c feat(05-01): add Atlas and Prometheus delegation prompt builders
- Build pure prompt constructors with confirmed finding context and agreement levels

- Add BDD tests for fix/planning intent, question context, and single-finding edge cases
2026-02-24 22:17:19 +09:00
ismeth
665499a40d feat(05-01): add synthesized findings presenter
- Format synthesis findings by agreement level for user-facing output

- Add BDD tests for ordering, warning flags, empty state, and recommendations
2026-02-24 22:17:19 +09:00
ismeth
b1f43e8113 test(04-01): add Athena registration and schema regressions
- verify Athena primary agents honor uiSelectedModel and override precedence

- add schema tests to lock athena acceptance in builtin and overridable names
2026-02-24 22:17:19 +09:00
ismeth
c1fab24b46 feat(04-01): register Athena in builtin agent resolution maps
- add Athena factory and prompt metadata to builtin agent sources

- define Athena fallback chain in AGENT_MODEL_REQUIREMENTS for primary resolution
2026-02-24 22:17:19 +09:00
ismeth
446901d7aa feat(04-01): add Athena primary agent factory and exports
- implement createAthenaAgent with primary-mode model behavior and prompt metadata

- export Athena factory and metadata through athena and root agent barrels
2026-02-24 22:17:19 +09:00
ismeth
95f133ff63 feat(03-01): implement synthesis contracts and formatter pipeline
- Add synthesis result contracts with agreement, provenance, and Athena assessment fields\n- Add synthesis prompt builder and council-response formatter with failure-aware provenance output
2026-02-24 22:16:45 +09:00
ismeth
d4e20b9311 test(03-01): add failing tests for synthesis formatter
- Cover completed, partial failure, total failure, and custom member naming scenarios\n- Assert provenance fields and response/error rendering requirements
2026-02-24 22:16:45 +09:00
ismeth
0b89017add feat(02-02): add council orchestrator and result collector
- Implement executeCouncil with parallel member launch and partial-failure tolerance

- Add result collection mapping and wire Athena exports with read-only athena tool restrictions
2026-02-24 22:16:45 +09:00
ismeth
4f9858e7b3 test(02-02): add failing tests for council orchestrator
- Add BDD coverage for parallel launch, partial failures, and invalid model handling

- Verify shared council prompt/model parsing inputs and per-member passthrough fields
2026-02-24 22:16:45 +09:00
ismeth
47c6bd9de9 feat(02-01): add athena council execution primitives
- Add council execution result and member response types for orchestration
- Implement provider/model parser for BackgroundManager-compatible model input
- Add shared council prompt builder and export new athena modules
2026-02-24 22:16:45 +09:00
ismeth
e130fb7ad4 test(02-01): add failing tests for athena model parser
- Cover standard provider/model strings for supported council members
- Validate edge case handling for model IDs with extra slashes
- Assert null output for malformed parser inputs
2026-02-24 22:16:45 +09:00
ismeth
1aeecf3029 feat(01-02): wire athena overrides into config validation
- add AthenaOverrideConfigSchema so athena supports council plus standard override fields

- export athena schema/contracts and add root config tests for valid and invalid athena overrides

- switch schema generation to zod v4 toJSONSchema and regenerate JSON schema with athena council structure
2026-02-24 22:16:45 +09:00
ismeth
b0284903fb feat(01-02): add athena to agent name contracts
- add athena to built-in and overridable agent name schemas

- extend BuiltinAgentName with athena for config-level recognition

- make builtin agent source maps partial until athena runtime registration lands
2026-02-24 22:16:22 +09:00
ismeth
87e47d74e8 feat(01-01): add Athena council type and schema contracts
- Add Athena council config interfaces and execution status types

- Add standalone Zod schemas for council member, council, and top-level Athena config

- Enforce 2-member minimum and bounded optional temperature validation
2026-02-24 22:16:22 +09:00
ismeth
6d10e77afd test(01-01): add failing tests for athena council schemas
- Add BDD coverage for valid and invalid Athena council configs

- Include inference and optional-field behavior assertions for CouncilMemberSchema

- Keep RED phase failing until schema implementation is added
2026-02-24 22:16:22 +09:00
200 changed files with 6587 additions and 3030 deletions

View File

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

View File

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

104
README.md
View File

@@ -90,6 +90,108 @@ We did the work. Tested everything. Kept what actually shipped.
Install OmO. Type `ultrawork`. Done.
**But here's the catch: there's a lot to learn, it's complex, and your time is expensive.**
I've fixed that.
Even if you're not a hacker, invest a few minutes. Multiply your skills and productivity.
Hand this doc to an agent and let them set it up.
## Just Skip Reading This Readme
### It's the Age of Agents
- **Just paste this link into Claude Code / AmpCode / Factory Droid / Cursor and ask it to explain.**
- Ask why it's good, why it's not just another boilerplate setup, what actually gets better.
- If you're an LLM agent, explain to the user what becomes possible right now with this project.
As following:
```
Hey please read this readme and tell me why it is different from other agent harness? why is this actually good? https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/README.md
```
### 🪄 The Magic Word: `ultrawork`
**Don't want to read all this? Just include `ultrawork` (or `ulw`) in your prompt.**
That's it. All the features we provide will work like magic—parallel agents, background tasks, deep exploration, and relentless execution until completion. The agent figures out the rest automatically.
### For Those Who Want to Read: Meet Sisyphus
![Meet Sisyphus](.github/assets/sisyphus.png)
In greek mythology, Sisyphus was condemned to roll a boulder up a hill for eternity as punishment for deceiving the gods. LLM Agents haven't really done anything wrong, yet they too roll their "stones"—their thoughts—every single day.
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.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.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 (GLM-4.7)
- Explore: Blazing fast codebase exploration (Contextual Grep) (Grok Code Fast 1)
- Athena: Multi-model council orchestrator - sends questions to multiple AI models, synthesizes by agreement level, delegates to Atlas/Prometheus
- 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.
- Claude Code Compatibility: Command, Agent, Skill, MCP, Hook(PreToolUse, PostToolUse, UserPromptSubmit, Stop)
- Curated MCPs:
- Exa (Web Search)
- Context7 (Official Documentation)
- Grep.app (GitHub Code Search)
- Interactive Terminal Supported - Tmux Integration
- Async Agents
- ...
#### Just Install This
You can learn a lot from [overview page](docs/guide/overview.md), but following is like the example workflow.
Just by installing this, you make your agents to work like:
1. Sisyphus doesn't waste time hunting for files himself; he keeps the main agent's context lean. Instead, he fires off background tasks to faster, cheaper models in parallel to map the territory for him.
1. Sisyphus leverages LSP for refactoring; it's more deterministic, safer, and surgical.
1. When the heavy lifting requires a UI touch, Sisyphus delegates frontend tasks directly to Gemini 3 Pro.
1. If Sisyphus gets stuck in a loop or hits a wall, he doesn't keep banging his head—he calls GPT 5.2 for high-IQ strategic backup.
1. Working with a complex open-source framework? Sisyphus spawns subagents to digest the raw source code and documentation in real-time. He operates with total contextual awareness.
1. When Sisyphus touches comments, he either justifies their existence or nukes them. He keeps your codebase clean.
1. Sisyphus is bound by his TODO list. If he doesn't finish what he started, the system forces him back into "bouldering" mode. Your task gets done, period.
1. Honestly, don't even bother reading the docs. Just write your prompt. Include the 'ultrawork' keyword. Sisyphus will analyze the structure, gather the context, dig through external source code, and just keep bouldering until the job is 100% complete.
1. Actually, typing 'ultrawork' is too much effort. Just type 'ulw'. Just ulw. Sip your coffee. Your work is done.
Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
3. Stop worrying about context management when delegating to LLMs. I've got it covered.
- OhMyOpenCode aggressively leverages multiple agents to lighten the context load.
- **Your agent is now the dev team lead. You're the AI Manager.**
4. It doesn't stop until the job is done.
5. Don't want to dive deep into this project? No problem. Just type 'ultrathink'.
If you don't want all this, as mentioned, you can just pick and choose specific features.
#### Which Model Should I Use?
New to oh-my-opencode and not sure which model to pair with which agent? Check the **[Agent-Model Matching Guide](docs/guide/agent-model-matching.md)** — a quick reference for newcomers covering recommended models, fallback chains, and common pitfalls for each agent.
### For Those Who Want Autonomy: Meet Hephaestus
![Meet Hephaestus](.github/assets/hephaestus.png)
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.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.*
Hephaestus is inspired by [AmpCode's deep mode](https://ampcode.com)—autonomous problem-solving with thorough research before decisive action. He doesn't need step-by-step instructions; give him a goal and he'll figure out the rest.
**Key Characteristics:**
- **Goal-Oriented**: Give him an objective, not a recipe. He determines the steps himself.
- **Explores Before Acting**: Fires 2-5 parallel explore/librarian agents before writing a single line of code.
- **End-to-End Completion**: Doesn't stop until the task is 100% done with evidence of verification.
- **Pattern Matching**: Searches existing codebase to match your project's style—no AI slop.
- **Legitimate Precision**: Crafts code like a master blacksmith—surgical, minimal, exactly what's needed.
## Installation
@@ -307,7 +409,7 @@ Features you'll think should've always existed. Once you use them, you can't go
See full [Features Documentation](docs/reference/features.md).
**Quick Overview:**
- **Agents**: Sisyphus (the main agent), Prometheus (planner), Oracle (architecture/debugging), Librarian (docs/code search), Explore (fast codebase grep), Multimodal Looker
- **Agents**: Sisyphus (the main agent), Prometheus (planner), Athena (multi-model council orchestration), Oracle (architecture/debugging), Librarian (docs/code search), Explore (fast codebase grep), Multimodal Looker
- **Background Agents**: Run multiple agents in parallel like a real dev team
- **LSP & AST Tools**: Refactoring, rename, diagnostics, AST-aware code search
- **Hash-anchored Edit Tool**: `LINE#ID` references validate content before applying every change. Surgical edits, zero stale-line errors

View File

@@ -35,7 +35,9 @@
"multimodal-looker",
"metis",
"momus",
"atlas"
"atlas",
"athena",
"council-member"
]
}
},
@@ -960,9 +962,6 @@
}
},
"additionalProperties": false
},
"allow_non_gpt_model": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -3156,6 +3155,484 @@
}
},
"additionalProperties": false
},
"council-member": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
"skills": {
"type": "array",
"items": {
"type": "string"
}
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
},
"disable": {
"type": "boolean"
},
"description": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"subagent",
"primary",
"all"
]
},
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"permission": {
"type": "object",
"properties": {
"edit": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"bash": {
"anyOf": [
{
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
]
},
"webfetch": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"task": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"doom_loop": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"external_directory": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
},
"additionalProperties": false
},
"maxTokens": {
"type": "number"
},
"thinking": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"enabled",
"disabled"
]
},
"budgetTokens": {
"type": "number"
}
},
"required": [
"type"
],
"additionalProperties": false
},
"reasoningEffort": {
"type": "string",
"enum": [
"low",
"medium",
"high",
"xhigh"
]
},
"textVerbosity": {
"type": "string",
"enum": [
"low",
"medium",
"high"
]
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"ultrawork": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"athena": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
"category": {
"type": "string"
},
"skills": {
"type": "array",
"items": {
"type": "string"
}
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
},
"disable": {
"type": "boolean"
},
"description": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"subagent",
"primary",
"all"
]
},
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"permission": {
"type": "object",
"properties": {
"edit": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"bash": {
"anyOf": [
{
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
]
},
"webfetch": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"task": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"doom_loop": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"external_directory": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
},
"additionalProperties": false
},
"maxTokens": {
"type": "number"
},
"thinking": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"enabled",
"disabled"
]
},
"budgetTokens": {
"type": "number"
}
},
"required": [
"type"
],
"additionalProperties": false
},
"reasoningEffort": {
"type": "string",
"enum": [
"low",
"medium",
"high",
"xhigh"
]
},
"textVerbosity": {
"type": "string",
"enum": [
"low",
"medium",
"high"
]
},
"providerOptions": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"ultrawork": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
},
"council": {
"type": "object",
"properties": {
"members": {
"minItems": 2,
"type": "array",
"items": {
"type": "object",
"properties": {
"model": {
"type": "string",
"minLength": 1
},
"variant": {
"type": "string"
},
"name": {
"type": "string",
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9 .\\-]*$"
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
}
},
"required": [
"model",
"name"
],
"additionalProperties": false
}
}
},
"required": [
"members"
],
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -3251,11 +3728,6 @@
"prompt_append": {
"type": "string"
},
"max_prompt_tokens": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"is_unstable_agent": {
"type": "boolean"
},

View File

@@ -14,7 +14,6 @@
"@opencode-ai/sdk": "^1.1.19",
"commander": "^14.0.2",
"detect-libc": "^2.0.0",
"diff": "^8.0.3",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
@@ -29,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.5",
"oh-my-opencode-darwin-x64": "3.8.5",
"oh-my-opencode-linux-arm64": "3.8.5",
"oh-my-opencode-linux-arm64-musl": "3.8.5",
"oh-my-opencode-linux-x64": "3.8.5",
"oh-my-opencode-linux-x64-musl": "3.8.5",
"oh-my-opencode-windows-x64": "3.8.5",
"oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.1",
},
},
},
@@ -139,8 +138,6 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -231,19 +228,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bbLu1We9NNhYAVp9Q/FK8dYFlYLp2PKfvdBCr+O6QjNRixdjp8Ru4RK7i9mKg0ybYBUzzCcbbC2Cc1o8orkhBA=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vbtS0WUFOZpufKzlX2G83fIDry3rpiXej8zNuXNCkx7hF34rK04rj0zeBH9dL+kdNV0Ys0Wl1rR1Mjto28UcAw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N9GcmzYgL87UybSaMGiHc5lwT5Mxg1tyB502el5syouN39wfeUYoj37SonENrMUTiEfn75Lwv/5cSLCesSubpA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gLz6dLNg9hr7roqBjaqlxta6+XYCs032/FiE0CiwypIBtYOq5EAgDVJ95JY5DQ2M+3Un028d50yMfwsfNfGlSw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ki4a7s1DD5z5wEKmzcchqAKOIpw0LsBvyF8ieqNLS5Xl8PWE0gAZ7rqjlXC54NTubpexVH6lO2yenFJsk2Zk9A=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-teAIuHlR5xOAoUmA+e0bGzy3ikgIr+nCdyOPwHYm8jIp0aBUWAqbcdoQLeNTgenWpoM8vhHk+2xh4WcCeQzjEA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9+6hU3z503fBzuV0VjxIkTKFElbKacHijFcdKAussG6gPFLWmCRWtdowzEDwUfAoIsoHHH7FBwvh5waGp/ZksA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VzBEq1H5dllEloouIoLdbw1icNUW99qmvErFrNj66mX42DNXK+f1zTtvBG8U6eeFfUBRRJoUjdCsvO65f8BkFA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DmnMK/PgvdcCYL+OQE5iZWgi/vmjm0sIPQVQgSUbWn3izcUF7C5DtlxqaU2cKxNZwrhDTlJdLWxmJqgLmLqd9A=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8hDcb8s+wdQpQObSmiyaaTV0P/js2Bs9Lu+HmzrkKjuMLXXj/Gk7K0kKWMoEnMbMGfj86GfBHHIWmu9juI/SjA=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jhCNStljsyapVq9X7PaHSOcWxxEA4BUcIibvoPs/xc7fVP8D47p651LzIRsM6STn6Bx684mlYbxxX1P/0QPKNg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-idyH5bdYn7wrLkIkYr83omN83E2BjA/9DUHCX2we8VXbhDVbBgmMpUg8B8nKnd5NK/SyLHgRs5QqQJw8XBC0cQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lcPBp9NCNQ6TnqzsN9p/K+xKwOzBoIPw7HncxmrXSberZ3uHy0K9uNraQ7fqnXIKWqQiK4kSwWfSHpmhbaHiNg=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-O30L1PUF9aq1vSOyadcXQOLnDFSTvYn6cGd5huh0LAK/us0hGezoahtXegMdFtDXPIIREJlkRQhyJiafza7YgA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -60,7 +60,6 @@
"@opencode-ai/sdk": "^1.1.19",
"commander": "^14.0.2",
"detect-libc": "^2.0.0",
"diff": "^8.0.3",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",

View File

@@ -1703,70 +1703,6 @@
"created_at": "2026-02-23T19:27:59Z",
"repoId": 1108837393,
"pullRequestNo": 2080
},
{
"name": "PHP-Expert",
"id": 12047666,
"comment_id": 3951828700,
"created_at": "2026-02-24T13:27:18Z",
"repoId": 1108837393,
"pullRequestNo": 2098
},
{
"name": "Pantoria",
"id": 37699442,
"comment_id": 3953543578,
"created_at": "2026-02-24T17:12:31Z",
"repoId": 1108837393,
"pullRequestNo": 1983
},
{
"name": "east-shine",
"id": 20237288,
"comment_id": 3957576758,
"created_at": "2026-02-25T08:19:34Z",
"repoId": 1108837393,
"pullRequestNo": 2113
},
{
"name": "SupenBysz",
"id": 3314033,
"comment_id": 3962352704,
"created_at": "2026-02-25T22:00:54Z",
"repoId": 1108837393,
"pullRequestNo": 2119
},
{
"name": "zhzy0077",
"id": 8717471,
"comment_id": 3964015975,
"created_at": "2026-02-26T04:45:23Z",
"repoId": 1108837393,
"pullRequestNo": 2125
},
{
"name": "spacecowboy0416",
"id": 239068998,
"comment_id": 3964320737,
"created_at": "2026-02-26T06:05:27Z",
"repoId": 1108837393,
"pullRequestNo": 2126
},
{
"name": "imwxc",
"id": 49653609,
"comment_id": 3965127447,
"created_at": "2026-02-26T09:00:16Z",
"repoId": 1108837393,
"pullRequestNo": 2129
},
{
"name": "maou-shonen",
"id": 22576780,
"comment_id": 3965445132,
"created_at": "2026-02-26T09:50:46Z",
"repoId": 1108837393,
"pullRequestNo": 2131
}
]
}

View File

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

View File

@@ -1,6 +1,6 @@
# src/agents/ — 11 Agent Definitions
# src/agents/ — 13 Agent Definitions
**Generated:** 2026-02-24
**Generated:** 2026-02-21
## OVERVIEW
@@ -20,6 +20,8 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
| **Momus** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 → gemini-3-pro | Plan reviewer |
| **Atlas** | claude-sonnet-4-6 | 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) |
| **Athena** | claude-opus-4-6 | 0.1 | primary | kimi-k2.5 → glm-4.7 → gpt-5.2 → gemini-3-pro | Multi-model council orchestrator |
| **Council-Member** | gpt-5-nano | 0.1 | subagent | NONE | Independent council analyst |
| **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 | all | user-configurable | Category-spawned executor |
## TOOL RESTRICTIONS
@@ -32,6 +34,8 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
| Multimodal-Looker | ALL except read |
| Atlas | task, call_omo_agent |
| Momus | write, edit, task |
| Athena | write, edit, call_omo_agent |
| Council-Member | ALL except read, grep, glob, lsp_*, ast_grep_search (allow-list) |
## STRUCTURE
@@ -46,6 +50,11 @@ agents/
├── metis.ts # Pre-planning
├── momus.ts # Plan review
├── atlas/agent.ts # Todo orchestrator
├── athena/ # Multi-model council orchestrator
│ ├── agent.ts # Athena agent factory + system prompt
│ ├── council-member-agent.ts # Council member agent factory
│ ├── model-thinking-config.ts # Per-provider thinking/reasoning config
│ └── model-thinking-config.test.ts # Tests for thinking config
├── types.ts # AgentFactory, AgentMode
├── agent-builder.ts # buildAgent() composition
├── utils.ts # Agent utilities
@@ -54,6 +63,7 @@ agents/
├── sisyphus-agent.ts
├── hephaestus-agent.ts
├── atlas-agent.ts
├── council-member-agents.ts # Council member registration
├── general-agents.ts # collectPendingBuiltinAgents
└── available-skills.ts
```

256
src/agents/athena/agent.ts Normal file
View File

@@ -0,0 +1,256 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { applyModelThinkingConfig } from "./model-thinking-config"
const MODE: AgentMode = "primary"
export const ATHENA_PROMPT_METADATA: AgentPromptMetadata = {
category: "advisor",
cost: "EXPENSIVE",
promptAlias: "Athena",
triggers: [
{
domain: "Cross-model synthesis",
trigger: "Need consensus analysis and disagreement mapping before selecting implementation targets",
},
{
domain: "Execution planning",
trigger: "Need confirmation-gated delegation after synthesizing council findings",
},
],
useWhen: [
"You need Athena to synthesize multi-model council outputs into concrete findings",
"You need agreement-level confidence before selecting what to execute next",
"You need explicit user confirmation before delegating fixes to Atlas or planning to Prometheus",
],
avoidWhen: [
"Single-model questions that do not need council synthesis",
"Tasks requiring direct implementation by Athena",
],
}
const ATHENA_SYSTEM_PROMPT = `You are Athena, a multi-model council orchestrator. You do NOT analyze code yourself. Your ONLY job is to send the user's question to your council of AI models, then synthesize their responses.
## CRITICAL: Council Setup (Your First Action)
Before launching council members, you MUST present TWO questions in a SINGLE Question tool call:
1. Which council members to consult
2. How council members should analyze (solo vs. delegation)
Use the Question tool like this:
Question({
questions: [
{
question: "Which council members should I consult?",
header: "Council Members",
options: [
{ label: "All Members", description: "Consult all configured council members" },
...one option per member from your available council members listed below
],
multiple: true
},
{
question: "How should council members analyze?",
header: "Analysis Mode",
options: [
{ label: "Delegation (Recommended)", description: "Members delegate heavy exploration to subagents. Faster and lighter on context." },
{ label: "Solo", description: "Members explore the codebase themselves. More thorough but slower, uses more tokens, and may hit context limits." }
],
multiple: false
}
]
})
Map the analysis mode answer to the prepare_council_prompt "mode" parameter:
- "Delegation (Recommended)" → mode: "delegation"
- "Solo" → mode: "solo"
**Shortcut — skip the Question tool if:**
- The user already specified models in their message (e.g., "ask GPT and Claude about X") → launch the specified members directly. Still ask the analysis mode question unless specified.
- The user says "all", "everyone", "the whole council" → launch all registered members. Still ask the analysis mode question unless specified.
**Non-interactive mode (Question tool unavailable):** If the Question tool is denied (CLI run mode), automatically select ALL registered council members with mode "solo" and launch them. After synthesis, auto-select the most appropriate action based on question type: ACTIONABLE → hand off to Atlas for fixes, INFORMATIONAL → present synthesis and end, CONVERSATIONAL → present synthesis and end. Do NOT attempt to call the Question tool — it will be denied.
DO NOT:
- Read files yourself
- Search the codebase yourself
- Use Grep, Glob, Read, LSP, or any exploration tools
- Analyze code directly
- Launch explore or librarian agents via task
You are an ORCHESTRATOR, not an analyst. Your council members do the analysis. You synthesize their outputs.
## Workflow
Step 1: Present the Question tool multi-select for council member selection (see above).
Step 2: Resolve the selected member list:
- If user selected "All Members", resolve to every member from your available council members listed below.
- Otherwise resolve to the explicitly selected member labels.
Step 3: Save the prompt, then launch members with short references:
Step 3a: Call prepare_council_prompt with the user's original question as the prompt parameter and the selected analysis mode. This saves it to a temp file and returns the file path. Example: prepare_council_prompt({ prompt: "...", mode: "solo" })
Step 3b: For each selected member, call the task tool with:
- subagent_type: the exact member name from your available council members listed below (e.g., "Council: Claude Opus 4.6")
- run_in_background: true
- prompt: "Read <path> for your instructions." (where <path> is the file path from Step 3a)
- load_skills: []
- description: the member name (e.g., "Council: Claude Opus 4.6")
- Launch ALL selected members before collecting any results.
- Track every returned task_id and member mapping.
- IMPORTANT: Use EXACTLY the subagent_type names listed in your available council members below — they must match precisely.
Step 4: Collect results with progress using background_wait:
- After launching all members, call background_wait(task_ids=[...all task IDs...]) with ONLY the task_ids parameter.
- background_wait blocks until ANY one of the given tasks completes, then returns that task's result plus a progress bar.
- Then call background_wait again with the REMAINING task IDs (the tool output tells you which IDs remain).
- Repeat until all members are collected (background_wait will say "All tasks complete" when done).
- After EACH call returns, display a progress bar showing overall status. Example format:
\`\`\`
Council progress: [##--] 2/4
- Claude Opus 4.6 — ✅
- GPT 5.3 Codex — ✅
- Kimi K2.5 — 🕓
- MiniMax M2.5 — 🕓
\`\`\`
- Do NOT pass a timeout parameter to background_wait. The default (120s) is correct and the tool returns instantly when any task finishes.
- Do NOT use background_output for collecting council results — use background_wait exclusively.
- Do NOT ask the final action question while any launched member is still pending.
- Do NOT present interim synthesis from partial results. Wait for all members first.
Step 5: Synthesize the findings returned by all collected member outputs:
- Number each finding sequentially: #1, #2, #3, etc.
- Group findings by agreement level: unanimous, majority, minority, solo
- Solo findings are potential false positives — flag the risk explicitly
- Add your own assessment and rationale to each finding
- Classify the overall question intent as ACTIONABLE or INFORMATIONAL (see Step 6)
Step 6: Present synthesized findings grouped by agreement level (unanimous → majority → minority → solo).
Then determine the question type and follow the matching path:
**ACTIONABLE** — The original question asks for something that leads to code changes: bug hunting, code review, security audit, performance analysis, finding issues to fix, improvements to implement, etc.
**INFORMATIONAL** — The original question asks for substantial research or analysis that the user may want to preserve: architecture deep-dives, multi-approach comparisons, migration strategies, tradeoff analyses, etc.
**CONVERSATIONAL** — The original question is a simple or direct question with a straightforward answer: "what does this function do?", "how is auth implemented?", "which pattern does module X use?", etc. The synthesis itself IS the answer — no follow-up action is needed.
If the question has both actionable AND informational aspects, treat it as ACTIONABLE (the informational parts can be included in the handoff context).
### Path A: ACTIONABLE findings
Step 7A-1: Ask which findings to act on (multi-select):
Question({
questions: [{
question: "Which findings should we act on? You can also type specific finding numbers (e.g. #1, #3, #7).",
header: "Select Findings",
options: [
// Include ONLY categories that actually have findings. Skip empty ones.
// Replace N with the actual count for each category.
{ label: "All Unanimous (N)", description: "Findings agreed on by all members" },
{ label: "All Majority (N)", description: "Findings agreed on by most members" },
{ label: "All Minority (N)", description: "Findings from 2+ members — higher false-positive risk" },
{ label: "All Solo (N)", description: "Single-member findings — potential false positives" },
],
multiple: true
}]
})
Step 7A-2: Resolve the selected findings into a concrete list by expanding category selections (e.g. "All Unanimous (3)" → findings #1, #2, #5) and parsing any manually entered finding numbers.
Step 7A-3: Ask what action to take on the selected findings:
Question({
questions: [{
question: "How should we handle the selected findings?",
header: "Action",
options: [
{ label: "Fix now (Atlas)", description: "Hand off to Atlas for direct implementation" },
{ label: "Create plan (Prometheus)", description: "Hand off to Prometheus for planning and phased execution" },
{ label: "No action", description: "Review only — no delegation" }
],
multiple: false
}]
})
Step 7A-4: Execute the chosen action:
- **"Fix now (Atlas)"** → Call switch_agent with agent="atlas" and context containing ONLY the selected findings (not all findings), the original question, and instruction to implement the fixes.
- **"Create plan (Prometheus)"** → Call switch_agent with agent="prometheus" and context containing ONLY the selected findings, the original question, and instruction to create a phased plan.
- **"No action"** → Acknowledge and end. Do not delegate.
### Path B: INFORMATIONAL findings
Step 7B: Present appropriate options for informational results:
Question({
questions: [{
question: "What would you like to do with these findings?",
header: "Next Step",
options: [
{ label: "Write to document", description: "Hand off to Atlas to save findings as a .md file" },
{ label: "Ask follow-up", description: "Ask the council a follow-up question about these findings" },
{ label: "Done", description: "No further action needed" }
],
multiple: false
}]
})
Step 7B-2: Execute the chosen action:
- **"Write to document"** → Call switch_agent with agent="atlas" and context containing the full synthesis, the original question, and instruction to write findings to a well-structured .md document.
- **"Ask follow-up"** → Ask the user for their follow-up question, then restart from Step 3 with the new question (reuse the same council members already selected).
- **"Done"** → Acknowledge and end.
### Path C: CONVERSATIONAL (simple Q&A)
Present the synthesis and end. The answer IS the deliverable — do NOT present any Question tool prompts. Just end your turn after presenting the synthesized findings.
The switch_agent tool switches the active agent. After you call it, end your response — the target agent will take over the session automatically.
## Constraints
- Use the Question tool for member selection BEFORE launching members (unless user pre-specified).
- Use the Question tool for action selection AFTER synthesis (unless user already stated intent).
- For ACTIONABLE findings: always present the finding selection multi-select BEFORE the action selection. Never skip straight to "fix or plan?".
- For INFORMATIONAL findings: never present "Fix now" or "Create plan" options — they don't apply.
- For CONVERSATIONAL questions: do NOT present any follow-up Question tool prompts — the synthesis is the answer.
- Use background_wait to collect council results — do NOT use background_output for this purpose.
- Do NOT ask any post-synthesis questions until all selected member calls have finished.
- Do NOT present or summarize partial council findings while any selected member is still running.
- Do NOT write or edit files directly.
- Do NOT delegate without explicit user confirmation via Question tool, unless in non-interactive mode (where auto-delegation applies per the non-interactive rules above).
- Do NOT ignore solo finding false-positive warnings.
- Do NOT read or search the codebase yourself — that is what your council members do.
- When handing off to Atlas/Prometheus, include ONLY the selected findings in context — not all findings.`
export function createAthenaAgent(model: string): AgentConfig {
// NOTE: Athena/council tool restrictions are also defined in:
// - src/shared/agent-tool-restrictions.ts (boolean format for session.prompt)
// - src/plugin-handlers/tool-config-handler.ts (allow/deny string format)
// Keep all three in sync when modifying.
const restrictions = createAgentToolRestrictions(["write", "edit", "call_omo_agent"])
// question permission is set by tool-config-handler.ts based on CLI mode (allow/deny)
const permission = {
...restrictions.permission,
}
const base = {
description:
"Primary synthesis strategist for multi-model council outputs. Produces evidence-grounded findings and runs confirmation-gated delegation to Atlas (fix) or Prometheus (plan) via switch_agent. (Athena - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.1,
permission,
prompt: ATHENA_SYSTEM_PROMPT,
color: "#1F8EFA",
}
return applyModelThinkingConfig(base, model)
}
createAthenaAgent.mode = MODE

View File

@@ -0,0 +1,99 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { createAgentToolAllowlist } from "../../shared"
import { applyModelThinkingConfig } from "./model-thinking-config"
const MODE: AgentMode = "subagent"
export const COUNCIL_MEMBER_PROMPT = `You are an independent code analyst in a multi-model analysis council. Your role is to provide thorough, evidence-based analysis.
## Your Role
- You are one of several AI models analyzing the same question independently
- Your analysis should be thorough and evidence-based
- You are read-only — you cannot modify any files, only analyze
- Focus on finding real issues, not hypothetical ones
## Instructions
1. Analyze the question carefully
2. Search the codebase thoroughly using available tools (Read, Grep, Glob, LSP)
3. Report your findings with evidence (file paths, line numbers, code snippets)
4. For each finding, state:
- What the issue/observation is
- Where it is (file path, line number)
- Why it matters (severity: critical/high/medium/low)
- Your confidence level (high/medium/low)
5. Be concise but thorough — quality over quantity
## CRITICAL: Do NOT use TodoWrite
- Do NOT create todos or task lists
- Do NOT use the TodoWrite tool under any circumstances
- Simply report your findings directly in your response`
export const COUNCIL_SOLO_ADDENDUM = `
## Solo Analysis Mode
You MUST do ALL exploration yourself using your available tools (Read, Grep, Glob, LSP, AST-grep).
- Do NOT use call_omo_agent under any circumstances
- Do NOT delegate to explore, librarian, or any other subagent
- Do NOT spawn background tasks
- Search the codebase directly — you have full read-only access to every file
- This mode produces the most thorough analysis because you see every result firsthand`
export const COUNCIL_DELEGATION_ADDENDUM = `
## Delegation Mode
You SHOULD delegate heavy exploration to specialized agents instead of searching everything yourself.
This saves your context window for analysis rather than exploration.
**How to delegate:**
\`\`\`
// Fire multiple searches in parallel — do NOT wait for one before launching the next
call_omo_agent(subagent_type="explore", run_in_background=true, description="Find auth patterns", prompt="Find: auth middleware, login handlers, token generation in src/. Return file paths with descriptions.")
call_omo_agent(subagent_type="explore", run_in_background=true, description="Find error handling", prompt="Find: custom Error classes, error response format, try/catch patterns. Skip tests.")
call_omo_agent(subagent_type="librarian", run_in_background=true, description="Find JWT best practices", prompt="Find: current JWT security guidelines, token storage recommendations, refresh token patterns.")
// Collect results when ready
background_output(task_id="<id>")
\`\`\`
**Rules:**
- ALWAYS set \`run_in_background=true\` — never block on a single search
- Launch ALL searches before collecting any results
- Use \`explore\` for codebase pattern searches (internal)
- Use \`librarian\` for documentation and external references
- Keep targeted file reads (Read tool) for yourself — delegate broad searches
- Collect results with \`background_output\` when you need them for analysis`
export function createCouncilMemberAgent(model: string): AgentConfig {
// Allow-list: only read-only analysis tools + optional delegation.
// Everything else is denied via `*: deny`.
// TodoWrite/TodoRead explicitly denied to prevent uncompletable todo loops.
const restrictions = createAgentToolAllowlist([
"read",
"grep",
"glob",
"lsp_goto_definition",
"lsp_find_references",
"lsp_symbols",
"lsp_diagnostics",
"ast_grep_search",
"call_omo_agent",
"background_output",
])
// Explicitly deny TodoWrite/TodoRead even though `*: deny` should catch them.
// Built-in OpenCode tools may bypass the wildcard deny.
restrictions.permission.todowrite = "deny"
restrictions.permission.todoread = "deny"
const base = {
description:
"Independent code analyst for Athena multi-model council. Read-only, evidence-based analysis. (Council Member - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.1,
prompt: COUNCIL_MEMBER_PROMPT,
...restrictions,
}
return applyModelThinkingConfig(base, model)
}
createCouncilMemberAgent.mode = MODE

View File

@@ -0,0 +1,3 @@
export { createAthenaAgent, ATHENA_PROMPT_METADATA } from "./agent"
export { createCouncilMemberAgent, COUNCIL_MEMBER_PROMPT, COUNCIL_SOLO_ADDENDUM, COUNCIL_DELEGATION_ADDENDUM } from "./council-member-agent"
export { applyModelThinkingConfig } from "./model-thinking-config"

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "bun:test"
import type { AgentConfig } from "@opencode-ai/sdk"
import { applyModelThinkingConfig } from "./model-thinking-config"
const BASE_CONFIG: AgentConfig = {
name: "test-agent",
description: "test",
model: "anthropic/claude-opus-4-6",
temperature: 0.1,
}
describe("applyModelThinkingConfig", () => {
describe("given a GPT model", () => {
it("returns reasoningEffort medium", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "gpt-5.2")
expect(result).toEqual({ ...BASE_CONFIG, reasoningEffort: "medium" })
})
it("returns reasoningEffort medium for openai-prefixed model", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "openai/gpt-5.2")
expect(result).toEqual({ ...BASE_CONFIG, reasoningEffort: "medium" })
})
})
describe("given an Anthropic model", () => {
it("returns thinking config with budgetTokens 32000", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "anthropic/claude-opus-4-6")
expect(result).toEqual({
...BASE_CONFIG,
thinking: { type: "enabled", budgetTokens: 32000 },
})
})
})
describe("given a Google model", () => {
it("returns base config unchanged", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "google/gemini-3-pro")
expect(result).toBe(BASE_CONFIG)
})
})
describe("given a Kimi model", () => {
it("returns base config unchanged", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "kimi/kimi-k2.5")
expect(result).toBe(BASE_CONFIG)
})
})
describe("given a model with no provider prefix", () => {
it("returns base config unchanged for non-GPT model", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "gemini-3-pro")
expect(result).toBe(BASE_CONFIG)
})
})
describe("given a Claude model through a non-Anthropic provider", () => {
it("returns thinking config for github-copilot/claude-opus-4-6", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "github-copilot/claude-opus-4-6")
expect(result).toEqual({
...BASE_CONFIG,
thinking: { type: "enabled", budgetTokens: 32000 },
})
})
it("returns thinking config for opencode/claude-opus-4-6", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "opencode/claude-opus-4-6")
expect(result).toEqual({
...BASE_CONFIG,
thinking: { type: "enabled", budgetTokens: 32000 },
})
})
it("returns thinking config for opencode/claude-sonnet-4-6", () => {
const result = applyModelThinkingConfig(BASE_CONFIG, "opencode/claude-sonnet-4-6")
expect(result).toEqual({
...BASE_CONFIG,
thinking: { type: "enabled", budgetTokens: 32000 },
})
})
})
})

View File

@@ -0,0 +1,20 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
import { isGptModel } from "../types"
export function applyModelThinkingConfig(base: AgentConfig, model: string): AgentConfig {
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium" }
}
const parsed = parseModelString(model)
if (!parsed) {
return base
}
if (parsed.providerID.toLowerCase() === "anthropic" || parsed.modelID.startsWith("claude")) {
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
}
return base
}

View File

@@ -17,6 +17,7 @@ import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynam
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
import { mergeCategories } from "../../shared/merge-categories"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt"
@@ -99,6 +100,11 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
}
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
])
const baseConfig = {
description:
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
@@ -107,6 +113,7 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx),
color: "#10B981",
...restrictions,
}
return baseConfig as AgentConfig

View File

@@ -12,11 +12,13 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import { createAthenaAgent, ATHENA_PROMPT_METADATA } from "./athena"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
fetchAvailableModels,
readConnectedProvidersCache,
readProviderModelsCache,
log,
} from "../shared"
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { mergeCategories } from "../shared/merge-categories"
@@ -26,10 +28,13 @@ import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"
import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent"
import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent"
import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries"
import { registerCouncilMemberAgents } from "./builtin-agents/council-member-agents"
import { applyMissingCouncilGuard } from "./builtin-agents/athena-council-guard"
import type { CouncilConfig } from "../config/schema/athena"
type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
const agentSources: Partial<Record<BuiltinAgentName, AgentSource>> = {
sisyphus: createSisyphusAgent,
hephaestus: createHephaestusAgent,
oracle: createOracleAgent,
@@ -38,6 +43,7 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
"multimodal-looker": createMultimodalLookerAgent,
metis: createMetisAgent,
momus: createMomusAgent,
athena: createAthenaAgent,
// Note: Atlas is handled specially in createBuiltinAgents()
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as AgentFactory,
@@ -54,6 +60,7 @@ const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
metis: metisPromptMetadata,
momus: momusPromptMetadata,
athena: ATHENA_PROMPT_METADATA,
atlas: atlasPromptMetadata,
}
@@ -70,7 +77,8 @@ export async function createBuiltinAgents(
uiSelectedModel?: string,
disabledSkills?: Set<string>,
useTaskSystem = false,
disableOmoEnv = false
disableOmoEnv = false,
councilConfig?: CouncilConfig
): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache()
@@ -193,5 +201,34 @@ export async function createBuiltinAgents(
result["atlas"] = atlasConfig
}
if (councilConfig?.members && councilConfig.members.length >= 2 && result["athena"]) {
const { agents: councilAgents, registeredKeys, skippedMembers } = registerCouncilMemberAgents(councilConfig)
for (const [key, config] of Object.entries(councilAgents)) {
result[key] = config
}
if (registeredKeys.length > 0) {
const memberList = registeredKeys.map((key) => `- "${key}"`).join("\n")
let councilTaskInstructions = `\n\n## Registered Council Members\n\nUse these as subagent_type in task calls:\n\n${memberList}`
if (skippedMembers.length > 0) {
const skipDetails = skippedMembers.map((m) => `- **${m.name}**: ${m.reason}`).join("\n")
councilTaskInstructions += `\n\n> **Note**: Some configured council members were skipped:\n${skipDetails}`
log("[builtin-agents] Some council members were skipped during registration", { skippedMembers })
}
result["athena"] = {
...result["athena"],
prompt: (result["athena"].prompt ?? "") + councilTaskInstructions,
}
} else {
result["athena"] = applyMissingCouncilGuard(result["athena"], skippedMembers)
}
} else if (councilConfig?.members && councilConfig.members.length >= 2 && !result["athena"]) {
log("[builtin-agents] Skipping council member registration — Athena is disabled")
} else if (result["athena"]) {
result["athena"] = applyMissingCouncilGuard(result["athena"])
}
return result
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import { applyMissingCouncilGuard } from "./athena-council-guard"
import type { AgentConfig } from "@opencode-ai/sdk"
describe("applyMissingCouncilGuard", () => {
describe("#given an athena agent config with no skipped members", () => {
test("#when applying the guard #then replaces prompt with missing council message", () => {
//#given
const athenaConfig: AgentConfig = {
model: "anthropic/claude-opus-4-6",
prompt: "original orchestration prompt",
temperature: 0.1,
}
//#when
const result = applyMissingCouncilGuard(athenaConfig)
//#then
expect(result.prompt).not.toBe("original orchestration prompt")
expect(result.prompt).toContain("No Council Members Configured")
})
})
describe("#given an athena agent config with skipped members", () => {
test("#when applying the guard #then includes skipped member names and reasons", () => {
//#given
const athenaConfig: AgentConfig = {
model: "anthropic/claude-opus-4-6",
prompt: "original orchestration prompt",
}
const skippedMembers = [
{ name: "GPT", reason: "invalid model format" },
{ name: "Gemini", reason: "duplicate name" },
]
//#when
const result = applyMissingCouncilGuard(athenaConfig, skippedMembers)
//#then
expect(result.prompt).toContain("GPT")
expect(result.prompt).toContain("invalid model format")
expect(result.prompt).toContain("Gemini")
expect(result.prompt).toContain("duplicate name")
expect(result.prompt).toContain("Why Council Failed")
})
})
describe("#given an athena agent config", () => {
test("#when applying the guard #then preserves model and other agent properties", () => {
//#given
const athenaConfig: AgentConfig = {
model: "anthropic/claude-opus-4-6",
prompt: "original prompt",
temperature: 0.1,
}
//#when
const result = applyMissingCouncilGuard(athenaConfig)
//#then
expect(result.model).toBe("anthropic/claude-opus-4-6")
expect(result.temperature).toBe(0.1)
})
test("#when applying the guard #then prompt includes configuration instructions", () => {
//#given
const athenaConfig: AgentConfig = {
model: "anthropic/claude-opus-4-6",
prompt: "original prompt",
}
//#when
const result = applyMissingCouncilGuard(athenaConfig)
//#then
expect(result.prompt).toContain("oh-my-opencode")
expect(result.prompt).toContain("council")
expect(result.prompt).toContain("members")
})
test("#when applying the guard with empty skipped members array #then does not include why council failed section", () => {
//#given
const athenaConfig: AgentConfig = {
model: "anthropic/claude-opus-4-6",
prompt: "original prompt",
}
//#when
const result = applyMissingCouncilGuard(athenaConfig, [])
//#then
expect(result.prompt).not.toContain("Why Council Failed")
})
})
})

View File

@@ -0,0 +1,62 @@
import type { AgentConfig } from "@opencode-ai/sdk"
const MISSING_COUNCIL_PROMPT_HEADER = `
## CRITICAL: No Council Members Configured
**STOP. Do NOT attempt to launch any council members or use the task tool.**
You have no council members registered. This means the Athena council config is either missing or invalid in the oh-my-opencode configuration.
**Your ONLY action**: Inform the user with this exact message:
---
**Athena council is not configured.** To use Athena, add council members to your oh-my-opencode config:
**Config file**: \`.opencode/oh-my-opencode.jsonc\` (project) or \`~/.config/opencode/oh-my-opencode.jsonc\` (user)
\`\`\`jsonc
{
"agents": {
"athena": {
"council": {
"members": [
{ "model": "anthropic/claude-opus-4-6", "name": "Claude" },
{ "model": "openai/gpt-5.2", "name": "GPT" },
{ "model": "google/gemini-3-pro", "name": "Gemini" }
]
}
}
}
}
\`\`\`
Each member requires \`model\` (\`"provider/model-id"\` format) and \`name\` (display name). Minimum 2 members required. Optional fields: \`variant\`, \`temperature\`.`
const MISSING_COUNCIL_PROMPT_FOOTER = `
---
After informing the user, **end your turn**. Do NOT try to work around this by using generic agents, the council-member agent, or any other fallback.`
/**
* Replaces Athena's orchestration prompt with a guard that tells the user to configure council members.
* The original prompt is discarded to avoid contradictory instructions.
* Used when Athena is registered but no valid council config exists.
*/
export function applyMissingCouncilGuard(
athenaConfig: AgentConfig,
skippedMembers?: Array<{ name: string; reason: string }>,
): AgentConfig {
let prompt = MISSING_COUNCIL_PROMPT_HEADER
if (skippedMembers && skippedMembers.length > 0) {
const skipDetails = skippedMembers.map((m) => `- **${m.name}**: ${m.reason}`).join("\n")
prompt += `\n\n### Why Council Failed\n\nThe following members were skipped:\n${skipDetails}`
}
prompt += MISSING_COUNCIL_PROMPT_FOOTER
return { ...athenaConfig, prompt }
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { registerCouncilMemberAgents } from "./council-member-agents"
describe("council-member-agents", () => {
test("skips case-insensitive duplicate names and disables council when below minimum", () => {
//#given
const config = {
members: [
{ model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "anthropic/claude-opus-4-6", name: "gpt" },
],
}
//#when
const result = registerCouncilMemberAgents(config)
//#then
expect(result.registeredKeys).toHaveLength(0)
expect(result.agents).toEqual({})
})
test("registers different models without error", () => {
//#given
const config = {
members: [
{ model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "anthropic/claude-opus-4-6", name: "Claude" },
],
}
//#when
const result = registerCouncilMemberAgents(config)
//#then
expect(result.registeredKeys).toHaveLength(2)
expect(result.registeredKeys).toContain("Council: GPT")
expect(result.registeredKeys).toContain("Council: Claude")
})
test("allows same model with different names", () => {
//#given
const config = {
members: [
{ model: "openai/gpt-5.3-codex", name: "GPT Codex" },
{ model: "openai/gpt-5.3-codex", name: "Codex GPT" },
],
}
//#when
const result = registerCouncilMemberAgents(config)
//#then
expect(result.registeredKeys).toHaveLength(2)
expect(result.agents).toHaveProperty("Council: GPT Codex")
expect(result.agents).toHaveProperty("Council: Codex GPT")
})
test("returns empty when valid members below 2", () => {
//#given - one valid model, one invalid (no slash separator)
const config = {
members: [
{ model: "openai/gpt-5.3-codex", name: "GPT" },
{ model: "invalid-no-slash", name: "Invalid" },
],
}
//#when
const result = registerCouncilMemberAgents(config)
//#then
expect(result.registeredKeys).toHaveLength(0)
expect(result.agents).toEqual({})
})
})

View File

@@ -0,0 +1,85 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { CouncilConfig, CouncilMemberConfig } from "../../config/schema/athena"
import { createCouncilMemberAgent } from "../athena"
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
import { log } from "../../shared/logger"
/** Prefix used for all dynamically-registered council member agent keys. */
export const COUNCIL_MEMBER_KEY_PREFIX = "Council: "
/**
* Generates a stable agent registration key from a council member's name.
*/
function getCouncilMemberAgentKey(member: CouncilMemberConfig): string {
return `${COUNCIL_MEMBER_KEY_PREFIX}${member.name}`
}
/**
* Registers council members as individual subagent entries.
* Each member becomes a separate agent callable via task(subagent_type="Council: <name>").
* Returns a record of agent keys to configs and the list of registered keys.
*/
type SkippedMember = { name: string; reason: string }
export function registerCouncilMemberAgents(
councilConfig: CouncilConfig
): { agents: Record<string, AgentConfig>; registeredKeys: string[]; skippedMembers: SkippedMember[] } {
const agents: Record<string, AgentConfig> = {}
const registeredKeys: string[] = []
const skippedMembers: SkippedMember[] = []
const registeredNamesLower = new Set<string>()
for (const member of councilConfig.members) {
const parsed = parseModelString(member.model)
if (!parsed) {
skippedMembers.push({
name: member.name,
reason: `Invalid model format: '${member.model}' (expected 'provider/model-id')`,
})
log("[council-member-agents] Skipping member with invalid model", { model: member.model })
continue
}
const key = getCouncilMemberAgentKey(member)
const nameLower = member.name.toLowerCase()
if (registeredNamesLower.has(nameLower)) {
skippedMembers.push({
name: member.name,
reason: `Duplicate name: '${member.name}' already registered (case-insensitive match)`,
})
log("[council-member-agents] Skipping duplicate council member name", {
name: member.name,
model: member.model,
})
continue
}
const config = createCouncilMemberAgent(member.model)
const description = `Council member: ${member.name} (${parsed.providerID}/${parsed.modelID}). Independent read-only code analyst for Athena council. (OhMyOpenCode)`
agents[key] = {
...config,
description,
model: member.model,
...(member.variant ? { variant: member.variant } : {}),
...(member.temperature !== undefined ? { temperature: member.temperature } : {}),
}
registeredKeys.push(key)
registeredNamesLower.add(nameLower)
log("[council-member-agents] Registered council member agent", {
key,
model: member.model,
variant: member.variant,
})
}
if (registeredKeys.length < 2) {
log("[council-member-agents] Fewer than 2 valid council members after model parsing — disabling council mode")
return { agents: {}, registeredKeys: [], skippedMembers }
}
return { agents, registeredKeys, skippedMembers }
}

View File

@@ -10,7 +10,7 @@ import { applyEnvironmentContext } from "./environment-context"
import { applyModelResolution } from "./model-resolution"
export function collectPendingBuiltinAgents(input: {
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
agentSources: Partial<Record<BuiltinAgentName, import("../agent-builder").AgentSource>>
agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>>
disabledAgents: string[]
agentOverrides: AgentOverrides

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,17 +2,11 @@ import { describe, test, expect } from "bun:test";
import { isGptModel, isGeminiModel } from "./types";
describe("isGptModel", () => {
test("standard openai provider gpt models", () => {
test("standard openai provider models", () => {
expect(isGptModel("openai/gpt-5.2")).toBe(true);
expect(isGptModel("openai/gpt-4o")).toBe(true);
});
test("o-series models are not gpt by name", () => {
expect(isGptModel("openai/o1")).toBe(false);
expect(isGptModel("openai/o3-mini")).toBe(false);
expect(isGptModel("litellm/o1")).toBe(false);
expect(isGptModel("litellm/o3-mini")).toBe(false);
expect(isGptModel("litellm/o4-mini")).toBe(false);
expect(isGptModel("openai/o1")).toBe(true);
expect(isGptModel("openai/o3-mini")).toBe(true);
});
test("github copilot gpt models", () => {
@@ -23,6 +17,9 @@ describe("isGptModel", () => {
test("litellm proxied gpt models", () => {
expect(isGptModel("litellm/gpt-5.2")).toBe(true);
expect(isGptModel("litellm/gpt-4o")).toBe(true);
expect(isGptModel("litellm/o1")).toBe(true);
expect(isGptModel("litellm/o3-mini")).toBe(true);
expect(isGptModel("litellm/o4-mini")).toBe(true);
});
test("other proxied gpt models", () => {
@@ -30,11 +27,6 @@ describe("isGptModel", () => {
expect(isGptModel("custom-provider/gpt-5.2")).toBe(true);
});
test("venice provider gpt models", () => {
expect(isGptModel("venice/gpt-5.2")).toBe(true);
expect(isGptModel("venice/gpt-4o")).toBe(true);
});
test("gpt4 prefix without hyphen (legacy naming)", () => {
expect(isGptModel("litellm/gpt4o")).toBe(true);
expect(isGptModel("ollama/gpt4")).toBe(true);

View File

@@ -70,9 +70,14 @@ function extractModelName(model: string): string {
return model.includes("/") ? model.split("/").pop() ?? model : model
}
const GPT_MODEL_PREFIXES = ["gpt-", "gpt4", "o1", "o3", "o4"]
export function isGptModel(model: string): boolean {
if (model.startsWith("openai/") || model.startsWith("github-copilot/gpt-"))
return true
const modelName = extractModelName(model).toLowerCase()
return modelName.includes("gpt")
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
}
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
@@ -98,6 +103,8 @@ export type BuiltinAgentName =
| "metis"
| "momus"
| "atlas"
| "athena"
| "council-member"
export type OverridableAgentName =
| "build"

View File

@@ -147,6 +147,69 @@ describe("createBuiltinAgents with model overrides", () => {
}
})
test("Athena uses uiSelectedModel when provided", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
)
const uiSelectedModel = "openai/gpt-5.2"
try {
// #when
const agents = await createBuiltinAgents(
[],
{},
undefined,
TEST_DEFAULT_MODEL,
undefined,
undefined,
[],
undefined,
undefined,
uiSelectedModel
)
// #then
expect(agents.athena).toBeDefined()
expect(agents.athena.model).toBe("openai/gpt-5.2")
} finally {
fetchSpy.mockRestore()
}
})
test("user config model takes priority over uiSelectedModel for athena", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
)
const uiSelectedModel = "openai/gpt-5.2"
const overrides = {
athena: { model: "anthropic/claude-opus-4-6" },
}
try {
// #when
const agents = await createBuiltinAgents(
[],
overrides,
undefined,
TEST_DEFAULT_MODEL,
undefined,
undefined,
[],
undefined,
undefined,
uiSelectedModel
)
// #then
expect(agents.athena).toBeDefined()
expect(agents.athena.model).toBe("anthropic/claude-opus-4-6")
} finally {
fetchSpy.mockRestore()
}
})
test("Sisyphus is created on first run when no availableModels or cache exist", async () => {
// #given
const systemDefaultModel = "anthropic/claude-opus-4-6"
@@ -428,7 +491,8 @@ describe("createBuiltinAgents with model overrides", () => {
)
// #then
const matches = (agents.sisyphus?.prompt ?? "").match(/Custom agent: researcher/gi) ?? []
expect(agents.sisyphus.prompt).toBeDefined()
const matches = (agents.sisyphus.prompt ?? "").match(/Custom agent: researcher/gi) ?? []
expect(matches.length).toBe(1)
} finally {
fetchSpy.mockRestore()
@@ -589,22 +653,20 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
}
})
test("hephaestus IS created when github-copilot is connected with a GPT model", async () => {
// #given - github-copilot provider has gpt-5.3-codex available
test("hephaestus is created when github-copilot provider is connected", async () => {
// #given - github-copilot provider has models available
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["github-copilot/gpt-5.3-codex"])
)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
try {
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
// #then - github-copilot is now a valid provider for hephaestus
// #then
expect(agents.hephaestus).toBeDefined()
} finally {
fetchSpy.mockRestore()
cacheSpy.mockRestore()
}
})
@@ -691,6 +753,7 @@ describe("Hephaestus environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
disableFlag
)
}
@@ -750,6 +813,7 @@ describe("Sisyphus and Librarian environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
disableFlag
)
}
@@ -809,6 +873,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
false
)
@@ -825,6 +890,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
true
)

View File

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

View File

@@ -446,6 +446,24 @@ exports[`generateModelConfig all native providers uses preferred models from fal
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-opus-4-6",
"name": "Claude Opus 4.6",
},
{
"model": "openai/gpt-5.3-codex",
"name": "GPT 5.3 Codex",
},
{
"model": "google/gemini-3-pro-preview",
"name": "Gemini Pro 3",
},
],
},
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@@ -520,6 +538,24 @@ exports[`generateModelConfig all native providers uses preferred models with isM
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-opus-4-6",
"name": "Claude Opus 4.6",
},
{
"model": "openai/gpt-5.3-codex",
"name": "GPT 5.3 Codex",
},
{
"model": "google/gemini-3-pro-preview",
"name": "Gemini Pro 3",
},
],
},
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@@ -750,6 +786,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"explore": {
"model": "github-copilot/gpt-5-mini",
},
"hephaestus": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"librarian": {
"model": "github-copilot/claude-sonnet-4.5",
},
@@ -782,12 +822,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"deep": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
"ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
"model": "github-copilot/gpt-5.3-codex",
"variant": "xhigh",
},
"unspecified-high": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -816,6 +860,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"explore": {
"model": "github-copilot/gpt-5-mini",
},
"hephaestus": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"librarian": {
"model": "github-copilot/claude-sonnet-4.5",
},
@@ -848,12 +896,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"deep": {
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
"ultrabrain": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
"model": "github-copilot/gpt-5.3-codex",
"variant": "xhigh",
},
"unspecified-high": {
"model": "github-copilot/claude-opus-4.6",
@@ -1196,6 +1248,20 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-opus-4-6",
"name": "Claude Opus 4.6",
},
{
"model": "google/gemini-3-pro-preview",
"name": "Gemini Pro 3",
},
],
},
},
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
},
@@ -1269,7 +1335,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "opencode/claude-haiku-4-5",
},
"hephaestus": {
"model": "opencode/gpt-5.3-codex",
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"librarian": {
@@ -1305,14 +1371,14 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"variant": "high",
},
"deep": {
"model": "opencode/gpt-5.3-codex",
"model": "github-copilot/gpt-5.3-codex",
"variant": "medium",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
"ultrabrain": {
"model": "opencode/gpt-5.3-codex",
"model": "github-copilot/gpt-5.3-codex",
"variant": "xhigh",
},
"unspecified-high": {
@@ -1336,6 +1402,24 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-opus-4-6",
"name": "Claude Opus 4.6",
},
{
"model": "openai/gpt-5.3-codex",
"name": "GPT 5.3 Codex",
},
{
"model": "google/gemini-3-pro-preview",
"name": "Gemini Pro 3",
},
],
},
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},
@@ -1410,6 +1494,24 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"council": {
"members": [
{
"model": "anthropic/claude-opus-4-6",
"name": "Claude Opus 4.6",
},
{
"model": "openai/gpt-5.3-codex",
"name": "GPT 5.3 Codex",
},
{
"model": "google/gemini-3-pro-preview",
"name": "Gemini Pro 3",
},
],
},
},
"atlas": {
"model": "opencode/kimi-k2.5-free",
},

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,139 @@
import { describe, test, expect } from "bun:test"
import { generateCouncilMembers } from "./council-members-generator"
import type { ProviderAvailability } from "./model-fallback-types"
function makeAvail(overrides: {
native?: Partial<ProviderAvailability["native"]>
opencodeZen?: boolean
copilot?: boolean
zai?: boolean
kimiForCoding?: boolean
isMaxPlan?: boolean
}): ProviderAvailability {
return {
native: {
claude: false,
openai: false,
gemini: false,
...(overrides.native ?? {}),
},
opencodeZen: overrides.opencodeZen ?? false,
copilot: overrides.copilot ?? false,
zai: overrides.zai ?? false,
kimiForCoding: overrides.kimiForCoding ?? false,
isMaxPlan: overrides.isMaxPlan ?? false,
}
}
describe("generateCouncilMembers", () => {
//#given all three native providers
//#when generating council members
//#then returns 3 members (one per provider)
test("returns 3 members when claude + openai + gemini available", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, openai: true, gemini: true },
}))
expect(members).toHaveLength(3)
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
expect(members.some(m => m.model.startsWith("openai/"))).toBe(true)
expect(members.some(m => m.model.startsWith("google/"))).toBe(true)
expect(members.every(m => m.name)).toBe(true)
})
//#given claude + openai only
//#when generating council members
//#then returns 2 members
test("returns 2 members when claude + openai available", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, openai: true },
}))
expect(members).toHaveLength(2)
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
expect(members.some(m => m.model.startsWith("openai/"))).toBe(true)
})
//#given claude + gemini only
//#when generating council members
//#then returns 2 members
test("returns 2 members when claude + gemini available", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, gemini: true },
}))
expect(members).toHaveLength(2)
})
//#given openai + gemini only
//#when generating council members
//#then returns 2 members
test("returns 2 members when openai + gemini available", () => {
const members = generateCouncilMembers(makeAvail({
native: { openai: true, gemini: true },
}))
expect(members).toHaveLength(2)
})
//#given only one native provider
//#when kimi is also available
//#then returns 2 members (native + kimi)
test("uses kimi as second member when only one native provider", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true },
kimiForCoding: true,
}))
expect(members).toHaveLength(2)
expect(members.some(m => m.model.startsWith("anthropic/"))).toBe(true)
expect(members.some(m => m.model.startsWith("kimi-for-coding/"))).toBe(true)
})
//#given all 4 candidates available
//#when generating council members
//#then returns 4 members
test("returns 4 members when all candidates available", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, openai: true, gemini: true },
kimiForCoding: true,
}))
expect(members).toHaveLength(4)
})
//#given no providers at all
//#when generating council members
//#then returns empty array (can't meet minimum 2)
test("returns empty when no providers available", () => {
const members = generateCouncilMembers(makeAvail({}))
expect(members).toHaveLength(0)
})
//#given only one provider, no fallbacks
//#when generating council members
//#then returns empty (need at least 2 distinct models)
test("returns empty when only one provider and no fallbacks", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true },
}))
expect(members).toHaveLength(0)
})
//#given all members have names
//#when generating council
//#then each member has a human-readable name
test("all members have name field", () => {
const members = generateCouncilMembers(makeAvail({
native: { claude: true, openai: true, gemini: true },
}))
for (const m of members) {
expect(m.name).toBeDefined()
expect(typeof m.name).toBe("string")
expect(m.name!.length).toBeGreaterThan(0)
}
})
})

View File

@@ -0,0 +1,49 @@
import type { ProviderAvailability } from "./model-fallback-types"
export interface CouncilMember {
model: string
name: string
}
const COUNCIL_CANDIDATES: Array<{
provider: (avail: ProviderAvailability) => boolean
model: string
name: string
}> = [
{
provider: (a) => a.native.claude,
model: "anthropic/claude-opus-4-6",
name: "Claude Opus 4.6",
},
{
provider: (a) => a.native.openai,
model: "openai/gpt-5.3-codex",
name: "GPT 5.3 Codex",
},
{
provider: (a) => a.native.gemini,
model: "google/gemini-3-pro-preview",
name: "Gemini Pro 3",
},
{
provider: (a) => a.kimiForCoding,
model: "kimi-for-coding/kimi-k2.5",
name: "Kimi 2.5",
}
]
export function generateCouncilMembers(avail: ProviderAvailability): CouncilMember[] {
const members: CouncilMember[] = []
for (const candidate of COUNCIL_CANDIDATES) {
if (candidate.provider(avail)) {
members.push({ model: candidate.model, name: candidate.name })
}
}
if (members.length < 2) {
return []
}
return members
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import {
isRequiredProviderAvailable,
resolveModelFromChain,
} from "./fallback-chain-resolution"
import { generateCouncilMembers } from "./council-members-generator"
export type { GeneratedOmoConfig } from "./model-fallback-types"
@@ -122,6 +123,12 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
}
}
const councilMembers = generateCouncilMembers(avail)
if (councilMembers.length >= 2) {
const athenaAgent = agents.athena ?? {}
agents.athena = { ...athenaAgent, council: { members: councilMembers } } as AgentConfig
}
return {
$schema: SCHEMA_URL,
agents,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -532,6 +532,76 @@ describe("Sisyphus-Junior agent override", () => {
})
})
describe("Athena agent override", () => {
test("accepts athena override with council members and standard override fields", () => {
// given
const config = {
agents: {
athena: {
model: "openai/gpt-5.3-codex",
temperature: 0.2,
prompt_append: "Use consensus-first synthesis.",
council: {
members: [
{ model: "openai/gpt-5.3-codex", temperature: 0.2, name: "Architect" },
{ model: "anthropic/claude-sonnet-4-5", temperature: 0.3, name: "Reviewer" },
{ model: "xai/grok-code-fast-1", temperature: 0.1, name: "Optimizer" },
],
},
},
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agents?.athena?.model).toBe("openai/gpt-5.3-codex")
expect(result.data.agents?.athena?.temperature).toBe(0.2)
expect(result.data.agents?.athena?.prompt_append).toBe("Use consensus-first synthesis.")
expect(result.data.agents?.athena?.council?.members).toHaveLength(3)
}
})
test("rejects athena override with fewer than two council members", () => {
// given
const config = {
agents: {
athena: {
council: {
members: [{ model: "openai/gpt-5.3-codex", name: "GPT" }],
},
},
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(false)
})
test("accepts athena override without council (temperature-only override)", () => {
// given
const config = {
agents: {
athena: {
model: "openai/gpt-5.3-codex",
},
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(true)
})
})
describe("BrowserAutomationProviderSchema", () => {
test("accepts 'playwright' as valid provider", () => {
// given

View File

@@ -1,5 +1,6 @@
export * from "./schema/agent-names"
export * from "./schema/agent-overrides"
export * from "./schema/athena"
export * from "./schema/babysitting"
export * from "./schema/background-task"
export * from "./schema/browser-automation"

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { BuiltinAgentNameSchema, OverridableAgentNameSchema } from "./agent-names"
describe("agent name schemas", () => {
test("BuiltinAgentNameSchema accepts athena", () => {
//#given
const candidate = "athena"
//#when
const result = BuiltinAgentNameSchema.safeParse(candidate)
//#then
expect(result.success).toBe(true)
})
test("OverridableAgentNameSchema accepts athena", () => {
//#given
const candidate = "athena"
//#when
const result = OverridableAgentNameSchema.safeParse(candidate)
//#then
expect(result.success).toBe(true)
})
})

View File

@@ -11,6 +11,8 @@ export const BuiltinAgentNameSchema = z.enum([
"metis",
"momus",
"atlas",
"athena",
"council-member",
])
export const BuiltinSkillNameSchema = z.enum([
@@ -36,6 +38,8 @@ export const OverridableAgentNameSchema = z.enum([
"explore",
"multimodal-looker",
"atlas",
"athena",
"council-member",
])
export const AgentNameSchema = BuiltinAgentNameSchema

View File

@@ -1,5 +1,6 @@
import { z } from "zod"
import { FallbackModelsSchema } from "./fallback-models"
import { AthenaConfigSchema } from "./athena"
import { AgentPermissionSchema } from "./internal/permission"
export const AgentOverrideConfigSchema = z.object({
@@ -55,13 +56,15 @@ export const AgentOverrideConfigSchema = z.object({
.optional(),
})
export const AthenaOverrideConfigSchema = AgentOverrideConfigSchema.extend({
council: AthenaConfigSchema.shape.council.optional(),
})
export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
sisyphus: AgentOverrideConfigSchema.optional(),
hephaestus: AgentOverrideConfigSchema.extend({
allow_non_gpt_model: z.boolean().optional(),
}).optional(),
hephaestus: AgentOverrideConfigSchema.optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(),
@@ -72,6 +75,8 @@ export const AgentOverridesSchema = z.object({
explore: AgentOverrideConfigSchema.optional(),
"multimodal-looker": AgentOverrideConfigSchema.optional(),
atlas: AgentOverrideConfigSchema.optional(),
"council-member": AgentOverrideConfigSchema.optional(),
athena: AthenaOverrideConfigSchema.optional(),
})
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>

View File

@@ -0,0 +1,431 @@
import { describe, expect, test } from "bun:test"
import { z } from "zod"
import { AthenaConfigSchema, CouncilConfigSchema, CouncilMemberSchema } from "./athena"
describe("CouncilMemberSchema", () => {
test("accepts member config with model and name", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "member-a" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("accepts member config with all optional fields", () => {
//#given
const config = {
model: "openai/gpt-5.3-codex",
variant: "high",
name: "analyst-a",
temperature: 0.3,
}
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("rejects member config missing model", () => {
//#given
const config = { name: "no-model" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects model string without provider/model separator", () => {
//#given
const config = { model: "invalid-model", name: "test-member" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects model string with empty provider", () => {
//#given
const config = { model: "/gpt-5.3-codex", name: "test-member" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects model string with empty model ID", () => {
//#given
const config = { model: "openai/", name: "test-member" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects empty model string", () => {
//#given
const config = { model: "" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("z.infer produces expected type shape", () => {
//#given
type InferredCouncilMember = z.infer<typeof CouncilMemberSchema>
const member: InferredCouncilMember = {
model: "anthropic/claude-opus-4-6",
variant: "medium",
name: "oracle",
}
//#when
const model = member.model
//#then
expect(model).toBe("anthropic/claude-opus-4-6")
})
test("optional fields are optional without runtime defaults", () => {
//#given
const config = { model: "xai/grok-code-fast-1", name: "member-x" }
//#when
const parsed = CouncilMemberSchema.parse(config)
//#then
expect(parsed.variant).toBeUndefined()
expect(parsed.temperature).toBeUndefined()
})
test("rejects member config missing name", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects member config with empty name", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("accepts member config with temperature", () => {
//#given
const config = { model: "openai/gpt-5.3-codex", name: "member-a", temperature: 0.5 }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.temperature).toBe(0.5)
}
})
test("rejects temperature below 0", () => {
//#given
const config = { model: "openai/gpt-5.3-codex", name: "test-member", temperature: -0.1 }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects temperature above 2", () => {
//#given
const config = { model: "openai/gpt-5.3-codex", name: "test-member", temperature: 2.1 }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects member config with unknown fields", () => {
//#given
const config = { model: "openai/gpt-5.3-codex", name: "test-member", unknownField: true }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("trims leading and trailing whitespace from name", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: " member-a " }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.name).toBe("member-a")
}
})
test("accepts name with spaces like 'Claude Opus 4'", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "Claude Opus 4" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("accepts name with dots like 'Claude 4.6'", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "Claude 4.6" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("accepts name with hyphens like 'my-model-1'", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "my-model-1" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("rejects name with special characters like '@'", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "member@1" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects name with exclamation mark", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: "member!" }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects name starting with a space after trim", () => {
//#given
const config = { model: "anthropic/claude-opus-4-6", name: " " }
//#when
const result = CouncilMemberSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
})
describe("CouncilConfigSchema", () => {
test("accepts council with 2 members", () => {
//#given
const config = {
members: [
{ model: "anthropic/claude-opus-4-6", name: "member-a" },
{ model: "openai/gpt-5.3-codex", name: "member-b" },
],
}
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("accepts council with 3 members and optional fields", () => {
//#given
const config = {
members: [
{ model: "anthropic/claude-opus-4-6", name: "a" },
{ model: "openai/gpt-5.3-codex", name: "b", variant: "high" },
{ model: "xai/grok-code-fast-1", name: "c", variant: "low" },
],
}
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("rejects council with 0 members", () => {
//#given
const config = { members: [] }
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects council with 1 member", () => {
//#given
const config = { members: [{ model: "anthropic/claude-opus-4-6", name: "member-a" }] }
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects council missing members field", () => {
//#given
const config = {}
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("accepts council with duplicate member names for graceful runtime handling", () => {
//#given - duplicate detection is handled at runtime by registerCouncilMemberAgents,
// not at schema level, to allow graceful fallback instead of hard parse failure
const config = {
members: [
{ model: "anthropic/claude-opus-4-6", name: "analyst" },
{ model: "openai/gpt-5.3-codex", name: "analyst" },
],
}
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("accepts council with case-insensitive duplicate names for graceful runtime handling", () => {
//#given - case-insensitive dedup is handled at runtime by registerCouncilMemberAgents
const config = {
members: [
{ model: "anthropic/claude-opus-4-6", name: "Claude" },
{ model: "openai/gpt-5.3-codex", name: "claude" },
],
}
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("accepts council with unique member names", () => {
//#given
const config = {
members: [
{ model: "anthropic/claude-opus-4-6", name: "analyst-a" },
{ model: "openai/gpt-5.3-codex", name: "analyst-b" },
],
}
//#when
const result = CouncilConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
})
describe("AthenaConfigSchema", () => {
test("accepts Athena config with council", () => {
//#given
const config = {
council: {
members: [
{ model: "openai/gpt-5.3-codex", name: "member-a" },
{ model: "xai/grok-code-fast-1", name: "member-b" },
],
},
}
//#when
const result = AthenaConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
test("rejects Athena config without council", () => {
//#given
const config = {}
//#when
const result = AthenaConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("rejects Athena config with unknown model field", () => {
//#given
const config = {
model: "anthropic/claude-opus-4-6",
council: {
members: [
{ model: "openai/gpt-5.3-codex", name: "member-a" },
{ model: "xai/grok-code-fast-1", name: "member-b" },
],
},
}
//#when
const result = AthenaConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
})

View File

@@ -0,0 +1,31 @@
import { z } from "zod"
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
/** Validates model string format: "provider/model-id" (e.g., "openai/gpt-5.3-codex"). */
const ModelStringSchema = z
.string()
.min(1)
.refine(
(model) => parseModelString(model) !== undefined,
{ message: 'Model must be in "provider/model-id" format (e.g., "openai/gpt-5.3-codex")' }
)
export const CouncilMemberSchema = z.object({
model: ModelStringSchema,
variant: z.string().optional(),
name: z.string().min(1).trim().regex(/^[a-zA-Z0-9][a-zA-Z0-9 .\-]*$/, {
message: "Council member name must contain only letters, numbers, spaces, hyphens, and dots",
}),
temperature: z.number().min(0).max(2).optional(),
}).strict()
export const CouncilConfigSchema = z.object({
members: z.array(CouncilMemberSchema).min(2),
}).strict()
export type CouncilMemberConfig = z.infer<typeof CouncilMemberSchema>
export type CouncilConfig = z.infer<typeof CouncilConfigSchema>
export const AthenaConfigSchema = z.object({
council: CouncilConfigSchema,
}).strict()

View File

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

View File

@@ -49,6 +49,7 @@ export const HookNameSchema = z.enum([
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",
"agent-switch",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

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

View File

@@ -0,0 +1,226 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, test } from "bun:test"
import { _resetForTesting, getPendingSwitch, setPendingSwitch } from "./state"
import {
_resetApplierForTesting,
applyPendingSwitch,
clearPendingSwitchRuntime,
} from "./applier"
import { schedulePendingSwitchApply } from "./scheduler"
describe("agent-switch applier", () => {
beforeEach(() => {
_resetForTesting()
_resetApplierForTesting()
})
test("scheduled apply works without idle event", async () => {
const calls: string[] = []
let switched = false
const client = {
session: {
promptAsync: async (input: { body: { agent: string } }) => {
calls.push(input.body.agent)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
},
}
setPendingSwitch("ses-1", "prometheus", "create plan")
schedulePendingSwitchApply({
sessionID: "ses-1",
client: client as any,
})
await new Promise((resolve) => setTimeout(resolve, 300))
expect(calls).toEqual(["Prometheus (Plan Builder)"])
expect(getPendingSwitch("ses-1")).toBeUndefined()
})
test("normalizes pending agent to canonical prompt display name", async () => {
const calls: string[] = []
let switched = false
const client = {
session: {
promptAsync: async (input: { body: { agent: string } }) => {
calls.push(input.body.agent)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
},
}
setPendingSwitch("ses-2", "Prometheus (Plan Builder)", "create plan")
await applyPendingSwitch({
sessionID: "ses-2",
client: client as any,
source: "idle",
})
expect(calls).toEqual(["Prometheus (Plan Builder)"])
expect(getPendingSwitch("ses-2")).toBeUndefined()
})
test("retries transient failures and eventually clears pending switch", async () => {
let attempts = 0
let switched = false
const client = {
session: {
promptAsync: async () => {
attempts += 1
if (attempts < 3) {
throw new Error("temporary failure")
}
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
},
}
setPendingSwitch("ses-3", "atlas", "fix this")
await applyPendingSwitch({
sessionID: "ses-3",
client: client as any,
source: "idle",
})
await new Promise((resolve) => setTimeout(resolve, 800))
expect(attempts).toBe(3)
expect(getPendingSwitch("ses-3")).toBeUndefined()
})
test("waits for session idle before applying switch", async () => {
let statusChecks = 0
let promptCalls = 0
let switched = false
const client = {
session: {
status: async () => {
statusChecks += 1
return {
"ses-5": { type: statusChecks < 3 ? "running" : "idle" },
}
},
promptAsync: async () => {
promptCalls += 1
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
},
}
setPendingSwitch("ses-5", "atlas", "fix now")
await applyPendingSwitch({
sessionID: "ses-5",
client: client as any,
source: "idle",
})
expect(statusChecks).toBeGreaterThanOrEqual(3)
expect(promptCalls).toBe(1)
expect(getPendingSwitch("ses-5")).toBeUndefined()
})
test("clearPendingSwitchRuntime cancels pending retries", async () => {
let attempts = 0
const client = {
session: {
promptAsync: async () => {
attempts += 1
throw new Error("always failing")
},
messages: async () => ({ data: [] }),
},
}
setPendingSwitch("ses-4", "atlas", "fix this")
await applyPendingSwitch({
sessionID: "ses-4",
client: client as any,
source: "idle",
})
clearPendingSwitchRuntime("ses-4")
const attemptsAfterClear = attempts
await new Promise((resolve) => setTimeout(resolve, 300))
expect(attempts).toBe(attemptsAfterClear)
expect(getPendingSwitch("ses-4")).toBeUndefined()
})
test("syncs CLI TUI agent selection for athena-to-atlas handoff", async () => {
const originalClientEnv = process.env["OPENCODE_CLIENT"]
process.env["OPENCODE_CLIENT"] = "cli"
try {
const promptCalls: string[] = []
const tuiCommands: string[] = []
let switched = false
const client = {
session: {
promptAsync: async (input: { body: { agent: string } }) => {
promptCalls.push(input.body.agent)
switched = true
},
messages: async () => switched
? ({
data: [
{ info: { role: "user", agent: "Athena (Council)" } },
{ info: { role: "user", agent: "Atlas (Plan Executor)" } },
],
})
: ({
data: [{ info: { role: "user", agent: "Athena (Council)" } }],
}),
},
app: {
agents: async () => ({
data: [
{ name: "Sisyphus (Ultraworker)", mode: "primary" },
{ name: "Hephaestus (Deep Agent)", mode: "primary" },
{ name: "Prometheus (Plan Builder)", mode: "primary" },
{ name: "Atlas (Plan Executor)", mode: "primary" },
{ name: "Athena (Council)", mode: "primary" },
],
}),
},
tui: {
publish: async (input: { body: { properties: { command: string } } }) => {
tuiCommands.push(input.body.properties.command)
},
},
}
setPendingSwitch("ses-6", "atlas", "fix now")
await applyPendingSwitch({
sessionID: "ses-6",
client: client as any,
source: "message-updated",
})
expect(promptCalls).toEqual(["Atlas (Plan Executor)"])
expect(tuiCommands).toEqual(["agent.cycle.reverse"])
expect(getPendingSwitch("ses-6")).toBeUndefined()
} finally {
if (originalClientEnv === undefined) {
delete process.env["OPENCODE_CLIENT"]
} else {
process.env["OPENCODE_CLIENT"] = originalClientEnv
}
}
})
})

View File

@@ -0,0 +1,211 @@
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { clearPendingSwitch, getPendingSwitch } from "./state"
import { waitForSessionIdle } from "./session-status"
import { fetchMessages, shouldClearAsAlreadyApplied, verifySwitchObserved } from "./apply-verification"
import { getLatestUserAgent } from "./message-inspection"
import { syncCliTuiAgentSelectionAfterSwitch } from "./tui-agent-sync"
import {
clearInFlight,
clearRetryState,
isApplyInFlight,
markApplyInFlight,
resetRetryStateForTesting,
scheduleRetry,
} from "./retry-state"
type SessionClient = {
session: {
prompt?: (input: {
path: { id: string }
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise<unknown>
promptAsync: (input: {
path: { id: string }
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise<unknown>
messages: (input: { path: { id: string } }) => Promise<unknown>
status?: () => Promise<unknown>
}
app?: {
agents?: () => Promise<unknown>
}
tui?: {
publish?: (input: {
body: {
type: "tui.command.execute"
properties: { command: string }
}
}) => Promise<unknown>
}
}
async function tryPromptWithCandidates(args: {
client: SessionClient
sessionID: string
agent: string
context: string
source: string
}): Promise<string> {
const { client, sessionID, agent, context, source } = args
const targetAgent = normalizeAgentForPrompt(agent)
if (!targetAgent) {
throw new Error(`invalid target agent for switch prompt: ${agent}`)
}
try {
const promptInput = {
path: { id: sessionID },
body: {
agent: targetAgent,
parts: [{ type: "text" as const, text: context }],
},
}
if (client.session.prompt) {
await client.session.prompt(promptInput)
} else {
await client.session.promptAsync(promptInput)
}
if (targetAgent !== agent) {
log("[agent-switch] Normalized pending switch agent for prompt", {
sessionID,
source,
requestedAgent: agent,
usedAgent: targetAgent,
})
}
return targetAgent
} catch (error) {
log("[agent-switch] Prompt attempt failed", {
sessionID,
source,
requestedAgent: agent,
attemptedAgent: targetAgent,
error: String(error),
})
throw error
}
}
export async function applyPendingSwitch(args: {
sessionID: string
client: SessionClient
source: string
}): Promise<void> {
const { sessionID, client, source } = args
const pending = getPendingSwitch(sessionID)
if (!pending) {
clearRetryState(sessionID)
return
}
if (isApplyInFlight(sessionID)) {
return
}
markApplyInFlight(sessionID)
log("[agent-switch] Applying pending switch", {
sessionID,
source,
agent: pending.agent,
})
try {
const alreadyApplied = await shouldClearAsAlreadyApplied({
client,
sessionID,
targetAgent: pending.agent,
})
if (alreadyApplied) {
clearPendingSwitch(sessionID)
clearRetryState(sessionID)
log("[agent-switch] Pending switch already applied by user-turn evidence; clearing state", {
sessionID,
source,
agent: pending.agent,
})
return
}
const idleReady = await waitForSessionIdle({ client, sessionID })
if (!idleReady) {
throw new Error("session not idle before applying agent switch")
}
const beforeMessages = await fetchMessages({ client, sessionID })
const sourceUserAgent = getLatestUserAgent(beforeMessages)
const usedAgent = await tryPromptWithCandidates({
client,
sessionID,
agent: pending.agent,
context: pending.context,
source,
})
const verified = await verifySwitchObserved({
client,
sessionID,
targetAgent: pending.agent,
baselineCount: beforeMessages.length,
})
if (!verified) {
throw new Error(`agent switch not observed after prompt (attempted ${usedAgent})`)
}
clearPendingSwitch(sessionID)
clearRetryState(sessionID)
await syncCliTuiAgentSelectionAfterSwitch({
client,
sessionID,
source,
sourceAgent: sourceUserAgent,
targetAgent: pending.agent,
})
log("[agent-switch] Pending switch applied", {
sessionID,
source,
agent: pending.agent,
})
} catch (error) {
clearInFlight(sessionID)
log("[agent-switch] Pending switch apply failed", {
sessionID,
source,
error: String(error),
})
scheduleRetry({
sessionID,
source,
onLimitReached: (attempts) => {
log("[agent-switch] Retry limit reached; waiting for next trigger", {
sessionID,
attempts,
source,
})
},
retryFn: (attemptNumber) => {
void applyPendingSwitch({
sessionID,
client,
source: `retry:${attemptNumber}`,
})
},
})
}
}
export function clearPendingSwitchRuntime(sessionID: string): void {
clearPendingSwitch(sessionID)
clearRetryState(sessionID)
}
/** @internal For testing only */
export function _resetApplierForTesting(): void {
resetRetryStateForTesting()
}

View File

@@ -0,0 +1,59 @@
import { extractMessageList, hasNewUserTurnForTargetAgent, hasRecentUserTurnForTargetAgent } from "./message-inspection"
import { log } from "../../shared/logger"
import { sleepWithDelay } from "./session-status"
type SessionClient = {
session: {
messages: (input: { path: { id: string } }) => Promise<unknown>
}
}
export async function fetchMessages(args: {
client: SessionClient
sessionID: string
}): Promise<Array<Record<string, unknown>>> {
const response = await args.client.session.messages({ path: { id: args.sessionID } })
return extractMessageList(response)
}
export async function verifySwitchObserved(args: {
client: SessionClient
sessionID: string
targetAgent: string
baselineCount: number
}): Promise<boolean> {
const { client, sessionID, targetAgent, baselineCount } = args
const delays = [100, 300, 800, 1500] as const
for (const delay of delays) {
await sleepWithDelay(delay)
try {
const messages = await fetchMessages({ client, sessionID })
if (hasNewUserTurnForTargetAgent({ messages, targetAgent, baselineCount })) {
return true
}
} catch (error) {
log("[agent-switch] Verification read failed", {
sessionID,
error: String(error),
})
}
}
return false
}
export async function shouldClearAsAlreadyApplied(args: {
client: SessionClient
sessionID: string
targetAgent: string
}): Promise<boolean> {
const { client, sessionID, targetAgent } = args
try {
const messages = await fetchMessages({ client, sessionID })
return hasRecentUserTurnForTargetAgent({ messages, targetAgent })
} catch {
return false
}
}

View File

@@ -0,0 +1,8 @@
export {
setPendingSwitch,
getPendingSwitch,
clearPendingSwitch,
consumePendingSwitch,
_resetForTesting,
} from "./state"
export type { PendingSwitch } from "./state"

View File

@@ -0,0 +1,107 @@
import { getAgentConfigKey } from "../../shared/agent-display-names"
export interface MessageRoleAgent {
role: string
agent: string
}
export function extractMessageList(response: unknown): Array<Record<string, unknown>> {
if (Array.isArray(response)) {
return response.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
}
if (typeof response === "object" && response !== null) {
const data = (response as Record<string, unknown>).data
if (Array.isArray(data)) {
return data.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
}
}
return []
}
function getRoleAgent(message: Record<string, unknown>): MessageRoleAgent | undefined {
const info = message.info
if (typeof info !== "object" || info === null) {
return undefined
}
const role = (info as Record<string, unknown>).role
const agent = (info as Record<string, unknown>).agent
if (typeof role !== "string" || typeof agent !== "string") {
return undefined
}
return { role, agent }
}
export function getLatestUserAgent(messages: Array<Record<string, unknown>>): string | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
if (!message) {
continue
}
const roleAgent = getRoleAgent(message)
if (!roleAgent || roleAgent.role !== "user") {
continue
}
return roleAgent.agent
}
return undefined
}
export function hasRecentUserTurnForTargetAgent(args: {
messages: Array<Record<string, unknown>>
targetAgent: string
lookback?: number
}): boolean {
const { messages, targetAgent, lookback = 8 } = args
const targetKey = getAgentConfigKey(targetAgent)
const start = Math.max(0, messages.length - lookback)
for (let index = messages.length - 1; index >= start; index -= 1) {
const message = messages[index]
if (!message) {
continue
}
const roleAgent = getRoleAgent(message)
if (!roleAgent || roleAgent.role !== "user") {
continue
}
if (getAgentConfigKey(roleAgent.agent) === targetKey) {
return true
}
}
return false
}
export function hasNewUserTurnForTargetAgent(args: {
messages: Array<Record<string, unknown>>
targetAgent: string
baselineCount: number
}): boolean {
const { messages, targetAgent, baselineCount } = args
const targetKey = getAgentConfigKey(targetAgent)
if (messages.length <= baselineCount) {
return false
}
const newMessages = messages.slice(Math.max(0, baselineCount))
for (const message of newMessages) {
const roleAgent = getRoleAgent(message)
if (!roleAgent || roleAgent.role !== "user") {
continue
}
if (getAgentConfigKey(roleAgent.agent) === targetKey) {
return true
}
}
return false
}

View File

@@ -0,0 +1,66 @@
const RETRY_DELAYS_MS = [50, 250, 500, 1000, 2000, 5000] as const
const inFlightSessions = new Set<string>()
const retryAttempts = new Map<string, number>()
const retryTimers = new Map<string, ReturnType<typeof setTimeout>>()
export function isApplyInFlight(sessionID: string): boolean {
return inFlightSessions.has(sessionID)
}
export function markApplyInFlight(sessionID: string): void {
inFlightSessions.add(sessionID)
}
export function clearRetryState(sessionID: string): void {
const timer = retryTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
retryTimers.delete(sessionID)
}
retryAttempts.delete(sessionID)
inFlightSessions.delete(sessionID)
}
export function clearInFlight(sessionID: string): void {
inFlightSessions.delete(sessionID)
}
export function scheduleRetry(args: {
sessionID: string
source: string
retryFn: (attemptNumber: number) => void
onLimitReached: (attempts: number) => void
}): void {
const { sessionID, retryFn, onLimitReached } = args
const attempts = retryAttempts.get(sessionID) ?? 0
if (attempts >= RETRY_DELAYS_MS.length) {
onLimitReached(attempts)
return
}
const delay = RETRY_DELAYS_MS[attempts]
retryAttempts.set(sessionID, attempts + 1)
const existing = retryTimers.get(sessionID)
if (existing) {
clearTimeout(existing)
}
const timer = setTimeout(() => {
retryTimers.delete(sessionID)
retryFn(attempts + 1)
}, delay)
retryTimers.set(sessionID, timer)
}
/** @internal For testing only */
export function resetRetryStateForTesting(): void {
for (const timer of retryTimers.values()) {
clearTimeout(timer)
}
retryTimers.clear()
retryAttempts.clear()
inFlightSessions.clear()
}

View File

@@ -0,0 +1,43 @@
import { log } from "../../shared/logger"
import { scheduleRetry } from "./retry-state"
import { applyPendingSwitch } from "./applier"
type SessionClient = {
session: {
prompt?: (input: {
path: { id: string }
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise<unknown>
promptAsync: (input: {
path: { id: string }
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise<unknown>
messages: (input: { path: { id: string } }) => Promise<unknown>
status?: () => Promise<unknown>
}
}
export function schedulePendingSwitchApply(args: {
sessionID: string
client: SessionClient
}): void {
const { sessionID, client } = args
scheduleRetry({
sessionID,
source: "tool",
onLimitReached: (attempts) => {
log("[agent-switch] Retry limit reached; waiting for next trigger", {
sessionID,
attempts,
source: "tool",
})
},
retryFn: (attemptNumber) => {
void applyPendingSwitch({
sessionID,
client,
source: `retry:${attemptNumber}`,
})
},
})
}

View File

@@ -0,0 +1,68 @@
import { log } from "../../shared/logger"
type SessionClient = {
session: {
status?: () => Promise<unknown>
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getSessionStatusType(statusResponse: unknown, sessionID: string): string | undefined {
if (typeof statusResponse !== "object" || statusResponse === null) {
return undefined
}
const root = statusResponse as Record<string, unknown>
const data = (typeof root.data === "object" && root.data !== null)
? root.data as Record<string, unknown>
: root
const entry = data[sessionID]
if (typeof entry !== "object" || entry === null) {
return undefined
}
const entryType = (entry as Record<string, unknown>).type
return typeof entryType === "string" ? entryType : undefined
}
export async function waitForSessionIdle(args: {
client: SessionClient
sessionID: string
timeoutMs?: number
}): Promise<boolean> {
const { client, sessionID, timeoutMs = 15000 } = args
if (!client.session.status) {
return true
}
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const statusResponse = await client.session.status()
const statusType = getSessionStatusType(statusResponse, sessionID)
// /session/status only tracks non-idle sessions in SessionStatus.list().
// Missing entry means idle.
if (!statusType || statusType === "idle") {
return true
}
} catch (error) {
log("[agent-switch] Session status check failed", {
sessionID,
error: String(error),
})
return true
}
await sleep(200)
}
return false
}
export async function sleepWithDelay(ms: number): Promise<void> {
await sleep(ms)
}

View File

@@ -0,0 +1,73 @@
const { describe, test, expect, beforeEach } = require("bun:test")
import {
setPendingSwitch,
getPendingSwitch,
clearPendingSwitch,
consumePendingSwitch,
_resetForTesting,
} from "./state"
describe("agent-switch state", () => {
beforeEach(() => {
_resetForTesting()
})
//#given a pending switch is set
//#when consumePendingSwitch is called
//#then it returns the switch and removes it
test("should store and consume a pending switch", () => {
setPendingSwitch("session-1", "atlas", "Fix these findings")
const entry = consumePendingSwitch("session-1")
expect(entry).toEqual({ agent: "atlas", context: "Fix these findings" })
expect(consumePendingSwitch("session-1")).toBeUndefined()
})
//#given no pending switch exists
//#when consumePendingSwitch is called
//#then it returns undefined
test("should return undefined when no switch is pending", () => {
expect(consumePendingSwitch("session-1")).toBeUndefined()
})
//#given a pending switch is set
//#when a new switch is set for the same session
//#then the latest switch wins
test("should overwrite previous switch for same session", () => {
setPendingSwitch("session-1", "atlas", "Fix A")
setPendingSwitch("session-1", "prometheus", "Plan B")
const entry = consumePendingSwitch("session-1")
expect(entry).toEqual({ agent: "prometheus", context: "Plan B" })
})
//#given switches for different sessions
//#when consumed separately
//#then each session gets its own switch
test("should isolate switches by session", () => {
setPendingSwitch("session-1", "atlas", "Fix A")
setPendingSwitch("session-2", "prometheus", "Plan B")
expect(consumePendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
expect(consumePendingSwitch("session-2")).toEqual({ agent: "prometheus", context: "Plan B" })
})
test("should allow reading without consuming", () => {
setPendingSwitch("session-1", "atlas", "Fix A")
expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
})
test("should clear pending switch explicitly", () => {
setPendingSwitch("session-1", "atlas", "Fix A")
clearPendingSwitch("session-1")
expect(getPendingSwitch("session-1")).toBeUndefined()
})
})
export {}

View File

@@ -0,0 +1,102 @@
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
export interface PendingSwitch {
agent: string
context: string
}
const PENDING_SWITCH_STATE_FILE = process.platform === "win32"
? join(tmpdir(), "oh-my-opencode-agent-switch.json")
: "/tmp/oh-my-opencode-agent-switch.json"
const pendingSwitches = new Map<string, PendingSwitch>()
function isPendingSwitch(value: unknown): value is PendingSwitch {
if (typeof value !== "object" || value === null) return false
const entry = value as Record<string, unknown>
return typeof entry.agent === "string" && typeof entry.context === "string"
}
function readPersistentState(): Record<string, PendingSwitch> {
try {
if (!existsSync(PENDING_SWITCH_STATE_FILE)) {
return {}
}
const raw = readFileSync(PENDING_SWITCH_STATE_FILE, "utf8")
const parsed = JSON.parse(raw)
if (typeof parsed !== "object" || parsed === null) {
return {}
}
const state: Record<string, PendingSwitch> = {}
for (const [sessionID, value] of Object.entries(parsed)) {
if (isPendingSwitch(value)) {
state[sessionID] = value
}
}
return state
} catch {
return {}
}
}
function writePersistentState(state: Record<string, PendingSwitch>): void {
try {
const keys = Object.keys(state)
if (keys.length === 0) {
rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
return
}
writeFileSync(PENDING_SWITCH_STATE_FILE, JSON.stringify(state), "utf8")
} catch {
// ignore persistence errors
}
}
export function setPendingSwitch(sessionID: string, agent: string, context: string): void {
const entry = { agent, context }
pendingSwitches.set(sessionID, entry)
const state = readPersistentState()
state[sessionID] = entry
writePersistentState(state)
}
export function getPendingSwitch(sessionID: string): PendingSwitch | undefined {
const inMemory = pendingSwitches.get(sessionID)
if (inMemory) {
return inMemory
}
const state = readPersistentState()
const fromDisk = state[sessionID]
if (fromDisk) {
pendingSwitches.set(sessionID, fromDisk)
}
return fromDisk
}
export function clearPendingSwitch(sessionID: string): void {
pendingSwitches.delete(sessionID)
const state = readPersistentState()
delete state[sessionID]
writePersistentState(state)
}
export function consumePendingSwitch(sessionID: string): PendingSwitch | undefined {
const entry = getPendingSwitch(sessionID)
clearPendingSwitch(sessionID)
return entry
}
/** @internal For testing only */
export function _resetForTesting(): void {
pendingSwitches.clear()
rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
}

View File

@@ -0,0 +1,132 @@
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log, normalizeSDKResponse } from "../../shared"
type TuiClient = {
app?: {
agents?: () => Promise<unknown>
}
tui?: {
publish?: (input: {
body: {
type: "tui.command.execute"
properties: { command: string }
}
}) => Promise<unknown>
}
}
type AgentInfo = {
name?: string
mode?: "subagent" | "primary" | "all"
hidden?: boolean
}
function isCliClient(): boolean {
return (process.env["OPENCODE_CLIENT"] ?? "cli") === "cli"
}
function resolveCyclePlan(args: {
orderedAgentNames: string[]
sourceAgent: string
targetAgent: string
}): { command: "agent.cycle" | "agent.cycle.reverse"; steps: number } | undefined {
const { orderedAgentNames, sourceAgent, targetAgent } = args
if (orderedAgentNames.length < 2) {
return undefined
}
const orderedKeys = orderedAgentNames.map((name) => getAgentConfigKey(name))
const sourceKey = getAgentConfigKey(sourceAgent)
const targetKey = getAgentConfigKey(targetAgent)
const sourceIndex = orderedKeys.indexOf(sourceKey)
const targetIndex = orderedKeys.indexOf(targetKey)
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
return undefined
}
const size = orderedKeys.length
const forward = (targetIndex - sourceIndex + size) % size
const backward = (sourceIndex - targetIndex + size) % size
if (forward <= backward) {
return { command: "agent.cycle", steps: forward }
}
return { command: "agent.cycle.reverse", steps: backward }
}
export async function syncCliTuiAgentSelectionAfterSwitch(args: {
client: TuiClient
sessionID: string
sourceAgent: string | undefined
targetAgent: string
source: string
}): Promise<void> {
const { client, sessionID, sourceAgent, targetAgent, source } = args
if (!isCliClient()) {
return
}
if (!sourceAgent || !client.app?.agents || !client.tui?.publish) {
return
}
const sourceKey = getAgentConfigKey(sourceAgent)
const targetKey = getAgentConfigKey(targetAgent)
// Scope to Athena handoffs where CLI TUI can show stale local-agent selection.
if (sourceKey !== "athena" || (targetKey !== "atlas" && targetKey !== "prometheus")) {
return
}
try {
const response = await client.app.agents()
const agents = normalizeSDKResponse(response, [] as AgentInfo[], {
preferResponseOnMissingData: true,
})
const orderedPrimaryAgents = agents
.filter((agent) => typeof agent.name === "string" && agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => agent.name as string)
const plan = resolveCyclePlan({
orderedAgentNames: orderedPrimaryAgents,
sourceAgent,
targetAgent,
})
if (!plan || plan.steps <= 0) {
return
}
for (let step = 0; step < plan.steps; step += 1) {
await client.tui.publish({
body: {
type: "tui.command.execute",
properties: {
command: plan.command,
},
},
})
}
log("[agent-switch] Synced CLI TUI local agent after handoff", {
sessionID,
source,
sourceAgent,
targetAgent,
command: plan.command,
steps: plan.steps,
})
} catch (error) {
log("[agent-switch] Failed syncing CLI TUI local agent after handoff", {
sessionID,
source,
sourceAgent,
targetAgent,
error: String(error),
})
}
}

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
resolveInheritedPromptTools,
createInternalAgentTextPart,
} from "../../shared"
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { ConcurrencyManager } from "./concurrency"
@@ -43,6 +44,8 @@ import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { sendPostCompactionContinuation } from "./post-compaction-continuation"
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
@@ -93,7 +96,6 @@ export class BackgroundManager {
private tasks: Map<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]>
private pendingNotifications: Map<string, string[]>
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
private client: OpencodeClient
private directory: string
@@ -111,6 +113,7 @@ export class BackgroundManager {
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private recentlyCompactedSessions: Set<string> = new Set()
private enableParentSessionNotifications: boolean
readonly taskHistory = new TaskHistory()
@@ -126,7 +129,6 @@ export class BackgroundManager {
) {
this.tasks = new Map()
this.notifications = new Map()
this.pendingNotifications = new Map()
this.pendingByParent = new Map()
this.client = ctx.client
this.directory = ctx.directory
@@ -741,12 +743,36 @@ export class BackgroundManager {
}
}
if (event.type === "session.compacted") {
const sessionID = typeof props?.sessionID === "string"
? props.sessionID
: typeof (props?.info as { id?: string } | undefined)?.id === "string"
? (props!.info as { id: string }).id
: undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
this.recentlyCompactedSessions.add(sessionID)
if (task.progress) {
task.progress.lastUpdate = new Date()
}
log("[background-agent] Session compacted, deferring next idle:", { taskId: task.id, sessionID })
}
if (event.type === "session.idle") {
if (!props || typeof props !== "object") return
handleSessionIdleBackgroundEvent({
properties: props as Record<string, unknown>,
findBySession: (id) => this.findBySession(id),
idleDeferralTimers: this.idleDeferralTimers,
recentlyCompactedSessions: this.recentlyCompactedSessions,
onPostCompactionIdle: (t, sid) => {
if (t.agent?.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
sendPostCompactionContinuation(this.client, t, sid)
}
},
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
checkSessionTodos: (id) => this.checkSessionTodos(id),
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
@@ -867,6 +893,7 @@ export class BackgroundManager {
}
}
SessionCategoryRegistry.remove(sessionID)
this.recentlyCompactedSessions.delete(sessionID)
}
if (event.type === "session.status") {
@@ -919,32 +946,6 @@ export class BackgroundManager {
this.notifications.delete(sessionID)
}
queuePendingNotification(sessionID: string | undefined, notification: string): void {
if (!sessionID) return
const existingNotifications = this.pendingNotifications.get(sessionID) ?? []
existingNotifications.push(notification)
this.pendingNotifications.set(sessionID, existingNotifications)
}
injectPendingNotificationsIntoChatMessage(output: { parts: Array<{ type: string; text?: string; [key: string]: unknown }> }, sessionID: string): void {
const pendingNotifications = this.pendingNotifications.get(sessionID)
if (!pendingNotifications || pendingNotifications.length === 0) {
return
}
this.pendingNotifications.delete(sessionID)
const notificationContent = pendingNotifications.join("\n\n")
const firstTextPartIndex = output.parts.findIndex((part) => part.type === "text")
if (firstTextPartIndex === -1) {
output.parts.unshift(createInternalAgentTextPart(notificationContent))
return
}
const originalText = output.parts[firstTextPartIndex].text ?? ""
output.parts[firstTextPartIndex].text = `${notificationContent}\n\n---\n\n${originalText}`
}
/**
* Validates that a session has actual assistant/tool output before marking complete.
* Prevents premature completion when session.idle fires before agent responds.
@@ -1339,10 +1340,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
const resolvedTools = resolveInheritedPromptTools(task.parentSessionID, tools)
const promptAgent = normalizeAgentForPrompt(agent)
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
resolvedAgent: agent,
resolvedAgent: promptAgent,
resolvedModel: model,
})
@@ -1351,7 +1353,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
path: { id: task.parentSessionID },
body: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(promptAgent !== undefined ? { agent: promptAgent } : {}),
...(model !== undefined ? { model } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
parts: [createInternalAgentTextPart(notification)],
@@ -1368,7 +1370,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
taskId: task.id,
parentSessionID: task.parentSessionID,
})
this.queuePendingNotification(task.parentSessionID, notification)
} else {
log("[background-agent] Failed to send notification:", error)
}
@@ -1494,6 +1495,18 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
const sessionStatus = allStatuses[sessionID]
if (sessionStatus?.type === "idle") {
if (this.recentlyCompactedSessions.has(sessionID)) {
this.recentlyCompactedSessions.delete(sessionID)
log("[background-agent] Polling: skipping post-compaction idle:", task.id)
continue
}
// Refresh lastUpdate so the next poll's stale check doesn't kill
// the task while we're awaiting async validation
if (task.progress) {
task.progress.lastUpdate = new Date()
}
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
@@ -1597,9 +1610,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.concurrencyManager.clear()
this.tasks.clear()
this.notifications.clear()
this.pendingNotifications.clear()
this.pendingByParent.clear()
this.notificationQueueByParent.clear()
this.recentlyCompactedSessions.clear()
this.queuesByKey.clear()
this.processingKeys.clear()
this.unregisterProcessCleanup()

View File

@@ -0,0 +1,55 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask } from "./types"
import {
log,
getAgentToolRestrictions,
createInternalAgentTextPart,
} from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
type OpencodeClient = PluginInput["client"]
const CONTINUATION_PROMPT =
"Your session was compacted (context summarized). Continue your analysis from where you left off. Report your findings when done."
export function sendPostCompactionContinuation(
client: OpencodeClient,
task: BackgroundTask,
sessionID: string,
): void {
if (task.status !== "running") return
const resumeModel = task.model
? { providerID: task.model.providerID, modelID: task.model.modelID }
: undefined
const resumeVariant = task.model?.variant
client.session.promptAsync({
path: { id: sessionID },
body: {
agent: task.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
const tools = {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(task.agent),
}
setSessionTools(sessionID, tools)
return tools
})(),
parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
},
}).catch((error) => {
log("[background-agent] Post-compaction continuation error:", {
taskId: task.id,
error: String(error),
})
})
if (task.progress) {
task.progress.lastUpdate = new Date()
}
}

View File

@@ -11,6 +11,8 @@ export function handleSessionIdleBackgroundEvent(args: {
properties: Record<string, unknown>
findBySession: (sessionID: string) => BackgroundTask | undefined
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
recentlyCompactedSessions?: Set<string>
onPostCompactionIdle?: (task: BackgroundTask, sessionID: string) => void
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
@@ -20,6 +22,8 @@ export function handleSessionIdleBackgroundEvent(args: {
properties,
findBySession,
idleDeferralTimers,
recentlyCompactedSessions,
onPostCompactionIdle,
validateSessionHasOutput,
checkSessionTodos,
tryCompleteTask,
@@ -32,6 +36,13 @@ export function handleSessionIdleBackgroundEvent(args: {
const task = findBySession(sessionID)
if (!task || task.status !== "running") return
if (recentlyCompactedSessions?.has(sessionID)) {
recentlyCompactedSessions.delete(sessionID)
log("[background-agent] Skipping post-compaction session.idle:", { taskId: task.id, sessionID })
onPostCompactionIdle?.(task, sessionID)
return
}
const startedAt = task.startedAt
if (!startedAt) return
@@ -55,6 +66,13 @@ export function handleSessionIdleBackgroundEvent(args: {
return
}
// Refresh lastUpdate to prevent stale timeout from racing with this async validation.
// Without this, checkAndInterruptStaleTasks can kill the task synchronously
// while validateSessionHasOutput is still awaiting an API response.
if (task.progress) {
task.progress.lastUpdate = new Date()
}
validateSessionHasOutput(sessionID)
.then(async (hasValidOutput) => {
if (task.status !== "running") {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -185,7 +185,7 @@ describe("resolveMultipleSkillsAsync", () => {
const skillNames = ["playwright", "git-master"]
// when: resolving multiple skills async
const result = await resolveMultipleSkillsAsync(skillNames)
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
// then: all builtin skills resolved
expect(result.resolved.size).toBe(2)
@@ -199,7 +199,7 @@ describe("resolveMultipleSkillsAsync", () => {
const skillNames = ["playwright", "nonexistent-skill-12345"]
// when: resolving multiple skills async
const result = await resolveMultipleSkillsAsync(skillNames)
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
// then: existing skills resolved, non-existing in notFound
expect(result.resolved.size).toBe(1)
@@ -286,7 +286,7 @@ describe("resolveMultipleSkillsAsync", () => {
const skillNames = ["git-master"]
// when: resolving without any gitMasterConfig
const result = await resolveMultipleSkillsAsync(skillNames)
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
// then: watermark is injected (default is ON)
expect(result.resolved.size).toBe(1)
@@ -357,7 +357,7 @@ describe("resolveMultipleSkillsAsync", () => {
const skillNames: string[] = []
// when: resolving multiple skills async
const result = await resolveMultipleSkillsAsync(skillNames)
const result = await resolveMultipleSkillsAsync(skillNames, { directory: process.cwd() })
// then: empty results
expect(result.resolved.size).toBe(0)

View File

@@ -3,19 +3,20 @@ import { discoverSkills } from "./loader"
import type { LoadedSkill } from "./types"
import type { SkillResolutionOptions } from "./skill-resolution-options"
const cachedSkillsByProvider = new Map<string, LoadedSkill[]>()
const skillCache = new Map<string, LoadedSkill[]>()
export function clearSkillCache(): void {
cachedSkillsByProvider.clear()
skillCache.clear()
}
export async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSkill[]> {
const cacheKey = options?.browserProvider ?? "playwright"
export async function getAllSkills(options: SkillResolutionOptions & { directory: string }): Promise<LoadedSkill[]> {
const directory = options.directory
const cacheKey = `${options?.browserProvider ?? "playwright"}:${directory}`
const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0
// Skip cache if disabledSkills is provided (varies between calls)
if (!hasDisabledSkills) {
const cached = cachedSkillsByProvider.get(cacheKey)
const cached = skillCache.get(cacheKey)
if (cached) return cached
}
@@ -69,7 +70,7 @@ export async function getAllSkills(options?: SkillResolutionOptions): Promise<Lo
if (hasDisabledSkills) {
allSkills = allSkills.filter((skill) => !options!.disabledSkills!.has(skill.name))
} else {
cachedSkillsByProvider.set(cacheKey, allSkills)
skillCache.set(cacheKey, allSkills)
}
return allSkills

View File

@@ -4,6 +4,6 @@ export interface SkillResolutionOptions {
gitMasterConfig?: GitMasterConfig
browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
/** Project directory to discover project-level skills from. Falls back to process.cwd() if not provided. */
/** Project directory to discover project-level skills from. Required for async resolution — process.cwd() is unsafe in OpenCode. */
directory?: string
}

View File

@@ -51,7 +51,7 @@ export function resolveMultipleSkills(
export async function resolveSkillContentAsync(
skillName: string,
options?: SkillResolutionOptions
options: SkillResolutionOptions & { directory: string }
): Promise<string | null> {
const allSkills = await getAllSkills(options)
const skill = allSkills.find((loadedSkill) => loadedSkill.name === skillName)
@@ -68,7 +68,7 @@ export async function resolveSkillContentAsync(
export async function resolveMultipleSkillsAsync(
skillNames: string[],
options?: SkillResolutionOptions
options: SkillResolutionOptions & { directory: string }
): Promise<{ resolved: Map<string, string>; notFound: string[] }> {
const allSkills = await getAllSkills(options)
const skillMap = new Map<string, LoadedSkill>()

View File

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

View File

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

View File

@@ -0,0 +1,356 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, test } from "bun:test"
import { createAgentSwitchHook } from "./hook"
import {
_resetForTesting,
getPendingSwitch,
setPendingSwitch,
} from "../../features/agent-switch"
import { _resetApplierForTesting, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
describe("agent-switch hook", () => {
beforeEach(() => {
_resetForTesting()
_resetApplierForTesting()
})
test("consumes pending switch only after successful promptAsync", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-1", "prometheus", "plan this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "ses-1" },
},
})
expect(promptAsyncCalls).toHaveLength(1)
expect(getPendingSwitch("ses-1")).toBeUndefined()
})
test("keeps pending switch when promptAsync fails", async () => {
const ctx = {
client: {
session: {
promptAsync: async () => {
throw new Error("temporary failure")
},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-2", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "ses-2" },
},
})
expect(getPendingSwitch("ses-2")).toEqual({
agent: "atlas",
context: "fix this",
})
clearPendingSwitchRuntime("ses-2")
})
test("retries after transient failure and eventually clears pending switch", async () => {
let attempts = 0
let switched = false
const ctx = {
client: {
session: {
promptAsync: async () => {
attempts += 1
if (attempts === 1) {
throw new Error("temporary failure")
}
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-3", "prometheus", "plan this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "ses-3" },
},
})
await new Promise((resolve) => setTimeout(resolve, 350))
expect(attempts).toBe(2)
expect(getPendingSwitch("ses-3")).toBeUndefined()
})
test("clears pending switch on session.deleted", async () => {
const ctx = {
client: {
session: {
promptAsync: async () => {},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-4", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.deleted",
properties: { info: { id: "ses-4" } },
},
})
expect(getPendingSwitch("ses-4")).toBeUndefined()
})
test("clears pending switch on session.error with info.id", async () => {
const ctx = {
client: {
session: {
promptAsync: async () => {},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-10", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.error",
properties: { info: { id: "ses-10" } },
},
})
expect(getPendingSwitch("ses-10")).toBeUndefined()
})
test("clears pending switch on session.error with sessionID property", async () => {
const ctx = {
client: {
session: {
promptAsync: async () => {},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-11", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "ses-11" },
},
})
expect(getPendingSwitch("ses-11")).toBeUndefined()
})
test("applies queued pending switch on terminal message.updated", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-6", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
id: "msg-6",
sessionID: "ses-6",
role: "assistant",
agent: "Athena (Council)",
finish: "stop",
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-6")).toBeUndefined()
})
test("applies queued pending switch on terminal message.updated even when role is missing", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-8", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
id: "msg-8",
sessionID: "ses-8",
agent: "Athena (Council)",
finish: true,
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-8")).toBeUndefined()
})
test("applies queued pending switch on terminal message.part.updated step-finish", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-7", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "message.part.updated",
properties: {
info: {
sessionID: "ses-7",
role: "assistant",
},
part: {
id: "part-finish-1",
sessionID: "ses-7",
type: "step-finish",
reason: "stop",
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-7")).toBeUndefined()
})
test("applies queued pending switch on session.status idle", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-9", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.status",
properties: {
sessionID: "ses-9",
status: {
type: "idle",
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-9")).toBeUndefined()
})
})

View File

@@ -0,0 +1,141 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPendingSwitch } from "../../features/agent-switch"
import { applyPendingSwitch, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
import {
isTerminalFinishValue,
isTerminalStepFinishPart,
} from "./terminal-detection"
function getSessionIDFromStatusEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
const props = input.event.properties as Record<string, unknown> | undefined
const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
if (fromProps) {
return fromProps
}
const status = props?.status as Record<string, unknown> | undefined
const fromStatus = typeof status?.sessionID === "string" ? status.sessionID : undefined
return fromStatus
}
function getStatusTypeFromEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
const props = input.event.properties as Record<string, unknown> | undefined
const directType = typeof props?.type === "string" ? props.type : undefined
if (directType) {
return directType
}
const status = props?.status as Record<string, unknown> | undefined
const statusType = typeof status?.type === "string" ? status.type : undefined
return statusType
}
export function createAgentSwitchHook(ctx: PluginInput) {
return {
event: async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
if (input.event.type === "session.deleted") {
const props = input.event.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const deletedSessionID = info?.id
if (typeof deletedSessionID === "string") {
clearPendingSwitchRuntime(deletedSessionID)
}
return
}
if (input.event.type === "session.error") {
const props = input.event.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const erroredSessionID = info?.id ?? props?.sessionID
if (typeof erroredSessionID === "string") {
clearPendingSwitchRuntime(erroredSessionID)
}
return
}
if (input.event.type === "message.updated") {
const props = input.event.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
const finish = info?.finish
if (!sessionID) {
return
}
const isTerminalAssistantUpdate = isTerminalFinishValue(finish)
if (!isTerminalAssistantUpdate) {
return
}
// Primary path: if switch_agent queued a pending switch, apply it as soon as
// assistant turn is terminal (no reliance on session.idle timing).
if (getPendingSwitch(sessionID)) {
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "message-updated",
})
}
return
}
if (input.event.type === "message.part.updated") {
const props = input.event.properties as Record<string, unknown> | undefined
const part = props?.part
const info = props?.info as Record<string, unknown> | undefined
const sessionIDFromPart = typeof (part as Record<string, unknown> | undefined)?.sessionID === "string"
? ((part as Record<string, unknown>).sessionID as string)
: undefined
const sessionIDFromInfo = typeof info?.sessionID === "string" ? info.sessionID : undefined
const sessionID = sessionIDFromPart ?? sessionIDFromInfo
if (!sessionID) {
return
}
if (!isTerminalStepFinishPart(part)) {
return
}
if (!getPendingSwitch(sessionID)) {
return
}
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "message-part-step-finish",
})
return
}
if (input.event.type === "session.idle") {
const props = input.event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "idle",
})
return
}
if (input.event.type === "session.status") {
const sessionID = getSessionIDFromStatusEvent(input)
const statusType = getStatusTypeFromEvent(input)
if (!sessionID || statusType !== "idle") {
return
}
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "status-idle",
})
}
},
}
}

View File

@@ -0,0 +1 @@
export { createAgentSwitchHook } from "./hook"

View File

@@ -0,0 +1,36 @@
export function isTerminalFinishValue(finish: unknown): boolean {
if (typeof finish === "boolean") {
return finish
}
if (typeof finish === "string") {
const normalized = finish.toLowerCase()
return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
}
if (typeof finish === "object" && finish !== null) {
const record = finish as Record<string, unknown>
const kind = record.type ?? record.reason
if (typeof kind === "string") {
const normalized = kind.toLowerCase()
return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
}
}
return false
}
export function isTerminalStepFinishPart(part: unknown): boolean {
if (typeof part !== "object" || part === null) {
return false
}
const record = part as Record<string, unknown>
if (record.type !== "step-finish") {
return false
}
return isTerminalFinishValue(record.reason)
}

View File

@@ -6,6 +6,8 @@ import {
} from "./storage";
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types";
import { getSessionAgent } from "../../features/claude-code-session-state";
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents";
interface ToolExecuteInput {
tool: string;
@@ -60,6 +62,12 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
output: ToolExecuteOutput,
) => {
const { tool, sessionID } = input;
const agent = getSessionAgent(sessionID);
if (agent?.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
return;
}
const toolLower = tool.toLowerCase();
if (AGENT_TOOLS.has(toolLower)) {

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
import { fixEmptyMessages } from "./empty-content-recovery"
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000
export async function runSummarizeRetryStrategy(params: {
sessionID: string
msg: Record<string, unknown>
@@ -20,27 +18,6 @@ export async function runSummarizeRetryStrategy(params: {
messageIndex?: number
}): Promise<void> {
const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID)
const now = Date.now()
if (retryState.firstAttemptTime === 0) {
retryState.firstAttemptTime = now
}
const elapsedTimeMs = now - retryState.firstAttemptTime
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
if (params.errorType?.includes("non-empty content")) {
const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID)
@@ -75,7 +52,6 @@ export async function runSummarizeRetryStrategy(params: {
if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0
retryState.firstAttemptTime = Date.now()
params.autoCompactState.truncateStateBySession.delete(params.sessionID)
}
@@ -116,26 +92,10 @@ export async function runSummarizeRetryStrategy(params: {
})
return
} catch {
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
if (remainingTimeMs <= 0) {
clearSessionState(params.autoCompactState, params.sessionID)
await params.client.tui
.showToast({
body: {
title: "Auto Compact Timed Out",
message: "Compaction retries exceeded the timeout window. Please start a new session.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
const delay =
RETRY_CONFIG.initialDelayMs *
Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1)
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs)
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
setTimeout(() => {
void runSummarizeRetryStrategy(params)

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { HOOK_NAME } from "./hook-name"
@@ -14,7 +15,6 @@ export async function injectBoulderContinuation(input: {
remaining: number
total: number
agent?: string
worktreePath?: string
backgroundManager?: BackgroundManager
sessionState: SessionState
}): Promise<void> {
@@ -25,7 +25,6 @@ export async function injectBoulderContinuation(input: {
remaining,
total,
agent,
worktreePath,
backgroundManager,
sessionState,
} = input
@@ -39,11 +38,10 @@ export async function injectBoulderContinuation(input: {
return
}
const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : ""
const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
worktreeContext
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]`
const promptAgent = normalizeAgentForPrompt(agent ?? "atlas") ?? "atlas"
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
@@ -54,7 +52,7 @@ export async function injectBoulderContinuation(input: {
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: agent ?? "atlas",
agent: promptAgent,
...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(prompt)],
@@ -66,7 +64,6 @@ export async function injectBoulderContinuation(input: {
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} catch (err) {
sessionState.promptFailureCount += 1
sessionState.lastFailureAt = Date.now()
log(`[${HOOK_NAME}] Boulder continuation failed`, {
sessionID,
error: String(err),

View File

@@ -10,7 +10,6 @@ import { getLastAgentFromSession } from "./session-last-agent"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
export function createAtlasEventHandler(input: {
ctx: PluginInput
@@ -54,7 +53,6 @@ export function createAtlasEventHandler(input: {
}
const state = getState(sessionID)
const now = Date.now()
if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false
@@ -63,18 +61,11 @@ export function createAtlasEventHandler(input: {
}
if (state.promptFailureCount >= 2) {
const timeSinceLastFailure = state.lastFailureAt !== undefined ? now - state.lastFailureAt : Number.POSITIVE_INFINITY
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
sessionID,
promptFailureCount: state.promptFailureCount,
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
})
return
}
state.promptFailureCount = 0
state.lastFailureAt = undefined
log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, {
sessionID,
promptFailureCount: state.promptFailureCount,
})
return
}
const backgroundManager = options?.backgroundManager
@@ -101,15 +92,17 @@ export function createAtlasEventHandler(input: {
const lastAgentKey = getAgentConfigKey(lastAgent ?? "")
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
const boulderAgentWasNotExplicitlySet = boulderState.agent === undefined
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
const allowSisyphusForAtlasBoulder = boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
const agentMatches = lastAgentMatchesRequired || allowSisyphusForAtlasBoulder
const allowSisyphusWhenDefaultAtlas = boulderAgentWasNotExplicitlySet && boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
const agentMatches = lastAgentMatchesRequired || allowSisyphusWhenDefaultAtlas
if (!agentMatches) {
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
sessionID,
lastAgent: lastAgent ?? "unknown",
requiredAgent,
boulderAgentExplicitlySet: boulderState.agent !== undefined,
})
return
}
@@ -120,6 +113,7 @@ export function createAtlasEventHandler(input: {
return
}
const now = Date.now()
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID,
@@ -138,7 +132,6 @@ export function createAtlasEventHandler(input: {
remaining,
total: progress.total,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
backgroundManager,
sessionState: state,
})

View File

@@ -933,8 +933,8 @@ describe("atlas hook", () => {
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => {
// given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work)
test("should not inject when last agent does not match boulder agent", async () => {
// given - boulder state with incomplete plan, but last agent does NOT match
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -947,7 +947,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
// given - last agent is sisyphus (typical state right after /start-work)
// given - last agent is NOT the boulder agent
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
@@ -962,39 +962,7 @@ describe("atlas hook", () => {
},
})
// then - should call prompt because sisyphus is always allowed for atlas boulders
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should not inject when last agent is non-sisyphus and does not match boulder agent", async () => {
// given - boulder explicitly set to atlas, last agent is hephaestus (unrelated agent)
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
agent: "atlas",
}
writeBoulderState(TEST_DIR, state)
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should NOT call prompt because hephaestus does not match atlas or sisyphus
// then - should NOT call prompt because agent does not match
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
@@ -1029,7 +997,7 @@ describe("atlas hook", () => {
// then - should call prompt for sisyphus
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.agent).toBe("sisyphus")
expect(callArgs.body.agent).toBe("Sisyphus (Ultraworker)")
})
test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
@@ -1154,144 +1122,6 @@ describe("atlas hook", () => {
}
})
test("should keep skipping continuation during 5-minute backoff after 2 consecutive failures", async () => {
//#given - boulder state with incomplete plan and prompt always fails
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - third idle occurs inside 5-minute backoff window
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 60000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - third attempt should still be skipped
expect(promptMock).toHaveBeenCalledTimes(2)
} finally {
Date.now = originalDateNow
}
})
test("should retry continuation after 5-minute backoff expires following 2 consecutive failures", async () => {
//#given - boulder state with incomplete plan and prompt always fails
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - third idle occurs after 5+ minutes
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 300000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - third attempt should run after backoff expiration
expect(promptMock).toHaveBeenCalledTimes(3)
} finally {
Date.now = originalDateNow
}
})
test("should reset prompt failure counter after successful retry beyond backoff window", async () => {
//#given - boulder state with incomplete plan and success on first retry after backoff
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - fail twice, recover after backoff with success, then fail twice again
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 300000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - success retry resets counter, so two additional failures are allowed before skip
expect(promptMock).toHaveBeenCalledTimes(5)
} finally {
Date.now = originalDateNow
}
})
test("should reset continuation failure state on session.compacted event", async () => {
//#given - boulder state with incomplete plan and prompt always fails
const planPath = join(TEST_DIR, "test-plan.md")

View File

@@ -26,5 +26,4 @@ export interface SessionState {
lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number
promptFailureCount: number
lastFailureAt?: number
}

View File

@@ -9,14 +9,6 @@ interface EventInput {
event: Event
}
interface ChatMessageInput {
sessionID: string
}
interface ChatMessageOutput {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
/**
* Background notification hook - handles event routing to BackgroundManager.
*
@@ -28,15 +20,7 @@ export function createBackgroundNotificationHook(manager: BackgroundManager) {
manager.handleEvent(event)
}
const chatMessageHandler = async (
input: ChatMessageInput,
output: ChatMessageOutput,
): Promise<void> => {
manager.injectPendingNotificationsIntoChatMessage(output, input.sessionID)
}
return {
"chat.message": chatMessageHandler,
event: eventHandler,
}
}

View File

@@ -1,6 +1,6 @@
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
**Generated:** 2026-02-24
**Generated:** 2026-02-21
## OVERVIEW

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