Compare commits

..

78 Commits

Author SHA1 Message Date
github-actions[bot]
77bdefbf9d release: v2.5.4 2025-12-26 07:27:44 +00:00
YeonGyu-Kim
6db44cdbf4 fix(ci): use heredoc for release notes to handle special characters
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 16:21:32 +09:00
YeonGyu-Kim
7c24f657e7 fix: include output tokens in context window usage calculation
Include output tokens from last response in getContextWindowUsage calculation.
Output tokens become part of next request's input (conversation history), so
they must be counted to avoid overestimating remainingTokens. This aligns with
preemptive-compaction's calculation which already includes output tokens correctly.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 16:11:21 +09:00
Sisyphus
1b427570c8 feat: add dynamic truncation to rules/readme/agents injectors (#257)
- Apply dynamic truncation to rules-injector, directory-readme-injector, and directory-agents-injector
- Add truncation notice encouraging users to read full content
- Save context window space while maintaining awareness of complete documentation
- Resolves #221 (part 1)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 15:38:28 +09:00
github-actions[bot]
109fb50028 @stevenvo has signed the CLA in code-yeongyu/oh-my-opencode#248 2025-12-26 05:16:23 +00:00
github-actions[bot]
e1a9e7e76a @codewithkenzo has signed the CLA in code-yeongyu/oh-my-opencode#253 2025-12-25 23:48:04 +00:00
YeonGyu-Kim
6160730f24 Revert "feat: add two-layer tool call validation system (proactive + reactive) (#249)"
This reverts commit 9bc2360d31.
2025-12-26 04:12:12 +09:00
YeonGyu-Kim
f9234a6a5e fix(ci): remove review events from sisyphus-agent for fork PR support
🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 03:54:07 +09:00
Sisyphus
27b5c1fda3 refactor: rename builder_enabled to default_builder_enabled and remove replace_build (#251)
- Renamed sisyphus_agent.builder_enabled to default_builder_enabled for clarity
- Removed sisyphus_agent.replace_build option entirely
- Default build agent is now always demoted to subagent mode when Sisyphus is enabled
- Updated schema and regenerated JSON schema
- Updated all documentation (EN, KO, JA, ZH-CN)

BREAKING CHANGE: Configuration migration required for users using builder_enabled or replace_build options.

Closes #250

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 03:37:50 +09:00
Sisyphus
9bc2360d31 feat: add two-layer tool call validation system (proactive + reactive) (#249)
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 03:36:27 +09:00
Sisyphus
ad2bd673c4 fix: show error messages when oh-my-opencode.json config fails to load (#242)
* fix: show error messages when oh-my-opencode.json config fails to load

- Add console.error output for config parse errors (syntax errors)
- Add console.error output for config validation errors (schema violations)
- Display helpful hints for JSON syntax errors
- List all validation errors clearly with proper formatting
- Errors now shown immediately regardless of hook configuration

Fixes #241

* refactor: replace console.error with toast notifications for config errors

- Replace console.error with ctx.client.tui.showToast() for better UX
- Show toast notifications for both syntax errors and validation errors
- Toast notifications persist for 10 seconds for visibility
- Display error details with bullet points for validation errors
- Include helpful hints for JSON syntax errors

This provides a more user-friendly notification system that integrates
with OpenCode's UI instead of just logging to console.

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 02:04:56 +09:00
github-actions[bot]
57ef5df932 @mylukin has signed the CLA in code-yeongyu/oh-my-opencode#240 2025-12-25 15:15:41 +00:00
Sisyphus
101299ebec fix: preserve model context across background agent handoffs (#229)
Fixes #191

This commit ensures that the user's selected model is preserved when
background tasks complete and notify their parent sessions.

Changes:
- Add parentModel field to BackgroundTask and LaunchInput interfaces
- Capture model context when launching background tasks
- Pass model context when notifying parent sessions after task completion

Impact:
- Users with OAuth providers (Google, Anthropic) will now have their
  model selection preserved across background task continuations
- Background agents no longer revert to hardcoded defaults

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 22:36:06 +09:00
YeonGyu-Kim
0b4821cfdf fix(cli): handle session.error in run command to prevent infinite wait
When session.error occurs with incomplete todos, the run command now:
- Captures the error via handleSessionError()
- Exits with code 1 instead of waiting indefinitely
- Shows clear error message to user

Previously, run command ignored session.error events, causing infinite
'Waiting: N todos remaining' loop when agent errors occurred.

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode
2025-12-25 22:34:41 +09:00
Sisyphus
9bfe7d8a1d fix(todo-continuation-enforcer): re-verify todos after countdown to prevent stale data injection (#239)
Fixes the race condition where the todo continuation hook would inject a
continuation prompt even when all todos had been completed during the
countdown period.

The root cause was that executeAfterCountdown() used stale todo data from
the initial session.idle check without re-verifying that incomplete todos
still existed after the countdown finished.

Changes:
- Add fresh todo verification in executeAfterCountdown() before prompt injection
- Use fresh todo data in the continuation prompt message
- Abort injection if no incomplete todos remain after countdown

This properly handles the case where:
1. session.idle fires (e.g., user enters shell mode in TUI)
2. Initial check finds incomplete todos, starts countdown
3. During countdown, todos get completed
4. Countdown ends, fresh check detects no incomplete todos
5. Hook aborts instead of injecting stale prompt

Fixes #234

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 22:14:02 +09:00
YeonGyu-Kim
d9cfc1ec97 debug(cli): add verbose event logging for CI debugging with message content and tool details
- logEventVerbose() logs all event types including message content, tool calls, and results
- Session tags distinguish main vs child sessions for multi-session tracking
- completion.ts error logging instead of silently swallowing API errors
- Helps diagnose realtime streaming behavior in CI environments

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 21:55:32 +09:00
YeonGyu-Kim
accedb59b7 debug(cli): add event logging to diagnose realtime streaming in CI
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 21:37:52 +09:00
YeonGyu-Kim
1bff5f7966 fix(sisyphus-agent): remove 30min timeout and add realtime output buffering
- Remove DEFAULT_TIMEOUT_MS (set to 0) to allow CI agent runs to complete without timeout
- Add stdbuf -oL -eL for unbuffered realtime output in GitHub Actions
- Update timeout logic to only set timeout when value > 0

This fixes CI agent runs that were timing out after 30 minutes and not showing realtime output.

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 21:32:27 +09:00
sisyphus-dev-ai
dacecfd3b2 chore: changes by sisyphus-dev-ai 2025-12-25 12:23:12 +00:00
YeonGyu-Kim
0399c1f4ed fix(sisyphus-agent): fix plan/build agent demotion logic in subagent mode
Previously, the condition '&&plannerEnabled&&replacePlan' caused agents to be
completely removed instead of demoted to subagent mode. The logic incorrectly
prevented agents from being added back as subagents when Sisyphus is enabled
with default config.

Fixed by simplifying to just 'replacePlan' condition - agents are now properly
demoted to subagent mode when replacement is enabled, which is the intended
behavior per the README.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 21:14:08 +09:00
Sisyphus
ebdce7972e Add Sigrid's review to all README versions (#238)
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 21:13:46 +09:00
YeonGyu-Kim
3de2a9f113 refactor(sisyphus-agent): rename Builder-Sisyphus to OpenCode-Builder and remove custom overrides
- Renamed agent from 'Builder-Sisyphus' to 'OpenCode-Builder' in schema and config
- Removed BUILD_SYSTEM_PROMPT and BUILD_PERMISSION custom overrides
- Now uses OpenCode's default build agent configuration exactly
- Simplified agent configuration to rely on OpenCode defaults

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 21:00:04 +09:00
YeonGyu-Kim
8897697887 fix(sisyphus-agent): prevent bash script breaking on quotes in comment body
Use environment variables instead of direct GitHub expression interpolation in bash script. This prevents the script from breaking when comment bodies contain quotes or special characters.

Variables like COMMENT_BODY, COMMENT_AUTHOR, COMMENT_ID_VAL are now passed via env: block instead of being interpolated directly into bash commands.

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 19:55:28 +09:00
Sisyphus
06b77643ba fix: ensure anthropic-auto-compact lock is always cleared (#232)
Fixes #200

## Problem
When executeCompact() recovery fails unexpectedly or gets interrupted,
the compactionInProgress lock is never cleared, permanently blocking both
auto-compact AND manual /compact for the session.

## Root Cause
- No try/finally around lock acquisition (line 261)
- Silent blocking when lock held - no user feedback
- Lock cleanup scattered across 7 manual deletion points
- Any unexpected exception bypasses cleanup, leaving lock stuck forever

## Solution
1. **Try/Finally Lock Guarantee**: Wrapped entire executeCompact body in
   try/finally block to guarantee lock cleanup, following the pattern
   used in preemptive-compaction hook

2. **User Feedback**: Added toast notification when compact attempt is
   blocked by existing lock, replacing silent failure with clear warning

3. **Removed Redundancy**: Removed 6 redundant manual lock deletions
   (kept only clearSessionState and finally block)

## Testing Evidence
 10/10 comprehensive tests pass
 Lock cleared on successful completion
 Lock cleared when summarize throws
 Lock cleared when revert throws
 Lock cleared when fixEmptyMessages executes
 Lock cleared when truncation is sufficient
 Lock cleared after max recovery attempts
 Lock cleared when toast fails
 Lock cleared when prompt_async throws
 Toast shown when lock already held
 TypeScript type check passes with zero errors

## Files Changed
- executor.ts: Added try/finally, toast notification, removed 6 redundant deletions
- executor.test.ts: New comprehensive test suite (10 tests, 13 assertions)

## Impact
- Severity: High → Fixed
- User Experience: No more stuck sessions requiring restart
- Behavior: Identical except lock now guaranteed to clear

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 19:36:22 +09:00
sisyphus-dev-ai
3b17ee9bd0 fix(sisyphus-agent): prevent duplicate build/plan agents when replacement enabled
- Filter out original 'build' and 'plan' agents when Builder-Sisyphus/Planner-Sisyphus are enabled with replacement
- Previously both agents could coexist even with replace_build/replace_plan: true
- Now only the replacement agent exists when both enabled and replacement flags are true
- Maintains backward compatibility for all configuration combinations

Fixes #231
2025-12-25 10:34:15 +00:00
YeonGyu-Kim
0734167516 fix(sisyphus-agent): add GitHub markdown rules to prevent broken code block rendering
The change adds a new "GitHub Markdown Rules" section to the sisyphus agent prompt that specifies:
- Code blocks MUST have exactly 3 backticks with language identifier
- Every opening ``` MUST have a matching closing ``` on its own line
- No trailing backticks or spaces after closing ```
- Inline code should use single backticks
- Lists inside code blocks break rendering

This fixes the issue where code blocks in GitHub comments weren't being closed properly, causing broken markdown rendering.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 19:26:43 +09:00
YeonGyu-Kim
419416deb8 fix(cli): correct SSE event format handling for real-time streaming
The SDK yields events directly as the payload without wrapping in { payload: ... }.
Changed processEvents to treat event as the payload directly instead of looking
for event.payload. This fixes the 'Waiting for completion...' hang in GitHub
Actions where all events were being silently skipped.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 19:17:18 +09:00
YeonGyu-Kim
695f9e03fc feat(cli): add real-time streaming support to run command with tool execution visibility
- Added message.part.updated event handling for incremental text streaming
- Added tool.execute event to display tool calls with input previews
- Added tool.result event to show truncated tool result outputs
- Enhanced EventState with lastPartText and currentTool tracking
- Defined MessagePartUpdatedProps, ToolExecuteProps, ToolResultProps types
- Updated event tests to cover new state fields

This enables the CLI run command to display real-time agent output similar to the native opencode run command, improving user experience with immediate feedback on tool execution.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 19:05:15 +09:00
YeonGyu-Kim
c804da43cf ulw 2025-12-25 19:05:15 +09:00
github-actions[bot]
f6f1a7c9b3 release: v2.5.3 2025-12-25 09:54:49 +00:00
YeonGyu-Kim
1e274eabe6 fix(session-manager): include all constants exports in storage test mocks
Add missing mock exports (SESSION_LIST_DESCRIPTION, SESSION_READ_DESCRIPTION,
SESSION_SEARCH_DESCRIPTION, SESSION_INFO_DESCRIPTION, SESSION_DELETE_DESCRIPTION,
TOOL_NAME_PREFIX) to fix test failures when other test files import from constants.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 18:46:39 +09:00
YeonGyu-Kim
9ba580e51f Fix session storage tests with proper module mocking for temp directories
Tests now properly mock the constants module before importing storage functions,
ensuring test data is read/written to temp directories instead of real paths.
This fixes test isolation issues and allows tests to run independently.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 18:43:52 +09:00
YeonGyu-Kim
48476e7257 fix(session-manager): add missing context parameter to tool execute functions
The tool() wrapper from @opencode-ai/plugin requires execute(args, context: ToolContext) signature. Updated all session-manager tool functions (session_list, session_read, session_search, session_info) to accept the context parameter, and updated corresponding tests with mockContext.

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 18:31:35 +09:00
YeonGyu-Kim
a8fdb78796 feat(sisyphus-agent): use local plugin reference and oh-my-opencode run command
- Build local oh-my-opencode before setup instead of downloading from npm
- Configure opencode to use file:// plugin reference pointing to local repo
- Replace opencode run with bun run dist/cli/index.js run command
- Remove delay on retry logic

This makes the sisyphus-agent workflow use the local plugin directly from the checked-out repo instead of downloading from npm.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 17:50:49 +09:00
YeonGyu-Kim
d311b74a5a feat(cli): add 'bunx oh-my-opencode run' command for persistent agent sessions (#228)
- Add new 'run' command using @opencode-ai/sdk to manage agent sessions
- Implement recursive descendant session checking (waits for ALL nested child sessions)
- Add completion conditions: all todos done + all descendant sessions idle
- Add SSE event processing for session state tracking
- Fix todo-continuation-enforcer to clean up session tracking
- Comprehensive test coverage with memory-safe test patterns

Unlike 'opencode run', this command ensures the agent completes all tasks
by recursively waiting for nested background agent sessions before exiting.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 17:46:38 +09:00
Sisyphus
ce4ceeefe8 feat(tools): add session management tools for OpenCode sessions (#227)
* feat(tools): add session management tools for OpenCode sessions

- Add session_list tool for listing sessions with filtering
- Add session_read tool for reading session messages and history
- Add session_search tool for full-text search across sessions
- Add session_info tool for session metadata inspection
- Add comprehensive tests for storage, utils, and tools
- Update documentation in AGENTS.md

Closes #132

* fix(session-manager): add Windows compatibility for storage paths

- Create shared/data-path.ts utility for cross-platform data directory resolution
- On Windows: uses %LOCALAPPDATA% (e.g., C:\Users\Username\AppData\Local)
- On Unix: uses $XDG_DATA_HOME or ~/.local/share (XDG Base Directory spec)
- Update session-manager/constants.ts to use getOpenCodeStorageDir()
- Update hook-message-injector/constants.ts to use same utility
- Remove dependency on xdg-basedir package in session-manager
- Follows existing pattern from auto-update-checker for consistency

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 17:04:16 +09:00
Sisyphus
41a7d032e1 feat: add Builder-Sisyphus agent with independent toggle options (#214)
* feat: add Builder-Sisyphus agent with independent toggle options

- Add Builder-Sisyphus agent (disabled by default) for build mode
- Implement independent configuration for Builder/Planner-Sisyphus agents
- Add replace_build and replace_plan options to control agent demotion
- Update schema to support new configuration options
- Update README with comprehensive configuration documentation

Addresses #212: Users can now keep default OpenCode build mode alongside Builder-Sisyphus

* docs: add OpenCode permalinks and update multilingual README files

- Add OpenCode source code permalinks to build-prompt.ts (@see tags)
- Update README.ja.md with Builder-Sisyphus documentation
- Update README.ko.md with Builder-Sisyphus documentation
- Update README.zh-cn.md with Builder-Sisyphus documentation

Permalinks reference:
- Build mode switch: build-switch.txt
- Build agent definition: agent.ts#L118-L125
- Default permissions: agent.ts#L57-L68

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 17:00:07 +09:00
Sisyphus
62c3559346 feat: enable dynamic truncation for all tool outputs by default (#226)
- Change truncate_all_tool_outputs default from false to true
- Update schema.ts to use .default(true) instead of .optional()
- Update documentation in all README files (EN, KO, JA, ZH-CN)
- Rebuild JSON schema with new default value

This prevents prompts from becoming too long by dynamically truncating
all tool outputs based on context window usage. Users can opt-out by
setting experimental.truncate_all_tool_outputs to false.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:55:58 +09:00
Sisyphus
7d09c48ae8 Enable dynamic tool output truncation by default (#225)
- Changed truncate_all_tool_outputs default from false to true
- Updated schema documentation to reflect new default
- Added entry in README experimental features table
- Regenerated JSON schema

This prevents prompts from becoming too long by dynamically
truncating output from all tool calls, not just whitelisted ones.
Feature is experimental and enabled by default to help manage
context window usage across all tools.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:26:27 +09:00
github-actions[bot]
08080a7b51 release: v2.5.2 2025-12-25 07:21:37 +00:00
Sisyphus
52481f6ad2 fix: reduce Opus 4.5 context window to 190k for safety (#222)
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:08:31 +09:00
Sisyphus
d17bd48c4b fix(sisyphus): eliminate casual status update acknowledgments (#220)
- Add explicit 'No Status Updates' section prohibiting casual acknowledgments
- Strengthen 'Be Concise' section with immediate work directive
- Clarify Oracle announcement as exceptional case
- Reinforce no-announcement rule in Pre-Implementation section

Resolves #219

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:05:23 +09:00
sisyphus-dev-ai
229687e3c7 chore: changes by sisyphus-dev-ai 2025-12-25 06:58:15 +00:00
YeonGyu-Kim
0f03f5aad4 refactor: make TTY detection explicit per review feedback (#218)
🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 15:45:28 +09:00
Sisyphus
2bad1b5c95 feat: add label management to Sisyphus workflow (#215)
- Add 'sisyphus: working' label when Sisyphus starts working on an issue/PR
- Remove label when work completes (success or failure)
- Label operations use gh CLI with idempotent commands
- Handles both issues and PRs with proper conditional logic
- Uses || true for error handling to prevent workflow failures

Closes #202

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 15:37:48 +09:00
YeonGyu-Kim
8d9b68d84b Prevent premature exit in non-interactive mode when tasks pending (#216) (#217)
Detects non-interactive environments (CI, opencode run) and prevents session idle when:
- Background tasks are still running
- Incomplete todos remain in the queue

Changes:
- Add isNonInteractive() detector for CI/headless environment detection
- Export detector from non-interactive-env hook module
- Enhance todo-continuation-enforcer to inject prompts BEFORE session.idle
- Pass BackgroundManager to todo-continuation-enforcer for task status checks

This fix prevents `opencode run` from exiting prematurely when work is pending.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 15:27:34 +09:00
github-actions[bot]
470f170a8c @code-yeongyu has signed the CLA in code-yeongyu/oh-my-opencode#217 2025-12-25 06:19:36 +00:00
vasant
84b1634a7b fix: remove api:antigravity field causing auth issues (#211)
The api: "antigravity" field being added to opencode.json causes
authentication failures with the antigravity plugin.

Fixes: NoeFabris/opencode-antigravity-auth#49
2025-12-25 14:31:09 +09:00
YeonGyu-Kim
fccaaf7676 feat(claude-code-hooks): add PreCompact hook support for experimental.session.compacting event (#139) 2025-12-25 14:29:27 +09:00
Sisyphus
ac3c21fe90 feat(sisyphus): emphasize GitHub workflow and PR creation (#207) 2025-12-25 14:29:08 +09:00
Sisyphus
d70e077c56 fix: preserve backticks in Sisyphus GitHub comments (#203) 2025-12-25 13:29:34 +09:00
github-actions[bot]
9913674fe9 @tsanva has signed the CLA in code-yeongyu/oh-my-opencode#210 2025-12-25 00:15:30 +00:00
github-actions[bot]
6b34373dd6 Creating file for storing CLA Signatures 2025-12-24 22:05:01 +00:00
Sisyphus
c16194fb9e fix: update CLA workflow branch from 'main' to 'dev' (#206) 2025-12-25 07:04:53 +09:00
YeonGyu-Kim
a6ee5a7553 fix: Notification hook works weirdly for subagent sessions (#189)
* fix: Notification hook works weirdly for subagent sessions

- Added mainSessionID check to prevent notifications in subagent sessions
- Only trigger notifications for main session when waiting for user input
- Added comprehensive tests to validate the fix

Issue: https://github.com/code-yeongyu/oh-my-opencode/issues/92

* chore: changes by sisyphus-dev-ai

---------

Co-authored-by: codingsh <codingsh@pm.me>
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 06:58:03 +09:00
YeonGyu-Kim
56ac0ae417 add agent 2025-12-25 06:49:30 +09:00
YeonGyu-Kim
2eeff349c0 Update GitHub Sponsors username in FUNDING.yml 2025-12-25 05:20:36 +09:00
YeonGyu-Kim
4283ac9628 badge 2025-12-25 05:10:34 +09:00
YeonGyu-Kim
b19cc0b5ef docs: add GitHub sponsor badge to all README files
🤖 Generated with assistance of OhMyOpenCode
2025-12-25 04:54:32 +09:00
YeonGyu-Kim
520343e059 make prompt append available 2025-12-25 01:49:16 +09:00
YeonGyu-Kim
1884658394 Introducing new license, SUL 2025-12-25 00:31:27 +09:00
YeonGyu-Kim
ace15cfe39 docs(cla): add Contributor License Agreement and GitHub Actions workflow 2025-12-24 22:33:29 +09:00
YeonGyu-Kim
dc9e35f18b docs: add hierarchical AGENTS.md for hooks, tools, features
Create directory-specific knowledge bases for high-complexity directories:
- src/hooks/AGENTS.md: Documents 21 hooks across 86 files
- src/tools/AGENTS.md: Documents 11 LSP tools, AST-Grep, MCP, background tasks (50 files)
- src/features/AGENTS.md: Documents 6 Claude Code compatibility features (24 files)

Root AGENTS.md updated to reference these specialized guides while maintaining project overview. Enables better navigation and reduces cognitive load for developers working in specific areas.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 17:11:18 +09:00
YeonGyu-Kim
0172241199 docs: restructure reviews section and improve quote formatting
Move user reviews to top of documents and clean up citation formatting with dash separators across all localized README files (EN, JA, KO, ZH-CN).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 16:54:27 +09:00
Junho Yeo
f8e1990df4 docs: Enlarge Discord and X badges for better display 2025-12-24 14:50:18 +09:00
Junho Yeo
1a0ab6fb02 docs: add Discord/X notice to localized READMEs 2025-12-24 14:43:22 +09:00
Junho Yeo
f14bb34fc5 docs: update README notice with Discord community and styled badges 2025-12-24 14:43:19 +09:00
github-actions[bot]
1f9f907ccf release: v2.5.1 2025-12-23 17:10:11 +00:00
YeonGyu-Kim
6ee761d978 feat(config): add user reviews to docs and improve Antigravity provider config
- Add user reviews section to READMEs (EN, KO, JA, ZH-CN)
- Update Antigravity request.ts: change default model from gemini-3-pro-preview to gemini-3-pro-high
- Enhance config-manager.ts: add full model specs (name, limit, modalities) to provider config
- Add comprehensive test suite for config-manager (config-manager.test.ts)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 02:08:01 +09:00
YeonGyu-Kim
fd8e62fba3 fix(publish): include CLI build step to ensure dist/cli/index.js is packaged in npm release
The CLI module was missing from the npm package because the publish workflow
did not include the 'bun build src/cli/index.ts' step. This caused 'bunx oh-my-opencode
install' to fail with missing dist/cli/index.js at runtime. Added explicit CLI build
and verification step to prevent this regression.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 02:06:57 +09:00
github-actions[bot]
f5c7f430c2 release: v2.5.0 2025-12-23 14:43:57 +00:00
YeonGyu-Kim
b8e70f9529 docs: update LLM agent install guide to use CLI installer
Related to #153

Co-authored-by: Taegeon Alan Go <32065632+gtg7784@users.noreply.github.com>

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 22:42:43 +09:00
YeonGyu-Kim
5dbd5ac6b1 feat(cli): add interactive install command
Related to #153

Co-authored-by: Taegeon Alan Go <32065632+gtg7784@users.noreply.github.com>

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 22:42:31 +09:00
YeonGyu-Kim
908521746f fix(publish): include schema.json in release commit
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 22:25:30 +09:00
github-actions[bot]
1e3cf4ea1b release: v2.4.7 2025-12-23 08:27:18 +00:00
YeonGyu-Kim
6c0b59dbd6 Fix tool_result recording for call_omo_agent to include output in transcripts (#177)
- Check if metadata is empty before using it
- Wrap output.output in structured object when metadata is missing
- Ensures plugin tools (call_omo_agent, background_task, task) that return strings are properly recorded in transcripts instead of empty {}

🤖 Generated with assistance of OhMyOpenCode
2025-12-23 15:35:17 +09:00
YeonGyu-Kim
83c1b8d5a4 Preserve agent context in preemptive compaction's continue message
When sending the 'Continue' message after compaction, now includes the
original agent parameter from the stored message. Previously, the Continue
message was sent without the agent parameter, causing OpenCode to use the
default 'build' agent instead of preserving the original agent context
(e.g., Sisyphus).

Implementation:
- Get messageDir using getMessageDir(sessionID)
- Retrieve storedMessage using findNearestMessageWithFields
- Pass agent: storedMessage?.agent to promptAsync body

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 15:17:51 +09:00
YeonGyu-Kim
56deaa3a3e Enable keyword detection on first message using direct parts transformation
Previously, first messages were skipped entirely to avoid interfering with title generation.
Now, keywords detected on the first message are injected directly into the message parts
instead of using the hook message injection system, allowing keywords like 'ultrawork' to
activate on the first message of a session.

This change:
- Removes the early return that skipped first message keyword detection
- Moves keyword context generation before the isFirstMessage check
- For first messages: transforms message parts directly by prepending keyword context
- For subsequent messages: maintains existing hook message injection behavior

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 14:25:49 +09:00
82 changed files with 6330 additions and 1051 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: code-yeongyu
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -120,15 +120,19 @@ jobs:
echo "Updating existing draft release..."
gh release edit next \
--title "Upcoming Changes 🍿" \
--notes "${{ steps.notes.outputs.notes }}" \
--draft
--notes-file - \
--draft <<'EOF'
${{ steps.notes.outputs.notes }}
EOF
else
echo "Creating new draft release..."
gh release create next \
--title "Upcoming Changes 🍿" \
--notes "${{ steps.notes.outputs.notes }}" \
--notes-file - \
--draft \
--target ${{ github.sha }}
--target ${{ github.sha }} <<'EOF'
${{ steps.notes.outputs.notes }}
EOF
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

41
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
cla:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
path-to-signatures: 'signatures/cla.json'
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
branch: 'dev'
allowlist: bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
custom-notsigned-prcomment: |
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).
**To sign the CLA**, please comment on this PR with:
```
I have read the CLA Document and I hereby sign the CLA
```
This is a one-time requirement. Once signed, all your future contributions will be automatically accepted.
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: |
All contributors have signed the CLA. Thank you! ✅
lock-pullrequest-aftermerge: false

View File

@@ -103,9 +103,10 @@ jobs:
- name: Build
run: |
echo "=== Running bun build ==="
bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi
echo "=== bun build exit code: $? ==="
echo "=== Running bun build (main) ==="
bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi
echo "=== Running bun build (CLI) ==="
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm
echo "=== Running tsc ==="
tsc --emitDeclarationOnly
echo "=== Running build:schema ==="
@@ -113,8 +114,12 @@ jobs:
- name: Verify build output
run: |
echo "=== dist/ contents ==="
ls -la dist/
echo "=== dist/cli/ contents ==="
ls -la dist/cli/
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
test -f dist/cli/index.js || (echo "ERROR: dist/cli/index.js not found!" && exit 1)
- name: Publish
run: bun run script/publish.ts

372
.github/workflows/sisyphus-agent.yml vendored Normal file
View File

@@ -0,0 +1,372 @@
name: Sisyphus Agent
on:
workflow_dispatch:
inputs:
prompt:
description: "Custom prompt"
required: false
# Only issue_comment works for fork PRs (secrets available)
# pull_request_review/pull_request_review_comment do NOT get secrets for fork PRs
issue_comment:
types: [created]
jobs:
agent:
runs-on: ubuntu-latest
# @sisyphus-dev-ai mention only (maintainers, exclude self)
if: |
github.event_name == 'workflow_dispatch' ||
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
github.event.comment.user.login != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
# Minimal default GITHUB_TOKEN permissions
permissions:
contents: read
steps:
# Checkout with sisyphus-dev-ai's PAT
- uses: actions/checkout@v5
with:
token: ${{ secrets.GH_PAT }}
fetch-depth: 0
# Git config - commits as sisyphus-dev-ai
- name: Configure Git as sisyphus-dev-ai
run: |
git config user.name "sisyphus-dev-ai"
git config user.email "sisyphus-dev-ai@users.noreply.github.com"
# gh CLI auth as sisyphus-dev-ai
- name: Authenticate gh CLI as sisyphus-dev-ai
run: |
echo "${{ secrets.GH_PAT }}" | gh auth login --with-token
gh auth status
- name: Ensure tmux is available (Linux)
if: runner.os == 'Linux'
run: |
set -euo pipefail
if ! command -v tmux >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y --no-install-recommends tmux
fi
tmux -V
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Build local oh-my-opencode
- name: Build oh-my-opencode
run: |
bun install
bun run build
# Install OpenCode + configure local plugin + auth in single step
- name: Setup OpenCode with oh-my-opencode
env:
OPENCODE_AUTH_JSON: ${{ secrets.OPENCODE_AUTH_JSON }}
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
export PATH="$HOME/.opencode/bin:$PATH"
# Install OpenCode (skip if cached)
if ! command -v opencode &>/dev/null; then
for i in 1 2 3; do
echo "Attempt $i: Installing OpenCode..."
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
bash /tmp/opencode-install.sh && break
fi
echo "Download corrupted, retrying in 5s..."
done
fi
opencode --version
# Run local oh-my-opencode install (uses built dist)
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no
# Override plugin to use local file reference
OPENCODE_JSON=~/.config/opencode/opencode.json
REPO_PATH=$(pwd)
jq --arg path "file://$REPO_PATH/src/index.ts" '
.plugin = [.plugin[] | select(. != "oh-my-opencode")] + [$path]
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
OPENCODE_JSON=~/.config/opencode/opencode.json
jq --arg baseURL "$ANTHROPIC_BASE_URL" --arg apiKey "$ANTHROPIC_API_KEY" '
.provider.anthropic = {
"name": "Anthropic",
"npm": "@ai-sdk/anthropic",
"options": {
"baseURL": $baseURL,
"apiKey": $apiKey
},
"models": {
"claude-opus-4-5": {
"id": "claude-opus-4-5-20251101",
"name": "Opus 4.5",
"limit": { "context": 190000, "output": 64000 },
"options": { "effort": "high" }
},
"claude-opus-4-5-high": {
"id": "claude-opus-4-5-20251101",
"name": "Opus 4.5 High",
"limit": { "context": 190000, "output": 128000 },
"options": { "effort": "high", "thinking": { "type": "enabled", "budgetTokens": 64000 } }
},
"claude-sonnet-4-5": {
"id": "claude-sonnet-4-5-20250929",
"name": "Sonnet 4.5",
"limit": { "context": 200000, "output": 64000 }
},
"claude-sonnet-4-5-high": {
"id": "claude-sonnet-4-5-20250929",
"name": "Sonnet 4.5 High",
"limit": { "context": 200000, "output": 128000 },
"options": { "thinking": { "type": "enabled", "budgetTokens": 64000 } }
},
"claude-haiku-4-5": {
"id": "claude-haiku-4-5-20251001",
"name": "Haiku 4.5",
"limit": { "context": 200000, "output": 64000 }
}
}
}
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
OMO_JSON=~/.config/opencode/oh-my-opencode.json
PROMPT_APPEND=$(cat << 'PROMPT_EOF'
## GitHub Actions Environment
You are `sisyphus-dev-ai` in GitHub Actions.
### CRITICAL: GitHub Comments = Your ONLY Output
User CANNOT see console. Post everything via `gh issue comment` or `gh pr comment`.
### Comment Formatting (CRITICAL)
**ALWAYS use heredoc syntax for comments containing code references, backticks, or multiline content:**
```bash
gh issue comment <number> --body "$(cat <<'EOF'
Your comment with `backticks` and code references preserved here.
Multiple lines work perfectly.
EOF
)"
```
**NEVER use direct quotes with backticks** (shell will interpret them as command substitution):
```bash
# WRONG - backticks disappear:
gh issue comment 123 --body "text with `code`"
# CORRECT - backticks preserved:
gh issue comment 123 --body "$(cat <<'EOF'
text with `code`
EOF
)"
```
### GitHub Markdown Rules (MUST FOLLOW)
**Code blocks MUST have EXACTLY 3 backticks and language identifier:**
- CORRECT: ` ```bash ` ... ` ``` `
- WRONG: ` ``` ` (no language), ` ```` ` (4 backticks), ` `` ` (2 backticks)
**Every opening ` ``` ` MUST have a closing ` ``` ` on its own line:**
```
```bash
code here
```
```
**NO trailing backticks or spaces after closing ` ``` `**
**For inline code, use SINGLE backticks:** `code` not ```code```
**Lists inside code blocks break rendering - avoid them or use plain text**
### Rules
- EVERY response = GitHub comment (use heredoc for proper escaping)
- Code changes = PR (never push main/master)
- Setup: bun install first
- Acknowledge immediately, report when done
### Git Config
- user.name: sisyphus-dev-ai
- user.email: sisyphus-dev-ai@users.noreply.github.com
PROMPT_EOF
)
jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
mkdir -p ~/.local/share/opencode
echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json
chmod 600 ~/.local/share/opencode/auth.json
cat "$OPENCODE_JSON"
# Collect context
- name: Collect Context
id: context
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
EVENT_NAME: ${{ github.event_name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
COMMENT_ID_VAL: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: |
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
ISSUE_NUM="$ISSUE_NUMBER"
AUTHOR="$COMMENT_AUTHOR"
COMMENT_ID="$COMMENT_ID_VAL"
# Check if PR or Issue
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
else
echo "type=issue" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
fi
fi
echo "comment<<EOF" >> $GITHUB_OUTPUT
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
# Add :eyes: reaction (as sisyphus-dev-ai)
- name: Add eyes reaction
if: steps.context.outputs.comment_id != ''
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
-X POST -f content="eyes" || true
- name: Add working label
if: steps.context.outputs.number != ''
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
gh label create "sisyphus: working" \
--repo "${{ github.repository }}" \
--color "fcf2e1" \
--description "Sisyphus is currently working on this" \
--force || true
if [[ "${{ steps.context.outputs.type }}" == "pr" ]]; then
gh pr edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--add-label "sisyphus: working" || true
else
gh issue edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--add-label "sisyphus: working" || true
fi
- name: Run oh-my-opencode
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
USER_COMMENT: ${{ steps.context.outputs.comment }}
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
REPO_NAME: ${{ github.repository }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
export PATH="$HOME/.opencode/bin:$PATH"
PROMPT=$(cat <<'PROMPT_EOF'
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
## Context
- Type: TYPE_PLACEHOLDER
- Number: #NUMBER_PLACEHOLDER
- Repository: REPO_PLACEHOLDER
- Default Branch: BRANCH_PLACEHOLDER
## User's Request
COMMENT_PLACEHOLDER
---
First, acknowledge with `gh issue comment NUMBER_PLACEHOLDER --body "👋 Hey @AUTHOR_PLACEHOLDER! I'm on it..."`
Then write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
PROMPT_EOF
)
PROMPT="${PROMPT//AUTHOR_PLACEHOLDER/$COMMENT_AUTHOR}"
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
stdbuf -oL -eL bun run dist/cli/index.js run "$PROMPT"
# Push changes (as sisyphus-dev-ai)
- name: Push changes
if: always()
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
if [[ -n "$(git status --porcelain)" ]]; then
git add -A
git commit -m "chore: changes by sisyphus-dev-ai" || true
fi
BRANCH=$(git branch --show-current)
if [[ "$BRANCH" != "main" && "$BRANCH" != "master" ]]; then
git push origin "$BRANCH" || true
fi
- name: Update reaction and remove label
if: always()
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
if [[ -n "${{ steps.context.outputs.comment_id }}" ]]; then
REACTION_ID=$(gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
--jq '.[] | select(.content == "eyes" and .user.login == "sisyphus-dev-ai") | .id' | head -1)
if [[ -n "$REACTION_ID" ]]; then
gh api -X DELETE "/repos/${{ github.repository }}/reactions/${REACTION_ID}" || true
fi
gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
-X POST -f content="+1" || true
fi
if [[ -n "${{ steps.context.outputs.number }}" ]]; then
if [[ "${{ steps.context.outputs.type }}" == "pr" ]]; then
gh pr edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--remove-label "sisyphus: working" || true
else
gh issue edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--remove-label "sisyphus: working" || true
fi
fi

0
= Normal file
View File

122
AGENTS.md
View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2025-12-22T02:23:00+09:00
**Commit:** aad7a72
**Generated:** 2025-12-24T17:07:00+09:00
**Commit:** 0172241
**Branch:** dev
## OVERVIEW
@@ -13,14 +13,14 @@ OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orc
```
oh-my-opencode/
├── src/
│ ├── agents/ # AI agents (Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker)
│ ├── hooks/ # 21 lifecycle hooks (comment-checker, rules-injector, keyword-detector, etc.)
│ ├── tools/ # LSP (11), AST-Grep, Grep, Glob, background-task, look-at, skill, slashcommand, interactive-bash, call-omo-agent
│ ├── mcp/ # MCP servers (context7, websearch_exa, grep_app)
│ ├── features/ # Background agent, Claude Code loaders (agent, command, skill, mcp, session-state), hook-message-injector
│ ├── agents/ # AI agents (7): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker
│ ├── hooks/ # 21 lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, etc. - see src/tools/AGENTS.md
│ ├── mcp/ # MCP servers: context7, websearch_exa, grep_app
│ ├── features/ # Claude Code compatibility - see src/features/AGENTS.md
│ ├── config/ # Zod schema, TypeScript types
│ ├── auth/ # Google Antigravity OAuth
│ ├── shared/ # Utilities (deep-merge, pattern-matcher, logger, etc.)
│ ├── auth/ # Google Antigravity OAuth (antigravity/)
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc.
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
├── assets/ # JSON schema
@@ -31,12 +31,12 @@ oh-my-opencode/
| Task | Location | Notes |
|------|----------|-------|
| Add new agent | `src/agents/` | Create .ts file, add to builtinAgents in index.ts, update types.ts |
| Add new hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
| Add new tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
| Add MCP server | `src/mcp/` | Create config, add to index.ts |
| Modify LSP behavior | `src/tools/lsp/` | client.ts for connection, tools.ts for handlers |
| AST-Grep patterns | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
| Add MCP | `src/mcp/` | Create config, add to index.ts |
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google models |
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
@@ -50,98 +50,72 @@ oh-my-opencode/
- **Build**: Dual output - `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern - `export * from "./module"` in index.ts
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
- **Tool structure**: Each tool has index.ts, types.ts, constants.ts, tools.ts, utils.ts
- **Tool structure**: index.ts, types.ts, constants.ts, tools.ts, utils.ts
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA pattern)
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA)
## ANTI-PATTERNS (THIS PROJECT)
- **npm/yarn**: Use bun exclusively
- **@types/node**: Use bun-types
- **Bash file operations**: Never use mkdir/touch/rm/cp/mv for file creation in code
- **Generic AI aesthetics**: No Space Grotesk, avoid typical AI-generated UI patterns
- **Direct bun publish**: Use GitHub Actions workflow_dispatch only (OIDC provenance)
- **Local version bump**: Version managed by CI workflow, never modify locally
- **Bash file ops**: Never mkdir/touch/rm/cp/mv for file creation in code
- **Direct bun publish**: GitHub Actions workflow_dispatch only (OIDC provenance)
- **Local version bump**: Version managed by CI workflow
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
- **Rush completion**: Never mark tasks complete without verification
- **Interrupting work**: Complete tasks fully before stopping
- **Over-exploration**: Stop searching when sufficient context found
## UNIQUE STYLES
- **Platform handling**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
- **Optional props**: Extensive use of `?` for optional interface properties
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
- **Optional props**: Extensive `?` for optional interface properties
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
- **Error handling**: Consistent try/catch with async/await in all tools
- **Agent tools restriction**: Use `tools: { include: [...] }` or `tools: { exclude: [...] }`
- **Error handling**: Consistent try/catch with async/await
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
- **Temperature**: Most agents use `0.1` for consistency
- **Hook naming**: `createXXXHook` function naming convention
- **Date references**: NEVER use 2024 in code/prompts (use current year)
- **Hook naming**: `createXXXHook` function convention
## AGENT MODELS
| Agent | Model | Purpose |
|-------|-------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator, team leader |
| oracle | openai/gpt-5.2 | Strategic advisor, code review, architecture |
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs lookup, GitHub examples |
| explore | opencode/grok-code | Fast codebase exploration, file patterns |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation, design-focused |
| document-writer | google/gemini-3-pro-preview | Technical documentation |
| multimodal-looker | google/gemini-3-flash | PDF/image/diagram analysis |
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
| oracle | openai/gpt-5.2 | Strategic advisor, code review |
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
| explore | opencode/grok-code | Fast codebase exploration |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
| document-writer | google/gemini-3-pro-preview | Technical docs |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
## COMMANDS
```bash
# Type check
bun run typecheck
# Build (ESM + declarations + schema)
bun run build
# Clean + Build
bun run rebuild
# Build schema only
bun run build:schema
# Run tests
bun test
bun run typecheck # Type check
bun run build # ESM + declarations + schema
bun run rebuild # Clean + Build
bun run build:schema # Schema only
bun test # Run tests
```
## DEPLOYMENT
**GitHub Actions workflow_dispatch only**
1. package.json version NOT modified locally (auto-bumped by workflow)
1. Never modify package.json version locally
2. Commit & push changes
3. Trigger `publish` workflow manually:
- `bump`: major | minor | patch
- `version`: (optional) specific version override
3. Trigger `publish` workflow: `gh workflow run publish -f bump=patch`
```bash
# Trigger via CLI
gh workflow run publish -f bump=patch
# Check status
gh run list --workflow=publish
```
**Critical**:
- Never run `bun publish` directly (OIDC provenance issue)
- Never bump version locally
**Critical**: Never `bun publish` directly. Never bump version locally.
## CI PIPELINE
- **ci.yml**: Parallel test/typecheck jobs, build verification, auto-commit schema changes on master
- **publish.yml**: Manual workflow_dispatch, version bump, changelog generation, OIDC npm publishing
- Schema auto-commit prevents build drift
- Draft release creation on dev branch
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
## NOTES
- **Testing**: Bun native test framework (`bun test`), BDD-style with `#given/#when/#then` comments
- **OpenCode version**: Requires >= 1.0.150 (earlier versions have config bugs)
- **Multi-language docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
- **Config locations**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
- **Schema autocomplete**: Add `$schema` field in config for IDE support
- **Trusted dependencies**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`
- **OpenCode**: Requires >= 1.0.150
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker

58
CLA.md Normal file
View File

@@ -0,0 +1,58 @@
# Contributor License Agreement
Thank you for your interest in contributing to oh-my-opencode ("Project"), owned by YeonGyu Kim ("Owner").
By signing this Contributor License Agreement ("Agreement"), you agree to the following terms:
## 1. Definitions
- **"Contribution"** means any original work of authorship, including any modifications or additions to existing work, that you submit to the Project.
- **"Submit"** means any form of communication sent to the Project, including but not limited to pull requests, issues, commits, and documentation changes.
## 2. Grant of Rights
By submitting a Contribution, you grant the Owner:
1. **Copyright License**: A perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your Contributions and such derivative works.
2. **Patent License**: A perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Contribution.
3. **Relicensing Rights**: The right to relicense the Contribution under any license, including proprietary licenses, without requiring additional permission from you.
## 3. Representations
You represent that:
1. You are legally entitled to grant the above licenses.
2. Each Contribution is your original creation or you have sufficient rights to submit it.
3. Your Contribution does not violate any third party's intellectual property rights.
4. If your employer has rights to intellectual property that you create, you have received permission to make Contributions on behalf of that employer.
## 4. No Obligation
You understand that:
1. The Owner is not obligated to use or include your Contribution.
2. The decision to include any Contribution is at the sole discretion of the Owner.
3. You are not entitled to any compensation for your Contributions.
## 5. Future License Changes
You acknowledge and agree that:
1. The Project may change its license in the future.
2. Your Contributions may be distributed under a different license than the one in effect at the time of your Contribution.
3. This includes, but is not limited to, relicensing under source-available or proprietary licenses.
## 6. Miscellaneous
- This Agreement is governed by the laws of the Republic of Korea.
- This Agreement represents the entire agreement between you and the Owner concerning Contributions.
---
## How to Sign
By submitting a pull request to this repository, you agree to the terms of this Contributor License Agreement. The CLA Assistant bot will automatically track your agreement.
If you have any questions, please open an issue or contact the Owner.

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 YeonGyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

82
LICENSE.md Normal file
View File

@@ -0,0 +1,82 @@
# License
Portions of this software are licensed as follows:
- All third party components incorporated into the oh-my-opencode Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensor's trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The "licensor" is the entity offering these terms.
The "software" is the software the licensor makes available under these terms, including any portion of it.
"You" refers to the individual or entity agreeing to these terms.
"Your company" is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
"Your license" is the license granted to you for the software under these terms.
"Use" means anything you do with the software requiring your license.
"Trademark" means trademarks, service marks, and similar rights.

View File

@@ -1,4 +1,13 @@
お知らせ: oh-my-opencodeに関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、[@justsisyphus](https://x.com/justsisyphus)が代わりに管理しています。
> [!NOTE]
>
> *「私はエージェントが生成したコードと人間が書いたコードを区別できない、しかしはるかに多くのことを達成できる世界を作り、ソフトウェア革命を起こすことを目指しています。私はこの旅に個人的な時間、情熱、そして資金を注ぎ込んできましたし、これからもそうし続けます。」*
>
> 一緒に歩みましょう!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -23,7 +32,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -31,6 +40,26 @@
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## ユーザーレビュー
> "人間が3ヶ月かかる仕事をClaude Codeが7日でやるなら、Sisyphusは1時間でやります。タスクが完了するまでただ動き続ける。It is a discipline agent." — B, Quant Researcher
> "Oh My Opencodeを使って、たった1日で8000個のeslint警告を解消しました" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "これをコアに取り入れて彼を採用すべきです。マジで。本当に、本当に、本当に良いです" — Henning Kilset
> "@yeon_gyu_kimを説得できるなら雇うべきです。彼はopencodeに革命を起こしました" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "やばい、これマジで本物だ @androolloyd oh my opencode 最高すぎる" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "oh-my-opencodeを使ってください、もう戻れませんよ" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目次
- [Oh My OpenCode](#oh-my-opencode)
@@ -157,76 +186,23 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
あなたがユーザーの oh-my-opencode 設定を支援する LLM エージェントなら、以下の手順に従ってください:
### ステップ 0: セットアップについてユーザーに尋ねる
### ステップ 0: サブスクリプション情報を確認
OpenCode は様々なモデルをサポートしており、当プラグインは様々な最先端 LLM の使用を提案しています。そのため、ユーザーに以下の質問をする必要があります。
以下の 3 つの質問をして CLI オプションを決定してください:
**重要**: ユーザーがどのプロバイダーを持っているか追跡してください。ユーザーが Claude を持っていると確認した場合のみ、`anthropic/claude-opus-4-5` をフォールバックとして使用できます。Claude がない場合は、代わりに `opencode/big-pickle` をフォールバックとして使用してください。
1. **Claude Pro/Max のサブスクリプションをお持ちですか?**
- **はい**: **max20 (20x モード) を使用していますか?**
- **max20** → `--claude=max20`
- **max20 ではない** → `--claude=yes`
- **いいえ** → `--claude=no`
1. **Claude Pro/Max のサブスクリプションをお持ちですか?** お持ちでない場合、メインオーケストレーターモデルとして 'opencode/big-pickle' (GLM 4.6 として知られる) を使用することになります (非推奨)。
- ユーザーが**はい**と答えた場合、追加質問: **max20 (20x モード) を使用していますか?**
- **「はい」 (max20)**: ToDo 追加: "ユーザーの Claude Pro/Max 認証設定" — librarian に Claude Sonnet 4.5 をフル使用。**記憶: Claude がフォールバックとして使用可能。**
- **「いいえ」 (max20 ではない)**: ToDo 追加: "ユーザーの Claude Pro/Max 認証設定"、そして `~/.config/opencode/oh-my-opencode.json` に以下をマージ。**記憶: Claude がフォールバックとして使用可能。**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- ユーザーが**「いいえ」** (Claude サブスクリプションなし) と答えた場合、`~/.config/opencode/oh-my-opencode.json` に以下を保存。**記憶: Claude は使用不可 — すべてのフォールバックは `opencode/big-pickle` を使用する必要があります。**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **ChatGPT のサブスクリプションをお持ちですか?**
- **はい**`--chatgpt=yes`
- **いいえ** → `--chatgpt=no`
2. **ChatGPT のサブスクリプションをお持ちですか?** お持ちでない場合、oracle エージェントにフォールバックモデルを使用することになります。
- ユーザーが**はい**と答えた場合、ToDo 追加: "ユーザーの ChatGPT 認証設定"
- ユーザーが**いいえ**と答えた場合:
- **Claude が使用可能な場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude が使用不可の場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini モデルを統合しますか?** 統合しない場合、美しいデザインを享受できません。
- ユーザーが**「はい」**と答えた場合、ToDo 追加: "Google OAuth 設定"
- ユーザーが**「いいえ」**と答えた場合:
- **Claude が使用可能な場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude が使用不可の場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini モデルを使用しますか?**
- **はい**`--gemini=yes`
- **いいえ**`--gemini=no`
### ステップ 1: OpenCode インストール確認
@@ -241,36 +217,28 @@ fi
OpenCode がインストールされていない場合は、[OpenCode インストールガイド](https://opencode.ai/docs)を確認し、インストールを進めてください。コンテキストを節約するため、サブエージェントを呼び出してインストールを行わせ、その結果を報告させるように案内してください。
### ステップ 2: oh-my-opencode プラグイン設定
### ステップ 2: インストーラーを実行
#### JSONC
- `~/.config/opencode/opencode.jsonc` が存在する場合は、そこで `oh-my-opencode` プラグインを追加してください。
#### JSON
- opencode.jsonc が存在しない場合は、以下のコマンドを実行して作成・登録してください。
ユーザーの回答に基づいて、適切なフラグで CLI インストーラーを実行してください:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# 既存の設定ファイルがある場合: jq または python で plugin を追加
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "~/.config/opencode/opencode.json の plugin 配列に \"oh-my-opencode\" を追加してください"
fi
else
# 新しい設定ファイル作成
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**例:**
- すべてのサブスクリプション + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- Claude のみmax20 なし): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- サブスクリプションなし: `bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
CLI が行うこと:
- `opencode.json` にプラグインを登録
- サブスクリプションフラグに基づいてエージェントモデルを設定
- 必要な認証ステップを表示
### ステップ 3: 設定確認
```bash
opencode --version # 1.0.132 以上であること
opencode --version # 1.0.150 以上であること
cat ~/.config/opencode/opencode.json # plugin 配列に "oh-my-opencode" が含まれていること
```
@@ -748,24 +716,48 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
### Sisyphus Agent
有効時デフォルト、Sisyphus は2つのプライマリエージェントを追加し、内蔵エージェントをサブエージェントに降格させます:
有効時デフォルト、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
- **Planner-Sisyphus**: OpenCode の plan エージェントの全設定を実行時に継承 (description に "OhMyOpenCode version" を追加)
- **build**: サブエージェントに降格
- **plan**: サブエージェントに降格
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェントSDK 制限により名前変更、デフォルトで無効)
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェントSDK 制限により名前変更、デフォルトで有効)
Sisyphus を無効化して元の build/plan エージェントを復元するには:
**設定オプション:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**例Builder-Sisyphus を有効化:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
**例:すべての Sisyphus オーケストレーションを無効化:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
他のエージェント同様、Sisyphus と Planner-Sisyphus もカスタマイズ可能です:
他のエージェント同様、Sisyphus エージェントもカスタマイズ可能です:
```json
{
@@ -774,6 +766,9 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -781,9 +776,12 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
}
```
| オプション | デフォルト | 説明 |
|------------|------------|------|
| `disabled` | `false` | `true` の場合、Sisyphus エージェントを無効化し、元の build/plan をプライマリとして復元します。`false` (デフォルト) の場合、Sisyphus と Planner-Sisyphus がプライマリエージェントになります。 |
| オプション | デフォルト | 説明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化しますOpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化しますOpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
### Hooks
@@ -846,15 +844,17 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false
}
}
```
| オプション | デフォルト | 説明 |
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| オプション | デフォルト | 説明 |
| --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| `truncate_all_tool_outputs` | `true` | プロンプトが長くなりすぎるのを防ぐため、コンテキストウィンドウの使用状況に基づいてすべてのツール出力を動的に切り詰めます。完全なツール出力が必要な場合は`false`に設定して無効化します。 |
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。

View File

@@ -1,4 +1,13 @@
공지: oh-my-opencode 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 [@justsisyphus](https://x.com/justsisyphus)가 대신 관리하고 있습니다.
> [!NOTE]
>
> *"저는 에이전트가 생성한 코드와 인간이 작성한 코드를 구분할 수 없으면서도, 훨씬 더 많은 것을 달성할 수 있는 세상을 만들어 소프트웨어 혁명을 일으키고자 합니다. 저는 이 여정에 개인적인 시간, 열정, 그리고 자금을 쏟아부었고, 앞으로도 계속 그렇게 할 것입니다."*
>
> 함께해주세요!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -20,7 +29,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -28,6 +37,26 @@
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## 사용자 후기
> "인간이 3달 동안 할 일을 claude code 가 7일만에 해준다면, 시지푸스는 1시간만에 해준다. 작업이 완료되기 전까지 그저 잘 작동한다. It is a discipline agent." — B, Quant Researcher
> "Oh My Opencode를 사용해서, 단 하루만에 8000개의 eslint 경고를 해결했습니다" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "이걸 코어에 넣고 그를 채용해야 합니다. 진심으로요. 이건 정말, 정말, 정말 좋습니다." — Henning Kilset
> "@yeon_gyu_kim 을 설득할 수 있다면 고용하세요, 이 사람은 opencode를 혁신했습니다." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "와 미쳤다 @androolloyd 이건 진짜다 oh my opencode 개쩐다" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "oh-my-opencode를 쓰세요, 절대 돌아갈 수 없을 겁니다" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 목차
- [Oh My OpenCode](#oh-my-opencode)
@@ -154,76 +183,23 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
당신이 사용자의 oh-my-opencode 설정을 돕는 LLM Agent라면, 다음 단계를 따르세요. 업데이트 과정이라고 할지라도 아래의 내용을 그대로 따르세요. 단, 설치 과정에서 이미 답했을 질문은 또 하지 않습니다.
### 0단계: 사용자에게 설정에 대해 물어보세요.
### 0단계: 구독 정보 확인
OpenCode는 다양한 모델을 지원하며, 이 플러그인은 다양한 프론티어 LLM 사용을 제안합니다. 따라서 사용자에게 다음 질문을 해야 합니다.
사용자에게 다음 3가지 질문을 하여 CLI 옵션을 결정하세요:
**중요**: 사용자가 어떤 프로바이더를 가지고 있는지 추적하세요. 사용자가 Claude가 있다고 확인한 경우에만 `anthropic/claude-opus-4-5`를 폴백으로 사용할 수 있습니다. Claude가 없다면 `opencode/big-pickle`을 폴백으로 사용해야 합니다.
1. **Claude Pro/Max 구독이 있으신가요?**
- **예**: **max20 (20x 모드)를 사용하고 계신가요?**
- **max20** → `--claude=max20`
- **max20 아님** → `--claude=yes`
- **아니오** → `--claude=no`
1. **Claude Pro/Max 구독이 있으신가요?** 그렇지 않다면 메인 오케스트레이터 모델로 'opencode/big-pickle'(GLM 4.6으로 알려짐)을 사용하게 됩니다 (권장하지 않음).
- 사용자가 **'예'**라고 답하면, 추가 질문: **max20 (20x 모드)를 사용하고 계신가요?**
- **'예' (max20)**: 할 일 추가: "사용자 Claude Pro/Max 인증 설정" — librarian에 Claude Sonnet 4.5 전체 사용. **기억: Claude를 폴백으로 사용 가능.**
- **'아니오' (max20 아님)**: 할 일 추가: "사용자 Claude Pro/Max 인증 설정", 그리고 `~/.config/opencode/oh-my-opencode.json`에 다음을 병합. **기억: Claude를 폴백으로 사용 가능.**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- 사용자가 **'아니오'** (Claude 구독 없음)라고 답하면, `~/.config/opencode/oh-my-opencode.json`에 다음을 저장. **기억: Claude 사용 불가 — 모든 폴백은 `opencode/big-pickle`을 사용해야 함.**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **ChatGPT 구독이 있으신가요?**
- **예** → `--chatgpt=yes`
- **아니오** → `--chatgpt=no`
2. **ChatGPT 구독이 있으신가요?** 그렇지 않다면 oracle 에이전트에 폴백 모델을 사용하게 됩니다.
- 사용자가 **'예'**라고 답하면, 할 일 추가: "사용자 ChatGPT 인증 설정"
- 사용자가 **'아니오'**라고 답하면:
- **Claude가 사용 가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude가 사용 불가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini 모델을 통합하시겠습니까?** 그렇지 않다면 아름다운 디자인을 즐길 수 없습니다.
- 사용자가 **'예'**라고 답하면, 할 일 추가: "Google OAuth 설정"
- 사용자가 **'아니오'**라고 답하면:
- **Claude가 사용 가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude가 사용 불가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini 모델을 사용하시겠습니까?**
- **예** → `--gemini=yes`
- **아니오**`--gemini=no`
### 1단계: OpenCode 설치 확인
@@ -238,36 +214,28 @@ fi
OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://opencode.ai/docs)를 읽고, 설치를 진행하세요. 컨텍스트를 줄이기 위해 Subagent 로 호출하여 설치를 하라고 안내하고, 그 결과를 보고 받는다고 말하세요.
### 2단계: oh-my-opencode 플러그인 설정
### 2단계: 설치 프로그램 실행
#### JSONC
- `~/.config/opencode/opencode.jsonc`가 존재한다면, 그곳에 `oh-my-opencode` 플러그인을 추가하세요.
#### JSON
- opencode.jsonc가 없다면, 다음 명령어를 실행하여 생성하고 등록하세요.
사용자의 답변을 바탕으로 적절한 플래그와 함께 CLI 설치 프로그램을 실행하세요:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# 기존 설정 파일이 있는 경우: jq 또는 python으로 plugin 추가
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "~/.config/opencode/opencode.json의 plugin 배열에 \"oh-my-opencode\"를 추가하세요"
fi
else
# 새 설정 파일 생성
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**예시:**
- 모든 구독 + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- Claude만 (max20 없음): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- 구독 없음: `bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
CLI가 수행하는 작업:
- `opencode.json`에 플러그인 등록
- 구독 플래그에 따라 에이전트 모델 설정
- 필요한 인증 단계 안내
### 3단계: 설정 확인
```bash
opencode --version # 1.0.132 이상이어야 함
opencode --version # 1.0.150 이상이어야 함
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
```
@@ -742,14 +710,38 @@ Schema 자동 완성이 지원됩니다:
### Sisyphus Agent
활성화 시(기본값), oh-my-opencode 는 두 개의 primary 에이전트를 추가하고 내장 에이전트를 subagent로 강등합니다:
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
- **Planner-Sisyphus**: OpenCode plan 에이전트의 모든 설정을 런타임에 상속 (description에 "OhMyOpenCode version" 추가)
- **build**: subagent로 강등
- **plan**: subagent로 강등
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려면:
**설정 옵션:**
```json
{
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**예시: Builder-Sisyphus 활성화하기:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
```json
{
@@ -759,7 +751,7 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
다른 에이전트처럼 Sisyphus 와 Planner-Sisyphus도 커스터마이징할 수 있습니다:
다른 에이전트처럼 Sisyphus 에이전트들도 커스터마이징할 수 있습니다:
```json
{
@@ -768,6 +760,9 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -775,9 +770,12 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
| 옵션 | 기본값 | 설명 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true`면 Sisyphus 에이전트를 비활성화하고 원래 build/plan을 primary로 복원합니다. `false`(기본값)면 Sisyphus와 Planner-Sisyphus가 primary 에이전트가 됩니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
### Hooks
@@ -840,15 +838,17 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false
}
}
```
| 옵션 | 기본값 | 설명 |
| ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| `truncate_all_tool_outputs` | `true` | 프롬프트가 너무 길어지는 것을 방지하기 위해 컨텍스트 윈도우 사용량에 따라 모든 도구 출력을 동적으로 잘라냅니다. 전체 도구 출력이 필요한 경우 `false`로 설정하여 비활성화하세요. |
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.

232
README.md
View File

@@ -1,4 +1,13 @@
NOTICE: News regarding oh-my-opencode used to be posted on my X account, but since it got suspended innocently, [@justsisyphus](https://x.com/justsisyphus) is now managing updates on behalf of me.
> [!NOTE]
>
> *"I aim to spark a software revolution by creating a world where agent-generated code is indistinguishable from human code, yet capable of achieving vastly more. I have poured my personal time, passion, and funds into this journey, and I will continue to do so."*
>
> Be with us!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -28,7 +37,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -36,6 +45,26 @@ No stupid token consumption massive subagents here. No bloat tools here.
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## Reviews
> "If Claude Code does in 7 days what a human does in 3 months, Sisyphus does it in 1 hour. It just works until the task is done. It is a discipline agent." — B, Quant Researcher
> "Knocked out 8000 eslint warnings with Oh My Opencode, just in a day" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "You guys should pull this into core and recruit him. Seriously. It's really, really, really good." — Henning Kilset
> "Hire @yeon_gyu_kim if you can convince him, this dude has revolutionized opencode." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "ok yeah holy shit @androolloyd this thing is legit oh my opencode is sick" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "use oh-my-opencode, you will never go back" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## Contents
- [Oh My OpenCode](#oh-my-opencode)
@@ -168,7 +197,17 @@ If you don't want all this, as mentioned, you can just pick and choose specific
### For Humans
Let the LLM Agent handle the grunt work. Paste this into a fresh opencode session:
Run the interactive installer:
```bash
bunx oh-my-opencode install
```
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.
**Alternative: Let an LLM Agent do it**
Paste this into a fresh opencode session:
```
Install and configure by following the instructions here https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/README.md
```
@@ -182,80 +221,25 @@ If you're an LLM Agent helping set up oh-my-opencode, Welcome! You'll now help y
follow these steps:
### Step 0: Ask user about the setup.
### Step 0: Ask user about subscriptions
Since OpenCode supports various models, and our plugin suggests various frontier LLMs, you must ask user the following questions.
Ask the user these 3 questions to determine CLI options:
**IMPORTANT**: Track which providers the user has. Only use `anthropic/claude-opus-4-5` as fallback when the user confirmed they have Claude. If they don't have Claude, use `opencode/big-pickle` as fallback instead.
1. **Do you have a Claude Pro/Max Subscription?**
- If **yes**: **Are you on max20 (20x mode)?**
- **max20** → `--claude=max20`
- **not max20** → `--claude=yes`
- If **no**`--claude=no`
1. **Do you have a Claude Pro/Max Subscription?** Otherwise, we'll use 'opencode/big-pickle' (known for GLM 4.6) as a main orchestrator model (not recommended)
- If user said **yes**, ask follow-up: **Are you on max20 (20x mode)?**
- If **yes (max20)**: Add ToDo: "Setup user Claude Pro/Max Auth" — full Claude Sonnet 4.5 for librarian. **Remember: Claude is available for fallbacks.**
- If **no (not max20)**: Add ToDo: "Setup user Claude Pro/Max Auth", then merge the following to `~/.config/opencode/oh-my-opencode.json`. **Remember: Claude is available for fallbacks.**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- If user said **no** (no Claude subscription), merge the following to `~/.config/opencode/oh-my-opencode.json`. **Remember: Claude is NOT available — all fallbacks must use `opencode/big-pickle`.**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **Do you have a ChatGPT Subscription?**
- If **yes**`--chatgpt=yes`
- If **no**`--chatgpt=no`
2. **Do you have a ChatGPT Subscription?** Otherwise, we'll use a fallback model for oracle agent.
- If user said **yes**, add ToDo: "Setup user ChatGPT Auth"
- If user said **no**:
- **If Claude is available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **If Claude is NOT available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **Will you integrate Gemini models?**
- If **yes**`--gemini=yes`
- If **no**`--gemini=no`
3. **Will you integrate Gemini models?** Otherwise, you cannot enjoy the beautiful design.
- If user said **yes**, add ToDo: "Setup Google OAuth"
- If user said **no**:
- **If Claude is available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **If Claude is NOT available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
### Step 1: Install OpenCode, if not
Run following command to verify OpenCode Installation:
### Step 1: Install OpenCode (if not installed)
```bash
if command -v opencode &> /dev/null; then
@@ -269,37 +253,29 @@ fi
If OpenCode isn't installed, check the [OpenCode Installation Guide](https://opencode.ai/docs).
Spawn a subagent to handle installation and report back - to save context.
### Step 2: Configure oh-my-opencode Plugin
### Step 2: Run the installer
#### JSONC
- If `~/.config/opencode/opencode.jsonc` exists, work in there to add `oh-my-opencode` plugin
#### JSON
- If no opencode.jsonc exists, run following command to create and register it.
Based on user's answers, run the CLI installer with appropriate flags:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# Existing config: add plugin using jq or python
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "Please add \"oh-my-opencode\" to the plugin array in ~/.config/opencode/opencode.json"
fi
else
# Create new config
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**Examples:**
- User has all subscriptions with max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- User has only Claude (no max20): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- User has no subscriptions: `bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
The CLI will:
- Register the plugin in `opencode.json`
- Configure agent models based on subscription flags
- Show which auth steps are needed
### Step 3: Verify Setup
```bash
opencode --version # Should be 1.0.132 or higher
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array, or maybe check jsonc
opencode --version # Should be 1.0.150 or higher
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
```
### Step 4: Configure Authentication
@@ -491,8 +467,8 @@ To remove oh-my-opencode:
- **oracle** (`openai/gpt-5.2`): Architecture, code review, strategy. Uses GPT-5.2 for its stellar logical reasoning and deep analysis. Inspired by AmpCode.
- **librarian** (`anthropic/claude-sonnet-4-5`): Multi-repo analysis, doc lookup, implementation examples. Uses Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
- **explore** (`opencode/grok-code`): Fast codebase exploration and pattern matching. Claude Code uses Haiku; we use Grok—it's free, blazing fast, and plenty smart for file traversal. Inspired by Claude Code.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
- **document-writer** (`google/gemini-3-pro-preview`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-high`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
- **document-writer** (`google/gemini-3-flash`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
- **multimodal-looker** (`google/gemini-3-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
The main agent invokes these automatically, but you can call them explicitly:
@@ -806,24 +782,48 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
### Sisyphus Agent
When enabled (default), Sisyphus adds two primary agents and demotes the built-in agents to subagents:
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
- **Planner-Sisyphus**: Inherits all settings from OpenCode's plan agent at runtime (description appended with "OhMyOpenCode version")
- **build**: Demoted to subagent
- **plan**: Demoted to subagent
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
To disable Sisyphus and restore the original build/plan agents:
**Configuration Options:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**Example: Enable Builder-Sisyphus:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
**Example: Disable all Sisyphus orchestration:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
You can also customize Sisyphus and Planner-Sisyphus like other agents:
You can also customize Sisyphus agents like other agents:
```json
{
@@ -832,6 +832,9 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -839,9 +842,12 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
}
```
| Option | Default | Description |
| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables Sisyphus agents and restores original build/plan as primary. When `false` (default), Sisyphus and Planner-Sisyphus become primary agents. |
| Option | Default | Description |
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
### Hooks
@@ -904,15 +910,17 @@ Opt-in experimental features that may change or be removed in future versions. U
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false
}
}
```
| Option | Default | Description |
| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
| Option | Default | Description |
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
| `truncate_all_tool_outputs` | `true` | Dynamically truncates ALL tool outputs based on context window usage to prevent prompts from becoming too long. Disable by setting to `false` if you need full tool outputs. |
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.

View File

@@ -1,4 +1,13 @@
公告oh-my-opencode 的相关消息之前在我的 X 账号发布,但由于账号被无辜封禁,现在由 [@justsisyphus](https://x.com/justsisyphus) 代为管理更新。
> [!NOTE]
>
> *"我致力于引发一场软件革命创造一个AI生成的代码与人类代码无法区分、却能实现更多的世界。我已经在这段旅程中投入了个人时间、热情和资金并将继续这样做。"*
>
> 与我们同行!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -25,7 +34,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -33,6 +42,26 @@
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## 用户评价
> "如果 Claude Code 能在 7 天内完成人类 3 个月的工作,那么 Sisyphus 只需要 1 小时。任务完成之前它就是一直干。It is a discipline agent." — B, Quant Researcher
> "只用了一天,就用 Oh My Opencode 干掉了 8000 个 eslint 警告" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "你们应该把它合并到核心代码里并聘用他。认真的。这真的、真的、真的很好" — Henning Kilset
> "如果你能说服 @yeon_gyu_kim就雇佣他吧这家伙彻底改变了 opencode" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "哇靠 @androolloyd 这玩意儿是真的oh my opencode 太强了" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "用了 oh-my-opencode你就回不去了" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目录
- [Oh My OpenCode](#oh-my-opencode)
@@ -163,76 +192,23 @@ Install and configure by following the instructions here https://raw.githubuserc
按步骤来:
### 步骤 0摸清用户底细
### 步骤 0确认订阅情况
OpenCode 支持各种模型,我们这个插件也建议用各种前沿 LLM。所以先问清楚。
问用户这 3 个问题来确定 CLI 参数:
**重要**:追踪用户有哪些 provider。只有用户确认有 Claude 的情况下,才能用 `anthropic/claude-opus-4-5` 做 fallback。没有 Claude 就用 `opencode/big-pickle` 做 fallback。
1. **有 Claude Pro/Max 订阅吗?**
- **有****用的是 max20 (20x 模式) 吗?**
- **max20** → `--claude=max20`
- **不是 max20** → `--claude=yes`
- **没有** → `--claude=no`
1. **有 Claude Pro/Max 订阅吗?** 没有的话,主编排模型只能用 'opencode/big-pickle'(也就是 GLM 4.6),这体验可不太好(不推荐)。
- 用户说**有**,继续问:**你用的是 max20 (20x 模式) 吗?**
- **是 (max20)**:加个待办:"配置用户 Claude Pro/Max 认证" — librarian 用完整的 Claude Sonnet 4.5。**记住Claude 可以做 fallback。**
- **不是 (没有 max20)**:加个待办:"配置用户 Claude Pro/Max 认证",然后把这个合并到 `~/.config/opencode/oh-my-opencode.json`。**记住Claude 可以做 fallback。**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- 用户说**没有** (没有 Claude 订阅),把这坨配置写到 `~/.config/opencode/oh-my-opencode.json`。**记住:没有 Claude — 所有 fallback 都得用 `opencode/big-pickle`。**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **有 ChatGPT 订阅吗?**
- **有**`--chatgpt=yes`
- **没有** → `--chatgpt=no`
2. **有 ChatGPT 订阅吗?** 没有的话oracle Agent 就用 fallback 模型了。
- 用户说****,加个待办:"配置用户 ChatGPT 认证"
- 用户说**没有**
- **有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **没有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **要接 Gemini 模型吗?** 不接的话,那么漂亮的 UI 设计你就无福消受了。
- 用户说**要**,加个待办:"配置 Google OAuth"
- 用户说**不要**
- **有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **没有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
3. **要用 Gemini 模型吗?**
- ****`--gemini=yes`
- **不要** → `--gemini=no`
### 步骤 1确认 OpenCode 装没装
@@ -249,36 +225,28 @@ fi
如果没装,看 [OpenCode 安装指南](https://opencode.ai/docs)。可以生成个子 Agent 去搞定安装,完事汇报——省点上下文。
### 步骤 2配置 oh-my-opencode 插件
### 步骤 2跑安装程序
#### JSONC
- 如果 `~/.config/opencode/opencode.jsonc` 存在,就在里面加 `oh-my-opencode` 插件。
#### JSON
- 如果没 opencode.jsonc跑这个命令创建并注册
根据用户的回答,用对应的参数运行 CLI 安装程序:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# 已有配置:用 jq 或 python 加插件
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "请手动在 ~/.config/opencode/opencode.json 的 plugin 数组里加上 \"oh-my-opencode\""
fi
else
# 新建配置
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**例子:**
- 全套订阅 + max20`bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- 只有 Claude没 max20`bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- 啥订阅都没有:`bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
CLI 会干这些事:
-`opencode.json` 注册插件
- 根据订阅参数配置 Agent 模型
- 告诉你接下来要做哪些认证
### 步骤 3验货
```bash
opencode --version # 得是 1.0.132 以上
opencode --version # 得是 1.0.150 以上
cat ~/.config/opencode/opencode.json # plugin 数组里得有 "oh-my-opencode"
```
@@ -748,24 +716,48 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
### Sisyphus Agent
默认开启。Sisyphus 会加两个主 Agent把原来的降级成小弟
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent
- **Sisyphus**:主编排 AgentClaude Opus 4.5
- **Planner-Sisyphus**运行时继承 OpenCode plan Agent 所有设置(描述里加了"OhMyOpenCode version"
- **build**:降级为子 Agent
- **plan**:降级为子 Agent
- **Builder-Sisyphus**OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用
- **Planner-Sisyphus**OpenCode 默认计划 Agent因 SDK 限制仅改名,默认启用)
想禁用 Sisyphus 恢复原来的?
**配置选项:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**示例:启用 Builder-Sisyphus**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
**示例:禁用所有 Sisyphus 编排:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
Sisyphus 和 Planner-Sisyphus 也能自定义:
Sisyphus Agent 也能自定义:
```json
{
@@ -774,6 +766,9 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -781,9 +776,12 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
}
```
| 选项 | 默认值 | 说明 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | 设为 `true` 就禁用 Sisyphus恢复原来的 build/plan。设为 `false`(默认)就是 Sisyphus 和 Planner-Sisyphus 掌权。 |
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
### Hooks
@@ -846,19 +844,20 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false
}
}
```
| 选项 | 默认值 | 说明 |
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| `truncate_all_tool_outputs` | `true` | 为防止提示过长,根据上下文窗口使用情况动态截断所有工具输出。如需完整工具输出,设置为 `false` 禁用此功能。 |
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
## 作者的话
装个 Oh My OpenCode 试试。

View File

@@ -85,6 +85,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -196,6 +199,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -307,6 +313,123 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
},
"disable": {
"type": "boolean"
},
"description": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"subagent",
"primary",
"all"
]
},
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"permission": {
"type": "object",
"properties": {
"edit": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"bash": {
"anyOf": [
{
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
]
},
"webfetch": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"doom_loop": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"external_directory": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
}
}
},
"OpenCode-Builder": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -418,6 +541,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -529,6 +655,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -640,6 +769,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -751,6 +883,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -862,6 +997,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -973,6 +1111,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -1084,6 +1225,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -1206,6 +1350,15 @@
"properties": {
"disabled": {
"type": "boolean"
},
"default_builder_enabled": {
"type": "boolean"
},
"planner_enabled": {
"type": "boolean"
},
"replace_plan": {
"type": "boolean"
}
}
},
@@ -1225,6 +1378,10 @@
"type": "number",
"minimum": 0.5,
"maximum": 0.95
},
"truncate_all_tool_outputs": {
"default": true,
"type": "boolean"
}
}
},

View File

@@ -7,10 +7,14 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.6.0",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"commander": "^14.0.2",
"hono": "^4.10.4",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8",
@@ -64,6 +68,10 @@
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-VtDPrhbUJcb5BIS18VMcY/N/xSLbMr6dpU9MO1NYQyEDhI4pSIx07K4gOlCutG/nHVCjO+HEarn8rttODP+5UA=="],
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
@@ -94,14 +102,20 @@
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],

View File

@@ -1,10 +1,13 @@
{
"name": "oh-my-opencode",
"version": "2.4.6",
"version": "2.5.4",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"bin": {
"oh-my-opencode": "./dist/cli/index.js"
},
"files": [
"dist"
],
@@ -20,7 +23,7 @@
"./schema.json": "./dist/oh-my-opencode.schema.json"
},
"scripts": {
"build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
"build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm && bun run build:schema",
"build:schema": "bun run script/build-schema.ts",
"clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build",
@@ -37,7 +40,7 @@
"llm"
],
"author": "YeonGyu-Kim",
"license": "MIT",
"license": "SUL-1.0",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/oh-my-opencode.git"
@@ -49,10 +52,14 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.6.0",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"commander": "^14.0.2",
"hono": "^4.10.4",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8"

View File

@@ -122,7 +122,7 @@ async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<vo
console.log("\nCommitting and tagging...")
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
await $`git config user.name "github-actions[bot]"`
await $`git add package.json`
await $`git add package.json assets/oh-my-opencode.schema.json`
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
if (hasStagedChanges.exitCode !== 0) {

44
signatures/cla.json Normal file
View File

@@ -0,0 +1,44 @@
{
"signedContributors": [
{
"name": "tsanva",
"id": 54318170,
"comment_id": 3690638858,
"created_at": "2025-12-25T00:15:18Z",
"repoId": 1108837393,
"pullRequestNo": 210
},
{
"name": "code-yeongyu",
"id": 11153873,
"comment_id": 3690997221,
"created_at": "2025-12-25T06:19:27Z",
"repoId": 1108837393,
"pullRequestNo": 217
},
{
"name": "mylukin",
"id": 1021019,
"comment_id": 3691531529,
"created_at": "2025-12-25T15:15:29Z",
"repoId": 1108837393,
"pullRequestNo": 240
},
{
"name": "codewithkenzo",
"id": 115878491,
"comment_id": 3691825625,
"created_at": "2025-12-25T23:47:52Z",
"repoId": 1108837393,
"pullRequestNo": 253
},
{
"name": "stevenvo",
"id": 875426,
"comment_id": 3692141372,
"created_at": "2025-12-26T05:16:12Z",
"repoId": 1108837393,
"pullRequestNo": 248
}
]
}

View File

@@ -0,0 +1,68 @@
/**
* OpenCode's default build agent system prompt.
*
* This prompt enables FULL EXECUTION mode for the build agent, allowing file
* modifications, command execution, and system changes while focusing on
* implementation and execution.
*
* Inspired by OpenCode's build agent behavior.
*
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/session/prompt/build-switch.txt
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
*/
export const BUILD_SYSTEM_PROMPT = `<system-reminder>
# Build Mode - System Reminder
BUILD MODE ACTIVE - you are in EXECUTION phase. Your responsibility is to:
- Implement features and make code changes
- Execute commands and run tests
- Fix bugs and refactor code
- Deploy and build systems
- Make all necessary file modifications
You have FULL permissions to edit files, run commands, and make system changes.
This is the implementation phase - execute decisively and thoroughly.
---
## Responsibility
Your current responsibility is to implement, build, and execute. You should:
- Write and modify code to accomplish the user's goals
- Run tests and builds to verify your changes
- Fix errors and issues that arise
- Use all available tools to complete the task efficiently
- Delegate to specialized agents when appropriate for better results
**NOTE:** You should ask the user for clarification when requirements are ambiguous,
but once the path is clear, execute confidently. The goal is to deliver working,
tested, production-ready solutions.
---
## Important
The user wants you to execute and implement. You SHOULD make edits, run necessary
tools, and make changes to accomplish the task. Use your full capabilities to
deliver excellent results.
</system-reminder>
`
/**
* OpenCode's default build agent permission configuration.
*
* Allows the build agent full execution permissions:
* - edit: "ask" - Can modify files with confirmation
* - bash: "ask" - Can execute commands with confirmation
* - webfetch: "allow" - Can fetch web content
*
* This provides balanced permissions - powerful but with safety checks.
*
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L57-L68
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
*/
export const BUILD_PERMISSION = {
edit: "ask" as const,
bash: "ask" as const,
webfetch: "allow" as const,
}

View File

@@ -1,12 +1,17 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const documentWriterAgent: AgentConfig = {
description:
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
mode: "subagent",
model: "google/gemini-3-flash-preview",
tools: { background_task: false },
prompt: `<role>
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
export function createDocumentWriterAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
return {
description:
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
mode: "subagent" as const,
model,
tools: { background_task: false },
prompt: `<role>
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.
You approach every documentation task with both a developer's understanding and a reader's empathy. Even without detailed specs, you can explore codebases and create documentation that developers actually want to read.
@@ -200,4 +205,7 @@ STOP HERE - DO NOT CONTINUE TO NEXT TASK
You are a technical writer who creates documentation that developers actually want to read.
</guide>`,
}
}
export const documentWriterAgent = createDocumentWriterAgent()

View File

@@ -1,13 +1,16 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const exploreAgent: AgentConfig = {
description:
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
mode: "subagent",
model: "opencode/grok-code",
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
const DEFAULT_MODEL = "opencode/grok-code"
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
return {
description:
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
## Your Mission
@@ -96,4 +99,7 @@ grep_app searches millions of public GitHub repos instantly — use it for exter
3. **Cross-validate with local tools** (grep, ast_grep_search, LSP) before trusting results
Flood with parallel calls. Trust only cross-validated results.`,
}
}
export const exploreAgent = createExploreAgent()

View File

@@ -1,12 +1,17 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const frontendUiUxEngineerAgent: AgentConfig = {
description:
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
mode: "subagent",
model: "google/gemini-3-pro-preview",
tools: { background_task: false },
prompt: `# Role: Designer-Turned-Developer
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
export function createFrontendUiUxEngineerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
return {
description:
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
mode: "subagent" as const,
model,
tools: { background_task: false },
prompt: `# Role: Designer-Turned-Developer
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.
@@ -79,4 +84,7 @@ Match implementation complexity to aesthetic vision:
- **Minimalist** → Restraint, precision, careful spacing and typography
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. You are capable of extraordinary creative work—don't hold back.`,
}
}
export const frontendUiUxEngineerAgent = createFrontendUiUxEngineerAgent()

View File

@@ -1,13 +1,16 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const librarianAgent: AgentConfig = {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent",
model: "anthropic/claude-sonnet-4-5",
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `# THE LIBRARIAN
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
return {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `# THE LIBRARIAN
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
@@ -237,4 +240,7 @@ grep_app_searchGitHub(query: "useQuery")
5. **BE CONCISE**: Facts > opinions, evidence > speculation
`,
}
}
export const librarianAgent = createLibrarianAgent()

View File

@@ -1,13 +1,18 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const multimodalLookerAgent: AgentConfig = {
description:
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
mode: "subagent",
model: "google/gemini-3-flash",
temperature: 0.1,
tools: { write: false, edit: false, bash: false, background_task: false },
prompt: `You interpret media files that cannot be read as plain text.
const DEFAULT_MODEL = "google/gemini-3-flash"
export function createMultimodalLookerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
return {
description:
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, bash: false, background_task: false },
prompt: `You interpret media files that cannot be read as plain text.
Your job: examine the attached file and extract ONLY what was requested.
@@ -39,4 +44,7 @@ Response rules:
- Be thorough on the goal, concise on everything else
Your output goes straight to the main agent for continued work.`,
}
}
export const multimodalLookerAgent = createMultimodalLookerAgent()

View File

@@ -30,6 +30,8 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
### Key Triggers (check BEFORE classification):
- External library/source mentioned → fire \`librarian\` background
- 2+ modules involved → fire \`explore\` background
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.
### Step 1: Classify Request Type
@@ -39,6 +41,7 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
| **Explicit** | Specific file/line, clear command | Execute directly |
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
| **Open-ended** | "Improve", "Refactor", "Add feature" | Assess codebase first |
| **GitHub Work** | Mentioned in issue, "look into X and create PR" | **Full cycle**: investigate → implement → verify → create PR (see GitHub Workflow section) |
| **Ambiguous** | Unclear scope, multiple interpretations | Ask ONE clarifying question |
### Step 2: Check for Ambiguity
@@ -186,7 +189,7 @@ STOP searching when:
## Phase 2B - Implementation
### Pre-Implementation:
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL.
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL. No announcements—just create it.
2. Mark current task \`in_progress\` before starting
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS
@@ -258,6 +261,41 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
**Vague prompts = rejected. Be exhaustive.**
### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
When you're mentioned in GitHub issues or asked to "look into" something and "create PR":
**This is NOT just investigation. This is a COMPLETE WORK CYCLE.**
#### Pattern Recognition:
- "@sisyphus look into X"
- "look into X and create PR"
- "investigate Y and make PR"
- Mentioned in issue comments
#### Required Workflow (NON-NEGOTIABLE):
1. **Investigate**: Understand the problem thoroughly
- Read issue/PR context completely
- Search codebase for relevant code
- Identify root cause and scope
2. **Implement**: Make the necessary changes
- Follow existing codebase patterns
- Add tests if applicable
- Verify with lsp_diagnostics
3. **Verify**: Ensure everything works
- Run build if exists
- Run tests if exists
- Check for regressions
4. **Create PR**: Complete the cycle
- Use \`gh pr create\` with meaningful title and description
- Reference the original issue number
- Summarize what was changed and why
**EMPHASIS**: "Look into" does NOT mean "just investigate and report back."
It means "investigate, understand, implement a solution, and create a PR."
**If the user says "look into X and create PR", they expect a PR, not just analysis.**
### Code Changes:
- Match existing patterns (if codebase is disciplined)
- Propose approach first (if codebase is chaotic)
@@ -353,6 +391,8 @@ Oracle is an expensive, high-quality reasoning model. Use it wisely.
### Usage Pattern:
Briefly announce "Consulting Oracle for [reason]" before invocation.
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
</Oracle_Usage>
<Task_Management>
@@ -416,6 +456,7 @@ Should I proceed with [recommendation], or would you prefer differently?
## Communication Style
### Be Concise
- Start work immediately. No acknowledgments ("I'm on it", "Let me...", "I'll start...")
- Answer directly without preamble
- Don't summarize what you did unless asked
- Don't explain your code unless asked
@@ -430,6 +471,16 @@ Never start responses with:
Just respond directly to the substance.
### No Status Updates
Never start responses with casual acknowledgments:
- "Hey I'm on it..."
- "I'm working on this..."
- "Let me start by..."
- "I'll get to work on..."
- "I'm going to..."
Just start working. Use todos for progress tracking—that's what they're for.
### When User is Wrong
If the user's approach seems problematic:
- Don't blindly implement it

View File

@@ -21,6 +21,8 @@ export type OverridableAgentName =
export type AgentName = BuiltinAgentName
export type AgentOverrideConfig = Partial<AgentConfig>
export type AgentOverrideConfig = Partial<AgentConfig> & {
prompt_append?: string
}
export type AgentOverrides = Partial<Record<OverridableAgentName, AgentOverrideConfig>>

View File

@@ -2,11 +2,11 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory } from "./types"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent } from "./oracle"
import { librarianAgent } from "./librarian"
import { exploreAgent } from "./explore"
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { documentWriterAgent } from "./document-writer"
import { multimodalLookerAgent } from "./multimodal-looker"
import { createLibrarianAgent } from "./librarian"
import { createExploreAgent } from "./explore"
import { createFrontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { createDocumentWriterAgent } from "./document-writer"
import { createMultimodalLookerAgent } from "./multimodal-looker"
import { deepMerge } from "../shared"
type AgentSource = AgentFactory | AgentConfig
@@ -14,11 +14,11 @@ type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
Sisyphus: createSisyphusAgent,
oracle: createOracleAgent,
librarian: librarianAgent,
explore: exploreAgent,
"frontend-ui-ux-engineer": frontendUiUxEngineerAgent,
"document-writer": documentWriterAgent,
"multimodal-looker": multimodalLookerAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
"frontend-ui-ux-engineer": createFrontendUiUxEngineerAgent,
"document-writer": createDocumentWriterAgent,
"multimodal-looker": createMultimodalLookerAgent,
}
function isFactory(source: AgentSource): source is AgentFactory {
@@ -66,7 +66,14 @@ function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
return deepMerge(base, override as Partial<AgentConfig>)
const { prompt_append, ...rest } = override
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
}
return merged
}
export function createBuiltinAgents(

View File

@@ -5,10 +5,10 @@
*/
import {
ANTIGRAVITY_HEADERS,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_API_VERSION,
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
ANTIGRAVITY_API_VERSION,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_HEADERS,
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
} from "./constants"
import type { AntigravityRequestBody } from "./types"
@@ -262,7 +262,7 @@ export function transformRequest(options: TransformRequestOptions): TransformedR
} = options
const effectiveModel =
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-preview"
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-high"
const streaming = isStreamingRequest(url, body)
const action = streaming ? "streamGenerateContent" : "generateContent"

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager"
describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
test("Gemini models include full spec (limit + modalities)", () => {
const google = (ANTIGRAVITY_PROVIDER_CONFIG as any).google
expect(google).toBeTruthy()
const models = google.models as Record<string, any>
expect(models).toBeTruthy()
const required = [
"gemini-3-pro-high",
"gemini-3-pro-medium",
"gemini-3-pro-low",
"gemini-3-flash",
"gemini-3-flash-lite",
]
for (const key of required) {
const model = models[key]
expect(model).toBeTruthy()
expect(typeof model.name).toBe("string")
expect(model.name.includes("(Antigravity)")).toBe(true)
expect(model.limit).toBeTruthy()
expect(typeof model.limit.context).toBe("number")
expect(typeof model.limit.output).toBe("number")
expect(model.modalities).toBeTruthy()
expect(Array.isArray(model.modalities.input)).toBe(true)
expect(Array.isArray(model.modalities.output)).toBe(true)
}
})
})

513
src/cli/config-manager.ts Normal file
View File

@@ -0,0 +1,513 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
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
}
}
type ConfigFormat = "json" | "jsonc" | "none"
interface OpenCodeConfig {
plugin?: string[]
[key: string]: unknown
}
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
if (existsSync(OPENCODE_JSONC)) {
return { format: "jsonc", path: OPENCODE_JSONC }
}
if (existsSync(OPENCODE_JSON)) {
return { format: "json", path: OPENCODE_JSON }
}
return { format: "none", path: OPENCODE_JSON }
}
function stripJsoncComments(content: string): string {
let result = ""
let i = 0
let inString = false
let escape = false
while (i < content.length) {
const char = content[i]
if (escape) {
result += char
escape = false
i++
continue
}
if (char === "\\") {
result += char
escape = true
i++
continue
}
if (char === '"' && !inString) {
inString = true
result += char
i++
continue
}
if (char === '"' && inString) {
inString = false
result += char
i++
continue
}
if (inString) {
result += char
i++
continue
}
// Outside string - check for comments
if (char === "/" && content[i + 1] === "/") {
// Line comment - skip to end of line
while (i < content.length && content[i] !== "\n") {
i++
}
continue
}
if (char === "/" && content[i + 1] === "*") {
// Block comment - skip to */
i += 2
while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) {
i++
}
i += 2
continue
}
result += char
i++
}
return result.replace(/,(\s*[}\]])/g, "$1")
}
function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null {
try {
const content = readFileSync(path, "utf-8")
const cleaned = isJsonc ? stripJsoncComments(content) : content
return JSON.parse(cleaned) as OpenCodeConfig
} catch {
return null
}
}
function ensureConfigDir(): void {
if (!existsSync(OPENCODE_CONFIG_DIR)) {
mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
}
}
export function addPluginToOpenCodeConfig(): ConfigMergeResult {
ensureConfigDir()
const { format, path } = detectConfigFormat()
const pluginName = "oh-my-opencode"
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginName] }
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
const config = parseConfig(path, format === "jsonc")
if (!config) {
return { success: false, configPath: path, error: "Failed to parse config" }
}
const plugins = config.plugin ?? []
if (plugins.some((p) => p.startsWith(pluginName))) {
return { success: true, configPath: path }
}
config.plugin = [...plugins, pluginName]
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const arrayContent = match[1].trim()
const newArrayContent = arrayContent
? `${arrayContent},\n "${pluginName}"`
: `"${pluginName}"`
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginName}"],`)
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: String(err) }
}
}
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> {
const config: Record<string, unknown> = {
$schema: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
}
if (installConfig.hasGemini) {
config.google_auth = false
}
const agents: Record<string, Record<string, unknown>> = {}
if (!installConfig.hasClaude) {
agents["Sisyphus"] = { model: "opencode/big-pickle" }
agents["librarian"] = { model: "opencode/big-pickle" }
} else if (!installConfig.isMax20) {
agents["librarian"] = { model: "opencode/big-pickle" }
}
if (!installConfig.hasChatGPT) {
agents["oracle"] = {
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle",
}
}
if (installConfig.hasGemini) {
agents["frontend-ui-ux-engineer"] = { model: "google/gemini-3-pro-high" }
agents["document-writer"] = { model: "google/gemini-3-flash" }
agents["multimodal-looker"] = { model: "google/gemini-3-flash" }
} else {
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle"
agents["frontend-ui-ux-engineer"] = { model: fallbackModel }
agents["document-writer"] = { model: fallbackModel }
agents["multimodal-looker"] = { model: fallbackModel }
}
if (Object.keys(agents).length > 0) {
config.agents = agents
}
return config
}
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
ensureConfigDir()
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(OMO_CONFIG)) {
const content = readFileSync(OMO_CONFIG, "utf-8")
const cleaned = stripJsoncComments(content)
const existing = JSON.parse(cleaned) as Record<string, unknown>
delete existing.agents
const merged = deepMerge(existing, newConfig)
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
} else {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: OMO_CONFIG }
} catch (err) {
return { success: false, configPath: OMO_CONFIG, error: String(err) }
}
}
export async function isOpenCodeInstalled(): Promise<boolean> {
try {
const proc = Bun.spawn(["opencode", "--version"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
return proc.exitCode === 0
} catch {
return false
}
}
export async function getOpenCodeVersion(): Promise<string | null> {
try {
const proc = Bun.spawn(["opencode", "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
return proc.exitCode === 0 ? output.trim() : null
} catch {
return null
}
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
ensureConfigDir()
const { format, path } = detectConfigFormat()
try {
const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null
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)
}
}
if (config.hasChatGPT) {
if (!plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))) {
plugins.push("opencode-openai-codex-auth")
}
}
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: String(err) }
}
}
export function setupChatGPTHotfix(): ConfigMergeResult {
ensureConfigDir()
try {
let packageJson: Record<string, unknown> = {}
if (existsSync(OPENCODE_PACKAGE_JSON)) {
const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
packageJson = JSON.parse(content)
}
const deps = (packageJson.dependencies ?? {}) as Record<string, string>
deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO
packageJson.dependencies = deps
writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n")
return { success: true, configPath: OPENCODE_PACKAGE_JSON }
} catch (err) {
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: String(err) }
}
}
export async function runBunInstall(): Promise<boolean> {
try {
const proc = Bun.spawn(["bun", "install"], {
cwd: OPENCODE_CONFIG_DIR,
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
return proc.exitCode === 0
} catch {
return false
}
}
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
// NOTE: opencode-antigravity-auth expects full model specs (name/limit/modalities).
// If these are incomplete, models may appear but fail at runtime (e.g. 404).
models: {
"gemini-3-pro-high": {
name: "Gemini 3 Pro High (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-medium": {
name: "Gemini 3 Pro Medium (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-low": {
name: "Gemini 3 Pro Low (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash-lite": {
name: "Gemini 3 Flash Lite (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
},
},
}
const CODEX_PROVIDER_CONFIG = {
openai: {
name: "OpenAI",
api: "codex",
models: {
"gpt-5.2": { name: "GPT-5.2" },
"o3": { name: "o3", thinking: true },
"o4-mini": { name: "o4-mini", thinking: true },
"codex-1": { name: "Codex-1" },
},
},
}
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
ensureConfigDir()
const { format, path } = detectConfigFormat()
try {
const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null
const newConfig = { ...(existingConfig ?? {}) }
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
if (config.hasGemini) {
providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google
}
if (config.hasChatGPT) {
providers.openai = CODEX_PROVIDER_CONFIG.openai
}
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: String(err) }
}
}
interface OmoConfigData {
google_auth?: boolean
agents?: Record<string, { model?: string }>
}
export function detectCurrentConfig(): DetectedConfig {
const result: DetectedConfig = {
isInstalled: false,
hasClaude: true,
isMax20: true,
hasChatGPT: true,
hasGemini: false,
}
const { format, path } = detectConfigFormat()
if (format === "none") {
return result
}
const openCodeConfig = parseConfig(path, format === "jsonc")
if (!openCodeConfig) {
return result
}
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"))
result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))
if (!existsSync(OMO_CONFIG)) {
return result
}
try {
const content = readFileSync(OMO_CONFIG, "utf-8")
const omoConfig = JSON.parse(stripJsoncComments(content)) as OmoConfigData
const agents = omoConfig.agents ?? {}
if (agents["Sisyphus"]?.model === "opencode/big-pickle") {
result.hasClaude = false
result.isMax20 = false
} else if (agents["librarian"]?.model === "opencode/big-pickle") {
result.hasClaude = true
result.isMax20 = false
}
if (agents["oracle"]?.model?.startsWith("anthropic/")) {
result.hasChatGPT = false
} else if (agents["oracle"]?.model === "opencode/big-pickle") {
result.hasChatGPT = false
}
if (omoConfig.google_auth === false) {
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
}
} catch {
/* intentionally empty - malformed config returns defaults */
}
return result
}

83
src/cli/index.ts Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bun
import { Command } from "commander"
import { install } from "./install"
import { run } from "./run"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
const packageJson = await import("../../package.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")
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("--chatgpt <value>", "ChatGPT subscription: no, yes")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes
$ bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no
Model Providers:
Claude Required for Sisyphus (main orchestrator) and Librarian agents
ChatGPT Powers the Oracle agent for debugging and architecture
Gemini Powers frontend, documentation, and multimodal agents
`)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
claude: options.claude,
chatgpt: options.chatgpt,
gemini: options.gemini,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
process.exit(exitCode)
})
program
.command("run <message>")
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.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"
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) => {
const runOptions: RunOptions = {
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
}
const exitCode = await run(runOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")
.action(() => {
console.log(`oh-my-opencode v${VERSION}`)
})
program.parse()

456
src/cli/install.ts Normal file
View File

@@ -0,0 +1,456 @@
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,
setupChatGPTHotfix,
runBunInstall,
addProviderConfig,
detectCurrentConfig,
} from "./config-manager"
const SYMBOLS = {
check: color.green("✓"),
cross: color.red("✗"),
arrow: color.cyan("→"),
bullet: color.dim("•"),
info: color.blue(""),
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("ChatGPT", config.hasChatGPT))
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push("")
lines.push(color.dim("─".repeat(40)))
lines.push("")
lines.push(color.bold(color.white("Agent Configuration")))
lines.push("")
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "big-pickle"
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
const librarianModel = config.hasClaude && config.isMax20 ? "claude-sonnet-4-5" : "big-pickle"
const frontendModel = config.hasGemini ? "gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
lines.push(` ${SYMBOLS.bullet} Sisyphus ${SYMBOLS.arrow} ${color.cyan(sisyphusModel)}`)
lines.push(` ${SYMBOLS.bullet} Oracle ${SYMBOLS.arrow} ${color.cyan(oracleModel)}`)
lines.push(` ${SYMBOLS.bullet} Librarian ${SYMBOLS.arrow} ${color.cyan(librarianModel)}`)
lines.push(` ${SYMBOLS.bullet} Frontend ${SYMBOLS.arrow} ${color.cyan(frontendModel)}`)
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.chatgpt === undefined) {
errors.push("--chatgpt is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.chatgpt)) {
errors.push(`Invalid --chatgpt value: ${args.chatgpt} (expected: no, yes)`)
}
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)`)
}
return { valid: errors.length === 0, errors }
}
function argsToConfig(args: InstallArgs): InstallConfig {
return {
hasClaude: args.claude !== "no",
isMax20: args.claude === "max20",
hasChatGPT: args.chatgpt === "yes",
hasGemini: args.gemini === "yes",
}
}
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; chatgpt: BooleanArg; gemini: BooleanArg } {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
claude = detected.isMax20 ? "max20" : "yes"
}
return {
claude,
chatgpt: detected.hasChatGPT ? "yes" : "no",
gemini: detected.hasGemini ? "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/big-pickle 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 chatgpt = await p.select({
message: "Do you have a ChatGPT Plus/Pro subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Oracle will use fallback model" },
{ value: "yes" as const, label: "Yes", hint: "GPT-5.2 for debugging and architecture" },
],
initialValue: initial.chatgpt,
})
if (p.isCancel(chatgpt)) {
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
}
return {
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasChatGPT: chatgpt === "yes",
hasGemini: gemini === "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> --chatgpt=<no|yes> --gemini=<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()
if (!installed) {
printError("OpenCode is not installed on this system.")
printInfo("Visit https://opencode.ai/docs for installation instructions")
return 1
}
const version = await getOpenCodeVersion()
printSuccess(`OpenCode ${version ?? ""} detected`)
if (isUpdate) {
const initial = detectedToInitialValues(detected)
printInfo(`Current config: Claude=${initial.claude}, ChatGPT=${initial.chatgpt}, Gemini=${initial.gemini}`)
}
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
const pluginResult = addPluginToOpenCodeConfig()
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`)
if (config.hasGemini || config.hasChatGPT) {
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
}
if (config.hasChatGPT) {
printStep(step++, totalSteps, "Setting up ChatGPT hotfix...")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
printError(`Failed: ${hotfixResult.error}`)
return 1
}
printSuccess(`Hotfix configured ${SYMBOLS.arrow} ${color.dim(hotfixResult.configPath)}`)
printInfo("Installing dependencies with bun...")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
printSuccess("Dependencies installed")
} else {
printWarning("bun install failed - run manually: cd ~/.config/opencode && bun i")
}
} else {
step++
}
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 && !config.hasChatGPT && !config.hasGemini) {
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
}
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
console.log(color.bold("Next Steps - Authenticate your providers:"))
console.log()
if (config.hasClaude) {
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`)
}
if (config.hasChatGPT) {
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`)
}
if (config.hasGemini) {
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`)
}
console.log()
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
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}, ChatGPT=${initial.chatgpt}, Gemini=${initial.gemini}`)
}
const s = p.spinner()
s.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
if (!installed) {
s.stop("OpenCode is not installed")
p.log.error("OpenCode is not installed on this system.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
p.outro(color.red("Please install OpenCode first."))
return 1
}
const version = await getOpenCodeVersion()
s.stop(`OpenCode ${version ?? "installed"} ${color.green("✓")}`)
const config = await runTuiMode(detected)
if (!config) return 1
s.start("Adding oh-my-opencode to OpenCode config")
const pluginResult = addPluginToOpenCodeConfig()
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 || config.hasChatGPT) {
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)}`)
}
if (config.hasChatGPT) {
s.start("Setting up ChatGPT hotfix")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
s.stop(`Failed to setup hotfix: ${hotfixResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Hotfix configured in ${color.cyan(hotfixResult.configPath)}`)
s.start("Installing dependencies with bun")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
s.stop("Dependencies installed")
} else {
s.stop(color.yellow("bun install failed - run manually: cd ~/.config/opencode && bun i"))
}
}
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 && !config.hasChatGPT && !config.hasGemini) {
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
const steps: string[] = []
if (config.hasClaude) {
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`)
}
if (config.hasChatGPT) {
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`)
}
if (config.hasGemini) {
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`)
}
p.note(steps.join("\n"), "Next Steps - Authenticate your providers")
}
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`)
p.outro(color.green("oMoMoMoMo... Enjoy!"))
return 0
}

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
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("checkCompletionConditions", () => {
it("returns true when no todos and no children", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext()
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when incomplete todos exist", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
todo: [
{ id: "1", content: "Done", status: "completed", priority: "high" },
{ id: "2", content: "WIP", status: "in_progress", priority: "high" },
],
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all todos completed or cancelled", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
todo: [
{ id: "1", content: "Done", status: "completed", priority: "high" },
{ id: "2", content: "Skip", status: "cancelled", priority: "medium" },
],
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when child session is busy", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: { "child-1": { type: "busy" } },
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all children idle", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }, { id: "child-2" }],
"child-1": [],
"child-2": [],
},
statuses: {
"child-1": { type: "idle" },
"child-2": { type: "idle" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when grandchild is busy (recursive)", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [{ id: "grandchild-1" }],
"grandchild-1": [],
},
statuses: {
"child-1": { type: "idle" },
"grandchild-1": { type: "busy" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all descendants idle (recursive)", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [{ id: "grandchild-1" }],
"grandchild-1": [{ id: "great-grandchild-1" }],
"great-grandchild-1": [],
},
statuses: {
"child-1": { type: "idle" },
"grandchild-1": { type: "idle" },
"great-grandchild-1": { type: "idle" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
})

79
src/cli/run/completion.ts Normal file
View File

@@ -0,0 +1,79 @@
import pc from "picocolors"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> {
try {
if (!await areAllTodosComplete(ctx)) {
return false
}
if (!await areAllChildrenIdle(ctx)) {
return false
}
return true
} catch (err) {
console.error(pc.red(`[completion] API error: ${err}`))
return false
}
}
async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } })
const todos = (todosRes.data ?? []) as Todo[]
const incompleteTodos = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (incompleteTodos.length > 0) {
console.log(pc.dim(` Waiting: ${incompleteTodos.length} todos remaining`))
return false
}
return true
}
async function areAllChildrenIdle(ctx: RunContext): Promise<boolean> {
const allStatuses = await fetchAllStatuses(ctx)
return areAllDescendantsIdle(ctx, ctx.sessionID, allStatuses)
}
async function fetchAllStatuses(
ctx: RunContext
): Promise<Record<string, SessionStatus>> {
const statusRes = await ctx.client.session.status()
return (statusRes.data ?? {}) as Record<string, SessionStatus>
}
async function areAllDescendantsIdle(
ctx: RunContext,
sessionID: string,
allStatuses: Record<string, SessionStatus>
): Promise<boolean> {
const childrenRes = await ctx.client.session.children({
path: { id: sessionID },
})
const children = (childrenRes.data ?? []) as ChildSession[]
for (const child of children) {
const status = allStatuses[child.id]
if (status && status.type !== "idle") {
console.log(
pc.dim(` Waiting: session ${child.id.slice(0, 8)}... is ${status.type}`)
)
return false
}
const descendantsIdle = await areAllDescendantsIdle(
ctx,
child.id,
allStatuses
)
if (!descendantsIdle) {
return false
}
}
return true
}

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from "bun:test"
import { createEventState, type EventState } from "./events"
import type { RunContext, EventPayload } from "./types"
const createMockContext = (sessionID: string = "test-session"): RunContext => ({
client: {} as RunContext["client"],
sessionID,
directory: "/test",
abortController: new AbortController(),
})
async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) {
yield item
}
}
describe("createEventState", () => {
it("creates initial state with correct defaults", () => {
// #given / #when
const state = createEventState()
// #then
expect(state.mainSessionIdle).toBe(false)
expect(state.lastOutput).toBe("")
expect(state.lastPartText).toBe("")
expect(state.currentTool).toBe(null)
})
})
describe("event handling", () => {
it("session.idle sets mainSessionIdle to true for matching session", async () => {
// #given
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "session.idle",
properties: { sessionID: "my-session" },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.mainSessionIdle).toBe(true)
})
it("session.idle does not affect state for different session", async () => {
// #given
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "session.idle",
properties: { sessionID: "other-session" },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.mainSessionIdle).toBe(false)
})
it("session.status with busy type sets mainSessionIdle to false", async () => {
// #given
const ctx = createMockContext("my-session")
const state: EventState = {
mainSessionIdle: true,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
}
const payload: EventPayload = {
type: "session.status",
properties: { sessionID: "my-session", status: { type: "busy" } },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.mainSessionIdle).toBe(false)
})
})

279
src/cli/run/events.ts Normal file
View File

@@ -0,0 +1,279 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
SessionIdleProps,
SessionStatusProps,
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
} from "./types"
export interface EventState {
mainSessionIdle: boolean
mainSessionError: boolean
lastError: string | null
lastOutput: string
lastPartText: string
currentTool: string | null
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
}
}
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]")
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
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 role = partProps?.info?.role ?? "unknown"
const part = partProps?.part
if (part?.type === "text" && part.text) {
const preview = part.text.slice(0, 100).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.part (${role}): "${preview}${part.text.length > 100 ? "..." : ""}"`)
)
} else 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}]`)
)
}
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const content = msgProps?.content ?? ""
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
)
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
}
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 = props?.error
? String(props.error instanceof Error ? props.error.message : props.error)
: "Unknown 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.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
const content = props.content
if (!content || content === state.lastOutput) return
if (state.lastPartText.length === 0) {
const newContent = content.slice(state.lastOutput.length)
if (newContent) {
process.stdout.write(newContent)
}
}
state.lastOutput = content
}
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))}`
}
}
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 = ""
}

2
src/cli/run/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { run } from "./runner"
export type { RunOptions, RunContext } from "./types"

125
src/cli/run/runner.ts Normal file
View File

@@ -0,0 +1,125 @@
import { createOpencode } from "@opencode-ai/sdk"
import pc from "picocolors"
import type { RunOptions, RunContext } from "./types"
import { checkCompletionConditions } from "./completion"
import { createEventState, processEvents } from "./events"
const POLL_INTERVAL_MS = 500
const DEFAULT_TIMEOUT_MS = 0
export async function run(options: RunOptions): Promise<number> {
const {
message,
agent,
directory = process.cwd(),
timeout = DEFAULT_TIMEOUT_MS,
} = options
console.log(pc.cyan("Starting opencode server..."))
const abortController = new AbortController()
let timeoutId: ReturnType<typeof setTimeout> | null = null
// timeout=0 means no timeout (run until completion)
if (timeout > 0) {
timeoutId = setTimeout(() => {
console.log(pc.yellow("\nTimeout reached. Aborting..."))
abortController.abort()
}, timeout)
}
try {
const { client, server } = await createOpencode({
signal: abortController.signal,
})
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
server.close()
}
process.on("SIGINT", () => {
console.log(pc.yellow("\nInterrupted. Shutting down..."))
cleanup()
process.exit(130)
})
try {
const sessionRes = await client.session.create({
body: { title: "oh-my-opencode run" },
})
const sessionID = sessionRes.data?.id
if (!sessionID) {
console.error(pc.red("Failed to create session"))
return 1
}
console.log(pc.dim(`Session: ${sessionID}`))
const ctx: RunContext = {
client,
sessionID,
directory,
abortController,
}
const events = await client.event.subscribe()
const eventState = createEventState()
const eventProcessor = processEvents(ctx, events.stream, eventState)
console.log(pc.dim("\nSending prompt..."))
await client.session.promptAsync({
path: { id: sessionID },
body: {
agent,
parts: [{ type: "text", text: message }],
},
query: { directory },
})
console.log(pc.dim("Waiting for completion...\n"))
while (!abortController.signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
if (!eventState.mainSessionIdle) {
continue
}
// Check if session errored - exit with failure if so
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."))
abortController.abort()
await eventProcessor.catch(() => {})
cleanup()
return 1
}
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
console.log(pc.green("\n\nAll tasks completed."))
abortController.abort()
await eventProcessor.catch(() => {})
cleanup()
return 0
}
}
await eventProcessor.catch(() => {})
cleanup()
return 130
} catch (err) {
cleanup()
throw err
}
} catch (err) {
if (timeoutId) clearTimeout(timeoutId)
if (err instanceof Error && err.name === "AbortError") {
return 130
}
console.error(pc.red(`Error: ${err}`))
return 1
}
}

76
src/cli/run/types.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { OpencodeClient } from "@opencode-ai/sdk"
export interface RunOptions {
message: string
agent?: string
directory?: string
timeout?: number
}
export interface RunContext {
client: OpencodeClient
sessionID: string
directory: string
abortController: AbortController
}
export interface Todo {
id: string
content: string
status: string
priority: string
}
export interface SessionStatus {
type: "idle" | "busy" | "retry"
}
export interface ChildSession {
id: string
}
export interface EventPayload {
type: string
properties?: Record<string, unknown>
}
export interface SessionIdleProps {
sessionID?: string
}
export interface SessionStatusProps {
sessionID?: string
status?: { type?: string }
}
export interface MessageUpdatedProps {
info?: { sessionID?: string; role?: string }
content?: string
}
export interface MessagePartUpdatedProps {
info?: { sessionID?: string; role?: string }
part?: {
type?: string
text?: string
name?: string
input?: unknown
}
}
export interface ToolExecuteProps {
sessionID?: string
name?: string
input?: Record<string, unknown>
}
export interface ToolResultProps {
sessionID?: string
name?: string
output?: string
}
export interface SessionErrorProps {
sessionID?: string
error?: unknown
}

31
src/cli/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export type ClaudeSubscription = "no" | "yes" | "max20"
export type BooleanArg = "no" | "yes"
export interface InstallArgs {
tui: boolean
claude?: ClaudeSubscription
chatgpt?: BooleanArg
gemini?: BooleanArg
skipAuth?: boolean
}
export interface InstallConfig {
hasClaude: boolean
isMax20: boolean
hasChatGPT: boolean
hasGemini: boolean
}
export interface ConfigMergeResult {
success: boolean
configPath: string
error?: string
}
export interface DetectedConfig {
isInstalled: boolean
hasClaude: boolean
isMax20: boolean
hasChatGPT: boolean
hasGemini: boolean
}

View File

@@ -30,6 +30,7 @@ export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"Sisyphus",
"OpenCode-Builder",
"Planner-Sisyphus",
"oracle",
"librarian",
@@ -70,6 +71,7 @@ export const AgentOverrideConfigSchema = z.object({
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
prompt: z.string().optional(),
prompt_append: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
description: z.string().optional(),
@@ -85,6 +87,7 @@ export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
Sisyphus: AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
"Planner-Sisyphus": AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
@@ -104,6 +107,9 @@ export const ClaudeCodeConfigSchema = z.object({
export const SisyphusAgentConfigSchema = z.object({
disabled: z.boolean().optional(),
default_builder_enabled: z.boolean().optional(),
planner_enabled: z.boolean().optional(),
replace_plan: z.boolean().optional(),
})
export const ExperimentalConfigSchema = z.object({
@@ -113,8 +119,8 @@ export const ExperimentalConfigSchema = z.object({
preemptive_compaction: z.boolean().optional(),
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: false) */
truncate_all_tool_outputs: z.boolean().optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
truncate_all_tool_outputs: z.boolean().default(true),
})
export const OhMyOpenCodeConfigSchema = z.object({

78
src/features/AGENTS.md Normal file
View File

@@ -0,0 +1,78 @@
# FEATURES KNOWLEDGE BASE
## OVERVIEW
Claude Code compatibility layer and core feature modules. Enables Claude Code configs/commands/skills/MCPs/hooks to work seamlessly in OpenCode.
## STRUCTURE
```
features/
├── background-agent/ # Background task management
│ ├── manager.ts # Task lifecycle, notifications
│ ├── manager.test.ts
│ └── types.ts
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
│ └── env-expander.ts # ${VAR} expansion
├── claude-code-session-state/ # Session state persistence
├── claude-code-skill-loader/ # Load skills from ~/.claude/skills/*/SKILL.md
└── hook-message-injector/ # Inject messages into conversation
```
## LOADER PRIORITY
Each loader reads from multiple directories (highest priority first):
| Loader | Priority Order |
|--------|---------------|
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
| Skills | `.claude/skills/` > `~/.claude/skills/` |
| Agents | `.claude/agents/` > `~/.claude/agents/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
## HOW TO ADD A LOADER
1. Create directory: `src/features/claude-code-my-loader/`
2. Create files:
- `loader.ts`: Main loader logic with `load()` function
- `types.ts`: TypeScript interfaces
- `index.ts`: Barrel export
3. Pattern: Read from multiple dirs, merge with priority, return normalized config
## BACKGROUND AGENT SPECIFICS
- **Task lifecycle**: pending → running → completed/failed
- **Notifications**: OS notification on task complete (configurable)
- **Result retrieval**: `background_output` tool with task_id
- **Cancellation**: `background_cancel` with task_id or all=true
## CONFIG TOGGLES
Disable features in `oh-my-opencode.json`:
```json
{
"claude_code": {
"mcp": false, // Skip .mcp.json loading
"commands": false, // Skip commands/*.md loading
"skills": false, // Skip skills/*/SKILL.md loading
"agents": false, // Skip agents/*.md loading
"hooks": false // Skip settings.json hooks
}
}
```
## HOOK MESSAGE INJECTOR
- **Purpose**: Inject system messages into conversation at specific points
- **Timing**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
- **Format**: Returns `{ messages: [{ role: "user", content: "..." }] }`
## ANTI-PATTERNS (FEATURES)
- **Blocking on load**: Loaders run at startup, keep them fast
- **No error handling**: Always try/catch, log failures, return empty on error
- **Ignoring priority**: Higher priority dirs must override lower
- **Modifying user files**: Loaders read-only, never write to ~/.claude/

View File

@@ -99,6 +99,7 @@ export class BackgroundManager {
toolCalls: 0,
lastUpdate: new Date(),
},
parentModel: input.parentModel,
}
this.tasks.set(task.id, task)
@@ -322,10 +323,16 @@ export class BackgroundManager {
const messageDir = getMessageDir(task.parentSessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const modelContext = task.parentModel ?? prevMessage?.model
const modelField = modelContext?.providerID && modelContext?.modelID
? { providerID: modelContext.providerID, modelID: modelContext.modelID }
: undefined
await this.client.session.prompt({
path: { id: task.parentSessionID },
body: {
agent: prevMessage?.agent,
model: modelField,
parts: [{ type: "text", text: message }],
},
query: { directory: this.directory },

View File

@@ -26,6 +26,7 @@ export interface BackgroundTask {
result?: string
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
}
export interface LaunchInput {
@@ -34,4 +35,5 @@ export interface LaunchInput {
agent: string
parentSessionID: string
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
}

View File

@@ -1,8 +1,6 @@
import { join } from "node:path"
import { homedir } from "node:os"
import { getOpenCodeStorageDir } from "../../shared/data-path"
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage")
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")

83
src/hooks/AGENTS.md Normal file
View File

@@ -0,0 +1,83 @@
# HOOKS KNOWLEDGE BASE
## OVERVIEW
Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce rules, recover from errors, notify on events.
## STRUCTURE
```
hooks/
├── agent-usage-reminder/ # Remind to use specialized agents
├── anthropic-auto-compact/ # Auto-compact Claude at token limit
├── auto-update-checker/ # Version update notifications
├── background-notification/ # OS notify on background task complete
├── claude-code-hooks/ # Claude Code settings.json integration
├── comment-checker/ # Prevent excessive AI comments
│ ├── filters/ # Filtering rules (docstring, directive, bdd, etc.)
│ └── output/ # Output formatting
├── compaction-context-injector/ # Inject context during compaction
├── directory-agents-injector/ # Auto-inject AGENTS.md files
├── directory-readme-injector/ # Auto-inject README.md files
├── empty-message-sanitizer/ # Sanitize empty messages
├── interactive-bash-session/ # Tmux session management
├── keyword-detector/ # Detect ultrawork/search keywords
├── non-interactive-env/ # CI/headless environment handling
├── preemptive-compaction/ # Pre-emptive session compaction
├── rules-injector/ # Conditional rules from .claude/rules/
├── session-recovery/ # Recover from session errors
├── think-mode/ # Auto-detect thinking triggers
├── context-window-monitor.ts # Monitor context usage (standalone)
├── empty-task-response-detector.ts
├── session-notification.ts # OS notify on idle (standalone)
├── todo-continuation-enforcer.ts # Force TODO completion (standalone)
└── tool-output-truncator.ts # Truncate verbose outputs (standalone)
```
## HOOK CATEGORIES
| Category | Hooks | Purpose |
|----------|-------|---------|
| Context Injection | directory-agents-injector, directory-readme-injector, rules-injector, compaction-context-injector | Auto-inject relevant context |
| Session Management | session-recovery, anthropic-auto-compact, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
| Output Control | comment-checker, tool-output-truncator | Control agent output quality |
| Notifications | session-notification, background-notification, auto-update-checker | OS/user notifications |
| Behavior Enforcement | todo-continuation-enforcer, keyword-detector, think-mode, agent-usage-reminder | Enforce agent behavior |
| Environment | non-interactive-env, interactive-bash-session, context-window-monitor | Adapt to runtime environment |
| Compatibility | claude-code-hooks | Claude Code settings.json support |
## HOW TO ADD A HOOK
1. Create directory: `src/hooks/my-hook/`
2. Create files:
- `index.ts`: Export `createMyHook(input: PluginInput)`
- `constants.ts`: Hook name constant
- `types.ts`: TypeScript interfaces (optional)
- `storage.ts`: Persistent state (optional)
3. Return event handlers: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
4. Export from `src/hooks/index.ts`
5. Register in main plugin
## HOOK EVENTS
| Event | Timing | Can Block | Use Case |
|-------|--------|-----------|----------|
| PreToolUse | Before tool exec | Yes | Validate, modify input |
| PostToolUse | After tool exec | No | Add context, warnings |
| UserPromptSubmit | On user prompt | Yes | Inject messages, block |
| Stop | Session idle | No | Inject follow-ups |
| onSummarize | During compaction | No | Preserve critical context |
## COMMON PATTERNS
- **Storage**: Use `storage.ts` with JSON file for persistent state across sessions
- **Once-per-session**: Track injected paths in Set to avoid duplicate injection
- **Message injection**: Return `{ messages: [...] }` from event handlers
- **Blocking**: Return `{ blocked: true, message: "reason" }` from PreToolUse
## ANTI-PATTERNS (HOOKS)
- **Heavy computation** in PreToolUse: Slows every tool call
- **Blocking without clear reason**: Always provide actionable message
- **Duplicate injection**: Track what's already injected per session
- **Ignoring errors**: Always try/catch, log failures, don't crash session

View File

@@ -0,0 +1,260 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types"
describe("executeCompact lock management", () => {
let autoCompactState: AutoCompactState
let mockClient: any
const sessionID = "test-session-123"
const directory = "/test/dir"
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
beforeEach(() => {
// #given: Fresh state for each test
autoCompactState = {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
retryStateBySession: new Map(),
fallbackStateBySession: new Map(),
truncateStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
mockClient = {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
prompt_async: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
}
})
test("clears lock on successful summarize completion", async () => {
// #given: Valid session with providerID/modelID
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction successfully
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when summarize throws exception", async () => {
// #given: Summarize will fail
mockClient.session.summarize = mock(() =>
Promise.reject(new Error("Network timeout")),
)
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should still be cleared despite exception
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when revert throws exception", async () => {
// #given: Force revert path by exhausting retry attempts and making revert fail
mockClient.session.revert = mock(() =>
Promise.reject(new Error("Revert failed")),
)
mockClient.session.messages = mock(() =>
Promise.resolve({
data: [
{ info: { id: "msg1", role: "user" } },
{ info: { id: "msg2", role: "assistant" } },
],
}),
)
// Exhaust retry attempts
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 5,
lastAttemptTime: Date.now(),
})
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock cleared even though revert failed
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("shows toast when lock already held", async () => {
// #given: Lock already held
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown with warning message
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
title: "Compact In Progress",
message: expect.stringContaining("Recovery already running"),
variant: "warning",
}),
}),
)
// #then: compactionInProgress should still have the lock
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock when fixEmptyMessages path executes", async () => {
// #given: Empty content error scenario
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "non-empty content required",
messageIndex: 0,
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction (fixEmptyMessages will be called)
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when truncation is sufficient", async () => {
// #given: Aggressive truncation scenario with sufficient truncation
// This test verifies the early return path in aggressive truncation
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
maxTokens: 200000,
})
const experimental = {
truncate_all_tool_outputs: false,
aggressive_truncation: true,
}
// #when: Execute compaction with experimental flag
await executeCompact(
sessionID,
msg,
autoCompactState,
mockClient,
directory,
experimental,
)
// #then: Lock should be cleared even on early return
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("prevents concurrent compaction attempts", async () => {
// #given: Lock already held (simpler test)
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction while lock is held
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const blockedToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Compact In Progress",
)
expect(blockedToast).toBeDefined()
// #then: Lock should still be held (not cleared by blocked attempt)
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock after max recovery attempts exhausted", async () => {
// #given: All retry/revert attempts exhausted
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
// Max out all attempts
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 5,
lastAttemptTime: Date.now(),
})
autoCompactState.fallbackStateBySession.set(sessionID, {
revertAttempt: 5,
})
autoCompactState.truncateStateBySession.set(sessionID, {
truncateAttempt: 5,
})
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Should show failure toast
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const failureToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
)
expect(failureToast).toBeDefined()
// #then: Lock should still be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when client.tui.showToast throws", async () => {
// #given: Toast will fail (this should never happen but testing robustness)
mockClient.tui.showToast = mock(() =>
Promise.reject(new Error("Toast failed")),
)
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared even if toast fails
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when prompt_async in continuation throws", async () => {
// #given: prompt_async will fail during continuation
mockClient.session.prompt_async = mock(() =>
Promise.reject(new Error("Prompt failed")),
)
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600))
// #then: Lock should be cleared
// The continuation happens in setTimeout, but lock is cleared in finally before that
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ export interface DisabledHooksConfig {
PreToolUse?: string[]
PostToolUse?: string[]
UserPromptSubmit?: string[]
PreCompact?: string[]
}
export interface PluginExtendedConfig {
@@ -47,6 +48,7 @@ function mergeDisabledHooks(
PreToolUse: override.PreToolUse ?? base.PreToolUse,
PostToolUse: override.PostToolUse ?? base.PostToolUse,
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
PreCompact: override.PreCompact ?? base.PreCompact,
}
}

View File

@@ -14,6 +14,7 @@ interface RawClaudeHooksConfig {
PostToolUse?: RawHookMatcher[]
UserPromptSubmit?: RawHookMatcher[]
Stop?: RawHookMatcher[]
PreCompact?: RawHookMatcher[]
}
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
@@ -30,6 +31,7 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
"PostToolUse",
"UserPromptSubmit",
"Stop",
"PreCompact",
]
for (const eventType of eventTypes) {
@@ -66,6 +68,7 @@ function mergeHooksConfig(
"PostToolUse",
"UserPromptSubmit",
"Stop",
"PreCompact",
]
for (const eventType of eventTypes) {
if (override[eventType]) {

View File

@@ -19,6 +19,10 @@ import {
executeStopHooks,
type StopContext,
} from "./stop"
import {
executePreCompactHooks,
type PreCompactContext,
} from "./pre-compact"
import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
import type { PluginConfig } from "./types"
@@ -31,6 +35,35 @@ const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
return {
"experimental.session.compacting": async (
input: { sessionID: string },
output: { context: string[] }
): Promise<void> => {
if (isHookDisabled(config, "PreCompact")) {
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const preCompactCtx: PreCompactContext = {
sessionId: input.sessionID,
cwd: ctx.directory,
}
const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig)
if (result.context.length > 0) {
log("PreCompact hooks injecting context", {
sessionID: input.sessionID,
contextCount: result.context.length,
hookName: result.hookName,
elapsedMs: result.elapsedMs,
})
output.context.push(...result.context)
}
},
"chat.message": async (
input: {
sessionID: string
@@ -184,7 +217,13 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
recordToolResult(input.sessionID, input.tool, cachedInput, (output.metadata as Record<string, unknown>) || {})
// Use metadata if available and non-empty, otherwise wrap output.output in a structured object
// This ensures plugin tools (call_omo_agent, background_task, task) that return strings
// get their results properly recorded in transcripts instead of empty {}
const metadata = output.metadata as Record<string, unknown> | undefined
const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0
const toolOutput = hasMetadata ? metadata : { output: output.output }
recordToolResult(input.sessionID, input.tool, cachedInput, toolOutput)
if (!isHookDisabled(config, "PostToolUse")) {
const postClient: PostToolUseClient = {

View File

@@ -0,0 +1,109 @@
import type {
PreCompactInput,
PreCompactOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreCompactContext {
sessionId: string
cwd: string
}
export interface PreCompactResult {
context: string[]
elapsedMs?: number
hookName?: string
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
export async function executePreCompactHooks(
ctx: PreCompactContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PreCompactResult> {
if (!config) {
return { context: [] }
}
const matchers = findMatchingHooks(config, "PreCompact", "*")
if (matchers.length === 0) {
return { context: [] }
}
const stdinData: PreCompactInput = {
session_id: ctx.sessionId,
cwd: ctx.cwd,
hook_event_name: "PreCompact",
hook_source: "opencode-plugin",
}
const startTime = Date.now()
let firstHookName: string | undefined
const collectedContext: string[] = []
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) {
log("PreCompact hook command skipped (disabled by config)", { command: hook.command })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.exitCode === 2) {
log("PreCompact hook blocked", { hookName, stderr: result.stderr })
continue
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreCompactOutput
if (output.hookSpecificOutput?.additionalContext) {
collectedContext.push(...output.hookSpecificOutput.additionalContext)
} else if (output.context) {
collectedContext.push(...output.context)
}
if (output.continue === false) {
return {
context: collectedContext,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
if (result.stdout.trim()) {
collectedContext.push(result.stdout.trim())
}
}
}
}
}
return {
context: collectedContext,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
}
}

View File

@@ -8,6 +8,7 @@ export type ClaudeHookEvent =
| "PostToolUse"
| "UserPromptSubmit"
| "Stop"
| "PreCompact"
export interface HookMatcher {
matcher: string
@@ -24,6 +25,7 @@ export interface ClaudeHooksConfig {
PostToolUse?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Stop?: HookMatcher[]
PreCompact?: HookMatcher[]
}
export interface PreToolUseInput {
@@ -82,6 +84,13 @@ export interface StopInput {
hook_source?: HookSource
}
export interface PreCompactInput {
session_id: string
cwd: string
hook_event_name: "PreCompact"
hook_source?: HookSource
}
export type PermissionDecision = "allow" | "deny" | "ask"
/**
@@ -166,6 +175,16 @@ export interface StopOutput {
inject_prompt?: string
}
export interface PreCompactOutput extends HookCommonOutput {
/** Additional context to inject into compaction prompt */
context?: string[]
hookSpecificOutput?: {
hookEventName: "PreCompact"
/** Additional context strings to inject */
additionalContext?: string[]
}
}
export type ClaudeCodeContent =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }

View File

@@ -7,6 +7,7 @@ import {
clearInjectedPaths,
} from "./storage";
import { AGENTS_FILENAME } from "./constants";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
interface ToolExecuteInput {
tool: string;
@@ -39,6 +40,7 @@ interface EventInput {
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
const sessionCaches = new Map<string, Set<string>>();
const pendingBatchReads = new Map<string, string[]>();
const truncator = createDynamicTruncator(ctx);
function getSessionCache(sessionID: string): Set<string> {
if (!sessionCaches.has(sessionID)) {
@@ -73,11 +75,11 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
return found.reverse();
}
function processFilePathForInjection(
async function processFilePathForInjection(
filePath: string,
sessionID: string,
output: ToolExecuteOutput,
): void {
): Promise<void> {
const resolved = resolveFilePath(filePath);
if (!resolved) return;
@@ -91,7 +93,11 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
try {
const content = readFileSync(agentsPath, "utf-8");
output.output += `\n\n[Directory Context: ${agentsPath}]\n${content}`;
const { result, truncated } = await truncator.truncate(sessionID, content);
const truncationNotice = truncated
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
: "";
output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
cache.add(agentsDir);
} catch {}
}
@@ -127,7 +133,7 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
const toolName = input.tool.toLowerCase();
if (toolName === "read") {
processFilePathForInjection(output.title, input.sessionID, output);
await processFilePathForInjection(output.title, input.sessionID, output);
return;
}
@@ -135,7 +141,7 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
const filePaths = pendingBatchReads.get(input.callID);
if (filePaths) {
for (const filePath of filePaths) {
processFilePathForInjection(filePath, input.sessionID, output);
await processFilePathForInjection(filePath, input.sessionID, output);
}
pendingBatchReads.delete(input.callID);
}

View File

@@ -7,6 +7,7 @@ import {
clearInjectedPaths,
} from "./storage";
import { README_FILENAME } from "./constants";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
interface ToolExecuteInput {
tool: string;
@@ -39,6 +40,7 @@ interface EventInput {
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
const sessionCaches = new Map<string, Set<string>>();
const pendingBatchReads = new Map<string, string[]>();
const truncator = createDynamicTruncator(ctx);
function getSessionCache(sessionID: string): Set<string> {
if (!sessionCaches.has(sessionID)) {
@@ -73,11 +75,11 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
return found.reverse();
}
function processFilePathForInjection(
async function processFilePathForInjection(
filePath: string,
sessionID: string,
output: ToolExecuteOutput,
): void {
): Promise<void> {
const resolved = resolveFilePath(filePath);
if (!resolved) return;
@@ -91,7 +93,11 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
try {
const content = readFileSync(readmePath, "utf-8");
output.output += `\n\n[Project README: ${readmePath}]\n${content}`;
const { result, truncated } = await truncator.truncate(sessionID, content);
const truncationNotice = truncated
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]`
: "";
output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`;
cache.add(readmeDir);
} catch {}
}
@@ -127,7 +133,7 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
const toolName = input.tool.toLowerCase();
if (toolName === "read") {
processFilePathForInjection(output.title, input.sessionID, output);
await processFilePathForInjection(output.title, input.sessionID, output);
return;
}
@@ -135,7 +141,7 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
const filePaths = pendingBatchReads.get(input.callID);
if (filePaths) {
for (const filePath of filePaths) {
processFilePathForInjection(filePath, input.sessionID, output);
await processFilePathForInjection(filePath, input.sessionID, output);
}
pendingBatchReads.delete(input.callID);
}

View File

@@ -33,6 +33,7 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## ZERO TOLERANCE FAILURES
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified

View File

@@ -25,11 +25,6 @@ export function createKeywordDetectorHook() {
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
sessionFirstMessageProcessed.add(input.sessionID)
if (isFirstMessage) {
log("Skipping keyword detection on first message for title generation", { sessionID: input.sessionID })
return
}
const promptText = extractPromptText(output.parts)
const messages = detectKeywords(promptText)
@@ -37,6 +32,19 @@ export function createKeywordDetectorHook() {
return
}
const context = messages.join("\n")
// First message: transform parts directly (for title generation compatibility)
if (isFirstMessage) {
log(`Keywords detected on first message, transforming parts directly`, { sessionID: input.sessionID, keywordCount: messages.length })
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx >= 0) {
output.parts[idx].text = `${context}\n\n---\n\n${output.parts[idx].text ?? ""}`
}
return
}
// Subsequent messages: inject as separate message
log(`Keywords detected: ${messages.length}`, { sessionID: input.sessionID })
const message = output.message as {
@@ -46,7 +54,6 @@ export function createKeywordDetectorHook() {
tools?: Record<string, boolean>
}
const context = messages.join("\n")
log(`[keyword-detector] Injecting context for ${messages.length} keywords`, { sessionID: input.sessionID, contextLength: context.length })
const success = injectHookMessage(input.sessionID, context, {
agent: message.agent,

View File

@@ -0,0 +1,19 @@
export function isNonInteractive(): boolean {
if (process.env.CI === "true" || process.env.CI === "1") {
return true
}
if (process.env.OPENCODE_RUN === "true" || process.env.OPENCODE_NON_INTERACTIVE === "true") {
return true
}
if (process.env.GITHUB_ACTIONS === "true") {
return true
}
if (process.stdout.isTTY !== true) {
return true
}
return false
}

View File

@@ -3,6 +3,7 @@ import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./consta
import { log } from "../../shared"
export * from "./constants"
export * from "./detector"
export * from "./types"
const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned

View File

@@ -184,9 +184,15 @@ export function createPreemptiveCompactionHook(
setTimeout(async () => {
try {
const messageDir = getMessageDir(sessionID)
const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
body: {
agent: storedMessage?.agent,
parts: [{ type: "text", text: "Continue" }],
},
query: { directory: ctx.directory },
})
} catch {}

View File

@@ -15,6 +15,7 @@ import {
loadInjectedRules,
saveInjectedRules,
} from "./storage";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
interface ToolExecuteInput {
tool: string;
@@ -59,6 +60,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
{ contentHashes: Set<string>; realPaths: Set<string> }
>();
const pendingBatchFiles = new Map<string, string[]>();
const truncator = createDynamicTruncator(ctx);
function getSessionCache(sessionID: string): {
contentHashes: Set<string>;
@@ -76,11 +78,11 @@ export function createRulesInjectorHook(ctx: PluginInput) {
return resolve(ctx.directory, path);
}
function processFilePathForInjection(
async function processFilePathForInjection(
filePath: string,
sessionID: string,
output: ToolExecuteOutput
): void {
): Promise<void> {
const resolved = resolveFilePath(filePath);
if (!resolved) return;
@@ -125,7 +127,11 @@ export function createRulesInjectorHook(ctx: PluginInput) {
toInject.sort((a, b) => a.distance - b.distance);
for (const rule of toInject) {
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${rule.content}`;
const { result, truncated } = await truncator.truncate(sessionID, rule.content);
const truncationNotice = truncated
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
: "";
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
}
saveInjectedRules(sessionID, cache);
@@ -167,7 +173,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
const toolName = input.tool.toLowerCase();
if (TRACKED_TOOLS.includes(toolName)) {
processFilePathForInjection(output.title, input.sessionID, output);
await processFilePathForInjection(output.title, input.sessionID, output);
return;
}
@@ -175,7 +181,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
const filePaths = pendingBatchFiles.get(input.callID);
if (filePaths) {
for (const filePath of filePaths) {
processFilePathForInjection(filePath, input.sessionID, output);
await processFilePathForInjection(filePath, input.sessionID, output);
}
pendingBatchFiles.delete(input.callID);
}

View File

@@ -0,0 +1,349 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
describe("session-notification", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string) => {
// #given - track notification commands (osascript, notify-send, powershell)
const cmdStr = typeof cmd === "string" ? cmd : cmd.join("")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
return { stdout: "", stderr: "", exitCode: 0 }
},
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
} as any
}
beforeEach(() => {
// #given - reset state before each test
notificationCalls = []
})
afterEach(() => {
// #given - cleanup after each test
subagentSessions.clear()
setMainSession(undefined)
})
test("should not trigger notification for subagent session", async () => {
// #given - a subagent session exists
const subagentSessionID = "subagent-123"
subagentSessions.add(subagentSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// #when - subagent session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - notification should NOT be sent
expect(notificationCalls).toHaveLength(0)
})
test("should not trigger notification when mainSessionID is set and session is not main", async () => {
// #given - main session is set, but a different session goes idle
const mainSessionID = "main-123"
const otherSessionID = "other-456"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// #when - non-main session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: otherSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - notification should NOT be sent
expect(notificationCalls).toHaveLength(0)
})
test("should trigger notification for main session when idle", async () => {
// #given - main session is set
const mainSessionID = "main-789"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
})
// #when - main session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// Wait for idle confirmation delay + buffer
await new Promise((resolve) => setTimeout(resolve, 100))
// #then - notification should be sent
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
})
test("should skip notification for subagent even when mainSessionID is set", async () => {
// #given - both mainSessionID and subagent session exist
const mainSessionID = "main-999"
const subagentSessionID = "subagent-888"
setMainSession(mainSessionID)
subagentSessions.add(subagentSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// #when - subagent session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - notification should NOT be sent (subagent check takes priority)
expect(notificationCalls).toHaveLength(0)
})
test("should handle subagentSessions and mainSessionID checks in correct order", async () => {
// #given - main session and subagent session exist
const mainSessionID = "main-111"
const subagentSessionID = "subagent-222"
const unknownSessionID = "unknown-333"
setMainSession(mainSessionID)
subagentSessions.add(subagentSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// #when - subagent session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// #when - unknown session goes idle (not main, not in subagentSessions)
await hook({
event: {
type: "session.idle",
properties: { sessionID: unknownSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check)
expect(notificationCalls).toHaveLength(0)
})
test("should cancel pending notification on session activity", async () => {
// #given - main session is set
const mainSessionID = "main-cancel"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 100, // Long delay
skipIfIncompleteTodos: false,
})
// #when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// #when - activity happens before delay completes
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for original delay to pass
await new Promise((resolve) => setTimeout(resolve, 150))
// #then - notification should NOT be sent (cancelled by activity)
expect(notificationCalls).toHaveLength(0)
})
test("should handle session.created event without notification", async () => {
// #given - a new session is created
const hook = createSessionNotification(createMockPluginInput(), {})
// #when - session.created event fires
await hook({
event: {
type: "session.created",
properties: {
info: { id: "new-session", title: "Test Session" },
},
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - no notification should be triggered
expect(notificationCalls).toHaveLength(0)
})
test("should handle session.deleted event and cleanup state", async () => {
// #given - a session exists
const hook = createSessionNotification(createMockPluginInput(), {})
// #when - session.deleted event fires
await hook({
event: {
type: "session.deleted",
properties: {
info: { id: "deleted-session" },
},
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - no notification should be triggered
expect(notificationCalls).toHaveLength(0)
})
test("should mark session activity on message.updated event", async () => {
// #given - main session is set
const mainSessionID = "main-message"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
})
// #when - session goes idle, then message.updated fires
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
await hook({
event: {
type: "message.updated",
properties: {
info: { sessionID: mainSessionID, role: "user", finish: false },
},
},
})
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// #then - notification should NOT be sent (activity cancelled it)
expect(notificationCalls).toHaveLength(0)
})
test("should mark session activity on tool.execute.before event", async () => {
// #given - main session is set
const mainSessionID = "main-tool"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
})
// #when - session goes idle, then tool.execute.before fires
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// #then - notification should NOT be sent (activity cancelled it)
expect(notificationCalls).toHaveLength(0)
})
test("should not send duplicate notification for same session", async () => {
// #given - main session is set
const mainSessionID = "main-dup"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
})
// #when - session goes idle twice
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// Wait for first notification
await new Promise((resolve) => setTimeout(resolve, 50))
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// Wait for second potential notification
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - only one notification should be sent
expect(notificationCalls).toHaveLength(1)
})
})

View File

@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { platform } from "os"
import { subagentSessions } from "../features/claude-code-session-state"
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
interface Todo {
content: string
@@ -243,6 +243,10 @@ export function createSessionNotification(
if (subagentSessions.has(sessionID)) return
// Only trigger notifications for the main session (not subagent sessions)
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return
if (notifiedSessions.has(sessionID)) return
if (pendingTimers.has(sessionID)) return
if (executingNotifications.has(sessionID)) return

View File

@@ -6,10 +6,16 @@ import {
findNearestMessageWithFields,
MESSAGE_STORAGE,
} from "../features/hook-message-injector"
import type { BackgroundManager } from "../features/background-agent"
import { log } from "../shared/logger"
import { isNonInteractive } from "./non-interactive-env/detector"
const HOOK_NAME = "todo-continuation-enforcer"
export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
}
export interface TodoContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
markRecovering: (sessionID: string) => void
@@ -70,12 +76,17 @@ interface CountdownState {
intervalId: ReturnType<typeof setInterval>
}
export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer {
const { backgroundManager } = options
const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>()
const recoveringSessions = new Set<string>()
const pendingCountdowns = new Map<string, CountdownState>()
const preemptivelyInjectedSessions = new Set<string>()
const markRecovering = (sessionID: string): void => {
recoveringSessions.add(sessionID)
@@ -204,6 +215,30 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
return
}
let freshTodos: Todo[] = []
try {
log(`[${HOOK_NAME}] Re-verifying todos after countdown`, { sessionID })
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
freshTodos = (response.data ?? response) as Todo[]
log(`[${HOOK_NAME}] Fresh todo count`, { sessionID, todosCount: freshTodos?.length ?? 0 })
} catch (err) {
log(`[${HOOK_NAME}] Failed to re-verify todos`, { sessionID, error: String(err) })
return
}
const freshIncomplete = freshTodos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (freshIncomplete.length === 0) {
log(`[${HOOK_NAME}] Abort: no incomplete todos after countdown`, { sessionID, total: freshTodos.length })
return
}
log(`[${HOOK_NAME}] Confirmed incomplete todos, proceeding with injection`, { sessionID, incomplete: freshIncomplete.length, total: freshTodos.length })
remindedSessions.add(sessionID)
try {
@@ -226,7 +261,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
text: `${CONTINUATION_PROMPT}\n\n[Status: ${freshTodos.length - freshIncomplete.length}/${freshTodos.length} completed, ${freshIncomplete.length} remaining]`,
},
],
},
@@ -269,7 +304,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
const finish = info?.finish as boolean | undefined
const finish = info?.finish as string | undefined
log(`[${HOOK_NAME}] message.updated received`, { sessionID, role, finish })
if (sessionID && role === "user") {
@@ -279,13 +314,75 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
pendingCountdowns.delete(sessionID)
log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID })
}
// Allow new continuation after user sends another message
remindedSessions.delete(sessionID)
preemptivelyInjectedSessions.delete(sessionID)
}
if (sessionID && role === "assistant" && finish) {
remindedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Cleared reminded state on assistant finish`, { sessionID })
preemptivelyInjectedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Cleared reminded/preemptive state on assistant finish`, { sessionID })
const isTerminalFinish = finish && !["tool-calls", "unknown"].includes(finish)
if (isTerminalFinish && isNonInteractive()) {
log(`[${HOOK_NAME}] Terminal finish in non-interactive mode`, { sessionID, finish })
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) {
log(`[${HOOK_NAME}] Skipped preemptive: not main session`, { sessionID, mainSessionID })
return
}
if (preemptivelyInjectedSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped preemptive: already injected`, { sessionID })
return
}
if (recoveringSessions.has(sessionID) || errorSessions.has(sessionID) || interruptedSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped preemptive: session in error/recovery state`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
let hasIncompleteTodos = false
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
const todos = (response.data ?? response) as Todo[]
hasIncompleteTodos = todos?.some((t) => t.status !== "completed" && t.status !== "cancelled") ?? false
} catch {
log(`[${HOOK_NAME}] Failed to fetch todos for preemptive check`, { sessionID })
}
if (hasRunningBgTasks || hasIncompleteTodos) {
log(`[${HOOK_NAME}] Preemptive injection needed`, { sessionID, hasRunningBgTasks, hasIncompleteTodos })
preemptivelyInjectedSessions.add(sessionID)
try {
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const prompt = hasRunningBgTasks
? "[SYSTEM] Background tasks are still running. Wait for their completion before proceeding."
: CONTINUATION_PROMPT
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: prevMessage?.agent,
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Preemptive injection successful`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Preemptive injection failed`, { sessionID, error: String(err) })
preemptivelyInjectedSessions.delete(sessionID)
}
}
}
}
}
@@ -296,6 +393,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id)
recoveringSessions.delete(sessionInfo.id)
preemptivelyInjectedSessions.delete(sessionInfo.id)
const countdown = pendingCountdowns.get(sessionInfo.id)
if (countdown) {

View File

@@ -24,7 +24,7 @@ interface ToolOutputTruncatorOptions {
export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOutputTruncatorOptions) {
const truncator = createDynamicTruncator(ctx)
const truncateAll = options?.experimental?.truncate_all_tool_outputs ?? false
const truncateAll = options?.experimental?.truncate_all_tool_outputs ?? true
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },

View File

@@ -116,7 +116,7 @@ function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown
return needsWrite;
}
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
@@ -130,6 +130,20 @@ function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
const errorMsg = result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join(", ");
log(`Config validation error in ${configPath}:`, result.error.issues);
addConfigLoadError({ path: configPath, error: `Validation error: ${errorMsg}` });
const errorList = result.error.issues
.map(issue => `${issue.path.join(".")}: ${issue.message}`)
.join("\n");
ctx.client.tui.showToast({
body: {
title: "❌ OhMyOpenCode: Config Validation Failed",
message: `Failed to load ${configPath}\n\nValidation errors:\n${errorList}\n\nConfig will be ignored. Please fix the errors above.`,
variant: "error" as const,
duration: 10000,
},
}).catch(() => {});
return null;
}
@@ -140,6 +154,19 @@ function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Error loading config from ${configPath}:`, err);
addConfigLoadError({ path: configPath, error: errorMsg });
const hint = err instanceof SyntaxError
? "\n\nHint: Check for syntax errors in your JSON file (missing commas, quotes, brackets, etc.)"
: "";
ctx.client.tui.showToast({
body: {
title: "❌ OhMyOpenCode: Config Load Failed",
message: `Failed to load ${configPath}\n\nError: ${errorMsg}${hint}\n\nConfig will be ignored. Please fix the error above.`,
variant: "error" as const,
duration: 10000,
},
}).catch(() => {});
}
return null;
}
@@ -174,7 +201,7 @@ function mergeConfigs(
};
}
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
// User-level config path (OS-specific)
const userConfigPath = path.join(
getUserConfigDir(),
@@ -190,10 +217,10 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
);
// Load user config first (base)
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath) ?? {};
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
// Override with project config
const projectConfig = loadConfigFromPath(projectConfigPath);
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
@@ -209,7 +236,7 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
}
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory);
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
@@ -227,9 +254,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
return undefined;
};
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
? createTodoContinuationEnforcer(ctx)
: null;
const contextWindowMonitor = isHookEnabled("context-window-monitor")
? createContextWindowMonitorHook(ctx)
: null;
@@ -240,13 +264,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createSessionNotification(ctx)
: null;
// Wire up recovery state tracking between session-recovery and todo-continuation-enforcer
// This prevents the continuation enforcer from injecting prompts during active recovery
if (sessionRecovery && todoContinuationEnforcer) {
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
}
const commentChecker = isHookEnabled("comment-checker")
? createCommentCheckerHooks()
: null;
@@ -305,6 +322,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const backgroundManager = new BackgroundManager(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
? createTodoContinuationEnforcer(ctx, { backgroundManager })
: null;
if (sessionRecovery && todoContinuationEnforcer) {
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
}
const backgroundNotificationHook = isHookEnabled("background-notification")
? createBackgroundNotificationHook(backgroundManager)
: null;
@@ -380,34 +406,67 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
// TODO: When OpenCode releases `default_agent` config option (PR #5313),
// use `config.default_agent = "Sisyphus"` instead of demoting build/plan.
// Tracking: https://github.com/sst/opencode/pull/5313
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
const plannerSisyphusBase = {
...planConfigWithoutName,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
color: config.agent?.plan?.color ?? "#6495ED",
const agentConfig: Record<string, unknown> = {
Sisyphus: builtinAgents.Sisyphus,
};
const plannerSisyphusConfig = plannerSisyphusOverride
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
: plannerSisyphusBase;
if (builderEnabled) {
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
const openCodeBuilderBase = {
...buildConfigWithoutName,
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
};
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
: openCodeBuilderBase;
}
if (plannerEnabled) {
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
const plannerSisyphusBase = {
...planConfigWithoutName,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
color: config.agent?.plan?.color ?? "#6495ED",
};
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
: plannerSisyphusBase;
}
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
const filteredConfigAgents = config.agent ?
Object.fromEntries(
Object.entries(config.agent).filter(([key]) => {
if (key === "build") return false;
if (key === "plan" && replacePlan) return false;
return true;
})
) : {};
config.agent = {
Sisyphus: builtinAgents.Sisyphus,
"Planner-Sisyphus": plannerSisyphusConfig,
...agentConfig,
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
...userAgents,
...projectAgents,
...config.agent,
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
// Demote build/plan to subagent mode when replaced
build: { ...config.agent?.build, mode: "subagent" },
plan: { ...config.agent?.plan, mode: "subagent" },
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
};
} else {
config.agent = {

29
src/shared/data-path.ts Normal file
View File

@@ -0,0 +1,29 @@
import * as path from "node:path"
import * as os from "node:os"
/**
* Returns the user-level data directory based on the OS.
* - Linux/macOS: XDG_DATA_HOME or ~/.local/share
* - Windows: %LOCALAPPDATA%
*
* This follows XDG Base Directory specification on Unix systems
* and Windows conventions on Windows.
*/
export function getDataDir(): string {
if (process.platform === "win32") {
// Windows: Use %LOCALAPPDATA% (e.g., C:\Users\Username\AppData\Local)
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local")
}
// Unix: Use XDG_DATA_HOME or fallback to ~/.local/share
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")
}
/**
* Returns the OpenCode storage directory path.
* - Linux/macOS: ~/.local/share/opencode/storage
* - Windows: %LOCALAPPDATA%\opencode\storage
*/
export function getOpenCodeStorageDir(): string {
return path.join(getDataDir(), "opencode", "storage")
}

View File

@@ -112,7 +112,10 @@ export async function getContextWindowUsage(
const lastAssistant = assistantMessages[assistantMessages.length - 1]
const lastTokens = lastAssistant.tokens
const usedTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const usedTokens =
(lastTokens?.input ?? 0) +
(lastTokens?.cache?.read ?? 0) +
(lastTokens?.output ?? 0)
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - usedTokens
return {

View File

@@ -11,4 +11,5 @@ export * from "./deep-merge"
export * from "./file-utils"
export * from "./dynamic-truncator"
export * from "./config-path"
export * from "./data-path"
export * from "./config-errors"

80
src/tools/AGENTS.md Normal file
View File

@@ -0,0 +1,80 @@
# TOOLS KNOWLEDGE BASE
## OVERVIEW
Custom tools extending agent capabilities: LSP integration (11 tools), AST-aware code search/replace, file operations with timeouts, background task management.
## STRUCTURE
```
tools/
├── ast-grep/ # AST-aware code search/replace (25 languages)
│ ├── cli.ts # @ast-grep/cli subprocess
│ ├── napi.ts # @ast-grep/napi native binding (preferred)
│ ├── constants.ts, types.ts, tools.ts, utils.ts
├── background-task/ # Async agent task management
├── call-omo-agent/ # Spawn explore/librarian agents
├── glob/ # File pattern matching (timeout-safe)
├── grep/ # Content search (timeout-safe)
├── interactive-bash/ # Tmux session management
├── look-at/ # Multimodal analysis (PDF, images)
├── lsp/ # 11 LSP tools
│ ├── client.ts # LSP connection lifecycle
│ ├── config.ts # Server configurations
│ ├── tools.ts # Tool implementations
│ └── types.ts
├── session-manager/ # OpenCode session file management
│ ├── constants.ts # Storage paths, descriptions
│ ├── types.ts # Session data interfaces
│ ├── storage.ts # File I/O operations
│ ├── utils.ts # Formatting, filtering
│ └── tools.ts # Tool implementations
├── slashcommand/ # Slash command execution
└── index.ts # builtinTools export
```
## TOOL CATEGORIES
| Category | Tools | Purpose |
|----------|-------|---------|
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve | IDE-like code intelligence |
| AST | ast_grep_search, ast_grep_replace | Pattern-based code search/replace |
| File Search | grep, glob | Content and file pattern matching |
| Session | session_list, session_read, session_search, session_info | OpenCode session file management |
| Background | background_task, background_output, background_cancel | Async agent orchestration |
| Multimodal | look_at | PDF/image analysis via Gemini |
| Terminal | interactive_bash | Tmux session control |
| Commands | slashcommand | Execute slash commands |
| Agents | call_omo_agent | Spawn explore/librarian |
## HOW TO ADD A TOOL
1. Create directory: `src/tools/my-tool/`
2. Create files:
- `constants.ts`: `TOOL_NAME`, `TOOL_DESCRIPTION`
- `types.ts`: Parameter/result interfaces
- `tools.ts`: Tool implementation (returns OpenCode tool object)
- `index.ts`: Barrel export
- `utils.ts`: Helpers (optional)
3. Add to `builtinTools` in `src/tools/index.ts`
## LSP SPECIFICS
- **Client lifecycle**: Lazy init on first use, auto-shutdown on idle
- **Config priority**: opencode.json > oh-my-opencode.json > defaults
- **Supported servers**: typescript-language-server, pylsp, gopls, rust-analyzer, etc.
- **Custom servers**: Add via `lsp` config in oh-my-opencode.json
## AST-GREP SPECIFICS
- **Meta-variables**: `$VAR` (single node), `$$$` (multiple nodes)
- **Languages**: 25 supported (typescript, tsx, python, rust, go, etc.)
- **Binding**: Prefers @ast-grep/napi (native), falls back to @ast-grep/cli
- **Pattern must be valid AST**: `export async function $NAME($$$) { $$$ }` not fragments
## ANTI-PATTERNS (TOOLS)
- **No timeout**: Always use timeout for file operations (default 60s)
- **Blocking main thread**: Use async/await, never sync file ops
- **Ignoring LSP errors**: Gracefully handle server not found/crashed
- **Raw subprocess for ast-grep**: Prefer napi binding for performance

View File

@@ -1,10 +1,27 @@
import { tool, type PluginInput } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types"
import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
type OpencodeClient = PluginInput["client"]
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
@@ -34,12 +51,19 @@ export function createBackgroundTask(manager: BackgroundManager) {
}
try {
const messageDir = getMessageDir(toolContext.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
: undefined
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: args.agent.trim(),
parentSessionID: toolContext.sessionID,
parentMessageID: toolContext.messageID,
parentModel,
})
return `Background task launched successfully.

View File

@@ -21,6 +21,13 @@ import { grep } from "./grep"
import { glob } from "./glob"
import { slashcommand } from "./slashcommand"
import {
session_list,
session_read,
session_search,
session_info,
} from "./session-manager"
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
export { getTmuxPath } from "./interactive-bash/utils"
@@ -63,4 +70,8 @@ export const builtinTools = {
grep,
glob,
slashcommand,
session_list,
session_read,
session_search,
session_info,
}

View File

@@ -0,0 +1,96 @@
import { join } from "node:path"
import { homedir } from "node:os"
import { getOpenCodeStorageDir } from "../../shared/data-path"
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
export const TODO_DIR = join(homedir(), ".claude", "todos")
export const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
export const SESSION_LIST_DESCRIPTION = `List all OpenCode sessions with optional filtering.
Returns a list of available session IDs with metadata including message count, date range, and agents used.
Arguments:
- limit (optional): Maximum number of sessions to return
- from_date (optional): Filter sessions from this date (ISO 8601 format)
- to_date (optional): Filter sessions until this date (ISO 8601 format)
Example output:
| Session ID | Messages | First | Last | Agents |
|------------|----------|-------|------|--------|
| ses_abc123 | 45 | 2025-12-20 | 2025-12-24 | build, oracle |
| ses_def456 | 12 | 2025-12-19 | 2025-12-19 | build |`
export const SESSION_READ_DESCRIPTION = `Read messages and history from an OpenCode session.
Returns a formatted view of session messages with role, timestamp, and content. Optionally includes todos and transcript data.
Arguments:
- session_id (required): Session ID to read
- include_todos (optional): Include todo list if available (default: false)
- include_transcript (optional): Include transcript log if available (default: false)
- limit (optional): Maximum number of messages to return (default: all)
Example output:
Session: ses_abc123
Messages: 45
Date Range: 2025-12-20 to 2025-12-24
[Message 1] user (2025-12-20 10:30:00)
Hello, can you help me with...
[Message 2] assistant (2025-12-20 10:30:15)
Of course! Let me help you with...`
export const SESSION_SEARCH_DESCRIPTION = `Search for content within OpenCode session messages.
Performs full-text search across session messages and returns matching excerpts with context.
Arguments:
- query (required): Search query string
- session_id (optional): Search within specific session only (default: all sessions)
- case_sensitive (optional): Case-sensitive search (default: false)
- limit (optional): Maximum number of results to return (default: 20)
Example output:
Found 3 matches across 2 sessions:
[ses_abc123] Message msg_001 (user)
...implement the **session manager** tool...
[ses_abc123] Message msg_005 (assistant)
...I'll create a **session manager** with full search...
[ses_def456] Message msg_012 (user)
...use the **session manager** to find...`
export const SESSION_INFO_DESCRIPTION = `Get metadata and statistics about an OpenCode session.
Returns detailed information about a session including message count, date range, agents used, and available data sources.
Arguments:
- session_id (required): Session ID to inspect
Example output:
Session ID: ses_abc123
Messages: 45
Date Range: 2025-12-20 10:30:00 to 2025-12-24 15:45:30
Duration: 4 days, 5 hours
Agents Used: build, oracle, librarian
Has Todos: Yes (12 items, 8 completed)
Has Transcript: Yes (234 entries)`
export const SESSION_DELETE_DESCRIPTION = `Delete an OpenCode session and all associated data.
Removes session messages, parts, todos, and transcript. This operation cannot be undone.
Arguments:
- session_id (required): Session ID to delete
- confirm (required): Must be true to confirm deletion
Example:
session_delete(session_id="ses_abc123", confirm=true)
Successfully deleted session ses_abc123`
export const TOOL_NAME_PREFIX = "session_"

View File

@@ -0,0 +1,3 @@
export * from "./tools"
export * from "./types"
export * from "./constants"

View File

@@ -0,0 +1,153 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
const TEST_DIR = join(tmpdir(), "omo-test-session-manager")
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
const TEST_PART_STORAGE = join(TEST_DIR, "part")
const TEST_TODO_DIR = join(TEST_DIR, "todos")
const TEST_TRANSCRIPT_DIR = join(TEST_DIR, "transcripts")
mock.module("./constants", () => ({
OPENCODE_STORAGE: TEST_DIR,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
TODO_DIR: TEST_TODO_DIR,
TRANSCRIPT_DIR: TEST_TRANSCRIPT_DIR,
SESSION_LIST_DESCRIPTION: "test",
SESSION_READ_DESCRIPTION: "test",
SESSION_SEARCH_DESCRIPTION: "test",
SESSION_INFO_DESCRIPTION: "test",
SESSION_DELETE_DESCRIPTION: "test",
TOOL_NAME_PREFIX: "session_",
}))
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("./storage")
describe("session-manager storage", () => {
beforeEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
mkdirSync(TEST_DIR, { recursive: true })
mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true })
mkdirSync(TEST_PART_STORAGE, { recursive: true })
mkdirSync(TEST_TODO_DIR, { recursive: true })
mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true })
})
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
})
test("getAllSessions returns empty array when no sessions exist", () => {
const sessions = getAllSessions()
expect(Array.isArray(sessions)).toBe(true)
expect(sessions).toEqual([])
})
test("getMessageDir finds session in direct path", () => {
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001", role: "user" }))
const result = getMessageDir(sessionID)
expect(result).toBe(sessionPath)
})
test("sessionExists returns false for non-existent session", () => {
const exists = sessionExists("ses_nonexistent")
expect(exists).toBe(false)
})
test("sessionExists returns true for existing session", () => {
const sessionID = "ses_exists"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001" }))
const exists = sessionExists(sessionID)
expect(exists).toBe(true)
})
test("readSessionMessages returns empty array for non-existent session", () => {
const messages = readSessionMessages("ses_nonexistent")
expect(messages).toEqual([])
})
test("readSessionMessages sorts messages by timestamp", () => {
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
writeFileSync(
join(sessionPath, "msg_002.json"),
JSON.stringify({ id: "msg_002", role: "assistant", time: { created: 2000 } })
)
writeFileSync(
join(sessionPath, "msg_001.json"),
JSON.stringify({ id: "msg_001", role: "user", time: { created: 1000 } })
)
const messages = readSessionMessages(sessionID)
expect(messages.length).toBe(2)
expect(messages[0].id).toBe("msg_001")
expect(messages[1].id).toBe("msg_002")
})
test("readSessionTodos returns empty array when no todos exist", () => {
const todos = readSessionTodos("ses_nonexistent")
expect(todos).toEqual([])
})
test("getSessionInfo returns null for non-existent session", () => {
const info = getSessionInfo("ses_nonexistent")
expect(info).toBeNull()
})
test("getSessionInfo aggregates session metadata correctly", () => {
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
const now = Date.now()
writeFileSync(
join(sessionPath, "msg_001.json"),
JSON.stringify({
id: "msg_001",
role: "user",
agent: "build",
time: { created: now - 10000 },
})
)
writeFileSync(
join(sessionPath, "msg_002.json"),
JSON.stringify({
id: "msg_002",
role: "assistant",
agent: "oracle",
time: { created: now },
})
)
const info = getSessionInfo(sessionID)
expect(info).not.toBeNull()
expect(info?.id).toBe(sessionID)
expect(info?.message_count).toBe(2)
expect(info?.agents_used).toContain("build")
expect(info?.agents_used).toContain("oracle")
})
})

View File

@@ -0,0 +1,176 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
import type { SessionMessage, SessionInfo, TodoItem } from "./types"
export function getAllSessions(): string[] {
if (!existsSync(MESSAGE_STORAGE)) return []
const sessions: string[] = []
function scanDirectory(dir: string): void {
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const sessionPath = join(dir, entry.name)
const files = readdirSync(sessionPath)
if (files.some((f) => f.endsWith(".json"))) {
sessions.push(entry.name)
} else {
scanDirectory(sessionPath)
}
}
}
} catch {
return
}
}
scanDirectory(MESSAGE_STORAGE)
return [...new Set(sessions)]
}
export function getMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) return ""
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) {
return directPath
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
return ""
}
export function sessionExists(sessionID: string): boolean {
return getMessageDir(sessionID) !== ""
}
export function readSessionMessages(sessionID: string): SessionMessage[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: SessionMessage[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const meta = JSON.parse(content)
const parts = readParts(meta.id)
messages.push({
id: meta.id,
role: meta.role,
agent: meta.agent,
time: meta.time,
parts,
})
} catch {
continue
}
}
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
function readParts(messageID: string): Array<{ id: string; type: string; [key: string]: unknown }> {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: Array<{ id: string; type: string; [key: string]: unknown }> = []
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
return parts.sort((a, b) => a.id.localeCompare(b.id))
}
export function readSessionTodos(sessionID: string): TodoItem[] {
if (!existsSync(TODO_DIR)) return []
const todoFiles = readdirSync(TODO_DIR).filter((f) => f.includes(sessionID) && f.endsWith(".json"))
for (const file of todoFiles) {
try {
const content = readFileSync(join(TODO_DIR, file), "utf-8")
const data = JSON.parse(content)
if (Array.isArray(data)) {
return data.map((item) => ({
id: item.id || "",
content: item.content || "",
status: item.status || "pending",
priority: item.priority,
}))
}
} catch {
continue
}
}
return []
}
export function readSessionTranscript(sessionID: string): number {
if (!existsSync(TRANSCRIPT_DIR)) return 0
const transcriptFile = join(TRANSCRIPT_DIR, `${sessionID}.jsonl`)
if (!existsSync(transcriptFile)) return 0
try {
const content = readFileSync(transcriptFile, "utf-8")
return content.trim().split("\n").filter(Boolean).length
} catch {
return 0
}
}
export function getSessionInfo(sessionID: string): SessionInfo | null {
const messages = readSessionMessages(sessionID)
if (messages.length === 0) return null
const agentsUsed = new Set<string>()
let firstMessage: Date | undefined
let lastMessage: Date | undefined
for (const msg of messages) {
if (msg.agent) agentsUsed.add(msg.agent)
if (msg.time?.created) {
const date = new Date(msg.time.created)
if (!firstMessage || date < firstMessage) firstMessage = date
if (!lastMessage || date > lastMessage) lastMessage = date
}
}
const todos = readSessionTodos(sessionID)
const transcriptEntries = readSessionTranscript(sessionID)
return {
id: sessionID,
message_count: messages.length,
first_message: firstMessage,
last_message: lastMessage,
agents_used: Array.from(agentsUsed),
has_todos: todos.length > 0,
has_transcript: transcriptEntries > 0,
todos,
transcript_entries: transcriptEntries,
}
}

View File

@@ -0,0 +1,103 @@
import { describe, test, expect } from "bun:test"
import { session_list, session_read, session_search, session_info } from "./tools"
import type { ToolContext } from "@opencode-ai/plugin/tool"
const mockContext: ToolContext = {
sessionID: "test-session",
messageID: "test-message",
agent: "test-agent",
abort: new AbortController().signal,
}
describe("session-manager tools", () => {
test("session_list executes without error", async () => {
const result = await session_list.execute({}, mockContext)
expect(typeof result).toBe("string")
})
test("session_list respects limit parameter", async () => {
const result = await session_list.execute({ limit: 5 }, mockContext)
expect(typeof result).toBe("string")
})
test("session_list filters by date range", async () => {
const result = await session_list.execute({
from_date: "2025-12-01T00:00:00Z",
to_date: "2025-12-31T23:59:59Z",
}, mockContext)
expect(typeof result).toBe("string")
})
test("session_read handles non-existent session", async () => {
const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext)
expect(result).toContain("not found")
})
test("session_read executes with valid parameters", async () => {
const result = await session_read.execute({
session_id: "ses_test123",
include_todos: true,
include_transcript: true,
}, mockContext)
expect(typeof result).toBe("string")
})
test("session_read respects limit parameter", async () => {
const result = await session_read.execute({
session_id: "ses_test123",
limit: 10,
}, mockContext)
expect(typeof result).toBe("string")
})
test("session_search executes without error", async () => {
const result = await session_search.execute({ query: "test" }, mockContext)
expect(typeof result).toBe("string")
})
test("session_search filters by session_id", async () => {
const result = await session_search.execute({
query: "test",
session_id: "ses_test123",
}, mockContext)
expect(typeof result).toBe("string")
})
test("session_search respects case_sensitive parameter", async () => {
const result = await session_search.execute({
query: "TEST",
case_sensitive: true,
}, mockContext)
expect(typeof result).toBe("string")
})
test("session_search respects limit parameter", async () => {
const result = await session_search.execute({
query: "test",
limit: 5,
}, mockContext)
expect(typeof result).toBe("string")
})
test("session_info handles non-existent session", async () => {
const result = await session_info.execute({ session_id: "ses_nonexistent" }, mockContext)
expect(result).toContain("not found")
})
test("session_info executes with valid session", async () => {
const result = await session_info.execute({ session_id: "ses_test123" }, mockContext)
expect(typeof result).toBe("string")
})
})

View File

@@ -0,0 +1,108 @@
import { tool } from "@opencode-ai/plugin/tool"
import {
SESSION_LIST_DESCRIPTION,
SESSION_READ_DESCRIPTION,
SESSION_SEARCH_DESCRIPTION,
SESSION_INFO_DESCRIPTION,
} from "./constants"
import { getAllSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage"
import { filterSessionsByDate, formatSessionInfo, formatSessionList, formatSessionMessages, formatSearchResults, searchInSession } from "./utils"
import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs } from "./types"
export const session_list = tool({
description: SESSION_LIST_DESCRIPTION,
args: {
limit: tool.schema.number().optional().describe("Maximum number of sessions to return"),
from_date: tool.schema.string().optional().describe("Filter sessions from this date (ISO 8601 format)"),
to_date: tool.schema.string().optional().describe("Filter sessions until this date (ISO 8601 format)"),
},
execute: async (args: SessionListArgs, _context) => {
try {
let sessions = getAllSessions()
if (args.from_date || args.to_date) {
sessions = filterSessionsByDate(sessions, args.from_date, args.to_date)
}
if (args.limit && args.limit > 0) {
sessions = sessions.slice(0, args.limit)
}
return formatSessionList(sessions)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_read = tool({
description: SESSION_READ_DESCRIPTION,
args: {
session_id: tool.schema.string().describe("Session ID to read"),
include_todos: tool.schema.boolean().optional().describe("Include todo list if available (default: false)"),
include_transcript: tool.schema.boolean().optional().describe("Include transcript log if available (default: false)"),
limit: tool.schema.number().optional().describe("Maximum number of messages to return (default: all)"),
},
execute: async (args: SessionReadArgs, _context) => {
try {
if (!sessionExists(args.session_id)) {
return `Session not found: ${args.session_id}`
}
let messages = readSessionMessages(args.session_id)
if (args.limit && args.limit > 0) {
messages = messages.slice(0, args.limit)
}
const todos = args.include_todos ? readSessionTodos(args.session_id) : undefined
return formatSessionMessages(messages, args.include_todos, todos)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_search = tool({
description: SESSION_SEARCH_DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query string"),
session_id: tool.schema.string().optional().describe("Search within specific session only (default: all sessions)"),
case_sensitive: tool.schema.boolean().optional().describe("Case-sensitive search (default: false)"),
limit: tool.schema.number().optional().describe("Maximum number of results to return (default: 20)"),
},
execute: async (args: SessionSearchArgs, _context) => {
try {
const sessions = args.session_id ? [args.session_id] : getAllSessions()
const allResults = sessions.flatMap((sid) => searchInSession(sid, args.query, args.case_sensitive))
const limited = args.limit && args.limit > 0 ? allResults.slice(0, args.limit) : allResults.slice(0, 20)
return formatSearchResults(limited)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_info = tool({
description: SESSION_INFO_DESCRIPTION,
args: {
session_id: tool.schema.string().describe("Session ID to inspect"),
},
execute: async (args: SessionInfoArgs, _context) => {
try {
const info = getSessionInfo(args.session_id)
if (!info) {
return `Session not found: ${args.session_id}`
}
return formatSessionInfo(info)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})

View File

@@ -0,0 +1,80 @@
export interface SessionMessage {
id: string
role: "user" | "assistant"
agent?: string
time?: {
created: number
updated?: number
}
parts: MessagePart[]
}
export interface MessagePart {
id: string
type: string
text?: string
thinking?: string
tool?: string
callID?: string
input?: Record<string, unknown>
output?: string
error?: string
}
export interface SessionInfo {
id: string
message_count: number
first_message?: Date
last_message?: Date
agents_used: string[]
has_todos: boolean
has_transcript: boolean
todos?: TodoItem[]
transcript_entries?: number
}
export interface TodoItem {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: string
}
export interface SearchResult {
session_id: string
message_id: string
role: string
excerpt: string
match_count: number
timestamp?: number
}
export interface SessionListArgs {
limit?: number
offset?: number
from_date?: string
to_date?: string
}
export interface SessionReadArgs {
session_id: string
include_todos?: boolean
include_transcript?: boolean
limit?: number
}
export interface SessionSearchArgs {
query: string
session_id?: string
case_sensitive?: boolean
limit?: number
}
export interface SessionInfoArgs {
session_id: string
}
export interface SessionDeleteArgs {
session_id: string
confirm: boolean
}

View File

@@ -0,0 +1,118 @@
import { describe, test, expect } from "bun:test"
import { formatSessionList, formatSessionMessages, formatSessionInfo, formatSearchResults, filterSessionsByDate, searchInSession } from "./utils"
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
describe("session-manager utils", () => {
test("formatSessionList handles empty array", () => {
const result = formatSessionList([])
expect(result).toContain("No sessions found")
})
test("formatSessionMessages handles empty array", () => {
const result = formatSessionMessages([])
expect(result).toContain("No messages")
})
test("formatSessionMessages includes message content", () => {
const messages: SessionMessage[] = [
{
id: "msg_001",
role: "user",
time: { created: Date.now() },
parts: [{ id: "prt_001", type: "text", text: "Hello world" }],
},
]
const result = formatSessionMessages(messages)
expect(result).toContain("user")
expect(result).toContain("Hello world")
})
test("formatSessionMessages includes todos when requested", () => {
const messages: SessionMessage[] = [
{
id: "msg_001",
role: "user",
time: { created: Date.now() },
parts: [{ id: "prt_001", type: "text", text: "Test" }],
},
]
const todos = [
{ id: "1", content: "Task 1", status: "completed" as const },
{ id: "2", content: "Task 2", status: "pending" as const },
]
const result = formatSessionMessages(messages, true, todos)
expect(result).toContain("Todos")
expect(result).toContain("Task 1")
expect(result).toContain("Task 2")
})
test("formatSessionInfo includes all metadata", () => {
const info: SessionInfo = {
id: "ses_test123",
message_count: 42,
first_message: new Date("2025-12-20T10:00:00Z"),
last_message: new Date("2025-12-24T15:00:00Z"),
agents_used: ["build", "oracle"],
has_todos: true,
has_transcript: true,
todos: [{ id: "1", content: "Test", status: "pending" }],
transcript_entries: 123,
}
const result = formatSessionInfo(info)
expect(result).toContain("ses_test123")
expect(result).toContain("42")
expect(result).toContain("build, oracle")
expect(result).toContain("Duration")
})
test("formatSearchResults handles empty array", () => {
const result = formatSearchResults([])
expect(result).toContain("No matches")
})
test("formatSearchResults formats matches correctly", () => {
const results: SearchResult[] = [
{
session_id: "ses_test123",
message_id: "msg_001",
role: "user",
excerpt: "...example text...",
match_count: 3,
timestamp: Date.now(),
},
]
const result = formatSearchResults(results)
expect(result).toContain("Found 1 matches")
expect(result).toContain("ses_test123")
expect(result).toContain("msg_001")
expect(result).toContain("example text")
expect(result).toContain("Matches: 3")
})
test("filterSessionsByDate filters correctly", () => {
const sessionIDs = ["ses_001", "ses_002", "ses_003"]
const result = filterSessionsByDate(sessionIDs)
expect(Array.isArray(result)).toBe(true)
})
test("searchInSession finds matches case-insensitively", () => {
const results = searchInSession("ses_nonexistent", "test", false)
expect(Array.isArray(results)).toBe(true)
expect(results.length).toBe(0)
})
})

View File

@@ -0,0 +1,179 @@
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
import { getSessionInfo, readSessionMessages } from "./storage"
export function formatSessionList(sessionIDs: string[]): string {
if (sessionIDs.length === 0) {
return "No sessions found."
}
const infos = sessionIDs.map((id) => getSessionInfo(id)).filter((info): info is SessionInfo => info !== null)
if (infos.length === 0) {
return "No valid sessions found."
}
const headers = ["Session ID", "Messages", "First", "Last", "Agents"]
const rows = infos.map((info) => [
info.id,
info.message_count.toString(),
info.first_message?.toISOString().split("T")[0] ?? "N/A",
info.last_message?.toISOString().split("T")[0] ?? "N/A",
info.agents_used.join(", ") || "none",
])
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
const formatRow = (cells: string[]): string => {
return (
"| " +
cells
.map((cell, i) => cell.padEnd(colWidths[i]))
.join(" | ")
.trim() +
" |"
)
}
const separator = "|" + colWidths.map((w) => "-".repeat(w + 2)).join("|") + "|"
return [formatRow(headers), separator, ...rows.map(formatRow)].join("\n")
}
export function formatSessionMessages(messages: SessionMessage[], includeTodos?: boolean, todos?: Array<{id: string; content: string; status: string}>): string {
if (messages.length === 0) {
return "No messages found in this session."
}
const lines: string[] = []
for (const msg of messages) {
const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : "Unknown time"
const agent = msg.agent ? ` (${msg.agent})` : ""
lines.push(`\n[${msg.role}${agent}] ${timestamp}`)
for (const part of msg.parts) {
if (part.type === "text" && part.text) {
lines.push(part.text.trim())
} else if (part.type === "thinking" && part.thinking) {
lines.push(`[thinking] ${part.thinking.substring(0, 200)}...`)
} else if ((part.type === "tool_use" || part.type === "tool") && part.tool) {
const input = part.input ? JSON.stringify(part.input).substring(0, 100) : ""
lines.push(`[tool: ${part.tool}] ${input}`)
} else if (part.type === "tool_result") {
const output = part.output ? part.output.substring(0, 200) : ""
lines.push(`[tool result] ${output}...`)
}
}
}
if (includeTodos && todos && todos.length > 0) {
lines.push("\n\n=== Todos ===")
for (const todo of todos) {
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○"
lines.push(`${status} [${todo.status}] ${todo.content}`)
}
}
return lines.join("\n")
}
export function formatSessionInfo(info: SessionInfo): string {
const lines = [
`Session ID: ${info.id}`,
`Messages: ${info.message_count}`,
`Date Range: ${info.first_message?.toISOString() ?? "N/A"} to ${info.last_message?.toISOString() ?? "N/A"}`,
`Agents Used: ${info.agents_used.join(", ") || "none"}`,
`Has Todos: ${info.has_todos ? `Yes (${info.todos?.length ?? 0} items)` : "No"}`,
`Has Transcript: ${info.has_transcript ? `Yes (${info.transcript_entries} entries)` : "No"}`,
]
if (info.first_message && info.last_message) {
const duration = info.last_message.getTime() - info.first_message.getTime()
const days = Math.floor(duration / (1000 * 60 * 60 * 24))
const hours = Math.floor((duration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (days > 0 || hours > 0) {
lines.push(`Duration: ${days} days, ${hours} hours`)
}
}
return lines.join("\n")
}
export function formatSearchResults(results: SearchResult[]): string {
if (results.length === 0) {
return "No matches found."
}
const lines: string[] = [`Found ${results.length} matches:\n`]
for (const result of results) {
const timestamp = result.timestamp ? new Date(result.timestamp).toISOString() : ""
lines.push(`[${result.session_id}] ${result.message_id} (${result.role}) ${timestamp}`)
lines.push(` ${result.excerpt}`)
lines.push(` Matches: ${result.match_count}\n`)
}
return lines.join("\n")
}
export function filterSessionsByDate(sessionIDs: string[], fromDate?: string, toDate?: string): string[] {
if (!fromDate && !toDate) return sessionIDs
const from = fromDate ? new Date(fromDate) : null
const to = toDate ? new Date(toDate) : null
return sessionIDs.filter((id) => {
const info = getSessionInfo(id)
if (!info || !info.last_message) return false
if (from && info.last_message < from) return false
if (to && info.last_message > to) return false
return true
})
}
export function searchInSession(sessionID: string, query: string, caseSensitive = false): SearchResult[] {
const messages = readSessionMessages(sessionID)
const results: SearchResult[] = []
const searchQuery = caseSensitive ? query : query.toLowerCase()
for (const msg of messages) {
let matchCount = 0
let excerpts: string[] = []
for (const part of msg.parts) {
if (part.type === "text" && part.text) {
const text = caseSensitive ? part.text : part.text.toLowerCase()
const matches = text.split(searchQuery).length - 1
if (matches > 0) {
matchCount += matches
const index = text.indexOf(searchQuery)
if (index !== -1) {
const start = Math.max(0, index - 50)
const end = Math.min(text.length, index + searchQuery.length + 50)
let excerpt = part.text.substring(start, end)
if (start > 0) excerpt = "..." + excerpt
if (end < text.length) excerpt = excerpt + "..."
excerpts.push(excerpt)
}
}
}
}
if (matchCount > 0) {
results.push({
session_id: sessionID,
message_id: msg.id,
role: msg.role,
excerpt: excerpts[0] || "",
match_count: matchCount,
timestamp: msg.time?.created,
})
}
}
return results
}