Compare commits

...

64 Commits

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

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

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

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

Closes #250

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

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

Fixes #241

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Fixes #234

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #132

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

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

---------

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

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

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

* docs: add OpenCode permalinks and update multilingual README files

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

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

---------

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

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

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

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

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:26:27 +09:00
91 changed files with 6512 additions and 854 deletions

View File

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

View File

@@ -6,12 +6,10 @@ on:
prompt:
description: "Custom prompt"
required: false
# Only issue_comment works for fork PRs (secrets available)
# pull_request_review/pull_request_review_comment do NOT get secrets for fork PRs
issue_comment:
types: [created]
pull_request_review:
types: [submitted]
pull_request_review_comment:
types: [created]
jobs:
agent:
@@ -19,9 +17,9 @@ jobs:
# @sisyphus-dev-ai mention only (maintainers, exclude self)
if: |
github.event_name == 'workflow_dispatch' ||
(contains(github.event.comment.body || github.event.review.body, '@sisyphus-dev-ai') &&
(github.event.comment.user.login || github.event.review.user.login) != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association))
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
github.event.comment.user.login != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
# Minimal default GITHUB_TOKEN permissions
permissions:
@@ -71,7 +69,13 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
# Install OpenCode + oh-my-opencode + auth in single step
# Build local oh-my-opencode
- name: Build oh-my-opencode
run: |
bun install
bun run build
# Install OpenCode + configure local plugin + auth in single step
- name: Setup OpenCode with oh-my-opencode
env:
OPENCODE_AUTH_JSON: ${{ secrets.OPENCODE_AUTH_JSON }}
@@ -89,12 +93,19 @@ jobs:
bash /tmp/opencode-install.sh && break
fi
echo "Download corrupted, retrying in 5s..."
sleep 5
done
fi
opencode --version
bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=no --gemini=no
# Run local oh-my-opencode install (uses built dist)
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no
# Override plugin to use local file reference
OPENCODE_JSON=~/.config/opencode/opencode.json
REPO_PATH=$(pwd)
jq --arg path "file://$REPO_PATH/src/index.ts" '
.plugin = [.plugin[] | select(. != "oh-my-opencode")] + [$path]
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
OPENCODE_JSON=~/.config/opencode/opencode.json
jq --arg baseURL "$ANTHROPIC_BASE_URL" --arg apiKey "$ANTHROPIC_API_KEY" '
@@ -173,6 +184,25 @@ jobs:
)"
```
### GitHub Markdown Rules (MUST FOLLOW)
**Code blocks MUST have EXACTLY 3 backticks and language identifier:**
- CORRECT: ` ```bash ` ... ` ``` `
- WRONG: ` ``` ` (no language), ` ```` ` (4 backticks), ` `` ` (2 backticks)
**Every opening ` ``` ` MUST have a closing ` ``` ` on its own line:**
```
```bash
code here
```
```
**NO trailing backticks or spaces after closing ` ``` `**
**For inline code, use SINGLE backticks:** `code` not ```code```
**Lists inside code blocks break rendering - avoid them or use plain text**
### Rules
- EVERY response = GitHub comment (use heredoc for proper escaping)
- Code changes = PR (never push main/master)
@@ -197,39 +227,30 @@ jobs:
id: context
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
EVENT_NAME: ${{ github.event_name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
COMMENT_ID_VAL: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: |
EVENT="${{ github.event_name }}"
if [[ "$EVENT" == "issue_comment" ]]; then
ISSUE_NUM="${{ github.event.issue.number }}"
COMMENT="${{ github.event.comment.body }}"
AUTHOR="${{ github.event.comment.user.login }}"
COMMENT_ID="${{ github.event.comment.id }}"
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
ISSUE_NUM="$ISSUE_NUMBER"
AUTHOR="$COMMENT_AUTHOR"
COMMENT_ID="$COMMENT_ID_VAL"
# Check if PR or Issue
if gh api "repos/${{ github.repository }}/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
else
echo "type=issue" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
fi
elif [[ "$EVENT" == "pull_request_review_comment" ]]; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
COMMENT="${{ github.event.comment.body }}"
AUTHOR="${{ github.event.comment.user.login }}"
COMMENT_ID="${{ github.event.comment.id }}"
elif [[ "$EVENT" == "pull_request_review" ]]; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
COMMENT="${{ github.event.review.body }}"
AUTHOR="${{ github.event.review.user.login }}"
COMMENT_ID=""
fi
echo "comment<<EOF" >> $GITHUB_OUTPUT
echo "$COMMENT" >> $GITHUB_OUTPUT
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
@@ -264,32 +285,47 @@ jobs:
--add-label "sisyphus: working" || true
fi
- name: Run OpenCode
- name: Run oh-my-opencode
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
USER_COMMENT: ${{ steps.context.outputs.comment }}
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
REPO_NAME: ${{ github.repository }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
export PATH="$HOME/.opencode/bin:$PATH"
PROMPT="
Your username is @sisyphus-dev-ai, mentioned by @${{ steps.context.outputs.author }} in ${{ github.repository }}.
PROMPT=$(cat <<'PROMPT_EOF'
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
## Context
- Type: ${{ steps.context.outputs.type }}
- Number: #${{ steps.context.outputs.number }}
- Repository: ${{ github.repository }}
- Default Branch: ${{ github.event.repository.default_branch }}
- Type: TYPE_PLACEHOLDER
- Number: #NUMBER_PLACEHOLDER
- Repository: REPO_PLACEHOLDER
- Default Branch: BRANCH_PLACEHOLDER
## User's Request
${{ steps.context.outputs.comment }}
COMMENT_PLACEHOLDER
---
First, acknowledge with \`gh issue comment ${{ steps.context.outputs.number }} --body \"👋 Hey @${{ steps.context.outputs.author }}! I'm on it...\"\`
First, acknowledge with `gh issue comment NUMBER_PLACEHOLDER --body "👋 Hey @AUTHOR_PLACEHOLDER! I'm on it..."`
Then write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to \`${{ github.event.repository.default_branch }}\` branch."
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
PROMPT_EOF
)
opencode run "$PROMPT"
PROMPT="${PROMPT//AUTHOR_PLACEHOLDER/$COMMENT_AUTHOR}"
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
stdbuf -oL -eL bun run dist/cli/index.js run "$PROMPT"
# Push changes (as sisyphus-dev-ai)
- name: Push changes

View File

@@ -56,6 +56,8 @@
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目次
@@ -599,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 ツールが空の応答を返すと検知します。既に空の応答が返ってきているのに、いつまでも待ち続ける状況を防ぎます。
@@ -714,24 +715,48 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
### Sisyphus Agent
有効時デフォルト、Sisyphus は2つのプライマリエージェントを追加し、内蔵エージェントをサブエージェントに降格させます:
有効時デフォルト、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
- **Planner-Sisyphus**: OpenCode の plan エージェントの全設定を実行時に継承 (description に "OhMyOpenCode version" を追加)
- **build**: サブエージェントに降格
- **plan**: サブエージェントに降格
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェントSDK 制限により名前変更、デフォルトで無効)
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェントSDK 制限により名前変更、デフォルトで有効)
Sisyphus を無効化して元の build/plan エージェントを復元するには:
**設定オプション:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**例Builder-Sisyphus を有効化:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
**例:すべての Sisyphus オーケストレーションを無効化:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
他のエージェント同様、Sisyphus と Planner-Sisyphus もカスタマイズ可能です:
他のエージェント同様、Sisyphus エージェントもカスタマイズ可能です:
```json
{
@@ -740,6 +765,9 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -747,9 +775,12 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
}
```
| オプション | デフォルト | 説明 |
|------------|------------|------|
| `disabled` | `false` | `true` の場合、Sisyphus エージェントを無効化し、元の build/plan をプライマリとして復元します。`false` (デフォルト) の場合、Sisyphus と Planner-Sisyphus がプライマリエージェントになります。 |
| オプション | デフォルト | 説明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化しますOpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化しますOpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
### Hooks
@@ -763,6 +794,8 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
利用可能なフック:`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 がデフォルトで有効になっています。
@@ -812,15 +845,19 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
| オプション | デフォルト | 説明 |
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| オプション | デフォルト | 説明 |
| --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| `truncate_all_tool_outputs` | `true` | プロンプトが長くなりすぎるのを防ぐため、コンテキストウィンドウの使用状況に基づいてすべてのツール出力を動的に切り詰めます。完全なツール出力が必要な場合は`false`に設定して無効化します。 |
| `dcp_on_compaction_failure` | `false` | 有効にすると、DCPDynamic Context Pruningはコンパクション要約が失敗した後にのみ実行され、その後コンパクションを再試行します。通常時は DCP は実行されません。トークン制限に達した際によりスマートな回復が必要な場合は有効にしてください。 |
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
@@ -868,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

@@ -53,6 +53,8 @@
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 목차
@@ -593,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 도구가 빈 응답을 반환하면 감지합니다. 이미 빈 응답이 왔는데 무한정 기다리는 상황을 방지합니다.
@@ -708,14 +709,38 @@ Schema 자동 완성이 지원됩니다:
### Sisyphus Agent
활성화 시(기본값), oh-my-opencode 는 두 개의 primary 에이전트를 추가하고 내장 에이전트를 subagent로 강등합니다:
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
- **Planner-Sisyphus**: OpenCode plan 에이전트의 모든 설정을 런타임에 상속 (description에 "OhMyOpenCode version" 추가)
- **build**: subagent로 강등
- **plan**: subagent로 강등
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려면:
**설정 옵션:**
```json
{
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**예시: Builder-Sisyphus 활성화하기:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
```json
{
@@ -725,7 +750,7 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
다른 에이전트처럼 Sisyphus 와 Planner-Sisyphus도 커스터마이징할 수 있습니다:
다른 에이전트처럼 Sisyphus 에이전트들도 커스터마이징할 수 있습니다:
```json
{
@@ -734,6 +759,9 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -741,9 +769,12 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
| 옵션 | 기본값 | 설명 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true`면 Sisyphus 에이전트를 비활성화하고 원래 build/plan을 primary로 복원합니다. `false`(기본값)면 Sisyphus와 Planner-Sisyphus가 primary 에이전트가 됩니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
### Hooks
@@ -757,6 +788,8 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
사용 가능한 훅: `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 를 지원합니다.
@@ -806,15 +839,19 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
| 옵션 | 기본값 | 설명 |
| ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| `truncate_all_tool_outputs` | `true` | 프롬프트가 너무 길어지는 것을 방지하기 위해 컨텍스트 윈도우 사용량에 따라 모든 도구 출력을 동적으로 잘라냅니다. 전체 도구 출력이 필요한 경우 `false`로 설정하여 비활성화하세요. |
| `dcp_on_compaction_failure` | `false` | 활성화하면, DCP(Dynamic Context Pruning)가 compaction(요약) 실패 후에만 실행되고 compaction을 재시도합니다. DCP는 평소에는 실행되지 않습니다. 토큰 제한에 도달했을 때 더 스마트한 복구를 원하면 활성화하세요. |
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.
@@ -862,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) 께 감사드립니다*

110
README.md
View File

@@ -61,6 +61,8 @@ No stupid token consumption massive subagents here. No bloat tools here.
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## Contents
@@ -665,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.
@@ -695,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).
@@ -780,24 +811,48 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
### Sisyphus Agent
When enabled (default), Sisyphus adds two primary agents and demotes the built-in agents to subagents:
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
- **Planner-Sisyphus**: Inherits all settings from OpenCode's plan agent at runtime (description appended with "OhMyOpenCode version")
- **build**: Demoted to subagent
- **plan**: Demoted to subagent
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
To disable Sisyphus and restore the original build/plan agents:
**Configuration Options:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**Example: Enable Builder-Sisyphus:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
**Example: Disable all Sisyphus orchestration:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
You can also customize Sisyphus and Planner-Sisyphus like other agents:
You can also customize Sisyphus agents like other agents:
```json
{
@@ -806,6 +861,9 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -813,9 +871,12 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
}
```
| Option | Default | Description |
| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables Sisyphus agents and restores original build/plan as primary. When `false` (default), Sisyphus and Planner-Sisyphus become primary agents. |
| Option | Default | Description |
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
### Hooks
@@ -829,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.
@@ -878,15 +941,19 @@ Opt-in experimental features that may change or be removed in future versions. U
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
| Option | Default | Description |
| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
| Option | Default | Description |
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
| `truncate_all_tool_outputs` | `true` | Dynamically truncates ALL tool outputs based on context window usage to prevent prompts from becoming too long. Disable by setting to `false` if you need full tool outputs. |
| `dcp_on_compaction_failure` | `false` | When enabled, Dynamic Context Pruning (DCP) runs only after compaction (summarize) fails, then retries compaction. DCP does NOT run during normal operations. 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.
@@ -934,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

@@ -58,6 +58,8 @@
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目录
@@ -604,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 工具回了个寂寞?立马报警,别傻傻等一个永远不会来的响应。
@@ -714,24 +715,48 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
### Sisyphus Agent
默认开启。Sisyphus 会加两个主 Agent把原来的降级成小弟
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent
- **Sisyphus**:主编排 AgentClaude Opus 4.5
- **Planner-Sisyphus**运行时继承 OpenCode plan Agent 所有设置(描述里加了"OhMyOpenCode version"
- **build**:降级为子 Agent
- **plan**:降级为子 Agent
- **Builder-Sisyphus**OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用
- **Planner-Sisyphus**OpenCode 默认计划 Agent因 SDK 限制仅改名,默认启用)
想禁用 Sisyphus 恢复原来的?
**配置选项:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**示例:启用 Builder-Sisyphus**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
**示例:禁用所有 Sisyphus 编排:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
Sisyphus 和 Planner-Sisyphus 也能自定义:
Sisyphus Agent 也能自定义:
```json
{
@@ -740,6 +765,9 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -747,9 +775,12 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
}
```
| 选项 | 默认值 | 说明 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | 设为 `true` 就禁用 Sisyphus恢复原来的 build/plan。设为 `false`(默认)就是 Sisyphus 和 Planner-Sisyphus 掌权。 |
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
### Hooks
@@ -763,6 +794,8 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
可关的 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。
@@ -812,15 +845,19 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
{
"experimental": {
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
| 选项 | 默认值 | 说明 |
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| `truncate_all_tool_outputs` | `true` | 为防止提示过长,根据上下文窗口使用情况动态截断所有工具输出。如需完整工具输出,设置为 `false` 禁用此功能。 |
| `dcp_on_compaction_failure` | `false` | 启用后DCP动态上下文剪枝仅在压缩摘要失败后运行然后重试压缩。平时 DCP 不会运行。当达到 token 限制时需要更智能的恢复请启用此选项。 |
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
@@ -867,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,8 @@
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer"
"empty-message-sanitizer",
"thinking-block-validator"
]
}
},
@@ -408,6 +409,120 @@
}
}
},
"OpenCode-Builder": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
},
"disable": {
"type": "boolean"
},
"description": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"subagent",
"primary",
"all"
]
},
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"permission": {
"type": "object",
"properties": {
"edit": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"bash": {
"anyOf": [
{
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
]
},
"webfetch": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"doom_loop": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"external_directory": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
}
}
},
"Planner-Sisyphus": {
"type": "object",
"properties": {
@@ -1225,6 +1340,18 @@
},
"hooks": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
"plugins_override": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
}
}
},
@@ -1236,6 +1363,15 @@
"properties": {
"disabled": {
"type": "boolean"
},
"default_builder_enabled": {
"type": "boolean"
},
"planner_enabled": {
"type": "boolean"
},
"replace_plan": {
"type": "boolean"
}
}
},
@@ -1257,6 +1393,101 @@
"maximum": 0.95
},
"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_on_compaction_failure": {
"type": "boolean"
}
}

View File

@@ -11,8 +11,10 @@
"@code-yeongyu/comment-checker": "^0.6.0",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"commander": "^14.0.2",
"hono": "^4.10.4",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
@@ -109,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.2",
"version": "2.6.2",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -56,8 +56,10 @@
"@code-yeongyu/comment-checker": "^0.6.0",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"commander": "^14.0.2",
"hono": "^4.10.4",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",

View File

@@ -15,6 +15,54 @@
"created_at": "2025-12-25T06:19:27Z",
"repoId": 1108837393,
"pullRequestNo": 217
},
{
"name": "mylukin",
"id": 1021019,
"comment_id": 3691531529,
"created_at": "2025-12-25T15:15:29Z",
"repoId": 1108837393,
"pullRequestNo": 240
},
{
"name": "codewithkenzo",
"id": 115878491,
"comment_id": 3691825625,
"created_at": "2025-12-25T23:47:52Z",
"repoId": 1108837393,
"pullRequestNo": 253
},
{
"name": "stevenvo",
"id": 875426,
"comment_id": 3692141372,
"created_at": "2025-12-26T05:16:12Z",
"repoId": 1108837393,
"pullRequestNo": 248
},
{
"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
}
]
}

View File

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

View File

@@ -1,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
}
@@ -252,8 +183,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 +414,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

@@ -1,7 +1,11 @@
#!/usr/bin/env bun
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
@@ -44,6 +48,59 @@ Model Providers:
process.exit(exitCode)
})
program
.command("run <message>")
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode run "Fix the bug in index.ts"
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
Unlike 'opencode run', this command waits until:
- All todos are completed or cancelled
- All child sessions (background tasks) are idle
`)
.action(async (message: string, options) => {
const runOptions: RunOptions = {
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
}
const exitCode = await run(runOptions)
process.exit(exitCode)
})
program
.command("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

@@ -0,0 +1,170 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
const createMockContext = (overrides: {
todo?: Todo[]
childrenBySession?: Record<string, ChildSession[]>
statuses?: Record<string, SessionStatus>
} = {}): RunContext => {
const {
todo = [],
childrenBySession = { "test-session": [] },
statuses = {},
} = overrides
return {
client: {
session: {
todo: mock(() => Promise.resolve({ data: todo })),
children: mock((opts: { path: { id: string } }) =>
Promise.resolve({ data: childrenBySession[opts.path.id] ?? [] })
),
status: mock(() => Promise.resolve({ data: statuses })),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
directory: "/test",
abortController: new AbortController(),
}
}
describe("checkCompletionConditions", () => {
it("returns true when no todos and no children", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext()
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when incomplete todos exist", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
todo: [
{ id: "1", content: "Done", status: "completed", priority: "high" },
{ id: "2", content: "WIP", status: "in_progress", priority: "high" },
],
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all todos completed or cancelled", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
todo: [
{ id: "1", content: "Done", status: "completed", priority: "high" },
{ id: "2", content: "Skip", status: "cancelled", priority: "medium" },
],
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when child session is busy", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: { "child-1": { type: "busy" } },
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all children idle", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }, { id: "child-2" }],
"child-1": [],
"child-2": [],
},
statuses: {
"child-1": { type: "idle" },
"child-2": { type: "idle" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when grandchild is busy (recursive)", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [{ id: "grandchild-1" }],
"grandchild-1": [],
},
statuses: {
"child-1": { type: "idle" },
"grandchild-1": { type: "busy" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all descendants idle (recursive)", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [{ id: "grandchild-1" }],
"grandchild-1": [{ id: "great-grandchild-1" }],
"great-grandchild-1": [],
},
statuses: {
"child-1": { type: "idle" },
"grandchild-1": { type: "idle" },
"great-grandchild-1": { type: "idle" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
})

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

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

View File

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

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

@@ -0,0 +1,279 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
SessionIdleProps,
SessionStatusProps,
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
} from "./types"
export interface EventState {
mainSessionIdle: boolean
mainSessionError: boolean
lastError: string | null
lastOutput: string
lastPartText: string
currentTool: string | null
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
}
}
export async function processEvents(
ctx: RunContext,
stream: AsyncIterable<unknown>,
state: EventState
): Promise<void> {
for await (const event of stream) {
if (ctx.abortController.signal.aborted) break
try {
const payload = event as EventPayload
if (!payload?.type) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
continue
}
logEventVerbose(ctx, payload)
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)
} catch (err) {
console.error(pc.red(`[event error] ${err}`))
}
}
}
function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const props = payload.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = props?.sessionID ?? info?.sessionID
const isMainSession = sessionID === ctx.sessionID
const sessionTag = isMainSession
? pc.green("[MAIN]")
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
switch (payload.type) {
case "session.idle":
case "session.status": {
const status = (props?.status as { type?: string })?.type ?? "idle"
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
break
}
case "message.part.updated": {
const partProps = props as MessagePartUpdatedProps | undefined
const role = partProps?.info?.role ?? "unknown"
const part = partProps?.part
if (part?.type === "text" && part.text) {
const preview = part.text.slice(0, 100).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.part (${role}): "${preview}${part.text.length > 100 ? "..." : ""}"`)
)
} else if (part?.type === "tool-invocation") {
const toolPart = part as { toolName?: string; state?: string }
console.error(
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
)
}
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const content = msgProps?.content ?? ""
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
)
break
}
case "tool.execute": {
const toolProps = props as ToolExecuteProps | undefined
const toolName = toolProps?.name ?? "unknown"
const input = toolProps?.input ?? {}
const inputStr = JSON.stringify(input).slice(0, 150)
console.error(
pc.cyan(`${sessionTag} ⚡ TOOL.EXECUTE: ${pc.bold(toolName)}`)
)
console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`))
break
}
case "tool.result": {
const resultProps = props as ToolResultProps | undefined
const output = resultProps?.output ?? ""
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
console.error(
pc.green(`${sessionTag} ✓ TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
)
break
}
default:
console.error(pc.dim(`${sessionTag} ${payload.type}`))
}
}
function handleSessionIdle(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.idle") return
const props = payload.properties as SessionIdleProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionIdle = true
}
}
function handleSessionStatus(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.status") return
const props = payload.properties as SessionStatusProps | undefined
if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") {
state.mainSessionIdle = false
}
}
function handleSessionError(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.error") return
const props = payload.properties as SessionErrorProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionError = true
state.lastError = props?.error
? String(props.error instanceof Error ? props.error.message : props.error)
: "Unknown error"
console.error(pc.red(`\n[session.error] ${state.lastError}`))
}
}
function handleMessagePartUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.part.updated") return
const props = payload.properties as MessagePartUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const part = props.part
if (!part) return
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
}
state.lastPartText = part.text
}
}
function handleMessageUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.updated") return
const props = payload.properties as MessageUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const content = props.content
if (!content || content === state.lastOutput) return
if (state.lastPartText.length === 0) {
const newContent = content.slice(state.lastOutput.length)
if (newContent) {
process.stdout.write(newContent)
}
}
state.lastOutput = content
}
function handleToolExecute(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.execute") return
const props = payload.properties as ToolExecuteProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const toolName = props?.name || "unknown"
state.currentTool = toolName
let inputPreview = ""
if (props?.input) {
const input = props.input
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
process.stdout.write(`\n${pc.cyan("⚡")} ${pc.bold(toolName)}${inputPreview}\n`)
}
function handleToolResult(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.result") return
const props = payload.properties as ToolResultProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const output = props?.output || ""
const maxLen = 200
const preview = output.length > maxLen
? output.slice(0, maxLen) + "..."
: output
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
}
state.currentTool = null
state.lastPartText = ""
}

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

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

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

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

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

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

View File

@@ -18,4 +18,5 @@ export type {
HookName,
SisyphusAgentConfig,
ExperimentalConfig,
DynamicContextPruningConfig,
} from "./schema"

View File

@@ -30,6 +30,7 @@ export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"Sisyphus",
"OpenCode-Builder",
"Planner-Sisyphus",
"oracle",
"librarian",
@@ -63,6 +64,7 @@ export const HookNameSchema = z.enum([
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer",
"thinking-block-validator",
])
export const AgentOverrideConfigSchema = z.object({
@@ -86,6 +88,7 @@ export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
Sisyphus: AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
"Planner-Sisyphus": AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
@@ -101,10 +104,51 @@ 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({
disabled: z.boolean().optional(),
default_builder_enabled: z.boolean().optional(),
planner_enabled: z.boolean().optional(),
replace_plan: z.boolean().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({
@@ -114,8 +158,12 @@ export const ExperimentalConfigSchema = z.object({
preemptive_compaction: z.boolean().optional(),
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: false) */
truncate_all_tool_outputs: z.boolean().optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
truncate_all_tool_outputs: z.boolean().default(true),
/** Dynamic context pruning configuration */
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
/** Run DCP only when compaction (summarize) fails, then retry compaction (default: false) */
dcp_on_compaction_failure: z.boolean().optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
@@ -138,5 +186,6 @@ export type AgentName = z.infer<typeof AgentNameSchema>
export type HookName = z.infer<typeof HookNameSchema>
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
export { McpNameSchema, type McpName } from "../mcp/types"

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()
@@ -99,6 +99,7 @@ export class BackgroundManager {
toolCalls: 0,
lastUpdate: new Date(),
},
parentModel: input.parentModel,
}
this.tasks.set(task.id, task)
@@ -286,6 +287,7 @@ export class BackgroundManager {
this.pollingInterval = setInterval(() => {
this.pollRunningTasks()
}, 2000)
this.pollingInterval.unref()
}
private stopPolling(): void {
@@ -295,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)
@@ -322,10 +330,16 @@ export class BackgroundManager {
const messageDir = getMessageDir(task.parentSessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const modelContext = task.parentModel ?? prevMessage?.model
const modelField = modelContext?.providerID && modelContext?.modelID
? { providerID: modelContext.providerID, modelID: modelContext.modelID }
: undefined
await this.client.session.prompt({
path: { id: task.parentSessionID },
body: {
agent: prevMessage?.agent,
model: modelField,
parts: [{ type: "text", text: message }],
},
query: { directory: this.directory },

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

@@ -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,190 @@
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"
export interface DeduplicationConfig {
enabled: boolean
protectedTools?: string[]
}
const MESSAGE_STORAGE = join(
process.env.HOME || process.env.USERPROFILE || "",
".config",
"opencode",
"sessions"
)
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,158 @@
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"
export interface PurgeErrorsConfig {
enabled: boolean
turns: number
protectedTools?: string[]
}
const MESSAGE_STORAGE = join(
process.env.HOME || process.env.USERPROFILE || "",
".config",
"opencode",
"sessions"
)
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,107 @@
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"
const MESSAGE_STORAGE = join(
process.env.HOME || process.env.USERPROFILE || "",
".config",
"opencode",
"sessions"
)
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,218 @@
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"
export interface SupersedeWritesConfig {
enabled: boolean
aggressive: boolean
}
const MESSAGE_STORAGE = join(
process.env.HOME || process.env.USERPROFILE || "",
".config",
"opencode",
"sessions"
)
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

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

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

View File

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

View File

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

@@ -31,11 +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

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

View File

@@ -0,0 +1,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

@@ -223,6 +223,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 +265,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,170 @@
/**
* 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 tool parts (tool_use)
*/
function hasToolParts(parts: Part[]): boolean {
if (!parts || parts.length === 0) return false
return parts.some((part: Part) => {
const type = part.type as string
return type === "tool" || type === "tool_use"
})
}
/**
* 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 tool parts but doesn't start with thinking
if (hasToolParts(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

@@ -215,6 +215,30 @@ export function createTodoContinuationEnforcer(
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 {
@@ -237,7 +261,7 @@ export function createTodoContinuationEnforcer(
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
text: `${CONTINUATION_PROMPT}\n\n[Status: ${freshTodos.length - freshIncomplete.length}/${freshTodos.length} completed, ${freshIncomplete.length} remaining]`,
},
],
},
@@ -296,7 +320,8 @@ export function createTodoContinuationEnforcer(
if (sessionID && role === "assistant" && finish) {
remindedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Cleared reminded state on assistant finish`, { sessionID })
preemptivelyInjectedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Cleared reminded/preemptive state on assistant finish`, { sessionID })
const isTerminalFinish = finish && !["tool-calls", "unknown"].includes(finish)
if (isTerminalFinish && isNonInteractive()) {

View File

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

View File

@@ -23,6 +23,7 @@ import {
createNonInteractiveEnvHook,
createInteractiveBashSessionHook,
createEmptyMessageSanitizerHook,
createThinkingBlockValidatorHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
@@ -31,15 +32,13 @@ import {
loadOpencodeGlobalCommands,
loadOpencodeProjectCommands,
} from "./features/claude-code-command-loader";
import {
loadUserSkillsAsCommands,
loadProjectSkillsAsCommands,
} from "./features/claude-code-skill-loader";
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 +47,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";
@@ -116,11 +115,11 @@ function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown
return needsWrite;
}
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = JSON.parse(content);
const rawConfig = parseJsonc<Record<string, unknown>>(content);
migrateConfigFile(configPath, rawConfig);
@@ -174,26 +173,22 @@ function mergeConfigs(
};
}
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
// User-level config path (OS-specific)
const userConfigPath = path.join(
getUserConfigDir(),
"opencode",
"oh-my-opencode.json"
);
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
// 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) ?? {};
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
// Override with project config
const projectConfig = loadConfigFromPath(projectConfigPath);
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
@@ -209,7 +204,7 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
}
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory);
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
@@ -292,6 +287,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);
@@ -338,6 +336,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);
},
@@ -368,6 +368,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,
@@ -377,42 +393,77 @@ 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;
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
// TODO: When OpenCode releases `default_agent` config option (PR #5313),
// use `config.default_agent = "Sisyphus"` instead of demoting build/plan.
// Tracking: https://github.com/sst/opencode/pull/5313
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
const plannerSisyphusBase = {
...planConfigWithoutName,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
color: config.agent?.plan?.color ?? "#6495ED",
// 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,
};
const plannerSisyphusConfig = plannerSisyphusOverride
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
: plannerSisyphusBase;
if (builderEnabled) {
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
const openCodeBuilderBase = {
...buildConfigWithoutName,
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
};
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
: openCodeBuilderBase;
}
if (plannerEnabled) {
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
const plannerSisyphusBase = {
...planConfigWithoutName,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
color: config.agent?.plan?.color ?? "#6495ED",
};
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
: plannerSisyphusBase;
}
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
const filteredConfigAgents = config.agent ?
Object.fromEntries(
Object.entries(config.agent).filter(([key]) => {
if (key === "build") return false;
if (key === "plan" && replacePlan) return false;
return true;
})
) : {};
config.agent = {
Sisyphus: builtinAgents.Sisyphus,
"Planner-Sisyphus": plannerSisyphusConfig,
...agentConfig,
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
...userAgents,
...projectAgents,
...config.agent,
...pluginAgents,
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
// Demote build/plan to subagent mode when replaced
build: { ...config.agent?.build, mode: "subagent" },
plan: { ...config.agent?.plan, mode: "subagent" },
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
};
} else {
config.agent = {
...builtinAgents,
...userAgents,
...projectAgents,
...pluginAgents,
...config.agent,
};
}
@@ -451,10 +502,12 @@ 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 userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
@@ -462,17 +515,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
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 = {
...userCommands,
...userSkills,
...opencodeGlobalCommands,
...systemCommands,
...projectCommands,
...projectSkills,
...opencodeProjectCommands,
...pluginComponents.commands,
};
},

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")
}

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

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

View File

@@ -1,164 +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)
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

@@ -11,4 +11,7 @@ export * from "./deep-merge"
export * from "./file-utils"
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

@@ -23,6 +23,12 @@ tools/
│ ├── config.ts # Server configurations
│ ├── tools.ts # Tool implementations
│ └── types.ts
├── session-manager/ # OpenCode session file management
│ ├── constants.ts # Storage paths, descriptions
│ ├── types.ts # Session data interfaces
│ ├── storage.ts # File I/O operations
│ ├── utils.ts # Formatting, filtering
│ └── tools.ts # Tool implementations
├── slashcommand/ # Slash command execution
└── index.ts # builtinTools export
```
@@ -34,6 +40,7 @@ tools/
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve | IDE-like code intelligence |
| AST | ast_grep_search, ast_grep_replace | Pattern-based code search/replace |
| File Search | grep, glob | Content and file pattern matching |
| Session | session_list, session_read, session_search, session_info | OpenCode session file management |
| Background | background_task, background_output, background_cancel | Async agent orchestration |
| Multimodal | look_at | PDF/image analysis via Gemini |
| Terminal | interactive_bash | Tmux session control |

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,10 +1,27 @@
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"
import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types"
import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
type OpencodeClient = PluginInput["client"]
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
@@ -20,7 +37,7 @@ function formatDuration(start: Date, end?: Date): string {
}
}
export function createBackgroundTask(manager: BackgroundManager) {
export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
return tool({
description: BACKGROUND_TASK_DESCRIPTION,
args: {
@@ -34,12 +51,19 @@ export function createBackgroundTask(manager: BackgroundManager) {
}
try {
const messageDir = getMessageDir(toolContext.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
: undefined
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: args.agent.trim(),
parentSessionID: toolContext.sessionID,
parentMessageID: toolContext.messageID,
parentModel,
})
return `Background task launched successfully.
@@ -193,7 +217,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: {
@@ -259,7 +283,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,4 +1,4 @@
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"
@@ -7,7 +7,7 @@ import { log } from "../../shared/logger"
export function createCallOmoAgent(
ctx: PluginInput,
backgroundManager: BackgroundManager
) {
): ToolDefinition {
const agentDescriptions = ALLOWED_AGENTS.map(
(name) => `- ${name}: Specialized agent for ${name} tasks`
).join("\n")

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

@@ -21,6 +21,13 @@ import { grep } from "./grep"
import { glob } from "./glob"
import { slashcommand } from "./slashcommand"
import {
session_list,
session_read,
session_search,
session_info,
} from "./session-manager"
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
export { getTmuxPath } from "./interactive-bash/utils"
@@ -30,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"]
@@ -38,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),
@@ -46,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,
@@ -63,4 +70,8 @@ export const builtinTools = {
grep,
glob,
slashcommand,
session_list,
session_read,
session_search,
session_info,
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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)