Compare commits

..

115 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
github-actions[bot]
55b9ad60d8 release: v3.8.5 2026-02-24 09:45:36 +00:00
YeonGyu-Kim
e997e0071c Merge pull request #2088 from minpeter/feat/hashline-edit-error-hints
fix(hashline-edit): improve error messages for invalid LINE#ID references
2026-02-24 18:36:04 +09:00
103 changed files with 5161 additions and 80 deletions

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"
]
}
},
@@ -3153,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

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.4",
"version": "3.8.5",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.4",
"oh-my-opencode-darwin-x64": "3.8.4",
"oh-my-opencode-linux-arm64": "3.8.4",
"oh-my-opencode-linux-arm64-musl": "3.8.4",
"oh-my-opencode-linux-x64": "3.8.4",
"oh-my-opencode-linux-x64-musl": "3.8.4",
"oh-my-opencode-windows-x64": "3.8.4"
"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"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
# src/agents/ — 11 Agent Definitions
# src/agents/ — 13 Agent Definitions
**Generated:** 2026-02-21
@@ -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

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

@@ -103,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()
@@ -689,6 +753,7 @@ describe("Hephaestus environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
disableFlag
)
}
@@ -748,6 +813,7 @@ describe("Sisyphus and Librarian environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
disableFlag
)
}
@@ -807,6 +873,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
false
)
@@ -823,6 +890,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
undefined,
undefined,
undefined,
undefined,
true
)

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",
},
@@ -1212,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",
},
@@ -1352,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",
},
@@ -1426,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

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

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

@@ -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,6 +56,10 @@ 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(),
@@ -70,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

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

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

@@ -855,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()

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"
@@ -110,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()
@@ -739,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),
@@ -865,6 +893,7 @@ export class BackgroundManager {
}
}
SessionCategoryRegistry.remove(sessionID)
this.recentlyCompactedSessions.delete(sessionID)
}
if (event.type === "session.status") {
@@ -1311,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,
})
@@ -1323,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)],
@@ -1465,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) {
@@ -1570,6 +1612,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.notifications.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

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

@@ -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,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"
@@ -40,6 +41,7 @@ export async function injectBoulderContinuation(input: {
const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\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 })
@@ -50,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)],

View File

@@ -997,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 () => {

View File

@@ -50,3 +50,5 @@ export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallba
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
export { createAgentSwitchHook } from "./agent-switch";

View File

@@ -11,6 +11,7 @@ import {
getSessionAgent,
subagentSessions,
} from "../../features/claude-code-session-state"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import type { ContextCollector } from "../../features/context-injector"
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
@@ -45,6 +46,21 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
}
// Athena is a council orchestrator — skip all keyword injections.
// search/analyze modes tell the agent to use explore agents and grep directly,
// which conflicts with Athena's job of launching council members via task calls.
// Use getAgentConfigKey to handle display name remapping ("Athena (Council)" → "athena").
const agentConfigKey = currentAgent ? getAgentConfigKey(currentAgent) : undefined
if (agentConfigKey === "athena") {
if (detectedKeywords.length > 0) {
log(`[keyword-detector] Skipping keywords for Athena (council orchestrator)`, {
sessionID: input.sessionID,
skippedTypes: detectedKeywords.map((k) => k.type),
})
}
return
}
if (detectedKeywords.length === 0) {
return
}

View File

@@ -745,4 +745,54 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
expect(textPart!.text).toBe("ultrawork plan this")
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
})
test("should skip ALL keyword injections for Athena with display name", async () => {
// given - session agent is stored as display name "Athena (Council)" after remapping
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "athena-display-name-session"
updateSessionAgent(sessionID, "Athena (Council)")
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "ultrawork search for bugs in the code" }],
}
// when - keyword detection runs with Athena display name in session state
await hook["chat.message"]({ sessionID }, output)
// then - ALL keywords should be skipped (no injection)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart!.text).toBe("ultrawork search for bugs in the code")
expect(textPart!.text).not.toContain("[search-mode]")
expect(textPart!.text).not.toContain("MAXIMIZE SEARCH EFFORT")
const skipLog = logCalls.find(c => c.msg.includes("Skipping keywords for Athena"))
expect(skipLog).toBeDefined()
clearSessionAgent(sessionID)
})
test("should skip ALL keyword injections for Athena with lowercase config key", async () => {
// given - session agent is stored as lowercase "athena"
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "athena-lowercase-session"
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "search for the implementation" }],
}
// when - keyword detection runs with athena as input.agent
await hook["chat.message"]({ sessionID, agent: "athena" }, output)
// then - ALL keywords should be skipped
const textPart = output.parts.find(p => p.type === "text")
expect(textPart!.text).toBe("search for the implementation")
expect(textPart!.text).not.toContain("[search-mode]")
const skipLog = logCalls.find(c => c.msg.includes("Skipping keywords for Athena"))
expect(skipLog).toBeDefined()
})
})

View File

@@ -8,6 +8,7 @@ import {
normalizeSDKResponse,
resolveInheritedPromptTools,
} from "../../shared"
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
type MessageInfo = {
agent?: string
@@ -68,11 +69,12 @@ export async function injectContinuationPrompt(
}
const inheritedTools = resolveInheritedPromptTools(sourceSessionID, tools)
const promptAgent = normalizeAgentForPrompt(agent)
await ctx.client.session.promptAsync({
path: { id: options.sessionID },
body: {
...(agent !== undefined ? { agent } : {}),
...(promptAgent ? { agent: promptAgent } : {}),
...(model !== undefined ? { model } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(options.prompt)],

View File

@@ -1,4 +1,5 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
import type { MessageData, ResumeConfig } from "./types"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
@@ -25,13 +26,15 @@ export function extractResumeConfig(userMessage: MessageData | undefined, sessio
}
export async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
const promptAgent = normalizeAgentForPrompt(config.agent)
try {
const inheritedTools = resolveInheritedPromptTools(config.sessionID, config.tools)
await client.session.promptAsync({
path: { id: config.sessionID },
body: {
parts: [createInternalAgentTextPart(RECOVERY_RESUME_TEXT)],
agent: config.agent,
...(promptAgent ? { agent: promptAgent } : {}),
model: config.model,
...(inheritedTools ? { tools: inheritedTools } : {}),
},

View File

@@ -6,6 +6,7 @@ import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
import {
ABORT_WINDOW_MS,
CONTINUATION_COOLDOWN_MS,
@@ -164,6 +165,10 @@ export async function handleSessionIdle(args: {
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
const resolvedAgentName = resolvedInfo?.agent
if (resolvedAgentName && resolvedAgentName.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
log(`[${HOOK_NAME}] Skipped: council member agent`, { sessionID, agent: resolvedAgentName })
return
}
if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName })
return

View File

@@ -1,5 +1,6 @@
import type { BackgroundManager } from "../../features/background-agent"
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import {
@@ -80,7 +81,7 @@ async function resolveMainSessionTarget(
log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) })
}
return { agent, model, tools: resolveInheritedPromptTools(sessionID, tools) }
return { agent: normalizeAgentForPrompt(agent), model, tools: resolveInheritedPromptTools(sessionID, tools) }
}
async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise<string | null> {

View File

@@ -78,6 +78,7 @@ export async function applyAgentConfig(params: {
const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false;
const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false;
const athenaCouncilConfig = params.pluginConfig.agents?.athena?.council
const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents,
params.pluginConfig.agents,
@@ -92,6 +93,7 @@ export async function applyAgentConfig(params: {
disabledSkills,
useTaskSystem,
disableOmoEnv,
athenaCouncilConfig,
);
const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true;

View File

@@ -38,6 +38,7 @@ describe("remapAgentKeysToDisplayNames", () => {
hephaestus: {},
prometheus: {},
atlas: {},
athena: {},
metis: {},
momus: {},
"sisyphus-junior": {},
@@ -52,6 +53,7 @@ describe("remapAgentKeysToDisplayNames", () => {
"Hephaestus (Deep Agent)",
"Prometheus (Plan Builder)",
"Atlas (Plan Executor)",
"Athena (Council)",
"Metis (Plan Consultant)",
"Momus (Plan Critic)",
"Sisyphus-Junior",

View File

@@ -1310,7 +1310,7 @@ describe("disable_omo_env pass-through", () => {
const lastCall =
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
expect(lastCall).toBeDefined()
expect(lastCall?.[12]).toBe(true)
expect(lastCall?.[13]).toBe(true)
})
test("passes disable_omo_env=false to createBuiltinAgents when omitted", async () => {
@@ -1344,6 +1344,6 @@ describe("disable_omo_env pass-through", () => {
const lastCall =
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
expect(lastCall).toBeDefined()
expect(lastCall?.[12]).toBe(false)
expect(lastCall?.[13]).toBe(false)
})
})

View File

@@ -26,6 +26,7 @@ export function applyToolConfig(params: {
LspCodeActionResolve: false,
"task_*": false,
teammate: false,
prepare_council_prompt: false,
...(params.pluginConfig.experimental?.task_system
? { todowrite: false, todoread: false }
: {}),
@@ -97,6 +98,19 @@ export function applyToolConfig(params: {
...denyTodoTools,
};
}
// NOTE: Athena/council tool restrictions are also defined in:
// - src/agents/athena/agent.ts (AgentConfig permission format)
// - src/shared/agent-tool-restrictions.ts (boolean format for session.prompt)
// Keep all three in sync when modifying.
const athena = agentByKey(params.agentResult, "athena");
if (athena) {
athena.permission = {
...athena.permission,
task: "allow",
prepare_council_prompt: "allow",
question: questionPermission,
};
}
params.config.permission = {
...(params.config.permission as Record<string, unknown>),

View File

@@ -65,6 +65,7 @@ export function createPluginInterface(args: {
"tool.execute.before": createToolExecuteBeforeHandler({
ctx,
hooks,
backgroundManager: managers.backgroundManager,
}),
"tool.execute.after": createToolExecuteAfterHandler({

View File

@@ -10,17 +10,6 @@ import {
syncSubagentSessions,
updateSessionAgent,
} from "../features/claude-code-session-state";
import {
clearPendingModelFallback,
clearSessionFallbackChain,
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { resetMessageCursor } from "../shared";
import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier";
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools";
import type { CreatedHooks } from "../create-hooks";
import type { Managers } from "../create-managers";
@@ -135,6 +124,7 @@ export function createEventHandler(args: {
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
const dispatchToHooks = async (input: EventInput): Promise<void> => {
await Promise.resolve(hooks.agentSwitchHook?.event?.(input));
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));

View File

@@ -9,6 +9,7 @@ import {
createCompactionContextInjector,
createCompactionTodoPreserverHook,
createAtlasHook,
createAgentSwitchHook,
} from "../../hooks"
import { safeCreateHook } from "../../shared/safe-create-hook"
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
@@ -21,6 +22,7 @@ export type ContinuationHooks = {
unstableAgentBabysitter: ReturnType<typeof createUnstableAgentBabysitter> | null
backgroundNotificationHook: ReturnType<typeof createBackgroundNotificationHook> | null
atlasHook: ReturnType<typeof createAtlasHook> | null
agentSwitchHook: ReturnType<typeof createAgentSwitchHook> | null
}
type SessionRecovery = {
@@ -111,6 +113,10 @@ export function createContinuationHooks(args: {
}))
: null
const agentSwitchHook = isHookEnabled("agent-switch")
? safeHook("agent-switch", () => createAgentSwitchHook(ctx))
: null
return {
stopContinuationGuard,
compactionContextInjector,
@@ -119,5 +125,6 @@ export function createContinuationHooks(args: {
unstableAgentBabysitter,
backgroundNotificationHook,
atlasHook,
agentSwitchHook,
}
}

View File

@@ -2,6 +2,109 @@ const { describe, expect, test } = require("bun:test")
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
describe("createToolExecuteBeforeHandler", () => {
test("blocks Athena question tool while council members are still running", async () => {
//#given
const ctx = {
client: {
session: {
messages: async () => ({
data: [{ info: { role: "assistant", agent: "Athena (Council)" } }],
}),
},
},
}
const backgroundManager = {
getTasksByParentSession: () => [
{ agent: "Council: Claude Opus 4.6", status: "running" },
],
}
const handler = createToolExecuteBeforeHandler({
ctx,
hooks: {},
backgroundManager,
})
//#when
const run = handler(
{ tool: "question", sessionID: "ses_athena", callID: "call_1" },
{ args: { questions: [] } }
)
//#then
await expect(run).rejects.toThrow("Council members are still running")
})
test("blocks Athena switch_agent while council members are still running", async () => {
//#given
const ctx = {
client: {
session: {
messages: async () => ({
data: [{ info: { role: "assistant", agent: "Athena (Council)" } }],
}),
},
},
}
const backgroundManager = {
getTasksByParentSession: () => [
{ agent: "Council: GPT 5.2", status: "pending" },
],
}
const handler = createToolExecuteBeforeHandler({
ctx,
hooks: {},
backgroundManager,
})
//#when
const run = handler(
{ tool: "switch_agent", sessionID: "ses_athena", callID: "call_1" },
{ args: { agent: "atlas", context: "ctx" } }
)
//#then
await expect(run).rejects.toThrow("Council members are still running")
})
test("allows Athena question tool when no council members are pending", async () => {
//#given
const ctx = {
client: {
session: {
messages: async () => ({
data: [{ info: { role: "assistant", agent: "Athena (Council)" } }],
}),
},
},
}
const backgroundManager = {
getTasksByParentSession: () => [
{ agent: "Council: Claude Opus 4.6", status: "completed" },
{ agent: "Council: GPT 5.2", status: "cancelled" },
],
}
const handler = createToolExecuteBeforeHandler({
ctx,
hooks: {},
backgroundManager,
})
//#when
const run = handler(
{ tool: "question", sessionID: "ses_athena", callID: "call_1" },
{ args: { questions: [] } }
)
//#then
await expect(run).resolves.toBeUndefined()
})
test("does not execute subagent question blocker hook for question tool", async () => {
//#given
const ctx = {

View File

@@ -1,23 +1,54 @@
import type { PluginContext } from "./types"
import type { BackgroundManager } from "../features/background-agent"
import { getMainSessionID } from "../features/claude-code-session-state"
import { clearBoulderState } from "../features/boulder-state"
import { log } from "../shared"
import { resolveSessionAgent } from "./session-agent-resolver"
import { parseRalphLoopArguments } from "../hooks/ralph-loop/command-arguments"
import { getAgentConfigKey } from "../shared/agent-display-names"
import type { CreatedHooks } from "../create-hooks"
import { COUNCIL_MEMBER_KEY_PREFIX } from "../agents/builtin-agents/council-member-agents"
export function createToolExecuteBeforeHandler(args: {
ctx: PluginContext
hooks: CreatedHooks
backgroundManager?: Pick<BackgroundManager, "getTasksByParentSession">
}): (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> },
) => Promise<void> {
const { ctx, hooks } = args
const { ctx, hooks, backgroundManager } = args
function hasPendingCouncilMembers(sessionID: string): boolean {
if (!backgroundManager) {
return false
}
const tasks = backgroundManager.getTasksByParentSession(sessionID)
return tasks.some((task) =>
task.agent.startsWith(COUNCIL_MEMBER_KEY_PREFIX) &&
(task.status === "pending" || task.status === "running")
)
}
return async (input, output): Promise<void> => {
const toolNameLower = input.tool?.toLowerCase()
if (toolNameLower === "question" || toolNameLower === "askuserquestion" || toolNameLower === "ask_user_question" || toolNameLower === "switch_agent") {
if (hasPendingCouncilMembers(input.sessionID)) {
const sessionAgent = await resolveSessionAgent(ctx.client, input.sessionID)
const sessionAgentKey = sessionAgent ? getAgentConfigKey(sessionAgent) : undefined
if (sessionAgentKey === "athena") {
throw new Error(
"Council members are still running. Wait for all launched members to finish and collect their outputs before asking next-step questions or switching agents."
)
}
}
}
await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output)
await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output)
await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)

View File

@@ -10,6 +10,7 @@ import {
builtinTools,
createBackgroundTools,
createCallOmoAgent,
createSwitchAgentTool,
createLookAt,
createSkillMcpTool,
createSkillTool,
@@ -25,6 +26,7 @@ import {
createTaskList,
createTaskUpdateTool,
createHashlineEditTool,
createPrepareCouncilPromptTool,
} from "../tools"
import { getMainSessionID } from "../features/claude-code-session-state"
import { filterDisabledTools } from "../shared/disabled-tools"
@@ -101,6 +103,7 @@ export function createToolRegistry(args: {
mcpManager: managers.skillMcpManager,
getSessionID: getSessionIDForMcp,
gitMasterConfig: pluginConfig.git_master,
directory: ctx.directory,
})
const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false
@@ -126,6 +129,9 @@ export function createToolRegistry(args: {
...createSessionManagerTools(ctx),
...backgroundTools,
call_omo_agent: callOmoAgent,
switch_agent: createSwitchAgentTool({
client: ctx.client,
}),
...(lookAt ? { look_at: lookAt } : {}),
task: delegateTask,
skill_mcp: skillMcpTool,
@@ -133,6 +139,7 @@ export function createToolRegistry(args: {
interactive_bash,
...taskToolsRecord,
...hashlineToolsRecord,
prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory),
}
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)

View File

@@ -187,10 +187,12 @@ describe("AGENT_DISPLAY_NAMES", () => {
"sisyphus-junior": "Sisyphus-Junior",
metis: "Metis (Plan Consultant)",
momus: "Momus (Plan Critic)",
athena: "Athena (Council)",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
"multimodal-looker": "multimodal-looker",
"council-member": "council-member",
}
// when checking the constant

View File

@@ -11,10 +11,12 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
"sisyphus-junior": "Sisyphus-Junior",
metis: "Metis (Plan Consultant)",
momus: "Momus (Plan Critic)",
athena: "Athena (Council)",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
"multimodal-looker": "multimodal-looker",
"council-member": "council-member",
}
/**
@@ -51,4 +53,32 @@ export function getAgentConfigKey(agentName: string): string {
if (reversed !== undefined) return reversed
if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower
return lower
}
}
/**
* Normalize an agent name for prompt APIs.
* - Known display names -> canonical display names
* - Known config keys (any case) -> canonical display names
* - Unknown/custom names -> preserved as-is (trimmed)
*/
export function normalizeAgentForPrompt(agentName: string | undefined): string | undefined {
if (typeof agentName !== "string") {
return undefined
}
const trimmed = agentName.trim()
if (!trimmed) {
return undefined
}
const lower = trimmed.toLowerCase()
const reversed = REVERSE_DISPLAY_NAMES[lower]
if (reversed !== undefined) {
return AGENT_DISPLAY_NAMES[reversed] ?? trimmed
}
if (AGENT_DISPLAY_NAMES[lower] !== undefined) {
return AGENT_DISPLAY_NAMES[lower]
}
return trimmed
}

View File

@@ -0,0 +1,140 @@
/**
* Parity test: verifies Athena and council-member tool restrictions stay in sync
* across the 3 definition surfaces.
*
* Surface 1: src/agents/athena/agent.ts — createAgentToolRestrictions() deny-list
* Surface 2: src/shared/agent-tool-restrictions.ts — AGENT_RESTRICTIONS boolean map
* Surface 3: src/agents/athena/council-member-agent.ts — createAgentToolAllowlist() array
*
* This test FAILS if someone adds/removes a restriction in one surface without updating the others.
*/
import { describe, expect, it } from "bun:test"
import { getAgentToolRestrictions } from "./agent-tool-restrictions"
// Surface 1: Athena deny-list from src/agents/athena/agent.ts
// createAgentToolRestrictions(["write", "edit", "call_omo_agent"])
const ATHENA_DENY_LIST = ["write", "edit", "call_omo_agent"]
// Surface 3: Council-member allowlist from src/agents/athena/council-member-agent.ts
// createAgentToolAllowlist([...])
const COUNCIL_MEMBER_ALLOWLIST = [
"read",
"grep",
"glob",
"lsp_goto_definition",
"lsp_find_references",
"lsp_symbols",
"lsp_diagnostics",
"ast_grep_search",
"call_omo_agent",
"background_output",
]
// Tools granted to Athena by tool-config-handler.ts (not in deny-list, not in AGENT_RESTRICTIONS)
const ATHENA_HANDLER_GRANTS = ["task", "prepare_council_prompt"]
describe("agent tool restrictions parity", () => {
describe("given Athena restrictions", () => {
describe("#when comparing deny-list (agent.ts) with boolean map (agent-tool-restrictions.ts)", () => {
it("every tool in the deny-list has a matching false entry in AGENT_RESTRICTIONS", () => {
const athenaRestrictions = getAgentToolRestrictions("athena")
for (const tool of ATHENA_DENY_LIST) {
expect(
athenaRestrictions[tool],
`Tool "${tool}" is in the deny-list (agent.ts) but not false in AGENT_RESTRICTIONS["athena"]`
).toBe(false)
}
})
it("every false entry in AGENT_RESTRICTIONS is in the deny-list", () => {
const athenaRestrictions = getAgentToolRestrictions("athena")
const deniedInMap = Object.entries(athenaRestrictions)
.filter(([, value]) => value === false)
.map(([key]) => key)
for (const tool of deniedInMap) {
expect(
ATHENA_DENY_LIST,
`Tool "${tool}" is false in AGENT_RESTRICTIONS["athena"] but missing from deny-list (agent.ts)`
).toContain(tool)
}
})
it("deny-list and AGENT_RESTRICTIONS false-entries have the same length", () => {
const athenaRestrictions = getAgentToolRestrictions("athena")
const deniedInMap = Object.entries(athenaRestrictions)
.filter(([, value]) => value === false)
.map(([key]) => key)
expect(deniedInMap.length).toBe(ATHENA_DENY_LIST.length)
})
})
describe("#when checking handler grants do not conflict with deny-list", () => {
it("tools granted by tool-config-handler are NOT in the deny-list", () => {
for (const tool of ATHENA_HANDLER_GRANTS) {
expect(
ATHENA_DENY_LIST,
`Tool "${tool}" is granted by tool-config-handler but also in the deny-list — conflict!`
).not.toContain(tool)
}
})
it("tools granted by tool-config-handler are NOT false in AGENT_RESTRICTIONS", () => {
const athenaRestrictions = getAgentToolRestrictions("athena")
for (const tool of ATHENA_HANDLER_GRANTS) {
expect(
athenaRestrictions[tool],
`Tool "${tool}" is granted by tool-config-handler but is false in AGENT_RESTRICTIONS["athena"]`
).not.toBe(false)
}
})
})
})
describe("given council-member restrictions", () => {
describe("#when comparing allowlist (council-member-agent.ts) with boolean map (agent-tool-restrictions.ts)", () => {
it("every tool in the allowlist has a matching true entry in AGENT_RESTRICTIONS", () => {
const councilRestrictions = getAgentToolRestrictions("council-member")
for (const tool of COUNCIL_MEMBER_ALLOWLIST) {
expect(
councilRestrictions[tool],
`Tool "${tool}" is in the allowlist (council-member-agent.ts) but not true in AGENT_RESTRICTIONS["council-member"]`
).toBe(true)
}
})
it("every true entry in AGENT_RESTRICTIONS is in the allowlist", () => {
const councilRestrictions = getAgentToolRestrictions("council-member")
const allowedInMap = Object.entries(councilRestrictions)
.filter(([key, value]) => key !== "*" && value === true)
.map(([key]) => key)
for (const tool of allowedInMap) {
expect(
COUNCIL_MEMBER_ALLOWLIST,
`Tool "${tool}" is true in AGENT_RESTRICTIONS["council-member"] but missing from allowlist (council-member-agent.ts)`
).toContain(tool)
}
})
it("allowlist and AGENT_RESTRICTIONS true-entries have the same length", () => {
const councilRestrictions = getAgentToolRestrictions("council-member")
const allowedInMap = Object.entries(councilRestrictions)
.filter(([key, value]) => key !== "*" && value === true)
.map(([key]) => key)
expect(allowedInMap.length).toBe(COUNCIL_MEMBER_ALLOWLIST.length)
})
it("AGENT_RESTRICTIONS has wildcard deny (*: false) for council-member", () => {
const councilRestrictions = getAgentToolRestrictions("council-member")
expect(councilRestrictions["*"]).toBe(false)
})
})
})
})

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import { getAgentToolRestrictions } from "./agent-tool-restrictions"
describe("agent-tool-restrictions", () => {
test("athena restrictions include call_omo_agent", () => {
//#given
//#when
const restrictions = getAgentToolRestrictions("athena")
//#then
expect(restrictions.write).toBe(false)
expect(restrictions.edit).toBe(false)
expect(restrictions.call_omo_agent).toBe(false)
})
test("council-member restrictions include all denied tools", () => {
//#given
//#when
const restrictions = getAgentToolRestrictions("council-member")
//#then
// Wildcard deny key
expect(restrictions["*"]).toBe(false)
// Explicitly allowed tools
expect(restrictions.read).toBe(true)
expect(restrictions.grep).toBe(true)
expect(restrictions.call_omo_agent).toBe(true)
expect(restrictions.background_output).toBe(true)
// Explicitly denied tools
expect(restrictions.todowrite).toBe(false)
expect(restrictions.todoread).toBe(false)
// Unlisted tools are undefined (SDK applies wildcard at runtime)
expect(restrictions.switch_agent).toBeUndefined()
expect(restrictions.background_wait).toBeUndefined()
})
test("#given dynamic council member name #when getAgentToolRestrictions #then returns council-member restrictions", () => {
//#given
const dynamicName = "Council: Claude Opus 4.6"
//#when
const restrictions = getAgentToolRestrictions(dynamicName)
//#then
// Wildcard deny key
expect(restrictions["*"]).toBe(false)
// Explicitly allowed tools
expect(restrictions.read).toBe(true)
expect(restrictions.grep).toBe(true)
expect(restrictions.call_omo_agent).toBe(true)
expect(restrictions.background_output).toBe(true)
// Explicitly denied tools
expect(restrictions.todowrite).toBe(false)
expect(restrictions.todoread).toBe(false)
// Unlisted tools are undefined (SDK applies wildcard at runtime)
expect(restrictions.switch_agent).toBeUndefined()
expect(restrictions.write).toBeUndefined()
expect(restrictions.edit).toBeUndefined()
expect(restrictions.task).toBeUndefined()
expect(restrictions.background_wait).toBeUndefined()
})
})

View File

@@ -4,6 +4,8 @@
* true = tool allowed, false = tool denied.
*/
import { COUNCIL_MEMBER_KEY_PREFIX } from "../agents/builtin-agents/council-member-agents"
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
write: false,
edit: false,
@@ -42,16 +44,46 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
"sisyphus-junior": {
task: false,
},
athena: {
write: false,
edit: false,
call_omo_agent: false,
},
// NOTE: Athena/council tool restrictions are also defined in:
// - src/agents/athena/agent.ts (AgentConfig permission format)
// - src/agents/athena/council-member-agent.ts (AgentConfig permission format — allow-list)
// - src/plugin-handlers/tool-config-handler.ts (allow/deny string format)
// Keep all three in sync when modifying.
// Council members use an allow-list: read-only analysis + optional call_omo_agent delegation.
// TodoWrite/TodoRead explicitly denied to prevent uncompletable todo loops.
// Prompt file lives in .sisyphus/tmp/ (inside project) so no external_directory needed.
"council-member": {
"*": false,
read: true,
grep: true,
glob: true,
lsp_goto_definition: true,
lsp_find_references: true,
lsp_symbols: true,
lsp_diagnostics: true,
ast_grep_search: true,
call_omo_agent: true,
background_output: true,
todowrite: false,
todoread: false,
},
}
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
if (agentName.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) {
return AGENT_RESTRICTIONS["council-member"] ?? {}
}
return AGENT_RESTRICTIONS[agentName]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
?? {}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
const restrictions = AGENT_RESTRICTIONS[agentName]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
return restrictions !== undefined && Object.keys(restrictions).length > 0
}

View File

@@ -179,8 +179,8 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(hephaestus.requiresModel).toBeUndefined()
})
test("all 10 builtin agents have valid fallbackChain arrays", () => {
// #given - list of 10 agent names
test("all 12 builtin agents have valid fallbackChain arrays", () => {
// #given - list of 12 agent names
const expectedAgents = [
"sisyphus",
"hephaestus",
@@ -192,13 +192,15 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
"metis",
"momus",
"atlas",
"athena",
"council-member",
]
// when - checking AGENT_MODEL_REQUIREMENTS
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
// #then - all agents present with valid fallbackChain
expect(definedAgents).toHaveLength(10)
expect(definedAgents).toHaveLength(12)
for (const agent of expectedAgents) {
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
expect(requirement).toBeDefined()

View File

@@ -89,6 +89,21 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},
athena: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["opencode"], model: "glm-4.7-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
],
},
"council-member": {
fallbackChain: [
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
}
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {

View File

@@ -4,4 +4,6 @@ Use \`background_output\` to get results. Prompts MUST be in English.`
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. Use full_session=true to fetch session messages with filters. System notifies on completion, so block=true rarely needed.`
export const BACKGROUND_WAIT_DESCRIPTION = `Wait for the next background task to complete from a set of task IDs. Returns as soon as ANY one finishes, with its result and a progress summary. Call repeatedly with remaining IDs until all are done.`
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.`

View File

@@ -0,0 +1,132 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundOutputManager, BackgroundOutputClient } from "./clients"
import { BACKGROUND_WAIT_DESCRIPTION } from "./constants"
import { delay } from "./delay"
import { formatTaskResult } from "./task-result-format"
import { formatTaskStatus } from "./task-status-format"
const DEFAULT_TIMEOUT_MS = 120_000
const MAX_TIMEOUT_MS = 600_000
const TERMINAL_STATUSES = new Set(["completed", "error", "cancelled", "interrupt"])
function isTerminal(status: string): boolean {
return TERMINAL_STATUSES.has(status)
}
export function createBackgroundWait(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
return tool({
description: BACKGROUND_WAIT_DESCRIPTION,
args: {
task_ids: tool.schema.array(tool.schema.string()).describe("Task IDs to monitor — returns when ANY one reaches a terminal state"),
timeout: tool.schema.number().optional().describe("Max wait in ms. Default: 120000 (2 min). The tool returns immediately when any task finishes, so large values are fine."),
},
async execute(args: { task_ids: string[]; timeout?: number }, toolContext?: unknown) {
const abort = (toolContext as { abort?: AbortSignal } | undefined)?.abort
const taskIds = args.task_ids
if (!taskIds || taskIds.length === 0) {
return "Error: task_ids array is required and must not be empty."
}
const timeoutMs = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
const alreadyTerminal = findFirstTerminal(manager, taskIds)
if (alreadyTerminal) {
return await buildCompletionResult(alreadyTerminal, manager, client, taskIds)
}
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
if (abort?.aborted) {
return buildProgressSummary(manager, taskIds, true)
}
await delay(1000)
const found = findFirstTerminal(manager, taskIds)
if (found) {
return await buildCompletionResult(found, manager, client, taskIds)
}
}
return buildProgressSummary(manager, taskIds, true)
},
})
}
function findFirstTerminal(manager: BackgroundOutputManager, taskIds: string[]): { id: string; status: string } | undefined {
for (const id of taskIds) {
const task = manager.getTask(id)
if (!task) continue
if (isTerminal(task.status)) {
return { id, status: task.status }
}
}
return undefined
}
async function buildCompletionResult(
completed: { id: string; status: string },
manager: BackgroundOutputManager,
client: BackgroundOutputClient,
allIds: string[],
): Promise<string> {
const task = manager.getTask(completed.id)
if (!task) return `Task was deleted: ${completed.id}`
const taskResult = task.status === "completed"
? await formatTaskResult(task, client)
: formatTaskStatus(task)
const summary = buildProgressSummary(manager, allIds, false)
const remaining = allIds.filter((id) => !isTerminal(manager.getTask(id)?.status ?? ""))
const lines = [summary, "", "---", "", taskResult]
if (remaining.length > 0) {
const idList = remaining.map((id) => `"${id}"`).join(", ")
lines.push("", `**${remaining.length} task${remaining.length === 1 ? "" : "s"} still running.** Call background_wait again with task_ids: [${idList}]`)
} else {
lines.push("", "**All tasks complete.** Proceed with synthesis.")
}
return lines.join("\n")
}
function buildProgressSummary(manager: BackgroundOutputManager, taskIds: string[], isTimeout: boolean): string {
const done = taskIds.filter((id) => isTerminal(manager.getTask(id)?.status ?? ""))
const total = taskIds.length
const header = isTimeout
? `## Still Waiting: [${progressBar(done.length, total)}] ${done.length}/${total}`
: `## Council Progress: [${progressBar(done.length, total)}] ${done.length}/${total}`
const lines = [header, ""]
for (const id of taskIds) {
const t = manager.getTask(id)
if (!t) {
lines.push(`- \`${id}\` — not found`)
continue
}
const marker = isTerminal(t.status) ? "received" : "waiting..."
lines.push(`- ${t.description || t.id}${marker}`)
}
if (isTimeout) {
const remaining = taskIds.filter((id) => !isTerminal(manager.getTask(id)?.status ?? ""))
if (remaining.length > 0) {
const idList = remaining.map((id) => `"${id}"`).join(", ")
lines.push("", `**Timeout — tasks still running.** Call background_wait again with task_ids: [${idList}]`)
}
}
return lines.join("\n")
}
function progressBar(done: number, total: number): string {
const filled = "#".repeat(done)
const empty = "-".repeat(total - done)
return `${filled}${empty}`
}

View File

@@ -2,6 +2,7 @@ export {
createBackgroundTask,
createBackgroundOutput,
createBackgroundCancel,
createBackgroundWait,
} from "./tools"
export type * from "./types"

View File

@@ -9,3 +9,4 @@ export type {
export { createBackgroundTask } from "./create-background-task"
export { createBackgroundOutput } from "./create-background-output"
export { createBackgroundCancel } from "./create-background-cancel"
export { createBackgroundWait } from "./create-background-wait"

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test"
import { parseModelString } from "./model-string-parser"
describe("parseModelString", () => {
describe("valid model strings", () => {
//#given provider/model strings with one separator
//#when parsing model strings
//#then it returns providerID and modelID parts
test("parses anthropic model", () => {
expect(parseModelString("anthropic/claude-opus-4-6")).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-6",
})
})
test("parses openai model", () => {
expect(parseModelString("openai/gpt-5.3-codex")).toEqual({
providerID: "openai",
modelID: "gpt-5.3-codex",
})
})
test("parses google model", () => {
expect(parseModelString("google/gemini-3-flash")).toEqual({
providerID: "google",
modelID: "gemini-3-flash",
})
})
test("parses xai model", () => {
expect(parseModelString("xai/grok-code-fast-1")).toEqual({
providerID: "xai",
modelID: "grok-code-fast-1",
})
})
})
describe("edge cases", () => {
//#given a model string with extra slashes
//#when parsing with first slash as separator
//#then provider is before first slash and model keeps remaining path
test("keeps extra slashes in model segment", () => {
expect(parseModelString("provider/model/with/extra/slashes")).toEqual({
providerID: "provider",
modelID: "model/with/extra/slashes",
})
})
})
describe("invalid model strings", () => {
//#given malformed or empty model strings
//#when parsing model strings
//#then it returns undefined
test("returns undefined for empty string", () => {
expect(parseModelString("")).toBeUndefined()
})
test("returns undefined for model without slash", () => {
expect(parseModelString("no-slash-model")).toBeUndefined()
})
test("returns undefined for empty provider", () => {
expect(parseModelString("/missing-provider")).toBeUndefined()
})
test("returns undefined for empty model", () => {
expect(parseModelString("missing-model/")).toBeUndefined()
})
})
describe("whitespace handling", () => {
//#given model strings with whitespace
//#when parsing
//#then it rejects whitespace-only parts and trims valid parts
test("returns undefined for whitespace-only string", () => {
expect(parseModelString(" ")).toBeUndefined()
})
test("returns undefined for whitespace-only provider", () => {
expect(parseModelString(" /model")).toBeUndefined()
})
test("returns undefined for whitespace-only model", () => {
expect(parseModelString("provider/ ")).toBeUndefined()
})
test("trims whitespace from provider and model", () => {
expect(parseModelString(" openai / gpt-5.3-codex ")).toEqual({
providerID: "openai",
modelID: "gpt-5.3-codex",
})
})
})
})

View File

@@ -2,9 +2,21 @@
* Parse a model string in "provider/model" format.
*/
export function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
if (!model || !model.trim()) {
return undefined
}
return undefined
const slashIndex = model.indexOf("/")
if (slashIndex <= 0) {
return undefined
}
const providerID = model.substring(0, slashIndex).trim()
const modelID = model.substring(slashIndex + 1).trim()
if (!providerID || !modelID) {
return undefined
}
return { providerID, modelID }
}

View File

@@ -1,10 +1,9 @@
import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
import { discoverSkills } from "../../features/opencode-skill-loader"
import { resolveMultipleSkillsAsync, getAllSkills } from "../../features/opencode-skill-loader/skill-content"
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string>, directory?: string }
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider; disabledSkills?: Set<string>; directory: string }
): Promise<{ content: string | undefined; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, error: null }
@@ -12,7 +11,7 @@ export async function resolveSkillContent(
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true, directory: options?.directory })
const allSkills = await getAllSkills(options)
const available = allSkills.map(s => s.name).join(", ")
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
}

View File

@@ -25,6 +25,7 @@ export { createSkillMcpTool } from "./skill-mcp"
import {
createBackgroundOutput,
createBackgroundCancel,
createBackgroundWait,
type BackgroundOutputManager,
type BackgroundCancelClient,
} from "./background-task"
@@ -37,6 +38,7 @@ type OpencodeClient = PluginInput["client"]
export { createCallOmoAgent } from "./call-omo-agent"
export { createLookAt } from "./look-at"
export { createDelegateTask } from "./delegate-task"
export { createSwitchAgentTool } from "./switch-agent"
export {
createTaskCreateTool,
createTaskGetTool,
@@ -44,6 +46,7 @@ export {
createTaskUpdateTool,
} from "./task"
export { createHashlineEditTool } from "./hashline-edit"
export { createPrepareCouncilPromptTool } from "./prepare-council-prompt"
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
const outputManager: BackgroundOutputManager = manager
@@ -51,6 +54,7 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
return {
background_output: createBackgroundOutput(outputManager, client),
background_cancel: createBackgroundCancel(manager, cancelClient),
background_wait: createBackgroundWait(outputManager, client),
}
}

View File

@@ -0,0 +1 @@
export { createPrepareCouncilPromptTool } from "./tools"

View File

@@ -0,0 +1,68 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { randomUUID } from "node:crypto"
import { writeFile, unlink, mkdir } from "node:fs/promises"
import { join } from "node:path"
import { log } from "../../shared/logger"
import { COUNCIL_MEMBER_PROMPT, COUNCIL_SOLO_ADDENDUM, COUNCIL_DELEGATION_ADDENDUM } from "../../agents/athena"
const CLEANUP_DELAY_MS = 30 * 60 * 1000
const COUNCIL_TMP_DIR = ".sisyphus/tmp"
export function createPrepareCouncilPromptTool(directory: string): ToolDefinition {
const description = `Save a council analysis prompt to a temp file so council members can read it.
Athena-only tool. Saves the prompt once, then each council member task() call uses a short
"Read <path>" instruction instead of repeating the full question. This keeps task() calls
fast and small.
The "mode" parameter controls whether council members can delegate exploration to subagents:
- "solo" (default): Members do all exploration themselves. More thorough but uses more tokens.
- "delegation": Members can delegate to explore/librarian agents. Faster, lighter context.
Returns the file path to reference in subsequent task() calls.`
return tool({
description,
args: {
prompt: tool.schema.string().describe("The full analysis prompt/question for council members"),
mode: tool.schema.string().optional().describe('Analysis mode: "solo" (default) or "delegation"'),
},
async execute(args: { prompt: string; mode?: string }) {
if (!args.prompt?.trim()) {
return "Prompt cannot be empty."
}
const mode = args.mode === "delegation" ? "delegation" : "solo"
const tmpDir = join(directory, COUNCIL_TMP_DIR)
await mkdir(tmpDir, { recursive: true })
const filename = `athena-council-${randomUUID().slice(0, 8)}.md`
const filePath = join(tmpDir, filename)
const modeAddendum = mode === "delegation" ? COUNCIL_DELEGATION_ADDENDUM : COUNCIL_SOLO_ADDENDUM
const content = `${COUNCIL_MEMBER_PROMPT}
${modeAddendum}
## Analysis Question
${args.prompt}`
await writeFile(filePath, content, "utf-8")
setTimeout(() => {
unlink(filePath).catch((err) => {
log("[prepare-council-prompt] Failed to clean up temp file", { filePath, error: String(err) })
})
}, CLEANUP_DELAY_MS)
log("[prepare-council-prompt] Saved prompt", { filePath, length: args.prompt.length, mode })
return `Council prompt saved to: ${filePath} (mode: ${mode})
Use this path in each council member's task() call:
- prompt: "Read ${filePath} for your instructions."
The file auto-deletes after 30 minutes.`
},
})
}

View File

@@ -1,3 +1,3 @@
export * from "./constants"
export * from "./types"
export { skill, createSkillTool } from "./tools"
export { createSkillTool } from "./tools"

View File

@@ -181,7 +181,7 @@ async function formatMcpCapabilities(
return sections.join("\n")
}
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
export function createSkillTool(options: SkillLoadOptions): ToolDefinition {
let cachedSkills: LoadedSkill[] | null = null
let cachedCommands: CommandInfo[] | null = options.commands ?? null
let cachedDescription: string | null = null
@@ -189,7 +189,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
const getSkills = async (): Promise<LoadedSkill[]> => {
if (options.skills) return options.skills
if (cachedSkills) return cachedSkills
cachedSkills = await getAllSkills({disabledSkills: options?.disabledSkills})
cachedSkills = await getAllSkills({disabledSkills: options?.disabledSkills, directory: options?.directory})
return cachedSkills
}
@@ -250,7 +250,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
body = injectGitMasterConfig(body, options.gitMasterConfig)
}
const dir = matchedSkill.path ? dirname(matchedSkill.path) : matchedSkill.resolvedPath || process.cwd()
const dir = matchedSkill.path ? dirname(matchedSkill.path) : matchedSkill.resolvedPath || options.directory
const output = [
`## Skill: ${matchedSkill.name}`,
@@ -309,5 +309,3 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
},
})
}
export const skill: ToolDefinition = createSkillTool()

View File

@@ -33,4 +33,6 @@ export interface SkillLoadOptions {
/** Git master configuration for watermark/co-author settings */
gitMasterConfig?: GitMasterConfig
disabledSkills?: Set<string>
/** Project directory for skill discovery and base directory resolution. Must be ctx.directory from PluginContext — process.cwd() is unsafe in OpenCode. */
directory: string
}

View File

@@ -0,0 +1 @@
export { createSwitchAgentTool } from "./tools"

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