Compare commits

..

48 Commits

Author SHA1 Message Date
github-actions[bot]
6e5edafeee release: v2.7.0 2025-12-28 08:59:46 +00:00
YeonGyu-Kim
bfb5d43bc2 Add AGENTS.md knowledge base documentation files
- Add src/agents/AGENTS.md with agent module documentation
- Update root AGENTS.md with latest generation timestamp (2025-12-28T17:15:00+09:00, commit f5b74d5)
- Update src/features/AGENTS.md with builtin-commands and claude-code-plugin-loader documentation
- Update src/hooks/AGENTS.md with thinking-block-validator hook documentation

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
385e8a97b0 Add builtin-commands feature with init-deep command and disabled_commands config option
- New src/features/builtin-commands/ module with CommandDefinition loader
- Implements init-deep command for hierarchical AGENTS.md knowledge base generation
- Adds BuiltinCommandName and BuiltinCommandNameSchema to config
- Integrates builtin commands loader into main plugin with proper config merging
- Supports disabling specific builtin commands via disabled_commands config array

🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
7daabf9617 Add ctx.metadata() calls for session navigation UI in background/subagent tasks
Add metadata() calls to background_task and call_omo_agent tools so that OpenCode UI displays session navigation hints (ctrl+x + arrow keys) like the original Task tool does. This enhances UX by providing consistent session navigation UI for background and subagent tasks.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
5fbcb88a3f fix(todo-continuation-enforcer): persist errorBypass mode until user sends message
Previously, errorBypass mode was cleared on session.idle, causing continuation
to fire again on next idle event. This led to unwanted task resumption after
user abort.

Changes:
- Don't clear errorBypass on session.idle - stay in errorBypass mode
- Clear errorBypass to idle only when user sends a new message

This ensures that once user aborts, the enforcer respects that decision until
the user explicitly sends a message to resume.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
daa5f6ee5b fix(todo-continuation-enforcer): redesign with version-token state machine
Fixes race conditions, enables continuous enforcement, and eliminates false positives/negatives.

- Complete redesign using version-token state machine for race condition prevention
- Replaced 5 separate Sets with single Map<sessionID, SessionState>
- Changed cancelCountdown() to invalidate() that ALWAYS bumps version regardless of mode
- Added background task check BEFORE starting countdown (prevents toast spam when bg tasks running)
- Added lastAttemptedAt throttling (10s minimum between attempts, set BEFORE API call)
- Removed non-interactive preemptive injection (all paths now use countdown)
- Added 3 version checks in executeInjection (start, after todo fetch, before API call)
- Removed remindedSessions flag for continuous enforcement

Fixes:
1. Race condition where session.idle fired before message.updated cleared reminded state
2. Single-shot behavior that prevented multiple reminders
3. Phantom reminders sent even after agent started working
4. Toast spam when background tasks are running

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
Sisyphus
4d66ea9730 fix(lsp): improve error messages when LSP server is not installed (#305)
Previously, when an LSP server was configured but not installed, the error
message said "No LSP server configured" which was misleading. Now the
error message distinguishes between:

1. Server not configured at all
2. Server configured but not installed (with installation hints)

The new error messages include:
- Clear indication of whether server is configured vs installed
- Installation commands for each built-in server
- Supported file extensions
- Configuration examples for custom servers

Fixes #304

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 17:38:23 +09:00
sisyphus-dev-ai
4d4273603a chore: changes by sisyphus-dev-ai 2025-12-28 07:57:05 +00:00
YeonGyu-Kim
7b7c14301e fix(dcp): correct storage path to match OpenCode's actual location
DCP was failing to find session messages because it was looking in
~/.config/opencode/sessions instead of ~/.local/share/opencode/storage.
Unified all hooks to use getOpenCodeStorageDir() for cross-platform consistency.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 16:13:50 +09:00
Sisyphus
e3be656f86 fix: disable todo-continuation for plan mode agents (#303)
* fix: disable todo-continuation for plan mode agents

Plan mode agents (e.g., 'plan', 'Planner-Sisyphus') only analyze and plan,
they don't implement. The todo-continuation hook was incorrectly triggering
for these agents because the existing write permission check only looked at
the stored message's tools field, not the agent's permission configuration.

This fix adds an explicit check for plan mode agents by name to skip the
todo continuation prompt injection.

Fixes #293

* chore: changes by sisyphus-dev-ai

* fix: address review comments for plan mode agent check

- Use exact match for plan mode agents instead of substring match to
  prevent false positives on agents like 'deployment-planner'
- Add plan mode agent check to preemptive injection path (non-interactive
  mode) which was missing from the initial fix

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 16:02:04 +09:00
Sisyphus
c11cb2e3f1 fix: defer module-level side effects to prevent Bun 1.3.5 + macOS 15 segfault (#301)
- Remove eager SG_CLI_PATH constant; use getSgCliPath() lazily in checkEnvironment()
- Move setInterval to inside createCommentCheckerHooks() with guard flag

These changes eliminate module-level side effects that could trigger segfaults
during plugin initialization on Bun 1.3.5 + macOS 15 due to createRequire()
being called during module evaluation.

Fixes #292

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 15:55:47 +09:00
YeonGyu-Kim
195e8dcb17 refactor(todo-continuation-enforcer): improve state machine and injection logic
Refactored state management to use a single source of truth per-session using
a state machine pattern with versioning. Key improvements:

- Replace multiple Sets with unified SessionState map for cleaner logic
- Add version tokens to invalidate pending callbacks on state changes
- Improve countdown timer management with proper cleanup
- Add throttle check to prevent rapid injection spam (10s minimum interval)
- Enhance injection checks: re-verify todos before injection, check bg tasks
- Handle message.part.updated events for streaming activity detection
- Add isMainSession() helper for consistent session filtering
- Clearer event handler logic with inline comments explaining state transitions
- Better logging for debugging state changes and decision points

State modes: idle → countingDown → injecting → idle (with recovery/errorBypass)
Prevents race conditions from async operations and UI state changes during countdown.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 15:49:13 +09:00
YeonGyu-Kim
284e7f5bc3 fix(anthropic-auto-compact): use correct MESSAGE_STORAGE path for session messages
The DCP pruning modules were using a hardcoded path (~/.config/opencode/sessions) that doesn't exist.
Sessions are actually stored at ~/.local/share/opencode/storage/message.

All pruning modules now import MESSAGE_STORAGE from hook-message-injector, which uses the correct path via getOpenCodeStorageDir().
This fixes the issue where DCP would fail with 'message dir not found' when trying to recover from token limit errors.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 15:49:04 +09:00
YeonGyu-Kim
465c9e511f feat(comment-checker): pass custom_prompt to CLI
- Add customPrompt parameter to runCommentChecker function
- Pass --prompt flag to comment-checker CLI when custom_prompt is configured
- Wire up config from plugin initialization

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 15:02:00 +09:00
YeonGyu-Kim
18d134fa57 fix(background-agent): prevent memory leak - completed tasks now removed from Map (#302)
- Add finally block in notifyParentSession() to ensure task cleanup
- Call tasks.delete(taskId) after notification sent or on error
- Prevents memory accumulation when tasks complete or fail
- taskId captured before setTimeout to ensure proper cleanup in async context

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:59:06 +09:00
YeonGyu-Kim
092718f82d fix(thinking-block-validator): handle text content parts in message validation
Previously, the validator only checked for tool_use parts, causing 'Expected thinking but found text' errors when messages had text content. Renamed hasToolParts to hasContentParts to include both tool_use and text types.

Also added CommentCheckerConfigSchema support for custom prompt configuration.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:55:27 +09:00
YeonGyu-Kim
19f504fcfa fix(session-recovery): improve empty message index search with expanded range
Expand the search range when finding empty messages by index to better handle API index vs storage index mismatches. This increases robustness when searching for messages to sanitize with more fallback indices.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
49f3be5a1f fix(session-manager): convert blocking sync I/O to async for improved concurrency
Convert session-manager storage layer from synchronous blocking I/O (readdirSync, readFileSync) to non-blocking async I/O (readdir, readFile from fs/promises). This fixes hanging issues in session_search and other tools caused by blocking filesystem operations.

Changes:
- storage.ts: getAllSessions, readSessionMessages, getSessionInfo now async
- utils.ts: Updated utility functions to be async-compatible
- tools.ts: Added await calls for async storage functions
- storage.test.ts, utils.test.ts: Updated tests with async/await patterns

This resolves the session_search tool hang issue and improves overall responsiveness.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
6d6102f1ff fix(anthropic-auto-compact): sanitize empty messages before summarization
Pre-emptively fix empty messages in sessions before running document compression to prevent summarization failures. This prevents accumulation of empty message placeholders that can interfere with context management.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
1d7e534b92 Upgrade @code-yeongyu/comment-checker from ^0.6.0 to ^0.6.1
🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
17b7dd396e feat(cli): librarian/explore model fallback based on installer settings (#299)
* feat(cli): librarian uses gemini-3-flash when hasGemini (antigravity auth)

Closes #294

* feat(cli): add explore to gemini-3-flash when hasGemini + update docs

* feat(cli): fix explore agent fallback logic to use haiku for max20 Claude users

- Use gemini-3-flash for both librarian and explore when hasGemini
- Use haiku for explore when Claude max20 is available (hasClaude && isMax20)
- Fall back to big-pickle for both when other models unavailable
- Updated all README files to document the fallback precedence

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:27:35 +09:00
YeonGyu-Kim
889d80d0ca feat(anthropic-auto-compact): run DCP first on token limit errors before compaction
- Refactored DCP (Dynamic Context Pruning) to execute FIRST when token limit errors occur
- Previously, DCP only ran as a fallback after compaction failed
- Now DCP runs first to prune redundant context, then compaction executes immediately
- Simplified config flag: dcp_on_compaction_failure → dcp_for_compaction
- Updated documentation in all 4 README files (EN, KO, JA, ZH-CN)
- Updated schema.ts with new config field name and documentation
- Updated executor.ts with new DCP-first logic flow

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:13:50 +09:00
YeonGyu-Kim
87e229fb62 feat(auth): enhance Antigravity token refresh with robust error handling and retry logic
- Add AntigravityTokenRefreshError custom error class with code, description, and status fields
- Implement parseOAuthErrorPayload() for parsing Google's various OAuth error response formats
- Add retry logic with exponential backoff (3 retries, 1s→2s→4s delay) for transient failures
- Add special handling for invalid_grant error - immediately throws without retry and clears caches
- Add invalidateProjectContextByRefreshToken() for selective cache invalidation
- Update fetch.ts error handling to work with new error class and cache invalidation

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 13:22:42 +09:00
github-actions[bot]
78514ec6d4 release: v2.6.2 2025-12-27 17:35:06 +00:00
YeonGyu-Kim
1c12925c9e fix(plugin-loader): support installed_plugins.json v1 format for backward compatibility (#288)
The installed_plugins.json file has two versions:
- v1: plugins stored as direct objects
- v2: plugins stored as arrays

Use discriminated union types (InstalledPluginsDatabaseV1/V2) for proper
type narrowing based on version field.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 02:33:14 +09:00
github-actions[bot]
262f0c3f1f release: v2.6.1 2025-12-27 17:22:11 +00:00
github-actions[bot]
aace1982ec @devxoul has signed the CLA in code-yeongyu/oh-my-opencode#288 2025-12-27 17:06:00 +00:00
github-actions[bot]
8d8ea4079d release: v2.6.0 2025-12-27 15:55:06 +00:00
YeonGyu-Kim
c5f51030f0 fix: defer config error toast to session.created for TUI readiness (#286)
* fix: defer config error toast to session.created for TUI readiness

Removed showToast calls from loadConfigFromPath() function. Error notifications were not visible during plugin initialization because the TUI was not ready yet.

Changes:
- Removed immediate showToast calls from validation error handler
- Removed immediate showToast calls from file load error handler
- Errors are still captured via addConfigLoadError() for later display
- auto-update-checker hook will display errors via showConfigErrorsIfAny() after session.created event

This ensures error messages are displayed when the TUI is fully ready and able to render them properly.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix: await config error toast before showing startup toast

Ensure config errors are awaited and displayed before the startup spinner toast is shown. Changed showConfigErrorsIfAny(ctx).catch(() => {}) to await showConfigErrorsIfAny(ctx) to guarantee proper error handling order.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:46:05 +09:00
Sisyphus
b2c2c6eab7 feat: Add JSONC support for oh-my-opencode config files (#275)
Uses Microsoft's jsonc-parser package for reliable JSONC parsing:
- oh-my-opencode.jsonc (preferred) or oh-my-opencode.json
- Supports line comments (//), block comments (/* */), and trailing commas
- Better error reporting with line/column positions

Core changes:
- Added jsonc-parser dependency (Microsoft's VS Code parser)
- Shared JSONC utilities (parseJsonc, parseJsoncSafe, readJsoncFile, detectConfigFile)
- Main plugin config loader uses detectConfigFile for .jsonc priority
- CLI config manager supports JSONC parsing

Comprehensive test suite with 18 tests for JSONC parsing.

Fixes #265

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 00:45:17 +09:00
YeonGyu-Kim
c4c0d82f97 fix(anthropic-auto-compact): run DCP only on compaction failure and retry after pruning (#284)
Make DCP behavior opt-in via new 'dcp_on_compaction_failure' experimental flag (disabled by default).

When enabled, Dynamic Context Pruning only executes after summarization fails, then retries compaction. By default, DCP runs before truncation as before.

Changes:
- Add 'dcp_on_compaction_failure' boolean flag to experimental config (default: false)
- Update executor.ts to check flag before running DCP behavior
- Add corresponding documentation to all 4 README files (EN, KO, JA, ZH-CN)
- Update JSON schema

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:43:37 +09:00
YeonGyu-Kim
3e180cd9f1 docs: add Aaron Iker as sponsor to all README files (#287)
Add Aaron Iker (@aaroniker) with GitHub and X links to the sponsors
section in all language README files (EN, KO, JA, ZH-CN).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:42:35 +09:00
YeonGyu-Kim
776d857fd2 feat: set Sisyphus as default agent when enabled (#285)
Uses OpenCode's `default_agent` config (PR #5843)
Sets Sisyphus as default when sisyphus_agent is not disabled
Closes #283

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:28:26 +09:00
Harsha Vardhan
90d43dc292 fix(dynamic-truncator): apply fallback truncation when context usage unavailable (#268)
When getContextWindowUsage returns null (no assistant messages yet, API
failure, or first request in session), the truncator was returning
untruncated output. This caused context overflow crashes on early
requests or when usage lookup failed.

Now applies conservative truncation (50k tokens) as fallback, preventing
prompt-too-long errors that crash sessions.
2025-12-28 00:22:02 +09:00
YeonGyu-Kim
6bc9a31ee4 feat(ultrawork-prompt): add TDD workflow integration with conditional applicability (#246)
- Add TDD cycle specification (SPEC → RED → GREEN → REFACTOR → NEXT)
- Add applicability check for test infrastructure and implementation tasks
- Add TDD execution rules (TEST FIRST, MINIMAL IMPLEMENTATION, etc.)
- Add 'NO TEST DELETION' to ZERO TOLERANCE FAILURES section
- Add skip notation requirement for non-applicable tasks

Addresses: #243

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:14:06 +09:00
github-actions[bot]
5c8cfbfad8 @adam2am has signed the CLA in code-yeongyu/oh-my-opencode#281 2025-12-27 14:49:14 +00:00
YeonGyu-Kim
1d2dc69ae5 fix: use pathToFileURL for Windows-compatible file URLs in look_at tool (#279)
Fixes #276 - The look_at tool was constructing invalid file:// URLs on Windows
by using template literals. Now uses Node.js pathToFileURL() which correctly
handles backslashes, spaces, and the triple-slash prefix required on Windows.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-27 23:47:59 +09:00
Sisyphus
0cee39dafb fix: properly mock utility functions in session-notification tests (#274)
The test mock for ctx.$ was not handling tagged template literals correctly,
causing it to ignore interpolated values. Additionally, utility functions that
check for command availability (osascript, notify-send, etc.) were returning
null in test environments, causing sendNotification to exit early.

Changes:
- Fixed template literal reconstruction in mock $ function
- Added spyOn mocks for all utility path functions
- All session-notification tests now passing (11/11)

Fixes #273

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-27 23:22:17 +09:00
YeonGyu-Kim
dd12928390 fix: resolve GitHub Actions workflow hang after task completion
- Add process.exit(0) in runner.ts for immediate termination
- Fix Timer type to ReturnType<typeof setInterval> in manager.ts
- Add .unref() to BackgroundManager polling interval
- Add cleanup() method to BackgroundManager

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-27 23:06:44 +09:00
Lukin
2246d1c5ef feat: add Claude Code plugin support (#240) 2025-12-27 18:56:40 +09:00
sisyphus-dev-ai
1fc7fe7122 feat(compaction): add dynamic context pruning as recovery stage
Implements DCP-style pruning strategies inspired by opencode-dynamic-context-pruning plugin:

- Deduplication: removes duplicate tool calls (same tool + args)
- Supersede writes: prunes write inputs when file subsequently read
- Purge errors: removes old error tool inputs after N turns

Integration:
- Added as Stage 2.5 in compaction pipeline (after truncation, before summarize)
- Configurable via experimental.dynamic_context_pruning
- Opt-in by default (experimental feature)
- Protected tools list prevents pruning critical tools

Configuration:
- Turn protection (default: 3 turns)
- Per-strategy enable/disable
- Aggressive/conservative modes for supersede writes
- Configurable error purge threshold (default: 5 turns)
- Toast notifications (off/minimal/detailed)

Testing:
- Added unit tests for deduplication signature creation
- Type check passes
- Schema regenerated

Closes #271
2025-12-27 09:20:42 +00:00
Sisyphus
3ba7e6d46b docs: clarify auto-update-checker and startup-toast relationship (#270) 2025-12-27 17:43:55 +09:00
Sisyphus
dec4994fd6 fix: check command existence before calling notify-send (#264) 2025-12-27 17:17:13 +09:00
github-actions[bot]
c5205e7e2f @harshav167 has signed the CLA in code-yeongyu/oh-my-opencode#268 2025-12-27 04:40:45 +00:00
Sisyphus
8e2fda870a feat: add get-local-version CLI command for version checking (#262)
- Add new CLI command 'get-local-version' to display current version and check for updates
- Reuses existing version checking infrastructure from auto-update-checker
- Supports both human-readable and JSON output formats
- Handles edge cases: local dev mode, pinned versions, network errors
- Provides colored terminal output with picocolors
- Closes #260

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-27 02:07:55 +09:00
Sisyphus
cad6425a4a fix: honor CLAUDE_CONFIG_DIR environment variable (#261)
Fixes #255

- Add getClaudeConfigDir() utility function that respects CLAUDE_CONFIG_DIR env var
- Update all hardcoded ~/.claude paths to use the new utility
- Add comprehensive tests for getClaudeConfigDir()
- Maintain backward compatibility with default ~/.claude when env var is not set

Files updated:
- src/shared/claude-config-dir.ts (new utility)
- src/shared/claude-config-dir.test.ts (tests)
- src/hooks/claude-code-hooks/config.ts
- src/hooks/claude-code-hooks/todo.ts
- src/hooks/claude-code-hooks/transcript.ts
- src/features/claude-code-command-loader/loader.ts
- src/features/claude-code-agent-loader/loader.ts
- src/features/claude-code-skill-loader/loader.ts
- src/features/claude-code-mcp-loader/loader.ts
- src/tools/session-manager/constants.ts
- src/tools/slashcommand/tools.ts

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 23:28:33 +09:00
Steven Vo
15de6f637e feat: add two-layer thinking block validation (proactive + reactive) (#248)
- Add thinking-block-validator hook for proactive prevention before API calls
- Enhance session-recovery to include previous thinking content
- Fix hook registration to actually invoke the validator

Addresses extended thinking errors with Claude Opus/Sonnet 4.5 using tool calls.

Related: https://github.com/vercel/ai/issues/7729
Related: https://github.com/sst/opencode/issues/2599
2025-12-26 23:14:11 +09:00
YeonGyu-Kim
e05d9dfc35 feat: add sponsors section to localized README files
- Add Sponsors section to README.ko.md, README.ja.md, README.zh-cn.md, README.md
- List Numman Ali as the first sponsor
- Move thanks message to end of file to match structure

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 16:52:58 +09:00
100 changed files with 4882 additions and 906 deletions

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2025-12-24T17:07:00+09:00
**Commit:** 0172241
**Generated:** 2025-12-28T17:15:00+09:00
**Commit:** f5b74d5
**Branch:** dev
## OVERVIEW

View File

@@ -396,8 +396,8 @@ gh repo star code-yeongyu/oh-my-opencode
- **Sisyphus** (`anthropic/claude-opus-4-5`): **デフォルトエージェントです。** OpenCode のための強力な AI オーケストレーターです。専門のサブエージェントを活用して、複雑なタスクを計画、委任、実行します。バックグラウンドタスクへの委任と Todo ベースのワークフローを重視します。最大の推論能力を発揮するため、Claude Opus 4.5 と拡張思考 (32k token budget) を使用します。
- **oracle** (`openai/gpt-5.2`): アーキテクチャ、コードレビュー、戦略立案のための専門アドバイザー。GPT-5.2 の卓越した論理的推論と深い分析能力を活用します。AmpCode からインスピレーションを得ました。
- **librarian** (`anthropic/claude-sonnet-4-5`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
- **explore** (`opencode/grok-code`): 高速なコードベース探索、ファイルパターンマッチング。Claude Code は Haiku を使用しますが、私たちは Grok を使います。現在無料であり、極めて高速で、ファイル探索タスクには十分な知能を備えているからです。Claude Code からインスピレーションを得ました。
- **librarian** (`anthropic/claude-sonnet-4-5` または `google/gemini-3-flash`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、それ以外は Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
- **explore** (`opencode/grok-code``google/gemini-3-flash`、または `anthropic/claude-haiku-4-5`): 高速なコードベース探索、ファイルパターンマッチング。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、Claude max20 が利用可能な場合は Haiku を使用し、それ以外は Grok を使います。Claude Code からインスピレーションを得ました。
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 開発者に転身したデザイナーという設定です。素晴らしい UI を作ります。美しく独創的な UI コードを生成することに長けた Gemini を使用します。
- **document-writer** (`google/gemini-3-pro-preview`): テクニカルライティングの専門家という設定です。Gemini は文筆家であり、流れるような文章を書きます。
- **multimodal-looker** (`google/gemini-3-flash`): 視覚コンテンツ解釈のための専門エージェント。PDF、画像、図表を分析して情報を抽出します。
@@ -601,8 +601,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
- **Agent Usage Reminder**: 検索ツールを直接呼び出す際、バックグラウンドタスクを通じた専門エージェントの活用を推奨するリマインダーを表示します。
- **Anthropic Auto Compact**: Claude モデルがトークン制限に達すると、自動的にセッションを要約・圧縮します。手動での介入は不要です。
- **Session Recovery**: セッションエラーツールの結果欠落、thinking ブロックの問題、空のメッセージなど)から自動復旧します。セッションが途中でクラッシュすることはありません。もしクラッシュしても復旧します。
- **Auto Update Checker**: oh-my-opencode の新バージョンがリリースされると通知します
- **Startup Toast**: OhMyOpenCode ロード時にウェルカムメッセージを表示します。セッションを正しく始めるための、ささやかな "oMoMoMo" です。
- **Auto Update Checker**: oh-my-opencode の新バージョンを自動でチェックし、設定を自動更新できます。現在のバージョンと Sisyphus ステータスを表示する起動トースト通知を表示しますSisyphus 有効時は「Sisyphus on steroids is steering OpenCode」、無効時は「OpenCode is now on Steroids. oMoMoMoMo...」)。全機能を無効化するには `disabled_hooks` に `"auto-update-checker"` を、トースト通知のみ無効化するには `"startup-toast"` を追加してください。[設定 > フック](#フック) 参照
- **Background Notification**: バックグラウンドエージェントのタスクが完了すると通知を受け取ります。
- **Session Notification**: エージェントがアイドル状態になると OS 通知を送ります。macOS、Linux、Windows で動作します—エージェントが入力を待っている時を見逃しません。
- **Empty Task Response Detector**: Task ツールが空の応答を返すと検知します。既に空の応答が返ってきているのに、いつまでも待ち続ける状況を防ぎます。
@@ -795,6 +794,8 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
### MCPs
コンテキスト7、Exa、grep.app MCP がデフォルトで有効になっています。
@@ -845,7 +846,8 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
"experimental": {
"aggressive_truncation": true,
"auto_resume": true,
"truncate_all_tool_outputs": false
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
@@ -855,6 +857,7 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| `truncate_all_tool_outputs` | `true` | プロンプトが長くなりすぎるのを防ぐため、コンテキストウィンドウの使用状況に基づいてすべてのツール出力を動的に切り詰めます。完全なツール出力が必要な場合は`false`に設定して無効化します。 |
| `dcp_for_compaction` | `false` | 有効にすると、トークン制限エラー発生時にDCPDynamic Context Pruningが最初に実行され、その後コンパクションが実行されます。DCPが不要なコンテキストを整理した後、すぐにコンパクションが進行します。トークン制限に達した際によりスマートな回復が必要な場合は有効にしてください。 |
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
@@ -902,11 +905,16 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
- [修正 PR](https://github.com/sst/opencode/pull/5040) は 1.0.132 以降にマージされたため、新しいバージョンを使用してください。
- 余談:この PR も、OhMyOpenCode の Librarian、Explore、Oracle セットアップを活用して偶然発見され、修正されました。
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*
## こちらの企業の専門家にご愛用いただいています
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## スポンサー
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 最初のスポンサー
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*

View File

@@ -393,8 +393,8 @@ gh repo star code-yeongyu/oh-my-opencode
- **Sisyphus** (`anthropic/claude-opus-4-5`): **기본 에이전트입니다.** OpenCode를 위한 강력한 AI 오케스트레이터입니다. 전문 서브에이전트를 활용하여 복잡한 작업을 계획, 위임, 실행합니다. 백그라운드 태스크 위임과 todo 기반 워크플로우를 강조합니다. 최대 추론 능력을 위해 Claude Opus 4.5와 확장된 사고(32k 버짓)를 사용합니다.
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **librarian** (`anthropic/claude-sonnet-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
- **librarian** (`anthropic/claude-sonnet-4-5` 또는 `google/gemini-3-flash`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, 그렇지 않으면 Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, 또는 `anthropic/claude-haiku-4-5`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, Claude max20이 있으면 Haiku를 사용하며, 그 외에는 Grok을 씁니다. Claude Code 에서 영감을 받았습니다.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
- **multimodal-looker** (`google/gemini-3-flash`): 시각적 콘텐츠 해석을 위한 전문 에이전트. PDF, 이미지, 다이어그램을 분석하여 정보를 추출합니다.
@@ -595,8 +595,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- **Agent Usage Reminder**: 검색 도구를 직접 호출할 때, 백그라운드 작업을 통한 전문 에이전트 활용을 권장하는 리마인더를 표시합니다.
- **Anthropic Auto Compact**: Claude 모델이 토큰 제한에 도달하면 자동으로 세션을 요약하고 압축합니다. 수동 개입 없이 작업을 계속할 수 있습니다.
- **Session Recovery**: 세션 에러(누락된 도구 결과, thinking 블록 문제, 빈 메시지 등)에서 자동 복구합니다. 돌다가 세션이 망가지지 않습니다. 망가져도 복구됩니다.
- **Auto Update Checker**: oh-my-opencode의 새 버전이 출시되면 알림을 표시합니다.
- **Startup Toast**: OhMyOpenCode 로드 시 환영 메시지를 표시합니다. 세션을 제대로 시작하기 위한 작은 "oMoMoMo".
- **Auto Update Checker**: oh-my-opencode의 새 버전을 자동으로 확인하고 설정을 자동 업데이트할 수 있습니다. 현재 버전과 Sisyphus 상태를 표시하는 시작 토스트 알림을 표시합니다 (Sisyphus 활성화 시 "Sisyphus on steroids is steering OpenCode", 비활성화 시 "OpenCode is now on Steroids. oMoMoMoMo..."). 모든 기능을 비활성화하려면 `disabled_hooks`에 `"auto-update-checker"`를, 토스트 알림만 비활성화하려면 `"startup-toast"`를 추가하세요. [설정 > 훅](#훅) 참조.
- **Background Notification**: 백그라운드 에이전트 작업이 완료되면 알림을 받습니다.
- **Session Notification**: 에이전트가 대기 상태가 되면 OS 알림을 보냅니다. macOS, Linux, Windows에서 작동—에이전트가 입력을 기다릴 때 놓치지 마세요.
- **Empty Task Response Detector**: Task 도구가 빈 응답을 반환하면 감지합니다. 이미 빈 응답이 왔는데 무한정 기다리는 상황을 방지합니다.
@@ -789,6 +788,8 @@ Schema 자동 완성이 지원됩니다:
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
### MCPs
기본적으로 Context7, Exa, grep.app MCP 를 지원합니다.
@@ -839,7 +840,8 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
"experimental": {
"aggressive_truncation": true,
"auto_resume": true,
"truncate_all_tool_outputs": false
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
@@ -849,6 +851,7 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| `truncate_all_tool_outputs` | `true` | 프롬프트가 너무 길어지는 것을 방지하기 위해 컨텍스트 윈도우 사용량에 따라 모든 도구 출력을 동적으로 잘라냅니다. 전체 도구 출력이 필요한 경우 `false`로 설정하여 비활성화하세요. |
| `dcp_for_compaction` | `false` | 활성화하면, 토큰 제한 에러 발생 시 DCP(Dynamic Context Pruning)가 가장 먼저 실행되고, 그 다음 compaction이 실행됩니다. DCP가 불필요한 컨텍스트를 정리한 후 바로 compaction이 진행됩니다. 토큰 제한에 도달했을 때 더 스마트한 복구를 원하면 활성화하세요. |
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.
@@ -896,11 +899,16 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
- [이를 고치는 PR 이 1.0.132 배포 이후에 병합되었으므로](https://github.com/sst/opencode/pull/5040) 이 변경사항이 포함된 최신 버전을 사용해주세요.
- TMI: PR 도 OhMyOpenCode 의 셋업의 Librarian, Explore, Oracle 을 활용하여 우연히 발견하고 해결되었습니다.
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*
## 다음 기업의 능력있는 개인들이 사용하고 있습니다
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## 스폰서
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 첫 번째 스폰서
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*

View File

@@ -465,8 +465,8 @@ To remove oh-my-opencode:
- **Sisyphus** (`anthropic/claude-opus-4-5`): **The default agent.** A powerful AI orchestrator for OpenCode. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Emphasizes background task delegation and todo-driven workflow. Uses Claude Opus 4.5 with extended thinking (32k budget) for maximum reasoning capability.
- **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.
- **librarian** (`anthropic/claude-sonnet-4-5` or `google/gemini-3-flash`): Multi-repo analysis, doc lookup, implementation examples. Uses Gemini 3 Flash when Antigravity auth is configured, otherwise Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, or `anthropic/claude-haiku-4-5`): Fast codebase exploration and pattern matching. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code.
- **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.
@@ -667,8 +667,7 @@ When agents thrive, you thrive. But I want to help you directly too.
- **Agent Usage Reminder**: When you call search tools directly, reminds you to leverage specialized agents via background tasks for better results.
- **Anthropic Auto Compact**: When Claude models hit token limits, automatically summarizes and compacts the session—no manual intervention needed.
- **Session Recovery**: Automatically recovers from session errors (missing tool results, thinking block issues, empty messages). Sessions don't crash mid-run. Even if they do, they recover.
- **Auto Update Checker**: Notifies you when a new version of oh-my-opencode is available.
- **Startup Toast**: Shows a welcome message when OhMyOpenCode loads. A little "oMoMoMo" to start your session right.
- **Auto Update Checker**: Automatically checks for new versions of oh-my-opencode and can auto-update your configuration. Shows startup toast notifications displaying current version and Sisyphus status ("Sisyphus on steroids is steering OpenCode" when enabled, or "OpenCode is now on Steroids. oMoMoMoMo..." otherwise). Disable all features with `"auto-update-checker"` in `disabled_hooks`, or disable just toast notifications with `"startup-toast"` in `disabled_hooks`. See [Configuration > Hooks](#hooks).
- **Background Notification**: Get notified when background agent tasks complete.
- **Session Notification**: Sends OS notifications when agents go idle. Works on macOS, Linux, and Windows—never miss when your agent needs input.
- **Empty Task Response Detector**: Catches when Task tool returns nothing. Warns you about potential agent failures so you don't wait forever for a response that already came back empty.
@@ -697,6 +696,36 @@ Schema autocomplete supported:
}
```
### JSONC Support
The `oh-my-opencode` configuration file supports JSONC (JSON with Comments):
- Line comments: `// comment`
- Block comments: `/* comment */`
- Trailing commas: `{ "key": "value", }`
When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc` takes priority.
**Example with comments:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Enable Google Gemini via Antigravity OAuth
"google_auth": false,
/* Agent overrides - customize models for specific tasks */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // GPT for strategic reasoning
},
"explore": {
"model": "opencode/grok-code" // Free & fast for exploration
},
},
}
```
### Google Auth
**Recommended**: Use the external [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin. It provides multi-account load balancing, more models (including Claude via Antigravity), and active maintenance. See [Installation > Google Gemini](#google-gemini-antigravity-oauth).
@@ -861,6 +890,8 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
### MCPs
Context7, Exa, and grep.app MCP enabled by default.
@@ -911,7 +942,8 @@ Opt-in experimental features that may change or be removed in future versions. U
"experimental": {
"aggressive_truncation": true,
"auto_resume": true,
"truncate_all_tool_outputs": false
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
@@ -921,6 +953,7 @@ Opt-in experimental features that may change or be removed in future versions. U
| `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. |
| `dcp_for_compaction` | `false` | When enabled, Dynamic Context Pruning (DCP) runs FIRST when token limit errors occur, before attempting compaction. DCP prunes redundant context, then compaction runs immediately. Enable this for smarter recovery when hitting token limits. |
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
@@ -968,11 +1001,16 @@ I have no affiliation with any project or model mentioned here. This is purely p
- [The fix](https://github.com/sst/opencode/pull/5040) was merged after 1.0.132—use a newer version.
- Fun fact: That PR was discovered and fixed thanks to OhMyOpenCode's Librarian, Explore, and Oracle setup.
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*
## Loved by professionals at
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## Sponsors
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- The first sponsor
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*

View File

@@ -404,8 +404,8 @@ gh repo star code-yeongyu/oh-my-opencode
- **Sisyphus** (`anthropic/claude-opus-4-5`)**默认 Agent。** OpenCode 专属的强力 AI 编排器。指挥专业子 Agent 搞定复杂任务。主打后台任务委派和 Todo 驱动。用 Claude Opus 4.5 加上扩展思考32k token 预算),智商拉满。
- **oracle** (`openai/gpt-5.2`)架构师、代码审查员、战略家。GPT-5.2 的逻辑推理和深度分析能力不是盖的。致敬 AmpCode。
- **librarian** (`anthropic/claude-sonnet-4-5`):多仓库分析、查文档、找示例。Claude Sonnet 4.5 深入理解代码库GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
- **explore** (`opencode/grok-code`)极速代码库扫描、模式匹配。Claude Code 用 Haiku我们用 Grok——免费、飞快、扫文件够用了。致敬 Claude Code。
- **librarian** (`anthropic/claude-sonnet-4-5``google/gemini-3-flash`):多仓库分析、查文档、找示例。配置 Antigravity 认证时使用 Gemini 3 Flash否则使用 Claude Sonnet 4.5 深入理解代码库GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
- **explore** (`opencode/grok-code``google/gemini-3-flash``anthropic/claude-haiku-4-5`):极速代码库扫描、模式匹配。配置 Antigravity 认证时使用 Gemini 3 FlashClaude max20 可用时使用 Haiku否则用 Grok。致敬 Claude Code。
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`)设计师出身的程序员。UI 做得那是真漂亮。Gemini 写这种创意美观的代码是一绝。
- **document-writer** (`google/gemini-3-pro-preview`)技术写作专家。Gemini 文笔好,写出来的东西读着顺畅。
- **multimodal-looker** (`google/gemini-3-flash`)视觉内容专家。PDF、图片、图表看一眼就知道里头有啥。
@@ -606,8 +606,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
- **Agent 使用提醒**:你自己搜东西的时候,弹窗提醒你"这种事让后台专业 Agent 干更好"。
- **Anthropic 自动压缩**Claude Token 爆了?自动总结压缩会话——不用你操心。
- **会话恢复**工具没结果Thinking 卡住?消息是空的?自动恢复。会话崩不了,崩了也能救回来。
- **自动更新检查**oh-my-opencode 更新了会告诉你
- **启动提示**:加载时来句"oMoMoMo",开启元气满满的一次会话。
- **自动更新检查**自动检查 oh-my-opencode 新版本并可自动更新配置。显示启动提示通知,展示当前版本和 Sisyphus 状态Sisyphus 启用时显示「Sisyphus on steroids is steering OpenCode」禁用时显示「OpenCode is now on Steroids. oMoMoMoMo...」)。要禁用全部功能,在 `disabled_hooks` 中添加 `"auto-update-checker"`;只禁用提示通知,添加 `"startup-toast"`。详见 [配置 > Hooks](#hooks)
- **后台通知**:后台 Agent 活儿干完了告诉你。
- **会话通知**Agent 没事干了发系统通知。macOS、Linux、Windows 通吃——别让 Agent 等你。
- **空 Task 响应检测**Task 工具回了个寂寞?立马报警,别傻傻等一个永远不会来的响应。
@@ -795,6 +794,8 @@ Sisyphus Agent 也能自定义:
可关的 hook`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-auto-compact`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
### MCPs
默认送你 Context7、Exa 和 grep.app MCP。
@@ -845,7 +846,8 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
"experimental": {
"aggressive_truncation": true,
"auto_resume": true,
"truncate_all_tool_outputs": false
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
@@ -855,6 +857,7 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| `truncate_all_tool_outputs` | `true` | 为防止提示过长,根据上下文窗口使用情况动态截断所有工具输出。如需完整工具输出,设置为 `false` 禁用此功能。 |
| `dcp_for_compaction` | `false` | 启用后,当发生 token 限制错误时DCP动态上下文剪枝首先运行然后立即执行压缩。DCP 清理不必要的上下文后,压缩立即进行。当达到 token 限制时需要更智能的恢复请启用此选项。 |
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
@@ -901,11 +904,16 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
- [修复 PR](https://github.com/sst/opencode/pull/5040) 在 1.0.132 之后才合进去——请用新版本。
- 花絮:这 bug 也是靠 OhMyOpenCode 的 Librarian、Explore、Oracle 配合发现并修好的。
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*
## 以下企业的专业人士都在用
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## 赞助者
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 第一位赞助者
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*

View File

@@ -59,7 +59,17 @@
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer"
"empty-message-sanitizer",
"thinking-block-validator"
]
}
},
"disabled_commands": {
"type": "array",
"items": {
"type": "string",
"enum": [
"init-deep"
]
}
},
@@ -1339,6 +1349,18 @@
},
"hooks": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
"plugins_override": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
}
}
},
@@ -1362,6 +1384,14 @@
}
}
},
"comment_checker": {
"type": "object",
"properties": {
"custom_prompt": {
"type": "string"
}
}
},
"experimental": {
"type": "object",
"properties": {
@@ -1382,6 +1412,100 @@
"truncate_all_tool_outputs": {
"default": true,
"type": "boolean"
},
"dynamic_context_pruning": {
"type": "object",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"notification": {
"default": "detailed",
"type": "string",
"enum": [
"off",
"minimal",
"detailed"
]
},
"turn_protection": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
},
"turns": {
"default": 3,
"type": "number",
"minimum": 1,
"maximum": 10
}
}
},
"protected_tools": {
"default": [
"task",
"todowrite",
"todoread",
"lsp_rename",
"lsp_code_action_resolve",
"session_read",
"session_write",
"session_search"
],
"type": "array",
"items": {
"type": "string"
}
},
"strategies": {
"type": "object",
"properties": {
"deduplication": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
}
}
},
"supersede_writes": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
},
"aggressive": {
"default": false,
"type": "boolean"
}
}
},
"purge_errors": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
},
"turns": {
"default": 5,
"type": "number",
"minimum": 1,
"maximum": 20
}
}
}
}
}
}
},
"dcp_for_compaction": {
"type": "boolean"
}
}
},

View File

@@ -8,12 +8,13 @@
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.6.0",
"@code-yeongyu/comment-checker": "^0.6.1",
"@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",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
@@ -72,7 +73,7 @@
"@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=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
"@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=="],
@@ -110,6 +111,8 @@
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "2.5.4",
"version": "2.7.0",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -53,12 +53,13 @@
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.6.0",
"@code-yeongyu/comment-checker": "^0.6.1",
"@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",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",

View File

@@ -39,6 +39,30 @@
"created_at": "2025-12-26T05:16:12Z",
"repoId": 1108837393,
"pullRequestNo": 248
},
{
"name": "harshav167",
"id": 80092815,
"comment_id": 3693666997,
"created_at": "2025-12-27T04:40:35Z",
"repoId": 1108837393,
"pullRequestNo": 268
},
{
"name": "adam2am",
"id": 128839448,
"comment_id": 3694022446,
"created_at": "2025-12-27T14:49:05Z",
"repoId": 1108837393,
"pullRequestNo": 281
},
{
"name": "devxoul",
"id": 931655,
"comment_id": 3694098760,
"created_at": "2025-12-27T17:05:50Z",
"repoId": 1108837393,
"pullRequestNo": 288
}
]
}

89
src/agents/AGENTS.md Normal file
View File

@@ -0,0 +1,89 @@
# AGENTS KNOWLEDGE BASE
## OVERVIEW
AI agent definitions for multi-model orchestration. 7 specialized agents: Sisyphus (orchestrator), oracle (strategy), librarian (research), explore (grep), frontend-ui-ux-engineer, document-writer, multimodal-looker.
## STRUCTURE
```
agents/
├── sisyphus.ts # Primary orchestrator (Claude Opus 4.5)
├── oracle.ts # Strategic advisor (GPT-5.2)
├── librarian.ts # Multi-repo research (Claude Sonnet 4.5)
├── explore.ts # Fast codebase grep (Grok Code)
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
├── document-writer.ts # Technical docs (Gemini 3 Flash)
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
├── build-prompt.ts # Shared build agent prompt
├── plan-prompt.ts # Shared plan agent prompt
├── types.ts # AgentModelConfig interface
├── utils.ts # createBuiltinAgents(), getAgentName()
└── index.ts # builtinAgents export
```
## AGENT MODELS
| Agent | Default Model | Fallback | Purpose |
|-------|---------------|----------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
| oracle | openai/gpt-5.2 | - | Architecture, debugging, code review |
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
| multimodal-looker | google/gemini-3-flash | - | PDF/image analysis |
## HOW TO ADD AN AGENT
1. Create `src/agents/my-agent.ts`:
```typescript
import type { AgentConfig } from "@opencode-ai/sdk"
export const myAgent: AgentConfig = {
model: "provider/model-name",
temperature: 0.1,
system: "Agent system prompt...",
tools: { include: ["tool1", "tool2"] }, // or exclude: [...]
}
```
2. Add to `builtinAgents` in `src/agents/index.ts`
3. Update `types.ts` if adding new config options
## AGENT CONFIG OPTIONS
| Option | Type | Description |
|--------|------|-------------|
| model | string | Model identifier (provider/model-name) |
| temperature | number | 0.0-1.0, most use 0.1 for consistency |
| system | string | System prompt (can be multiline template literal) |
| tools | object | `{ include: [...] }` or `{ exclude: [...] }` |
| top_p | number | Optional nucleus sampling |
| maxTokens | number | Optional max output tokens |
## MODEL FALLBACK LOGIC
`createBuiltinAgents()` in utils.ts handles model fallback:
1. Check user config override (`agents.{name}.model`)
2. Check installer settings (claude max20, gemini antigravity)
3. Use default model
**Fallback order for explore**:
- If gemini antigravity enabled → `google/gemini-3-flash`
- If claude max20 enabled → `anthropic/claude-haiku-4-5`
- Default → `opencode/grok-code` (free)
## ANTI-PATTERNS (AGENTS)
- **High temperature**: Don't use >0.3 for code-related agents
- **Broad tool access**: Prefer explicit `include` over unrestricted access
- **Monolithic prompts**: Keep prompts focused; delegate to specialized agents
- **Missing fallbacks**: Consider free/cheap fallbacks for rate-limited models
## SHARED PROMPTS
- **build-prompt.ts**: Base prompt for build agents (OpenCode default + Sisyphus variants)
- **plan-prompt.ts**: Base prompt for plan agents (Planner-Sisyphus)
Used by `src/index.ts` when creating Builder-Sisyphus and Planner-Sisyphus variants.

View File

@@ -17,16 +17,15 @@
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
*/
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants"
import { fetchProjectContext, clearProjectContextCache } from "./project"
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants"
import { fetchProjectContext, clearProjectContextCache, invalidateProjectContextByRefreshToken } from "./project"
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage, AntigravityTokenRefreshError } from "./token"
import { transformRequest } from "./request"
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
import {
transformResponse,
transformStreamingResponse,
isStreamingResponse,
extractSignatureFromSsePayload,
} from "./response"
import { normalizeToolsForGemini, type OpenAITool } from "./tools"
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
@@ -391,7 +390,6 @@ export function createAntigravityFetch(
try {
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
// Update cached tokens
cachedTokens = {
type: "antigravity",
access_token: newTokens.access_token,
@@ -400,10 +398,8 @@ export function createAntigravityFetch(
timestamp: Date.now(),
}
// Clear project context cache on token refresh
clearProjectContextCache()
// Format and save new tokens
const formattedRefresh = formatTokenForStorage(
newTokens.refresh_token,
refreshParts.projectId || "",
@@ -418,6 +414,16 @@ export function createAntigravityFetch(
debugLog("Token refreshed successfully")
} catch (error) {
if (error instanceof AntigravityTokenRefreshError) {
if (error.isInvalidGrant) {
debugLog(`[REFRESH] Token revoked (invalid_grant), clearing caches`)
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
clearProjectContextCache()
}
throw new Error(
`Antigravity: Token refresh failed: ${error.description || error.message}${error.code ? ` (${error.code})` : ""}`
)
}
throw new Error(
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
)
@@ -535,11 +541,33 @@ export function createAntigravityFetch(
debugLog("[401] Token refreshed, retrying request...")
return executeWithEndpoints()
} catch (refreshError) {
if (refreshError instanceof AntigravityTokenRefreshError) {
if (refreshError.isInvalidGrant) {
debugLog(`[401] Token revoked (invalid_grant), clearing caches`)
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
clearProjectContextCache()
}
debugLog(`[401] Token refresh failed: ${refreshError.description || refreshError.message}`)
return new Response(
JSON.stringify({
error: {
message: refreshError.description || refreshError.message,
type: refreshError.isInvalidGrant ? "token_revoked" : "unauthorized",
code: refreshError.code || "token_refresh_failed",
},
}),
{
status: 401,
statusText: "Unauthorized",
headers: { "Content-Type": "application/json" },
}
)
}
debugLog(`[401] Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`)
return new Response(
JSON.stringify({
error: {
message: `Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`,
message: refreshError instanceof Error ? refreshError.message : "Unknown error",
type: "unauthorized",
code: "token_refresh_failed",
},

View File

@@ -267,3 +267,8 @@ export function clearProjectContextCache(accessToken?: string): void {
projectContextCache.clear()
}
}
export function invalidateProjectContextByRefreshToken(_refreshToken: string): void {
projectContextCache.clear()
debugLog(`[invalidateProjectContextByRefreshToken] Cleared all project context cache due to refresh token invalidation`)
}

View File

@@ -1,8 +1,3 @@
/**
* Antigravity token management utilities.
* Handles token expiration checking, refresh, and storage format parsing.
*/
import {
ANTIGRAVITY_CLIENT_ID,
ANTIGRAVITY_CLIENT_SECRET,
@@ -13,33 +8,86 @@ import type {
AntigravityRefreshParts,
AntigravityTokenExchangeResult,
AntigravityTokens,
OAuthErrorPayload,
ParsedOAuthError,
} from "./types"
/**
* Check if the access token is expired.
* Includes a 60-second safety buffer to refresh before actual expiration.
*
* @param tokens - The Antigravity tokens to check
* @returns true if the token is expired or will expire within the buffer period
*/
export function isTokenExpired(tokens: AntigravityTokens): boolean {
// Calculate when the token expires (timestamp + expires_in in ms)
// timestamp is in milliseconds, expires_in is in seconds
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
export class AntigravityTokenRefreshError extends Error {
code?: string
description?: string
status: number
statusText: string
responseBody?: string
// Check if current time is past (expiration - buffer)
constructor(options: {
message: string
code?: string
description?: string
status: number
statusText: string
responseBody?: string
}) {
super(options.message)
this.name = "AntigravityTokenRefreshError"
this.code = options.code
this.description = options.description
this.status = options.status
this.statusText = options.statusText
this.responseBody = options.responseBody
}
get isInvalidGrant(): boolean {
return this.code === "invalid_grant"
}
get isNetworkError(): boolean {
return this.status === 0
}
}
function parseOAuthErrorPayload(text: string | undefined): ParsedOAuthError {
if (!text) {
return {}
}
try {
const payload = JSON.parse(text) as OAuthErrorPayload
let code: string | undefined
if (typeof payload.error === "string") {
code = payload.error
} else if (payload.error && typeof payload.error === "object") {
code = payload.error.status ?? payload.error.code
}
return {
code,
description: payload.error_description,
}
} catch {
return { description: text }
}
}
export function isTokenExpired(tokens: AntigravityTokens): boolean {
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS
}
/**
* Refresh an access token using a refresh token.
* Exchanges the refresh token for a new access token via Google's OAuth endpoint.
*
* @param refreshToken - The refresh token to use
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Token exchange result with new access token, or throws on error
*/
const MAX_REFRESH_RETRIES = 3
const INITIAL_RETRY_DELAY_MS = 1000
function calculateRetryDelay(attempt: number): number {
return Math.min(INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt), 10000)
}
function isRetryableError(status: number): boolean {
if (status === 0) return true
if (status === 429) return true
if (status >= 500 && status < 600) return true
return false
}
export async function refreshAccessToken(
refreshToken: string,
clientId: string = ANTIGRAVITY_CLIENT_ID,
@@ -52,35 +100,81 @@ export async function refreshAccessToken(
client_secret: clientSecret,
})
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
let lastError: AntigravityTokenRefreshError | undefined
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
try {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
})
if (response.ok) {
const data = (await response.json()) as {
access_token: string
refresh_token?: string
expires_in: number
token_type: string
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token || refreshToken,
expires_in: data.expires_in,
token_type: data.token_type,
}
}
const responseBody = await response.text().catch(() => undefined)
const parsed = parseOAuthErrorPayload(responseBody)
lastError = new AntigravityTokenRefreshError({
message: parsed.description || `Token refresh failed: ${response.status} ${response.statusText}`,
code: parsed.code,
description: parsed.description,
status: response.status,
statusText: response.statusText,
responseBody,
})
if (parsed.code === "invalid_grant") {
throw lastError
}
if (!isRetryableError(response.status)) {
throw lastError
}
if (attempt < MAX_REFRESH_RETRIES) {
const delay = calculateRetryDelay(attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
}
} catch (error) {
if (error instanceof AntigravityTokenRefreshError) {
throw error
}
lastError = new AntigravityTokenRefreshError({
message: error instanceof Error ? error.message : "Network error during token refresh",
status: 0,
statusText: "Network Error",
})
if (attempt < MAX_REFRESH_RETRIES) {
const delay = calculateRetryDelay(attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw lastError || new AntigravityTokenRefreshError({
message: "Token refresh failed after all retries",
status: 0,
statusText: "Max Retries Exceeded",
})
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(
`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
)
}
const data = (await response.json()) as {
access_token: string
refresh_token?: string
expires_in: number
token_type: string
}
return {
access_token: data.access_token,
// Google may return a new refresh token, fall back to the original
refresh_token: data.refresh_token || refreshToken,
expires_in: data.expires_in,
token_type: data.token_type,
}
}
/**

View File

@@ -194,3 +194,20 @@ export interface AntigravityRefreshParts {
projectId?: string
managedProjectId?: string
}
/**
* OAuth error payload from Google
* Google returns errors in multiple formats, this handles all of them
*/
export interface OAuthErrorPayload {
error?: string | { status?: string; code?: string; message?: string }
error_description?: string
}
/**
* Parsed OAuth error with normalized fields
*/
export interface ParsedOAuthError {
code?: string
description?: string
}

View File

@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../shared"
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
@@ -39,80 +40,10 @@ export function detectConfigFormat(): { format: ConfigFormat; path: string } {
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
return parseJsonc<OpenCodeConfig>(content)
} catch {
return null
}
@@ -215,9 +146,16 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
if (!installConfig.hasClaude) {
agents["Sisyphus"] = { model: "opencode/big-pickle" }
}
if (installConfig.hasGemini) {
agents["librarian"] = { model: "google/gemini-3-flash" }
agents["explore"] = { model: "google/gemini-3-flash" }
} else if (installConfig.hasClaude && installConfig.isMax20) {
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
} else {
agents["librarian"] = { model: "opencode/big-pickle" }
} else if (!installConfig.isMax20) {
agents["librarian"] = { model: "opencode/big-pickle" }
agents["explore"] = { model: "opencode/big-pickle" }
}
if (!installConfig.hasChatGPT) {
@@ -252,8 +190,7 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
if (existsSync(OMO_CONFIG)) {
const content = readFileSync(OMO_CONFIG, "utf-8")
const cleaned = stripJsoncComments(content)
const existing = JSON.parse(cleaned) as Record<string, unknown>
const existing = parseJsonc<Record<string, unknown>>(content)
delete existing.agents
const merged = deepMerge(existing, newConfig)
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
@@ -484,7 +421,7 @@ export function detectCurrentConfig(): DetectedConfig {
try {
const content = readFileSync(OMO_CONFIG, "utf-8")
const omoConfig = JSON.parse(stripJsoncComments(content)) as OmoConfigData
const omoConfig = parseJsonc<OmoConfigData>(content)
const agents = omoConfig.agents ?? {}

View File

@@ -0,0 +1,66 @@
import color from "picocolors"
import type { VersionInfo } from "./types"
const SYMBOLS = {
check: color.green("✓"),
cross: color.red("✗"),
arrow: color.cyan("→"),
info: color.blue(""),
warn: color.yellow("⚠"),
pin: color.magenta("📌"),
dev: color.cyan("🔧"),
}
export function formatVersionOutput(info: VersionInfo): string {
const lines: string[] = []
lines.push("")
lines.push(color.bold(color.white("oh-my-opencode Version Information")))
lines.push(color.dim("─".repeat(50)))
lines.push("")
if (info.currentVersion) {
lines.push(` Current Version: ${color.cyan(info.currentVersion)}`)
} else {
lines.push(` Current Version: ${color.dim("unknown")}`)
}
if (!info.isLocalDev && info.latestVersion) {
lines.push(` Latest Version: ${color.cyan(info.latestVersion)}`)
}
lines.push("")
switch (info.status) {
case "up-to-date":
lines.push(` ${SYMBOLS.check} ${color.green("You're up to date!")}`)
break
case "outdated":
lines.push(` ${SYMBOLS.warn} ${color.yellow("Update available")}`)
lines.push(` ${color.dim("Run:")} ${color.cyan("cd ~/.config/opencode && bun update oh-my-opencode")}`)
break
case "local-dev":
lines.push(` ${SYMBOLS.dev} ${color.cyan("Running in local development mode")}`)
lines.push(` ${color.dim("Using file:// protocol from config")}`)
break
case "pinned":
lines.push(` ${SYMBOLS.pin} ${color.magenta(`Version pinned to ${info.pinnedVersion}`)}`)
lines.push(` ${color.dim("Update check skipped for pinned versions")}`)
break
case "error":
lines.push(` ${SYMBOLS.cross} ${color.red("Unable to check for updates")}`)
lines.push(` ${color.dim("Network error or npm registry unavailable")}`)
break
case "unknown":
lines.push(` ${SYMBOLS.info} ${color.yellow("Version information unavailable")}`)
break
}
lines.push("")
return lines.join("\n")
}
export function formatJsonOutput(info: VersionInfo): string {
return JSON.stringify(info, null, 2)
}

View File

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

View File

@@ -0,0 +1,14 @@
export interface VersionInfo {
currentVersion: string | null
latestVersion: string | null
isUpToDate: boolean
isLocalDev: boolean
isPinned: boolean
pinnedVersion: string | null
status: "up-to-date" | "outdated" | "local-dev" | "pinned" | "error" | "unknown"
}
export interface GetLocalVersionOptions {
directory?: string
json?: boolean
}

View File

@@ -2,8 +2,10 @@
import { Command } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
const packageJson = await import("../../package.json")
const VERSION = packageJson.version
@@ -73,6 +75,32 @@ Unlike 'opencode run', this command waits until:
process.exit(exitCode)
})
program
.command("get-local-version")
.description("Show current installed version and check for updates")
.option("-d, --directory <path>", "Working directory to check config from")
.option("--json", "Output in JSON format for scripting")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode get-local-version
$ bunx oh-my-opencode get-local-version --json
$ bunx oh-my-opencode get-local-version --directory /path/to/project
This command shows:
- Current installed version
- Latest available version on npm
- Whether you're up to date
- Special modes (local dev, pinned version)
`)
.action(async (options) => {
const versionOptions: GetLocalVersionOptions = {
directory: options.directory,
json: options.json ?? false,
}
const exitCode = await getLocalVersion(versionOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")

View File

@@ -91,19 +91,15 @@ export async function run(options: RunOptions): Promise<number> {
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
process.exit(1)
}
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
console.log(pc.green("\n\nAll tasks completed."))
abortController.abort()
await eventProcessor.catch(() => {})
cleanup()
return 0
process.exit(0)
}
}

View File

@@ -5,6 +5,7 @@ export {
McpNameSchema,
AgentNameSchema,
HookNameSchema,
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
} from "./schema"
@@ -16,6 +17,8 @@ export type {
McpName,
AgentName,
HookName,
BuiltinCommandName,
SisyphusAgentConfig,
ExperimentalConfig,
DynamicContextPruningConfig,
} from "./schema"

View File

@@ -64,6 +64,11 @@ export const HookNameSchema = z.enum([
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer",
"thinking-block-validator",
])
export const BuiltinCommandNameSchema = z.enum([
"init-deep",
])
export const AgentOverrideConfigSchema = z.object({
@@ -103,6 +108,8 @@ export const ClaudeCodeConfigSchema = z.object({
skills: z.boolean().optional(),
agents: z.boolean().optional(),
hooks: z.boolean().optional(),
plugins: z.boolean().optional(),
plugins_override: z.record(z.string(), z.boolean()).optional(),
})
export const SisyphusAgentConfigSchema = z.object({
@@ -112,6 +119,47 @@ export const SisyphusAgentConfigSchema = z.object({
replace_plan: z.boolean().optional(),
})
export const CommentCheckerConfigSchema = z.object({
/** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */
custom_prompt: z.string().optional(),
})
export const DynamicContextPruningConfigSchema = z.object({
/** Enable dynamic context pruning (default: false) */
enabled: z.boolean().default(false),
/** Notification level: off, minimal, or detailed (default: detailed) */
notification: z.enum(["off", "minimal", "detailed"]).default("detailed"),
/** Turn protection - prevent pruning recent tool outputs */
turn_protection: z.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(10).default(3),
}).optional(),
/** Tools that should never be pruned */
protected_tools: z.array(z.string()).default([
"task", "todowrite", "todoread",
"lsp_rename", "lsp_code_action_resolve",
"session_read", "session_write", "session_search",
]),
/** Pruning strategies configuration */
strategies: z.object({
/** Remove duplicate tool calls (same tool + same args) */
deduplication: z.object({
enabled: z.boolean().default(true),
}).optional(),
/** Prune write inputs when file subsequently read */
supersede_writes: z.object({
enabled: z.boolean().default(true),
/** Aggressive mode: prune any write if ANY subsequent read */
aggressive: z.boolean().default(false),
}).optional(),
/** Prune errored tool inputs after N turns */
purge_errors: z.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(20).default(5),
}).optional(),
}).optional(),
})
export const ExperimentalConfigSchema = z.object({
aggressive_truncation: z.boolean().optional(),
auto_resume: z.boolean().optional(),
@@ -121,6 +169,10 @@ export const ExperimentalConfigSchema = z.object({
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
truncate_all_tool_outputs: z.boolean().default(true),
/** Dynamic context pruning configuration */
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
/** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */
dcp_for_compaction: z.boolean().optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
@@ -128,10 +180,12 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_mcps: z.array(McpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
agents: AgentOverridesSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
google_auth: z.boolean().optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
comment_checker: CommentCheckerConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
auto_update: z.boolean().optional(),
})
@@ -141,7 +195,10 @@ export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type AgentName = z.infer<typeof AgentNameSchema>
export type HookName = z.infer<typeof HookNameSchema>
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
export { McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -12,10 +12,12 @@ features/
│ ├── manager.ts # Task lifecycle, notifications
│ ├── manager.test.ts
│ └── types.ts
├── builtin-commands/ # Built-in slash command definitions
├── 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-plugin-loader/ # Load external plugins from installed_plugins.json
├── 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

View File

@@ -57,7 +57,7 @@ export class BackgroundManager {
private notifications: Map<string, BackgroundTask[]>
private client: OpencodeClient
private directory: string
private pollingInterval?: Timer
private pollingInterval?: ReturnType<typeof setInterval>
constructor(ctx: PluginInput) {
this.tasks = new Map()
@@ -287,6 +287,7 @@ export class BackgroundManager {
this.pollingInterval = setInterval(() => {
this.pollRunningTasks()
}, 2000)
this.pollingInterval.unref()
}
private stopPolling(): void {
@@ -296,6 +297,12 @@ export class BackgroundManager {
}
}
cleanup(): void {
this.stopPolling()
this.tasks.clear()
this.notifications.clear()
}
private notifyParentSession(task: BackgroundTask): void {
const duration = this.formatDuration(task.startedAt, task.completedAt)
@@ -318,6 +325,7 @@ export class BackgroundManager {
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
const taskId = task.id
setTimeout(async () => {
try {
const messageDir = getMessageDir(task.parentSessionID)
@@ -337,10 +345,13 @@ export class BackgroundManager {
},
query: { directory: this.directory },
})
this.clearNotificationsForTask(task.id)
this.clearNotificationsForTask(taskId)
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
} catch (error) {
log("[background-agent] prompt failed:", String(error))
} finally {
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
}, 200)
}

View File

@@ -0,0 +1,35 @@
import type { CommandDefinition } from "../claude-code-command-loader"
import type { BuiltinCommandName, BuiltinCommands } from "./types"
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
"init-deep": {
description: "(builtin) Initialize hierarchical AGENTS.md knowledge base",
template: `<command-instruction>
${INIT_DEEP_TEMPLATE}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`,
argumentHint: "[--create-new] [--max-depth=N]",
},
}
export function loadBuiltinCommands(
disabledCommands?: BuiltinCommandName[]
): BuiltinCommands {
const disabled = new Set(disabledCommands ?? [])
const commands: BuiltinCommands = {}
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
if (!disabled.has(name as BuiltinCommandName)) {
commands[name] = {
name,
...definition,
}
}
}
return commands
}

View File

@@ -0,0 +1,2 @@
export * from "./types"
export * from "./commands"

View File

@@ -0,0 +1,299 @@
export const INIT_DEEP_TEMPLATE = `# Initialize Deep Knowledge Base
Generate comprehensive AGENTS.md files across project hierarchy. Combines root-level project knowledge (gen-knowledge) with complexity-based subdirectory documentation (gen-knowledge-deep).
## Usage
\`\`\`
/init-deep # Analyze and generate hierarchical AGENTS.md
/init-deep --create-new # Force create from scratch (ignore existing)
/init-deep --max-depth=2 # Limit to N directory levels (default: 3)
\`\`\`
---
## Core Principles
- **Telegraphic Style**: Sacrifice grammar for concision ("Project uses React" → "React 18")
- **Predict-then-Compare**: Predict standard → find actual → document ONLY deviations
- **Hierarchy Aware**: Parent covers general, children cover specific
- **No Redundancy**: Child AGENTS.md NEVER repeats parent content
---
## Process
<critical>
**MANDATORY: TodoWrite for ALL phases. Mark in_progress → completed in real-time.**
</critical>
### Phase 0: Initialize
\`\`\`
TodoWrite([
{ id: "p1-analysis", content: "Parallel project structure & complexity analysis", status: "pending", priority: "high" },
{ id: "p2-scoring", content: "Score directories, determine AGENTS.md locations", status: "pending", priority: "high" },
{ id: "p3-root", content: "Generate root AGENTS.md with Predict-then-Compare", status: "pending", priority: "high" },
{ id: "p4-subdirs", content: "Generate subdirectory AGENTS.md files in parallel", status: "pending", priority: "high" },
{ id: "p5-review", content: "Review, deduplicate, validate all files", status: "pending", priority: "medium" }
])
\`\`\`
---
## Phase 1: Parallel Project Analysis
**Mark "p1-analysis" as in_progress.**
Launch **ALL tasks simultaneously**:
<parallel-tasks>
### Structural Analysis (bash - run in parallel)
\`\`\`bash
# Task A: Directory depth analysis
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
# Task B: File count per directory
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
# Task C: Code concentration
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.go" -o -name "*.rs" -o -name "*.java" \\) -not -path '*/node_modules/*' -not -path '*/venv/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
# Task D: Existing knowledge files
find . -type f \\( -name "AGENTS.md" -o -name "CLAUDE.md" \\) -not -path '*/node_modules/*' 2>/dev/null
\`\`\`
### Context Gathering (Explore agents - background_task in parallel)
\`\`\`
background_task(agent="explore", prompt="Project structure: PREDICT standard {lang} patterns → FIND package.json/pyproject.toml/go.mod → REPORT deviations only")
background_task(agent="explore", prompt="Entry points: PREDICT typical (main.py, index.ts) → FIND actual → REPORT non-standard organization")
background_task(agent="explore", prompt="Conventions: FIND .cursor/rules, .cursorrules, eslintrc, pyproject.toml → REPORT project-specific rules DIFFERENT from defaults")
background_task(agent="explore", prompt="Anti-patterns: FIND comments with 'DO NOT', 'NEVER', 'ALWAYS', 'LEGACY', 'DEPRECATED' → REPORT forbidden patterns")
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile, justfile → REPORT non-standard build/deploy patterns")
background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.config, test structure → REPORT unique testing conventions")
\`\`\`
</parallel-tasks>
**Collect all results. Mark "p1-analysis" as completed.**
---
## Phase 2: Complexity Scoring & Location Decision
**Mark "p2-scoring" as in_progress.**
### Scoring Matrix
| Factor | Weight | Threshold |
|--------|--------|-----------|
| File count | 3x | >20 files = high |
| Subdirectory count | 2x | >5 subdirs = high |
| Code file ratio | 2x | >70% code = high |
| Unique patterns | 1x | Has own config |
| Module boundary | 2x | Has __init__.py/index.ts |
### Decision Rules
| Score | Action |
|-------|--------|
| **Root (.)** | ALWAYS create AGENTS.md |
| **High (>15)** | Create dedicated AGENTS.md |
| **Medium (8-15)** | Create if distinct domain |
| **Low (<8)** | Skip, parent sufficient |
### Output Format
\`\`\`
AGENTS_LOCATIONS = [
{ path: ".", type: "root" },
{ path: "src/api", score: 18, reason: "high complexity, 45 files" },
{ path: "src/hooks", score: 12, reason: "distinct domain, unique patterns" },
]
\`\`\`
**Mark "p2-scoring" as completed.**
---
## Phase 3: Generate Root AGENTS.md
**Mark "p3-root" as in_progress.**
Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
### Required Sections
\`\`\`markdown
# PROJECT KNOWLEDGE BASE
**Generated:** {TIMESTAMP}
**Commit:** {SHORT_SHA}
**Branch:** {BRANCH}
## OVERVIEW
{1-2 sentences: what project does, core tech stack}
## STRUCTURE
\\\`\\\`\\\`
{project-root}/
├── {dir}/ # {non-obvious purpose only}
└── {entry} # entry point
\\\`\\\`\\\`
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Add feature X | \\\`src/x/\\\` | {pattern hint} |
## CONVENTIONS
{ONLY deviations from standard - skip generic advice}
- **{rule}**: {specific detail}
## ANTI-PATTERNS (THIS PROJECT)
{Things explicitly forbidden HERE}
- **{pattern}**: {why} → {alternative}
## UNIQUE STYLES
{Project-specific coding styles}
- **{style}**: {how different}
## COMMANDS
\\\`\\\`\\\`bash
{dev-command}
{test-command}
{build-command}
\\\`\\\`\\\`
## NOTES
{Gotchas, non-obvious info}
\`\`\`
### Quality Gates
- [ ] Size: 50-150 lines
- [ ] No generic advice ("write clean code")
- [ ] No obvious info ("tests/ has tests")
- [ ] Every item is project-specific
**Mark "p3-root" as completed.**
---
## Phase 4: Generate Subdirectory AGENTS.md
**Mark "p4-subdirs" as in_progress.**
For each location in AGENTS_LOCATIONS (except root), launch **parallel document-writer agents**:
\`\`\`typescript
for (const loc of AGENTS_LOCATIONS.filter(l => l.path !== ".")) {
background_task({
agent: "document-writer",
prompt: \\\`
Generate AGENTS.md for: \${loc.path}
CONTEXT:
- Complexity reason: \${loc.reason}
- Parent AGENTS.md: ./AGENTS.md (already covers project overview)
CRITICAL RULES:
1. Focus ONLY on this directory's specific context
2. NEVER repeat parent AGENTS.md content
3. Shorter is better - 30-80 lines max
4. Telegraphic style - sacrifice grammar
REQUIRED SECTIONS:
- OVERVIEW (1 line: what this directory does)
- STRUCTURE (only if >5 subdirs)
- WHERE TO LOOK (directory-specific tasks)
- CONVENTIONS (only if DIFFERENT from root)
- ANTI-PATTERNS (directory-specific only)
OUTPUT: Write to \${loc.path}/AGENTS.md
\\\`
})
}
\`\`\`
**Wait for all agents. Mark "p4-subdirs" as completed.**
---
## Phase 5: Review & Deduplicate
**Mark "p5-review" as in_progress.**
### Validation Checklist
For EACH generated AGENTS.md:
| Check | Action if Fail |
|-------|----------------|
| Contains generic advice | REMOVE the line |
| Repeats parent content | REMOVE the line |
| Missing required section | ADD it |
| Over 150 lines (root) / 80 lines (subdir) | TRIM |
| Verbose explanations | REWRITE telegraphic |
### Cross-Reference Validation
\`\`\`
For each child AGENTS.md:
For each line in child:
If similar line exists in parent:
REMOVE from child (parent already covers)
\`\`\`
**Mark "p5-review" as completed.**
---
## Final Report
\`\`\`
=== init-deep Complete ===
Files Generated:
✓ ./AGENTS.md (root, {N} lines)
✓ ./src/hooks/AGENTS.md ({N} lines)
✓ ./src/tools/AGENTS.md ({N} lines)
Directories Analyzed: {N}
AGENTS.md Created: {N}
Total Lines: {N}
Hierarchy:
./AGENTS.md
├── src/hooks/AGENTS.md
└── src/tools/AGENTS.md
\`\`\`
---
## Anti-Patterns for THIS Command
- **Over-documenting**: Not every directory needs AGENTS.md
- **Redundancy**: Child must NOT repeat parent
- **Generic content**: Remove anything that applies to ALL projects
- **Sequential execution**: MUST use parallel agents
- **Deep nesting**: Rarely need AGENTS.md at depth 4+
- **Verbose style**: "This directory contains..." → just list it`

View File

@@ -0,0 +1,9 @@
import type { CommandDefinition } from "../claude-code-command-loader"
export type BuiltinCommandName = "init-deep"
export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]
}
export type BuiltinCommands = Record<string, CommandDefinition>

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
@@ -68,7 +68,7 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
}
export function loadUserAgents(): Record<string, AgentConfig> {
const userAgentsDir = join(homedir(), ".claude", "agents")
const userAgentsDir = join(getClaudeConfigDir(), "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, AgentConfig> = {}

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
@@ -68,7 +68,7 @@ function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefi
}
export function loadUserCommands(): Record<string, CommandDefinition> {
const userCommandsDir = join(homedir(), ".claude", "commands")
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands)
}
@@ -80,6 +80,7 @@ export function loadProjectCommands(): Record<string, CommandDefinition> {
}
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
const { homedir } = require("os")
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)

View File

@@ -1,6 +1,6 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import { getClaudeConfigDir } from "../../shared"
import type {
ClaudeCodeMcpConfig,
LoadedMcpServer,
@@ -16,11 +16,11 @@ interface McpConfigPath {
}
function getMcpConfigPaths(): McpConfigPath[] {
const home = homedir()
const claudeConfigDir = getClaudeConfigDir()
const cwd = process.cwd()
return [
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
{ path: join(claudeConfigDir, ".mcp.json"), scope: "user" },
{ path: join(cwd, ".mcp.json"), scope: "project" },
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
]

View File

@@ -0,0 +1,3 @@
export * from "./types"
export * from "./loader"
export type { PluginLoaderOptions, ClaudeSettings } from "./types"

View File

@@ -0,0 +1,480 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile, resolveSymlink } from "../../shared/file-utils"
import { log } from "../../shared/logger"
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
import { transformMcpServer } from "../claude-code-mcp-loader/transformer"
import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types"
import type { SkillMetadata } from "../claude-code-skill-loader/types"
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
import type { ClaudeCodeMcpConfig, McpServerConfig } from "../claude-code-mcp-loader/types"
import type {
InstalledPluginsDatabase,
PluginInstallation,
PluginManifest,
LoadedPlugin,
PluginLoadResult,
PluginLoadError,
PluginScope,
HooksConfig,
ClaudeSettings,
PluginLoaderOptions,
} from "./types"
const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"
function getPluginsBaseDir(): string {
// Allow override for testing
if (process.env.CLAUDE_PLUGINS_HOME) {
return process.env.CLAUDE_PLUGINS_HOME
}
return join(homedir(), ".claude", "plugins")
}
function getInstalledPluginsPath(): string {
return join(getPluginsBaseDir(), "installed_plugins.json")
}
function resolvePluginPath(path: string, pluginRoot: string): string {
return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot)
}
function resolvePluginPaths<T>(obj: T, pluginRoot: string): T {
if (obj === null || obj === undefined) return obj
if (typeof obj === "string") {
return resolvePluginPath(obj, pluginRoot) as T
}
if (Array.isArray(obj)) {
return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = resolvePluginPaths(value, pluginRoot)
}
return result as T
}
return obj
}
function loadInstalledPlugins(): InstalledPluginsDatabase | null {
const dbPath = getInstalledPluginsPath()
if (!existsSync(dbPath)) {
return null
}
try {
const content = readFileSync(dbPath, "utf-8")
return JSON.parse(content) as InstalledPluginsDatabase
} catch (error) {
log("Failed to load installed plugins database", error)
return null
}
}
function getClaudeSettingsPath(): string {
if (process.env.CLAUDE_SETTINGS_PATH) {
return process.env.CLAUDE_SETTINGS_PATH
}
return join(homedir(), ".claude", "settings.json")
}
function loadClaudeSettings(): ClaudeSettings | null {
const settingsPath = getClaudeSettingsPath()
if (!existsSync(settingsPath)) {
return null
}
try {
const content = readFileSync(settingsPath, "utf-8")
return JSON.parse(content) as ClaudeSettings
} catch (error) {
log("Failed to load Claude settings", error)
return null
}
}
function loadPluginManifest(installPath: string): PluginManifest | null {
const manifestPath = join(installPath, ".claude-plugin", "plugin.json")
if (!existsSync(manifestPath)) {
return null
}
try {
const content = readFileSync(manifestPath, "utf-8")
return JSON.parse(content) as PluginManifest
} catch (error) {
log(`Failed to load plugin manifest from ${manifestPath}`, error)
return null
}
}
function derivePluginNameFromKey(pluginKey: string): string {
const atIndex = pluginKey.indexOf("@")
if (atIndex > 0) {
return pluginKey.substring(0, atIndex)
}
return pluginKey
}
function isPluginEnabled(
pluginKey: string,
settingsEnabledPlugins: Record<string, boolean> | undefined,
overrideEnabledPlugins: Record<string, boolean> | undefined
): boolean {
if (overrideEnabledPlugins && pluginKey in overrideEnabledPlugins) {
return overrideEnabledPlugins[pluginKey]
}
if (settingsEnabledPlugins && pluginKey in settingsEnabledPlugins) {
return settingsEnabledPlugins[pluginKey]
}
return true
}
function extractPluginEntries(
db: InstalledPluginsDatabase
): Array<[string, PluginInstallation | undefined]> {
if (db.version === 1) {
return Object.entries(db.plugins).map(([key, installation]) => [key, installation])
}
return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]])
}
export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult {
const db = loadInstalledPlugins()
const settings = loadClaudeSettings()
const plugins: LoadedPlugin[] = []
const errors: PluginLoadError[] = []
if (!db || !db.plugins) {
return { plugins, errors }
}
const settingsEnabledPlugins = settings?.enabledPlugins
const overrideEnabledPlugins = options?.enabledPluginsOverride
for (const [pluginKey, installation] of extractPluginEntries(db)) {
if (!installation) continue
if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) {
log(`Plugin disabled: ${pluginKey}`)
continue
}
const { installPath, scope, version } = installation
if (!existsSync(installPath)) {
errors.push({
pluginKey,
installPath,
error: "Plugin installation path does not exist",
})
continue
}
const manifest = loadPluginManifest(installPath)
const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey)
const loadedPlugin: LoadedPlugin = {
name: pluginName,
version: version || manifest?.version || "unknown",
scope: scope as PluginScope,
installPath,
pluginKey,
manifest: manifest ?? undefined,
}
if (existsSync(join(installPath, "commands"))) {
loadedPlugin.commandsDir = join(installPath, "commands")
}
if (existsSync(join(installPath, "agents"))) {
loadedPlugin.agentsDir = join(installPath, "agents")
}
if (existsSync(join(installPath, "skills"))) {
loadedPlugin.skillsDir = join(installPath, "skills")
}
const hooksPath = join(installPath, "hooks", "hooks.json")
if (existsSync(hooksPath)) {
loadedPlugin.hooksPath = hooksPath
}
const mcpPath = join(installPath, ".mcp.json")
if (existsSync(mcpPath)) {
loadedPlugin.mcpPath = mcpPath
}
plugins.push(loadedPlugin)
log(`Discovered plugin: ${pluginName}@${version} (${scope})`, { installPath, hasManifest: !!manifest })
}
return { plugins, errors }
}
export function loadPluginCommands(
plugins: LoadedPlugin[]
): Record<string, CommandDefinition> {
const commands: Record<string, CommandDefinition> = {}
for (const plugin of plugins) {
if (!plugin.commandsDir || !existsSync(plugin.commandsDir)) continue
const entries = readdirSync(plugin.commandsDir, { withFileTypes: true })
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const commandPath = join(plugin.commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
const namespacedName = `${plugin.name}:${commandName}`
try {
const content = readFileSync(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
${body.trim()}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}`
commands[namespacedName] = {
name: namespacedName,
description: formattedDescription,
template: wrappedTemplate,
agent: data.agent,
model: sanitizeModelField(data.model, "claude-code"),
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
log(`Loaded plugin command: ${namespacedName}`, { path: commandPath })
} catch (error) {
log(`Failed to load plugin command: ${commandPath}`, error)
}
}
}
return commands
}
export function loadPluginSkillsAsCommands(
plugins: LoadedPlugin[]
): Record<string, CommandDefinition> {
const skills: Record<string, CommandDefinition> = {}
for (const plugin of plugins) {
if (!plugin.skillsDir || !existsSync(plugin.skillsDir)) continue
const entries = readdirSync(plugin.skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const skillPath = join(plugin.skillsDir, entry.name)
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const resolvedPath = resolveSymlink(skillPath)
const skillMdPath = join(resolvedPath, "SKILL.md")
if (!existsSync(skillMdPath)) continue
try {
const content = readFileSync(skillMdPath, "utf-8")
const { data, body } = parseFrontmatter<SkillMetadata>(content)
const skillName = data.name || entry.name
const namespacedName = `${plugin.name}:${skillName}`
const originalDescription = data.description || ""
const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
skills[namespacedName] = {
name: namespacedName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model),
}
log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath })
} catch (error) {
log(`Failed to load plugin skill: ${skillPath}`, error)
}
}
}
return skills
}
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
result[tool.toLowerCase()] = true
}
return result
}
export function loadPluginAgents(
plugins: LoadedPlugin[]
): Record<string, AgentConfig> {
const agents: Record<string, AgentConfig> = {}
for (const plugin of plugins) {
if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue
const entries = readdirSync(plugin.agentsDir, { withFileTypes: true })
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const agentPath = join(plugin.agentsDir, entry.name)
const agentName = basename(entry.name, ".md")
const namespacedName = `${plugin.name}:${agentName}`
try {
const content = readFileSync(agentPath, "utf-8")
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
const name = data.name || agentName
const originalDescription = data.description || ""
const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`
const config: AgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
}
const toolsConfig = parseToolsConfig(data.tools)
if (toolsConfig) {
config.tools = toolsConfig
}
agents[namespacedName] = config
log(`Loaded plugin agent: ${namespacedName}`, { path: agentPath })
} catch (error) {
log(`Failed to load plugin agent: ${agentPath}`, error)
}
}
}
return agents
}
export async function loadPluginMcpServers(
plugins: LoadedPlugin[]
): Promise<Record<string, McpServerConfig>> {
const servers: Record<string, McpServerConfig> = {}
for (const plugin of plugins) {
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
try {
const content = await Bun.file(plugin.mcpPath).text()
let config = JSON.parse(content) as ClaudeCodeMcpConfig
config = resolvePluginPaths(config, plugin.installPath)
config = expandEnvVarsInObject(config)
if (!config.mcpServers) continue
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (serverConfig.disabled) {
log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`)
continue
}
try {
const transformed = transformMcpServer(name, serverConfig)
const namespacedName = `${plugin.name}:${name}`
servers[namespacedName] = transformed
log(`Loaded plugin MCP server: ${namespacedName}`, { path: plugin.mcpPath })
} catch (error) {
log(`Failed to transform plugin MCP server "${name}"`, error)
}
}
} catch (error) {
log(`Failed to load plugin MCP config: ${plugin.mcpPath}`, error)
}
}
return servers
}
export function loadPluginHooksConfigs(
plugins: LoadedPlugin[]
): HooksConfig[] {
const configs: HooksConfig[] = []
for (const plugin of plugins) {
if (!plugin.hooksPath || !existsSync(plugin.hooksPath)) continue
try {
const content = readFileSync(plugin.hooksPath, "utf-8")
let config = JSON.parse(content) as HooksConfig
config = resolvePluginPaths(config, plugin.installPath)
configs.push(config)
log(`Loaded plugin hooks config from ${plugin.name}`, { path: plugin.hooksPath })
} catch (error) {
log(`Failed to load plugin hooks config: ${plugin.hooksPath}`, error)
}
}
return configs
}
export interface PluginComponentsResult {
commands: Record<string, CommandDefinition>
skills: Record<string, CommandDefinition>
agents: Record<string, AgentConfig>
mcpServers: Record<string, McpServerConfig>
hooksConfigs: HooksConfig[]
plugins: LoadedPlugin[]
errors: PluginLoadError[]
}
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
const { plugins, errors } = discoverInstalledPlugins(options)
const commands = loadPluginCommands(plugins)
const skills = loadPluginSkillsAsCommands(plugins)
const agents = loadPluginAgents(plugins)
const mcpServers = await loadPluginMcpServers(plugins)
const hooksConfigs = loadPluginHooksConfigs(plugins)
log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`)
return {
commands,
skills,
agents,
mcpServers,
hooksConfigs,
plugins,
errors,
}
}

View File

@@ -0,0 +1,210 @@
/**
* Claude Code Plugin Types
*
* Type definitions for Claude Code plugin system compatibility.
* Based on https://code.claude.com/docs/en/plugins-reference
*/
export type PluginScope = "user" | "project" | "local" | "managed"
/**
* Plugin installation entry in installed_plugins.json
*/
export interface PluginInstallation {
scope: PluginScope
installPath: string
version: string
installedAt: string
lastUpdated: string
gitCommitSha?: string
isLocal?: boolean
}
/**
* Installed plugins database v1 (legacy)
* plugins stored as direct objects
*/
export interface InstalledPluginsDatabaseV1 {
version: 1
plugins: Record<string, PluginInstallation>
}
/**
* Installed plugins database v2 (current)
* plugins stored as arrays
*/
export interface InstalledPluginsDatabaseV2 {
version: 2
plugins: Record<string, PluginInstallation[]>
}
/**
* Installed plugins database structure
* Located at ~/.claude/plugins/installed_plugins.json
*/
export type InstalledPluginsDatabase = InstalledPluginsDatabaseV1 | InstalledPluginsDatabaseV2
/**
* Plugin author information
*/
export interface PluginAuthor {
name?: string
email?: string
url?: string
}
/**
* Plugin manifest (plugin.json)
* Located at <plugin_root>/.claude-plugin/plugin.json
*/
export interface PluginManifest {
name: string
version?: string
description?: string
author?: PluginAuthor
homepage?: string
repository?: string
license?: string
keywords?: string[]
// Component paths (can be string or array)
commands?: string | string[]
agents?: string | string[]
skills?: string | string[]
hooks?: string | HooksConfig
mcpServers?: string | McpServersConfig
lspServers?: string | LspServersConfig
outputStyles?: string | string[]
}
/**
* Hooks configuration
*/
export interface HookEntry {
type: "command" | "prompt" | "agent"
command?: string
prompt?: string
agent?: string
}
export interface HookMatcher {
matcher?: string
hooks: HookEntry[]
}
export interface HooksConfig {
hooks?: {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]
PostToolUseFailure?: HookMatcher[]
PermissionRequest?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Notification?: HookMatcher[]
Stop?: HookMatcher[]
SubagentStart?: HookMatcher[]
SubagentStop?: HookMatcher[]
SessionStart?: HookMatcher[]
SessionEnd?: HookMatcher[]
PreCompact?: HookMatcher[]
}
}
/**
* MCP servers configuration in plugin
*/
export interface PluginMcpServer {
command?: string
args?: string[]
env?: Record<string, string>
cwd?: string
url?: string
type?: "stdio" | "http" | "sse"
disabled?: boolean
}
export interface McpServersConfig {
mcpServers?: Record<string, PluginMcpServer>
}
/**
* LSP server configuration
*/
export interface LspServerConfig {
command: string
args?: string[]
extensionToLanguage: Record<string, string>
transport?: "stdio" | "socket"
env?: Record<string, string>
initializationOptions?: Record<string, unknown>
settings?: Record<string, unknown>
workspaceFolder?: string
startupTimeout?: number
shutdownTimeout?: number
restartOnCrash?: boolean
maxRestarts?: number
loggingConfig?: {
args?: string[]
env?: Record<string, string>
}
}
export interface LspServersConfig {
[language: string]: LspServerConfig
}
/**
* Loaded plugin with all resolved components
*/
export interface LoadedPlugin {
name: string
version: string
scope: PluginScope
installPath: string
manifest?: PluginManifest
pluginKey: string
// Resolved paths for components
commandsDir?: string
agentsDir?: string
skillsDir?: string
hooksPath?: string
mcpPath?: string
lspPath?: string
}
/**
* Plugin load result with all components
*/
export interface PluginLoadResult {
plugins: LoadedPlugin[]
errors: PluginLoadError[]
}
export interface PluginLoadError {
pluginKey: string
installPath: string
error: string
}
/**
* Claude settings from ~/.claude/settings.json
*/
export interface ClaudeSettings {
enabledPlugins?: Record<string, boolean>
// Other settings we don't use
[key: string]: unknown
}
/**
* Plugin loader options
*/
export interface PluginLoaderOptions {
/**
* Override enabled plugins from oh-my-opencode config.
* Key format: "pluginName@marketplace" (e.g., "shell-scripting@claude-code-workflows")
* Value: true = enabled, false = disabled
*
* This takes precedence over ~/.claude/settings.json enabledPlugins
*/
enabledPluginsOverride?: Record<string, boolean>
}

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlink } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
@@ -68,7 +68,7 @@ $ARGUMENTS
}
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
const userSkillsDir = join(homedir(), ".claude", "skills")
const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition

View File

@@ -27,6 +27,7 @@ hooks/
├── rules-injector/ # Conditional rules from .claude/rules/
├── session-recovery/ # Recover from session errors
├── think-mode/ # Auto-detect thinking triggers
├── thinking-block-validator/ # Validate thinking blocks in messages
├── context-window-monitor.ts # Monitor context usage (standalone)
├── empty-task-response-detector.ts
├── session-notification.ts # OS notify on idle (standalone)

View File

@@ -1,7 +1,7 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
import { getOpenCodeStorageDir } from "../../shared/data-path";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const AGENT_USAGE_REMINDER_STORAGE = join(
OPENCODE_STORAGE,
"agent-usage-reminder",

View File

@@ -17,6 +17,7 @@ describe("executeCompact lock management", () => {
retryStateBySession: new Map(),
fallbackStateBySession: new Map(),
truncateStateBySession: new Map(),
dcpStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}

View File

@@ -1,11 +1,13 @@
import type {
AutoCompactState,
DcpState,
FallbackState,
RetryState,
TruncateState,
} from "./types";
import type { ExperimentalConfig } from "../../config";
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
import { executeDynamicContextPruning } from "./pruning-executor";
import {
findLargestToolResult,
truncateToolResult,
@@ -19,6 +21,8 @@ import {
} from "../session-recovery/storage";
import { log } from "../../shared/logger";
const PLACEHOLDER_TEXT = "[user interrupted]";
type Client = {
session: {
messages: (opts: {
@@ -89,6 +93,48 @@ function getOrCreateTruncateState(
return state;
}
function getOrCreateDcpState(
autoCompactState: AutoCompactState,
sessionID: string,
): DcpState {
let state = autoCompactState.dcpStateBySession.get(sessionID);
if (!state) {
state = { attempted: false, itemsPruned: 0 };
autoCompactState.dcpStateBySession.set(sessionID, state);
}
return state;
}
function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
const emptyMessageIds = findEmptyMessages(sessionID);
if (emptyMessageIds.length === 0) {
return 0;
}
let fixedCount = 0;
for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT);
if (replaced) {
fixedCount++;
} else {
const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT);
if (injected) {
fixedCount++;
}
}
}
if (fixedCount > 0) {
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
sessionID,
fixedCount,
totalEmpty: emptyMessageIds.length,
});
}
return fixedCount;
}
async function getLastMessagePair(
sessionID: string,
client: Client,
@@ -184,6 +230,7 @@ function clearSessionState(
autoCompactState.retryStateBySession.delete(sessionID);
autoCompactState.fallbackStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID);
autoCompactState.dcpStateBySession.delete(sessionID);
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
autoCompactState.compactionInProgress.delete(sessionID);
}
@@ -311,6 +358,104 @@ export async function executeCompact(
const errorData = autoCompactState.errorDataBySession.get(sessionID);
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
// DCP FIRST - run before any other recovery attempts when token limit exceeded
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
if (
experimental?.dcp_for_compaction &&
!dcpState.attempted &&
errorData?.currentTokens &&
errorData?.maxTokens &&
errorData.currentTokens > errorData.maxTokens
) {
dcpState.attempted = true;
log("[auto-compact] DCP triggered FIRST on token limit error", {
sessionID,
currentTokens: errorData.currentTokens,
maxTokens: errorData.maxTokens,
});
const dcpConfig = experimental.dynamic_context_pruning ?? {
enabled: true,
notification: "detailed" as const,
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
};
try {
const pruningResult = await executeDynamicContextPruning(
sessionID,
dcpConfig,
client
);
if (pruningResult.itemsPruned > 0) {
dcpState.itemsPruned = pruningResult.itemsPruned;
log("[auto-compact] DCP successful, proceeding to compaction", {
itemsPruned: pruningResult.itemsPruned,
tokensSaved: pruningResult.totalTokensSaved,
});
await (client as Client).tui
.showToast({
body: {
title: "Dynamic Context Pruning",
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Running compaction...`,
variant: "success",
duration: 3000,
},
})
.catch(() => {});
// After DCP, immediately try summarize
const providerID = msg.providerID as string | undefined;
const modelID = msg.modelID as string | undefined;
if (providerID && modelID) {
try {
sanitizeEmptyMessagesBeforeSummarize(sessionID);
await (client as Client).tui
.showToast({
body: {
title: "Auto Compact",
message: "Summarizing session after DCP...",
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
await (client as Client).session.summarize({
path: { id: sessionID },
body: { providerID, modelID },
query: { directory },
});
clearSessionState(autoCompactState, sessionID);
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});
} catch {}
}, 500);
return;
} catch (summarizeError) {
log("[auto-compact] summarize after DCP failed, continuing recovery", {
error: String(summarizeError),
});
}
}
} else {
log("[auto-compact] DCP did not prune any items", { sessionID });
}
} catch (error) {
log("[auto-compact] DCP failed", { error: String(error) });
}
}
if (
experimental?.aggressive_truncation &&
errorData?.currentTokens &&
@@ -508,6 +653,8 @@ export async function executeCompact(
if (providerID && modelID) {
try {
sanitizeEmptyMessagesBeforeSummarize(sessionID);
await (client as Client).tui
.showToast({
body: {

View File

@@ -16,6 +16,7 @@ function createAutoCompactState(): AutoCompactState {
retryStateBySession: new Map(),
fallbackStateBySession: new Map(),
truncateStateBySession: new Map(),
dcpStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
compactionInProgress: new Set<string>(),
}
@@ -36,6 +37,7 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: Anthr
autoCompactState.retryStateBySession.delete(sessionInfo.id)
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
autoCompactState.dcpStateBySession.delete(sessionInfo.id)
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
autoCompactState.compactionInProgress.delete(sessionInfo.id)
}
@@ -148,6 +150,6 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: Anthr
}
}
export type { AutoCompactState, FallbackState, ParsedTokenLimitError, TruncateState } from "./types"
export type { AutoCompactState, DcpState, FallbackState, ParsedTokenLimitError, TruncateState } from "./types"
export { parseAnthropicTokenLimitError } from "./parser"
export { executeCompact, getLastAssistant } from "./executor"

View File

@@ -26,6 +26,7 @@ const TOKEN_LIMIT_KEYWORDS = [
"context length",
"too many tokens",
"non-empty content",
"invalid_request_error",
]
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
@@ -114,9 +115,10 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
if (typeof responseBody === "string") {
try {
const jsonPatterns = [
/data:\s*(\{[\s\S]*?\})\s*$/m,
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
/(\{[\s\S]*?"error"[\s\S]*?\})/,
// Greedy match to last } for nested JSON
/data:\s*(\{[\s\S]*\})\s*$/m,
/(\{"type"\s*:\s*"error"[\s\S]*\})/,
/(\{[\s\S]*"error"[\s\S]*\})/,
]
for (const pattern of jsonPatterns) {

View File

@@ -0,0 +1,33 @@
import { describe, test, expect } from "bun:test"
import { createToolSignature } from "./pruning-deduplication"
describe("createToolSignature", () => {
test("creates consistent signature for same input", () => {
const input1 = { filePath: "/foo/bar.ts", content: "hello" }
const input2 = { content: "hello", filePath: "/foo/bar.ts" }
const sig1 = createToolSignature("read", input1)
const sig2 = createToolSignature("read", input2)
expect(sig1).toBe(sig2)
})
test("creates different signature for different input", () => {
const input1 = { filePath: "/foo/bar.ts" }
const input2 = { filePath: "/foo/baz.ts" }
const sig1 = createToolSignature("read", input1)
const sig2 = createToolSignature("read", input2)
expect(sig1).not.toBe(sig2)
})
test("includes tool name in signature", () => {
const input = { filePath: "/foo/bar.ts" }
const sig1 = createToolSignature("read", input)
const sig2 = createToolSignature("write", input)
expect(sig1).not.toBe(sig2)
})
})

View File

@@ -0,0 +1,184 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { PruningState, ToolCallSignature } from "./pruning-types"
import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
export interface DeduplicationConfig {
enabled: boolean
protectedTools?: string[]
}
interface ToolPart {
type: string
callID?: string
tool?: string
state?: {
input?: unknown
output?: string
}
}
interface MessagePart {
type: string
parts?: ToolPart[]
}
export function createToolSignature(toolName: string, input: unknown): string {
const sortedInput = sortObject(input)
return `${toolName}::${JSON.stringify(sortedInput)}`
}
function sortObject(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj
if (typeof obj !== "object") return obj
if (Array.isArray(obj)) return obj.map(sortObject)
const sorted: Record<string, unknown> = {}
const keys = Object.keys(obj as Record<string, unknown>).sort()
for (const key of keys) {
sorted[key] = sortObject((obj as Record<string, unknown>)[key])
}
return sorted
}
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 readMessages(sessionID: string): MessagePart[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return []
const messages: MessagePart[] = []
try {
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
for (const file of files) {
const content = readFileSync(join(messageDir, file), "utf-8")
const data = JSON.parse(content)
if (data.parts) {
messages.push(data)
}
}
} catch {
return []
}
return messages
}
export function executeDeduplication(
sessionID: string,
state: PruningState,
config: DeduplicationConfig,
protectedTools: Set<string>
): number {
if (!config.enabled) return 0
const messages = readMessages(sessionID)
const signatures = new Map<string, ToolCallSignature[]>()
let currentTurn = 0
for (const msg of messages) {
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type === "step-start") {
currentTurn++
continue
}
if (part.type !== "tool" || !part.callID || !part.tool) continue
if (protectedTools.has(part.tool)) continue
if (config.protectedTools?.includes(part.tool)) continue
if (state.toolIdsToPrune.has(part.callID)) continue
const signature = createToolSignature(part.tool, part.state?.input)
if (!signatures.has(signature)) {
signatures.set(signature, [])
}
signatures.get(signature)!.push({
toolName: part.tool,
signature,
callID: part.callID,
turn: currentTurn,
})
if (!state.toolSignatures.has(signature)) {
state.toolSignatures.set(signature, [])
}
state.toolSignatures.get(signature)!.push({
toolName: part.tool,
signature,
callID: part.callID,
turn: currentTurn,
})
}
}
let prunedCount = 0
let tokensSaved = 0
for (const [signature, calls] of signatures) {
if (calls.length > 1) {
const toPrune = calls.slice(0, -1)
for (const call of toPrune) {
state.toolIdsToPrune.add(call.callID)
prunedCount++
const output = findToolOutput(messages, call.callID)
if (output) {
tokensSaved += estimateTokens(output)
}
log("[pruning-deduplication] pruned duplicate", {
tool: call.toolName,
callID: call.callID,
turn: call.turn,
signature: signature.substring(0, 100),
})
}
}
}
log("[pruning-deduplication] complete", {
prunedCount,
tokensSaved,
uniqueSignatures: signatures.size,
})
return prunedCount
}
function findToolOutput(messages: MessagePart[], callID: string): string | null {
for (const msg of messages) {
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type === "tool" && part.callID === callID && part.state?.output) {
return part.state.output
}
}
}
return null
}

View File

@@ -0,0 +1,126 @@
import type { DynamicContextPruningConfig } from "../../config"
import type { PruningState, PruningResult } from "./pruning-types"
import { executeDeduplication } from "./pruning-deduplication"
import { executeSupersedeWrites } from "./pruning-supersede"
import { executePurgeErrors } from "./pruning-purge-errors"
import { applyPruning } from "./pruning-storage"
import { log } from "../../shared/logger"
const DEFAULT_PROTECTED_TOOLS = new Set([
"task",
"todowrite",
"todoread",
"lsp_rename",
"lsp_code_action_resolve",
"session_read",
"session_write",
"session_search",
])
function createPruningState(): PruningState {
return {
toolIdsToPrune: new Set<string>(),
currentTurn: 0,
fileOperations: new Map(),
toolSignatures: new Map(),
erroredTools: new Map(),
}
}
export async function executeDynamicContextPruning(
sessionID: string,
config: DynamicContextPruningConfig,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any
): Promise<PruningResult> {
const state = createPruningState()
const protectedTools = new Set([
...DEFAULT_PROTECTED_TOOLS,
...(config.protected_tools || []),
])
log("[pruning-executor] starting DCP", {
sessionID,
notification: config.notification,
turnProtection: config.turn_protection,
})
let dedupCount = 0
let supersedeCount = 0
let purgeCount = 0
if (config.strategies?.deduplication?.enabled !== false) {
dedupCount = executeDeduplication(
sessionID,
state,
{ enabled: true },
protectedTools
)
}
if (config.strategies?.supersede_writes?.enabled !== false) {
supersedeCount = executeSupersedeWrites(
sessionID,
state,
{
enabled: true,
aggressive: config.strategies?.supersede_writes?.aggressive || false,
},
protectedTools
)
}
if (config.strategies?.purge_errors?.enabled !== false) {
purgeCount = executePurgeErrors(
sessionID,
state,
{
enabled: true,
turns: config.strategies?.purge_errors?.turns || 5,
},
protectedTools
)
}
const totalPruned = state.toolIdsToPrune.size
const tokensSaved = await applyPruning(sessionID, state)
log("[pruning-executor] DCP complete", {
totalPruned,
tokensSaved,
deduplication: dedupCount,
supersede: supersedeCount,
purge: purgeCount,
})
const result: PruningResult = {
itemsPruned: totalPruned,
totalTokensSaved: tokensSaved,
strategies: {
deduplication: dedupCount,
supersedeWrites: supersedeCount,
purgeErrors: purgeCount,
},
}
if (config.notification !== "off" && totalPruned > 0) {
const message =
config.notification === "detailed"
? `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens). Dedup: ${dedupCount}, Supersede: ${supersedeCount}, Purge: ${purgeCount}`
: `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens)`
await client.tui
.showToast({
body: {
title: "Dynamic Context Pruning",
message,
variant: "success",
duration: 3000,
},
})
.catch(() => {})
}
return result
}

View File

@@ -0,0 +1,152 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { PruningState, ErroredToolCall } from "./pruning-types"
import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
export interface PurgeErrorsConfig {
enabled: boolean
turns: number
protectedTools?: string[]
}
interface ToolPart {
type: string
callID?: string
tool?: string
state?: {
input?: unknown
output?: string
status?: string
}
}
interface MessagePart {
type: string
parts?: ToolPart[]
}
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 readMessages(sessionID: string): MessagePart[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return []
const messages: MessagePart[] = []
try {
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
for (const file of files) {
const content = readFileSync(join(messageDir, file), "utf-8")
const data = JSON.parse(content)
if (data.parts) {
messages.push(data)
}
}
} catch {
return []
}
return messages
}
export function executePurgeErrors(
sessionID: string,
state: PruningState,
config: PurgeErrorsConfig,
protectedTools: Set<string>
): number {
if (!config.enabled) return 0
const messages = readMessages(sessionID)
let currentTurn = 0
for (const msg of messages) {
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type === "step-start") {
currentTurn++
}
}
}
state.currentTurn = currentTurn
let turnCounter = 0
let prunedCount = 0
let tokensSaved = 0
for (const msg of messages) {
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type === "step-start") {
turnCounter++
continue
}
if (part.type !== "tool" || !part.callID || !part.tool) continue
if (protectedTools.has(part.tool)) continue
if (config.protectedTools?.includes(part.tool)) continue
if (state.toolIdsToPrune.has(part.callID)) continue
if (part.state?.status !== "error") continue
const turnAge = currentTurn - turnCounter
if (turnAge >= config.turns) {
state.toolIdsToPrune.add(part.callID)
prunedCount++
const input = part.state.input
if (input) {
tokensSaved += estimateTokens(JSON.stringify(input))
}
const errorInfo: ErroredToolCall = {
callID: part.callID,
toolName: part.tool,
turn: turnCounter,
errorAge: turnAge,
}
state.erroredTools.set(part.callID, errorInfo)
log("[pruning-purge-errors] pruned old error", {
tool: part.tool,
callID: part.callID,
turn: turnCounter,
errorAge: turnAge,
threshold: config.turns,
})
}
}
}
log("[pruning-purge-errors] complete", {
prunedCount,
tokensSaved,
currentTurn,
threshold: config.turns,
})
return prunedCount
}

View File

@@ -0,0 +1,101 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PruningState } from "./pruning-types"
import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
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
}
interface ToolPart {
type: string
callID?: string
tool?: string
state?: {
input?: unknown
output?: string
status?: string
}
}
interface MessageData {
parts?: ToolPart[]
[key: string]: unknown
}
export async function applyPruning(
sessionID: string,
state: PruningState
): Promise<number> {
const messageDir = getMessageDir(sessionID)
if (!messageDir) {
log("[pruning-storage] message dir not found", { sessionID })
return 0
}
let totalTokensSaved = 0
let filesModified = 0
try {
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
for (const file of files) {
const filePath = join(messageDir, file)
const content = readFileSync(filePath, "utf-8")
const data: MessageData = JSON.parse(content)
if (!data.parts) continue
let modified = false
for (const part of data.parts) {
if (part.type !== "tool" || !part.callID) continue
if (!state.toolIdsToPrune.has(part.callID)) continue
if (part.state?.input) {
const inputStr = JSON.stringify(part.state.input)
totalTokensSaved += estimateTokens(inputStr)
part.state.input = { __pruned: true, reason: "DCP" }
modified = true
}
if (part.state?.output) {
totalTokensSaved += estimateTokens(part.state.output)
part.state.output = "[Content pruned by Dynamic Context Pruning]"
modified = true
}
}
if (modified) {
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8")
filesModified++
}
}
} catch (error) {
log("[pruning-storage] error applying pruning", {
sessionID,
error: String(error),
})
}
log("[pruning-storage] applied pruning", {
sessionID,
filesModified,
totalTokensSaved,
})
return totalTokensSaved
}

View File

@@ -0,0 +1,212 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { PruningState, FileOperation } from "./pruning-types"
import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
export interface SupersedeWritesConfig {
enabled: boolean
aggressive: boolean
}
interface ToolPart {
type: string
callID?: string
tool?: string
state?: {
input?: unknown
output?: string
}
}
interface MessagePart {
type: string
parts?: ToolPart[]
}
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 readMessages(sessionID: string): MessagePart[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return []
const messages: MessagePart[] = []
try {
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
for (const file of files) {
const content = readFileSync(join(messageDir, file), "utf-8")
const data = JSON.parse(content)
if (data.parts) {
messages.push(data)
}
}
} catch {
return []
}
return messages
}
function extractFilePath(toolName: string, input: unknown): string | null {
if (!input || typeof input !== "object") return null
const inputObj = input as Record<string, unknown>
if (toolName === "write" || toolName === "edit" || toolName === "read") {
if (typeof inputObj.filePath === "string") {
return inputObj.filePath
}
}
return null
}
export function executeSupersedeWrites(
sessionID: string,
state: PruningState,
config: SupersedeWritesConfig,
protectedTools: Set<string>
): number {
if (!config.enabled) return 0
const messages = readMessages(sessionID)
const writesByFile = new Map<string, FileOperation[]>()
const readsByFile = new Map<string, number[]>()
let currentTurn = 0
for (const msg of messages) {
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type === "step-start") {
currentTurn++
continue
}
if (part.type !== "tool" || !part.callID || !part.tool) continue
if (protectedTools.has(part.tool)) continue
if (state.toolIdsToPrune.has(part.callID)) continue
const filePath = extractFilePath(part.tool, part.state?.input)
if (!filePath) continue
if (part.tool === "write" || part.tool === "edit") {
if (!writesByFile.has(filePath)) {
writesByFile.set(filePath, [])
}
writesByFile.get(filePath)!.push({
callID: part.callID,
tool: part.tool,
filePath,
turn: currentTurn,
})
if (!state.fileOperations.has(filePath)) {
state.fileOperations.set(filePath, [])
}
state.fileOperations.get(filePath)!.push({
callID: part.callID,
tool: part.tool,
filePath,
turn: currentTurn,
})
} else if (part.tool === "read") {
if (!readsByFile.has(filePath)) {
readsByFile.set(filePath, [])
}
readsByFile.get(filePath)!.push(currentTurn)
}
}
}
let prunedCount = 0
let tokensSaved = 0
for (const [filePath, writes] of writesByFile) {
const reads = readsByFile.get(filePath) || []
if (config.aggressive) {
for (const write of writes) {
const superseded = reads.some(readTurn => readTurn > write.turn)
if (superseded) {
state.toolIdsToPrune.add(write.callID)
prunedCount++
const input = findToolInput(messages, write.callID)
if (input) {
tokensSaved += estimateTokens(JSON.stringify(input))
}
log("[pruning-supersede] pruned superseded write", {
tool: write.tool,
callID: write.callID,
turn: write.turn,
filePath,
})
}
}
} else {
if (writes.length > 1) {
for (const write of writes.slice(0, -1)) {
const superseded = reads.some(readTurn => readTurn > write.turn)
if (superseded) {
state.toolIdsToPrune.add(write.callID)
prunedCount++
const input = findToolInput(messages, write.callID)
if (input) {
tokensSaved += estimateTokens(JSON.stringify(input))
}
log("[pruning-supersede] pruned superseded write (conservative)", {
tool: write.tool,
callID: write.callID,
turn: write.turn,
filePath,
})
}
}
}
}
}
log("[pruning-supersede] complete", {
prunedCount,
tokensSaved,
filesTracked: writesByFile.size,
mode: config.aggressive ? "aggressive" : "conservative",
})
return prunedCount
}
function findToolInput(messages: MessagePart[], callID: string): unknown | null {
for (const msg of messages) {
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type === "tool" && part.callID === callID && part.state?.input) {
return part.state.input
}
}
}
return null
}

View File

@@ -0,0 +1,44 @@
export interface ToolCallSignature {
toolName: string
signature: string
callID: string
turn: number
}
export interface FileOperation {
callID: string
tool: string
filePath: string
turn: number
}
export interface ErroredToolCall {
callID: string
toolName: string
turn: number
errorAge: number
}
export interface PruningResult {
itemsPruned: number
totalTokensSaved: number
strategies: {
deduplication: number
supersedeWrites: number
purgeErrors: number
}
}
export interface PruningState {
toolIdsToPrune: Set<string>
currentTurn: number
fileOperations: Map<string, FileOperation[]>
toolSignatures: Map<string, ToolCallSignature[]>
erroredTools: Map<string, ErroredToolCall>
}
export const CHARS_PER_TOKEN = 4
export function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN)
}

View File

@@ -1,19 +1,8 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { xdgData } from "xdg-basedir"
let OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
// Fix for macOS where xdg-basedir points to ~/Library/Application Support
// but OpenCode (cli) uses ~/.local/share
if (process.platform === "darwin" && !existsSync(OPENCODE_STORAGE)) {
const localShare = join(homedir(), ".local", "share", "opencode", "storage")
if (existsSync(localShare)) {
OPENCODE_STORAGE = localShare
}
}
import { getOpenCodeStorageDir } from "../../shared/data-path"
const OPENCODE_STORAGE = getOpenCodeStorageDir()
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
const PART_STORAGE = join(OPENCODE_STORAGE, "part")

View File

@@ -23,12 +23,18 @@ export interface TruncateState {
lastTruncatedPartId?: string
}
export interface DcpState {
attempted: boolean
itemsPruned: number
}
export interface AutoCompactState {
pendingCompact: Set<string>
errorDataBySession: Map<string, ParsedTokenLimitError>
retryStateBySession: Map<string, RetryState>
fallbackStateBySession: Map<string, FallbackState>
truncateStateBySession: Map<string, TruncateState>
dcpStateBySession: Map<string, DcpState>
emptyContentAttemptBySession: Map<string, number>
compactionInProgress: Set<string>
}

View File

@@ -34,12 +34,12 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
hasChecked = true
setTimeout(() => {
setTimeout(async () => {
const cachedVersion = getCachedVersion()
const localDevVersion = getLocalDevVersion(ctx.directory)
const displayVersion = localDevVersion ?? cachedVersion
showConfigErrorsIfAny(ctx).catch(() => {})
await showConfigErrorsIfAny(ctx)
if (localDevVersion) {
if (showStartupToast) {

View File

@@ -1,6 +1,6 @@
import { homedir } from "os"
import { join } from "path"
import { existsSync } from "fs"
import { getClaudeConfigDir } from "../../shared"
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
interface RawHookMatcher {
@@ -44,9 +44,9 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
}
export function getClaudeSettingsPaths(customPath?: string): string[] {
const home = homedir()
const claudeConfigDir = getClaudeConfigDir()
const paths = [
join(home, ".claude", "settings.json"),
join(claudeConfigDir, "settings.json"),
join(process.cwd(), ".claude", "settings.json"),
join(process.cwd(), ".claude", "settings.local.json"),
]

View File

@@ -1,9 +1,9 @@
import { join } from "path"
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
import { homedir } from "os"
import { getClaudeConfigDir } from "../../shared"
import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types"
const TODO_DIR = join(homedir(), ".claude", "todos")
const TODO_DIR = join(getClaudeConfigDir(), "todos")
export function getTodoPath(sessionId: string): string {
return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`)

View File

@@ -1,15 +1,12 @@
/**
* Transcript Manager
* Creates and manages Claude Code compatible transcript files
*/
import { join } from "path"
import { mkdirSync, appendFileSync, existsSync, writeFileSync, unlinkSync } from "fs"
import { homedir, tmpdir } from "os"
import { tmpdir } from "os"
import { randomUUID } from "crypto"
import type { TranscriptEntry } from "./types"
import { transformToolName } from "../../shared/tool-name"
import { getClaudeConfigDir } from "../../shared"
const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts")
export function getTranscriptPath(sessionId: string): string {
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)

View File

@@ -142,8 +142,9 @@ export interface CheckResult {
* Run comment-checker CLI with given input.
* @param input Hook input to check
* @param cliPath Optional explicit path to CLI binary
* @param customPrompt Optional custom prompt to replace default warning message
*/
export async function runCommentChecker(input: HookInput, cliPath?: string): Promise<CheckResult> {
export async function runCommentChecker(input: HookInput, cliPath?: string, customPrompt?: string): Promise<CheckResult> {
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
if (!binaryPath) {
@@ -160,7 +161,12 @@ export async function runCommentChecker(input: HookInput, cliPath?: string): Pro
debugLog("running comment-checker with input:", jsonInput.substring(0, 200))
try {
const proc = spawn([binaryPath], {
const args = [binaryPath]
if (customPrompt) {
args.push("--prompt", customPrompt)
}
const proc = spawn(args, {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",

View File

@@ -1,5 +1,6 @@
import type { PendingCall } from "./types"
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
import type { CommentCheckerConfig } from "../../config/schema"
import * as fs from "fs"
import { existsSync } from "fs"
@@ -20,6 +21,7 @@ const pendingCalls = new Map<string, PendingCall>()
const PENDING_CALL_TTL = 60_000
let cliPathPromise: Promise<string | null> | null = null
let cleanupIntervalStarted = false
function cleanupOldPendingCalls(): void {
const now = Date.now()
@@ -30,10 +32,13 @@ function cleanupOldPendingCalls(): void {
}
}
setInterval(cleanupOldPendingCalls, 10_000)
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
debugLog("createCommentCheckerHooks called", { config })
export function createCommentCheckerHooks() {
debugLog("createCommentCheckerHooks called")
if (!cleanupIntervalStarted) {
cleanupIntervalStarted = true
setInterval(cleanupOldPendingCalls, 10_000)
}
// Start background CLI initialization (may trigger lazy download)
startBackgroundInit()
@@ -123,7 +128,7 @@ export function createCommentCheckerHooks() {
// CLI mode only
debugLog("using CLI:", cliPath)
await processWithCli(input, pendingCall, output, cliPath)
await processWithCli(input, pendingCall, output, cliPath, config?.custom_prompt)
} catch (err) {
debugLog("tool.execute.after failed:", err)
}
@@ -135,7 +140,8 @@ async function processWithCli(
input: { tool: string; sessionID: string; callID: string },
pendingCall: PendingCall,
output: { output: string },
cliPath: string
cliPath: string,
customPrompt?: string
): Promise<void> {
debugLog("using CLI mode with path:", cliPath)
@@ -154,7 +160,7 @@ async function processWithCli(
},
}
const result = await runCommentChecker(hookInput, cliPath)
const result = await runCommentChecker(hookInput, cliPath, customPrompt)
if (result.hasComments && result.message) {
debugLog("CLI detected comments, appending message")

View File

@@ -1,7 +1,7 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
import { getOpenCodeStorageDir } from "../../shared/data-path";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const AGENTS_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-agents",

View File

@@ -1,7 +1,7 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
import { getOpenCodeStorageDir } from "../../shared/data-path";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const README_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-readme",

View File

@@ -21,3 +21,4 @@ export { createKeywordDetectorHook } from "./keyword-detector";
export { createNonInteractiveEnvHook } from "./non-interactive-env";
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";

View File

@@ -1,7 +1,7 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
import { getOpenCodeStorageDir } from "../../shared/data-path";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const INTERACTIVE_BASH_SESSION_STORAGE = join(
OPENCODE_STORAGE,
"interactive-bash-session",

View File

@@ -31,12 +31,23 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
3. Always Use Plan agent with gathered context to create detailed work breakdown
4. Execute with continuous verification against original requirements
## TDD (if test infrastructure exists)
1. Write spec (requirements)
2. Write tests (failing)
3. RED: tests fail
4. Implement minimal code
5. GREEN: tests pass
6. Refactor if needed (must stay green)
7. Next feature, repeat
## 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
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.

View File

@@ -1,7 +1,7 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
import { getOpenCodeStorageDir } from "../../shared/data-path";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
export const PROJECT_MARKERS = [

View File

@@ -0,0 +1,140 @@
import { spawn } from "bun"
type Platform = "darwin" | "linux" | "win32" | "unsupported"
let notifySendPath: string | null = null
let notifySendPromise: Promise<string | null> | null = null
let osascriptPath: string | null = null
let osascriptPromise: Promise<string | null> | null = null
let powershellPath: string | null = null
let powershellPromise: Promise<string | null> | null = null
let afplayPath: string | null = null
let afplayPromise: Promise<string | null> | null = null
let paplayPath: string | null = null
let paplayPromise: Promise<string | null> | null = null
let aplayPath: string | null = null
let aplayPromise: Promise<string | null> | null = null
async function findCommand(commandName: string): Promise<string | null> {
const isWindows = process.platform === "win32"
const cmd = isWindows ? "where" : "which"
try {
const proc = spawn([cmd, commandName], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
return null
}
const stdout = await new Response(proc.stdout).text()
const path = stdout.trim().split("\n")[0]
if (!path) {
return null
}
return path
} catch {
return null
}
}
export async function getNotifySendPath(): Promise<string | null> {
if (notifySendPath !== null) return notifySendPath
if (notifySendPromise) return notifySendPromise
notifySendPromise = (async () => {
const path = await findCommand("notify-send")
notifySendPath = path
return path
})()
return notifySendPromise
}
export async function getOsascriptPath(): Promise<string | null> {
if (osascriptPath !== null) return osascriptPath
if (osascriptPromise) return osascriptPromise
osascriptPromise = (async () => {
const path = await findCommand("osascript")
osascriptPath = path
return path
})()
return osascriptPromise
}
export async function getPowershellPath(): Promise<string | null> {
if (powershellPath !== null) return powershellPath
if (powershellPromise) return powershellPromise
powershellPromise = (async () => {
const path = await findCommand("powershell")
powershellPath = path
return path
})()
return powershellPromise
}
export async function getAfplayPath(): Promise<string | null> {
if (afplayPath !== null) return afplayPath
if (afplayPromise) return afplayPromise
afplayPromise = (async () => {
const path = await findCommand("afplay")
afplayPath = path
return path
})()
return afplayPromise
}
export async function getPaplayPath(): Promise<string | null> {
if (paplayPath !== null) return paplayPath
if (paplayPromise) return paplayPromise
paplayPromise = (async () => {
const path = await findCommand("paplay")
paplayPath = path
return path
})()
return paplayPromise
}
export async function getAplayPath(): Promise<string | null> {
if (aplayPath !== null) return aplayPath
if (aplayPromise) return aplayPromise
aplayPromise = (async () => {
const path = await findCommand("aplay")
aplayPath = path
return path
})()
return aplayPromise
}
export function startBackgroundCheck(platform: Platform): void {
if (platform === "darwin") {
getOsascriptPath().catch(() => {})
getAfplayPath().catch(() => {})
} else if (platform === "linux") {
getNotifySendPath().catch(() => {})
getPaplayPath().catch(() => {})
getAplayPath().catch(() => {})
} else if (platform === "win32") {
getPowershellPath().catch(() => {})
}
}

View File

@@ -1,16 +1,20 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
import * as utils from "./session-notification-utils"
describe("session-notification", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string) => {
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
// #given - track notification commands (osascript, notify-send, powershell)
const cmdStr = typeof cmd === "string" ? cmd : cmd.join("")
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
@@ -26,8 +30,15 @@ describe("session-notification", () => {
}
beforeEach(() => {
// #given - reset state before each test
notificationCalls = []
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay")
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
})
afterEach(() => {

View File

@@ -1,6 +1,15 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { platform } from "os"
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
import {
getOsascriptPath,
getNotifySendPath,
getPowershellPath,
getAfplayPath,
getPaplayPath,
getAplayPath,
startBackgroundCheck,
} from "./session-notification-utils"
interface Todo {
content: string
@@ -51,15 +60,25 @@ async function sendNotification(
): Promise<void> {
switch (p) {
case "darwin": {
const osascriptPath = await getOsascriptPath()
if (!osascriptPath) return
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await ctx.$`osascript -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
break
}
case "linux":
await ctx.$`notify-send ${title} ${message} 2>/dev/null`.catch(() => {})
case "linux": {
const notifySendPath = await getNotifySendPath()
if (!notifySendPath) return
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
break
}
case "win32": {
const powershellPath = await getPowershellPath()
if (!powershellPath) return
const psTitle = title.replace(/'/g, "''")
const psMessage = message.replace(/'/g, "''")
const toastScript = `
@@ -74,7 +93,7 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
$Notifier.Show($Toast)
`.trim().replace(/\n/g, "; ")
await ctx.$`powershell -Command ${toastScript}`.catch(() => {})
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
break
}
}
@@ -82,17 +101,30 @@ $Notifier.Show($Toast)
async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Promise<void> {
switch (p) {
case "darwin":
ctx.$`afplay ${soundPath}`.catch(() => {})
case "darwin": {
const afplayPath = await getAfplayPath()
if (!afplayPath) return
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
break
case "linux":
ctx.$`paplay ${soundPath} 2>/dev/null`.catch(() => {
ctx.$`aplay ${soundPath} 2>/dev/null`.catch(() => {})
})
}
case "linux": {
const paplayPath = await getPaplayPath()
if (paplayPath) {
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
} else {
const aplayPath = await getAplayPath()
if (aplayPath) {
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
}
}
break
case "win32":
ctx.$`powershell -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
}
case "win32": {
const powershellPath = await getPowershellPath()
if (!powershellPath) return
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
break
}
}
}
@@ -114,6 +146,8 @@ export function createSessionNotification(
const currentPlatform = detectPlatform()
const defaultSoundPath = getDefaultSoundPath(currentPlatform)
startBackgroundCheck(currentPlatform)
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",

View File

@@ -1,7 +1,7 @@
import { join } from "node:path"
import { xdgData } from "xdg-basedir"
import { getOpenCodeStorageDir } from "../../shared/data-path"
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")

View File

@@ -135,7 +135,16 @@ export function findEmptyMessageByIndex(sessionID: string, targetIndex: number):
const messages = readMessages(sessionID)
// API index may differ from storage index due to system messages
const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2]
const indicesToTry = [
targetIndex,
targetIndex - 1,
targetIndex + 1,
targetIndex - 2,
targetIndex + 2,
targetIndex - 3,
targetIndex - 4,
targetIndex - 5,
]
for (const idx of indicesToTry) {
if (idx < 0 || idx >= messages.length) continue
@@ -223,6 +232,41 @@ export function findMessagesWithOrphanThinking(sessionID: string): string[] {
return result
}
/**
* Find the most recent thinking content from previous assistant messages
* Following Anthropic's recommendation to include thinking blocks from previous turns
*/
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
const messages = readMessages(sessionID)
// Find the index of the current message
const currentIndex = messages.findIndex(m => m.id === beforeMessageID)
if (currentIndex === -1) return ""
// Search backwards through previous assistant messages
for (let i = currentIndex - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
// Look for thinking parts in this message
const parts = readParts(msg.id)
for (const part of parts) {
if (THINKING_TYPES.has(part.type)) {
// Found thinking content - return it
// Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property
const thinking = (part as { thinking?: string; text?: string }).thinking
const reasoning = (part as { thinking?: string; text?: string }).text
const content = thinking || reasoning
if (content && content.trim().length > 0) {
return content
}
}
}
}
return ""
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
@@ -230,13 +274,16 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
mkdirSync(partDir, { recursive: true })
}
// Try to get thinking content from previous turns (Anthropic's recommendation)
const previousThinking = findLastThinkingContent(sessionID, messageID)
const partId = `prt_0000000000_thinking`
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: "",
thinking: previousThinking || "[Continuing from previous reasoning]",
synthetic: true,
}

View File

@@ -0,0 +1,171 @@
/**
* Proactive Thinking Block Validator Hook
*
* Prevents "Expected thinking/redacted_thinking but found tool_use" errors
* by validating and fixing message structure BEFORE sending to Anthropic API.
*
* This hook runs on the "experimental.chat.messages.transform" hook point,
* which is called before messages are converted to ModelMessage format and
* sent to the API.
*
* Key differences from session-recovery hook:
* - PROACTIVE (prevents error) vs REACTIVE (fixes after error)
* - Runs BEFORE API call vs AFTER API error
* - User never sees the error vs User sees error then recovery
*/
import type { Message, Part } from "@opencode-ai/sdk"
interface MessageWithParts {
info: Message
parts: Part[]
}
type MessagesTransformHook = {
"experimental.chat.messages.transform"?: (
input: Record<string, never>,
output: { messages: MessageWithParts[] }
) => Promise<void>
}
/**
* Check if a model has extended thinking enabled
* Uses patterns from think-mode/switcher.ts for consistency
*/
function isExtendedThinkingModel(modelID: string): boolean {
if (!modelID) return false
const lower = modelID.toLowerCase()
// Check for explicit thinking/high variants (always enabled)
if (lower.includes("thinking") || lower.endsWith("-high")) {
return true
}
// Check for thinking-capable models (claude-4 family, claude-3)
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
return (
lower.includes("claude-sonnet-4") ||
lower.includes("claude-opus-4") ||
lower.includes("claude-3")
)
}
/**
* Check if a message has any content parts (tool_use, text, or other non-thinking content)
*/
function hasContentParts(parts: Part[]): boolean {
if (!parts || parts.length === 0) return false
return parts.some((part: Part) => {
const type = part.type as string
// Include tool parts and text parts (anything that's not thinking/reasoning)
return type === "tool" || type === "tool_use" || type === "text"
})
}
/**
* Check if a message starts with a thinking/reasoning block
*/
function startsWithThinkingBlock(parts: Part[]): boolean {
if (!parts || parts.length === 0) return false
const firstPart = parts[0]
const type = firstPart.type as string
return type === "thinking" || type === "reasoning"
}
/**
* Find the most recent thinking content from previous assistant messages
*/
function findPreviousThinkingContent(
messages: MessageWithParts[],
currentIndex: number
): string {
// Search backwards from current message
for (let i = currentIndex - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role !== "assistant") continue
// Look for thinking parts
if (!msg.parts) continue
for (const part of msg.parts) {
const type = part.type as string
if (type === "thinking" || type === "reasoning") {
const thinking = (part as any).thinking || (part as any).text
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
return thinking
}
}
}
}
return ""
}
/**
* Prepend a thinking block to a message's parts array
*/
function prependThinkingBlock(
message: MessageWithParts,
thinkingContent: string
): void {
if (!message.parts) {
message.parts = []
}
// Create synthetic thinking part
const thinkingPart = {
type: "thinking" as const,
id: `prt_0000000000_synthetic_thinking`,
sessionID: (message.info as any).sessionID || "",
messageID: message.info.id,
thinking: thinkingContent,
synthetic: true,
}
// Prepend to parts array
message.parts.unshift(thinkingPart as unknown as Part)
}
/**
* Validate and fix assistant messages that have tool_use but no thinking block
*/
export function createThinkingBlockValidatorHook(): MessagesTransformHook {
return {
"experimental.chat.messages.transform": async (_input, output) => {
const { messages } = output
if (!messages || messages.length === 0) {
return
}
// Get the model info from the last user message
const lastUserMessage = messages.findLast(m => m.info.role === "user")
const modelID = (lastUserMessage?.info as any)?.modelID || ""
// Only process if extended thinking might be enabled
if (!isExtendedThinkingModel(modelID)) {
return
}
// Process all assistant messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
// Only check assistant messages
if (msg.info.role !== "assistant") continue
// Check if message has content parts but doesn't start with thinking
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
// Find thinking content from previous turns
const previousThinking = findPreviousThinkingContent(messages, i)
// Prepend thinking block with content from previous turn or placeholder
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
prependThinkingBlock(msg, thinkingContent)
}
}
},
}
}

View File

@@ -8,7 +8,6 @@ import {
} 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"
@@ -37,6 +36,32 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
- Mark each task complete when finished
- Do not stop until all tasks are done`
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const MIN_INJECTION_INTERVAL_MS = 10_000
// ============================================================================
// STATE MACHINE TYPES
// ============================================================================
type SessionMode =
| "idle" // Observed idle, no countdown started yet
| "countingDown" // Waiting N seconds before injecting
| "injecting" // Currently calling session.prompt
| "recovering" // Session recovery in progress (external control)
| "errorBypass" // Bypass mode after session.error/interrupt
interface SessionState {
version: number // Monotonic generation token - increment to invalidate pending callbacks
mode: SessionMode
timer?: ReturnType<typeof setTimeout> // Pending countdown timer
lastAttemptedAt?: number // Timestamp of last injection attempt (throttle all attempts)
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
@@ -68,104 +93,354 @@ function detectInterrupt(error: unknown): boolean {
return false
}
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900 // Slightly less than 1s so toasts don't overlap
interface CountdownState {
secondsRemaining: number
intervalId: ReturnType<typeof setInterval>
function getIncompleteCount(todos: Todo[]): number {
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
}
// ============================================================================
// MAIN IMPLEMENTATION
// ============================================================================
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>()
// Single source of truth: per-session state machine
const sessions = new Map<string, SessionState>()
// ============================================================================
// STATE HELPERS
// ============================================================================
function getOrCreateState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = { version: 0, mode: "idle" }
sessions.set(sessionID, state)
}
return state
}
function clearTimer(state: SessionState): void {
if (state.timer) {
clearTimeout(state.timer)
state.timer = undefined
}
}
/**
* Invalidate any pending or in-flight operation by incrementing version.
* ALWAYS bumps version regardless of current mode to prevent last-mile races.
*/
function invalidate(sessionID: string, reason: string): void {
const state = sessions.get(sessionID)
if (!state) return
// Skip if in recovery mode (external control)
if (state.mode === "recovering") return
state.version++
clearTimer(state)
if (state.mode !== "idle" && state.mode !== "errorBypass") {
log(`[${HOOK_NAME}] Invalidated`, { sessionID, reason, prevMode: state.mode, newVersion: state.version })
state.mode = "idle"
}
}
/**
* Check if this is the main session (not a subagent session).
*/
function isMainSession(sessionID: string): boolean {
const mainSessionID = getMainSessionID()
// If no main session is set, allow all. If set, only allow main.
return !mainSessionID || sessionID === mainSessionID
}
// ============================================================================
// EXTERNAL API
// ============================================================================
const markRecovering = (sessionID: string): void => {
recoveringSessions.add(sessionID)
const state = getOrCreateState(sessionID)
invalidate(sessionID, "entering recovery mode")
state.mode = "recovering"
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
}
const markRecoveryComplete = (sessionID: string): void => {
recoveringSessions.delete(sessionID)
const state = sessions.get(sessionID)
if (state && state.mode === "recovering") {
state.mode = "idle"
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
}
}
// ============================================================================
// TOAST HELPER
// ============================================================================
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
await ctx.client.tui.showToast({
body: {
title: "Todo Continuation",
message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`,
variant: "warning" as const,
duration: TOAST_DURATION_MS,
},
}).catch(() => {})
}
// ============================================================================
// CORE INJECTION LOGIC
// ============================================================================
async function executeInjection(sessionID: string, capturedVersion: number): Promise<void> {
const state = sessions.get(sessionID)
if (!state) return
// Version check: if version changed since we started, abort
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version mismatch`, {
sessionID, capturedVersion, currentVersion: state.version
})
return
}
// Mode check: must still be in countingDown mode
if (state.mode !== "countingDown") {
log(`[${HOOK_NAME}] Injection aborted: mode changed`, {
sessionID, mode: state.mode
})
return
}
// Throttle check: minimum interval between injection attempts
if (state.lastAttemptedAt) {
const elapsed = Date.now() - state.lastAttemptedAt
if (elapsed < MIN_INJECTION_INTERVAL_MS) {
log(`[${HOOK_NAME}] Injection throttled: too soon since last injection`, {
sessionID, elapsedMs: elapsed, minIntervalMs: MIN_INJECTION_INTERVAL_MS
})
state.mode = "idle"
return
}
}
state.mode = "injecting"
// Re-verify todos (CRITICAL: always re-check before injecting)
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch todos for injection`, { sessionID, error: String(err) })
state.mode = "idle"
return
}
// Version check again after async operation
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted after todo fetch: version mismatch`, { sessionID })
state.mode = "idle"
return
}
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] No incomplete todos at injection time`, { sessionID, total: todos.length })
state.mode = "idle"
return
}
// Skip entirely if background tasks are running (no false positives)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
state.mode = "idle"
return
}
// Get previous message agent info
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
// Check write permission
const agentHasWritePermission = !prevMessage?.tools ||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
if (!agentHasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, {
sessionID, agent: prevMessage?.agent, tools: prevMessage?.tools
})
state.mode = "idle"
return
}
// Plan mode agents only analyze and plan, not implement - skip todo continuation
const agentName = prevMessage?.agent?.toLowerCase() ?? ""
const isPlanModeAgent = agentName === "plan" || agentName === "planner-sisyphus"
if (isPlanModeAgent) {
log(`[${HOOK_NAME}] Skipped: plan mode agent detected`, {
sessionID, agent: prevMessage?.agent
})
state.mode = "idle"
return
}
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incompleteCount}/${todos.length} completed, ${incompleteCount} remaining]`
// Final version check right before API call (last-mile race mitigation)
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version changed before API call`, { sessionID })
state.mode = "idle"
return
}
// Set lastAttemptedAt BEFORE calling API (throttle attempts, not just successes)
state.lastAttemptedAt = Date.now()
try {
log(`[${HOOK_NAME}] Injecting continuation prompt`, {
sessionID,
agent: prevMessage?.agent,
incompleteCount
})
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: prevMessage?.agent,
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) })
}
state.mode = "idle"
}
// ============================================================================
// COUNTDOWN STARTER
// ============================================================================
function startCountdown(sessionID: string, incompleteCount: number): void {
const state = getOrCreateState(sessionID)
// Cancel any existing countdown
invalidate(sessionID, "starting new countdown")
// Increment version for this new countdown
state.version++
state.mode = "countingDown"
const capturedVersion = state.version
log(`[${HOOK_NAME}] Starting countdown`, {
sessionID,
seconds: COUNTDOWN_SECONDS,
version: capturedVersion,
incompleteCount
})
// Show initial toast
showCountdownToast(COUNTDOWN_SECONDS, incompleteCount)
// Show countdown toasts
let secondsRemaining = COUNTDOWN_SECONDS
const toastInterval = setInterval(() => {
// Check if countdown was cancelled
if (state.version !== capturedVersion) {
clearInterval(toastInterval)
return
}
secondsRemaining--
if (secondsRemaining > 0) {
showCountdownToast(secondsRemaining, incompleteCount)
}
}, 1000)
// Schedule the injection
state.timer = setTimeout(() => {
clearInterval(toastInterval)
clearTimer(state)
executeInjection(sessionID, capturedVersion)
}, COUNTDOWN_SECONDS * 1000)
}
// ============================================================================
// EVENT HANDLER
// ============================================================================
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
// -------------------------------------------------------------------------
// SESSION.ERROR - Enter error bypass mode
// -------------------------------------------------------------------------
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
const isInterrupt = detectInterrupt(props?.error)
errorSessions.add(sessionID)
if (isInterrupt) {
interruptedSessions.add(sessionID)
}
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
const countdown = pendingCountdowns.get(sessionID)
if (countdown) {
clearInterval(countdown.intervalId)
pendingCountdowns.delete(sessionID)
}
}
if (!sessionID) return
const isInterrupt = detectInterrupt(props?.error)
const state = getOrCreateState(sessionID)
invalidate(sessionID, isInterrupt ? "user interrupt" : "session error")
state.mode = "errorBypass"
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
return
}
// -------------------------------------------------------------------------
// SESSION.IDLE - Main trigger for todo continuation
// -------------------------------------------------------------------------
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle received`, { sessionID })
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) {
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID, mainSessionID })
// Skip if not main session
if (!isMainSession(sessionID)) {
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
return
}
const existingCountdown = pendingCountdowns.get(sessionID)
if (existingCountdown) {
clearInterval(existingCountdown.intervalId)
pendingCountdowns.delete(sessionID)
log(`[${HOOK_NAME}] Cancelled existing countdown`, { sessionID })
}
const state = getOrCreateState(sessionID)
// Check if session is in recovery mode - if so, skip entirely without clearing state
if (recoveringSessions.has(sessionID)) {
// Skip if in recovery mode
if (state.mode === "recovering") {
log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID })
return
}
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
if (shouldBypass) {
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
log(`[${HOOK_NAME}] Skipped: error/interrupt bypass`, { sessionID })
// Skip if in error bypass mode (DO NOT clear - wait for user message)
if (state.mode === "errorBypass") {
log(`[${HOOK_NAME}] Skipped: error bypass (awaiting user message to resume)`, { sessionID })
return
}
if (remindedSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped: already reminded this session`, { sessionID })
// Skip if already counting down or injecting
if (state.mode === "countingDown" || state.mode === "injecting") {
log(`[${HOOK_NAME}] Skipped: already ${state.mode}`, { sessionID })
return
}
// Check for incomplete todos BEFORE starting countdown
// Fetch todos
let todos: Todo[] = []
try {
log(`[${HOOK_NAME}] Fetching todos for session`, { sessionID })
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
log(`[${HOOK_NAME}] Todo API response`, { sessionID, todosCount: todos?.length ?? 0 })
} catch (err) {
log(`[${HOOK_NAME}] Todo API error`, { sessionID, error: String(err) })
return
@@ -176,231 +451,107 @@ export function createTodoContinuationEnforcer(
return
}
const incomplete = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (incomplete.length === 0) {
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All todos completed`, { sessionID, total: todos.length })
return
}
log(`[${HOOK_NAME}] Found incomplete todos, starting countdown`, { sessionID, incomplete: incomplete.length, total: todos.length })
// Skip if background tasks are running (avoid toast spam with no injection)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
const showCountdownToast = async (seconds: number): Promise<void> => {
await ctx.client.tui.showToast({
body: {
title: "Todo Continuation",
message: `Resuming in ${seconds}s... (${incomplete.length} tasks remaining)`,
variant: "warning" as const,
duration: TOAST_DURATION_MS,
},
}).catch(() => {})
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
return
}
const executeAfterCountdown = async (): Promise<void> => {
pendingCountdowns.delete(sessionID)
log(`[${HOOK_NAME}] Countdown finished, executing continuation`, { sessionID })
log(`[${HOOK_NAME}] Found incomplete todos`, {
sessionID,
incomplete: incompleteCount,
total: todos.length
})
// Re-check conditions after countdown
if (recoveringSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Abort: session entered recovery mode during countdown`, { sessionID })
return
}
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Abort: error/interrupt occurred during countdown`, { sessionID })
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
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 {
// Get previous message's agent info to respect agent mode
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const agentHasWritePermission = !prevMessage?.tools || (prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
if (!agentHasWritePermission) {
log(`[${HOOK_NAME}] Skipped: previous agent lacks write permission`, { sessionID, agent: prevMessage?.agent, tools: prevMessage?.tools })
remindedSessions.delete(sessionID)
return
}
log(`[${HOOK_NAME}] Injecting continuation prompt`, { sessionID, agent: prevMessage?.agent })
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: prevMessage?.agent,
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${freshTodos.length - freshIncomplete.length}/${freshTodos.length} completed, ${freshIncomplete.length} remaining]`,
},
],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) })
remindedSessions.delete(sessionID)
}
}
let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(secondsRemaining).catch(() => {})
const intervalId = setInterval(() => {
secondsRemaining--
if (secondsRemaining <= 0) {
clearInterval(intervalId)
pendingCountdowns.delete(sessionID)
executeAfterCountdown()
return
}
const countdown = pendingCountdowns.get(sessionID)
if (!countdown) {
clearInterval(intervalId)
return
}
countdown.secondsRemaining = secondsRemaining
showCountdownToast(secondsRemaining).catch(() => {})
}, 1000)
pendingCountdowns.set(sessionID, { secondsRemaining, intervalId })
startCountdown(sessionID, incompleteCount)
return
}
// -------------------------------------------------------------------------
// MESSAGE.UPDATED - Cancel countdown on activity
// -------------------------------------------------------------------------
if (event.type === "message.updated") {
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 string | undefined
log(`[${HOOK_NAME}] message.updated received`, { sessionID, role, finish })
if (sessionID && role === "user") {
const countdown = pendingCountdowns.get(sessionID)
if (countdown) {
clearInterval(countdown.intervalId)
pendingCountdowns.delete(sessionID)
log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID })
if (!sessionID) return
// User message: Always cancel countdown and clear errorBypass
if (role === "user") {
const state = sessions.get(sessionID)
if (state?.mode === "errorBypass") {
state.mode = "idle"
log(`[${HOOK_NAME}] User message cleared errorBypass mode`, { sessionID })
}
remindedSessions.delete(sessionID)
preemptivelyInjectedSessions.delete(sessionID)
invalidate(sessionID, "user message received")
return
}
if (sessionID && role === "assistant" && finish) {
remindedSessions.delete(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)
}
}
}
// Assistant message WITHOUT finish: Agent is working, cancel countdown
if (role === "assistant" && !finish) {
invalidate(sessionID, "assistant is working (streaming)")
return
}
// Assistant message WITH finish: Agent finished a turn (let session.idle handle it)
if (role === "assistant" && finish) {
log(`[${HOOK_NAME}] Assistant turn finished`, { sessionID, finish })
return
}
return
}
// -------------------------------------------------------------------------
// MESSAGE.PART.UPDATED - Cancel countdown on streaming activity
// -------------------------------------------------------------------------
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
invalidate(sessionID, "assistant streaming")
}
return
}
// -------------------------------------------------------------------------
// TOOL EVENTS - Cancel countdown when tools are executing
// -------------------------------------------------------------------------
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
invalidate(sessionID, `tool execution (${event.type})`)
}
return
}
// -------------------------------------------------------------------------
// SESSION.DELETED - Cleanup
// -------------------------------------------------------------------------
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
remindedSessions.delete(sessionInfo.id)
interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id)
recoveringSessions.delete(sessionInfo.id)
preemptivelyInjectedSessions.delete(sessionInfo.id)
const countdown = pendingCountdowns.get(sessionInfo.id)
if (countdown) {
clearInterval(countdown.intervalId)
pendingCountdowns.delete(sessionInfo.id)
const state = sessions.get(sessionInfo.id)
if (state) {
clearTimer(state)
}
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted, state cleaned up`, { sessionID: sessionInfo.id })
}
return
}
}

View File

@@ -23,6 +23,7 @@ import {
createNonInteractiveEnvHook,
createInteractiveBashSessionHook,
createEmptyMessageSanitizerHook,
createThinkingBlockValidatorHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
@@ -31,15 +32,14 @@ import {
loadOpencodeGlobalCommands,
loadOpencodeProjectCommands,
} from "./features/claude-code-command-loader";
import {
loadUserSkillsAsCommands,
loadProjectSkillsAsCommands,
} from "./features/claude-code-skill-loader";
import { loadBuiltinCommands } from "./features/builtin-commands";
import {
loadUserAgents,
loadProjectAgents,
} from "./features/claude-code-agent-loader";
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
import { loadAllPluginComponents } from "./features/claude-code-plugin-loader";
import {
setMainSession,
getMainSessionID,
@@ -48,7 +48,7 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt,
import { BackgroundManager } from "./features/background-agent";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
import { log, deepMerge, getUserConfigDir, addConfigLoadError } from "./shared";
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile } from "./shared";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
import * as fs from "fs";
import * as path from "path";
@@ -120,7 +120,7 @@ function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig |
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = JSON.parse(content);
const rawConfig = parseJsonc<Record<string, unknown>>(content);
migrateConfigFile(configPath, rawConfig);
@@ -130,20 +130,6 @@ function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig |
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;
}
@@ -154,19 +140,6 @@ function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig |
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;
}
@@ -197,24 +170,26 @@ function mergeConfigs(
...(override.disabled_hooks ?? []),
]),
],
disabled_commands: [
...new Set([
...(base.disabled_commands ?? []),
...(override.disabled_commands ?? []),
]),
],
claude_code: deepMerge(base.claude_code, override.claude_code),
};
}
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
// User-level config path (OS-specific)
const userConfigPath = path.join(
getUserConfigDir(),
"opencode",
"oh-my-opencode.json"
);
// User-level config path (OS-specific) - prefer .jsonc over .json
const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode");
const userDetected = detectConfigFile(userBasePath);
const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json";
// Project-level config path
const projectConfigPath = path.join(
directory,
".opencode",
"oh-my-opencode.json"
);
// Project-level config path - prefer .jsonc over .json
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
const projectDetected = detectConfigFile(projectBasePath);
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json";
// Load user config first (base)
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
@@ -265,7 +240,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
: null;
const commentChecker = isHookEnabled("comment-checker")
? createCommentCheckerHooks()
? createCommentCheckerHooks(pluginConfig.comment_checker)
: null;
const toolOutputTruncator = isHookEnabled("tool-output-truncator")
? createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })
@@ -319,6 +294,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer")
? createEmptyMessageSanitizerHook()
: null;
const thinkingBlockValidator = isHookEnabled("thinking-block-validator")
? createThinkingBlockValidatorHook()
: null;
const backgroundManager = new BackgroundManager(ctx);
@@ -365,6 +343,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
input: Record<string, never>,
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await thinkingBlockValidator?.["experimental.chat.messages.transform"]?.(input, output as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
},
@@ -395,6 +375,22 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
}
}
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
? await loadAllPluginComponents({
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
})
: { commands: {}, skills: {}, agents: {}, mcpServers: {}, hooksConfigs: [], plugins: [], errors: [] };
if (pluginComponents.plugins.length > 0) {
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
plugins: pluginComponents.plugins.map(p => `${p.name}@${p.version}`),
});
}
if (pluginComponents.errors.length > 0) {
log(`Plugin load errors`, { errors: pluginComponents.errors });
}
const builtinAgents = createBuiltinAgents(
pluginConfig.disabled_agents,
pluginConfig.agents,
@@ -404,6 +400,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
const pluginAgents = pluginComponents.agents;
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
@@ -411,9 +408,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
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
// Set Sisyphus as default agent (feature added in OpenCode PR #5843)
(config as { default_agent?: string }).default_agent = "Sisyphus";
const agentConfig: Record<string, unknown> = {
Sisyphus: builtinAgents.Sisyphus,
@@ -463,6 +459,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
...userAgents,
...projectAgents,
...pluginAgents,
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
// Demote build/plan to subagent mode when replaced
build: { ...config.agent?.build, mode: "subagent" },
@@ -473,6 +470,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
...builtinAgents,
...userAgents,
...projectAgents,
...pluginAgents,
...config.agent,
};
}
@@ -511,28 +509,28 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
? await loadMcpConfigs()
: { servers: {} };
config.mcp = {
...config.mcp,
...createBuiltinMcps(pluginConfig.disabled_mcps),
...mcpResult.servers,
...pluginComponents.mcpServers,
};
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
const systemCommands = config.command ?? {};
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
const opencodeProjectCommands = loadOpencodeProjectCommands();
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkillsAsCommands() : {};
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkillsAsCommands() : {};
config.command = {
...builtinCommands,
...userCommands,
...userSkills,
...opencodeGlobalCommands,
...systemCommands,
...projectCommands,
...projectSkills,
...opencodeProjectCommands,
...pluginComponents.commands,
};
},
@@ -643,6 +641,7 @@ export type {
AgentOverrides,
McpName,
HookName,
BuiltinCommandName,
} from "./config";
// NOTE: Do NOT export functions from main index.ts!

View File

@@ -0,0 +1,60 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { homedir } from "node:os"
import { join } from "node:path"
import { getClaudeConfigDir } from "./claude-config-dir"
describe("getClaudeConfigDir", () => {
let originalEnv: string | undefined
beforeEach(() => {
originalEnv = process.env.CLAUDE_CONFIG_DIR
})
afterEach(() => {
if (originalEnv !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalEnv
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
})
test("returns CLAUDE_CONFIG_DIR when env var is set", () => {
process.env.CLAUDE_CONFIG_DIR = "/custom/claude/path"
const result = getClaudeConfigDir()
expect(result).toBe("/custom/claude/path")
})
test("returns ~/.claude when env var is not set", () => {
delete process.env.CLAUDE_CONFIG_DIR
const result = getClaudeConfigDir()
expect(result).toBe(join(homedir(), ".claude"))
})
test("returns ~/.claude when env var is empty string", () => {
process.env.CLAUDE_CONFIG_DIR = ""
const result = getClaudeConfigDir()
expect(result).toBe(join(homedir(), ".claude"))
})
test("handles absolute paths with trailing slash", () => {
process.env.CLAUDE_CONFIG_DIR = "/custom/path/"
const result = getClaudeConfigDir()
expect(result).toBe("/custom/path/")
})
test("handles relative paths", () => {
process.env.CLAUDE_CONFIG_DIR = "./my-claude-config"
const result = getClaudeConfigDir()
expect(result).toBe("./my-claude-config")
})
})

View File

@@ -0,0 +1,11 @@
import { homedir } from "node:os"
import { join } from "node:path"
export function getClaudeConfigDir(): string {
const envConfigDir = process.env.CLAUDE_CONFIG_DIR
if (envConfigDir) {
return envConfigDir
}
return join(homedir(), ".claude")
}

View File

@@ -2,27 +2,20 @@ 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%
* Returns the user-level data directory.
* Matches OpenCode's behavior via xdg-basedir:
* - All platforms: XDG_DATA_HOME or ~/.local/share
*
* This follows XDG Base Directory specification on Unix systems
* and Windows conventions on Windows.
* Note: OpenCode uses xdg-basedir which returns ~/.local/share on ALL platforms
* including Windows, so we match that behavior exactly.
*/
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
* All platforms: ~/.local/share/opencode/storage
*/
export function getOpenCodeStorageDir(): string {
return path.join(getDataDir(), "opencode", "storage")

View File

@@ -1,167 +1,189 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { PluginInput } from "@opencode-ai/plugin";
const ANTHROPIC_ACTUAL_LIMIT = 200_000
const CHARS_PER_TOKEN_ESTIMATE = 4
const DEFAULT_TARGET_MAX_TOKENS = 50_000
const ANTHROPIC_ACTUAL_LIMIT = 200_000;
const CHARS_PER_TOKEN_ESTIMATE = 4;
const DEFAULT_TARGET_MAX_TOKENS = 50_000;
interface AssistantMessageInfo {
role: "assistant"
tokens: {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
role: "assistant";
tokens: {
input: number;
output: number;
reasoning: number;
cache: { read: number; write: number };
};
}
interface MessageWrapper {
info: { role: string } & Partial<AssistantMessageInfo>
info: { role: string } & Partial<AssistantMessageInfo>;
}
export interface TruncationResult {
result: string
truncated: boolean
removedCount?: number
result: string;
truncated: boolean;
removedCount?: number;
}
export interface TruncationOptions {
targetMaxTokens?: number
preserveHeaderLines?: number
contextWindowLimit?: number
targetMaxTokens?: number;
preserveHeaderLines?: number;
contextWindowLimit?: number;
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
}
export function truncateToTokenLimit(
output: string,
maxTokens: number,
preserveHeaderLines = 3
output: string,
maxTokens: number,
preserveHeaderLines = 3,
): TruncationResult {
const currentTokens = estimateTokens(output)
const currentTokens = estimateTokens(output);
if (currentTokens <= maxTokens) {
return { result: output, truncated: false }
}
if (currentTokens <= maxTokens) {
return { result: output, truncated: false };
}
const lines = output.split("\n")
const lines = output.split("\n");
if (lines.length <= preserveHeaderLines) {
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE
return {
result: output.slice(0, maxChars) + "\n\n[Output truncated due to context window limit]",
truncated: true,
}
}
if (lines.length <= preserveHeaderLines) {
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE;
return {
result:
output.slice(0, maxChars) +
"\n\n[Output truncated due to context window limit]",
truncated: true,
};
}
const headerLines = lines.slice(0, preserveHeaderLines)
const contentLines = lines.slice(preserveHeaderLines)
const headerLines = lines.slice(0, preserveHeaderLines);
const contentLines = lines.slice(preserveHeaderLines);
const headerText = headerLines.join("\n")
const headerTokens = estimateTokens(headerText)
const truncationMessageTokens = 50
const availableTokens = maxTokens - headerTokens - truncationMessageTokens
const headerText = headerLines.join("\n");
const headerTokens = estimateTokens(headerText);
const truncationMessageTokens = 50;
const availableTokens = maxTokens - headerTokens - truncationMessageTokens;
if (availableTokens <= 0) {
return {
result: headerText + "\n\n[Content truncated due to context window limit]",
truncated: true,
removedCount: contentLines.length,
}
}
if (availableTokens <= 0) {
return {
result:
headerText + "\n\n[Content truncated due to context window limit]",
truncated: true,
removedCount: contentLines.length,
};
}
const resultLines: string[] = []
let currentTokenCount = 0
const resultLines: string[] = [];
let currentTokenCount = 0;
for (const line of contentLines) {
const lineTokens = estimateTokens(line + "\n")
if (currentTokenCount + lineTokens > availableTokens) {
break
}
resultLines.push(line)
currentTokenCount += lineTokens
}
for (const line of contentLines) {
const lineTokens = estimateTokens(line + "\n");
if (currentTokenCount + lineTokens > availableTokens) {
break;
}
resultLines.push(line);
currentTokenCount += lineTokens;
}
const truncatedContent = [...headerLines, ...resultLines].join("\n")
const removedCount = contentLines.length - resultLines.length
const truncatedContent = [...headerLines, ...resultLines].join("\n");
const removedCount = contentLines.length - resultLines.length;
return {
result: truncatedContent + `\n\n[${removedCount} more lines truncated due to context window limit]`,
truncated: true,
removedCount,
}
return {
result:
truncatedContent +
`\n\n[${removedCount} more lines truncated due to context window limit]`,
truncated: true,
removedCount,
};
}
export async function getContextWindowUsage(
ctx: PluginInput,
sessionID: string
): Promise<{ usedTokens: number; remainingTokens: number; usagePercentage: number } | null> {
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
})
ctx: PluginInput,
sessionID: string,
): Promise<{
usedTokens: number;
remainingTokens: number;
usagePercentage: number;
} | null> {
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
});
const messages = (response.data ?? response) as MessageWrapper[]
const messages = (response.data ?? response) as MessageWrapper[];
const assistantMessages = messages
.filter((m) => m.info.role === "assistant")
.map((m) => m.info as AssistantMessageInfo)
const assistantMessages = messages
.filter((m) => m.info.role === "assistant")
.map((m) => m.info as AssistantMessageInfo);
if (assistantMessages.length === 0) return null
if (assistantMessages.length === 0) return null;
const lastAssistant = assistantMessages[assistantMessages.length - 1]
const lastTokens = lastAssistant.tokens
const usedTokens =
(lastTokens?.input ?? 0) +
(lastTokens?.cache?.read ?? 0) +
(lastTokens?.output ?? 0)
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - usedTokens
const lastAssistant = assistantMessages[assistantMessages.length - 1];
const lastTokens = lastAssistant.tokens;
const usedTokens =
(lastTokens?.input ?? 0) +
(lastTokens?.cache?.read ?? 0) +
(lastTokens?.output ?? 0);
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - usedTokens;
return {
usedTokens,
remainingTokens,
usagePercentage: usedTokens / ANTHROPIC_ACTUAL_LIMIT,
}
} catch {
return null
}
return {
usedTokens,
remainingTokens,
usagePercentage: usedTokens / ANTHROPIC_ACTUAL_LIMIT,
};
} catch {
return null;
}
}
export async function dynamicTruncate(
ctx: PluginInput,
sessionID: string,
output: string,
options: TruncationOptions = {}
ctx: PluginInput,
sessionID: string,
output: string,
options: TruncationOptions = {},
): Promise<TruncationResult> {
const { targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS, preserveHeaderLines = 3 } = options
const {
targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS,
preserveHeaderLines = 3,
} = options;
const usage = await getContextWindowUsage(ctx, sessionID)
const usage = await getContextWindowUsage(ctx, sessionID);
if (!usage) {
return { result: output, truncated: false }
}
if (!usage) {
// Fallback: apply conservative truncation when context usage unavailable
return truncateToTokenLimit(output, targetMaxTokens, preserveHeaderLines);
}
const maxOutputTokens = Math.min(usage.remainingTokens * 0.5, targetMaxTokens)
const maxOutputTokens = Math.min(
usage.remainingTokens * 0.5,
targetMaxTokens,
);
if (maxOutputTokens <= 0) {
return {
result: "[Output suppressed - context window exhausted]",
truncated: true,
}
}
if (maxOutputTokens <= 0) {
return {
result: "[Output suppressed - context window exhausted]",
truncated: true,
};
}
return truncateToTokenLimit(output, maxOutputTokens, preserveHeaderLines)
return truncateToTokenLimit(output, maxOutputTokens, preserveHeaderLines);
}
export function createDynamicTruncator(ctx: PluginInput) {
return {
truncate: (sessionID: string, output: string, options?: TruncationOptions) =>
dynamicTruncate(ctx, sessionID, output, options),
return {
truncate: (
sessionID: string,
output: string,
options?: TruncationOptions,
) => dynamicTruncate(ctx, sessionID, output, options),
getUsage: (sessionID: string) => getContextWindowUsage(ctx, sessionID),
getUsage: (sessionID: string) => getContextWindowUsage(ctx, sessionID),
truncateSync: (output: string, maxTokens: number, preserveHeaderLines?: number) =>
truncateToTokenLimit(output, maxTokens, preserveHeaderLines),
}
truncateSync: (
output: string,
maxTokens: number,
preserveHeaderLines?: number,
) => truncateToTokenLimit(output, maxTokens, preserveHeaderLines),
};
}

View File

@@ -13,3 +13,5 @@ export * from "./dynamic-truncator"
export * from "./config-path"
export * from "./data-path"
export * from "./config-errors"
export * from "./claude-config-dir"
export * from "./jsonc-parser"

View File

@@ -0,0 +1,266 @@
import { describe, expect, test } from "bun:test"
import { detectConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
describe("parseJsonc", () => {
test("parses plain JSON", () => {
//#given
const json = `{"key": "value"}`
//#when
const result = parseJsonc<{ key: string }>(json)
//#then
expect(result.key).toBe("value")
})
test("parses JSONC with line comments", () => {
//#given
const jsonc = `{
// This is a comment
"key": "value"
}`
//#when
const result = parseJsonc<{ key: string }>(jsonc)
//#then
expect(result.key).toBe("value")
})
test("parses JSONC with block comments", () => {
//#given
const jsonc = `{
/* Block comment */
"key": "value"
}`
//#when
const result = parseJsonc<{ key: string }>(jsonc)
//#then
expect(result.key).toBe("value")
})
test("parses JSONC with multi-line block comments", () => {
//#given
const jsonc = `{
/* Multi-line
comment
here */
"key": "value"
}`
//#when
const result = parseJsonc<{ key: string }>(jsonc)
//#then
expect(result.key).toBe("value")
})
test("parses JSONC with trailing commas", () => {
//#given
const jsonc = `{
"key1": "value1",
"key2": "value2",
}`
//#when
const result = parseJsonc<{ key1: string; key2: string }>(jsonc)
//#then
expect(result.key1).toBe("value1")
expect(result.key2).toBe("value2")
})
test("parses JSONC with trailing comma in array", () => {
//#given
const jsonc = `{
"arr": [1, 2, 3,]
}`
//#when
const result = parseJsonc<{ arr: number[] }>(jsonc)
//#then
expect(result.arr).toEqual([1, 2, 3])
})
test("preserves URLs with // in strings", () => {
//#given
const jsonc = `{
"url": "https://example.com"
}`
//#when
const result = parseJsonc<{ url: string }>(jsonc)
//#then
expect(result.url).toBe("https://example.com")
})
test("parses complex JSONC config", () => {
//#given
const jsonc = `{
// This is an example config
"agents": {
"oracle": { "model": "openai/gpt-5.2" }, // GPT for strategic reasoning
},
/* Agent overrides */
"disabled_agents": [],
}`
//#when
const result = parseJsonc<{
agents: { oracle: { model: string } }
disabled_agents: string[]
}>(jsonc)
//#then
expect(result.agents.oracle.model).toBe("openai/gpt-5.2")
expect(result.disabled_agents).toEqual([])
})
test("throws on invalid JSON", () => {
//#given
const invalid = `{ "key": invalid }`
//#when
//#then
expect(() => parseJsonc(invalid)).toThrow()
})
test("throws on unclosed string", () => {
//#given
const invalid = `{ "key": "unclosed }`
//#when
//#then
expect(() => parseJsonc(invalid)).toThrow()
})
})
describe("parseJsoncSafe", () => {
test("returns data on valid JSONC", () => {
//#given
const jsonc = `{ "key": "value" }`
//#when
const result = parseJsoncSafe<{ key: string }>(jsonc)
//#then
expect(result.data).not.toBeNull()
expect(result.data?.key).toBe("value")
expect(result.errors).toHaveLength(0)
})
test("returns errors on invalid JSONC", () => {
//#given
const invalid = `{ "key": invalid }`
//#when
const result = parseJsoncSafe(invalid)
//#then
expect(result.data).toBeNull()
expect(result.errors.length).toBeGreaterThan(0)
})
})
describe("readJsoncFile", () => {
const testDir = join(__dirname, ".test-jsonc")
const testFile = join(testDir, "config.jsonc")
test("reads and parses valid JSONC file", () => {
//#given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
const content = `{
// Comment
"test": "value"
}`
writeFileSync(testFile, content)
//#when
const result = readJsoncFile<{ test: string }>(testFile)
//#then
expect(result).not.toBeNull()
expect(result?.test).toBe("value")
rmSync(testDir, { recursive: true, force: true })
})
test("returns null for non-existent file", () => {
//#given
const nonExistent = join(testDir, "does-not-exist.jsonc")
//#when
const result = readJsoncFile(nonExistent)
//#then
expect(result).toBeNull()
})
test("returns null for malformed JSON", () => {
//#given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
writeFileSync(testFile, "{ invalid }")
//#when
const result = readJsoncFile(testFile)
//#then
expect(result).toBeNull()
rmSync(testDir, { recursive: true, force: true })
})
})
describe("detectConfigFile", () => {
const testDir = join(__dirname, ".test-detect")
test("prefers .jsonc over .json", () => {
//#given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
const basePath = join(testDir, "config")
writeFileSync(`${basePath}.json`, "{}")
writeFileSync(`${basePath}.jsonc`, "{}")
//#when
const result = detectConfigFile(basePath)
//#then
expect(result.format).toBe("jsonc")
expect(result.path).toBe(`${basePath}.jsonc`)
rmSync(testDir, { recursive: true, force: true })
})
test("detects .json when .jsonc doesn't exist", () => {
//#given
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
const basePath = join(testDir, "config")
writeFileSync(`${basePath}.json`, "{}")
//#when
const result = detectConfigFile(basePath)
//#then
expect(result.format).toBe("json")
expect(result.path).toBe(`${basePath}.json`)
rmSync(testDir, { recursive: true, force: true })
})
test("returns none when neither exists", () => {
//#given
const basePath = join(testDir, "nonexistent")
//#when
const result = detectConfigFile(basePath)
//#then
expect(result.format).toBe("none")
})
})

View File

@@ -0,0 +1,66 @@
import { existsSync, readFileSync } from "node:fs"
import { parse, ParseError, printParseErrorCode } from "jsonc-parser"
export interface JsoncParseResult<T> {
data: T | null
errors: Array<{ message: string; offset: number; length: number }>
}
export function parseJsonc<T = unknown>(content: string): T {
const errors: ParseError[] = []
const result = parse(content, errors, {
allowTrailingComma: true,
disallowComments: false,
}) as T
if (errors.length > 0) {
const errorMessages = errors
.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
.join(", ")
throw new SyntaxError(`JSONC parse error: ${errorMessages}`)
}
return result
}
export function parseJsoncSafe<T = unknown>(content: string): JsoncParseResult<T> {
const errors: ParseError[] = []
const data = parse(content, errors, {
allowTrailingComma: true,
disallowComments: false,
}) as T | null
return {
data: errors.length > 0 ? null : data,
errors: errors.map((e) => ({
message: printParseErrorCode(e.error),
offset: e.offset,
length: e.length,
})),
}
}
export function readJsoncFile<T = unknown>(filePath: string): T | null {
try {
const content = readFileSync(filePath, "utf-8")
return parseJsonc<T>(content)
} catch {
return null
}
}
export function detectConfigFile(basePath: string): {
format: "json" | "jsonc" | "none"
path: string
} {
const jsoncPath = `${basePath}.jsonc`
const jsonPath = `${basePath}.json`
if (existsSync(jsoncPath)) {
return { format: "jsonc", path: jsoncPath }
}
if (existsSync(jsonPath)) {
return { format: "json", path: jsonPath }
}
return { format: "none", path: jsonPath }
}

View File

@@ -100,8 +100,6 @@ export function setSgCliPath(path: string): void {
resolvedCliPath = path
}
export const SG_CLI_PATH = getSgCliPath()
// CLI supported languages (25 total)
export const CLI_LANGUAGES = [
"bash",
@@ -184,21 +182,20 @@ export interface EnvironmentCheckResult {
* Call this at startup to provide early feedback about missing dependencies.
*/
export function checkEnvironment(): EnvironmentCheckResult {
const cliPath = getSgCliPath()
const result: EnvironmentCheckResult = {
cli: {
available: false,
path: SG_CLI_PATH,
path: cliPath,
},
napi: {
available: false,
},
}
// Check CLI availability
if (existsSync(SG_CLI_PATH)) {
if (existsSync(cliPath)) {
result.cli.available = true
} else if (SG_CLI_PATH === "sg") {
// Fallback path - try which/where to find in PATH
} else if (cliPath === "sg") {
try {
const { spawnSync } = require("child_process")
const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
@@ -213,7 +210,7 @@ export function checkEnvironment(): EnvironmentCheckResult {
result.cli.error = "Failed to check sg availability"
}
} else {
result.cli.error = `Binary not found: ${SG_CLI_PATH}`
result.cli.error = `Binary not found: ${cliPath}`
}
// Check NAPI availability

View File

@@ -1,6 +1,7 @@
import type { ToolDefinition } from "@opencode-ai/plugin"
import { ast_grep_search, ast_grep_replace } from "./tools"
export const builtinTools = {
export const builtinTools: Record<string, ToolDefinition> = {
ast_grep_search,
ast_grep_replace,
}

View File

@@ -1,4 +1,4 @@
import { tool } from "@opencode-ai/plugin/tool"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli"
import { formatSearchResult, formatReplaceResult } from "./utils"
@@ -32,7 +32,7 @@ function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
return null
}
export const ast_grep_search = tool({
export const ast_grep_search: ToolDefinition = tool({
description:
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
@@ -75,7 +75,7 @@ export const ast_grep_search = tool({
},
})
export const ast_grep_replace = tool({
export const ast_grep_replace: ToolDefinition = tool({
description:
"Replace code patterns across filesystem with AST-aware rewriting. " +
"Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +

View File

@@ -1,4 +1,4 @@
import { tool, type PluginInput } from "@opencode-ai/plugin"
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
@@ -37,7 +37,15 @@ function formatDuration(start: Date, end?: Date): string {
}
}
export function createBackgroundTask(manager: BackgroundManager) {
type ToolContextWithMetadata = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}
export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
return tool({
description: BACKGROUND_TASK_DESCRIPTION,
args: {
@@ -46,12 +54,14 @@ export function createBackgroundTask(manager: BackgroundManager) {
agent: tool.schema.string().describe("Agent type to use (any registered agent)"),
},
async execute(args: BackgroundTaskArgs, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
if (!args.agent || args.agent.trim() === "") {
return `❌ Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
}
try {
const messageDir = getMessageDir(toolContext.sessionID)
const messageDir = getMessageDir(ctx.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
@@ -61,11 +71,16 @@ export function createBackgroundTask(manager: BackgroundManager) {
description: args.description,
prompt: args.prompt,
agent: args.agent.trim(),
parentSessionID: toolContext.sessionID,
parentMessageID: toolContext.messageID,
parentSessionID: ctx.sessionID,
parentMessageID: ctx.messageID,
parentModel,
})
ctx.metadata?.({
title: args.description,
metadata: { sessionId: task.sessionID },
})
return `Background task launched successfully.
Task ID: ${task.id}
@@ -217,7 +232,7 @@ Session ID: ${task.sessionID}
${textContent || "(No text output)"}`
}
export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient) {
export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient): ToolDefinition {
return tool({
description: BACKGROUND_OUTPUT_DESCRIPTION,
args: {
@@ -283,7 +298,7 @@ export function createBackgroundOutput(manager: BackgroundManager, client: Openc
})
}
export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient) {
export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient): ToolDefinition {
return tool({
description: BACKGROUND_CANCEL_DESCRIPTION,
args: {

View File

@@ -1,13 +1,21 @@
import { tool, type PluginInput } from "@opencode-ai/plugin"
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import { log } from "../../shared/logger"
type ToolContextWithMetadata = {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}
export function createCallOmoAgent(
ctx: PluginInput,
backgroundManager: BackgroundManager
) {
): ToolDefinition {
const agentDescriptions = ALLOWED_AGENTS.map(
(name) => `- ${name}: Specialized agent for ${name} tasks`
).join("\n")
@@ -27,6 +35,7 @@ export function createCallOmoAgent(
session_id: tool.schema.string().describe("Existing Task session to continue").optional(),
},
async execute(args: CallOmoAgentArgs, toolContext) {
const toolCtx = toolContext as ToolContextWithMetadata
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
if (!ALLOWED_AGENTS.includes(args.subagent_type as typeof ALLOWED_AGENTS[number])) {
@@ -37,17 +46,17 @@ export function createCallOmoAgent(
if (args.session_id) {
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
}
return await executeBackground(args, toolContext, backgroundManager)
return await executeBackground(args, toolCtx, backgroundManager)
}
return await executeSync(args, toolContext, ctx)
return await executeSync(args, toolCtx, ctx)
},
})
}
async function executeBackground(
args: CallOmoAgentArgs,
toolContext: { sessionID: string; messageID: string },
toolContext: ToolContextWithMetadata,
manager: BackgroundManager
): Promise<string> {
try {
@@ -59,6 +68,11 @@ async function executeBackground(
parentMessageID: toolContext.messageID,
})
toolContext.metadata?.({
title: args.description,
metadata: { sessionId: task.sessionID },
})
return `Background agent task launched successfully.
Task ID: ${task.id}
@@ -79,7 +93,7 @@ Use \`background_output\` tool with task_id="${task.id}" to check progress:
async function executeSync(
args: CallOmoAgentArgs,
toolContext: { sessionID: string },
toolContext: ToolContextWithMetadata,
ctx: PluginInput
): Promise<string> {
let sessionID: string
@@ -112,6 +126,11 @@ async function executeSync(
log(`[call_omo_agent] Created session: ${sessionID}`)
}
toolContext.metadata?.({
title: args.description,
metadata: { sessionId: sessionID },
})
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))

View File

@@ -1,8 +1,8 @@
import { tool } from "@opencode-ai/plugin/tool"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRgFiles } from "./cli"
import { formatGlobResult } from "./utils"
export const glob = tool({
export const glob: ToolDefinition = tool({
description:
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +

View File

@@ -1,8 +1,8 @@
import { tool } from "@opencode-ai/plugin/tool"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRg } from "./cli"
import { formatGrepResult } from "./utils"
export const grep = tool({
export const grep: ToolDefinition = tool({
description:
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
"Searches file contents using regular expressions. " +

View File

@@ -37,7 +37,7 @@ import {
createBackgroundCancel,
} from "./background-task"
import type { PluginInput } from "@opencode-ai/plugin"
import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../features/background-agent"
type OpencodeClient = PluginInput["client"]
@@ -45,7 +45,7 @@ type OpencodeClient = PluginInput["client"]
export { createCallOmoAgent } from "./call-omo-agent"
export { createLookAt } from "./look-at"
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient) {
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
return {
background_task: createBackgroundTask(manager),
background_output: createBackgroundOutput(manager, client),
@@ -53,7 +53,7 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
}
}
export const builtinTools = {
export const builtinTools: Record<string, ToolDefinition> = {
lsp_hover,
lsp_goto_definition,
lsp_find_references,

View File

@@ -1,4 +1,4 @@
import { tool } from "@opencode-ai/plugin/tool"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
import { getCachedTmuxPath } from "./utils"
@@ -47,7 +47,7 @@ export function tokenizeCommand(cmd: string): string[] {
return tokens
}
export const interactive_bash = tool({
export const interactive_bash: ToolDefinition = tool({
description: INTERACTIVE_BASH_DESCRIPTION,
args: {
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),

View File

@@ -1,5 +1,6 @@
import { extname, basename } from "node:path"
import { tool, type PluginInput } from "@opencode-ai/plugin"
import { pathToFileURL } from "node:url"
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
import type { LookAtArgs } from "./types"
import { log } from "../../shared/logger"
@@ -28,7 +29,7 @@ function inferMimeType(filePath: string): string {
return mimeTypes[ext] || "application/octet-stream"
}
export function createLookAt(ctx: PluginInput) {
export function createLookAt(ctx: PluginInput): ToolDefinition {
return tool({
description: LOOK_AT_DESCRIPTION,
args: {
@@ -78,7 +79,7 @@ If the requested information is not found, clearly state what is missing.`
},
parts: [
{ type: "text", text: prompt },
{ type: "file", mime: mimeType, url: `file://${args.file_path}`, filename },
{ type: "file", mime: mimeType, url: pathToFileURL(args.file_path).href, filename },
],
},
})

View File

@@ -1,9 +1,8 @@
import { spawn, type Subprocess } from "bun"
import { readFileSync } from "fs"
import { extname, resolve } from "path"
import type { ResolvedServer } from "./config"
import { getLanguageId } from "./config"
import type { Diagnostic } from "./types"
import type { Diagnostic, ResolvedServer } from "./types"
interface ManagedClient {
client: LSPClient

View File

@@ -1,16 +1,8 @@
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { BUILTIN_SERVERS, EXT_TO_LANG } from "./constants"
export interface ResolvedServer {
id: string
command: string[]
extensions: string[]
priority: number
env?: Record<string, string>
initialization?: Record<string, unknown>
}
import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants"
import type { ResolvedServer, ServerLookupResult } from "./types"
interface LspEntry {
disabled?: boolean
@@ -120,23 +112,47 @@ function getMergedServers(): ServerWithSource[] {
})
}
export function findServerForExtension(ext: string): ResolvedServer | null {
export function findServerForExtension(ext: string): ServerLookupResult {
const servers = getMergedServers()
for (const server of servers) {
if (server.extensions.includes(ext) && isServerInstalled(server.command)) {
return {
id: server.id,
command: server.command,
extensions: server.extensions,
priority: server.priority,
env: server.env,
initialization: server.initialization,
status: "found",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
priority: server.priority,
env: server.env,
initialization: server.initialization,
},
}
}
}
return null
for (const server of servers) {
if (server.extensions.includes(ext)) {
const installHint =
LSP_INSTALL_HINTS[server.id] || `Install '${server.command[0]}' and ensure it's in your PATH`
return {
status: "not_installed",
server: {
id: server.id,
command: server.command,
extensions: server.extensions,
},
installHint,
}
}
}
const availableServers = [...new Set(servers.map((s) => s.id))]
return {
status: "not_configured",
extension: ext,
availableServers,
}
}
export function getLanguageId(ext: string): string {

View File

@@ -40,6 +40,37 @@ export const DEFAULT_MAX_REFERENCES = 200
export const DEFAULT_MAX_SYMBOLS = 200
export const DEFAULT_MAX_DIAGNOSTICS = 200
export const LSP_INSTALL_HINTS: Record<string, string> = {
typescript: "npm install -g typescript-language-server typescript",
deno: "Install Deno from https://deno.land",
vue: "npm install -g @vue/language-server",
eslint: "npm install -g vscode-langservers-extracted",
oxlint: "npm install -g oxlint",
biome: "npm install -g @biomejs/biome",
gopls: "go install golang.org/x/tools/gopls@latest",
"ruby-lsp": "gem install ruby-lsp",
basedpyright: "pip install basedpyright",
pyright: "pip install pyright",
ty: "pip install ty",
ruff: "pip install ruff",
"elixir-ls": "See https://github.com/elixir-lsp/elixir-ls",
zls: "See https://github.com/zigtools/zls",
csharp: "dotnet tool install -g csharp-ls",
fsharp: "dotnet tool install -g fsautocomplete",
"sourcekit-lsp": "Included with Xcode or Swift toolchain",
rust: "rustup component add rust-analyzer",
clangd: "See https://clangd.llvm.org/installation",
svelte: "npm install -g svelte-language-server",
astro: "npm install -g @astrojs/language-server",
"bash-ls": "npm install -g bash-language-server",
jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls",
"yaml-ls": "npm install -g yaml-language-server",
"lua-ls": "See https://github.com/LuaLS/lua-language-server",
php: "npm install -g intelephense",
dart: "Included with Dart SDK",
"terraform-ls": "See https://github.com/hashicorp/terraform-ls",
}
// Synced with OpenCode's server.ts
// https://github.com/sst/opencode/blob/main/packages/opencode/src/lsp/server.ts
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {

View File

@@ -1,4 +1,4 @@
import { tool } from "@opencode-ai/plugin/tool"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { getAllServers } from "./config"
import {
DEFAULT_MAX_REFERENCES,
@@ -34,7 +34,7 @@ import type {
export const lsp_hover = tool({
export const lsp_hover: ToolDefinition = tool({
description: "Get type info, docs, and signature for a symbol at position.",
args: {
filePath: tool.schema.string(),
@@ -55,7 +55,7 @@ export const lsp_hover = tool({
},
})
export const lsp_goto_definition = tool({
export const lsp_goto_definition: ToolDefinition = tool({
description: "Jump to symbol definition. Find WHERE something is defined.",
args: {
filePath: tool.schema.string(),
@@ -92,7 +92,7 @@ export const lsp_goto_definition = tool({
},
})
export const lsp_find_references = tool({
export const lsp_find_references: ToolDefinition = tool({
description: "Find ALL usages/references of a symbol across the entire workspace.",
args: {
filePath: tool.schema.string(),
@@ -129,7 +129,7 @@ export const lsp_find_references = tool({
},
})
export const lsp_document_symbols = tool({
export const lsp_document_symbols: ToolDefinition = tool({
description: "Get hierarchical outline of all symbols in a file.",
args: {
filePath: tool.schema.string(),
@@ -167,7 +167,7 @@ export const lsp_document_symbols = tool({
},
})
export const lsp_workspace_symbols = tool({
export const lsp_workspace_symbols: ToolDefinition = tool({
description: "Search symbols by name across ENTIRE workspace.",
args: {
filePath: tool.schema.string(),
@@ -202,7 +202,7 @@ export const lsp_workspace_symbols = tool({
},
})
export const lsp_diagnostics = tool({
export const lsp_diagnostics: ToolDefinition = tool({
description: "Get errors, warnings, hints from language server BEFORE running build.",
args: {
filePath: tool.schema.string(),
@@ -249,7 +249,7 @@ export const lsp_diagnostics = tool({
},
})
export const lsp_servers = tool({
export const lsp_servers: ToolDefinition = tool({
description: "List available LSP servers and installation status.",
args: {},
execute: async (_args, context) => {
@@ -271,7 +271,7 @@ export const lsp_servers = tool({
},
})
export const lsp_prepare_rename = tool({
export const lsp_prepare_rename: ToolDefinition = tool({
description: "Check if rename is valid. Use BEFORE lsp_rename.",
args: {
filePath: tool.schema.string(),
@@ -295,7 +295,7 @@ export const lsp_prepare_rename = tool({
},
})
export const lsp_rename = tool({
export const lsp_rename: ToolDefinition = tool({
description: "Rename symbol across entire workspace. APPLIES changes to all files.",
args: {
filePath: tool.schema.string(),
@@ -318,7 +318,7 @@ export const lsp_rename = tool({
},
})
export const lsp_code_actions = tool({
export const lsp_code_actions: ToolDefinition = tool({
description: "Get available quick fixes, refactorings, and source actions (organize imports, fix all).",
args: {
filePath: tool.schema.string(),
@@ -362,7 +362,7 @@ export const lsp_code_actions = tool({
},
})
export const lsp_code_action_resolve = tool({
export const lsp_code_action_resolve: ToolDefinition = tool({
description: "Resolve and APPLY a code action from lsp_code_actions.",
args: {
filePath: tool.schema.string(),

View File

@@ -135,3 +135,23 @@ export interface CodeAction {
command?: Command
data?: unknown
}
export interface ServerLookupInfo {
id: string
command: string[]
extensions: string[]
}
export type ServerLookupResult =
| { status: "found"; server: ResolvedServer }
| { status: "not_configured"; extension: string; availableServers: string[] }
| { status: "not_installed"; server: ServerLookupInfo; installHint: string }
export interface ResolvedServer {
id: string
command: string[]
extensions: string[]
priority: number
env?: Record<string, string>
initialization?: Record<string, unknown>
}

View File

@@ -17,6 +17,7 @@ import type {
TextEdit,
CodeAction,
Command,
ServerLookupResult,
} from "./types"
export function findWorkspaceRoot(filePath: string): string {
@@ -40,15 +41,51 @@ export function findWorkspaceRoot(filePath: string): string {
return require("path").dirname(resolve(filePath))
}
export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
if (result.status === "not_installed") {
const { server, installHint } = result
return [
`LSP server '${server.id}' is configured but NOT INSTALLED.`,
``,
`Command not found: ${server.command[0]}`,
``,
`To install:`,
` ${installHint}`,
``,
`Supported extensions: ${server.extensions.join(", ")}`,
``,
`After installation, the server will be available automatically.`,
`Run 'lsp_servers' tool to verify installation status.`,
].join("\n")
}
return [
`No LSP server configured for extension: ${result.extension}`,
``,
`Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`,
``,
`To add a custom server, configure 'lsp' in oh-my-opencode.json:`,
` {`,
` "lsp": {`,
` "my-server": {`,
` "command": ["my-lsp", "--stdio"],`,
` "extensions": ["${result.extension}"]`,
` }`,
` }`,
` }`,
].join("\n")
}
export async function withLspClient<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
const absPath = resolve(filePath)
const ext = extname(absPath)
const server = findServerForExtension(ext)
const result = findServerForExtension(ext)
if (!server) {
throw new Error(`No LSP server configured for extension: ${ext}`)
if (result.status !== "found") {
throw new Error(formatServerLookupError(result))
}
const server = result.server
const root = findWorkspaceRoot(absPath)
const client = await lspManager.getClient(root, server)

View File

@@ -1,12 +1,12 @@
import { join } from "node:path"
import { homedir } from "node:os"
import { getOpenCodeStorageDir } from "../../shared/data-path"
import { getClaudeConfigDir } from "../../shared"
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 TODO_DIR = join(getClaudeConfigDir(), "todos")
export const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "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.

View File

@@ -23,7 +23,8 @@ mock.module("./constants", () => ({
TOOL_NAME_PREFIX: "session_",
}))
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("./storage")
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
await import("./storage")
describe("session-manager storage", () => {
beforeEach(() => {
@@ -43,48 +44,61 @@ describe("session-manager storage", () => {
}
})
test("getAllSessions returns empty array when no sessions exist", () => {
const sessions = getAllSessions()
test("getAllSessions returns empty array when no sessions exist", async () => {
// #when
const sessions = await getAllSessions()
// #then
expect(Array.isArray(sessions)).toBe(true)
expect(sessions).toEqual([])
})
test("getMessageDir finds session in direct path", () => {
// #given
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" }))
// #when
const result = getMessageDir(sessionID)
// #then
expect(result).toBe(sessionPath)
})
test("sessionExists returns false for non-existent session", () => {
// #when
const exists = sessionExists("ses_nonexistent")
// #then
expect(exists).toBe(false)
})
test("sessionExists returns true for existing session", () => {
// #given
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" }))
// #when
const exists = sessionExists(sessionID)
// #then
expect(exists).toBe(true)
})
test("readSessionMessages returns empty array for non-existent session", () => {
const messages = readSessionMessages("ses_nonexistent")
test("readSessionMessages returns empty array for non-existent session", async () => {
// #when
const messages = await readSessionMessages("ses_nonexistent")
// #then
expect(messages).toEqual([])
})
test("readSessionMessages sorts messages by timestamp", () => {
test("readSessionMessages sorts messages by timestamp", async () => {
// #given
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
@@ -98,26 +112,33 @@ describe("session-manager storage", () => {
JSON.stringify({ id: "msg_001", role: "user", time: { created: 1000 } })
)
const messages = readSessionMessages(sessionID)
// #when
const messages = await readSessionMessages(sessionID)
// #then
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")
test("readSessionTodos returns empty array when no todos exist", async () => {
// #when
const todos = await readSessionTodos("ses_nonexistent")
// #then
expect(todos).toEqual([])
})
test("getSessionInfo returns null for non-existent session", () => {
const info = getSessionInfo("ses_nonexistent")
test("getSessionInfo returns null for non-existent session", async () => {
// #when
const info = await getSessionInfo("ses_nonexistent")
// #then
expect(info).toBeNull()
})
test("getSessionInfo aggregates session metadata correctly", () => {
test("getSessionInfo aggregates session metadata correctly", async () => {
// #given
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
@@ -142,8 +163,10 @@ describe("session-manager storage", () => {
})
)
const info = getSessionInfo(sessionID)
// #when
const info = await getSessionInfo(sessionID)
// #then
expect(info).not.toBeNull()
expect(info?.id).toBe(sessionID)
expect(info?.message_count).toBe(2)

View File

@@ -1,23 +1,25 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { existsSync, readdirSync } from "node:fs"
import { readdir, readFile } from "node:fs/promises"
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[] {
export async function getAllSessions(): Promise<string[]> {
if (!existsSync(MESSAGE_STORAGE)) return []
const sessions: string[] = []
function scanDirectory(dir: string): void {
async function scanDirectory(dir: string): Promise<void> {
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const sessionPath = join(dir, entry.name)
const files = readdirSync(sessionPath)
const files = await readdir(sessionPath)
if (files.some((f) => f.endsWith(".json"))) {
sessions.push(entry.name)
} else {
scanDirectory(sessionPath)
await scanDirectory(sessionPath)
}
}
}
@@ -26,7 +28,7 @@ export function getAllSessions(): string[] {
}
}
scanDirectory(MESSAGE_STORAGE)
await scanDirectory(MESSAGE_STORAGE)
return [...new Set(sessions)]
}
@@ -38,11 +40,15 @@ export function getMessageDir(sessionID: string): string {
return directPath
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
try {
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
} catch {
return ""
}
return ""
@@ -52,29 +58,34 @@ export function sessionExists(sessionID: string): boolean {
return getMessageDir(sessionID) !== ""
}
export function readSessionMessages(sessionID: string): SessionMessage[] {
export async function readSessionMessages(sessionID: string): Promise<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)
try {
const files = await readdir(messageDir)
for (const file of files) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(messageDir, file), "utf-8")
const meta = JSON.parse(content)
const parts = readParts(meta.id)
const parts = await readParts(meta.id)
messages.push({
id: meta.id,
role: meta.role,
agent: meta.agent,
time: meta.time,
parts,
})
} catch {
continue
messages.push({
id: meta.id,
role: meta.role,
agent: meta.agent,
time: meta.time,
parts,
})
} catch {
continue
}
}
} catch {
return []
}
return messages.sort((a, b) => {
@@ -85,65 +96,75 @@ export function readSessionMessages(sessionID: string): SessionMessage[] {
})
}
function readParts(messageID: string): Array<{ id: string; type: string; [key: string]: unknown }> {
async function readParts(messageID: string): Promise<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
try {
const files = await readdir(partDir)
for (const file of files) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
} catch {
return []
}
return parts.sort((a, b) => a.id.localeCompare(b.id))
}
export function readSessionTodos(sessionID: string): TodoItem[] {
export async function readSessionTodos(sessionID: string): Promise<TodoItem[]> {
if (!existsSync(TODO_DIR)) return []
const todoFiles = readdirSync(TODO_DIR).filter((f) => f.includes(sessionID) && f.endsWith(".json"))
try {
const allFiles = await readdir(TODO_DIR)
const todoFiles = allFiles.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,
}))
for (const file of todoFiles) {
try {
const content = await readFile(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
}
} catch {
continue
}
} catch {
return []
}
return []
}
export function readSessionTranscript(sessionID: string): number {
export async function readSessionTranscript(sessionID: string): Promise<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")
const content = await readFile(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)
export async function getSessionInfo(sessionID: string): Promise<SessionInfo | null> {
const messages = await readSessionMessages(sessionID)
if (messages.length === 0) return null
const agentsUsed = new Set<string>()
@@ -159,8 +180,8 @@ export function getSessionInfo(sessionID: string): SessionInfo | null {
}
}
const todos = readSessionTodos(sessionID)
const transcriptEntries = readSessionTranscript(sessionID)
const todos = await readSessionTodos(sessionID)
const transcriptEntries = await readSessionTranscript(sessionID)
return {
id: sessionID,

View File

@@ -1,4 +1,4 @@
import { tool } from "@opencode-ai/plugin/tool"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import {
SESSION_LIST_DESCRIPTION,
SESSION_READ_DESCRIPTION,
@@ -6,10 +6,27 @@ import {
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"
import {
filterSessionsByDate,
formatSessionInfo,
formatSessionList,
formatSessionMessages,
formatSearchResults,
searchInSession,
} from "./utils"
import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs, SearchResult } from "./types"
export const session_list = tool({
const SEARCH_TIMEOUT_MS = 60_000
const MAX_SESSIONS_TO_SCAN = 50
function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${operation} timed out after ${ms}ms`)), ms)),
])
}
export const session_list: ToolDefinition = tool({
description: SESSION_LIST_DESCRIPTION,
args: {
limit: tool.schema.number().optional().describe("Maximum number of sessions to return"),
@@ -18,24 +35,24 @@ export const session_list = tool({
},
execute: async (args: SessionListArgs, _context) => {
try {
let sessions = getAllSessions()
let sessions = await getAllSessions()
if (args.from_date || args.to_date) {
sessions = filterSessionsByDate(sessions, args.from_date, args.to_date)
sessions = await filterSessionsByDate(sessions, args.from_date, args.to_date)
}
if (args.limit && args.limit > 0) {
sessions = sessions.slice(0, args.limit)
}
return formatSessionList(sessions)
return await formatSessionList(sessions)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_read = tool({
export const session_read: ToolDefinition = tool({
description: SESSION_READ_DESCRIPTION,
args: {
session_id: tool.schema.string().describe("Session ID to read"),
@@ -49,13 +66,13 @@ export const session_read = tool({
return `Session not found: ${args.session_id}`
}
let messages = readSessionMessages(args.session_id)
let messages = await 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
const todos = args.include_todos ? await readSessionTodos(args.session_id) : undefined
return formatSessionMessages(messages, args.include_todos, todos)
} catch (e) {
@@ -64,7 +81,7 @@ export const session_read = tool({
},
})
export const session_search = tool({
export const session_search: ToolDefinition = tool({
description: SESSION_SEARCH_DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query string"),
@@ -74,27 +91,45 @@ export const session_search = tool({
},
execute: async (args: SessionSearchArgs, _context) => {
try {
const sessions = args.session_id ? [args.session_id] : getAllSessions()
const resultLimit = args.limit && args.limit > 0 ? args.limit : 20
const allResults = sessions.flatMap((sid) => searchInSession(sid, args.query, args.case_sensitive))
const searchOperation = async (): Promise<SearchResult[]> => {
if (args.session_id) {
return searchInSession(args.session_id, args.query, args.case_sensitive, resultLimit)
}
const limited = args.limit && args.limit > 0 ? allResults.slice(0, args.limit) : allResults.slice(0, 20)
const allSessions = await getAllSessions()
const sessionsToScan = allSessions.slice(0, MAX_SESSIONS_TO_SCAN)
return formatSearchResults(limited)
const allResults: SearchResult[] = []
for (const sid of sessionsToScan) {
if (allResults.length >= resultLimit) break
const remaining = resultLimit - allResults.length
const sessionResults = await searchInSession(sid, args.query, args.case_sensitive, remaining)
allResults.push(...sessionResults)
}
return allResults.slice(0, resultLimit)
}
const results = await withTimeout(searchOperation(), SEARCH_TIMEOUT_MS, "Search")
return formatSearchResults(results)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
},
})
export const session_info = tool({
export const session_info: ToolDefinition = 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)
const info = await getSessionInfo(args.session_id)
if (!info) {
return `Session not found: ${args.session_id}`

View File

@@ -1,21 +1,39 @@
import { describe, test, expect } from "bun:test"
import { formatSessionList, formatSessionMessages, formatSessionInfo, formatSearchResults, filterSessionsByDate, searchInSession } from "./utils"
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([])
test("formatSessionList handles empty array", async () => {
// #given
const sessions: string[] = []
// #when
const result = await formatSessionList(sessions)
// #then
expect(result).toContain("No sessions found")
})
test("formatSessionMessages handles empty array", () => {
const result = formatSessionMessages([])
// #given
const messages: SessionMessage[] = []
// #when
const result = formatSessionMessages(messages)
// #then
expect(result).toContain("No messages")
})
test("formatSessionMessages includes message content", () => {
// #given
const messages: SessionMessage[] = [
{
id: "msg_001",
@@ -24,14 +42,17 @@ describe("session-manager utils", () => {
parts: [{ id: "prt_001", type: "text", text: "Hello world" }],
},
]
// #when
const result = formatSessionMessages(messages)
// #then
expect(result).toContain("user")
expect(result).toContain("Hello world")
})
test("formatSessionMessages includes todos when requested", () => {
// #given
const messages: SessionMessage[] = [
{
id: "msg_001",
@@ -40,20 +61,22 @@ describe("session-manager utils", () => {
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 },
]
// #when
const result = formatSessionMessages(messages, true, todos)
// #then
expect(result).toContain("Todos")
expect(result).toContain("Task 1")
expect(result).toContain("Task 2")
})
test("formatSessionInfo includes all metadata", () => {
// #given
const info: SessionInfo = {
id: "ses_test123",
message_count: 42,
@@ -65,9 +88,11 @@ describe("session-manager utils", () => {
todos: [{ id: "1", content: "Test", status: "pending" }],
transcript_entries: 123,
}
// #when
const result = formatSessionInfo(info)
// #then
expect(result).toContain("ses_test123")
expect(result).toContain("42")
expect(result).toContain("build, oracle")
@@ -75,12 +100,18 @@ describe("session-manager utils", () => {
})
test("formatSearchResults handles empty array", () => {
const result = formatSearchResults([])
// #given
const results: SearchResult[] = []
// #when
const result = formatSearchResults(results)
// #then
expect(result).toContain("No matches")
})
test("formatSearchResults formats matches correctly", () => {
// #given
const results: SearchResult[] = [
{
session_id: "ses_test123",
@@ -91,9 +122,11 @@ describe("session-manager utils", () => {
timestamp: Date.now(),
},
]
// #when
const result = formatSearchResults(results)
// #then
expect(result).toContain("Found 1 matches")
expect(result).toContain("ses_test123")
expect(result).toContain("msg_001")
@@ -101,17 +134,26 @@ describe("session-manager utils", () => {
expect(result).toContain("Matches: 3")
})
test("filterSessionsByDate filters correctly", () => {
test("filterSessionsByDate filters correctly", async () => {
// #given
const sessionIDs = ["ses_001", "ses_002", "ses_003"]
const result = filterSessionsByDate(sessionIDs)
// #when
const result = await filterSessionsByDate(sessionIDs)
// #then
expect(Array.isArray(result)).toBe(true)
})
test("searchInSession finds matches case-insensitively", () => {
const results = searchInSession("ses_nonexistent", "test", false)
test("searchInSession finds matches case-insensitively", async () => {
// #given
const sessionID = "ses_nonexistent"
const query = "test"
// #when
const results = await searchInSession(sessionID, query, false)
// #then
expect(Array.isArray(results)).toBe(true)
expect(results.length).toBe(0)
})

View File

@@ -1,12 +1,14 @@
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
import { getSessionInfo, readSessionMessages } from "./storage"
export function formatSessionList(sessionIDs: string[]): string {
export async function formatSessionList(sessionIDs: string[]): Promise<string> {
if (sessionIDs.length === 0) {
return "No sessions found."
}
const infos = sessionIDs.map((id) => getSessionInfo(id)).filter((info): info is SessionInfo => info !== null)
const infos = (await Promise.all(sessionIDs.map((id) => getSessionInfo(id)))).filter(
(info): info is SessionInfo => info !== null
)
if (infos.length === 0) {
return "No valid sessions found."
@@ -39,7 +41,11 @@ export function formatSessionList(sessionIDs: string[]): string {
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 {
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."
}
@@ -116,32 +122,46 @@ export function formatSearchResults(results: SearchResult[]): string {
return lines.join("\n")
}
export function filterSessionsByDate(sessionIDs: string[], fromDate?: string, toDate?: string): string[] {
export async function filterSessionsByDate(
sessionIDs: string[],
fromDate?: string,
toDate?: string
): Promise<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
const results: string[] = []
for (const id of sessionIDs) {
const info = await getSessionInfo(id)
if (!info || !info.last_message) continue
if (from && info.last_message < from) return false
if (to && info.last_message > to) return false
if (from && info.last_message < from) continue
if (to && info.last_message > to) continue
return true
})
results.push(id)
}
return results
}
export function searchInSession(sessionID: string, query: string, caseSensitive = false): SearchResult[] {
const messages = readSessionMessages(sessionID)
export async function searchInSession(
sessionID: string,
query: string,
caseSensitive = false,
maxResults?: number
): Promise<SearchResult[]> {
const messages = await readSessionMessages(sessionID)
const results: SearchResult[] = []
const searchQuery = caseSensitive ? query : query.toLowerCase()
for (const msg of messages) {
if (maxResults && results.length >= maxResults) break
let matchCount = 0
let excerpts: string[] = []
const excerpts: string[] = []
for (const part of msg.parts) {
if (part.type === "text" && part.text) {

View File

@@ -1,9 +1,9 @@
import { tool } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename, dirname } from "path"
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { CommandScope, CommandMetadata, CommandInfo } from "./types"
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
@@ -50,7 +50,8 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
}
function discoverCommandsSync(): CommandInfo[] {
const userCommandsDir = join(homedir(), ".claude", "commands")
const { homedir } = require("os")
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
@@ -126,7 +127,7 @@ function formatCommandList(commands: CommandInfo[]): string {
return lines.join("\n")
}
export const slashcommand = tool({
export const slashcommand: ToolDefinition = tool({
description: `Execute a slash command within the main conversation.
When you use this tool, the slash command gets expanded to a full prompt that provides detailed instructions on how to complete the task.
@@ -145,7 +146,7 @@ Commands are loaded from (priority order, highest wins):
- .opencode/command/ (opencode-project - OpenCode project-specific commands)
- ./.claude/commands/ (project - Claude Code project-specific commands)
- ~/.config/opencode/command/ (opencode - OpenCode global commands)
- ~/.claude/commands/ (user - Claude Code global commands)
- $CLAUDE_CONFIG_DIR/commands/ or ~/.claude/commands/ (user - Claude Code global commands)
Each command is a markdown file with:
- YAML frontmatter: description, argument-hint, model, agent, subtask (optional)