Compare commits

...

130 Commits

Author SHA1 Message Date
github-actions[bot]
7e0ab828f9 release: v3.5.1 2026-02-11 01:01:58 +00:00
YeonGyu-Kim
13d960f3ca fix(look-at): revert to sync prompt to fix race condition with async polling
df0b9f76 regressed look_at from synchronous prompt (session.prompt) to
async prompt (session.promptAsync) + pollSessionUntilIdle polling. This
introduced a race condition where the poller fires before the server
registers the session as busy, causing it to return immediately with no
messages available.

Fix: restore promptSyncWithModelSuggestionRetry (blocking HTTP call) and
remove polling entirely. Catch prompt errors gracefully and still attempt
to fetch messages, since session.prompt may throw even on success.
2026-02-11 09:59:00 +09:00
github-actions[bot]
687cc2386f @marlon-costa-dc has signed the CLA in code-yeongyu/oh-my-opencode#1726 2026-02-10 18:50:08 +00:00
github-actions[bot]
d88449b1e2 @sjawhar has signed the CLA in code-yeongyu/oh-my-opencode#1727 2026-02-10 17:44:05 +00:00
github-actions[bot]
074d8dff09 release: v3.5.0 2026-02-10 16:25:32 +00:00
YeonGyu-Kim
fba916db60 fix(atlas): await injectBoulderContinuation and handle errors
The async call was fire-and-forget with no error handling. Now properly
awaited with try/catch that logs failures and increments promptFailureCount.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
f727aab892 fix(skill-mcp): redact sensitive query params from URLs in error messages
API keys passed as query parameters (exaApiKey, tokens, secrets) were
exposed in thrown error messages. Now replaces them with ***REDACTED***.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
686f32929c fix(cli-run): handle retry status type as non-idle in event handlers
Session status 'retry' was unhandled, leaving mainSessionIdle=true
during retries which could cause premature completion detection.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
af7733f89f fix(config-migration): always apply migration in-memory and track backup success
Migration changes were only applied to rawConfig if file write succeeded,
leaving the running process on stale config. Also stops logging backup
path when the backup copy itself failed.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
3553ab79e1 fix(git-worktree): use trimEnd instead of trim to preserve leading whitespace
Git status porcelain output uses leading spaces for status indicators;
trim() was stripping them which could break parsing.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
fb19e544c9 fix(cli): add backup and crash recovery to auth-plugins config write
Creates .bak before writeFileSync; on failure restores from backup
and returns a descriptive error instead of corrupting the config.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
88e1e3d0fa fix(ralph-loop): only scan text parts for completion tags and handle both API shapes
Reasoning parts could contain completion-like text triggering false
positives. Also handles session.messages returning either an array
or {data: [...]} shape.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
11d1e70067 fix(agents): wire useTaskSystem config flag into Sisyphus and Hephaestus
The experimental.task_system flag was defined in config but never
passed through to agent creation, so the task system prompt switch
was always off.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
17c56d8814 fix(mcp): restore x-api-key header for EXA websearch alongside query param
The header-based auth was removed during refactoring; some MCP server
implementations require it. Now sends both query param and header.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
6694082a7e fix(atlas): correct plan path from .sisyphus/tasks/*.yaml to .sisyphus/plans/*.md
The verification reminder template was pointing at the wrong directory;
actual plan files are stored under .sisyphus/plans/ as markdown.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
f9d3a9493a fix(model-suggestion-retry): add 120s timeout to promptAsync call
Wraps promptAsync with Promise.race to prevent indefinite hangs
when the interactive prompt never resolves.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
7427922e6f fix(delegate-task): ensure subagentSessions cleanup on all exit paths
Added outer finally block so subagentSessions.delete(syncSessionID)
runs even on early return from sendSyncPrompt error.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
ea1b22454d fix(comment-checker): add 30s hard timeout to CLI spawn
If the comment-checker binary hangs, Promise.race with a 30s timeout
kills the process and returns a safe fallback {hasComments: false}.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
a8681a9ffe fix(session-recovery): return success=false for assistant_prefill_unsupported
Returning true tricked the system into thinking recovery succeeded,
triggering auto-continue which hit the same error again in an infinite loop.
2026-02-11 00:45:51 +09:00
YeonGyu-Kim
c677042f05 fix(cli-run): set default timeout to 10 minutes and attach immediate .catch() on event processor
DEFAULT_TIMEOUT_MS was 0 (no timeout), causing opencode run to hang forever
if the session never completed. Also attached .catch() to processEvents()
immediately to prevent unhandled promise rejections before Promise.race.
2026-02-11 00:45:51 +09:00
github-actions[bot]
25c7337fd1 @RobertWsp has signed the CLA in code-yeongyu/oh-my-opencode#1723 2026-02-10 15:33:50 +00:00
github-actions[bot]
b4768014e0 @materializerx has signed the CLA in code-yeongyu/oh-my-opencode#1724 2026-02-10 15:22:25 +00:00
YeonGyu-Kim
162701f56e test(delegate-task): validate sync prompt tool restrictions 2026-02-10 22:54:48 +09:00
YeonGyu-Kim
087ce06055 refactor(delegate-task): inject sync task deps for test isolation 2026-02-10 22:54:30 +09:00
YeonGyu-Kim
967058fe3d fix(delegate-task): stabilize sync session polling 2026-02-10 22:52:17 +09:00
YeonGyu-Kim
257eb9277b fix(atlas): restrict boulder continuation to sessions in boulder session_ids
Main session was unconditionally allowed through the boulder session guard,
causing continuation injection into sessions not part of the active boulder.
Now only sessions explicitly in boulder's session_ids (or background tasks)
receive boulder continuation, matching todo-continuation-enforcer behavior.
2026-02-10 22:15:28 +09:00
YeonGyu-Kim
2b87719c83 docs: document intentional design decisions in atlas, todo-continuation, and delegation hooks 2026-02-10 22:00:54 +09:00
YeonGyu-Kim
1199e2b839 fix(background): Wave 2 - fix interrupt status checks, display text, error recovery grace, LSP JSONC
- fix(background): include "interrupt" status in all terminal status checks (3 files)
- fix(background): display "INTERRUPTED" instead of "CANCELLED" for interrupted tasks
- fix(cli): add error recovery grace period in poll-for-completion
- fix(lsp): use JSONC parser for config loading to support comments

All changes verified with tests and typecheck.
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
df0b9f7664 fix(delegate-task): Wave 1 - fix polling timeout, resource cleanup, tool restrictions, idle dedup, auth-plugins JSONC, CLI runner hang
- fix(delegate-task): return error on poll timeout instead of silent null
- fix(delegate-task): ensure toast and session cleanup on all error paths with try/finally
- fix(delegate-task): apply agent tool restrictions in sync-prompt-sender
- fix(plugin): add symmetric idle dedup to prevent double hook triggers
- fix(cli): replace regex-based JSONC editing with jsonc-parser in auth-plugins
- fix(cli): abort event stream after completion and restore no-timeout default

All changes verified with tests and typecheck.
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
7fe1a653c8 fix(tests): stabilize toast manager and continuation tests 2026-02-10 22:00:54 +09:00
YeonGyu-Kim
2bf11a8ed7 feat(prometheus): allow bash commands for Prometheus agent
Remove bash tool restriction from prometheus-md-only hook. Prometheus
can now execute bash commands for better plan generation context.
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
fe1faa6d0f docs(tasks): add TODO sync documentation to AGENTS.md
- Add comprehensive TODO SYNC section documenting automatic
  bidirectional sync between tasks and OpenCode todo system
- Improve sync-continuation.test.ts with proper mock modules
  for pollSyncSession and fetchSyncResult dependencies
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
6d17ac7d3a docs(tools): update AGENTS.md to document individual task tools
Replace unified 'task' tool documentation with 4 individual tools:

- task_create: Create task with auto-generated T-{uuid} ID

- task_list: List active tasks with summary

- task_get: Retrieve full task object by ID

- task_update: Update task fields with dependency support

Add detailed TASK TOOLS section with args tables and usage examples.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
5a527e214a test(sync-continuation): add comprehensive test coverage
- Add tests for sync-continuation error paths and toast cleanup
- Add tests for sync-result-fetcher with anchor message support
- Expand sync-session-poller tests for edge cases and completion detection
- Add bulk cleanup test for recent-synthetic-idles
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
231e790a0c fix(sync-continuation): improve error handling and toast cleanup
- Add proper error handling in executeSyncContinuation with try-catch blocks
- Ensure toast cleanup happens in all error paths via finally block
- Add anchorMessageCount tracking for accurate result fetching after continuation
- Improve fetchSyncResult to filter messages after anchor point
- Add silent failure detection when no new response is generated
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
45dfc4ec66 feat(atlas): enforce mandatory manual code review and direct boulder state checks
- VERIFICATION_REMINDER: add Step 2 manual code review (non-negotiable)
  - Require Read of EVERY changed file line by line
  - Cross-check subagent claims vs actual code
  - Verify logic correctness, completeness, edge cases, patterns
- Add Step 5: direct boulder state check via Read plan file
  - Count remaining tasks directly, no cached state
- BOULDER_CONTINUATION_PROMPT: add first rule to read plan file immediately
- verification-reminders.ts: restructure steps 5-8 for boulder/todo checks
- Atlas default.ts (Claude): enhance 3.4 QA with A/B/C/D sections
  - A: Automated verification
  - B: Manual code review (non-negotiable)
  - C: Hands-on QA (if applicable)
  - D: Check boulder state directly
- Atlas gpt.ts (GPT-5.2): apply same QA enhancements with GPT-optimized structure
- verification_rules: update both Claude and GPT versions with manual review requirements

Addresses issue where Atlas would skip manual code inspection after delegation,
leading to rubber-stamping of broken or incomplete work.
2026-02-10 22:00:54 +09:00
YeonGyu-Kim
f84ef532c1 fix(todo-continuation-enforcer): require boulder session for continuation
The todo-continuation-enforcer was firing boulder continuation in ALL main
sessions with incomplete todos, regardless of whether /start-work was ever
executed. This caused unwanted BOULDER CONTINUATION directives in sessions
that never invoked /start-work.

Changes:
- Add readBoulderState check in idle-event.ts to verify session is registered
  in boulder.json's session_ids array
- Change filter condition from main session check to boulder session check
- Add 4 new test cases for boulder session gate behavior
- Update all existing 41 tests to set up boulder state appropriately

Now boulder continuation only fires when:
1. Session is in boulder.json's session_ids (/start-work was executed), OR
2. Session is a background task session (subagent)

TDD cycle:
- RED: 2 new tests failed as expected (no boulder check in implementation)
- GREEN: Implementation added, all 41 tests pass
- REFACTOR: Full test suite 2513 pass, typecheck & build clean
2026-02-10 22:00:53 +09:00
github-actions[bot]
563da9470d @cyberprophet has signed the CLA in code-yeongyu/oh-my-opencode#1717 2026-02-10 12:06:15 +00:00
github-actions[bot]
a8a4f54428 @lxia1220 has signed the CLA in code-yeongyu/oh-my-opencode#1713 2026-02-10 06:43:45 +00:00
YeonGyu-Kim
83f1304e01 docs(agents): regenerate all AGENTS.md with deep codebase analysis 2026-02-10 14:53:39 +09:00
YeonGyu-Kim
b538806d5e docs(agents): add merge commit policy to PR rules section 2026-02-10 14:24:18 +09:00
YeonGyu-Kim
a25d8dfdae refactor(prompts): enrich explore/librarian delegation examples with structured context handoff
Expand prompt structure comment to 4-field format (CONTEXT/GOAL/DOWNSTREAM/REQUEST).
Update all explore/librarian task() examples across Sisyphus, Hephaestus,
Prometheus interview-mode, and both ultrawork variants with richer context
including downstream usage, scope limits, and return format expectations.
2026-02-10 14:24:18 +09:00
YeonGyu-Kim
4f9cec434b Merge pull request #1709 from code-yeongyu/feature/comment-checker-apply-patch
feat(comment-checker): support apply_patch
2026-02-10 14:17:28 +09:00
YeonGyu-Kim
f3f5b98c68 test: use BDD markers in pruneRecentSyntheticIdles test 2026-02-10 14:13:28 +09:00
YeonGyu-Kim
97b7215848 fix(event): prune synthetic idle dedup map 2026-02-10 14:08:02 +09:00
YeonGyu-Kim
61531ca26c feat(comment-checker): run checks for apply_patch edits 2026-02-10 13:58:34 +09:00
YeonGyu-Kim
19a4324b3e fix(provider-cache): extract models from provider.list().all response
OpenCode SDK does not expose client.model.list API. This caused the
provider-models cache to always be empty (models: {}), which in turn
caused delegate-task categories with requiresModel (e.g., 'deep',
'artistry') to fail with misleading 'Unknown category' errors.

Changes:
- connected-providers-cache.ts: Extract models from provider.list()
  response's .all array instead of calling non-existent client.model.list
- category-resolver.ts: Distinguish between 'unknown category' and
  'model not available' errors with clearer error messages
- Add comprehensive tests for both fixes

Bug chain:
client.model?.list is undefined -> empty cache -> isModelAvailable
returns false for requiresModel categories -> null returned from
resolveCategoryConfig -> 'Unknown category' error (wrong message)
2026-02-10 13:25:49 +09:00
YeonGyu-Kim
2fd847d88d refactor: fix import path and update test fixtures
- Fix import path in opencode-skill-loader/loader.ts
- Update executor.test.ts fixtures
2026-02-10 11:41:45 +09:00
YeonGyu-Kim
1717050f73 feat(event): normalize session.status to session.idle
Add session-status-normalizer to handle session.status events and
convert idle status to synthetic session.idle events. Includes
deduplication logic to prevent duplicate idle events within 500ms.
2026-02-10 11:41:45 +09:00
YeonGyu-Kim
44675fb57f fix(atlas): allow boulder continuation for Sisyphus sessions
When boulderState.agent is not explicitly set (defaults to 'atlas'),
allow continuation for sessions where the last agent is 'sisyphus'.
This fixes the issue where boulder continuation was skipped when
Sisyphus took over the conversation after boulder creation.
2026-02-10 11:41:44 +09:00
YeonGyu-Kim
7255fec8b3 test(git-worktree): fix test pollution from incomplete fs mock
Replace mock.module with spyOn + mockRestore to prevent fs module
pollution across test files. mock.module replaces the entire module
and caused 69 test failures in other files that depend on fs.
2026-02-10 11:41:44 +09:00
YeonGyu-Kim
fecc488848 fix(sisyphus-junior): disambiguate blocked delegation tool from allowed task management tools
When task_system is enabled, the prompt said 'task tool: BLOCKED' which
LLMs interpreted as blocking task_create/task_update/task_list/task_get
too. Now the constraints section explicitly separates 'task (agent
delegation tool): BLOCKED' from 'task_create, task_update, ...: ALLOWED'
so Junior no longer refuses to use task management tools.
2026-02-10 11:41:44 +09:00
YeonGyu-Kim
b45af0e4d2 Merge pull request #1703 from nianyi778/add-elestyle-to-loved-by
Add ELESTYLE to 'Loved by professionals at' section
2026-02-10 11:26:40 +09:00
likai
25be4ab905 Add ELESTYLE to 'Loved by professionals at' section 2026-02-10 10:43:09 +09:00
github-actions[bot]
4f03aea0a1 @nianyi778 has signed the CLA in code-yeongyu/oh-my-opencode#1703 2026-02-10 01:41:30 +00:00
YeonGyu-Kim
0565ce839e fix(cli/run): handle session.status idle event in addition to deprecated session.idle 2026-02-09 21:12:11 +09:00
YeonGyu-Kim
bb2df9fec6 fix(cli/run): set default timeout to 30 minutes to match help text 2026-02-09 21:12:00 +09:00
YeonGyu-Kim
564bb20f6a fix(cli/run): move error check before idle/tool gates in pollForCompletion 2026-02-09 21:11:48 +09:00
YeonGyu-Kim
096233b23f fix(config-manager): replace heuristic JSONC editing with jsonc-parser modify/applyEdits 2026-02-09 21:11:40 +09:00
YeonGyu-Kim
7eb67521cb fix(agent-config): pass useTaskSystem to sisyphus-junior when task_system is enabled
sisyphus-junior prompt always used todo-based discipline text regardless of
experimental.task_system setting because the useTaskSystem flag was never
forwarded from agent-config-handler to createSisyphusJuniorAgentWithOverrides.
2026-02-09 21:10:15 +09:00
YeonGyu-Kim
498fda11a0 feat(background-agent): handle "interrupt" in notifications, output, and formatting
Update notification systems to display INTERRUPTED status.

Add interrupt handling to background_output tool (terminal status).

Add interrupt-specific status note to formatTaskStatus.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-09 18:26:16 +09:00
YeonGyu-Kim
5b34a98e0a feat(background-agent): use "interrupt" status for promptAsync errors
Change promptAsync catch blocks to set status = "interrupt" instead of "error".

This distinguishes prompt errors from stale timeouts (cancelled) and TTL expirations (error).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-09 18:25:54 +09:00
YeonGyu-Kim
a37259326a feat(background-agent): add "interrupt" to BackgroundTaskStatus type
Add interrupt as a terminal status for background tasks that fail due to promptAsync errors (e.g., prompt exceed, agent not found).

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-09 18:25:34 +09:00
YeonGyu-Kim
a5bdb64933 fix(delegation): restore category to sisyphus-junior auto-mapping
Category-based delegation should always route to sisyphus-junior even if
subagent_type is mistakenly provided, matching the original behavior and
preventing accidental bypass of category routing.
2026-02-09 16:14:30 +09:00
YeonGyu-Kim
11f587194f fix(delegation): replace message-count-stability polling with native finish-based completion detection
Sync task completion was fragile — detecting premature stability during
brief idle periods between tool calls. Now mirrors opencode's native
SessionPrompt.loop() logic: checks assistant finish reason is terminal
(not tool-calls/unknown) and assistant.id > user.id.

Also switches sync prompt sender from blocking HTTP (promptSync) to
async fire-and-forget (promptAsync) to avoid JSON parse errors in ACP.
2026-02-09 15:37:19 +09:00
YeonGyu-Kim
20d009964d docs: refresh all 13 hierarchical AGENTS.md files with current codebase state 2026-02-09 14:29:53 +09:00
YeonGyu-Kim
f22f14d9d1 fix(look-at): catch prompt errors gracefully instead of re-throwing
session.prompt() may throw {} or JSON parse errors even when the server
successfully processes the request. Instead of crashing the tool, catch
all errors and proceed to fetch messages — if the response is available,
return it; otherwise return a clean error string.
2026-02-09 14:18:24 +09:00
YeonGyu-Kim
3d5abb950e refactor: enforce modular code rules — split 25+ files, rename catch-all modules, SRP compliance
refactor: enforce modular code architecture (waves 1-2)
2026-02-09 13:39:36 +09:00
YeonGyu-Kim
c71f0aa700 merge: integrate origin/dev (5th merge) — resolve @path skill references in split file structure 2026-02-09 12:08:15 +09:00
YeonGyu-Kim
70ac962fca feat: auto-resolve @path references in skill templates to absolute paths
Skill loaders previously only told agents that @path references are
relative to the skill directory, but agents often failed to resolve
them. Now @path/with/slash patterns are automatically expanded to
absolute paths during template construction.
2026-02-09 12:04:41 +09:00
YeonGyu-Kim
133da2624a fix(config-manager): guard against non-array plugin values in auth-plugins 2026-02-09 12:00:24 +09:00
YeonGyu-Kim
6a91d72a72 fix(agents): remove duplicate category override application in general-agents 2026-02-09 12:00:11 +09:00
YeonGyu-Kim
b0202e23f7 fix(agents): sanitize custom agent names for markdown table safety 2026-02-09 12:00:01 +09:00
YeonGyu-Kim
c4572a25fb fix(config-manager): skip string literals when counting braces in JSONC provider replacement 2026-02-09 11:59:50 +09:00
YeonGyu-Kim
554926209d fix(git-worktree): use Node readFileSync for cross-platform untracked file line counts 2026-02-09 11:45:29 +09:00
YeonGyu-Kim
0e49214ee7 fix(background-agent): rename getCompletedTasks to getNonRunningTasks for semantic accuracy 2026-02-09 11:45:20 +09:00
YeonGyu-Kim
edc3317e37 fix(git-worktree): compute real line counts for untracked files in diff stats 2026-02-09 11:36:35 +09:00
YeonGyu-Kim
7fdba56d8f fix(background-agent): align getCompletedTasks filter with state manager semantics 2026-02-09 11:36:29 +09:00
YeonGyu-Kim
247940bf02 fix: address Cubic background-agent issues — task status filter, array response handling, error mapping, concurrency key, duration fallback, output validation 2026-02-09 11:19:39 +09:00
YeonGyu-Kim
d6fbe7bd8d fix: address Cubic CLI and agent issues — URL encode, JSONC leading comments, config clone, untracked files, parse error handling, cache path, message-dir dedup 2026-02-09 11:17:51 +09:00
YeonGyu-Kim
5ca3d9c489 fix: address remaining Cubic issues — reset lastPartText on new message, TTY guard for installer, filter disabled skills, local-dev version resolution 2026-02-09 11:01:38 +09:00
YeonGyu-Kim
e5abf8702e merge: integrate origin/dev (4th merge) 2026-02-09 10:59:39 +09:00
YeonGyu-Kim
8dd07973a9 Merge pull request #1685 from code-yeongyu/fix/run-completion-race-condition
fix: prevent run completion race condition with consecutive stability checks
2026-02-09 10:58:37 +09:00
YeonGyu-Kim
e55fc1f14c fix: prevent run completion race condition with consecutive stability checks
pollForCompletion exited immediately when session went idle before agent
created TODOs or registered children (0 todos + 0 children = vacuously
complete). Add consecutive stability checks (3x500ms debounce) and
currentTool guard to prevent premature exit.

Extract pollForCompletion to dedicated module for testability.
2026-02-09 10:41:51 +09:00
github-actions[bot]
f07e364171 @mrm007 has signed the CLA in code-yeongyu/oh-my-opencode#1680 2026-02-08 21:41:45 +00:00
github-actions[bot]
e26c355c76 @aliozdenisik has signed the CLA in code-yeongyu/oh-my-opencode#1676 2026-02-08 17:12:45 +00:00
github-actions[bot]
5f9c3262a2 @JunyeongChoi0 has signed the CLA in code-yeongyu/oh-my-opencode#1674 2026-02-08 16:02:43 +00:00
YeonGyu-Kim
a1d7f9e822 fix: guard against missing brace in JSONC provider replacement 2026-02-08 22:43:02 +09:00
YeonGyu-Kim
06d265c1de fix: use brace-depth matching for JSONC provider replacement instead of fragile regex 2026-02-08 22:38:51 +09:00
YeonGyu-Kim
8a2c3cc98d fix: address Cubic round 5 issues — prototype-pollution guard, URL-encode, JSONC preservation, config-context warning, dynamic config path 2026-02-08 22:35:16 +09:00
YeonGyu-Kim
be03e27faf chore: trigger re-review 2026-02-08 22:14:39 +09:00
YeonGyu-Kim
2834445067 fix: guard interactive prompts on both stdin and stdout TTY 2026-02-08 22:09:12 +09:00
YeonGyu-Kim
7331cbdea2 fix: address Cubic P2 issues in doctor checks and agent overrides 2026-02-08 22:03:58 +09:00
YeonGyu-Kim
babcb0050a fix: address Cubic P2 issues in CLI modules 2026-02-08 21:57:34 +09:00
YeonGyu-Kim
ce37924fd8 Merge remote-tracking branch 'origin/dev' into refactor/modular-code-enforcement
# Conflicts:
#	src/features/background-agent/manager.ts
#	src/features/background-agent/spawner.ts
#	src/features/tmux-subagent/manager.ts
#	src/shared/model-availability.test.ts
#	src/shared/model-availability.ts
#	src/shared/model-resolution-pipeline.ts
#	src/tools/delegate-task/executor.ts
2026-02-08 21:43:57 +09:00
YeonGyu-Kim
71728e1546 fix: integrate dev model-availability changes lost during merge 2026-02-08 21:32:52 +09:00
YeonGyu-Kim
f67a4df07e fix: integrate dev background_output task_id title resolution 2026-02-08 21:24:08 +09:00
YeonGyu-Kim
9353ac5b9d fix: integrate dev CLAUDE_CODE_TASK_LIST_ID env var support 2026-02-08 21:23:21 +09:00
YeonGyu-Kim
fecc6b8605 fix: remove task-continuation-enforcer references after dev merge
Dev removed task-continuation-enforcer entirely. Remove all remaining
references from plugin hooks, event handler, tool-execute-before, and
config schema to align with origin/dev.
2026-02-08 21:11:07 +09:00
YeonGyu-Kim
46a30cd7ec Merge remote-tracking branch 'origin/dev' into refactor/modular-code-enforcement
# Conflicts:
#	src/agents/utils.ts
#	src/config/schema.ts
#	src/features/background-agent/spawner/background-session-creator.ts
#	src/features/background-agent/spawner/parent-directory-resolver.ts
#	src/features/background-agent/spawner/tmux-callback-invoker.ts
#	src/features/tmux-subagent/manager.ts
#	src/hooks/interactive-bash-session/index.ts
#	src/hooks/task-continuation-enforcer.test.ts
#	src/index.ts
#	src/plugin-handlers/config-handler.test.ts
#	src/tools/background-task/tools.ts
#	src/tools/call-omo-agent/tools.ts
#	src/tools/delegate-task/executor.ts
2026-02-08 19:05:41 +09:00
YeonGyu-Kim
cbb7771525 fix: prevent command injection in git diff stats collection
Replace execSync with string commands with execFileSync using argument
arrays to avoid shell interpretation of file paths with special chars.
2026-02-08 18:39:36 +09:00
YeonGyu-Kim
d5f0e75b7d fix: restore permission config in background session creation
Add permission: [{ permission: 'question', action: 'deny', pattern: '*' }]
to client.session.create() call to prevent background sessions from
asking questions that go unanswered, causing hangs.
2026-02-08 18:39:36 +09:00
YeonGyu-Kim
c9be2e1696 refactor: extract model selection logic from delegate-task into focused modules
- Create available-models.ts for model availability checking
- Create model-selection.ts for category-to-model resolution logic
- Update category-resolver, subagent-resolver, and sync modules to import
  from new focused modules instead of monolithic sources
2026-02-08 18:03:15 +09:00
YeonGyu-Kim
caf08af88b fix: resolve test isolation failures in task-continuation-enforcer and config-handler tests
- Change BackgroundManager import to type-only to prevent global process
  listener pollution across parallel test files
- Replace real BackgroundManager construction with createMockBackgroundManager
- Fix nested spyOn in config-handler tests to reuse beforeEach spy via
  mockResolvedValue instead of re-spying inside test bodies
2026-02-08 18:03:08 +09:00
YeonGyu-Kim
f3f6ba47fe merge: integrate origin/dev into modular-enforcement branch
Resolves all merge conflicts, preserving our split module structure
while integrating all dev changes:
- Custom agent summaries support (parseRegisteredAgentSummaries)
- Background notification queue (enqueueNotificationForParent)
- Atlas shared git-worktree module (collectGitDiffStats, formatFileChanges)
- Ralph-loop withTimeout + DEFAULT_API_TIMEOUT=5000
- Session recovery assistant_prefill_unsupported error type
- Atlas agentOverrides forwarding
- Config handler plan model demotion (buildPlanDemoteConfig)
- Delegate-task agentOverrides, promptSyncWithModelSuggestionRetry, variant
- LSP init timeout + stale init detection
- isPlanFamily function + task-continuation-enforcer hook
- Handoff command
2026-02-08 17:34:47 +09:00
YeonGyu-Kim
598a4389d1 refactor(core): split index.ts and config-handler.ts into focused modules
Main entry point:
- create-hooks.ts, create-tools.ts, create-managers.ts
- plugin-interface.ts: plugin interface types
- plugin/ directory: plugin lifecycle modules

Config handler:
- agent-config-handler.ts, command-config-handler.ts
- tool-config-handler.ts, mcp-config-handler.ts
- provider-config-handler.ts, category-config-resolver.ts
- agent-priority-order.ts, prometheus-agent-config-builder.ts
- plugin-components-loader.ts
2026-02-08 16:25:25 +09:00
YeonGyu-Kim
d525958a9d refactor(cli): split install.ts and model-fallback.ts into focused modules
Install pipeline:
- cli-installer.ts, tui-installer.ts, tui-install-prompts.ts
- install-validators.ts

Model fallback:
- model-fallback-types.ts, fallback-chain-resolution.ts
- provider-availability.ts, provider-model-id-transform.ts
2026-02-08 16:25:12 +09:00
YeonGyu-Kim
3c1e71f256 refactor(cli): split doctor/model-resolution and run/events into focused modules
Doctor checks:
- model-resolution-cache.ts, model-resolution-config.ts
- model-resolution-details.ts, model-resolution-effective-model.ts
- model-resolution-types.ts, model-resolution-variant.ts

Run events:
- event-formatting.ts, event-handlers.ts
- event-state.ts, event-stream-processor.ts
2026-02-08 16:25:01 +09:00
YeonGyu-Kim
4e5792ce4d refactor(shared): split model-availability.ts into model resolution modules
Extract model availability checking pipeline:
- available-models-fetcher.ts: top-level model fetching orchestration
- model-cache-availability.ts, models-json-cache-reader.ts
- provider-models-cache-model-reader.ts: provider cache reading with null guard
- fallback-model-availability.ts, model-name-matcher.ts
- open-code-client-accessors.ts, open-code-client-shapes.ts
- record-type-guard.ts
2026-02-08 16:24:52 +09:00
YeonGyu-Kim
052beb364f refactor(task-tool): split task.ts into per-action modules
Extract CRUD actions into dedicated modules:
- task-action-create.ts, task-action-get.ts
- task-action-list.ts, task-action-update.ts, task-action-delete.ts
- task-id-validator.ts: ID validation logic
2026-02-08 16:24:43 +09:00
YeonGyu-Kim
4400e18a52 refactor(slashcommand): split tools.ts into discovery and formatting modules
Extract slash command tool internals:
- command-discovery.ts: command finding and listing
- command-output-formatter.ts: output formatting
- skill-command-converter.ts: skill-to-command conversion
- slashcommand-description.ts: tool description generation
- slashcommand-tool.ts: core tool definition
2026-02-08 16:24:34 +09:00
YeonGyu-Kim
480dcff420 refactor(look-at): split tools.ts into argument parsing and extraction modules
Extract multimodal look-at tool internals:
- look-at-arguments.ts: argument validation and parsing
- assistant-message-extractor.ts: response extraction
- mime-type-inference.ts: file type detection
- multimodal-agent-metadata.ts: agent metadata constants
2026-02-08 16:24:21 +09:00
YeonGyu-Kim
6e0f6d53a7 refactor(call-omo-agent): split tools.ts into agent execution modules
Extract agent call pipeline:
- agent-type-normalizer.ts, tool-context-with-metadata.ts
- subagent-session-creator.ts, subagent-session-prompter.ts
- sync-agent-executor.ts, background-agent-executor.ts
- session-completion-poller.ts, session-message-output-extractor.ts
- message-storage-directory.ts
2026-02-08 16:24:13 +09:00
YeonGyu-Kim
76fad73550 refactor(ast-grep): split cli.ts and constants.ts into focused modules
Extract AST-grep tooling into single-responsibility files:
- cli-binary-path-resolution.ts, sg-cli-path.ts
- environment-check.ts, language-support.ts
- process-output-timeout.ts, sg-compact-json-output.ts
2026-02-08 16:24:03 +09:00
YeonGyu-Kim
e4583668c0 refactor(hooks): split session-notification and unstable-agent-babysitter
Extract notification and babysitter logic:
- session-notification-formatting.ts, session-notification-scheduler.ts
- session-notification-sender.ts, session-todo-status.ts
- task-message-analyzer.ts: message analysis for babysitter hook
2026-02-08 16:23:56 +09:00
YeonGyu-Kim
2d22a54b55 refactor(rules-injector): split finder.ts into rule discovery modules
Extract rule finding logic:
- project-root-finder.ts: project root detection
- rule-file-finder.ts: rule file discovery
- rule-file-scanner.ts: filesystem scanning for rules
- rule-distance.ts: rule-to-file distance calculation
2026-02-08 16:22:33 +09:00
YeonGyu-Kim
c2efdb4334 refactor(interactive-bash-session): extract tracker and command parser
Split hook into focused modules:
- interactive-bash-session-tracker.ts: session tracking logic
- tmux-command-parser.ts: tmux command parsing utilities
2026-02-08 16:22:25 +09:00
YeonGyu-Kim
d3a3f0c3a6 refactor(claude-code-hooks): extract handlers and session state
Split hook into per-concern modules:
- handlers/ directory for individual hook handlers
- session-hook-state.ts: session-level hook state management
2026-02-08 16:22:17 +09:00
YeonGyu-Kim
0f145b2e40 refactor(ralph-loop): split hook into state controller and event handler modules
Extract Ralph loop lifecycle management:
- loop-state-controller.ts: start/stop/recovery state machine
- ralph-loop-event-handler.ts: event handling logic
- continuation-prompt-builder.ts, continuation-prompt-injector.ts
- completion-promise-detector.ts, loop-session-recovery.ts
- message-storage-directory.ts
2026-02-08 16:22:10 +09:00
YeonGyu-Kim
161d6e4159 refactor(context-window-recovery): split executor and storage into focused modules
Extract recovery strategies and storage management:
- recovery-strategy.ts, aggressive-truncation-strategy.ts
- summarize-retry-strategy.ts, target-token-truncation.ts
- empty-content-recovery.ts, message-builder.ts
- tool-result-storage.ts, storage-paths.ts, state.ts
- client.ts, tool-part-types.ts
2026-02-08 16:22:01 +09:00
YeonGyu-Kim
8dff42830c refactor(builtin-skills): extract git-master metadata to separate module
Split prompt-heavy git-master.ts:
- git-master-skill-metadata.ts: skill metadata constants (name, desc, agent)
2026-02-08 16:21:50 +09:00
YeonGyu-Kim
9b841c6edc refactor(mcp-oauth): extract OAuth authorization flow from provider.ts
Split provider.ts into focused modules:
- oauth-authorization-flow.ts: OAuth2 authorization code flow logic
2026-02-08 16:21:43 +09:00
YeonGyu-Kim
39dc62c62a refactor(claude-code-plugin-loader): split loader.ts into per-type loaders
Extract plugin component loading into dedicated modules:
- discovery.ts: plugin directory detection
- plugin-path-resolver.ts: path resolution logic
- agent-loader.ts, command-loader.ts, hook-loader.ts
- mcp-server-loader.ts, skill-loader.ts
2026-02-08 16:21:37 +09:00
YeonGyu-Kim
46969935cd refactor(skill-mcp-manager): split manager.ts into connection and client modules
Extract MCP client lifecycle management:
- connection.ts: getOrCreateClientWithRetry logic
- stdio-client.ts, http-client.ts: transport-specific creation
- oauth-handler.ts: OAuth token management
- cleanup.ts: session and global cleanup
- connection-type.ts: connection type detection
2026-02-08 16:21:28 +09:00
YeonGyu-Kim
51ced65b5f refactor(opencode-skill-loader): split loader and merger into focused modules
Extract skill loading pipeline into single-responsibility modules:
- skill-discovery.ts, skill-directory-loader.ts, skill-deduplication.ts
- loaded-skill-from-path.ts, loaded-skill-template-extractor.ts
- skill-template-resolver.ts, skill-definition-record.ts
- git-master-template-injection.ts, allowed-tools-parser.ts
- skill-mcp-config.ts, skill-resolution-options.ts
- merger/ directory for skill merging logic
2026-02-08 16:21:19 +09:00
YeonGyu-Kim
f8b5771443 refactor(tmux-subagent): split manager and decision-engine into focused modules
Extract session lifecycle, polling, grid planning, and event handling:
- polling.ts: session polling controller with stability detection
- event-handlers.ts: session created/deleted handlers
- grid-planning.ts, spawn-action-decider.ts, spawn-target-finder.ts
- session-status-parser.ts, session-message-count.ts
- cleanup.ts, polling-constants.ts, tmux-grid-constants.ts
2026-02-08 16:21:04 +09:00
YeonGyu-Kim
e3bd43ff64 refactor(background-agent): split manager.ts into focused modules
Extract 30+ single-responsibility modules from manager.ts (1556 LOC):
- task lifecycle: task-starter, task-completer, task-canceller, task-resumer
- task queries: task-queries, task-poller, task-queue-processor
- notifications: notification-builder, notification-tracker, parent-session-notifier
- session handling: session-validator, session-output-validator, session-todo-checker
- spawner: spawner/ directory with focused spawn modules
- utilities: duration-formatter, error-classifier, message-storage-locator
- result handling: result-handler-context, background-task-completer
- shutdown: background-manager-shutdown, process-signal
2026-02-08 16:20:52 +09:00
YeonGyu-Kim
c7122b4127 fix: resolve all test failures and Cubic review issues
- Fix unstable-agent-babysitter: add promptAsync to test mock
- Fix claude-code-mcp-loader: isolate tests from user home configs
- Fix npm-dist-tags: encode packageName for scoped packages
- Fix agent-builder: clone source to prevent shared object mutation
- Fix add-plugin-to-opencode-config: handle JSONC with leading comments
- Fix auth-plugins/add-provider-config: error on parse failures
- Fix bun-install: clear timeout on completion
- Fix git-diff-stats: include untracked files in diff summary
2026-02-08 15:31:32 +09:00
YeonGyu-Kim
119e18c810 refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks
- Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.)
- Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files
- Decompose session-recovery into separate recovery strategy files per error type
- Extract todo-continuation-enforcer from monolith to directory with dedicated modules
- Split background-task/tools.ts into individual tool creator files
- Extract command-executor, tmux-utils into focused sub-modules
- Split config/schema.ts into domain-specific schema files
- Decompose cli/config-manager.ts into focused modules
- Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests
- Fix all import path depths for moved files (../../ -> ../../../)
- Add explicit type annotations to resolve TS7006 implicit any errors

Typecheck: 0 errors
Tests: 2359 pass, 5 fail (all pre-existing)
2026-02-08 15:01:42 +09:00
YeonGyu-Kim
29155ec7bc refactor: wave 1 - extract leaf modules, rename catch-all files, split index.ts hooks
- Split 25+ index.ts files into hook.ts + extracted modules
- Rename all catch-all utils.ts/helpers.ts to domain-specific names
- Split src/tools/lsp/ into ~15 focused modules
- Split src/tools/delegate-task/ into ~18 focused modules
- Separate shared types from implementation
- 155 files changed, 60+ new files created
- All typecheck clean, 61 tests pass
2026-02-08 13:57:26 +09:00
653 changed files with 34627 additions and 21500 deletions

BIN
.github/assets/elestyle.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

209
AGENTS.md
View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-02-08T16:45:00+09:00
**Commit:** edee865f
**Generated:** 2026-02-10T14:44:00+09:00
**Commit:** b538806d
**Branch:** dev
---
@@ -27,12 +27,14 @@ feature branches (your work)
| **ALL PRs → `dev`** | Every pull request MUST target the `dev` branch |
| **NEVER PR → `master`** | PRs to `master` are **automatically rejected** by CI |
| **"Create a PR" = target `dev`** | When asked to create a new PR, it ALWAYS means targeting `dev` |
| **Merge commit ONLY** | Squash merge is **disabled** in this repo. Always use merge commit when merging PRs. |
### Why This Matters
- `master` = production/published npm package
- `dev` = integration branch where features are merged and tested
- Feature branches → `dev` → (after testing) → `master`
- Squash merge is disabled at the repository level — attempting it will fail
**If you create a PR targeting `master`, it WILL be rejected. No exceptions.**
@@ -75,11 +77,6 @@ Oh-My-OpenCode is a **plugin for OpenCode**. You will frequently need to examine
| Debugging plugin issues | Fire `librarian` to find relevant OpenCode internals |
| Answering "how does OpenCode do X?" | Fire `librarian` FIRST |
**The `librarian` agent is specialized for:**
- Searching remote codebases (GitHub)
- Retrieving official documentation
- Finding implementation examples in open source
**DO NOT guess or hallucinate about OpenCode internals.** Always verify by examining actual source code via `librarian` or direct clone.
---
@@ -90,8 +87,6 @@ Oh-My-OpenCode is a **plugin for OpenCode**. You will frequently need to examine
### All Project Communications MUST Be in English
This is an **international open-source project**. To ensure accessibility and maintainability:
| Context | Language Requirement |
|---------|---------------------|
| **GitHub Issues** | English ONLY |
@@ -101,64 +96,74 @@ This is an **international open-source project**. To ensure accessibility and ma
| **Documentation** | English ONLY |
| **AGENTS.md files** | English ONLY |
### Why This Matters
- **Global Collaboration**: Contributors from all countries can participate
- **Searchability**: English keywords are universally searchable
- **AI Agent Compatibility**: AI tools work best with English content
- **Consistency**: Mixed languages create confusion and fragmentation
### Enforcement
- Issues/PRs with non-English content may be closed with a request to resubmit in English
- Commit messages must be in English - CI may reject non-English commits
- Translated READMEs exist (README.ko.md, README.ja.md, etc.) but the primary docs are English
**If you're not comfortable writing in English, use translation tools. Broken English is fine - we'll help fix it. Non-English is not acceptable.**
**If you're not comfortable writing in English, use translation tools. Broken English is fine. Non-English is not acceptable.**
---
## OVERVIEW
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.6, GPT-5.3 Codex, Gemini 3 Flash). 40+ lifecycle hooks, 25+ tools (LSP, AST-Grep, delegation), 11 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode.
OpenCode plugin (v3.4.0): multi-model agent orchestration with 11 specialized agents (Claude Opus 4.6, GPT-5.3 Codex, Gemini 3 Flash, GLM-4.7, Grok). 41 lifecycle hooks across 7 event types, 25+ tools (LSP, AST-Grep, delegation, task management), full Claude Code compatibility layer. "oh-my-zsh" for OpenCode.
## STRUCTURE
```
oh-my-opencode/
├── src/
│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md
│ ├── hooks/ # 40+ lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # 25+ tools - see src/tools/AGENTS.md
│ ├── features/ # Background agents, skills, Claude Code compat - see src/features/AGENTS.md
│ ├── shared/ # 66 cross-cutting utilities - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
│ ├── config/ # Zod schema (schema.ts 455 lines), TypeScript types
│ ├── plugin-handlers/ # Plugin config loading (config-handler.ts 562 lines)
│ ├── index.ts # Main plugin entry (999 lines)
│ ├── plugin-config.ts # Config loading orchestration
── plugin-state.ts # Model cache state
├── script/ # build-schema.ts, build-binaries.ts, publish.ts
├── packages/ # 11 platform-specific binaries
└── dist/ # Build output (ESM + .d.ts)
│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md
│ ├── hooks/ # 41 lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # 25+ tools - see src/tools/AGENTS.md
│ ├── features/ # Background agents, skills, CC compat - see src/features/AGENTS.md
│ ├── shared/ # 84 cross-cutting utilities - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
│ ├── config/ # Zod schema - see src/config/AGENTS.md
│ ├── plugin-handlers/ # Config loading - see src/plugin-handlers/AGENTS.md
│ ├── plugin/ # Plugin interface composition (21 files)
│ ├── index.ts # Main plugin entry (88 lines)
── create-hooks.ts # Hook creation coordination (62 lines)
├── create-managers.ts # Manager initialization (80 lines)
│ ├── create-tools.ts # Tool registry composition (54 lines)
│ ├── plugin-interface.ts # Plugin interface assembly (66 lines)
│ ├── plugin-config.ts # Config loading orchestration
│ └── plugin-state.ts # Model cache state
├── script/ # build-schema.ts, build-binaries.ts, publish.ts, generate-changelog.ts
├── packages/ # 7 platform-specific binary packages
└── dist/ # Build output (ESM + .d.ts)
```
## INITIALIZATION FLOW
```
OhMyOpenCodePlugin(ctx)
1. injectServerAuthIntoClient(ctx.client)
2. startTmuxCheck()
3. loadPluginConfig(ctx.directory, ctx) → OhMyOpenCodeConfig
4. createFirstMessageVariantGate()
5. createModelCacheState()
6. createManagers(ctx, config, tmux, cache) → TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
7. createTools(ctx, config, managers) → filteredTools, mergedSkills, availableSkills, availableCategories
8. createHooks(ctx, config, backgroundMgr) → 41 hooks (core + continuation + skill)
9. createPluginInterface(...) → tool, chat.params, chat.message, event, tool.execute.before/after
10. Return plugin with experimental.session.compacting
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` in utils.ts |
| Add hook | `src/hooks/` | Create dir with `createXXXHook()`, register in index.ts |
| Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` in builtin-agents/ |
| Add hook | `src/hooks/` | Create dir, register in `src/plugin/hooks/create-*-hooks.ts` |
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts |
| Add MCP | `src/mcp/` | Create config, add to `createBuiltinMcps()` |
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
| Add skill | `src/features/builtin-skills/` | Create .ts in skills/ |
| Add command | `src/features/builtin-commands/` | Add template + register in commands.ts |
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` |
| Config schema | `src/config/schema/` | 21 schema component files, run `bun run build:schema` |
| Plugin config | `src/plugin-handlers/config-handler.ts` | JSONC loading, merging, migration |
| Background agents | `src/features/background-agent/` | manager.ts (1556 lines) |
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (770 lines) |
| Delegation | `src/tools/delegate-task/` | Category routing (executor.ts 983 lines) |
| Background agents | `src/features/background-agent/` | manager.ts (1646 lines) |
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (1976 lines) |
| Delegation | `src/tools/delegate-task/` | Category routing (constants.ts 569 lines) |
| Task system | `src/features/claude-tasks/` | Task schema, storage, todo sync |
| Plugin interface | `src/plugin/` | 21 files composing hooks, handlers, registries |
## TDD (Test-Driven Development)
@@ -170,7 +175,7 @@ oh-my-opencode/
**Rules:**
- NEVER write implementation before test
- NEVER delete failing tests - fix the code
- Test file: `*.test.ts` alongside source (163 test files)
- Test file: `*.test.ts` alongside source (176 test files)
- BDD comments: `//#given`, `//#when`, `//#then`
## CONVENTIONS
@@ -180,8 +185,9 @@ oh-my-opencode/
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern via index.ts
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
- **Testing**: BDD comments, 163 test files
- **Testing**: BDD comments, 176 test files, 117k+ lines TypeScript
- **Temperature**: 0.1 for code agents, max 0.3
- **Modular architecture**: 200 LOC hard limit per file (prompt strings exempt)
## ANTI-PATTERNS
@@ -204,22 +210,57 @@ oh-my-opencode/
| Git | Skip hooks (--no-verify), force push without request |
| Bash | `sleep N` - use conditional waits |
| Bash | `cd dir && cmd` - use workdir parameter |
| Files | Catch-all utils.ts/helpers.ts - name by purpose |
## AGENT MODELS
| Agent | Model | Purpose |
|-------|-------|---------|
| Sisyphus | anthropic/claude-opus-4-6 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
| Hephaestus | openai/gpt-5.3-codex | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.3-codex, no fallback) |
| Atlas | anthropic/claude-sonnet-4-5 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
| oracle | openai/gpt-5.2 | Consultation, debugging |
| librarian | zai-coding-plan/glm-4.7 | Docs, GitHub search (fallback: glm-4.7-free) |
| explore | xai/grok-code-fast-1 | Fast codebase grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-6 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
| Metis | anthropic/claude-opus-4-6 | Pre-planning analysis (temp 0.3, fallback: kimi-k2.5 → gpt-5.2) |
| Momus | openai/gpt-5.2 | Plan validation (temp 0.1, fallback: claude-opus-4-6) |
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | Category-spawned executor (temp 0.1) |
| Agent | Model | Temp | Purpose |
|-------|-------|------|---------|
| Sisyphus | anthropic/claude-opus-4-6 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
| Hephaestus | openai/gpt-5.3-codex | 0.1 | Autonomous deep worker (NO fallback) |
| Atlas | anthropic/claude-sonnet-4-5 | 0.1 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
| Prometheus | anthropic/claude-opus-4-6 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging (fallback: claude-opus-4-6) |
| librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) |
| explore | xai/grok-code-fast-1 | 0.1 | Fast codebase grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
| Metis | anthropic/claude-opus-4-6 | 0.3 | Pre-planning analysis (fallback: kimi-k2.5 → gpt-5.2) |
| Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-6) |
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
## OPENCODE PLUGIN API
Plugin SDK from `@opencode-ai/plugin` (v1.1.19). Plugin = `async (PluginInput) => Hooks`.
| Hook | Purpose |
|------|---------|
| `tool` | Register custom tools (Record<string, ToolDefinition>) |
| `chat.message` | Intercept user messages (can modify parts) |
| `chat.params` | Modify LLM parameters (temperature, topP, options) |
| `tool.execute.before` | Pre-tool interception (can modify args) |
| `tool.execute.after` | Post-tool processing (can modify output) |
| `event` | Session lifecycle events (session.created, session.stop, etc.) |
| `config` | Config modification (register agents, MCPs, commands) |
| `experimental.chat.messages.transform` | Transform message history |
| `experimental.session.compacting` | Session compaction customization |
## DEPENDENCIES
| Package | Purpose |
|---------|---------|
| `@opencode-ai/plugin` + `sdk` | OpenCode integration SDK |
| `@ast-grep/cli` + `napi` | AST pattern matching (search/replace) |
| `@code-yeongyu/comment-checker` | AI comment detection/prevention |
| `@modelcontextprotocol/sdk` | MCP client for remote HTTP servers |
| `@clack/prompts` | Interactive CLI TUI |
| `commander` | CLI argument parsing |
| `zod` (v4) | Schema validation for config |
| `jsonc-parser` | JSONC config with comments |
| `picocolors` | Terminal colors |
| `picomatch` | Glob pattern matching |
| `vscode-jsonrpc` | LSP communication |
| `js-yaml` | YAML parsing (tasks, skills) |
| `detect-libc` | Platform binary selection |
## COMMANDS
@@ -227,7 +268,8 @@ oh-my-opencode/
bun run typecheck # Type check
bun run build # ESM + declarations + schema
bun run rebuild # Clean + Build
bun test # 100+ test files
bun test # 176 test files
bun run build:schema # Regenerate JSON schema
```
## DEPLOYMENT
@@ -241,41 +283,38 @@ bun test # 100+ test files
| File | Lines | Description |
|------|-------|-------------|
| `src/features/background-agent/manager.ts` | 1642 | Task lifecycle, concurrency |
| `src/features/builtin-skills/skills/git-master.ts` | 1107 | Git master skill definition |
| `src/index.ts` | 999 | Main plugin entry |
| `src/tools/delegate-task/executor.ts` | 969 | Category-based delegation executor |
| `src/tools/lsp/client.ts` | 851 | LSP client operations |
| `src/tools/background-task/tools.ts` | 757 | Background task tools |
| `src/hooks/atlas/index.ts` | 697 | Orchestrator hook |
| `src/cli/config-manager.ts` | 667 | JSONC config parsing |
| `src/features/skill-mcp-manager/manager.ts` | 640 | MCP client lifecycle |
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactor command template |
| `src/agents/hephaestus.ts` | 618 | Autonomous deep worker agent |
| `src/agents/utils.ts` | 571 | Agent creation, model fallback resolution |
| `src/plugin-handlers/config-handler.ts` | 562 | Plugin config loading |
| `src/tools/delegate-task/constants.ts` | 552 | Delegation constants |
| `src/cli/install.ts` | 542 | Interactive CLI installer |
| `src/hooks/task-continuation-enforcer.ts` | 530 | Task completion enforcement |
| `src/agents/sisyphus.ts` | 530 | Main orchestrator agent |
| `src/features/background-agent/manager.ts` | 1646 | Task lifecycle, concurrency |
| `src/hooks/anthropic-context-window-limit-recovery/` | 2232 | Multi-strategy context recovery |
| `src/hooks/claude-code-hooks/` | 2110 | Claude Code settings.json compat |
| `src/hooks/todo-continuation-enforcer/` | 2061 | Core boulder mechanism |
| `src/hooks/atlas/` | 1976 | Session orchestration |
| `src/hooks/ralph-loop/` | 1687 | Self-referential dev loop |
| `src/hooks/keyword-detector/` | 1665 | Mode detection (ultrawork/search) |
| `src/hooks/rules-injector/` | 1604 | Conditional rules injection |
| `src/hooks/think-mode/` | 1365 | Model/variant switching |
| `src/hooks/session-recovery/` | 1279 | Auto error recovery |
| `src/features/builtin-skills/skills/git-master.ts` | 1111 | Git master skill |
| `src/tools/delegate-task/constants.ts` | 569 | Category routing configs |
## MCP ARCHITECTURE
Three-tier system:
1. **Built-in**: websearch (Exa/Tavily), context7 (docs), grep_app (GitHub)
2. **Claude Code compat**: .mcp.json with `${VAR}` expansion
3. **Skill-embedded**: YAML frontmatter in skills
1. **Built-in** (src/mcp/): websearch (Exa/Tavily), context7 (docs), grep_app (GitHub)
2. **Claude Code compat** (features/claude-code-mcp-loader/): .mcp.json with `${VAR}` expansion
3. **Skill-embedded** (features/opencode-skill-loader/): YAML frontmatter in SKILL.md
## CONFIG SYSTEM
- **Zod validation**: `src/config/schema.ts` (455 lines)
- **Zod validation**: 21 schema component files in `src/config/schema/`
- **JSONC support**: Comments, trailing commas
- **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`)
- **Loading**: `src/plugin-handlers/config-handler.ts` → merge → validate
- **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`) → Defaults
- **Migration**: Legacy config auto-migration in `src/shared/migration/`
## NOTES
- **OpenCode**: Requires >= 1.0.150
- **1069 TypeScript files**, 176 test files, 117k+ lines
- **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **No linter/formatter**: No ESLint, Prettier, or Biome configured
- **License**: SUL-1.0 (Sisyphus Use License)

View File

@@ -370,6 +370,8 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
- [ELESTYLE](https://elestyle.jp)
- elepay - マルチモバイル決済ゲートウェイ、OneQR - キャッシュレスソリューション向けモバイルアプリケーションSaaS
## スポンサー
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)

View File

@@ -379,5 +379,7 @@ OpenCode가 Debian/Arch라면 Oh My OpenCode는 Ubuntu/[Omarchy](https://omarchy
- Spray(인플루언서 마케팅 솔루션), vovushop(국가 간 상거래 플랫폼), vreview(AI 상거래 리뷰 마케팅 솔루션) 제작
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
- [ELESTYLE](https://elestyle.jp)
- elepay - 멀티 모바일 결제 게이트웨이, OneQR - 캐시리스 솔루션용 모바일 애플리케이션 SaaS
*이 놀라운 히어로 이미지에 대해 [@junhoyeo](https://github.com/junhoyeo)에게 특별히 감사드립니다.*

View File

@@ -378,5 +378,7 @@ I have no affiliation with any project or model mentioned here. This is purely p
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
- [ELESTYLE](https://elestyle.jp)
- Making elepay - multi-mobile payment gateway, OneQR - mobile application SaaS for cashless solutions
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*

View File

@@ -376,6 +376,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads
- 制作 Spray - 网红营销解决方案、vovushop - 跨境电商平台、vreview - AI 电商评论营销解决方案
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
- [ELESTYLE](https://elestyle.jp)
- elepay - 多渠道移动支付网关、OneQR - 无现金解决方案移动应用 SaaS
## 赞助商
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.4.0",
"version": "3.5.1",
"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.4.0",
"version": "3.5.1",
"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.4.0",
"version": "3.5.1",
"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.4.0",
"version": "3.5.1",
"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.4.0",
"version": "3.5.1",
"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.4.0",
"version": "3.5.1",
"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.4.0",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1239,6 +1239,110 @@
"created_at": "2026-02-08T02:34:46Z",
"repoId": 1108837393,
"pullRequestNo": 1641
},
{
"name": "JunyeongChoi0",
"id": 99778164,
"comment_id": 3867461224,
"created_at": "2026-02-08T16:02:31Z",
"repoId": 1108837393,
"pullRequestNo": 1674
},
{
"name": "aliozdenisik",
"id": 106994209,
"comment_id": 3867619266,
"created_at": "2026-02-08T17:12:34Z",
"repoId": 1108837393,
"pullRequestNo": 1676
},
{
"name": "mrm007",
"id": 3297808,
"comment_id": 3868350953,
"created_at": "2026-02-08T21:41:35Z",
"repoId": 1108837393,
"pullRequestNo": 1680
},
{
"name": "nianyi778",
"id": 23355645,
"comment_id": 3874840250,
"created_at": "2026-02-10T01:41:08Z",
"repoId": 1108837393,
"pullRequestNo": 1703
},
{
"name": "lxia1220",
"id": 43934024,
"comment_id": 3875675071,
"created_at": "2026-02-10T06:43:35Z",
"repoId": 1108837393,
"pullRequestNo": 1713
},
{
"name": "cyberprophet",
"id": 48705422,
"comment_id": 3877193956,
"created_at": "2026-02-10T12:06:03Z",
"repoId": 1108837393,
"pullRequestNo": 1717
},
{
"name": "materializerx",
"id": 96932157,
"comment_id": 3878329143,
"created_at": "2026-02-10T15:07:38Z",
"repoId": 1108837393,
"pullRequestNo": 1724
},
{
"name": "materializerx",
"id": 96932157,
"comment_id": 3878458939,
"created_at": "2026-02-10T15:21:04Z",
"repoId": 1108837393,
"pullRequestNo": 1724
},
{
"name": "RobertWsp",
"id": 67512895,
"comment_id": 3878518426,
"created_at": "2026-02-10T15:27:01Z",
"repoId": 1108837393,
"pullRequestNo": 1723
},
{
"name": "RobertWsp",
"id": 67512895,
"comment_id": 3878575833,
"created_at": "2026-02-10T15:32:31Z",
"repoId": 1108837393,
"pullRequestNo": 1723
},
{
"name": "sjawhar",
"id": 5074378,
"comment_id": 3879746658,
"created_at": "2026-02-10T17:43:47Z",
"repoId": 1108837393,
"pullRequestNo": 1727
},
{
"name": "marlon-costa-dc",
"id": 128386606,
"comment_id": 3879827362,
"created_at": "2026-02-10T17:59:06Z",
"repoId": 1108837393,
"pullRequestNo": 1726
},
{
"name": "marlon-costa-dc",
"id": 128386606,
"comment_id": 3879847814,
"created_at": "2026-02-10T18:03:41Z",
"repoId": 1108837393,
"pullRequestNo": 1726
}
]
}

View File

@@ -1,128 +1,80 @@
# AGENTS KNOWLEDGE BASE
# SRC KNOWLEDGE BASE
## OVERVIEW
Main plugin entry point and orchestration layer. 1000+ lines of plugin initialization, hook registration, tool composition, and lifecycle management.
**Core Responsibilities:**
- Plugin initialization and configuration loading
- 40+ lifecycle hooks orchestration
- 25+ tools composition and filtering
- Background agent management
- Session state coordination
- MCP server lifecycle
- Tmux integration
- Claude Code compatibility layer
Main plugin entry point and orchestration layer. Plugin initialization, hook registration, tool composition, and lifecycle management.
## STRUCTURE
```
src/
├── index.ts # Main plugin entry (1000 lines) - orchestration layer
├── index.compaction-model-agnostic.static.test.ts # Compaction hook tests
├── agents/ # 11 AI agents (16 files)
├── cli/ # CLI commands (9 files)
├── config/ # Schema validation (3 files)
├── features/ # Background features (20+ files)
├── hooks/ # 40+ lifecycle hooks (14 files)
├── mcp/ # MCP server configs (7 files)
├── plugin-handlers/ # Config loading (3 files)
├── shared/ # Utilities (70 files)
── tools/ # 25+ tools (15 files)
├── index.ts # Main plugin entry (88 lines) — OhMyOpenCodePlugin factory
├── create-hooks.ts # Hook coordination: core, continuation, skill (62 lines)
├── create-managers.ts # Manager initialization: Tmux, Background, SkillMcp, Config (80 lines)
├── create-tools.ts # Tool registry + skill context composition (54 lines)
├── plugin-interface.ts # Plugin interface assembly — 7 OpenCode hooks (66 lines)
├── plugin-config.ts # Config loading orchestration (user + project merge)
├── plugin-state.ts # Model cache state (context limits, anthropic 1M flag)
├── agents/ # 11 AI agents (32 files) - see agents/AGENTS.md
├── cli/ # CLI installer, doctor (107+ files) - see cli/AGENTS.md
├── config/ # Zod schema (21 component files) - see config/AGENTS.md
── features/ # Background agents, skills, commands (18 dirs) - see features/AGENTS.md
├── hooks/ # 41 lifecycle hooks (36 dirs) - see hooks/AGENTS.md
├── mcp/ # Built-in MCPs (6 files) - see mcp/AGENTS.md
├── plugin/ # Plugin interface composition (21 files)
├── plugin-handlers/ # Config loading, plan inheritance (15 files) - see plugin-handlers/AGENTS.md
├── shared/ # Cross-cutting utilities (84 files) - see shared/AGENTS.md
└── tools/ # 25+ tools (14 dirs) - see tools/AGENTS.md
```
## KEY COMPONENTS
## PLUGIN INITIALIZATION (10 steps)
**Plugin Initialization:**
- `OhMyOpenCodePlugin()`: Main plugin factory (lines 124-841)
- Configuration loading via `loadPluginConfig()`
- Hook registration with safe creation patterns
- Tool composition and disabled tool filtering
1. `injectServerAuthIntoClient(ctx.client)` — Auth injection
2. `startTmuxCheck()` — Tmux availability
3. `loadPluginConfig(ctx.directory, ctx)` — User + project config merge → Zod validation
4. `createFirstMessageVariantGate()` — First message variant override gate
5. `createModelCacheState()` — Model context limits cache
6. `createManagers(...)` → 4 managers:
- `TmuxSessionManager` — Multi-pane tmux sessions
- `BackgroundManager` — Parallel subagent execution
- `SkillMcpManager` — MCP server lifecycle
- `ConfigHandler` — Plugin config API to OpenCode
7. `createTools(...)``createSkillContext()` + `createAvailableCategories()` + `createToolRegistry()`
8. `createHooks(...)``createCoreHooks()` + `createContinuationHooks()` + `createSkillHooks()`
9. `createPluginInterface(...)` → 7 OpenCode hook handlers
10. Return plugin with `experimental.session.compacting`
**Lifecycle Management:**
- 40+ hooks: session recovery, continuation enforcers, compaction, context injection
- Background agent coordination via `BackgroundManager`
- Tmux session management for multi-pane workflows
- MCP server lifecycle via `SkillMcpManager`
## HOOK REGISTRATION (3 tiers)
**Tool Ecosystem:**
- 25+ tools: LSP, AST-grep, delegation, background tasks, skills
- Tool filtering based on agent permissions and user config
- Metadata restoration for tool outputs
**Core Hooks** (`create-core-hooks.ts`):
- Session (20): context-window-monitor, session-recovery, think-mode, ralph-loop, anthropic-effort, ...
- Tool Guard (8): comment-checker, tool-output-truncator, rules-injector, write-existing-file-guard, ...
- Transform (4): claude-code-hooks, keyword-detector, context-injector, thinking-block-validator
**Integration Points:**
- Claude Code compatibility hooks and commands
- OpenCode SDK client interactions
- Session state persistence and recovery
- Model variant resolution and application
**Continuation Hooks** (`create-continuation-hooks.ts`):
- 7 hooks: stop-continuation-guard, compaction-context-injector, todo-continuation-enforcer, atlas, ...
## HOOK REGISTRATION PATTERNS
**Skill Hooks** (`create-skill-hooks.ts`):
- 2 hooks: category-skill-reminder, auto-slash-command
## PLUGIN INTERFACE (7 OpenCode handlers)
| Handler | Source | Purpose |
|---------|--------|---------|
| `tool` | filteredTools | All registered tools |
| `chat.params` | createChatParamsHandler | Anthropic effort level |
| `chat.message` | createChatMessageHandler | First message variant, session setup |
| `experimental.chat.messages.transform` | createMessagesTransformHandler | Context injection, keyword detection |
| `config` | configHandler | Agent/MCP/command registration |
| `event` | createEventHandler | Session lifecycle |
| `tool.execute.before` | createToolExecuteBeforeHandler | Pre-tool hooks |
| `tool.execute.after` | createToolExecuteAfterHandler | Post-tool hooks |
## SAFE HOOK CREATION PATTERN
**Safe Hook Creation:**
```typescript
const hook = isHookEnabled("hook-name")
? safeCreateHook("hook-name", () => createHookFactory(ctx), { enabled: safeHookEnabled })
: null;
```
**Hook Categories:**
- **Session Management**: recovery, notification, compaction
- **Continuation**: todo/task enforcers, stop guards
- **Context**: injection, rules, directory content
- **Tool Enhancement**: output truncation, error recovery, validation
- **Agent Coordination**: usage reminders, babysitting, delegation
## TOOL COMPOSITION
**Core Tools:**
```typescript
const allTools: Record<string, ToolDefinition> = {
...builtinTools, // Basic file/session operations
...createGrepTools(ctx), // Content search
...createAstGrepTools(ctx), // AST-aware refactoring
task: delegateTask, // Agent delegation
skill: skillTool, // Skill execution
// ... 20+ more tools
};
```
**Tool Filtering:**
- Agent permission-based restrictions
- User-configured disabled tools
- Dynamic tool availability based on session state
## SESSION LIFECYCLE
**Session Events:**
- `session.created`: Initialize session state, tmux setup
- `session.deleted`: Cleanup resources, clear caches
- `message.updated`: Update agent assignments
- `session.error`: Trigger recovery mechanisms
**Continuation Flow:**
1. User message triggers agent selection
2. Model/variant resolution applied
3. Tools execute with hook interception
4. Continuation enforcers monitor completion
5. Session compaction preserves context
## CONFIGURATION INTEGRATION
**Plugin Config Loading:**
- Project + user config merging
- Schema validation via Zod
- Migration support for legacy configs
- Dynamic feature enablement
**Runtime Configuration:**
- Hook enablement based on `disabled_hooks`
- Tool filtering via `disabled_tools`
- Agent overrides and category definitions
- Experimental feature toggles
## ANTI-PATTERNS
- **Direct hook exports**: All hooks created via factories for testability
- **Global state pollution**: Session-scoped state management
- **Synchronous blocking**: Async-first architecture with background coordination
- **Tight coupling**: Plugin components communicate via events, not direct calls
- **Memory leaks**: Proper cleanup on session deletion and plugin unload
All hooks use this pattern for graceful degradation on failure.

View File

@@ -2,88 +2,99 @@
## OVERVIEW
32 files containing AI agents and utilities for multi-model orchestration. Each agent has factory function + metadata + fallback chains.
**Primary Agents** (respect UI model selection):
- Sisyphus, Atlas, Prometheus
**Subagents** (use own fallback chains):
- Hephaestus, Oracle, Librarian, Explore, Multimodal-Looker, Metis, Momus, Sisyphus-Junior
11 AI agents with factory functions, fallback chains, and model-specific prompt variants. Each agent has metadata (category, cost, triggers) and configurable tool restrictions.
## STRUCTURE
```
agents/
├── atlas/ # Master Orchestrator (holds todo list)
│ ├── index.ts
│ ├── default.ts # Claude-optimized prompt (390 lines)
│ ├── gpt.ts # GPT-optimized prompt (330 lines)
├── sisyphus.ts # Main orchestrator (530 lines)
├── hephaestus.ts # Autonomous deep worker (624 lines)
├── oracle.ts # Strategic advisor (170 lines)
├── librarian.ts # Multi-repo research (328 lines)
├── explore.ts # Fast codebase grep (124 lines)
├── multimodal-looker.ts # Media analyzer (58 lines)
├── metis.ts # Pre-planning analysis (347 lines)
├── momus.ts # Plan validator (244 lines)
├── atlas/ # Master orchestrator
│ ├── agent.ts # Atlas factory
│ ├── default.ts # Claude-optimized prompt
│ ├── gpt.ts # GPT-optimized prompt
│ └── utils.ts
├── prometheus/ # Planning Agent (Interview/Consultant mode)
├── prometheus/ # Planning agent
│ ├── index.ts
│ ├── system-prompt.ts # 6-section prompt assembly
│ ├── plan-template.ts # Work plan structure (423 lines)
│ ├── interview-mode.ts # Interview flow (335 lines)
│ ├── plan-generation.ts
│ ├── high-accuracy-mode.ts
│ ├── identity-constraints.ts # Identity rules (301 lines)
│ └── behavioral-summary.ts
├── sisyphus-junior/ # Delegated task executor (category-spawned)
│ ├── index.ts
│ ├── default.ts
│ └── gpt.ts
├── sisyphus.ts # Main orchestrator prompt (530 lines)
├── hephaestus.ts # Autonomous deep worker (618 lines, GPT 5.3 Codex)
├── oracle.ts # Strategic advisor (GPT-5.2)
├── librarian.ts # Multi-repo research (328 lines)
├── explore.ts # Fast contextual grep
├── multimodal-looker.ts # Media analyzer (Gemini 3 Flash)
├── metis.ts # Pre-planning analysis (347 lines)
├── momus.ts # Plan reviewer
├── sisyphus-junior/ # Delegated task executor
│ ├── agent.ts
│ ├── default.ts # Claude prompt
│ └── gpt.ts # GPT prompt
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (431 lines)
├── builtin-agents/ # Agent registry (8 files)
├── utils.ts # Agent creation, model fallback resolution (571 lines)
├── types.ts # AgentModelConfig, AgentPromptMetadata
── utils.ts # createBuiltinAgents(), resolveModelWithFallback() (485 lines)
└── index.ts # builtinAgents export
── index.ts # Exports
```
## AGENT MODELS
| Agent | Model | Temp | Purpose |
|-------|-------|------|---------|
| Sisyphus | anthropic/claude-opus-4-6 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
| Hephaestus | openai/gpt-5.3-codex | 0.1 | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.3-codex, no fallback) |
| Atlas | anthropic/claude-sonnet-4-5 | 0.1 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
| librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) |
| explore | xai/grok-code-fast-1 | 0.1 | Fast contextual grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-6 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
| Metis | anthropic/claude-opus-4-6 | 0.3 | Pre-planning analysis (fallback: kimi-k2.5 → gpt-5.2) |
| Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-6) |
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
## HOW TO ADD
1. Create `src/agents/my-agent.ts` exporting factory + metadata.
2. Add to `agentSources` in `src/agents/utils.ts`.
3. Update `AgentNameSchema` in `src/config/schema.ts`.
4. Register in `src/index.ts` initialization.
| Agent | Model | Temp | Fallback Chain | Cost |
|-------|-------|------|----------------|------|
| Sisyphus | claude-opus-4-6 | 0.1 | kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro | EXPENSIVE |
| Hephaestus | gpt-5.3-codex | 0.1 | NONE (required) | EXPENSIVE |
| Atlas | claude-sonnet-4-5 | 0.1 | kimi-k2.5 → gpt-5.2 | EXPENSIVE |
| Prometheus | claude-opus-4-6 | 0.1 | kimi-k2.5 → gpt-5.2 | EXPENSIVE |
| oracle | gpt-5.2 | 0.1 | claude-opus-4-6 | EXPENSIVE |
| librarian | glm-4.7 | 0.1 | glm-4.7-free | CHEAP |
| explore | grok-code-fast-1 | 0.1 | claude-haiku-4-5 → gpt-5-mini → gpt-5-nano | FREE |
| multimodal-looker | gemini-3-flash | 0.1 | NONE | CHEAP |
| Metis | claude-opus-4-6 | 0.3 | kimi-k2.5 → gpt-5.2 | EXPENSIVE |
| Momus | gpt-5.2 | 0.1 | claude-opus-4-6 | EXPENSIVE |
| Sisyphus-Junior | claude-sonnet-4-5 | 0.1 | (user-configurable) | EXPENSIVE |
## TOOL RESTRICTIONS
| Agent | Denied Tools |
|-------|-------------|
| oracle | write, edit, task, task |
| librarian | write, edit, task, task, call_omo_agent |
| explore | write, edit, task, task, call_omo_agent |
| multimodal-looker | Allowlist: read only |
| Sisyphus-Junior | task, task |
| Atlas | task, call_omo_agent |
## PATTERNS
- **Factory**: `createXXXAgent(model: string): AgentConfig`
| Agent | Denied | Allowed |
|-------|--------|---------|
| oracle | write, edit, task, call_omo_agent | Read-only consultation |
| librarian | write, edit, task, call_omo_agent | Research tools only |
| explore | write, edit, task, call_omo_agent | Search tools only |
| multimodal-looker | ALL except `read` | Vision-only |
| Sisyphus-Junior | task | No delegation |
| Atlas | task, call_omo_agent | Orchestration only |
## THINKING / REASONING
| Agent | Claude | GPT |
|-------|--------|-----|
| Sisyphus | 32k budget tokens | reasoningEffort: "medium" |
| Hephaestus | — | reasoningEffort: "medium" |
| Oracle | 32k budget tokens | reasoningEffort: "medium" |
| Metis | 32k budget tokens | — |
| Momus | 32k budget tokens | reasoningEffort: "medium" |
| Sisyphus-Junior | 32k budget tokens | reasoningEffort: "medium" |
## HOW TO ADD
1. Create `src/agents/my-agent.ts` exporting factory + metadata
2. Add to `agentSources` in `src/agents/builtin-agents/`
3. Update `AgentNameSchema` in `src/config/schema/agent-names.ts`
4. Register in `src/plugin-handlers/agent-config-handler.ts`
## KEY PATTERNS
- **Factory**: `createXXXAgent(model): AgentConfig`
- **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers
- **Tool restrictions**: `createAgentToolRestrictions(tools)` or `createAgentToolAllowlist(tools)`
- **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus, Atlas
- **Model-specific routing**: Atlas, Sisyphus-Junior have GPT vs Claude prompt variants
- **Model-specific prompts**: Atlas, Sisyphus-Junior have GPT vs Claude variants
- **Dynamic prompts**: Sisyphus, Hephaestus use `dynamic-agent-prompt-builder.ts` to inject available tools/skills/categories
## ANTI-PATTERNS
- **Trust reports**: NEVER trust "I'm done" - verify outputs
- **High temp**: Don't use >0.3 for code agents
- **Trust agent self-reports**: NEVER — always verify outputs
- **High temperature**: Don't use >0.3 for code agents
- **Sequential calls**: Use `task` with `run_in_background` for exploration
- **Prometheus writing code**: Planner only - never implements
- **Prometheus writing code**: Planner only never implements

View File

@@ -0,0 +1,52 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentFactory } from "./types"
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
import type { BrowserAutomationProvider } from "../config/schema"
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
export type AgentSource = AgentFactory | AgentConfig
export function isFactory(source: AgentSource): source is AgentFactory {
return typeof source === "function"
}
export function buildAgent(
source: AgentSource,
model: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
browserProvider?: BrowserAutomationProvider,
disabledSkills?: Set<string>
): AgentConfig {
const base = isFactory(source) ? source(model) : { ...source }
const categoryConfigs: Record<string, CategoryConfig> = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
if (agentWithCategory.category) {
const categoryConfig = categoryConfigs[agentWithCategory.category]
if (categoryConfig) {
if (!base.model) {
base.model = categoryConfig.model
}
if (base.temperature === undefined && categoryConfig.temperature !== undefined) {
base.temperature = categoryConfig.temperature
}
if (base.variant === undefined && categoryConfig.variant !== undefined) {
base.variant = categoryConfig.variant
}
}
}
if (agentWithCategory.skills?.length) {
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
if (resolved.size > 0) {
const skillContent = Array.from(resolved.values()).join("\n\n")
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
}
}
return base
}

142
src/agents/atlas/agent.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* Atlas - Master Orchestrator Agent
*
* Orchestrates work via task() to complete ALL tasks in a todo list until fully done.
* You are the conductor of a symphony of specialized agents.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt"
import {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./prompt-section-builder"
const MODE: AgentMode = "primary"
export type AtlasPromptSource = "default" | "gpt"
/**
* Determines which Atlas prompt to use based on model.
*/
export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
export interface OrchestratorContext {
model?: string
availableAgents?: AvailableAgent[]
availableSkills?: AvailableSkill[]
userCategories?: Record<string, CategoryConfig>
}
/**
* Gets the appropriate Atlas prompt based on model.
*/
export function getAtlasPrompt(model?: string): string {
const source = getAtlasPromptSource(model)
switch (source) {
case "gpt":
return getGptAtlasPrompt()
case "default":
default:
return getDefaultAtlasPrompt()
}
}
function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
const agents = ctx?.availableAgents ?? []
const skills = ctx?.availableSkills ?? []
const userCategories = ctx?.userCategories
const model = ctx?.model
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
name,
description: getCategoryDescription(name, userCategories),
}))
const categorySection = buildCategorySection(userCategories)
const agentSection = buildAgentSelectionSection(agents)
const decisionMatrix = buildDecisionMatrix(agents, userCategories)
const skillsSection = buildSkillsSection(skills)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
const basePrompt = getAtlasPrompt(model)
return basePrompt
.replace("{CATEGORY_SECTION}", categorySection)
.replace("{AGENT_SECTION}", agentSection)
.replace("{DECISION_MATRIX}", decisionMatrix)
.replace("{SKILLS_SECTION}", skillsSection)
.replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide)
}
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
])
const baseConfig = {
description:
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
mode: MODE,
...(ctx.model ? { model: ctx.model } : {}),
temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx),
color: "#10B981",
...restrictions,
}
return baseConfig as AgentConfig
}
createAtlasAgent.mode = MODE
export const atlasPromptMetadata: AgentPromptMetadata = {
category: "advisor",
cost: "EXPENSIVE",
promptAlias: "Atlas",
triggers: [
{
domain: "Todo list orchestration",
trigger: "Complete ALL tasks in a todo list with verification",
},
{
domain: "Multi-agent coordination",
trigger: "Parallel task execution across specialized agents",
},
],
useWhen: [
"User provides a todo list path (.sisyphus/plans/{name}.md)",
"Multiple tasks need to be completed in sequence or parallel",
"Work requires coordination across multiple specialized agents",
],
avoidWhen: [
"Single simple task that doesn't require orchestration",
"Tasks that can be handled directly by one agent",
"When user wants to execute tasks manually",
],
keyTrigger:
"Todo list path provided OR multiple tasks requiring multi-agent orchestration",
}

View File

@@ -178,34 +178,54 @@ task(
)
\`\`\`
### 3.4 Verify (PROJECT-LEVEL QA)
### 3.4 Verify (MANDATORY — EVERY SINGLE DELEGATION)
**After EVERY delegation, YOU must verify:**
**You are the QA gate. Subagents lie. Automated checks alone are NOT enough.**
1. **Project-level diagnostics**:
\`lsp_diagnostics(filePath="src/")\` or \`lsp_diagnostics(filePath=".")\`
MUST return ZERO errors
After EVERY delegation, complete ALL of these steps — no shortcuts:
2. **Build verification**:
\`bun run build\` or \`bun run typecheck\`
Exit code MUST be 0
#### A. Automated Verification
1. \`lsp_diagnostics(filePath=".")\` → ZERO errors at project level
2. \`bun run build\` or \`bun run typecheck\` → exit code 0
3. \`bun test\` → ALL tests pass
3. **Test verification**:
\`bun test\`
ALL tests MUST pass
#### B. Manual Code Review (NON-NEGOTIABLE — DO NOT SKIP)
4. **Manual inspection**:
- Read changed files
- Confirm changes match requirements
- Check for regressions
**This is the step you are most tempted to skip. DO NOT SKIP IT.**
**Checklist:**
1. \`Read\` EVERY file the subagent created or modified — no exceptions
2. For EACH file, check line by line:
- Does the logic actually implement the task requirement?
- Are there stubs, TODOs, placeholders, or hardcoded values?
- Are there logic errors or missing edge cases?
- Does it follow the existing codebase patterns?
- Are imports correct and complete?
3. Cross-reference: compare what subagent CLAIMED vs what the code ACTUALLY does
4. If anything doesn't match → resume session and fix immediately
**If you cannot explain what the changed code does, you have not reviewed it.**
#### C. Hands-On QA (if applicable)
| Deliverable | Method | Tool |
|-------------|--------|------|
| Frontend/UI | Browser | \`/playwright\` |
| TUI/CLI | Interactive | \`interactive_bash\` |
| API/Backend | Real requests | curl |
#### D. Check Boulder State Directly
After verification, READ the plan file directly — every time, no exceptions:
\`\`\`
[ ] lsp_diagnostics at project level - ZERO errors
[ ] Build command - exit 0
[ ] Test suite - all pass
[ ] Files exist and match requirements
[ ] No regressions
Read(".sisyphus/tasks/{plan-name}.yaml")
\`\`\`
Count remaining \`- [ ]\` tasks. This is your ground truth for what comes next.
**Checklist (ALL must be checked):**
\`\`\`
[ ] Automated: lsp_diagnostics clean, build passes, tests pass
[ ] Manual: Read EVERY changed file, verified logic matches requirements
[ ] Cross-check: Subagent claims match actual code
[ ] Boulder: Read plan file, confirmed current progress
\`\`\`
**If verification fails**: Resume the SAME session with the ACTUAL error output:
@@ -325,22 +345,25 @@ task(category="quick", load_skills=[], run_in_background=false, prompt="Task 4..
You are the QA gate. Subagents lie. Verify EVERYTHING.
**After each delegation**:
1. \`lsp_diagnostics\` at PROJECT level (not file level)
2. Run build command
3. Run test suite
4. Read changed files manually
5. Confirm requirements met
**After each delegation — BOTH automated AND manual verification are MANDATORY:**
1. \`lsp_diagnostics\` at PROJECT level → ZERO errors
2. Run build command → exit 0
3. Run test suite → ALL pass
4. **\`Read\` EVERY changed file line by line** → logic matches requirements
5. **Cross-check**: subagent's claims vs actual code — do they match?
6. **Check boulder state**: Read the plan file directly, count remaining tasks
**Evidence required**:
| Action | Evidence |
|--------|----------|
| Code change | lsp_diagnostics clean at project level |
| Code change | lsp_diagnostics clean + manual Read of every changed file |
| Build | Exit code 0 |
| Tests | All pass |
| Delegation | Verified independently |
| Logic correct | You read the code and can explain what it does |
| Boulder state | Read plan file, confirmed progress |
**No evidence = not complete.**
**No evidence = not complete. Skipping manual review = rubber-stamping broken work.**
</verification_rules>
<boundaries>

View File

@@ -182,19 +182,51 @@ Extract wisdom → include in prompt.
task(category="[cat]", load_skills=["[skills]"], run_in_background=false, prompt=\`[6-SECTION PROMPT]\`)
\`\`\`
### 3.4 Verify (PROJECT-LEVEL QA)
### 3.4 Verify (MANDATORY — EVERY SINGLE DELEGATION)
After EVERY delegation:
After EVERY delegation, complete ALL steps — no shortcuts:
#### A. Automated Verification
1. \`lsp_diagnostics(filePath=".")\` → ZERO errors
2. \`Bash("bun run build")\` → exit 0
3. \`Bash("bun test")\` → all pass
4. \`Read\` changed files → confirm requirements met
Checklist:
- [ ] lsp_diagnostics clean
- [ ] Build passes
- [ ] Tests pass
- [ ] Files match requirements
#### B. Manual Code Review (NON-NEGOTIABLE)
1. \`Read\` EVERY file the subagent touched — no exceptions
2. For each file, verify line by line:
| Check | What to Look For |
|-------|------------------|
| Logic correctness | Does implementation match task requirements? |
| Completeness | No stubs, TODOs, placeholders, hardcoded values? |
| Edge cases | Off-by-one, null checks, error paths handled? |
| Patterns | Follows existing codebase conventions? |
| Imports | Correct, complete, no unused? |
3. Cross-check: subagent's claims vs actual code — do they match?
4. If mismatch found → resume session with \`session_id\` and fix
**If you cannot explain what the changed code does, you have not reviewed it.**
#### C. Hands-On QA (if applicable)
| Deliverable | Method | Tool |
|-------------|--------|------|
| Frontend/UI | Browser | \`/playwright\` |
| TUI/CLI | Interactive | \`interactive_bash\` |
| API/Backend | Real requests | curl |
#### D. Check Boulder State Directly
After verification, READ the plan file — every time:
\`\`\`
Read(".sisyphus/tasks/{plan-name}.yaml")
\`\`\`
Count remaining \`- [ ]\` tasks. This is your ground truth.
Checklist (ALL required):
- [ ] Automated: diagnostics clean, build passes, tests pass
- [ ] Manual: Read EVERY changed file, logic matches requirements
- [ ] Cross-check: subagent claims match actual code
- [ ] Boulder: Read plan file, confirmed current progress
### 3.5 Handle Failures
@@ -269,15 +301,23 @@ task(category="quick", load_skills=[], run_in_background=false, prompt="Task 3..
<verification_rules>
You are the QA gate. Subagents lie. Verify EVERYTHING.
**After each delegation**:
**After each delegation — BOTH automated AND manual verification are MANDATORY**:
| Step | Tool | Expected |
|------|------|----------|
| 1 | \`lsp_diagnostics(".")\` | ZERO errors |
| 2 | \`Bash("bun run build")\` | exit 0 |
| 3 | \`Bash("bun test")\` | all pass |
| 4 | \`Read\` changed files | matches requirements |
| 4 | \`Read\` EVERY changed file | logic matches requirements |
| 5 | Cross-check claims vs code | subagent's report matches reality |
| 6 | \`Read\` plan file | boulder state confirmed |
**No evidence = not complete.**
**Manual code review (Step 4) is NON-NEGOTIABLE:**
- Read every line of every changed file
- Verify logic correctness, completeness, edge cases
- If you can't explain what the code does, you haven't reviewed it
**No evidence = not complete. Skipping manual review = rubber-stamping broken work.**
</verification_rules>
<boundaries>

View File

@@ -1,33 +1,3 @@
/**
* Atlas - Master Orchestrator Agent
*
* Orchestrates work via task() to complete ALL tasks in a todo list until fully done.
* You are the conductor of a symphony of specialized agents.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
import { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
import {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./utils"
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
export {
@@ -36,118 +6,9 @@ export {
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./utils"
export { isGptModel }
} from "./prompt-section-builder"
const MODE: AgentMode = "primary"
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
export type AtlasPromptSource = "default" | "gpt"
/**
* Determines which Atlas prompt to use based on model.
*/
export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
export interface OrchestratorContext {
model?: string
availableAgents?: AvailableAgent[]
availableSkills?: AvailableSkill[]
userCategories?: Record<string, CategoryConfig>
}
/**
* Gets the appropriate Atlas prompt based on model.
*/
export function getAtlasPrompt(model?: string): string {
const source = getAtlasPromptSource(model)
switch (source) {
case "gpt":
return getGptAtlasPrompt()
case "default":
default:
return getDefaultAtlasPrompt()
}
}
function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
const agents = ctx?.availableAgents ?? []
const skills = ctx?.availableSkills ?? []
const userCategories = ctx?.userCategories
const model = ctx?.model
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
name,
description: getCategoryDescription(name, userCategories),
}))
const categorySection = buildCategorySection(userCategories)
const agentSection = buildAgentSelectionSection(agents)
const decisionMatrix = buildDecisionMatrix(agents, userCategories)
const skillsSection = buildSkillsSection(skills)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
const basePrompt = getAtlasPrompt(model)
return basePrompt
.replace("{CATEGORY_SECTION}", categorySection)
.replace("{AGENT_SECTION}", agentSection)
.replace("{DECISION_MATRIX}", decisionMatrix)
.replace("{SKILLS_SECTION}", skillsSection)
.replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide)
}
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
])
const baseConfig = {
description:
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
mode: MODE,
...(ctx.model ? { model: ctx.model } : {}),
temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx),
color: "#10B981",
...restrictions,
}
return baseConfig as AgentConfig
}
createAtlasAgent.mode = MODE
export const atlasPromptMetadata: AgentPromptMetadata = {
category: "advisor",
cost: "EXPENSIVE",
promptAlias: "Atlas",
triggers: [
{
domain: "Todo list orchestration",
trigger: "Complete ALL tasks in a todo list with verification",
},
{
domain: "Multi-agent coordination",
trigger: "Parallel task execution across specialized agents",
},
],
useWhen: [
"User provides a todo list path (.sisyphus/plans/{name}.md)",
"Multiple tasks need to be completed in sequence or parallel",
"Work requires coordination across multiple specialized agents",
],
avoidWhen: [
"Single simple task that doesn't require orchestration",
"Tasks that can be handled directly by one agent",
"When user wants to execute tasks manually",
],
keyTrigger:
"Todo list path provided OR multiple tasks requiring multi-agent orchestration",
}
export { isGptModel } from "../types"

View File

@@ -0,0 +1,182 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
import type { CategoriesConfig, GitMasterConfig } from "../config/schema"
import type { LoadedSkill } from "../features/opencode-skill-loader/types"
import type { BrowserAutomationProvider } from "../config/schema"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import { fetchAvailableModels, readConnectedProvidersCache } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { buildAvailableSkills } from "./builtin-agents/available-skills"
import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents"
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"
type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
sisyphus: createSisyphusAgent,
hephaestus: createHephaestusAgent,
oracle: createOracleAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
"multimodal-looker": createMultimodalLookerAgent,
metis: createMetisAgent,
momus: createMomusAgent,
// Note: Atlas is handled specially in createBuiltinAgents()
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as AgentFactory,
}
/**
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
* (Delegation Table, Tool Selection, Key Triggers, etc.)
*/
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
oracle: ORACLE_PROMPT_METADATA,
librarian: LIBRARIAN_PROMPT_METADATA,
explore: EXPLORE_PROMPT_METADATA,
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
metis: metisPromptMetadata,
momus: momusPromptMetadata,
atlas: atlasPromptMetadata,
}
export async function createBuiltinAgents(
disabledAgents: string[] = [],
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
customAgentSummaries?: unknown,
browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string,
disabledSkills?: Set<string>,
useTaskSystem = false
): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache()
// IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization.
// This function is called from config handler, and calling client API causes deadlock.
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
const availableModels = await fetchAvailableModels(undefined, {
connectedProviders: connectedProviders ?? undefined,
})
const isFirstRunNoCache =
availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0)
const result: Record<string, AgentConfig> = {}
const mergedCategories = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
name,
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
}))
const availableSkills = buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills)
// Collect general agents first (for availableAgents), but don't add to result yet
const { pendingAgentConfigs, availableAgents } = collectPendingBuiltinAgents({
agentSources,
agentMetadata,
disabledAgents,
agentOverrides,
directory,
systemDefaultModel,
mergedCategories,
gitMasterConfig,
browserProvider,
uiSelectedModel,
availableModels,
disabledSkills,
})
const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries)
const builtinAgentNames = new Set(Object.keys(agentSources).map((name) => name.toLowerCase()))
const disabledAgentNames = new Set(disabledAgents.map((name) => name.toLowerCase()))
for (const agent of registeredAgents) {
const lowerName = agent.name.toLowerCase()
if (builtinAgentNames.has(lowerName)) continue
if (disabledAgentNames.has(lowerName)) continue
if (availableAgents.some((availableAgent) => availableAgent.name.toLowerCase() === lowerName)) continue
availableAgents.push({
name: agent.name,
description: agent.description,
metadata: buildCustomAgentMetadata(agent.name, agent.description),
})
}
const sisyphusConfig = maybeCreateSisyphusConfig({
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
userCategories: categories,
useTaskSystem,
})
if (sisyphusConfig) {
result["sisyphus"] = sisyphusConfig
}
const hephaestusConfig = maybeCreateHephaestusConfig({
disabledAgents,
agentOverrides,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
useTaskSystem,
})
if (hephaestusConfig) {
result["hephaestus"] = hephaestusConfig
}
// Add pending agents after sisyphus and hephaestus to maintain order
for (const [name, config] of pendingAgentConfigs) {
result[name] = config
}
const atlasConfig = maybeCreateAtlasConfig({
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
availableAgents,
availableSkills,
mergedCategories,
userCategories: categories,
})
if (atlasConfig) {
result["atlas"] = atlasConfig
}
return result
}

View File

@@ -0,0 +1,65 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrideConfig } from "../types"
import type { CategoryConfig } from "../../config/schema"
import { deepMerge, migrateAgentConfig } from "../../shared"
/**
* Expands a category reference from an agent override into concrete config properties.
* Category properties are applied unconditionally (overwriting factory defaults),
* because the user's chosen category should take priority over factory base values.
* Direct override properties applied later via mergeAgentConfig() will supersede these.
*/
export function applyCategoryOverride(
config: AgentConfig,
categoryName: string,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
const categoryConfig = mergedCategories[categoryName]
if (!categoryConfig) return config
const result = { ...config } as AgentConfig & Record<string, unknown>
if (categoryConfig.model) result.model = categoryConfig.model
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
if (categoryConfig.prompt_append && typeof result.prompt === "string") {
result.prompt = result.prompt + "\n" + categoryConfig.prompt_append
}
return result as AgentConfig
}
export function mergeAgentConfig(base: AgentConfig, override: AgentOverrideConfig): AgentConfig {
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
}
return merged
}
export function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
result = applyCategoryOverride(result, overrideCategory, mergedCategories)
}
if (override) {
result = mergeAgentConfig(result, override)
}
return result
}

View File

@@ -0,0 +1,64 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrides } from "../types"
import type { CategoriesConfig, CategoryConfig } from "../../config/schema"
import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared"
import { applyOverrides } from "./agent-overrides"
import { applyModelResolution } from "./model-resolution"
import { createAtlasAgent } from "../atlas"
export function maybeCreateAtlasConfig(input: {
disabledAgents: string[]
agentOverrides: AgentOverrides
uiSelectedModel?: string
availableModels: Set<string>
systemDefaultModel?: string
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
mergedCategories: Record<string, CategoryConfig>
userCategories?: CategoriesConfig
useTaskSystem?: boolean
}): AgentConfig | undefined {
const {
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
availableAgents,
availableSkills,
mergedCategories,
userCategories,
} = input
if (disabledAgents.includes("atlas")) return undefined
const orchestratorOverride = agentOverrides["atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
const atlasResolution = applyModelResolution({
uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel,
userModel: orchestratorOverride?.model,
requirement: atlasRequirement,
availableModels,
systemDefaultModel,
})
if (!atlasResolution) return undefined
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
let orchestratorConfig = createAtlasAgent({
model: atlasModel,
availableAgents,
availableSkills,
userCategories,
})
if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
return orchestratorConfig
}

View File

@@ -0,0 +1,35 @@
import type { AvailableSkill } from "../dynamic-agent-prompt-builder"
import type { BrowserAutomationProvider } from "../../config/schema"
import type { LoadedSkill, SkillScope } from "../../features/opencode-skill-loader/types"
import { createBuiltinSkills } from "../../features/builtin-skills"
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
if (scope === "user" || scope === "opencode") return "user"
if (scope === "project" || scope === "opencode-project") return "project"
return "plugin"
}
export function buildAvailableSkills(
discoveredSkills: LoadedSkill[],
browserProvider?: BrowserAutomationProvider,
disabledSkills?: Set<string>
): AvailableSkill[] {
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
name: skill.name,
description: skill.description,
location: "plugin" as const,
}))
const discoveredAvailable: AvailableSkill[] = discoveredSkills
.filter(s => !builtinSkillNames.has(s.name) && !disabledSkills?.has(s.name))
.map((skill) => ({
name: skill.name,
description: skill.definition.description ?? "",
location: mapScopeToLocation(skill.scope),
}))
return [...builtinAvailable, ...discoveredAvailable]
}

View File

@@ -0,0 +1,8 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import { createEnvContext } from "../env-context"
export function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig {
if (!directory || !config.prompt) return config
const envContext = createEnvContext()
return { ...config, prompt: config.prompt + envContext }
}

View File

@@ -0,0 +1,103 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrides, AgentPromptMetadata } from "../types"
import type { CategoryConfig, GitMasterConfig } from "../../config/schema"
import type { BrowserAutomationProvider } from "../../config/schema"
import type { AvailableAgent } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS, isModelAvailable } from "../../shared"
import { buildAgent, isFactory } from "../agent-builder"
import { applyOverrides } from "./agent-overrides"
import { applyEnvironmentContext } from "./environment-context"
import { applyModelResolution } from "./model-resolution"
export function collectPendingBuiltinAgents(input: {
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>>
disabledAgents: string[]
agentOverrides: AgentOverrides
directory?: string
systemDefaultModel?: string
mergedCategories: Record<string, CategoryConfig>
gitMasterConfig?: GitMasterConfig
browserProvider?: BrowserAutomationProvider
uiSelectedModel?: string
availableModels: Set<string>
disabledSkills?: Set<string>
useTaskSystem?: boolean
}): { pendingAgentConfigs: Map<string, AgentConfig>; availableAgents: AvailableAgent[] } {
const {
agentSources,
agentMetadata,
disabledAgents,
agentOverrides,
directory,
systemDefaultModel,
mergedCategories,
gitMasterConfig,
browserProvider,
uiSelectedModel,
availableModels,
disabledSkills,
} = input
const availableAgents: AvailableAgent[] = []
const pendingAgentConfigs: Map<string, AgentConfig> = new Map()
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
if (agentName === "sisyphus") continue
if (agentName === "hephaestus") continue
if (agentName === "atlas") continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
const override = agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
if (requirement?.requiresModel && availableModels) {
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
continue
}
}
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
const resolution = applyModelResolution({
uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined,
userModel: override?.model,
requirement,
availableModels,
systemDefaultModel,
})
if (!resolution) continue
const { model, variant: resolvedVariant } = resolution
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
// Apply resolved variant from model fallback chain
if (resolvedVariant) {
config = { ...config, variant: resolvedVariant }
}
if (agentName === "librarian") {
config = applyEnvironmentContext(config, directory)
}
config = applyOverrides(config, override, mergedCategories)
// Store for later - will be added after sisyphus and hephaestus
pendingAgentConfigs.set(name, config)
const metadata = agentMetadata[agentName]
if (metadata) {
availableAgents.push({
name: agentName,
description: config.description ?? "",
metadata,
})
}
}
return { pendingAgentConfigs, availableAgents }
}

View File

@@ -0,0 +1,91 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrides } from "../types"
import type { CategoryConfig } from "../../config/schema"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS, isAnyProviderConnected } from "../../shared"
import { createHephaestusAgent } from "../hephaestus"
import { createEnvContext } from "../env-context"
import { applyCategoryOverride, mergeAgentConfig } from "./agent-overrides"
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
export function maybeCreateHephaestusConfig(input: {
disabledAgents: string[]
agentOverrides: AgentOverrides
availableModels: Set<string>
systemDefaultModel?: string
isFirstRunNoCache: boolean
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
availableCategories: AvailableCategory[]
mergedCategories: Record<string, CategoryConfig>
directory?: string
useTaskSystem: boolean
}): AgentConfig | undefined {
const {
disabledAgents,
agentOverrides,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
useTaskSystem,
} = input
if (disabledAgents.includes("hephaestus")) return undefined
const hephaestusOverride = agentOverrides["hephaestus"]
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
const hasRequiredProvider =
!hephaestusRequirement?.requiresProvider ||
hasHephaestusExplicitConfig ||
isFirstRunNoCache ||
isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
if (!hasRequiredProvider) return undefined
let hephaestusResolution = applyModelResolution({
userModel: hephaestusOverride?.model,
requirement: hephaestusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !hephaestusOverride?.model) {
hephaestusResolution = getFirstFallbackModel(hephaestusRequirement)
}
if (!hephaestusResolution) return undefined
const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution
let hephaestusConfig = createHephaestusAgent(
hephaestusModel,
availableAgents,
undefined,
availableSkills,
availableCategories,
useTaskSystem
)
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (hepOverrideCategory) {
hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories)
}
if (directory && hephaestusConfig.prompt) {
const envContext = createEnvContext()
hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext }
}
if (hephaestusOverride) {
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
}
return hephaestusConfig
}

View File

@@ -0,0 +1,28 @@
import { resolveModelPipeline } from "../../shared"
export function applyModelResolution(input: {
uiSelectedModel?: string
userModel?: string
requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] }
availableModels: Set<string>
systemDefaultModel?: string
}) {
const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input
return resolveModelPipeline({
intent: { uiSelectedModel, userModel },
constraints: { availableModels },
policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel },
})
}
export function getFirstFallbackModel(requirement?: {
fallbackChain?: { providers: string[]; model: string; variant?: string }[]
}) {
const entry = requirement?.fallbackChain?.[0]
if (!entry || entry.providers.length === 0) return undefined
return {
model: `${entry.providers[0]}/${entry.model}`,
provenance: "provider-fallback" as const,
variant: entry.variant,
}
}

View File

@@ -0,0 +1,84 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrides } from "../types"
import type { CategoriesConfig, CategoryConfig } from "../../config/schema"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder"
import { AGENT_MODEL_REQUIREMENTS, isAnyFallbackModelAvailable } from "../../shared"
import { applyEnvironmentContext } from "./environment-context"
import { applyOverrides } from "./agent-overrides"
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
import { createSisyphusAgent } from "../sisyphus"
export function maybeCreateSisyphusConfig(input: {
disabledAgents: string[]
agentOverrides: AgentOverrides
uiSelectedModel?: string
availableModels: Set<string>
systemDefaultModel?: string
isFirstRunNoCache: boolean
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
availableCategories: AvailableCategory[]
mergedCategories: Record<string, CategoryConfig>
directory?: string
userCategories?: CategoriesConfig
useTaskSystem: boolean
}): AgentConfig | undefined {
const {
disabledAgents,
agentOverrides,
uiSelectedModel,
availableModels,
systemDefaultModel,
isFirstRunNoCache,
availableAgents,
availableSkills,
availableCategories,
mergedCategories,
directory,
useTaskSystem,
} = input
const sisyphusOverride = agentOverrides["sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
const hasSisyphusExplicitConfig = sisyphusOverride !== undefined
const meetsSisyphusAnyModelRequirement =
!sisyphusRequirement?.requiresAnyModel ||
hasSisyphusExplicitConfig ||
isFirstRunNoCache ||
isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels)
if (disabledAgents.includes("sisyphus") || !meetsSisyphusAnyModelRequirement) return undefined
let sisyphusResolution = applyModelResolution({
uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel,
userModel: sisyphusOverride?.model,
requirement: sisyphusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) {
sisyphusResolution = getFirstFallbackModel(sisyphusRequirement)
}
if (!sisyphusResolution) return undefined
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
let sisyphusConfig = createSisyphusAgent(
sisyphusModel,
availableAgents,
undefined,
availableSkills,
availableCategories,
useTaskSystem
)
if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
return sisyphusConfig
}

View File

@@ -0,0 +1,61 @@
import type { AgentPromptMetadata } from "./types"
import { truncateDescription } from "../shared/truncate-description"
type RegisteredAgentSummary = {
name: string
description: string
}
function sanitizeMarkdownTableCell(value: string): string {
return value
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|")
.replace(/\s+/g, " ")
.trim()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[] {
if (!Array.isArray(input)) return []
const result: RegisteredAgentSummary[] = []
for (const item of input) {
if (!isRecord(item)) continue
const name = typeof item.name === "string" ? item.name : undefined
if (!name) continue
const hidden = item.hidden
if (hidden === true) continue
const disabled = item.disabled
if (disabled === true) continue
const enabled = item.enabled
if (enabled === false) continue
const description = typeof item.description === "string" ? item.description : ""
result.push({ name: sanitizeMarkdownTableCell(name), description: sanitizeMarkdownTableCell(description) })
}
return result
}
export function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata {
const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description))
const safeAgentName = sanitizeMarkdownTableCell(agentName)
return {
category: "specialist",
cost: "CHEAP",
triggers: [
{
domain: `Custom agent: ${safeAgentName}`,
trigger: shortDescription || "Use when this agent's description matches the task",
},
],
}
}

33
src/agents/env-context.ts Normal file
View File

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

View File

@@ -278,13 +278,19 @@ ${librarianSection}
\`\`\`typescript
// CORRECT: Always background, always parallel
// Prompt structure: [CONTEXT: what I'm doing] + [GOAL: what I'm trying to achieve] + [QUESTION: what I need to know] + [REQUEST: what to find]
// Prompt structure (each field should be substantive, not a single sentence):
// [CONTEXT]: What task I'm working on, which files/modules are involved, and what approach I'm taking
// [GOAL]: The specific outcome I need — what decision or action the results will unblock
// [DOWNSTREAM]: How I will use the results — what I'll build/decide based on what's found
// [REQUEST]: Concrete search instructions — what to find, what format to return, and what to SKIP
// Contextual Grep (internal)
task(subagent_type="explore", run_in_background=true, load_skills=[], prompt="I'm implementing user authentication for our API. I need to understand how auth is currently structured in this codebase. Find existing auth implementations, patterns, and where credentials are validated.")
task(subagent_type="explore", run_in_background=true, load_skills=[], prompt="I'm adding error handling to the auth flow. I want to follow existing project conventions for consistency. Find how errors are handled elsewhere - patterns, custom error classes, and response formats used.")
task(subagent_type="explore", run_in_background=true, load_skills=[], description="Find auth implementations", prompt="I'm implementing JWT auth for the REST API in src/api/routes/. I need to match existing auth conventions so my code fits seamlessly. I'll use this to decide middleware structure and token flow. Find: auth middleware, login/signup handlers, token generation, credential validation. Focus on src/ — skip tests. Return file paths with pattern descriptions.")
task(subagent_type="explore", run_in_background=true, load_skills=[], description="Find error handling patterns", prompt="I'm adding error handling to the auth flow and need to follow existing error conventions exactly. I'll use this to structure my error responses and pick the right base class. Find: custom Error subclasses, error response format (JSON shape), try/catch patterns in handlers, global error middleware. Skip test files. Return the error class hierarchy and response format.")
// Reference Grep (external)
task(subagent_type="librarian", run_in_background=true, load_skills=[], prompt="I'm implementing JWT-based auth and need to ensure security best practices. Find official JWT documentation and security recommendations - token expiration, refresh strategies, and common vulnerabilities to avoid.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], prompt="I'm building Express middleware for auth and want production-quality patterns. Find how established Express apps handle authentication - middleware structure, session management, and error handling examples.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
// Continue immediately - collect results when needed
// WRONG: Sequential or blocking - NEVER DO THIS

View File

@@ -1,5 +1,5 @@
export * from "./types"
export { createBuiltinAgents } from "./utils"
export { createBuiltinAgents } from "./builtin-agents"
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
export { createSisyphusAgent } from "./sisyphus"
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"

View File

@@ -1,50 +1,4 @@
/**
* Prometheus Planner System Prompt
*
* Named after the Titan who gave fire (knowledge/foresight) to humanity.
* Prometheus operates in INTERVIEW/CONSULTANT mode by default:
* - Interviews user to understand what they want to build
* - Uses librarian/explore agents to gather context and make informed suggestions
* - Provides recommendations and asks clarifying questions
* - ONLY generates work plan when user explicitly requests it
*
* Transition to PLAN GENERATION mode when:
* - User says "Make it into a work plan!" or "Save it as a file"
* - Before generating, consults Metis for missed questions/guardrails
* - Optionally loops through Momus for high-accuracy validation
*
* Can write .md files only (enforced by prometheus-md-only hook).
*/
import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
/**
* Combined Prometheus system prompt.
* Assembled from modular sections for maintainability.
*/
export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS}
${PROMETHEUS_INTERVIEW_MODE}
${PROMETHEUS_PLAN_GENERATION}
${PROMETHEUS_HIGH_ACCURACY_MODE}
${PROMETHEUS_PLAN_TEMPLATE}
${PROMETHEUS_BEHAVIORAL_SUMMARY}`
/**
* Prometheus planner permission configuration.
* Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook).
* Question permission allows agent to ask user questions via OpenCode's QuestionTool.
*/
export const PROMETHEUS_PERMISSION = {
edit: "allow" as const,
bash: "allow" as const,
webfetch: "allow" as const,
question: "allow" as const,
}
export { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "./system-prompt"
// Re-export individual sections for granular access
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"

View File

@@ -65,9 +65,13 @@ Or should I just note down this single fix?"
**Research First:**
\`\`\`typescript
// Prompt structure: CONTEXT (what I'm doing) + GOAL (what I'm trying to achieve) + QUESTION (what I need to know) + REQUEST (what to find)
task(subagent_type="explore", load_skills=[], prompt="I'm refactoring [target] and need to understand its impact scope before making changes. Find all usages via lsp_find_references - show calling code, patterns of use, and potential breaking points.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm about to modify [affected code] and need to ensure behavior preservation. Find existing test coverage - which tests exercise this code, what assertions exist, and any gaps in coverage.", run_in_background=true)
// Prompt structure (each field substantive):
// [CONTEXT]: Task, files/modules involved, approach
// [GOAL]: Specific outcome needed — what decision/action results will unblock
// [DOWNSTREAM]: How results will be used
// [REQUEST]: What to find, return format, what to SKIP
task(subagent_type="explore", load_skills=[], prompt="I'm refactoring [target] and need to map its full impact scope before making changes. I'll use this to build a safe refactoring plan. Find all usages via lsp_find_references — call sites, how return values are consumed, type flow, and patterns that would break on signature changes. Also check for dynamic access that lsp_find_references might miss. Return: file path, usage pattern, risk level (high/medium/low) per call site.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm about to modify [affected code] and need to understand test coverage for behavior preservation. I'll use this to decide whether to add tests first. Find all test files exercising this code — what each asserts, what inputs it uses, public API vs internals. Identify coverage gaps: behaviors used in production but untested. Return a coverage map: tested vs untested behaviors.", run_in_background=true)
\`\`\`
**Interview Focus:**
@@ -90,10 +94,10 @@ task(subagent_type="explore", load_skills=[], prompt="I'm about to modify [affec
**Pre-Interview Research (MANDATORY):**
\`\`\`typescript
// Launch BEFORE asking user questions
// Prompt structure: CONTEXT + GOAL + QUESTION + REQUEST
task(subagent_type="explore", load_skills=[], prompt="I'm building a new [feature] and want to maintain codebase consistency. Find similar implementations in this project - their structure, patterns used, and conventions to follow.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm adding [feature type] to the project and need to understand existing conventions. Find how similar features are organized - file structure, naming patterns, and architectural approach.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm implementing [technology] and want to follow established best practices. Find official documentation and community recommendations - setup patterns, common pitfalls, and production-ready examples.", run_in_background=true)
// Prompt structure: [CONTEXT] + [GOAL] + [DOWNSTREAM] + [REQUEST]
task(subagent_type="explore", load_skills=[], prompt="I'm building a new [feature] from scratch and need to match existing codebase conventions exactly. I'll use this to copy the right file structure and patterns. Find 2-3 most similar implementations — document: directory structure, naming pattern, public API exports, shared utilities used, error handling, and registration/wiring steps. Return concrete file paths and patterns, not abstract descriptions.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm adding [feature type] and need to understand organizational conventions to match them. I'll use this to determine directory layout and naming scheme. Find how similar features are organized: nesting depth, index.ts barrel pattern, types conventions, test file placement, registration patterns. Compare 2-3 feature directories. Return the canonical structure as a file tree.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm implementing [technology] in production and need authoritative guidance to avoid common mistakes. I'll use this for setup and configuration decisions. Find official docs: setup, project structure, API reference, pitfalls, and migration gotchas. Also find 1-2 production-quality OSS examples (not tutorials). Skip beginner guides — I need production patterns only.", run_in_background=true)
\`\`\`
**Interview Focus** (AFTER research):
@@ -132,7 +136,7 @@ Based on your stack, I'd recommend NextAuth.js - it integrates well with Next.js
Run this check:
\`\`\`typescript
task(subagent_type="explore", load_skills=[], prompt="I'm assessing this project's test setup before planning work that may require TDD. I need to understand what testing capabilities exist. Find test infrastructure: package.json test scripts, config files (jest.config, vitest.config, pytest.ini), and existing test files. Report: 1) Does test infra exist? 2) What framework? 3) Example test patterns.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm assessing test infrastructure before planning TDD work. I'll use this to decide whether to include test setup tasks. Find: 1) Test framework — package.json scripts, config files (jest/vitest/bun/pytest), test dependencies. 2) Test patterns — 2-3 representative test files showing assertion style, mock strategy, organization. 3) Coverage config and test-to-source ratio. 4) CI integration — test commands in .github/workflows. Return structured report: YES/NO per capability with examples.", run_in_background=true)
\`\`\`
#### Step 2: Ask the Test Question (MANDATORY)
@@ -230,8 +234,8 @@ Add to draft immediately:
**Research First:**
\`\`\`typescript
task(subagent_type="explore", load_skills=[], prompt="I'm planning architectural changes and need to understand the current system design. Find existing architecture: module boundaries, dependency patterns, data flow, and key abstractions used.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm designing architecture for [domain] and want to make informed decisions. Find architectural best practices - proven patterns, trade-offs, and lessons learned from similar systems.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm planning architectural changes and need to understand current system design. I'll use this to identify safe-to-change vs load-bearing boundaries. Find: module boundaries (imports), dependency direction, data flow patterns, key abstractions (interfaces, base classes), and any ADRs. Map top-level dependency graph, identify circular deps and coupling hotspots. Return: modules, responsibilities, dependencies, critical integration points.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm designing architecture for [domain] and need to evaluate trade-offs before committing. I'll use this to present concrete options to the user. Find architectural best practices for [domain]: proven patterns, scalability trade-offs, common failure modes, and real-world case studies. Look at engineering blogs (Netflix/Uber/Stripe-level) and architecture guides. Skip generic pattern catalogs — I need domain-specific guidance.", run_in_background=true)
\`\`\`
**Oracle Consultation** (recommend when stakes are high):
@@ -253,9 +257,9 @@ task(subagent_type="oracle", load_skills=[], prompt="Architecture consultation n
**Parallel Investigation:**
\`\`\`typescript
task(subagent_type="explore", load_skills=[], prompt="I'm researching how to implement [feature] and need to understand current approach. Find how X is currently handled in this codebase - implementation details, edge cases covered, and any known limitations.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm implementing Y and need authoritative guidance. Find official documentation - API reference, configuration options, and recommended usage patterns.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm looking for battle-tested implementations of Z. Find open source projects that solve this - focus on production-quality code, how they handle edge cases, and any gotchas documented.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm researching [feature] to decide whether to extend or replace the current approach. I'll use this to recommend a strategy. Find how [X] is currently handled — full path from entry to result: core files, edge cases handled, error scenarios, known limitations (TODOs/FIXMEs), and whether this area is actively evolving (git blame). Return: what works, what's fragile, what's missing.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm implementing [Y] and need authoritative guidance to make correct API choices first try. I'll use this to follow intended patterns, not anti-patterns. Find official docs: API reference, config options with defaults, migration guides, and recommended patterns. Check for 'common mistakes' sections and GitHub issues for gotchas. Return: key API signatures, recommended config, pitfalls.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm looking for battle-tested implementations of [Z] to identify the consensus approach. I'll use this to avoid reinventing the wheel. Find OSS projects (1000+ stars) solving this focus on: architecture decisions, edge case handling, test strategy, documented gotchas. Compare 2-3 implementations for common vs project-specific patterns. Skip tutorials — production code only.", run_in_background=true)
\`\`\`
**Interview Focus:**
@@ -281,17 +285,17 @@ task(subagent_type="librarian", load_skills=[], prompt="I'm looking for battle-t
**For Understanding Codebase:**
\`\`\`typescript
task(subagent_type="explore", load_skills=[], prompt="I'm working on [topic] and need to understand how it's organized in this project. Find all related files - show the structure, patterns used, and conventions I should follow.", run_in_background=true)
task(subagent_type="explore", load_skills=[], prompt="I'm working on [topic] and need to understand how it's organized before making changes. I'll use this to match existing conventions. Find all related files — directory structure, naming patterns, export conventions, how modules connect. Compare 2-3 similar modules to identify the canonical pattern. Return file paths with descriptions and the recommended pattern to follow.", run_in_background=true)
\`\`\`
**For External Knowledge:**
\`\`\`typescript
task(subagent_type="librarian", load_skills=[], prompt="I'm integrating [library] and need to understand [specific feature]. Find official documentation - API details, configuration options, and recommended best practices.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm integrating [library] and need to understand [specific feature] for correct first-try implementation. I'll use this to follow recommended patterns. Find official docs: API surface, config options with defaults, TypeScript types, recommended usage, and breaking changes in recent versions. Check changelog if our version differs from latest. Return: API signatures, config snippets, pitfalls.", run_in_background=true)
\`\`\`
**For Implementation Examples:**
\`\`\`typescript
task(subagent_type="librarian", load_skills=[], prompt="I'm implementing [feature] and want to learn from existing solutions. Find open source implementations - focus on production-quality code, architecture decisions, and common patterns.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm implementing [feature] and want to learn from production OSS before designing our approach. I'll use this to identify consensus patterns. Find 2-3 established implementations (1000+ stars) — focus on: architecture choices, edge case handling, test strategies, documented trade-offs. Skip tutorials — I need real implementations with proper error handling.", run_in_background=true)
\`\`\`
## Interview Mode Anti-Patterns

View File

@@ -0,0 +1,29 @@
import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
/**
* Combined Prometheus system prompt.
* Assembled from modular sections for maintainability.
*/
export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS}
${PROMETHEUS_INTERVIEW_MODE}
${PROMETHEUS_PLAN_GENERATION}
${PROMETHEUS_HIGH_ACCURACY_MODE}
${PROMETHEUS_PLAN_TEMPLATE}
${PROMETHEUS_BEHAVIORAL_SUMMARY}`
/**
* Prometheus planner permission configuration.
* Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook).
* Question permission allows agent to ask user questions via OpenCode's QuestionTool.
*/
export const PROMETHEUS_PERMISSION = {
edit: "allow" as const,
bash: "allow" as const,
webfetch: "allow" as const,
question: "allow" as const,
}

View File

@@ -0,0 +1,119 @@
/**
* Sisyphus-Junior - Focused Task Executor
*
* Executes delegated tasks directly without spawning other agents.
* Category-spawned executor with domain-specific configurations.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { isGptModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema"
import {
createAgentToolRestrictions,
type PermissionValue,
} from "../../shared/permission-compat"
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
const MODE: AgentMode = "subagent"
// Core tools that Sisyphus-Junior must NEVER have access to
// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian
const BLOCKED_TOOLS = ["task"]
export const SISYPHUS_JUNIOR_DEFAULTS = {
model: "anthropic/claude-sonnet-4-5",
temperature: 0.1,
} as const
export type SisyphusJuniorPromptSource = "default" | "gpt"
/**
* Determines which Sisyphus-Junior prompt to use based on model.
*/
export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
/**
* Builds the appropriate Sisyphus-Junior prompt based on model.
*/
export function buildSisyphusJuniorPrompt(
model: string | undefined,
useTaskSystem: boolean,
promptAppend?: string
): string {
const source = getSisyphusJuniorPromptSource(model)
switch (source) {
case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default":
default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
}
}
export function createSisyphusJuniorAgentWithOverrides(
override: AgentOverrideConfig | undefined,
systemDefaultModel?: string,
useTaskSystem = false
): AgentConfig {
if (override?.disable) {
override = undefined
}
const overrideModel = (override as { model?: string } | undefined)?.model
const model = overrideModel ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
const promptAppend = override?.prompt_append
const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend)
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
const userPermission = (override?.permission ?? {}) as Record<string, PermissionValue>
const basePermission = baseRestrictions.permission
const merged: Record<string, PermissionValue> = { ...userPermission }
for (const tool of BLOCKED_TOOLS) {
merged[tool] = "deny"
}
merged.call_omo_agent = "allow"
const toolsConfig = { permission: { ...merged, ...basePermission } }
const base: AgentConfig = {
description: override?.description ??
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
mode: MODE,
model,
temperature,
maxTokens: 64000,
prompt,
color: override?.color ?? "#20B2AA",
...toolsConfig,
}
if (override?.top_p !== undefined) {
base.top_p = override.top_p
}
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium" } as AgentConfig
}
return {
...base,
thinking: { type: "enabled", budgetTokens: 32000 },
} as AgentConfig
}
createSisyphusJuniorAgentWithOverrides.mode = MODE

View File

@@ -12,6 +12,7 @@ export function buildDefaultSisyphusJuniorPrompt(
promptAppend?: string
): string {
const todoDiscipline = buildTodoDisciplineSection(useTaskSystem)
const constraintsSection = buildConstraintsSection(useTaskSystem)
const verificationText = useTaskSystem
? "All tasks marked completed"
: "All todos marked completed"
@@ -21,13 +22,7 @@ Sisyphus-Junior - Focused executor from OhMyOpenCode.
Execute tasks directly. NEVER delegate or spawn other agents.
</Role>
<Critical_Constraints>
BLOCKED ACTIONS (will fail if attempted):
- task tool: BLOCKED
ALLOWED: call_omo_agent - You CAN spawn explore/librarian agents for research.
You work ALONE for implementation. No delegation of implementation tasks.
</Critical_Constraints>
${constraintsSection}
${todoDiscipline}
@@ -48,6 +43,29 @@ Task NOT complete without:
return prompt + "\n\n" + promptAppend
}
function buildConstraintsSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
return `<Critical_Constraints>
BLOCKED ACTIONS (will fail if attempted):
- task (agent delegation tool): BLOCKED — you cannot delegate work to other agents
ALLOWED tools:
- call_omo_agent: You CAN spawn explore/librarian agents for research
- task_create, task_update, task_list, task_get: ALLOWED — use these for tracking your work
You work ALONE for implementation. No delegation of implementation tasks.
</Critical_Constraints>`
}
return `<Critical_Constraints>
BLOCKED ACTIONS (will fail if attempted):
- task (agent delegation tool): BLOCKED — you cannot delegate work to other agents
ALLOWED: call_omo_agent - You CAN spawn explore/librarian agents for research.
You work ALONE for implementation. No delegation of implementation tasks.
</Critical_Constraints>`
}
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
return `<Task_Discipline>

View File

@@ -21,6 +21,7 @@ export function buildGptSisyphusJuniorPrompt(
promptAppend?: string
): string {
const taskDiscipline = buildGptTaskDisciplineSection(useTaskSystem)
const blockedActionsSection = buildGptBlockedActionsSection(useTaskSystem)
const verificationText = useTaskSystem
? "All tasks marked completed"
: "All todos marked completed"
@@ -45,19 +46,7 @@ Role: Execute tasks directly. You work ALONE.
- Do NOT expand task boundaries beyond what's written.
</scope_and_design_constraints>
<blocked_actions>
BLOCKED (will fail if attempted):
| Tool | Status |
|------|--------|
| task | BLOCKED |
ALLOWED:
| Tool | Usage |
|------|-------|
| call_omo_agent | Spawn explore/librarian for research ONLY |
You work ALONE for implementation. No delegation.
</blocked_actions>
${blockedActionsSection}
<uncertainty_and_ambiguity>
- If a task is ambiguous or underspecified:
@@ -99,6 +88,42 @@ Task NOT complete without evidence:
return prompt + "\n\n" + promptAppend
}
function buildGptBlockedActionsSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
return `<blocked_actions>
BLOCKED (will fail if attempted):
| Tool | Status | Description |
|------|--------|-------------|
| task | BLOCKED | Agent delegation tool — you cannot spawn other agents |
ALLOWED:
| Tool | Usage |
|------|-------|
| call_omo_agent | Spawn explore/librarian for research ONLY |
| task_create | Create tasks to track your work |
| task_update | Update task status (in_progress, completed) |
| task_list | List active tasks |
| task_get | Get task details by ID |
You work ALONE for implementation. No delegation.
</blocked_actions>`
}
return `<blocked_actions>
BLOCKED (will fail if attempted):
| Tool | Status | Description |
|------|--------|-------------|
| task | BLOCKED | Agent delegation tool — you cannot spawn other agents |
ALLOWED:
| Tool | Usage |
|------|-------|
| call_omo_agent | Spawn explore/librarian for research ONLY |
You work ALONE for implementation. No delegation.
</blocked_actions>`
}
function buildGptTaskDisciplineSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
return `<task_discipline_spec>

View File

@@ -200,6 +200,88 @@ describe("createSisyphusJuniorAgentWithOverrides", () => {
})
})
describe("useTaskSystem integration", () => {
test("useTaskSystem=true produces Task_Discipline prompt for Claude", () => {
//#given
const override = { model: "anthropic/claude-sonnet-4-5" }
//#when
const result = createSisyphusJuniorAgentWithOverrides(override, undefined, true)
//#then
expect(result.prompt).toContain("TaskCreate")
expect(result.prompt).toContain("TaskUpdate")
expect(result.prompt).not.toContain("todowrite")
})
test("useTaskSystem=true produces task_discipline_spec prompt for GPT", () => {
//#given
const override = { model: "openai/gpt-5.2" }
//#when
const result = createSisyphusJuniorAgentWithOverrides(override, undefined, true)
//#then
expect(result.prompt).toContain("<task_discipline_spec>")
expect(result.prompt).toContain("TaskCreate")
expect(result.prompt).not.toContain("<todo_discipline_spec>")
})
test("useTaskSystem=false (default) produces Todo_Discipline prompt", () => {
//#given
const override = {}
//#when
const result = createSisyphusJuniorAgentWithOverrides(override)
//#then
expect(result.prompt).toContain("todowrite")
expect(result.prompt).not.toContain("TaskCreate")
})
test("useTaskSystem=true explicitly lists task management tools as ALLOWED for Claude", () => {
//#given
const override = { model: "anthropic/claude-sonnet-4-5" }
//#when
const result = createSisyphusJuniorAgentWithOverrides(override, undefined, true)
//#then - prompt must disambiguate: delegation tool blocked, management tools allowed
expect(result.prompt).toContain("task_create")
expect(result.prompt).toContain("task_update")
expect(result.prompt).toContain("task_list")
expect(result.prompt).toContain("task_get")
expect(result.prompt).toContain("agent delegation tool")
})
test("useTaskSystem=true explicitly lists task management tools as ALLOWED for GPT", () => {
//#given
const override = { model: "openai/gpt-5.2" }
//#when
const result = createSisyphusJuniorAgentWithOverrides(override, undefined, true)
//#then - prompt must disambiguate: delegation tool blocked, management tools allowed
expect(result.prompt).toContain("task_create")
expect(result.prompt).toContain("task_update")
expect(result.prompt).toContain("task_list")
expect(result.prompt).toContain("task_get")
expect(result.prompt).toContain("Agent delegation tool")
})
test("useTaskSystem=false does NOT list task management tools in constraints", () => {
//#given - Claude model without task system
const override = { model: "anthropic/claude-sonnet-4-5" }
//#when
const result = createSisyphusJuniorAgentWithOverrides(override, undefined, false)
//#then - no task management tool references in constraints section
expect(result.prompt).not.toContain("task_create")
expect(result.prompt).not.toContain("task_update")
})
})
describe("prompt composition", () => {
test("base prompt contains discipline constraints", () => {
// given

View File

@@ -1,121 +1,10 @@
/**
* Sisyphus-Junior - Focused Task Executor
*
* Executes delegated tasks directly without spawning other agents.
* Category-spawned executor with domain-specific configurations.
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { isGptModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema"
import {
createAgentToolRestrictions,
type PermissionValue,
} from "../../shared/permission-compat"
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
export { buildDefaultSisyphusJuniorPrompt } from "./default"
export { buildGptSisyphusJuniorPrompt } from "./gpt"
const MODE: AgentMode = "subagent"
// Core tools that Sisyphus-Junior must NEVER have access to
// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian
const BLOCKED_TOOLS = ["task"]
export const SISYPHUS_JUNIOR_DEFAULTS = {
model: "anthropic/claude-sonnet-4-5",
temperature: 0.1,
} as const
export type SisyphusJuniorPromptSource = "default" | "gpt"
/**
* Determines which Sisyphus-Junior prompt to use based on model.
*/
export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
return "default"
}
/**
* Builds the appropriate Sisyphus-Junior prompt based on model.
*/
export function buildSisyphusJuniorPrompt(
model: string | undefined,
useTaskSystem: boolean,
promptAppend?: string
): string {
const source = getSisyphusJuniorPromptSource(model)
switch (source) {
case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default":
default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
}
}
export function createSisyphusJuniorAgentWithOverrides(
override: AgentOverrideConfig | undefined,
systemDefaultModel?: string,
useTaskSystem = false
): AgentConfig {
if (override?.disable) {
override = undefined
}
const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
const promptAppend = override?.prompt_append
const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend)
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
const userPermission = (override?.permission ?? {}) as Record<string, PermissionValue>
const basePermission = baseRestrictions.permission
const merged: Record<string, PermissionValue> = { ...userPermission }
for (const tool of BLOCKED_TOOLS) {
merged[tool] = "deny"
}
merged.call_omo_agent = "allow"
const toolsConfig = { permission: { ...merged, ...basePermission } }
const base: AgentConfig = {
description: override?.description ??
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
mode: MODE,
model,
temperature,
maxTokens: 64000,
prompt,
color: override?.color ?? "#20B2AA",
...toolsConfig,
}
if (override?.top_p !== undefined) {
base.top_p = override.top_p
}
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium" } as AgentConfig
}
return {
...base,
thinking: { type: "enabled", budgetTokens: 32000 },
} as AgentConfig
}
createSisyphusJuniorAgentWithOverrides.mode = MODE
export {
SISYPHUS_JUNIOR_DEFAULTS,
getSisyphusJuniorPromptSource,
buildSisyphusJuniorPrompt,
createSisyphusJuniorAgentWithOverrides,
} from "./agent"
export type { SisyphusJuniorPromptSource } from "./agent"

View File

@@ -275,13 +275,19 @@ ${librarianSection}
\`\`\`typescript
// CORRECT: Always background, always parallel
// Prompt structure: [CONTEXT: what I'm doing] + [GOAL: what I'm trying to achieve] + [QUESTION: what I need to know] + [REQUEST: what to find]
// Prompt structure (each field should be substantive, not a single sentence):
// [CONTEXT]: What task I'm working on, which files/modules are involved, and what approach I'm taking
// [GOAL]: The specific outcome I need — what decision or action the results will unblock
// [DOWNSTREAM]: How I will use the results — what I'll build/decide based on what's found
// [REQUEST]: Concrete search instructions — what to find, what format to return, and what to SKIP
// Contextual Grep (internal)
task(subagent_type="explore", run_in_background=true, load_skills=[], description="Find auth implementations", prompt="I'm implementing user authentication for our API. I need to understand how auth is currently structured in this codebase. Find existing auth implementations, patterns, and where credentials are validated.")
task(subagent_type="explore", run_in_background=true, load_skills=[], description="Find error handling patterns", prompt="I'm adding error handling to the auth flow. I want to follow existing project conventions for consistency. Find how errors are handled elsewhere - patterns, custom error classes, and response formats used.")
task(subagent_type="explore", run_in_background=true, load_skills=[], description="Find auth implementations", prompt="I'm implementing JWT auth for the REST API in src/api/routes/. I need to match existing auth conventions so my code fits seamlessly. I'll use this to decide middleware structure and token flow. Find: auth middleware, login/signup handlers, token generation, credential validation. Focus on src/ — skip tests. Return file paths with pattern descriptions.")
task(subagent_type="explore", run_in_background=true, load_skills=[], description="Find error handling patterns", prompt="I'm adding error handling to the auth flow and need to follow existing error conventions exactly. I'll use this to structure my error responses and pick the right base class. Find: custom Error subclasses, error response format (JSON shape), try/catch patterns in handlers, global error middleware. Skip test files. Return the error class hierarchy and response format.")
// Reference Grep (external)
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT-based auth and need to ensure security best practices. Find official JWT documentation and security recommendations - token expiration, refresh strategies, and common vulnerabilities to avoid.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express middleware for auth and want production-quality patterns. Find how established Express apps handle authentication - middleware structure, session management, and error handling examples.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
// Continue working immediately. Collect with background_output when needed.
// WRONG: Sequential or blocking

View File

@@ -1,5 +1,7 @@
/// <reference types="bun-types" />
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
import { createBuiltinAgents } from "./utils"
import { createBuiltinAgents } from "./builtin-agents"
import type { AgentConfig } from "@opencode-ai/sdk"
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
import * as connectedProvidersCache from "../shared/connected-providers-cache"
@@ -759,7 +761,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
})
describe("buildAgent with category and skills", () => {
const { buildAgent } = require("./utils")
const { buildAgent } = require("./agent-builder")
const TEST_MODEL = "anthropic/claude-opus-4-6"
beforeEach(() => {

View File

@@ -1,571 +0,0 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
import {
deepMerge,
fetchAvailableModels,
resolveModelPipeline,
AGENT_MODEL_REQUIREMENTS,
readConnectedProvidersCache,
isModelAvailable,
isAnyFallbackModelAvailable,
isAnyProviderConnected,
migrateAgentConfig,
truncateDescription,
} from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../features/builtin-skills"
import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/types"
import type { BrowserAutomationProvider } from "../config/schema"
type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
sisyphus: createSisyphusAgent,
hephaestus: createHephaestusAgent,
oracle: createOracleAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
"multimodal-looker": createMultimodalLookerAgent,
metis: createMetisAgent,
momus: createMomusAgent,
// Note: Atlas is handled specially in createBuiltinAgents()
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as unknown as AgentFactory,
}
/**
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
* (Delegation Table, Tool Selection, Key Triggers, etc.)
*/
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
oracle: ORACLE_PROMPT_METADATA,
librarian: LIBRARIAN_PROMPT_METADATA,
explore: EXPLORE_PROMPT_METADATA,
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
metis: metisPromptMetadata,
momus: momusPromptMetadata,
atlas: atlasPromptMetadata,
}
function isFactory(source: AgentSource): source is AgentFactory {
return typeof source === "function"
}
type RegisteredAgentSummary = {
name: string
description: string
}
function sanitizeMarkdownTableCell(value: string): string {
return value
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|")
.replace(/\s+/g, " ")
.trim()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[] {
if (!Array.isArray(input)) return []
const result: RegisteredAgentSummary[] = []
for (const item of input) {
if (!isRecord(item)) continue
const name = typeof item.name === "string" ? item.name : undefined
if (!name) continue
const hidden = item.hidden
if (hidden === true) continue
const disabled = item.disabled
if (disabled === true) continue
const enabled = item.enabled
if (enabled === false) continue
const description = typeof item.description === "string" ? item.description : ""
result.push({ name, description: sanitizeMarkdownTableCell(description) })
}
return result
}
function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata {
const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description))
const safeAgentName = sanitizeMarkdownTableCell(agentName)
return {
category: "specialist",
cost: "CHEAP",
triggers: [
{
domain: `Custom agent: ${safeAgentName}`,
trigger: shortDescription || "Use when this agent's description matches the task",
},
],
}
}
export function buildAgent(
source: AgentSource,
model: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
browserProvider?: BrowserAutomationProvider,
disabledSkills?: Set<string>
): AgentConfig {
const base = isFactory(source) ? source(model) : source
const categoryConfigs: Record<string, CategoryConfig> = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
if (agentWithCategory.category) {
const categoryConfig = categoryConfigs[agentWithCategory.category]
if (categoryConfig) {
if (!base.model) {
base.model = categoryConfig.model
}
if (base.temperature === undefined && categoryConfig.temperature !== undefined) {
base.temperature = categoryConfig.temperature
}
if (base.variant === undefined && categoryConfig.variant !== undefined) {
base.variant = categoryConfig.variant
}
}
}
if (agentWithCategory.skills?.length) {
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
if (resolved.size > 0) {
const skillContent = Array.from(resolved.values()).join("\n\n")
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
}
}
return base
}
/**
* Creates OmO-specific environment context (time, timezone, locale).
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
* so we only include fields that OpenCode doesn't provide to avoid duplication.
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
*/
export function createEnvContext(): string {
const now = new Date()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const locale = Intl.DateTimeFormat().resolvedOptions().locale
const dateStr = now.toLocaleDateString(locale, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
const timeStr = now.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
return `
<omo-env>
Current date: ${dateStr}
Current time: ${timeStr}
Timezone: ${timezone}
Locale: ${locale}
</omo-env>`
}
/**
* Expands a category reference from an agent override into concrete config properties.
* Category properties are applied unconditionally (overwriting factory defaults),
* because the user's chosen category should take priority over factory base values.
* Direct override properties applied later via mergeAgentConfig() will supersede these.
*/
function applyCategoryOverride(
config: AgentConfig,
categoryName: string,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
const categoryConfig = mergedCategories[categoryName]
if (!categoryConfig) return config
const result = { ...config } as AgentConfig & Record<string, unknown>
if (categoryConfig.model) result.model = categoryConfig.model
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
return result as AgentConfig
}
function applyModelResolution(input: {
uiSelectedModel?: string
userModel?: string
requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] }
availableModels: Set<string>
systemDefaultModel?: string
}) {
const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input
return resolveModelPipeline({
intent: { uiSelectedModel, userModel },
constraints: { availableModels },
policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel },
})
}
function getFirstFallbackModel(requirement?: {
fallbackChain?: { providers: string[]; model: string; variant?: string }[]
}) {
const entry = requirement?.fallbackChain?.[0]
if (!entry || entry.providers.length === 0) return undefined
return {
model: `${entry.providers[0]}/${entry.model}`,
provenance: "provider-fallback" as const,
variant: entry.variant,
}
}
function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig {
if (!directory || !config.prompt) return config
const envContext = createEnvContext()
return { ...config, prompt: config.prompt + envContext }
}
function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
result = applyCategoryOverride(result, overrideCategory, mergedCategories)
}
if (override) {
result = mergeAgentConfig(result, override)
}
return result
}
function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
}
return merged
}
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
if (scope === "user" || scope === "opencode") return "user"
if (scope === "project" || scope === "opencode-project") return "project"
return "plugin"
}
export async function createBuiltinAgents(
disabledAgents: string[] = [],
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
customAgentSummaries?: unknown,
browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string,
disabledSkills?: Set<string>
): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache()
// IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization.
// This function is called from config handler, and calling client API causes deadlock.
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
const availableModels = await fetchAvailableModels(undefined, {
connectedProviders: connectedProviders ?? undefined,
})
const isFirstRunNoCache =
availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0)
const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = []
const mergedCategories = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
name,
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
}))
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
name: skill.name,
description: skill.description,
location: "plugin" as const,
}))
const discoveredAvailable: AvailableSkill[] = discoveredSkills
.filter(s => !builtinSkillNames.has(s.name))
.map((skill) => ({
name: skill.name,
description: skill.definition.description ?? "",
location: mapScopeToLocation(skill.scope),
}))
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable]
const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries)
const builtinAgentNames = new Set(Object.keys(agentSources).map((n) => n.toLowerCase()))
const disabledAgentNames = new Set(disabledAgents.map((n) => n.toLowerCase()))
// Collect general agents first (for availableAgents), but don't add to result yet
const pendingAgentConfigs: Map<string, AgentConfig> = new Map()
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
if (agentName === "sisyphus") continue
if (agentName === "hephaestus") continue
if (agentName === "atlas") continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
const override = agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
if (requirement?.requiresModel && availableModels) {
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
continue
}
}
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
const resolution = applyModelResolution({
uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined,
userModel: override?.model,
requirement,
availableModels,
systemDefaultModel,
})
if (!resolution) continue
const { model, variant: resolvedVariant } = resolution
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
// Apply resolved variant from model fallback chain
if (resolvedVariant) {
config = { ...config, variant: resolvedVariant }
}
// Expand override.category into concrete properties (higher priority than factory/resolved)
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
}
if (agentName === "librarian") {
config = applyEnvironmentContext(config, directory)
}
config = applyOverrides(config, override, mergedCategories)
// Store for later - will be added after sisyphus and hephaestus
pendingAgentConfigs.set(name, config)
const metadata = agentMetadata[agentName]
if (metadata) {
availableAgents.push({
name: agentName,
description: config.description ?? "",
metadata,
})
}
}
for (const agent of registeredAgents) {
const lowerName = agent.name.toLowerCase()
if (builtinAgentNames.has(lowerName)) continue
if (disabledAgentNames.has(lowerName)) continue
if (availableAgents.some((a) => a.name.toLowerCase() === lowerName)) continue
availableAgents.push({
name: agent.name,
description: agent.description,
metadata: buildCustomAgentMetadata(agent.name, agent.description),
})
}
const sisyphusOverride = agentOverrides["sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
const hasSisyphusExplicitConfig = sisyphusOverride !== undefined
const meetsSisyphusAnyModelRequirement =
!sisyphusRequirement?.requiresAnyModel ||
hasSisyphusExplicitConfig ||
isFirstRunNoCache ||
isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels)
if (!disabledAgents.includes("sisyphus") && meetsSisyphusAnyModelRequirement) {
let sisyphusResolution = applyModelResolution({
uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel,
userModel: sisyphusOverride?.model,
requirement: sisyphusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) {
sisyphusResolution = getFirstFallbackModel(sisyphusRequirement)
}
if (sisyphusResolution) {
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
let sisyphusConfig = createSisyphusAgent(
sisyphusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
result["sisyphus"] = sisyphusConfig
}
}
if (!disabledAgents.includes("hephaestus")) {
const hephaestusOverride = agentOverrides["hephaestus"]
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
const hasRequiredProvider =
!hephaestusRequirement?.requiresProvider ||
hasHephaestusExplicitConfig ||
isFirstRunNoCache ||
isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
if (hasRequiredProvider) {
let hephaestusResolution = applyModelResolution({
userModel: hephaestusOverride?.model,
requirement: hephaestusRequirement,
availableModels,
systemDefaultModel,
})
if (isFirstRunNoCache && !hephaestusOverride?.model) {
hephaestusResolution = getFirstFallbackModel(hephaestusRequirement)
}
if (hephaestusResolution) {
const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution
let hephaestusConfig = createHephaestusAgent(
hephaestusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
if (!hephaestusOverride?.variant) {
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
}
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (hepOverrideCategory) {
hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories)
}
if (directory && hephaestusConfig.prompt) {
const envContext = createEnvContext()
hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext }
}
if (hephaestusOverride) {
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
}
result["hephaestus"] = hephaestusConfig
}
}
}
// Add pending agents after sisyphus and hephaestus to maintain order
for (const [name, config] of pendingAgentConfigs) {
result[name] = config
}
if (!disabledAgents.includes("atlas")) {
const orchestratorOverride = agentOverrides["atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
const atlasResolution = applyModelResolution({
uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel,
userModel: orchestratorOverride?.model,
requirement: atlasRequirement,
availableModels,
systemDefaultModel,
})
if (atlasResolution) {
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
let orchestratorConfig = createAtlasAgent({
model: atlasModel,
availableAgents,
availableSkills,
userCategories: categories,
})
if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
result["atlas"] = orchestratorConfig
}
}
return result
}

View File

@@ -2,79 +2,71 @@
## OVERVIEW
CLI entry: `bunx oh-my-opencode`. 70 CLI utilities and commands with Commander.js + @clack/prompts TUI.
CLI entry: `bunx oh-my-opencode`. 107+ files with Commander.js + @clack/prompts TUI.
**Commands**: install (interactive setup), doctor (14 health checks), run (session launcher), get-local-version, mcp-oauth
**Commands**: install, run, doctor, get-local-version, mcp-oauth
## STRUCTURE
```
cli/
├── index.ts # Commander.js entry (5 commands)
├── install.ts # Interactive TUI (542 lines)
├── config-manager.ts # JSONC parsing (667 lines)
├── model-fallback.ts # Model fallback configuration
├── types.ts # InstallArgs, InstallConfig
├── doctor/
│ ├── index.ts # Doctor entry
│ ├── runner.ts # Check orchestration
│ ├── formatter.ts # Colored output
│ ├── constants.ts # Check IDs, symbols
── types.ts # CheckResult, CheckDefinition
│ └── checks/ # 14 checks, 23 files
├── version.ts # OpenCode + plugin version
├── config.ts # JSONC validity, Zod
├── auth.ts # Anthropic, OpenAI, Google
│ ├── dependencies.ts # AST-Grep, Comment Checker
├── lsp.ts # LSP connectivity
├── mcp.ts # MCP validation
├── model-resolution.ts # Model resolution check (323 lines)
└── gh.ts # GitHub CLI
├── run/
── index.ts # Session launcher
│ └── events.ts # CLI run events (325 lines)
├── mcp-oauth/
│ └── index.ts # MCP OAuth flow
└── get-local-version/
└── index.ts # Version detection
├── index.ts # Entry point (5 lines)
├── cli-program.ts # Commander.js program (150+ lines, 5 commands)
├── install.ts # TTY routing (TUI or CLI installer)
├── cli-installer.ts # Non-interactive installer (164 lines)
├── tui-installer.ts # Interactive TUI with @clack/prompts (140 lines)
├── config-manager/ # 17 config utilities
│ ├── add-plugin-to-opencode-config.ts # Plugin registration
│ ├── add-provider-config.ts # Provider setup
│ ├── detect-current-config.ts # Project vs user config
│ ├── write-omo-config.ts # JSONC writing
── ...
├── doctor/ # 14 health checks
├── runner.ts # Check orchestration
├── formatter.ts # Colored output
└── checks/ # 29 files: auth, config, dependencies, gh, lsp, mcp, opencode, plugin, version, model-resolution (6 sub-checks)
├── run/ # Session launcher (24 files)
├── runner.ts # Run orchestration (126 lines)
├── agent-resolver.ts # Agent selection: flag → env → config → fallback
├── session-resolver.ts # Session creation or resume
├── event-handlers.ts # Event processing (125 lines)
│ ├── completion.ts # Completion detection
── poll-for-completion.ts # Polling with timeout
├── mcp-oauth/ # OAuth token management (login, logout, status)
├── get-local-version/ # Version detection + update check
├── model-fallback.ts # Model fallback configuration
└── provider-availability.ts # Provider availability checks
```
## COMMANDS
| Command | Purpose |
|---------|---------|
| `install` | Interactive setup with provider selection |
| `doctor` | 14 health checks for diagnostics |
| `run` | Launch session with todo enforcement |
| `get-local-version` | Version detection and update check |
| `mcp-oauth` | MCP OAuth authentication flow |
| Command | Purpose | Key Logic |
|---------|---------|-----------|
| `install` | Interactive setup | Provider selection → config generation → plugin registration |
| `run` | Session launcher | Agent: flag → env → config → Sisyphus. Enforces todo completion. |
| `doctor` | 14 health checks | installation, config, auth, deps, tools, updates |
| `get-local-version` | Version check | Detects installed, compares with npm latest |
| `mcp-oauth` | OAuth tokens | login (PKCE flow), logout, status |
## DOCTOR CATEGORIES (14 Checks)
## DOCTOR CHECK CATEGORIES
| Category | Checks |
|----------|--------|
| installation | opencode, plugin |
| configuration | config validity, Zod, model-resolution |
| configuration | config validity, Zod, model-resolution (6 sub-checks) |
| authentication | anthropic, openai, google |
| dependencies | ast-grep, comment-checker, gh-cli |
| tools | LSP, MCP |
| tools | LSP, MCP, MCP-OAuth |
| updates | version comparison |
## HOW TO ADD CHECK
1. Create `src/cli/doctor/checks/my-check.ts`
2. Export `getXXXCheckDefinition()` factory returning `CheckDefinition`
2. Export `getXXXCheckDefinition()` returning `CheckDefinition`
3. Add to `getAllCheckDefinitions()` in `checks/index.ts`
## TUI FRAMEWORK
- **@clack/prompts**: `select()`, `spinner()`, `intro()`, `outro()`
- **picocolors**: Terminal colors for status and headers
- **Symbols**: ✓ (pass), ✗ (fail), ⚠ (warn), (info)
## ANTI-PATTERNS
- **Blocking in non-TTY**: Always check `process.stdout.isTTY`
- **Direct JSON.parse**: Use `parseJsonc()` from shared utils
- **Silent failures**: Return `warn` or `fail` in doctor instead of throwing
- **Hardcoded paths**: Use `getOpenCodeConfigPaths()` from `config-manager.ts`
- **Blocking in non-TTY**: Check `process.stdout.isTTY`
- **Direct JSON.parse**: Use `parseJsonc()` from shared
- **Silent failures**: Return `warn` or `fail` in doctor, don't throw
- **Hardcoded paths**: Use `getOpenCodeConfigPaths()` from config-manager

164
src/cli/cli-installer.ts Normal file
View File

@@ -0,0 +1,164 @@
import color from "picocolors"
import type { InstallArgs } from "./types"
import {
addAuthPlugins,
addPluginToOpenCodeConfig,
addProviderConfig,
detectCurrentConfig,
getOpenCodeVersion,
isOpenCodeInstalled,
writeOmoConfig,
} from "./config-manager"
import {
SYMBOLS,
argsToConfig,
detectedToInitialValues,
formatConfigSummary,
printBox,
printError,
printHeader,
printInfo,
printStep,
printSuccess,
printWarning,
validateNonTuiArgs,
} from "./install-validators"
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
const validation = validateNonTuiArgs(args)
if (!validation.valid) {
printHeader(false)
printError("Validation failed:")
for (const err of validation.errors) {
console.log(` ${SYMBOLS.bullet} ${err}`)
}
console.log()
printInfo(
"Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --gemini=<no|yes> --copilot=<no|yes>",
)
console.log()
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
printHeader(isUpdate)
const totalSteps = 6
let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
const openCodeVersion = await getOpenCodeVersion()
if (!installed) {
printWarning(
"OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.",
)
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
}
if (isUpdate) {
const initial = detectedToInitialValues(detected)
printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
const pluginResult = await addPluginToOpenCodeConfig(version)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
if (config.hasGemini) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
printError(`Failed: ${authResult.error}`)
return 1
}
printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`)
printStep(step++, totalSteps, "Adding provider configurations...")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
printError(`Failed: ${providerResult.error}`)
return 1
}
printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`)
} else {
step += 2
}
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
console.log(color.dim(" • Reduced orchestration quality"))
console.log(color.dim(" • Weaker tool selection and delegation"))
console.log(color.dim(" • Less reliable task completion"))
console.log()
console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience."))
console.log()
}
if (
!config.hasClaude &&
!config.hasOpenAI &&
!config.hasGemini &&
!config.hasCopilot &&
!config.hasOpencodeZen
) {
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
console.log()
printBox(
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"The Magic Word",
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`,
)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
printBox(
`Run ${color.cyan("opencode auth login")} and select your provider:\n` +
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
(config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") +
(config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""),
"Authenticate Your Providers",
)
}
return 0
}

191
src/cli/cli-program.ts Normal file
View File

@@ -0,0 +1,191 @@
import { Command } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import { createMcpOAuthCommand } from "./mcp-oauth"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
import type { DoctorOptions } from "./doctor"
import packageJson from "../../package.json" with { type: "json" }
const VERSION = packageJson.version
const program = new Command()
program
.name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
.version(VERSION, "-v, --version", "Show version number")
.enablePositionalOptions()
program
.command("install")
.description("Install and configure oh-my-opencode with interactive setup")
.option("--no-tui", "Run in non-interactive mode (requires all options)")
.option("--claude <value>", "Claude subscription: no, yes, max20")
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
OpenAI Native openai/ models (GPT-5.2 for Oracle)
Gemini Native google/ models (Gemini 3 Pro, Flash)
Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
`)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
process.exit(exitCode)
})
program
.command("run <message>")
.allowUnknownOption()
.passThroughOptions()
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
.option("--attach <url>", "Attach to existing opencode server URL")
.option("--on-complete <command>", "Shell command to run after completion")
.option("--json", "Output structured JSON result to stdout")
.option("--session-id <id>", "Resume existing session instead of creating new one")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode run "Fix the bug in index.ts"
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
$ bunx oh-my-opencode run --port 4321 "Fix the bug"
$ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug"
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
Agent resolution order:
1) --agent flag
2) OPENCODE_DEFAULT_AGENT
3) oh-my-opencode.json "default_run_agent"
4) Sisyphus (fallback)
Available core agents:
Sisyphus, Hephaestus, Prometheus, Atlas
Unlike 'opencode run', this command waits until:
- All todos are completed or cancelled
- All child sessions (background tasks) are idle
`)
.action(async (message: string, options) => {
if (options.port && options.attach) {
console.error("Error: --port and --attach are mutually exclusive")
process.exit(1)
}
const runOptions: RunOptions = {
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
port: options.port,
attach: options.attach,
onComplete: options.onComplete,
json: options.json ?? false,
sessionId: options.sessionId,
}
const exitCode = await run(runOptions)
process.exit(exitCode)
})
program
.command("get-local-version")
.description("Show current installed version and check for updates")
.option("-d, --directory <path>", "Working directory to check config from")
.option("--json", "Output in JSON format for scripting")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode get-local-version
$ bunx oh-my-opencode get-local-version --json
$ bunx oh-my-opencode get-local-version --directory /path/to/project
This command shows:
- Current installed version
- Latest available version on npm
- Whether you're up to date
- Special modes (local dev, pinned version)
`)
.action(async (options) => {
const versionOptions: GetLocalVersionOptions = {
directory: options.directory,
json: options.json ?? false,
}
const exitCode = await getLocalVersion(versionOptions)
process.exit(exitCode)
})
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode doctor
$ bunx oh-my-opencode doctor --verbose
$ bunx oh-my-opencode doctor --json
$ bunx oh-my-opencode doctor --category authentication
Categories:
installation Check OpenCode and plugin installation
configuration Validate configuration files
authentication Check auth provider status
dependencies Check external dependencies
tools Check LSP and MCP servers
updates Check for version updates
`)
.action(async (options) => {
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")
.action(() => {
console.log(`oh-my-opencode v${VERSION}`)
})
program.addCommand(createMcpOAuthCommand())
export function runCli(): void {
program.parse()
}

View File

@@ -1,667 +1,23 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
import {
parseJsonc,
getOpenCodeConfigPaths,
type OpenCodeBinaryType,
type OpenCodeConfigPaths,
} from "../shared"
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
import { generateModelConfig } from "./model-fallback"
export type { ConfigContext } from "./config-manager/config-context"
export {
initConfigContext,
getConfigContext,
resetConfigContext,
} from "./config-manager/config-context"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
export { fetchNpmDistTags } from "./config-manager/npm-dist-tags"
export { getPluginNameWithVersion } from "./config-manager/plugin-name-with-version"
export { addPluginToOpenCodeConfig } from "./config-manager/add-plugin-to-opencode-config"
interface ConfigContext {
binary: OpenCodeBinaryType
version: string | null
paths: OpenCodeConfigPaths
}
export { generateOmoConfig } from "./config-manager/generate-omo-config"
export { writeOmoConfig } from "./config-manager/write-omo-config"
let configContext: ConfigContext | null = null
export { isOpenCodeInstalled, getOpenCodeVersion } from "./config-manager/opencode-binary"
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
const paths = getOpenCodeConfigPaths({ binary, version })
configContext = { binary, version, paths }
}
export { fetchLatestVersion, addAuthPlugins } from "./config-manager/auth-plugins"
export { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager/antigravity-provider-configuration"
export { addProviderConfig } from "./config-manager/add-provider-config"
export { detectCurrentConfig } from "./config-manager/detect-current-config"
export function getConfigContext(): ConfigContext {
if (!configContext) {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
configContext = { binary: "opencode", version: null, paths }
}
return configContext
}
export function resetConfigContext(): void {
configContext = null
}
function getConfigDir(): string {
return getConfigContext().paths.configDir
}
function getConfigJson(): string {
return getConfigContext().paths.configJson
}
function getConfigJsonc(): string {
return getConfigContext().paths.configJsonc
}
function getPackageJson(): string {
return getConfigContext().paths.packageJson
}
function getOmoConfig(): string {
return getConfigContext().paths.omoConfig
}
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
interface NodeError extends Error {
code?: string
}
function isPermissionError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM"
}
function isFileNotFoundError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "ENOENT"
}
function formatErrorWithSuggestion(err: unknown, context: string): string {
if (isPermissionError(err)) {
return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.`
}
if (isFileNotFoundError(err)) {
return `File not found while trying to ${context}. The file may have been deleted or moved.`
}
if (err instanceof SyntaxError) {
return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.`
}
const message = err instanceof Error ? err.message : String(err)
if (message.includes("ENOSPC")) {
return `Disk full: Cannot ${context}. Free up disk space and try again.`
}
if (message.includes("EROFS")) {
return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.`
}
return `Failed to ${context}: ${message}`
}
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
if (!res.ok) return null
const data = await res.json() as { version: string }
return data.version
} catch {
return null
}
}
interface NpmDistTags {
latest?: string
beta?: string
next?: string
[tag: string]: string | undefined
}
const NPM_FETCH_TIMEOUT_MS = 5000
export async function fetchNpmDistTags(packageName: string): Promise<NpmDistTags | null> {
try {
const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, {
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
})
if (!res.ok) return null
const data = await res.json() as NpmDistTags
return data
} catch {
return null
}
}
const PACKAGE_NAME = "oh-my-opencode"
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
if (distTags) {
const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)])
for (const tag of allTags) {
if (distTags[tag] === currentVersion) {
return `${PACKAGE_NAME}@${tag}`
}
}
}
return `${PACKAGE_NAME}@${currentVersion}`
}
type ConfigFormat = "json" | "jsonc" | "none"
interface OpenCodeConfig {
plugin?: string[]
[key: string]: unknown
}
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
const configJsonc = getConfigJsonc()
const configJson = getConfigJson()
if (existsSync(configJsonc)) {
return { format: "jsonc", path: configJsonc }
}
if (existsSync(configJson)) {
return { format: "json", path: configJson }
}
return { format: "none", path: configJson }
}
interface ParseConfigResult {
config: OpenCodeConfig | null
error?: string
}
function isEmptyOrWhitespace(content: string): boolean {
return content.trim().length === 0
}
function parseConfig(path: string, _isJsonc: boolean): OpenCodeConfig | null {
const result = parseConfigWithError(path)
return result.config
}
function parseConfigWithError(path: string): ParseConfigResult {
try {
const stat = statSync(path)
if (stat.size === 0) {
return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` }
}
const content = readFileSync(path, "utf-8")
if (isEmptyOrWhitespace(content)) {
return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` }
}
const config = parseJsonc<OpenCodeConfig>(content)
if (config === null || config === undefined) {
return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` }
}
if (typeof config !== "object" || Array.isArray(config)) {
return { config: null, error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}` }
}
return { config }
} catch (err) {
return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) }
}
}
function ensureConfigDir(): void {
const configDir = getConfigDir()
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
}
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
const pluginEntry = await getPluginNameWithVersion(currentVersion)
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginEntry] }
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
return { success: false, configPath: path, error: parseResult.error ?? "Failed to parse config file" }
}
const config = parseResult.config
const plugins = config.plugin ?? []
const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`))
if (existingIndex !== -1) {
if (plugins[existingIndex] === pluginEntry) {
return { success: true, configPath: path }
}
plugins[existingIndex] = pluginEntry
} else {
plugins.push(pluginEntry)
}
config.plugin = plugins
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
writeFileSync(path, newContent)
}
} else {
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "update opencode config") }
}
}
function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
const result = { ...target }
for (const key of Object.keys(source) as Array<keyof T>) {
const sourceValue = source[key]
const targetValue = result[key]
if (
sourceValue !== null &&
typeof sourceValue === "object" &&
!Array.isArray(sourceValue) &&
targetValue !== null &&
typeof targetValue === "object" &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
) as T[keyof T]
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T]
}
}
return result
}
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
return generateModelConfig(installConfig)
}
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const omoConfigPath = getOmoConfig()
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(omoConfigPath)) {
try {
const stat = statSync(omoConfigPath)
const content = readFileSync(omoConfigPath, "utf-8")
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const existing = parseJsonc<Record<string, unknown>>(content)
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const merged = deepMerge(existing, newConfig)
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
throw parseErr
}
} else {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: omoConfigPath }
} catch (err) {
return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
}
}
interface OpenCodeBinaryResult {
binary: OpenCodeBinaryType
version: string
}
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
const version = output.trim()
initConfigContext(binary, version)
return { binary, version }
}
} catch {
continue
}
}
return null
}
export async function isOpenCodeInstalled(): Promise<boolean> {
const result = await findOpenCodeBinaryWithVersion()
return result !== null
}
export async function getOpenCodeVersion(): Promise<string | null> {
const result = await findOpenCodeBinaryWithVersion()
return result?.version ?? null
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const plugins: string[] = existingConfig?.plugin ?? []
if (config.hasGemini) {
const version = await fetchLatestVersion("opencode-antigravity-auth")
const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth"
if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
plugins.push(pluginEntry)
}
}
const newConfig = { ...(existingConfig ?? {}), plugin: plugins }
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add auth plugins to config") }
}
}
export interface BunInstallResult {
success: boolean
timedOut?: boolean
error?: string
}
export async function runBunInstall(): Promise<boolean> {
const result = await runBunInstallWithDetails()
return result.success
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
cwd: getConfigDir(),
stdout: "pipe",
stderr: "pipe",
})
const timeoutPromise = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
)
const exitPromise = proc.exited.then(() => "completed" as const)
const result = await Promise.race([exitPromise, timeoutPromise])
if (result === "timeout") {
try {
proc.kill()
} catch {
/* intentionally empty - process may have already exited */
}
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`,
}
}
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
return {
success: false,
error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`,
}
}
return { success: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
success: false,
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
}
}
}
/**
* Antigravity Provider Configuration
*
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
* - `antigravity-gemini-3-pro` with variants: low, high
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
*
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
* but variants are the recommended approach.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
*/
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
models: {
"antigravity-gemini-3-pro": {
name: "Gemini 3 Pro (Antigravity)",
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingLevel: "low" },
high: { thinkingLevel: "high" },
},
},
"antigravity-gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
minimal: { thinkingLevel: "minimal" },
low: { thinkingLevel: "low" },
medium: { thinkingLevel: "medium" },
high: { thinkingLevel: "high" },
},
},
"antigravity-claude-sonnet-4-5": {
name: "Claude Sonnet 4.5 (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"antigravity-claude-sonnet-4-5-thinking": {
name: "Claude Sonnet 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
"antigravity-claude-opus-4-5-thinking": {
name: "Claude Opus 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
},
},
}
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const newConfig = { ...(existingConfig ?? {}) }
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
if (config.hasGemini) {
providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google
}
if (Object.keys(providers).length > 0) {
newConfig.provider = providers
}
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add provider config") }
}
}
function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean; hasKimiForCoding: boolean } {
const omoConfigPath = getOmoConfig()
if (!existsSync(omoConfigPath)) {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
try {
const content = readFileSync(omoConfigPath, "utf-8")
const omoConfig = parseJsonc<Record<string, unknown>>(content)
if (!omoConfig || typeof omoConfig !== "object") {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
const configStr = JSON.stringify(omoConfig)
const hasOpenAI = configStr.includes('"openai/')
const hasOpencodeZen = configStr.includes('"opencode/')
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding }
} catch {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
}
export function detectCurrentConfig(): DetectedConfig {
const result: DetectedConfig = {
isInstalled: false,
hasClaude: true,
isMax20: true,
hasOpenAI: true,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: true,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
const { format, path } = detectConfigFormat()
if (format === "none") {
return result
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
return result
}
const openCodeConfig = parseResult.config
const plugins = openCodeConfig.plugin ?? []
result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode"))
if (!result.isInstalled) {
return result
}
// Gemini auth plugin detection still works via plugin presence
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig()
result.hasOpenAI = hasOpenAI
result.hasOpencodeZen = hasOpencodeZen
result.hasZaiCodingPlan = hasZaiCodingPlan
result.hasKimiForCoding = hasKimiForCoding
return result
}
export type { BunInstallResult } from "./config-manager/bun-install"
export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install"

View File

@@ -0,0 +1,82 @@
import { readFileSync, writeFileSync } from "node:fs"
import type { ConfigMergeResult } from "../types"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
import { getPluginNameWithVersion } from "./plugin-name-with-version"
const PACKAGE_NAME = "oh-my-opencode"
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const { format, path } = detectConfigFormat()
const pluginEntry = await getPluginNameWithVersion(currentVersion)
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginEntry] }
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
const parseResult = parseOpenCodeConfigFileWithError(path)
if (!parseResult.config) {
return {
success: false,
configPath: path,
error: parseResult.error ?? "Failed to parse config file",
}
}
const config = parseResult.config
const plugins = config.plugin ?? []
const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`))
if (existingIndex !== -1) {
if (plugins[existingIndex] === pluginEntry) {
return { success: true, configPath: path }
}
plugins[existingIndex] = pluginEntry
} else {
plugins.push(pluginEntry)
}
config.plugin = plugins
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/(\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
writeFileSync(path, newContent)
}
} else {
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return {
success: false,
configPath: path,
error: formatErrorWithSuggestion(err, "update opencode config"),
}
}
}

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from "bun:test"
import { modifyProviderInJsonc } from "./jsonc-provider-editor"
import { parseJsonc } from "../../shared/jsonc-parser"
describe("modifyProviderInJsonc", () => {
describe("Test 1: Basic JSONC with existing provider", () => {
it("replaces provider value, preserves comments and other keys", () => {
// given
const content = `{
// my config
"provider": { "openai": {} },
"plugin": ["foo"]
}`
const newProviderValue = { google: { name: "Google" } }
// when
const result = modifyProviderInJsonc(content, newProviderValue)
// then
expect(result).toContain('"google"')
expect(result).toContain('"plugin": ["foo"]')
expect(result).toContain('// my config')
// Post-write validation
const parsed = parseJsonc<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('plugin')
expect(parsed).toHaveProperty('provider')
})
})
describe("Test 2: Comment containing '}' inside provider block", () => {
it("must NOT corrupt file", () => {
// given
const content = `{
"provider": {
// } this brace should be ignored
"openai": {}
},
"other": 1
}`
const newProviderValue = { google: { name: "Google" } }
// when
const result = modifyProviderInJsonc(content, newProviderValue)
// then
expect(result).toContain('"other"')
// Post-write validation
const parsed = parseJsonc<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('other')
expect(parsed.other).toBe(1)
})
})
describe("Test 3: Comment containing '\"provider\"' before real key", () => {
it("must NOT match wrong location", () => {
// given
const content = `{
// "provider": { "example": true }
"provider": { "openai": {} },
"other": 1
}`
const newProviderValue = { google: { name: "Google" } }
// when
const result = modifyProviderInJsonc(content, newProviderValue)
// then
expect(result).toContain('"other"')
// Post-write validation
const parsed = parseJsonc<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('other')
expect(parsed.other).toBe(1)
expect(parsed.provider).toHaveProperty('google')
})
})
describe("Test 4: Comment containing '{' inside provider", () => {
it("must NOT mess up depth", () => {
// given
const content = `{
"provider": {
// { unmatched brace in comment
"openai": {}
},
"other": 1
}`
const newProviderValue = { google: { name: "Google" } }
// when
const result = modifyProviderInJsonc(content, newProviderValue)
// then
expect(result).toContain('"other"')
// Post-write validation
const parsed = parseJsonc<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('other')
expect(parsed.other).toBe(1)
})
})
describe("Test 5: No existing provider key", () => {
it("inserts provider without corrupting", () => {
// given
const content = `{
// config comment
"plugin": ["foo"]
}`
const newProviderValue = { google: { name: "Google" } }
// when
const result = modifyProviderInJsonc(content, newProviderValue)
// then
expect(result).toContain('"provider"')
expect(result).toContain('"plugin"')
expect(result).toContain('foo')
expect(result).toContain('// config comment')
// Post-write validation
const parsed = parseJsonc<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('provider')
expect(parsed).toHaveProperty('plugin')
expect(parsed.plugin).toEqual(['foo'])
})
})
describe("Test 6: String value exactly 'provider' before real key", () => {
it("must NOT match wrong location", () => {
// given
const content = `{
"note": "provider",
"provider": { "openai": {} },
"other": 1
}`
const newProviderValue = { google: { name: "Google" } }
// when
const result = modifyProviderInJsonc(content, newProviderValue)
// then
expect(result).toContain('"other"')
expect(result).toContain('"note": "provider"')
// Post-write validation
const parsed = parseJsonc<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('other')
expect(parsed.other).toBe(1)
expect(parsed.note).toBe('provider')
})
})
describe("Test 7: Post-write validation", () => {
it("result file must be valid JSONC for all cases", () => {
// Test Case 1
const content1 = `{
"provider": { "openai": {} },
"plugin": ["foo"]
}`
const result1 = modifyProviderInJsonc(content1, { google: {} })
expect(() => parseJsonc(result1)).not.toThrow()
// Test Case 2
const content2 = `{
"provider": {
// } comment
"openai": {}
}
}`
const result2 = modifyProviderInJsonc(content2, { google: {} })
expect(() => parseJsonc(result2)).not.toThrow()
// Test Case 3
const content3 = `{
"plugin": ["foo"]
}`
const result3 = modifyProviderInJsonc(content3, { google: {} })
expect(() => parseJsonc(result3)).not.toThrow()
})
})
describe("Test 8: Trailing commas preserved", () => {
it("file is valid JSONC with trailing commas", () => {
// given
const content = `{
"provider": { "openai": {}, },
"plugin": ["foo",],
}`
const newProviderValue = { google: { name: "Google" } }
// when
const result = modifyProviderInJsonc(content, newProviderValue)
// then
expect(() => parseJsonc(result)).not.toThrow()
const parsed = parseJsonc<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('plugin')
expect(parsed.plugin).toEqual(['foo'])
})
})
})

View File

@@ -0,0 +1,82 @@
import { readFileSync, writeFileSync, copyFileSync } from "node:fs"
import type { ConfigMergeResult, InstallConfig } from "../types"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
import { ANTIGRAVITY_PROVIDER_CONFIG } from "./antigravity-provider-configuration"
import { modifyProviderInJsonc } from "./jsonc-provider-editor"
import { parseJsonc } from "../../shared/jsonc-parser"
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseOpenCodeConfigFileWithError(path)
if (parseResult.error && !parseResult.config) {
return {
success: false,
configPath: path,
error: `Failed to parse config file: ${parseResult.error}`,
}
}
existingConfig = parseResult.config
}
const newConfig = { ...(existingConfig ?? {}) }
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
if (config.hasGemini) {
providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google
}
if (Object.keys(providers).length > 0) {
newConfig.provider = providers
}
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
// Backup original file
copyFileSync(path, `${path}.bak`)
const providerValue = (newConfig.provider ?? {}) as Record<string, unknown>
const newContent = modifyProviderInJsonc(content, providerValue)
// Post-write validation
try {
parseJsonc(newContent)
} catch (error) {
return {
success: false,
configPath: path,
error: `Generated JSONC is invalid: ${error instanceof Error ? error.message : String(error)}`,
}
}
writeFileSync(path, newContent)
} else {
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return {
success: false,
configPath: path,
error: formatErrorWithSuggestion(err, "add provider config"),
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* Antigravity Provider Configuration
*
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
* - `antigravity-gemini-3-pro` with variants: low, high
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
*
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
* but variants are the recommended approach.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
*/
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
models: {
"antigravity-gemini-3-pro": {
name: "Gemini 3 Pro (Antigravity)",
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingLevel: "low" },
high: { thinkingLevel: "high" },
},
},
"antigravity-gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
minimal: { thinkingLevel: "minimal" },
low: { thinkingLevel: "low" },
medium: { thinkingLevel: "medium" },
high: { thinkingLevel: "high" },
},
},
"antigravity-claude-sonnet-4-5": {
name: "Claude Sonnet 4.5 (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"antigravity-claude-sonnet-4-5-thinking": {
name: "Claude Sonnet 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
"antigravity-claude-opus-4-5-thinking": {
name: "Claude Opus 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
},
},
}

View File

@@ -0,0 +1,224 @@
import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from "node:fs"
import { parseJsonc } from "../../shared/jsonc-parser"
import type { InstallConfig } from "../types"
import { resetConfigContext } from "./config-context"
let testConfigPath: string
let testConfigDir: string
let testCounter = 0
let fetchVersionSpy: unknown
beforeEach(async () => {
testCounter++
testConfigDir = join(tmpdir(), `test-opencode-${Date.now()}-${testCounter}`)
testConfigPath = join(testConfigDir, "opencode.jsonc")
mkdirSync(testConfigDir, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = testConfigDir
resetConfigContext()
const module = await import("./auth-plugins")
fetchVersionSpy = spyOn(module, "fetchLatestVersion").mockResolvedValue("1.2.3")
})
afterEach(() => {
try {
rmSync(testConfigDir, { recursive: true, force: true })
} catch {}
})
const testConfig: InstallConfig = {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: true,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
describe("addAuthPlugins", () => {
describe("Test 1: JSONC with commented plugin line", () => {
it("preserves comment, updates actual plugin array", async () => {
const content = `{
// "plugin": ["old-plugin"]
"plugin": ["existing-plugin"],
"provider": {}
}`
writeFileSync(testConfigPath, content, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
const newContent = readFileSync(result.configPath, "utf-8")
expect(newContent).toContain('// "plugin": ["old-plugin"]')
expect(newContent).toContain('existing-plugin')
expect(newContent).toContain('opencode-antigravity-auth')
const parsed = parseJsonc<Record<string, unknown>>(newContent)
const plugins = parsed.plugin as string[]
expect(plugins).toContain('existing-plugin')
expect(plugins.some((p) => p.startsWith('opencode-antigravity-auth'))).toBe(true)
})
})
describe("Test 2: Plugin array already contains antigravity", () => {
it("does not add duplicate", async () => {
const content = `{
"plugin": ["existing-plugin", "opencode-antigravity-auth"],
"provider": {}
}`
writeFileSync(testConfigPath, content, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
const newContent = readFileSync(testConfigPath, "utf-8")
const parsed = parseJsonc<Record<string, unknown>>(newContent)
const plugins = parsed.plugin as string[]
const antigravityCount = plugins.filter((p) => p.startsWith('opencode-antigravity-auth')).length
expect(antigravityCount).toBe(1)
})
})
describe("Test 3: Backup created before write", () => {
it("creates .bak file", async () => {
const originalContent = `{
"plugin": ["existing-plugin"],
"provider": {}
}`
writeFileSync(testConfigPath, originalContent, "utf-8")
readFileSync(testConfigPath, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
expect(existsSync(`${result.configPath}.bak`)).toBe(true)
const backupContent = readFileSync(`${result.configPath}.bak`, "utf-8")
expect(backupContent).toBe(originalContent)
})
})
describe("Test 4: Comment with } character", () => {
it("preserves comments with special characters", async () => {
const content = `{
// This comment has } special characters
"plugin": ["existing-plugin"],
"provider": {}
}`
writeFileSync(testConfigPath, content, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
const newContent = readFileSync(testConfigPath, "utf-8")
expect(newContent).toContain('// This comment has } special characters')
expect(() => parseJsonc(newContent)).not.toThrow()
})
})
describe("Test 5: Comment containing 'plugin' string", () => {
it("must NOT match comment location", async () => {
const content = `{
// "plugin": ["fake"]
"plugin": ["existing-plugin"],
"provider": {}
}`
writeFileSync(testConfigPath, content, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
const newContent = readFileSync(testConfigPath, "utf-8")
expect(newContent).toContain('// "plugin": ["fake"]')
const parsed = parseJsonc<Record<string, unknown>>(newContent)
const plugins = parsed.plugin as string[]
expect(plugins).toContain('existing-plugin')
expect(plugins).not.toContain('fake')
})
})
describe("Test 6: No existing plugin array", () => {
it("creates plugin array when none exists", async () => {
const content = `{
"provider": {}
}`
writeFileSync(testConfigPath, content, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
const newContent = readFileSync(result.configPath, "utf-8")
const parsed = parseJsonc<Record<string, unknown>>(newContent)
expect(parsed).toHaveProperty('plugin')
const plugins = parsed.plugin as string[]
expect(plugins.some((p) => p.startsWith('opencode-antigravity-auth'))).toBe(true)
})
})
describe("Test 7: Post-write validation ensures valid JSONC", () => {
it("result file must be valid JSONC", async () => {
const content = `{
"plugin": ["existing-plugin"],
"provider": {}
}`
writeFileSync(testConfigPath, content, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
const newContent = readFileSync(testConfigPath, "utf-8")
expect(() => parseJsonc(newContent)).not.toThrow()
const parsed = parseJsonc<Record<string, unknown>>(newContent)
expect(parsed).toHaveProperty('plugin')
expect(parsed).toHaveProperty('provider')
})
})
describe("Test 8: Multiple plugins in array", () => {
it("appends to existing plugins", async () => {
const content = `{
"plugin": ["plugin-1", "plugin-2", "plugin-3"],
"provider": {}
}`
writeFileSync(testConfigPath, content, "utf-8")
const { addAuthPlugins } = await import("./auth-plugins")
const result = await addAuthPlugins(testConfig)
expect(result.success).toBe(true)
const newContent = readFileSync(result.configPath, "utf-8")
const parsed = parseJsonc<Record<string, unknown>>(newContent)
const plugins = parsed.plugin as string[]
expect(plugins).toContain('plugin-1')
expect(plugins).toContain('plugin-2')
expect(plugins).toContain('plugin-3')
expect(plugins.some((p) => p.startsWith('opencode-antigravity-auth'))).toBe(true)
})
})
})

View File

@@ -0,0 +1,145 @@
import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs"
import { modify, applyEdits } from "jsonc-parser"
import type { ConfigMergeResult, InstallConfig } from "../types"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
import { parseJsonc } from "../../shared/jsonc-parser"
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`)
if (!res.ok) return null
const data = (await res.json()) as { version: string }
return data.version
} catch {
return null
}
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const { format, path } = detectConfigFormat()
const backupPath = `${path}.bak`
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseOpenCodeConfigFileWithError(path)
if (parseResult.error && !parseResult.config) {
return {
success: false,
configPath: path,
error: `Failed to parse config file: ${parseResult.error}`,
}
}
existingConfig = parseResult.config
}
const rawPlugins = existingConfig?.plugin
const plugins: string[] = Array.isArray(rawPlugins) ? rawPlugins : []
if (config.hasGemini) {
const version = await fetchLatestVersion("opencode-antigravity-auth")
const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth"
if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
plugins.push(pluginEntry)
}
}
const newConfig = { ...(existingConfig ?? {}), plugin: plugins }
if (format !== "none" && existsSync(path)) {
copyFileSync(path, backupPath)
}
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const newContent = applyEdits(
content,
modify(content, ["plugin"], plugins, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
)
try {
parseJsonc(newContent)
} catch (error) {
if (existsSync(backupPath)) {
copyFileSync(backupPath, path)
}
throw new Error(`Generated JSONC is invalid: ${error instanceof Error ? error.message : String(error)}`)
}
try {
writeFileSync(path, newContent)
} catch (error) {
const hasBackup = existsSync(backupPath)
try {
if (hasBackup) {
copyFileSync(backupPath, path)
}
} catch (restoreError) {
return {
success: false,
configPath: path,
error: `Failed to write config file, and restore from backup failed: ${String(error)}; restore error: ${String(restoreError)}`,
}
}
return {
success: false,
configPath: path,
error: hasBackup
? `Failed to write config file. Restored from backup: ${String(error)}`
: `Failed to write config file. No backup was available: ${String(error)}`,
}
}
} else {
const nextContent = JSON.stringify(newConfig, null, 2) + "\n"
try {
writeFileSync(path, nextContent)
} catch (error) {
const hasBackup = existsSync(backupPath)
try {
if (hasBackup) {
copyFileSync(backupPath, path)
}
} catch (restoreError) {
return {
success: false,
configPath: path,
error: `Failed to write config file, and restore from backup failed: ${String(error)}; restore error: ${String(restoreError)}`,
}
}
return {
success: false,
configPath: path,
error: hasBackup
? `Failed to write config file. Restored from backup: ${String(error)}`
: `Failed to write config file. No backup was available: ${String(error)}`,
}
}
}
return { success: true, configPath: path }
} catch (err) {
return {
success: false,
configPath: path,
error: formatErrorWithSuggestion(err, "add auth plugins to config"),
}
}
}

View File

@@ -0,0 +1,61 @@
import { getConfigDir } from "./config-context"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
export interface BunInstallResult {
success: boolean
timedOut?: boolean
error?: string
}
export async function runBunInstall(): Promise<boolean> {
const result = await runBunInstallWithDetails()
return result.success
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
cwd: getConfigDir(),
stdout: "inherit",
stderr: "inherit",
})
let timeoutId: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<"timeout">((resolve) => {
timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
})
const exitPromise = proc.exited.then(() => "completed" as const)
const result = await Promise.race([exitPromise, timeoutPromise])
clearTimeout(timeoutId!)
if (result === "timeout") {
try {
proc.kill()
} catch {
/* intentionally empty - process may have already exited */
}
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`,
}
}
if (proc.exitCode !== 0) {
return {
success: false,
error: `bun install failed with exit code ${proc.exitCode}`,
}
}
return { success: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
success: false,
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
}
}
}

View File

@@ -0,0 +1,49 @@
import { getOpenCodeConfigPaths } from "../../shared"
import type {
OpenCodeBinaryType,
OpenCodeConfigPaths,
} from "../../shared/opencode-config-dir-types"
export interface ConfigContext {
binary: OpenCodeBinaryType
version: string | null
paths: OpenCodeConfigPaths
}
let configContext: ConfigContext | null = null
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
const paths = getOpenCodeConfigPaths({ binary, version })
configContext = { binary, version, paths }
}
export function getConfigContext(): ConfigContext {
if (!configContext) {
if (process.env.NODE_ENV !== "production") {
console.warn("[config-context] getConfigContext() called before initConfigContext(); defaulting to CLI paths.")
}
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
configContext = { binary: "opencode", version: null, paths }
}
return configContext
}
export function resetConfigContext(): void {
configContext = null
}
export function getConfigDir(): string {
return getConfigContext().paths.configDir
}
export function getConfigJson(): string {
return getConfigContext().paths.configJson
}
export function getConfigJsonc(): string {
return getConfigContext().paths.configJsonc
}
export function getOmoConfigPath(): string {
return getConfigContext().paths.omoConfig
}

View File

@@ -0,0 +1,30 @@
export function deepMergeRecord<TTarget extends Record<string, unknown>>(
target: TTarget,
source: Partial<TTarget>
): TTarget {
const result: TTarget = { ...target }
for (const key of Object.keys(source) as Array<keyof TTarget>) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue
const sourceValue = source[key]
const targetValue = result[key]
if (
sourceValue !== null &&
typeof sourceValue === "object" &&
!Array.isArray(sourceValue) &&
targetValue !== null &&
typeof targetValue === "object" &&
!Array.isArray(targetValue)
) {
result[key] = deepMergeRecord(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
) as TTarget[keyof TTarget]
} else if (sourceValue !== undefined) {
result[key] = sourceValue as TTarget[keyof TTarget]
}
}
return result
}

View File

@@ -0,0 +1,78 @@
import { existsSync, readFileSync } from "node:fs"
import { parseJsonc } from "../../shared"
import type { DetectedConfig } from "../types"
import { getOmoConfigPath } from "./config-context"
import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
function detectProvidersFromOmoConfig(): {
hasOpenAI: boolean
hasOpencodeZen: boolean
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean
} {
const omoConfigPath = getOmoConfigPath()
if (!existsSync(omoConfigPath)) {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
try {
const content = readFileSync(omoConfigPath, "utf-8")
const omoConfig = parseJsonc<Record<string, unknown>>(content)
if (!omoConfig || typeof omoConfig !== "object") {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
const configStr = JSON.stringify(omoConfig)
const hasOpenAI = configStr.includes('"openai/')
const hasOpencodeZen = configStr.includes('"opencode/')
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding }
} catch {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
}
export function detectCurrentConfig(): DetectedConfig {
const result: DetectedConfig = {
isInstalled: false,
hasClaude: true,
isMax20: true,
hasOpenAI: true,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: true,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
const { format, path } = detectConfigFormat()
if (format === "none") {
return result
}
const parseResult = parseOpenCodeConfigFileWithError(path)
if (!parseResult.config) {
return result
}
const openCodeConfig = parseResult.config
const plugins = openCodeConfig.plugin ?? []
result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode"))
if (!result.isInstalled) {
return result
}
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig()
result.hasOpenAI = hasOpenAI
result.hasOpencodeZen = hasOpencodeZen
result.hasZaiCodingPlan = hasZaiCodingPlan
result.hasKimiForCoding = hasKimiForCoding
return result
}

View File

@@ -0,0 +1,9 @@
import { existsSync, mkdirSync } from "node:fs"
import { getConfigDir } from "./config-context"
export function ensureConfigDirectoryExists(): void {
const configDir = getConfigDir()
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
}

View File

@@ -0,0 +1,39 @@
interface NodeError extends Error {
code?: string
}
function isPermissionError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM"
}
function isFileNotFoundError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "ENOENT"
}
export function formatErrorWithSuggestion(err: unknown, context: string): string {
if (isPermissionError(err)) {
return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.`
}
if (isFileNotFoundError(err)) {
return `File not found while trying to ${context}. The file may have been deleted or moved.`
}
if (err instanceof SyntaxError) {
return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.`
}
const message = err instanceof Error ? err.message : String(err)
if (message.includes("ENOSPC")) {
return `Disk full: Cannot ${context}. Free up disk space and try again.`
}
if (message.includes("EROFS")) {
return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.`
}
return `Failed to ${context}: ${message}`
}

View File

@@ -0,0 +1,6 @@
import type { InstallConfig } from "../types"
import { generateModelConfig } from "../model-fallback"
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
return generateModelConfig(installConfig)
}

View File

@@ -0,0 +1,11 @@
import { modify, applyEdits } from "jsonc-parser"
export function modifyProviderInJsonc(
content: string,
newProviderValue: Record<string, unknown>
): string {
const edits = modify(content, ["provider"], newProviderValue, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
return applyEdits(content, edits)
}

View File

@@ -0,0 +1,21 @@
export interface NpmDistTags {
latest?: string
beta?: string
next?: string
[tag: string]: string | undefined
}
const NPM_FETCH_TIMEOUT_MS = 5000
export async function fetchNpmDistTags(packageName: string): Promise<NpmDistTags | null> {
try {
const res = await fetch(`https://registry.npmjs.org/-/package/${encodeURIComponent(packageName)}/dist-tags`, {
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
})
if (!res.ok) return null
const data = (await res.json()) as NpmDistTags
return data
} catch {
return null
}
}

View File

@@ -0,0 +1,40 @@
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
import { initConfigContext } from "./config-context"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
interface OpenCodeBinaryResult {
binary: OpenCodeBinaryType
version: string
}
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
const version = output.trim()
initConfigContext(binary, version)
return { binary, version }
}
} catch {
continue
}
}
return null
}
export async function isOpenCodeInstalled(): Promise<boolean> {
const result = await findOpenCodeBinaryWithVersion()
return result !== null
}
export async function getOpenCodeVersion(): Promise<string | null> {
const result = await findOpenCodeBinaryWithVersion()
return result?.version ?? null
}

View File

@@ -0,0 +1,17 @@
import { existsSync } from "node:fs"
import { getConfigJson, getConfigJsonc } from "./config-context"
export type ConfigFormat = "json" | "jsonc" | "none"
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
const configJsonc = getConfigJsonc()
const configJson = getConfigJson()
if (existsSync(configJsonc)) {
return { format: "jsonc", path: configJsonc }
}
if (existsSync(configJson)) {
return { format: "json", path: configJson }
}
return { format: "none", path: configJson }
}

View File

@@ -0,0 +1,48 @@
import { readFileSync, statSync } from "node:fs"
import { parseJsonc } from "../../shared"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
interface ParseConfigResult {
config: OpenCodeConfig | null
error?: string
}
export interface OpenCodeConfig {
plugin?: string[]
[key: string]: unknown
}
function isEmptyOrWhitespace(content: string): boolean {
return content.trim().length === 0
}
export function parseOpenCodeConfigFileWithError(path: string): ParseConfigResult {
try {
const stat = statSync(path)
if (stat.size === 0) {
return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` }
}
const content = readFileSync(path, "utf-8")
if (isEmptyOrWhitespace(content)) {
return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` }
}
const config = parseJsonc<OpenCodeConfig>(content)
if (config === null || config === undefined) {
return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` }
}
if (typeof config !== "object" || Array.isArray(config)) {
return {
config: null,
error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}`,
}
}
return { config }
} catch (err) {
return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) }
}
}

View File

@@ -0,0 +1,19 @@
import { fetchNpmDistTags } from "./npm-dist-tags"
const PACKAGE_NAME = "oh-my-opencode"
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
if (distTags) {
const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)])
for (const tag of allTags) {
if (distTags[tag] === currentVersion) {
return `${PACKAGE_NAME}@${tag}`
}
}
}
return `${PACKAGE_NAME}@${currentVersion}`
}

View File

@@ -0,0 +1,67 @@
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
import { parseJsonc } from "../../shared"
import type { ConfigMergeResult, InstallConfig } from "../types"
import { getConfigDir, getOmoConfigPath } from "./config-context"
import { deepMergeRecord } from "./deep-merge-record"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
import { generateOmoConfig } from "./generate-omo-config"
function isEmptyOrWhitespace(content: string): boolean {
return content.trim().length === 0
}
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
try {
ensureConfigDirectoryExists()
} catch (err) {
return {
success: false,
configPath: getConfigDir(),
error: formatErrorWithSuggestion(err, "create config directory"),
}
}
const omoConfigPath = getOmoConfigPath()
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(omoConfigPath)) {
try {
const stat = statSync(omoConfigPath)
const content = readFileSync(omoConfigPath, "utf-8")
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const existing = parseJsonc<Record<string, unknown>>(content)
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const merged = deepMergeRecord(existing, newConfig)
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
throw parseErr
}
} else {
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: omoConfigPath }
} catch (err) {
return {
success: false,
configPath: omoConfigPath,
error: formatErrorWithSuggestion(err, "write oh-my-opencode config"),
}
}
}

View File

@@ -15,6 +15,12 @@ export * from "./opencode"
export * from "./plugin"
export * from "./config"
export * from "./model-resolution"
export * from "./model-resolution-types"
export * from "./model-resolution-cache"
export * from "./model-resolution-config"
export * from "./model-resolution-effective-model"
export * from "./model-resolution-variant"
export * from "./model-resolution-details"
export * from "./auth"
export * from "./dependencies"
export * from "./gh"

View File

@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../../../shared"
import type { AvailableModelsInfo } from "./model-resolution-types"
function getOpenCodeCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
export function loadAvailableModelsFromCache(): AvailableModelsInfo {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
return { providers: [], modelCount: 0, cacheExists: false }
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data = parseJsonc<Record<string, { models?: Record<string, unknown> }>>(content)
const providers = Object.keys(data)
let modelCount = 0
for (const providerId of providers) {
const models = data[providerId]?.models
if (models && typeof models === "object") {
modelCount += Object.keys(models).length
}
}
return { providers, modelCount, cacheExists: true }
} catch {
return { providers: [], modelCount: 0, cacheExists: false }
}
}

View File

@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { detectConfigFile, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
import type { OmoConfig } from "./model-resolution-types"
const PACKAGE_NAME = "oh-my-opencode"
const USER_CONFIG_BASE = join(
getOpenCodeConfigPaths({ binary: "opencode", version: null }).configDir,
PACKAGE_NAME
)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
export function loadOmoConfig(): OmoConfig | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
try {
const content = readFileSync(projectDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
try {
const content = readFileSync(userDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
return null
}

View File

@@ -0,0 +1,56 @@
import { join } from "node:path"
import { getOpenCodeCacheDir } from "../../../shared"
import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant"
export function buildModelResolutionDetails(options: {
info: ModelResolutionInfo
available: AvailableModelsInfo
config: OmoConfig
}): string[] {
const details: string[] = []
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
details.push("═══ Available Models (from cache) ═══")
details.push("")
if (options.available.cacheExists) {
details.push(` Providers in cache: ${options.available.providers.length}`)
details.push(
` Sample: ${options.available.providers.slice(0, 6).join(", ")}${options.available.providers.length > 6 ? "..." : ""}`
)
details.push(` Total models: ${options.available.modelCount}`)
details.push(` Cache: ${cacheFile}`)
details.push(` Runtime: only connected providers used`)
details.push(` Refresh: opencode models --refresh`)
} else {
details.push(" ⚠ Cache not found. Run 'opencode' to populate.")
}
details.push("")
details.push("═══ Configured Models ═══")
details.push("")
details.push("Agents:")
for (const agent of options.info.agents) {
const marker = agent.userOverride ? "●" : "○"
const display = formatModelWithVariant(
agent.effectiveModel,
getEffectiveVariant(agent.name, agent.requirement, options.config)
)
details.push(` ${marker} ${agent.name}: ${display}`)
}
details.push("")
details.push("Categories:")
for (const category of options.info.categories) {
const marker = category.userOverride ? "●" : "○"
const display = formatModelWithVariant(
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, options.config)
)
details.push(` ${marker} ${category.name}: ${display}`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")
return details
}

View File

@@ -0,0 +1,27 @@
import type { ModelRequirement } from "../../../shared/model-requirements"
function formatProviderChain(providers: string[]): string {
return providers.join(" → ")
}
export function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return userOverride
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "unknown"
}
return `${firstEntry.providers[0]}/${firstEntry.model}`
}
export function buildEffectiveResolution(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return `User override: ${userOverride}`
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "No fallback chain defined"
}
return `Provider fallback: ${formatProviderChain(firstEntry.providers)}${firstEntry.model}`
}

View File

@@ -0,0 +1,35 @@
import type { ModelRequirement } from "../../../shared/model-requirements"
export interface AgentResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface CategoryResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface ModelResolutionInfo {
agents: AgentResolutionInfo[]
categories: CategoryResolutionInfo[]
}
export interface OmoConfig {
agents?: Record<string, { model?: string; variant?: string; category?: string }>
categories?: Record<string, { model?: string; variant?: string }>
}
export interface AvailableModelsInfo {
providers: string[]
modelCount: number
cacheExists: boolean
}

View File

@@ -0,0 +1,55 @@
import type { ModelRequirement } from "../../../shared/model-requirements"
import type { OmoConfig } from "./model-resolution-types"
export function formatModelWithVariant(model: string, variant?: string): string {
return variant ? `${model} (${variant})` : model
}
function getAgentOverride(
agentName: string,
config: OmoConfig
): { variant?: string; category?: string } | undefined {
const agentOverrides = config.agents
if (!agentOverrides) return undefined
return (
agentOverrides[agentName] ??
Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
)
}
export function getEffectiveVariant(
agentName: string,
requirement: ModelRequirement,
config: OmoConfig
): string | undefined {
const agentOverride = getAgentOverride(agentName, config)
if (agentOverride?.variant) {
return agentOverride.variant
}
const categoryName = agentOverride?.category
if (categoryName) {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
export function getCategoryEffectiveVariant(
categoryName: string,
requirement: ModelRequirement,
config: OmoConfig
): string | undefined {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}

View File

@@ -1,132 +1,14 @@
import { readFileSync, existsSync } from "node:fs"
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
type ModelRequirement,
} from "../../../shared/model-requirements"
import { homedir } from "node:os"
import { join } from "node:path"
function getOpenCodeCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
function loadAvailableModels(): { providers: string[]; modelCount: number; cacheExists: boolean } {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
return { providers: [], modelCount: 0, cacheExists: false }
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data = JSON.parse(content) as Record<string, { models?: Record<string, unknown> }>
const providers = Object.keys(data)
let modelCount = 0
for (const providerId of providers) {
const models = data[providerId]?.models
if (models && typeof models === "object") {
modelCount += Object.keys(models).length
}
}
return { providers, modelCount, cacheExists: true }
} catch {
return { providers: [], modelCount: 0, cacheExists: false }
}
}
const PACKAGE_NAME = "oh-my-opencode"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
export interface AgentResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface CategoryResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
userVariant?: string
effectiveModel: string
effectiveResolution: string
}
export interface ModelResolutionInfo {
agents: AgentResolutionInfo[]
categories: CategoryResolutionInfo[]
}
interface OmoConfig {
agents?: Record<string, { model?: string; variant?: string; category?: string }>
categories?: Record<string, { model?: string; variant?: string }>
}
function loadConfig(): OmoConfig | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
try {
const content = readFileSync(projectDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
try {
const content = readFileSync(userDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
return null
}
function formatProviderChain(providers: string[]): string {
return providers.join(" → ")
}
function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return userOverride
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "unknown"
}
return `${firstEntry.providers[0]}/${firstEntry.model}`
}
function buildEffectiveResolution(
requirement: ModelRequirement,
userOverride?: string,
): string {
if (userOverride) {
return `User override: ${userOverride}`
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "No fallback chain defined"
}
return `Provider fallback: ${formatProviderChain(firstEntry.providers)}${firstEntry.model}`
}
import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types"
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
import { loadOmoConfig } from "./model-resolution-config"
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
import { buildModelResolutionDetails } from "./model-resolution-details"
export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
@@ -184,116 +66,10 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
return { agents, categories }
}
function formatModelWithVariant(model: string, variant?: string): string {
return variant ? `${model} (${variant})` : model
}
function getAgentOverride(
agentName: string,
config: OmoConfig,
): { variant?: string; category?: string } | undefined {
const agentOverrides = config.agents
if (!agentOverrides) return undefined
// Direct lookup first, then case-insensitive lookup (matches agent-variant.ts)
return (
agentOverrides[agentName] ??
Object.entries(agentOverrides).find(
([key]) => key.toLowerCase() === agentName.toLowerCase()
)?.[1]
)
}
function getEffectiveVariant(
name: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const agentOverride = getAgentOverride(name, config)
// Priority 1: Agent's direct variant override
if (agentOverride?.variant) {
return agentOverride.variant
}
// Priority 2: Agent's category -> category's variant (matches agent-variant.ts)
const categoryName = agentOverride?.category
if (categoryName) {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
}
// Priority 3: Fall back to requirement's fallback chain
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
interface AvailableModelsInfo {
providers: string[]
modelCount: number
cacheExists: boolean
}
function getCategoryEffectiveVariant(
categoryName: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo, config: OmoConfig): string[] {
const details: string[] = []
details.push("═══ Available Models (from cache) ═══")
details.push("")
if (available.cacheExists) {
details.push(` Providers in cache: ${available.providers.length}`)
details.push(` Sample: ${available.providers.slice(0, 6).join(", ")}${available.providers.length > 6 ? "..." : ""}`)
details.push(` Total models: ${available.modelCount}`)
details.push(` Cache: ~/.cache/opencode/models.json`)
details.push(` Runtime: only connected providers used`)
details.push(` Refresh: opencode models --refresh`)
} else {
details.push(" ⚠ Cache not found. Run 'opencode' to populate.")
}
details.push("")
details.push("═══ Configured Models ═══")
details.push("")
details.push("Agents:")
for (const agent of info.agents) {
const marker = agent.userOverride ? "●" : "○"
const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.name, agent.requirement, config))
details.push(` ${marker} ${agent.name}: ${display}`)
}
details.push("")
details.push("Categories:")
for (const category of info.categories) {
const marker = category.userOverride ? "●" : "○"
const display = formatModelWithVariant(
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, config)
)
details.push(` ${marker} ${category.name}: ${display}`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")
return details
}
export async function checkModelResolution(): Promise<CheckResult> {
const config = loadConfig() ?? {}
const config = loadOmoConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config)
const available = loadAvailableModels()
const available = loadAvailableModelsFromCache()
const agentCount = info.agents.length
const categoryCount = info.categories.length
@@ -308,7 +84,7 @@ export async function checkModelResolution(): Promise<CheckResult> {
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
status: available.cacheExists ? "pass" : "warn",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
details: buildDetailsArray(info, available, config),
details: buildModelResolutionDetails({ info, available, config }),
}
}

View File

@@ -0,0 +1,55 @@
import {
AGENT_MODEL_REQUIREMENTS,
type FallbackEntry,
} from "../shared/model-requirements"
import type { ProviderAvailability } from "./model-fallback-types"
import { isProviderAvailable } from "./provider-availability"
import { transformModelForProvider } from "./provider-model-id-transform"
export function resolveModelFromChain(
fallbackChain: FallbackEntry[],
availability: ProviderAvailability
): { model: string; variant?: string } | null {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (isProviderAvailable(provider, availability)) {
const transformedModel = transformModelForProvider(provider, entry.model)
return {
model: `${provider}/${transformedModel}`,
variant: entry.variant,
}
}
}
}
return null
}
export function getSisyphusFallbackChain(): FallbackEntry[] {
return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain
}
export function isAnyFallbackEntryAvailable(
fallbackChain: FallbackEntry[],
availability: ProviderAvailability
): boolean {
return fallbackChain.some((entry) =>
entry.providers.some((provider) => isProviderAvailable(provider, availability))
)
}
export function isRequiredModelAvailable(
requiresModel: string,
fallbackChain: FallbackEntry[],
availability: ProviderAvailability
): boolean {
const matchingEntry = fallbackChain.find((entry) => entry.model === requiresModel)
if (!matchingEntry) return false
return matchingEntry.providers.some((provider) => isProviderAvailable(provider, availability))
}
export function isRequiredProviderAvailable(
requiredProviders: string[],
availability: ProviderAvailability
): boolean {
return requiredProviders.some((provider) => isProviderAvailable(provider, availability))
}

View File

@@ -0,0 +1,112 @@
import {
findPluginEntry,
getCachedVersion,
getLatestVersion,
getLocalDevVersion,
isLocalDevMode,
} from "../../hooks/auto-update-checker/checker"
import type { GetLocalVersionOptions, VersionInfo } from "./types"
import { formatJsonOutput, formatVersionOutput } from "./formatter"
export async function getLocalVersion(
options: GetLocalVersionOptions = {}
): Promise<number> {
const directory = options.directory ?? process.cwd()
try {
if (isLocalDevMode(directory)) {
const currentVersion = getLocalDevVersion(directory) ?? getCachedVersion()
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: true,
isPinned: false,
pinnedVersion: null,
status: "local-dev",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const pluginInfo = findPluginEntry(directory)
if (pluginInfo?.isPinned) {
const info: VersionInfo = {
currentVersion: pluginInfo.pinnedVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: true,
pinnedVersion: pluginInfo.pinnedVersion,
status: "pinned",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const currentVersion = getCachedVersion()
if (!currentVersion) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "unknown",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
const { extractChannel } = await import("../../hooks/auto-update-checker/index")
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const isUpToDate = currentVersion === latestVersion
const info: VersionInfo = {
currentVersion,
latestVersion,
isUpToDate,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: isUpToDate ? "up-to-date" : "outdated",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
} catch (error) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
}

View File

@@ -1,106 +1,2 @@
import { getCachedVersion, getLatestVersion, isLocalDevMode, findPluginEntry } from "../../hooks/auto-update-checker/checker"
import type { GetLocalVersionOptions, VersionInfo } from "./types"
import { formatVersionOutput, formatJsonOutput } from "./formatter"
export async function getLocalVersion(options: GetLocalVersionOptions = {}): Promise<number> {
const directory = options.directory ?? process.cwd()
try {
if (isLocalDevMode(directory)) {
const currentVersion = getCachedVersion()
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: true,
isPinned: false,
pinnedVersion: null,
status: "local-dev",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const pluginInfo = findPluginEntry(directory)
if (pluginInfo?.isPinned) {
const info: VersionInfo = {
currentVersion: pluginInfo.pinnedVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: true,
pinnedVersion: pluginInfo.pinnedVersion,
status: "pinned",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const currentVersion = getCachedVersion()
if (!currentVersion) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "unknown",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
const { extractChannel } = await import("../../hooks/auto-update-checker/index")
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
if (!latestVersion) {
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const isUpToDate = currentVersion === latestVersion
const info: VersionInfo = {
currentVersion,
latestVersion,
isUpToDate,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: isUpToDate ? "up-to-date" : "outdated",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
} catch (error) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
}
export { getLocalVersion } from "./get-local-version"
export * from "./types"

View File

@@ -1,190 +1,4 @@
#!/usr/bin/env bun
import { Command } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import { createMcpOAuthCommand } from "./mcp-oauth"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
import type { DoctorOptions } from "./doctor"
import packageJson from "../../package.json" with { type: "json" }
import { runCli } from "./cli-program"
const VERSION = packageJson.version
const program = new Command()
program
.name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
.version(VERSION, "-v, --version", "Show version number")
.enablePositionalOptions()
program
.command("install")
.description("Install and configure oh-my-opencode with interactive setup")
.option("--no-tui", "Run in non-interactive mode (requires all options)")
.option("--claude <value>", "Claude subscription: no, yes, max20")
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
OpenAI Native openai/ models (GPT-5.2 for Oracle)
Gemini Native google/ models (Gemini 3 Pro, Flash)
Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
`)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
process.exit(exitCode)
})
program
.command("run <message>")
.allowUnknownOption()
.passThroughOptions()
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
.option("--attach <url>", "Attach to existing opencode server URL")
.option("--on-complete <command>", "Shell command to run after completion")
.option("--json", "Output structured JSON result to stdout")
.option("--session-id <id>", "Resume existing session instead of creating new one")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode run "Fix the bug in index.ts"
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
$ bunx oh-my-opencode run --port 4321 "Fix the bug"
$ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug"
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
Agent resolution order:
1) --agent flag
2) OPENCODE_DEFAULT_AGENT
3) oh-my-opencode.json "default_run_agent"
4) Sisyphus (fallback)
Available core agents:
Sisyphus, Hephaestus, Prometheus, Atlas
Unlike 'opencode run', this command waits until:
- All todos are completed or cancelled
- All child sessions (background tasks) are idle
`)
.action(async (message: string, options) => {
if (options.port && options.attach) {
console.error("Error: --port and --attach are mutually exclusive")
process.exit(1)
}
const runOptions: RunOptions = {
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
port: options.port,
attach: options.attach,
onComplete: options.onComplete,
json: options.json ?? false,
sessionId: options.sessionId,
}
const exitCode = await run(runOptions)
process.exit(exitCode)
})
program
.command("get-local-version")
.description("Show current installed version and check for updates")
.option("-d, --directory <path>", "Working directory to check config from")
.option("--json", "Output in JSON format for scripting")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode get-local-version
$ bunx oh-my-opencode get-local-version --json
$ bunx oh-my-opencode get-local-version --directory /path/to/project
This command shows:
- Current installed version
- Latest available version on npm
- Whether you're up to date
- Special modes (local dev, pinned version)
`)
.action(async (options) => {
const versionOptions: GetLocalVersionOptions = {
directory: options.directory,
json: options.json ?? false,
}
const exitCode = await getLocalVersion(versionOptions)
process.exit(exitCode)
})
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode doctor
$ bunx oh-my-opencode doctor --verbose
$ bunx oh-my-opencode doctor --json
$ bunx oh-my-opencode doctor --category authentication
Categories:
installation Check OpenCode and plugin installation
configuration Validate configuration files
authentication Check auth provider status
dependencies Check external dependencies
tools Check LSP and MCP servers
updates Check for version updates
`)
.action(async (options) => {
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")
.action(() => {
console.log(`oh-my-opencode v${VERSION}`)
})
program.addCommand(createMcpOAuthCommand())
program.parse()
runCli()

View File

@@ -0,0 +1,189 @@
import color from "picocolors"
import type {
BooleanArg,
ClaudeSubscription,
DetectedConfig,
InstallArgs,
InstallConfig,
} from "./types"
export const SYMBOLS = {
check: color.green("[OK]"),
cross: color.red("[X]"),
arrow: color.cyan("->"),
bullet: color.dim("*"),
info: color.blue("[i]"),
warn: color.yellow("[!]"),
star: color.yellow("*"),
}
function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○")
const label = enabled ? color.white(name) : color.dim(name)
const suffix = detail ? color.dim(` (${detail})`) : ""
return ` ${status} ${label}${suffix}`
}
export function formatConfigSummary(config: InstallConfig): string {
const lines: string[] = []
lines.push(color.bold(color.white("Configuration Summary")))
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.2 for Oracle"))
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
lines.push("")
lines.push(color.dim("─".repeat(40)))
lines.push("")
lines.push(color.bold(color.white("Model Assignment")))
lines.push("")
lines.push(` ${SYMBOLS.info} Models auto-configured based on provider priority`)
lines.push(` ${SYMBOLS.bullet} Priority: Native > Copilot > OpenCode Zen > Z.ai`)
return lines.join("\n")
}
export function printHeader(isUpdate: boolean): void {
const mode = isUpdate ? "Update" : "Install"
console.log()
console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `)))
console.log()
}
export function printStep(step: number, total: number, message: string): void {
const progress = color.dim(`[${step}/${total}]`)
console.log(`${progress} ${message}`)
}
export function printSuccess(message: string): void {
console.log(`${SYMBOLS.check} ${message}`)
}
export function printError(message: string): void {
console.log(`${SYMBOLS.cross} ${color.red(message)}`)
}
export function printInfo(message: string): void {
console.log(`${SYMBOLS.info} ${message}`)
}
export function printWarning(message: string): void {
console.log(`${SYMBOLS.warn} ${color.yellow(message)}`)
}
export function printBox(content: string, title?: string): void {
const lines = content.split("\n")
const maxWidth =
Math.max(
...lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").length),
title?.length ?? 0,
) + 4
const border = color.dim("─".repeat(maxWidth))
console.log()
if (title) {
console.log(
color.dim("┌─") +
color.bold(` ${title} `) +
color.dim("─".repeat(maxWidth - title.length - 4)) +
color.dim("┐"),
)
} else {
console.log(color.dim("┌") + border + color.dim("┐"))
}
for (const line of lines) {
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "")
const padding = maxWidth - stripped.length
console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│"))
}
console.log(color.dim("└") + border + color.dim("┘"))
console.log()
}
export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)")
} else if (!["no", "yes", "max20"].includes(args.claude)) {
errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`)
}
if (args.gemini === undefined) {
errors.push("--gemini is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.gemini)) {
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
}
if (args.copilot === undefined) {
errors.push("--copilot is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.copilot)) {
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
}
if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`)
}
if (args.opencodeZen !== undefined && !["no", "yes"].includes(args.opencodeZen)) {
errors.push(`Invalid --opencode-zen value: ${args.opencodeZen} (expected: no, yes)`)
}
if (args.zaiCodingPlan !== undefined && !["no", "yes"].includes(args.zaiCodingPlan)) {
errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`)
}
if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) {
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
}
return { valid: errors.length === 0, errors }
}
export function argsToConfig(args: InstallArgs): InstallConfig {
return {
hasClaude: args.claude !== "no",
isMax20: args.claude === "max20",
hasOpenAI: args.openai === "yes",
hasGemini: args.gemini === "yes",
hasCopilot: args.copilot === "yes",
hasOpencodeZen: args.opencodeZen === "yes",
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes",
}
}
export function detectedToInitialValues(detected: DetectedConfig): {
claude: ClaudeSubscription
openai: BooleanArg
gemini: BooleanArg
copilot: BooleanArg
opencodeZen: BooleanArg
zaiCodingPlan: BooleanArg
kimiForCoding: BooleanArg
} {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
claude = detected.isMax20 ? "max20" : "yes"
}
return {
claude,
openai: detected.hasOpenAI ? "yes" : "no",
gemini: detected.hasGemini ? "yes" : "no",
copilot: detected.hasCopilot ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
}
}

View File

@@ -1,542 +1,10 @@
import * as p from "@clack/prompts"
import color from "picocolors"
import type { InstallArgs, InstallConfig, ClaudeSubscription, BooleanArg, DetectedConfig } from "./types"
import {
addPluginToOpenCodeConfig,
writeOmoConfig,
isOpenCodeInstalled,
getOpenCodeVersion,
addAuthPlugins,
addProviderConfig,
detectCurrentConfig,
} from "./config-manager"
import { shouldShowChatGPTOnlyWarning } from "./model-fallback"
import packageJson from "../../package.json" with { type: "json" }
import type { InstallArgs } from "./types"
import { runCliInstaller } from "./cli-installer"
import { runTuiInstaller } from "./tui-installer"
const VERSION = packageJson.version
const SYMBOLS = {
check: color.green("[OK]"),
cross: color.red("[X]"),
arrow: color.cyan("->"),
bullet: color.dim("*"),
info: color.blue("[i]"),
warn: color.yellow("[!]"),
star: color.yellow("*"),
}
function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○")
const label = enabled ? color.white(name) : color.dim(name)
const suffix = detail ? color.dim(` (${detail})`) : ""
return ` ${status} ${label}${suffix}`
}
function formatConfigSummary(config: InstallConfig): string {
const lines: string[] = []
lines.push(color.bold(color.white("Configuration Summary")))
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
lines.push(formatProvider("OpenAI/ChatGPT", config.hasOpenAI, "GPT-5.2 for Oracle"))
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
lines.push("")
lines.push(color.dim("─".repeat(40)))
lines.push("")
lines.push(color.bold(color.white("Model Assignment")))
lines.push("")
lines.push(` ${SYMBOLS.info} Models auto-configured based on provider priority`)
lines.push(` ${SYMBOLS.bullet} Priority: Native > Copilot > OpenCode Zen > Z.ai`)
return lines.join("\n")
}
function printHeader(isUpdate: boolean): void {
const mode = isUpdate ? "Update" : "Install"
console.log()
console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `)))
console.log()
}
function printStep(step: number, total: number, message: string): void {
const progress = color.dim(`[${step}/${total}]`)
console.log(`${progress} ${message}`)
}
function printSuccess(message: string): void {
console.log(`${SYMBOLS.check} ${message}`)
}
function printError(message: string): void {
console.log(`${SYMBOLS.cross} ${color.red(message)}`)
}
function printInfo(message: string): void {
console.log(`${SYMBOLS.info} ${message}`)
}
function printWarning(message: string): void {
console.log(`${SYMBOLS.warn} ${color.yellow(message)}`)
}
function printBox(content: string, title?: string): void {
const lines = content.split("\n")
const maxWidth = Math.max(...lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, "").length), title?.length ?? 0) + 4
const border = color.dim("─".repeat(maxWidth))
console.log()
if (title) {
console.log(color.dim("┌─") + color.bold(` ${title} `) + color.dim("─".repeat(maxWidth - title.length - 4)) + color.dim("┐"))
} else {
console.log(color.dim("┌") + border + color.dim("┐"))
}
for (const line of lines) {
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "")
const padding = maxWidth - stripped.length
console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│"))
}
console.log(color.dim("└") + border + color.dim("┘"))
console.log()
}
function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)")
} else if (!["no", "yes", "max20"].includes(args.claude)) {
errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`)
}
if (args.gemini === undefined) {
errors.push("--gemini is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.gemini)) {
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
}
if (args.copilot === undefined) {
errors.push("--copilot is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.copilot)) {
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
}
if (args.openai !== undefined && !["no", "yes"].includes(args.openai)) {
errors.push(`Invalid --openai value: ${args.openai} (expected: no, yes)`)
}
if (args.opencodeZen !== undefined && !["no", "yes"].includes(args.opencodeZen)) {
errors.push(`Invalid --opencode-zen value: ${args.opencodeZen} (expected: no, yes)`)
}
if (args.zaiCodingPlan !== undefined && !["no", "yes"].includes(args.zaiCodingPlan)) {
errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`)
}
if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) {
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
}
return { valid: errors.length === 0, errors }
}
function argsToConfig(args: InstallArgs): InstallConfig {
return {
hasClaude: args.claude !== "no",
isMax20: args.claude === "max20",
hasOpenAI: args.openai === "yes",
hasGemini: args.gemini === "yes",
hasCopilot: args.copilot === "yes",
hasOpencodeZen: args.opencodeZen === "yes",
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes",
}
}
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; openai: BooleanArg; gemini: BooleanArg; copilot: BooleanArg; opencodeZen: BooleanArg; zaiCodingPlan: BooleanArg; kimiForCoding: BooleanArg } {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
claude = detected.isMax20 ? "max20" : "yes"
}
return {
claude,
openai: detected.hasOpenAI ? "yes" : "no",
gemini: detected.hasGemini ? "yes" : "no",
copilot: detected.hasCopilot ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
}
}
async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | null> {
const initial = detectedToInitialValues(detected)
const claude = await p.select({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
initialValue: initial.claude,
})
if (p.isCancel(claude)) {
p.cancel("Installation cancelled.")
return null
}
const openai = await p.select({
message: "Do you have an OpenAI/ChatGPT Plus subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Oracle will use fallback models" },
{ value: "yes" as const, label: "Yes", hint: "GPT-5.2 for Oracle (high-IQ debugging)" },
],
initialValue: initial.openai,
})
if (p.isCancel(openai)) {
p.cancel("Installation cancelled.")
return null
}
const gemini = await p.select({
message: "Will you integrate Google Gemini?",
options: [
{ value: "no" as const, label: "No", hint: "Frontend/docs agents will use fallback" },
{ value: "yes" as const, label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" },
],
initialValue: initial.gemini,
})
if (p.isCancel(gemini)) {
p.cancel("Installation cancelled.")
return null
}
const copilot = await p.select({
message: "Do you have a GitHub Copilot subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Only native providers will be used" },
{ value: "yes" as const, label: "Yes", hint: "Fallback option when native providers unavailable" },
],
initialValue: initial.copilot,
})
if (p.isCancel(copilot)) {
p.cancel("Installation cancelled.")
return null
}
const opencodeZen = await p.select({
message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." },
],
initialValue: initial.opencodeZen,
})
if (p.isCancel(opencodeZen)) {
p.cancel("Installation cancelled.")
return null
}
const zaiCodingPlan = await p.select({
message: "Do you have a Z.ai Coding Plan subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" },
],
initialValue: initial.zaiCodingPlan,
})
if (p.isCancel(zaiCodingPlan)) {
p.cancel("Installation cancelled.")
return null
}
const kimiForCoding = await p.select({
message: "Do you have a Kimi For Coding subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" },
],
initialValue: initial.kimiForCoding,
})
if (p.isCancel(kimiForCoding)) {
p.cancel("Installation cancelled.")
return null
}
return {
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasOpenAI: openai === "yes",
hasGemini: gemini === "yes",
hasCopilot: copilot === "yes",
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
}
}
async function runNonTuiInstall(args: InstallArgs): Promise<number> {
const validation = validateNonTuiArgs(args)
if (!validation.valid) {
printHeader(false)
printError("Validation failed:")
for (const err of validation.errors) {
console.log(` ${SYMBOLS.bullet} ${err}`)
}
console.log()
printInfo("Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --gemini=<no|yes> --copilot=<no|yes>")
console.log()
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
printHeader(isUpdate)
const totalSteps = 6
let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
const version = await getOpenCodeVersion()
if (!installed) {
printWarning("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${version ?? ""} detected`)
}
if (isUpdate) {
const initial = detectedToInitialValues(detected)
printInfo(`Current config: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`)
if (config.hasGemini) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
printError(`Failed: ${authResult.error}`)
return 1
}
printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`)
printStep(step++, totalSteps, "Adding provider configurations...")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
printError(`Failed: ${providerResult.error}`)
return 1
}
printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`)
} else {
step += 2
}
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
console.log(color.dim(" • Reduced orchestration quality"))
console.log(color.dim(" • Weaker tool selection and delegation"))
console.log(color.dim(" • Less reliable task completion"))
console.log()
console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience."))
console.log()
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
console.log()
printBox(
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"The Magic Word"
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
printBox(
`Run ${color.cyan("opencode auth login")} and select your provider:\n` +
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
(config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") +
(config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""),
"Authenticate Your Providers"
)
}
return 0
}
export async function install(args: InstallArgs): Promise<number> {
if (!args.tui) {
return runNonTuiInstall(args)
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... ")))
if (isUpdate) {
const initial = detectedToInitialValues(detected)
p.log.info(`Existing configuration detected: Claude=${initial.claude}, Gemini=${initial.gemini}`)
}
const s = p.spinner()
s.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
const version = await getOpenCodeVersion()
if (!installed) {
s.stop(`OpenCode binary not found ${color.yellow("[!]")}`)
p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else {
s.stop(`OpenCode ${version ?? "installed"} ${color.green("[OK]")}`)
}
const config = await runTuiMode(detected)
if (!config) return 1
s.start("Adding oh-my-opencode to OpenCode config")
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
if (!pluginResult.success) {
s.stop(`Failed to add plugin: ${pluginResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
if (config.hasGemini) {
s.start("Adding auth plugins (fetching latest versions)")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
s.stop(`Failed to add auth plugins: ${authResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Auth plugins added to ${color.cyan(authResult.configPath)}`)
s.start("Adding provider configurations")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
s.stop(`Failed to add provider config: ${providerResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`)
}
s.start("Writing oh-my-opencode configuration")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
s.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
console.log(color.dim(" • Reduced orchestration quality"))
console.log(color.dim(" • Weaker tool selection and delegation"))
console.log(color.dim(" • Less reliable task completion"))
console.log()
console.log(color.yellow(" Consider subscribing to Claude Pro/Max for the best experience."))
console.log()
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`)
p.note(
`Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"The Magic Word"
)
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
p.outro(color.green("oMoMoMoMo... Enjoy!"))
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
const providers: string[] = []
if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`)
if (config.hasGemini) providers.push(`Google ${color.gray("→ OAuth with Antigravity")}`)
if (config.hasCopilot) providers.push(`GitHub ${color.gray("→ Copilot")}`)
console.log()
console.log(color.bold("Authenticate Your Providers"))
console.log()
console.log(` Run ${color.cyan("opencode auth login")} and select:`)
for (const provider of providers) {
console.log(` ${SYMBOLS.bullet} ${provider}`)
}
console.log()
}
return 0
return args.tui ? runTuiInstaller(args, VERSION) : runCliInstaller(args, VERSION)
}

View File

@@ -0,0 +1,29 @@
export interface ProviderAvailability {
native: {
claude: boolean
openai: boolean
gemini: boolean
}
opencodeZen: boolean
copilot: boolean
zai: boolean
kimiForCoding: boolean
isMaxPlan: boolean
}
export interface AgentConfig {
model: string
variant?: string
}
export interface CategoryConfig {
model: string
variant?: string
}
export interface GeneratedOmoConfig {
$schema: string
agents?: Record<string, AgentConfig>
categories?: Record<string, CategoryConfig>
[key: string]: unknown
}

View File

@@ -1,133 +1,27 @@
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
type FallbackEntry,
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
} from "../shared/model-requirements"
import type { InstallConfig } from "./types"
interface ProviderAvailability {
native: {
claude: boolean
openai: boolean
gemini: boolean
}
opencodeZen: boolean
copilot: boolean
zai: boolean
kimiForCoding: boolean
isMaxPlan: boolean
}
import type { AgentConfig, CategoryConfig, GeneratedOmoConfig } from "./model-fallback-types"
import { toProviderAvailability } from "./provider-availability"
import {
getSisyphusFallbackChain,
isAnyFallbackEntryAvailable,
isRequiredModelAvailable,
isRequiredProviderAvailable,
resolveModelFromChain,
} from "./fallback-chain-resolution"
interface AgentConfig {
model: string
variant?: string
}
interface CategoryConfig {
model: string
variant?: string
}
export interface GeneratedOmoConfig {
$schema: string
agents?: Record<string, AgentConfig>
categories?: Record<string, CategoryConfig>
[key: string]: unknown
}
export type { GeneratedOmoConfig } from "./model-fallback-types"
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
function toProviderAvailability(config: InstallConfig): ProviderAvailability {
return {
native: {
claude: config.hasClaude,
openai: config.hasOpenAI,
gemini: config.hasGemini,
},
opencodeZen: config.hasOpencodeZen,
copilot: config.hasCopilot,
zai: config.hasZaiCodingPlan,
kimiForCoding: config.hasKimiForCoding,
isMaxPlan: config.isMax20,
}
}
function isProviderAvailable(provider: string, avail: ProviderAvailability): boolean {
const mapping: Record<string, boolean> = {
anthropic: avail.native.claude,
openai: avail.native.openai,
google: avail.native.gemini,
"github-copilot": avail.copilot,
opencode: avail.opencodeZen,
"zai-coding-plan": avail.zai,
"kimi-for-coding": avail.kimiForCoding,
}
return mapping[provider] ?? false
}
function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
.replace("gemini-3-pro", "gemini-3-pro-preview")
.replace("gemini-3-flash", "gemini-3-flash-preview")
}
return model
}
function resolveModelFromChain(
fallbackChain: FallbackEntry[],
avail: ProviderAvailability
): { model: string; variant?: string } | null {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (isProviderAvailable(provider, avail)) {
const transformedModel = transformModelForProvider(provider, entry.model)
return {
model: `${provider}/${transformedModel}`,
variant: entry.variant,
}
}
}
}
return null
}
function getSisyphusFallbackChain(): FallbackEntry[] {
return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain
}
function isAnyFallbackEntryAvailable(
fallbackChain: FallbackEntry[],
avail: ProviderAvailability
): boolean {
return fallbackChain.some((entry) =>
entry.providers.some((provider) => isProviderAvailable(provider, avail))
)
}
function isRequiredModelAvailable(
requiresModel: string,
fallbackChain: FallbackEntry[],
avail: ProviderAvailability
): boolean {
const matchingEntry = fallbackChain.find((entry) => entry.model === requiresModel)
if (!matchingEntry) return false
return matchingEntry.providers.some((provider) => isProviderAvailable(provider, avail))
}
function isRequiredProviderAvailable(
requiredProviders: string[],
avail: ProviderAvailability
): boolean {
return requiredProviders.some((provider) => isProviderAvailable(provider, avail))
}
export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
const avail = toProviderAvailability(config)

View File

@@ -0,0 +1,30 @@
import type { InstallConfig } from "./types"
import type { ProviderAvailability } from "./model-fallback-types"
export function toProviderAvailability(config: InstallConfig): ProviderAvailability {
return {
native: {
claude: config.hasClaude,
openai: config.hasOpenAI,
gemini: config.hasGemini,
},
opencodeZen: config.hasOpencodeZen,
copilot: config.hasCopilot,
zai: config.hasZaiCodingPlan,
kimiForCoding: config.hasKimiForCoding,
isMaxPlan: config.isMax20,
}
}
export function isProviderAvailable(provider: string, availability: ProviderAvailability): boolean {
const mapping: Record<string, boolean> = {
anthropic: availability.native.claude,
openai: availability.native.openai,
google: availability.native.gemini,
"github-copilot": availability.copilot,
opencode: availability.opencodeZen,
"zai-coding-plan": availability.zai,
"kimi-for-coding": availability.kimiForCoding,
}
return mapping[provider] ?? false
}

View File

@@ -0,0 +1,12 @@
export function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
.replace("gemini-3-pro", "gemini-3-pro-preview")
.replace("gemini-3-flash", "gemini-3-flash-preview")
}
return model
}

View File

@@ -0,0 +1,140 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
SessionErrorProps,
} from "./types"
export function serializeError(error: unknown): string {
if (!error) return "Unknown error"
if (error instanceof Error) {
const parts = [error.message]
if (error.cause) {
parts.push(`Cause: ${serializeError(error.cause)}`)
}
return parts.join(" | ")
}
if (typeof error === "string") {
return error
}
if (typeof error === "object") {
const obj = error as Record<string, unknown>
const messagePaths = [
obj.message,
obj.error,
(obj.data as Record<string, unknown>)?.message,
(obj.data as Record<string, unknown>)?.error,
(obj.error as Record<string, unknown>)?.message,
]
for (const msg of messagePaths) {
if (typeof msg === "string" && msg.length > 0) {
return msg
}
}
try {
const json = JSON.stringify(error, null, 2)
if (json !== "{}") {
return json
}
} catch (_) {
void _
}
}
return String(error)
}
function getSessionTag(ctx: RunContext, payload: EventPayload): string {
const props = payload.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = props?.sessionID ?? info?.sessionID
const isMainSession = sessionID === ctx.sessionID
if (isMainSession) return pc.green("[MAIN]")
if (sessionID) return pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
return pc.dim("[system]")
}
export function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const sessionTag = getSessionTag(ctx, payload)
const props = payload.properties as Record<string, unknown> | undefined
switch (payload.type) {
case "session.idle":
case "session.status": {
const status = (props?.status as { type?: string })?.type ?? "idle"
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
break
}
case "message.part.updated": {
const partProps = props as MessagePartUpdatedProps | undefined
const part = partProps?.part
if (part?.type === "tool-invocation") {
const toolPart = part as { toolName?: string; state?: string }
console.error(pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`))
} else if (part?.type === "text" && part.text) {
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
console.error(pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`))
}
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const model = msgProps?.info?.modelID
const agent = msgProps?.info?.agent
const details = [role, agent, model].filter(Boolean).join(", ")
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
break
}
case "tool.execute": {
const toolProps = props as ToolExecuteProps | undefined
const toolName = toolProps?.name ?? "unknown"
const input = toolProps?.input ?? {}
let inputStr: string
try {
inputStr = JSON.stringify(input)
} catch {
try {
inputStr = String(input)
} catch {
inputStr = "[unserializable]"
}
}
const inputPreview = inputStr.slice(0, 150)
console.error(pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`))
console.error(pc.dim(` input: ${inputPreview}${inputStr.length >= 150 ? "..." : ""}`))
break
}
case "tool.result": {
const resultProps = props as ToolResultProps | undefined
const output = resultProps?.output ?? ""
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
console.error(pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`))
break
}
case "session.error": {
const errorProps = props as SessionErrorProps | undefined
const errorMsg = serializeError(errorProps?.error)
console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`))
break
}
default:
console.error(pc.dim(`${sessionTag} ${payload.type}`))
}
}

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from "bun:test"
import type { RunContext } from "./types"
import { createEventState } from "./events"
import { handleSessionStatus } from "./event-handlers"
const createMockContext = (sessionID: string = "test-session"): RunContext => ({
sessionID,
} as RunContext)
describe("handleSessionStatus", () => {
it("recognizes idle from session.status event (not just deprecated session.idle)", () => {
//#given - state with mainSessionIdle=false
const ctx = createMockContext("test-session")
const state = createEventState()
state.mainSessionIdle = false
const payload = {
type: "session.status",
properties: {
sessionID: "test-session",
status: { type: "idle" as const },
},
}
//#when - handleSessionStatus called with idle status
handleSessionStatus(ctx, payload as any, state)
//#then - state.mainSessionIdle === true
expect(state.mainSessionIdle).toBe(true)
})
it("handleSessionStatus sets idle=false on busy", () => {
//#given - state with mainSessionIdle=true
const ctx = createMockContext("test-session")
const state = createEventState()
state.mainSessionIdle = true
const payload = {
type: "session.status",
properties: {
sessionID: "test-session",
status: { type: "busy" as const },
},
}
//#when - handleSessionStatus called with busy status
handleSessionStatus(ctx, payload as any, state)
//#then - state.mainSessionIdle === false
expect(state.mainSessionIdle).toBe(false)
})
it("does nothing for different session ID", () => {
//#given - state with mainSessionIdle=true
const ctx = createMockContext("test-session")
const state = createEventState()
state.mainSessionIdle = true
const payload = {
type: "session.status",
properties: {
sessionID: "other-session",
status: { type: "idle" as const },
},
}
//#when - handleSessionStatus called with different session ID
handleSessionStatus(ctx, payload as any, state)
//#then - state.mainSessionIdle remains unchanged
expect(state.mainSessionIdle).toBe(true)
})
})

View File

@@ -0,0 +1,127 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
SessionIdleProps,
SessionStatusProps,
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
} from "./types"
import type { EventState } from "./event-state"
import { serializeError } from "./event-formatting"
export function handleSessionIdle(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.idle") return
const props = payload.properties as SessionIdleProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionIdle = true
}
}
export function handleSessionStatus(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.status") return
const props = payload.properties as SessionStatusProps | undefined
if (props?.sessionID !== ctx.sessionID) return
if (props?.status?.type === "busy") {
state.mainSessionIdle = false
} else if (props?.status?.type === "idle") {
state.mainSessionIdle = true
} else if (props?.status?.type === "retry") {
state.mainSessionIdle = false
}
}
export function handleSessionError(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "session.error") return
const props = payload.properties as SessionErrorProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionError = true
state.lastError = serializeError(props?.error)
console.error(pc.red(`\n[session.error] ${state.lastError}`))
}
}
export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "message.part.updated") return
const props = payload.properties as MessagePartUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const part = props.part
if (!part) return
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
state.hasReceivedMeaningfulWork = true
}
state.lastPartText = part.text
}
}
export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "message.updated") return
const props = payload.properties as MessageUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
state.hasReceivedMeaningfulWork = true
state.messageCount++
state.lastPartText = ""
}
export function handleToolExecute(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "tool.execute") return
const props = payload.properties as ToolExecuteProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const toolName = props?.name || "unknown"
state.currentTool = toolName
let inputPreview = ""
if (props?.input) {
const input = props.input
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
}
export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void {
if (payload.type !== "tool.result") return
const props = payload.properties as ToolResultProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const output = props?.output || ""
const maxLen = 200
const preview = output.length > maxLen ? output.slice(0, maxLen) + "..." : output
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
}
state.currentTool = null
state.lastPartText = ""
}

View File

@@ -0,0 +1,25 @@
export interface EventState {
mainSessionIdle: boolean
mainSessionError: boolean
lastError: string | null
lastOutput: string
lastPartText: string
currentTool: string | null
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
hasReceivedMeaningfulWork: boolean
/** Count of assistant messages for the main session */
messageCount: number
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
hasReceivedMeaningfulWork: false,
messageCount: 0,
}
}

View File

@@ -0,0 +1,43 @@
import pc from "picocolors"
import type { RunContext, EventPayload } from "./types"
import type { EventState } from "./event-state"
import { logEventVerbose } from "./event-formatting"
import {
handleSessionError,
handleSessionIdle,
handleSessionStatus,
handleMessagePartUpdated,
handleMessageUpdated,
handleToolExecute,
handleToolResult,
} from "./event-handlers"
export async function processEvents(
ctx: RunContext,
stream: AsyncIterable<unknown>,
state: EventState
): Promise<void> {
for await (const event of stream) {
if (ctx.abortController.signal.aborted) break
try {
const payload = event as EventPayload
if (!payload?.type) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
continue
}
logEventVerbose(ctx, payload)
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)
} catch (err) {
console.error(pc.red(`[event error] ${err}`))
}
}
}

View File

@@ -1,329 +1,4 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
SessionIdleProps,
SessionStatusProps,
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
} from "./types"
export function serializeError(error: unknown): string {
if (!error) return "Unknown error"
if (error instanceof Error) {
const parts = [error.message]
if (error.cause) {
parts.push(`Cause: ${serializeError(error.cause)}`)
}
return parts.join(" | ")
}
if (typeof error === "string") {
return error
}
if (typeof error === "object") {
const obj = error as Record<string, unknown>
const messagePaths = [
obj.message,
obj.error,
(obj.data as Record<string, unknown>)?.message,
(obj.data as Record<string, unknown>)?.error,
(obj.error as Record<string, unknown>)?.message,
]
for (const msg of messagePaths) {
if (typeof msg === "string" && msg.length > 0) {
return msg
}
}
try {
const json = JSON.stringify(error, null, 2)
if (json !== "{}") {
return json
}
} catch (_) {
void _
}
}
return String(error)
}
export interface EventState {
mainSessionIdle: boolean
mainSessionError: boolean
lastError: string | null
lastOutput: string
lastPartText: string
currentTool: string | null
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
hasReceivedMeaningfulWork: boolean
/** Count of assistant messages for the main session */
messageCount: number
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
hasReceivedMeaningfulWork: false,
messageCount: 0,
}
}
export async function processEvents(
ctx: RunContext,
stream: AsyncIterable<unknown>,
state: EventState
): Promise<void> {
for await (const event of stream) {
if (ctx.abortController.signal.aborted) break
try {
const payload = event as EventPayload
if (!payload?.type) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
continue
}
logEventVerbose(ctx, payload)
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)
} catch (err) {
console.error(pc.red(`[event error] ${err}`))
}
}
}
function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const props = payload.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = props?.sessionID ?? info?.sessionID
const isMainSession = sessionID === ctx.sessionID
const sessionTag = isMainSession
? pc.green("[MAIN]")
: sessionID
? pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
: pc.dim("[system]")
switch (payload.type) {
case "session.idle":
case "session.status": {
const status = (props?.status as { type?: string })?.type ?? "idle"
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
break
}
case "message.part.updated": {
const partProps = props as MessagePartUpdatedProps | undefined
const part = partProps?.part
if (part?.type === "tool-invocation") {
const toolPart = part as { toolName?: string; state?: string }
console.error(
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
)
} else if (part?.type === "text" && part.text) {
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)
)
}
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const model = msgProps?.info?.modelID
const agent = msgProps?.info?.agent
const details = [role, agent, model].filter(Boolean).join(", ")
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
break
}
case "tool.execute": {
const toolProps = props as ToolExecuteProps | undefined
const toolName = toolProps?.name ?? "unknown"
const input = toolProps?.input ?? {}
const inputStr = JSON.stringify(input).slice(0, 150)
console.error(
pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`)
)
console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`))
break
}
case "tool.result": {
const resultProps = props as ToolResultProps | undefined
const output = resultProps?.output ?? ""
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
console.error(
pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
)
break
}
case "session.error": {
const errorProps = props as SessionErrorProps | undefined
const errorMsg = serializeError(errorProps?.error)
console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`))
break
}
default:
console.error(pc.dim(`${sessionTag} ${payload.type}`))
}
}
function handleSessionIdle(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.idle") return
const props = payload.properties as SessionIdleProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionIdle = true
}
}
function handleSessionStatus(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.status") return
const props = payload.properties as SessionStatusProps | undefined
if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") {
state.mainSessionIdle = false
}
}
function handleSessionError(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.error") return
const props = payload.properties as SessionErrorProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionError = true
state.lastError = serializeError(props?.error)
console.error(pc.red(`\n[session.error] ${state.lastError}`))
}
}
function handleMessagePartUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.part.updated") return
const props = payload.properties as MessagePartUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const part = props.part
if (!part) return
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
state.hasReceivedMeaningfulWork = true
}
state.lastPartText = part.text
}
}
function handleMessageUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.updated") return
const props = payload.properties as MessageUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
state.hasReceivedMeaningfulWork = true
state.messageCount++
}
function handleToolExecute(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.execute") return
const props = payload.properties as ToolExecuteProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const toolName = props?.name || "unknown"
state.currentTool = toolName
let inputPreview = ""
if (props?.input) {
const input = props.input
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
}
function handleToolResult(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.result") return
const props = payload.properties as ToolResultProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const output = props?.output || ""
const maxLen = 200
const preview = output.length > maxLen
? output.slice(0, maxLen) + "..."
: output
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
}
state.currentTool = null
state.lastPartText = ""
}
export type { EventState } from "./event-state"
export { createEventState } from "./event-state"
export { serializeError } from "./event-formatting"
export { processEvents } from "./event-stream-processor"

View File

@@ -4,4 +4,6 @@ export { createServerConnection } from "./server-connection"
export { resolveSession } from "./session-resolver"
export { createJsonOutputManager } from "./json-output"
export { executeOnCompleteHook } from "./on-complete-hook"
export { createEventState, processEvents, serializeError } from "./events"
export type { EventState } from "./events"
export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types"

View File

@@ -0,0 +1,266 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { createEventState } from "./events"
import { pollForCompletion } from "./poll-for-completion"
const createMockContext = (overrides: {
todo?: Todo[]
childrenBySession?: Record<string, ChildSession[]>
statuses?: Record<string, SessionStatus>
} = {}): RunContext => {
const {
todo = [],
childrenBySession = { "test-session": [] },
statuses = {},
} = overrides
return {
client: {
session: {
todo: mock(() => Promise.resolve({ data: todo })),
children: mock((opts: { path: { id: string } }) =>
Promise.resolve({ data: childrenBySession[opts.path.id] ?? [] })
),
status: mock(() => Promise.resolve({ data: statuses })),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
directory: "/test",
abortController: new AbortController(),
}
}
describe("pollForCompletion", () => {
it("requires consecutive stability checks before exiting - not immediate", async () => {
//#given - 0 todos, 0 children, session idle, meaningful work done
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - exits with 0 but only after 3 consecutive checks
expect(result).toBe(0)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBeGreaterThanOrEqual(3)
})
it("does not exit when currentTool is set - resets consecutive counter", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
eventState.currentTool = "task"
const abortController = new AbortController()
//#when - abort after enough time to verify it didn't exit
setTimeout(() => abortController.abort(), 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - should be aborted, not completed (tool blocked exit)
expect(result).toBe(130)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBe(0)
})
it("resets consecutive counter when session becomes busy between checks", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
let todoCallCount = 0
let busyInserted = false
;(ctx.client.session as any).todo = mock(async () => {
todoCallCount++
if (todoCallCount === 1 && !busyInserted) {
busyInserted = true
eventState.mainSessionIdle = false
setTimeout(() => { eventState.mainSessionIdle = true }, 15)
}
return { data: [] }
})
;(ctx.client.session as any).children = mock(() =>
Promise.resolve({ data: [] })
)
;(ctx.client.session as any).status = mock(() =>
Promise.resolve({ data: {} })
)
//#when
const startMs = Date.now()
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
const elapsedMs = Date.now() - startMs
//#then - took longer than 3 polls because busy interrupted the streak
expect(result).toBe(0)
expect(elapsedMs).toBeGreaterThan(30)
})
it("returns 1 on session error", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.mainSessionError = true
eventState.lastError = "Test error"
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then
expect(result).toBe(1)
})
it("returns 130 when aborted", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
const abortController = new AbortController()
//#when
setTimeout(() => abortController.abort(), 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then
expect(result).toBe(130)
})
it("does not check completion when hasReceivedMeaningfulWork is false", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = false
const abortController = new AbortController()
//#when
setTimeout(() => abortController.abort(), 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then
expect(result).toBe(130)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBe(0)
})
it("simulates race condition: brief idle with 0 todos does not cause immediate exit", async () => {
//#given - simulate Sisyphus outputting text, session goes idle briefly, then tool fires
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
let pollTick = 0
;(ctx.client.session as any).todo = mock(async () => {
pollTick++
if (pollTick === 2) {
eventState.currentTool = "task"
}
return { data: [] }
})
;(ctx.client.session as any).children = mock(() =>
Promise.resolve({ data: [] })
)
;(ctx.client.session as any).status = mock(() =>
Promise.resolve({ data: {} })
)
//#when - abort after tool stays in-flight
setTimeout(() => abortController.abort(), 200)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - should NOT have exited with 0 (tool blocked it, then aborted)
expect(result).toBe(130)
})
it("returns 1 when session errors while not idle (error not masked by idle gate)", async () => {
//#given - mainSessionIdle=false, mainSessionError=true, lastError="crash"
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = false
eventState.mainSessionError = true
eventState.lastError = "crash"
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when - pollForCompletion runs
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - returns 1 (not 130/timeout), error message printed
expect(result).toBe(1)
const errorCalls = (console.error as ReturnType<typeof mock>).mock.calls
expect(errorCalls.some((call) => call[0]?.includes("Session ended with error"))).toBe(true)
})
it("returns 1 when session errors while tool is active (error not masked by tool gate)", async () => {
//#given - mainSessionIdle=true, currentTool="bash", mainSessionError=true
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.currentTool = "bash"
eventState.mainSessionError = true
eventState.lastError = "error during tool"
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
})
//#then - returns 1
expect(result).toBe(1)
})
})

View File

@@ -0,0 +1,77 @@
import pc from "picocolors"
import type { RunContext } from "./types"
import type { EventState } from "./events"
import { checkCompletionConditions } from "./completion"
const DEFAULT_POLL_INTERVAL_MS = 500
const DEFAULT_REQUIRED_CONSECUTIVE = 3
const ERROR_GRACE_CYCLES = 3
export interface PollOptions {
pollIntervalMs?: number
requiredConsecutive?: number
}
export async function pollForCompletion(
ctx: RunContext,
eventState: EventState,
abortController: AbortController,
options: PollOptions = {}
): Promise<number> {
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
const requiredConsecutive =
options.requiredConsecutive ?? DEFAULT_REQUIRED_CONSECUTIVE
let consecutiveCompleteChecks = 0
let errorCycleCount = 0
while (!abortController.signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
// ERROR CHECK FIRST — errors must not be masked by other gates
if (eventState.mainSessionError) {
errorCycleCount++
if (errorCycleCount >= ERROR_GRACE_CYCLES) {
console.error(
pc.red(`\n\nSession ended with error: ${eventState.lastError}`)
)
console.error(
pc.yellow("Check if todos were completed before the error.")
)
return 1
}
// Continue polling during grace period to allow recovery
continue
} else {
// Reset error counter when error clears (recovery succeeded)
errorCycleCount = 0
}
if (!eventState.mainSessionIdle) {
consecutiveCompleteChecks = 0
continue
}
if (eventState.currentTool !== null) {
consecutiveCompleteChecks = 0
continue
}
if (!eventState.hasReceivedMeaningfulWork) {
consecutiveCompleteChecks = 0
continue
}
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
consecutiveCompleteChecks++
if (consecutiveCompleteChecks >= requiredConsecutive) {
console.log(pc.green("\n\nAll tasks completed."))
return 0
}
} else {
consecutiveCompleteChecks = 0
}
}
return 130
}

View File

@@ -1,6 +1,5 @@
import pc from "picocolors"
import type { RunOptions, RunContext } from "./types"
import { checkCompletionConditions } from "./completion"
import { createEventState, processEvents, serializeError } from "./events"
import { loadPluginConfig } from "../../plugin-config"
import { createServerConnection } from "./server-connection"
@@ -8,11 +7,11 @@ import { resolveSession } from "./session-resolver"
import { createJsonOutputManager } from "./json-output"
import { executeOnCompleteHook } from "./on-complete-hook"
import { resolveRunAgent } from "./agent-resolver"
import { pollForCompletion } from "./poll-for-completion"
export { resolveRunAgent }
const POLL_INTERVAL_MS = 500
const DEFAULT_TIMEOUT_MS = 0
const DEFAULT_TIMEOUT_MS = 600_000
export async function run(options: RunOptions): Promise<number> {
process.env.OPENCODE_CLI_RUN_MODE = "true"
@@ -68,7 +67,9 @@ export async function run(options: RunOptions): Promise<number> {
const ctx: RunContext = { client, sessionID, directory, abortController }
const events = await client.event.subscribe()
const eventState = createEventState()
const eventProcessor = processEvents(ctx, events.stream, eventState)
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
() => {},
)
console.log(pc.dim("\nSending prompt..."))
await client.session.promptAsync({
@@ -80,11 +81,14 @@ export async function run(options: RunOptions): Promise<number> {
query: { directory },
})
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController)
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController)
await eventProcessor.catch(() => {})
cleanup()
// Abort the event stream to stop the processor
abortController.abort()
await eventProcessor
cleanup()
const durationMs = Date.now() - startTime
@@ -124,30 +128,3 @@ export async function run(options: RunOptions): Promise<number> {
}
}
async function pollForCompletion(
ctx: RunContext,
eventState: ReturnType<typeof createEventState>,
abortController: AbortController
): Promise<number> {
while (!abortController.signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
if (!eventState.mainSessionIdle) continue
if (eventState.mainSessionError) {
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
console.error(pc.yellow("Check if todos were completed before the error."))
return 1
}
if (!eventState.hasReceivedMeaningfulWork) continue
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
console.log(pc.green("\n\nAll tasks completed."))
return 0
}
}
return 130
}

View File

@@ -0,0 +1,113 @@
import * as p from "@clack/prompts"
import type { Option } from "@clack/prompts"
import type {
ClaudeSubscription,
DetectedConfig,
InstallConfig,
} from "./types"
import { detectedToInitialValues } from "./install-validators"
async function selectOrCancel<TValue extends Readonly<string | boolean | number>>(params: {
message: string
options: Option<TValue>[]
initialValue: TValue
}): Promise<TValue | null> {
if (!process.stdin.isTTY || !process.stdout.isTTY) return null
const value = await p.select<TValue>({
message: params.message,
options: params.options,
initialValue: params.initialValue,
})
if (p.isCancel(value)) {
p.cancel("Installation cancelled.")
return null
}
return value as TValue
}
export async function promptInstallConfig(detected: DetectedConfig): Promise<InstallConfig | null> {
const initial = detectedToInitialValues(detected)
const claude = await selectOrCancel<ClaudeSubscription>({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no", label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "yes", label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20", label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
initialValue: initial.claude,
})
if (!claude) return null
const openai = await selectOrCancel({
message: "Do you have an OpenAI/ChatGPT Plus subscription?",
options: [
{ value: "no", label: "No", hint: "Oracle will use fallback models" },
{ value: "yes", label: "Yes", hint: "GPT-5.2 for Oracle (high-IQ debugging)" },
],
initialValue: initial.openai,
})
if (!openai) return null
const gemini = await selectOrCancel({
message: "Will you integrate Google Gemini?",
options: [
{ value: "no", label: "No", hint: "Frontend/docs agents will use fallback" },
{ value: "yes", label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" },
],
initialValue: initial.gemini,
})
if (!gemini) return null
const copilot = await selectOrCancel({
message: "Do you have a GitHub Copilot subscription?",
options: [
{ value: "no", label: "No", hint: "Only native providers will be used" },
{ value: "yes", label: "Yes", hint: "Fallback option when native providers unavailable" },
],
initialValue: initial.copilot,
})
if (!copilot) return null
const opencodeZen = await selectOrCancel({
message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [
{ value: "no", label: "No", hint: "Will use other configured providers" },
{ value: "yes", label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." },
],
initialValue: initial.opencodeZen,
})
if (!opencodeZen) return null
const zaiCodingPlan = await selectOrCancel({
message: "Do you have a Z.ai Coding Plan subscription?",
options: [
{ value: "no", label: "No", hint: "Will use other configured providers" },
{ value: "yes", label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" },
],
initialValue: initial.zaiCodingPlan,
})
if (!zaiCodingPlan) return null
const kimiForCoding = await selectOrCancel({
message: "Do you have a Kimi For Coding subscription?",
options: [
{ value: "no", label: "No", hint: "Will use other configured providers" },
{ value: "yes", label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" },
],
initialValue: initial.kimiForCoding,
})
if (!kimiForCoding) return null
return {
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasOpenAI: openai === "yes",
hasGemini: gemini === "yes",
hasCopilot: copilot === "yes",
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
}
}

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