Compare commits

...

198 Commits

Author SHA1 Message Date
github-actions[bot]
8db2bd3893 release: v0.4.2 2025-12-13 03:16:48 +00:00
YeonGyu-Kim
555abbc0d6 fix(google-auth): integrate into main package via config option
Fixes #30. OpenCode's plugin loader treats subpath exports like
"oh-my-opencode/google-auth" as separate npm packages, causing
BunInstallFailedError.

Solution: Enable Google auth via `google_auth: true` in
oh-my-opencode.json instead of a separate subpath plugin.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 12:14:51 +09:00
Junho Yeo
3b129f11c4 fix(config): deep merge agent overrides with reusable deepMerge utility (#27) 2025-12-13 12:00:38 +09:00
Junho Yeo
2cab36f06d fix(hooks): prevent infinite loop when todo-continuation-enforcer runs during session recovery (#29) 2025-12-13 11:48:22 +09:00
github-actions[bot]
fd357e490b release: v0.4.0 2025-12-12 19:59:41 +00:00
YeonGyu-Kim
55157bceaf fix(deps): add @openauthjs/openauth and hono as direct dependencies
PKCE auth requires these packages directly, not as transitive deps.
Fixes CI build failure: Cannot find module '@openauthjs/openauth/pkce'
2025-12-13 04:58:37 +09:00
YeonGyu-Kim
5608bd0ef9 fix(antigravity): improve streaming retry logic and implement true SSE streaming
- Add isRetryableResponse() to detect SUBSCRIPTION_REQUIRED 403 errors for retry handling
- Remove JSDoc comments from isRetryableError() for clarity
- Add debug logging for request/response details (streaming flag, status, content-type)
- Refactor transformStreamingResponse() to use TransformStream for true streaming
  - Replace buffering approach with incremental chunk processing
  - Implement createSseTransformStream() for line-by-line transformation
  - Reduces memory footprint and Time-To-First-Byte (TTFB)
- Update SSE content-type detection to include alt=sse URL parameter
- Simplify response transformation logic for non-streaming path
- Add more granular debug logging for thought signature extraction

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 04:50:11 +09:00
YeonGyu-Kim
abd90bbc9c fix(antigravity): use loadCodeAssist project ID and add OpenAI message conversion
- Add message-converter.ts for OpenAI messages to Gemini contents conversion
- Use SKIP_THOUGHT_SIGNATURE_VALIDATOR as default signature (CLIProxyAPI approach)
- Restore loadCodeAssist API call to get user's actual project ID
- Improve debug logging for troubleshooting
- Fix tool normalization edge cases

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 04:24:24 +09:00
YeonGyu-Kim
7fe85a11da fix(session-recovery): handle API/storage index mismatch in empty message recovery
- Try both targetIndex and targetIndex-1 to handle system message offset
- Remove 'last assistant message' skip logic (API error means it's not final)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 04:16:42 +09:00
YeonGyu-Kim
8e62514eef docs: reorder features section and add background task documentation
- Reorder: Agents → Tools → Hooks → Claude Code Compatibility
- Add Background Task section under Tools with usage examples

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 01:34:48 +09:00
YeonGyu-Kim
dddb920061 docs: improve authentication setup guide with detailed provider instructions
Add step-by-step instructions for Anthropic, Google Gemini (Antigravity OAuth),
and OpenAI (Codex Auth) authentication setup.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 01:19:55 +09:00
YeonGyu-Kim
787e247a08 feat(hooks): add auto-update-checker for plugin version management
Checks npm registry for latest version on session.created, invalidates
cache and shows toast notification when update is available.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 01:18:39 +09:00
YeonGyu-Kim
6f229a86e3 fix(background-task): change default block to false and clarify system notification
- Change background_output default from block=true to block=false
- Add documentation about system auto-notification on task completion
- Clarify that block=false returns full status info, not empty

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 01:18:19 +09:00
YeonGyu-Kim
5fd59afacf feat(antigravity): add thought signature support for multi-turn conversations
Gemini 3 Pro requires thoughtSignature on function call blocks in
subsequent requests. This commit:

- Add thought-signature-store for session-based signature storage
- Extract signature from both streaming (SSE) and non-streaming responses
- Inject signature into functionCall parts on subsequent requests
- Maintain consistent sessionId per fetch instance

Debug logging available via ANTIGRAVITY_DEBUG=1

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 01:16:53 +09:00
YeonGyu-Kim
3d273ff853 fix(hooks): use last assistant message tokens instead of cumulative sum
Previously, token calculation accumulated ALL assistant messages' tokens,
causing incorrect usage display (e.g., 524.9%) after compaction.
Now uses only the last message's input tokens, which reflects the actual
current context window usage.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 01:02:21 +09:00
YeonGyu-Kim
6a565ee126 refactor: remove opencode-openai-codex-auth dependency and auth subpath
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
0bf853d9ef feat(antigravity-auth): separate google-auth module with dynamic port allocation
- Separate Google Antigravity auth to 'oh-my-opencode/google-auth' subpath
- 'oh-my-opencode/auth' now exports OpenAI Codex auth plugin
- Implement dynamic port allocation to avoid port conflicts
- Add userAgent, requestId, sessionId fields for Antigravity API compatibility
- Add debug logging for troubleshooting (ANTIGRAVITY_DEBUG=1)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
16393b2554 fix(antigravity-auth): apply auth headers in pass-through path
Pass-through path (non-string body) now preserves Authorization header.
This ensures authentication works even when request transformation is bypassed.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
d450c4f966 fix(antigravity-auth): address Oracle feedback - custom credentials, logging, docs
- Fix custom credentials to actually work in OAuth/refresh flows
  - oauth.ts: Add clientId/clientSecret parameters to buildAuthURL(), exchangeCode()
  - token.ts: Add clientId/clientSecret parameters to refreshAccessToken()
  - fetch.ts: Pass credentials to oauth/token functions
  - plugin.ts: Use closure cache for credentials, pass to all flows

- Unify console.* logging policy with ANTIGRAVITY_DEBUG guards
  - constants.ts: Document logging policy
  - tools.ts: Guard console.warn with ANTIGRAVITY_DEBUG
  - plugin.ts: Guard 4 console.error with ANTIGRAVITY_DEBUG

- Add explicit init.body type handling
  - fetch.ts: Check body type, pass-through non-string bodies
  - fetch.ts: Document body type assumption

- Document SSE buffering behavior
  - response.ts: Add warning that current implementation buffers
  - response.ts: Add TODO for future ReadableStream enhancement

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
4b3b581901 fix(background-task): handle SDK response structure compatibility
- Handle both SDK response patterns: .data wrapper vs direct array
- Add null/empty message checks for robustness
- Improve type safety with explicit interface definitions
- Prevent errors when messages array is undefined

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
af03a89e0c docs(ai-todolist): mark Phase 1-4 as complete (14 tasks) 2025-12-13 00:35:34 +09:00
YeonGyu-Kim
7bfca25958 feat(google-antigravity-auth): create auth plugin for Google models
- Implement createGoogleAntigravityAuthPlugin factory function
- Add OAuth method with PKCE for Google authentication
- Create custom fetch interceptor loader for Antigravity API
- Update auth.ts to export Google Antigravity plugin as default
- Update barrel export in antigravity/index.ts
- Add Google Antigravity auth location to AGENTS.md

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
d444e62b20 feat(antigravity-auth): add request/response transformation with tools and thinking
🤖 GENERATED WITH ASSISTANCE OF OpenCode
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
07e2e907c5 feat(antigravity-auth): add OAuth flow and token management
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
36b8576c78 feat(antigravity-auth): add types and constants foundation
🤖 Generated with assistance of OhMyOpenCode
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
5ba1d9f3c3 refactor(background-notification): remove chat.message handler
- Remove unused chat message notification handler
- Remove formatDuration and formatNotifications helpers
- Simplify to event-only handling
- Remove chat.message hook call from main plugin

Background task notifications now rely on event-based system only.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
efe37d4cfc refactor(auth): rename codex-auth to auth subpath export
- Rename src/codex-auth.ts → src/auth.ts
- Update package.json exports: ./codex-auth → ./auth
- Update build script to include auth.ts

Users can now use oh-my-opencode/auth as OpenAI auth plugin.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
c662f9c240 fix(session-recovery): handle empty content in user messages
Previously only checked assistant messages for empty content.
Now checks all messages except the final assistant message,
following Anthropic API rules.

Fixes: "messages.N: all messages must have non-empty content" error

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
12c0b7b6c0 refactor(omo-task): rename to call_omo_agent with run_in_background parameter
- Rename omo-task to call-omo-agent with mandatory run_in_background parameter
- Implement background mode using BackgroundManager (fire-and-forget abort)
- Implement sync mode with existing subagent logic
- Fix background_cancel: use fire-and-forget abort to prevent parent session interruption
- Add call_omo_agent to tool disable list in explore/librarian agents
- Add call_omo_agent to tool disable list in BackgroundManager

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
f007437991 feat(anthropic-auto-compact): add retry mechanism with exponential backoff
Implements retry logic with up to 5 attempts when compaction fails.
Uses exponential backoff strategy (2s → 4s → 8s → 16s → 30s).
Shows toast notifications for retry status and final failure.
Prevents infinite loops by clearing state after max attempts.

🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
f6bdc45fe7 feat(background-task): disable tools in child sessions
Background task tool이 child session을 생성할 때 background_task, background_output, background_cancel 도구를 자동으로 비활성화합니다. OpenCode Task tool 패턴과 동일하게 무한 재귀 호출을 방지합니다.

- manager.ts: promptAsync 호출 시 tools 설정 추가
- index.ts: 불필요한 agent 레벨 disable 설정 제거 (manager에서 처리)
- notification: tool calls 카운트 제거 (정확하게 트래킹되지 않음)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
01f935f074 refactor(background-task): unify background_result and background_status into background_output tool
- Merge background_status into background_output with block parameter
- Replace background_result references with background_output throughout codebase
- Update tool descriptions to reflect new unified API
- Remove background-tasks.json (memory-based only)
- Simplify notification messages and tool usage instructions
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
550322cb0c fix(background-agent): use promptAsync to avoid response parsing errors
session.prompt() fails due to response Zod validation.
promptAsync is fire-and-forget, no response parsing.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
14f785925c fix(background-agent): send notification to main session ID
TUI API sends to active session (could be subagent).
Use getMainSessionID() to explicitly target main session.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
6449a00f46 fix(background-agent): use TUI appendPrompt + submitPrompt for notification
session.prompt() fails with validation errors in background context.
Switch to TUI API which directly manipulates the main session input.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
3fd9e95579 fix(background-agent): simplify notification - remove status checks
Previous implementation had too many defensive checks that blocked
normal cases. Simplified to: Toast -> 200ms delay -> session.prompt()

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
78047dfd7d fix(background-agent): use session status check and prompt() for visible notification
- Replace promptAsync() with session.prompt() for visible TUI updates
- Add main session check to skip subagent sessions
- Add session status idle check before sending prompt
- Add 200ms debounce with re-check to prevent race conditions
- Fallback to pending queue when session is busy

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
9986841f9b fix(background-agent): force TUI update when notifying parent session
- Use `promptAsync` instead of `prompt` to avoid session state conflicts
- Use `tui.showToast` for immediate visible feedback
- Hack: Trigger `tui.submitPrompt` after message injection to force TUI refresh and trigger AI response
- Update `BackgroundManager` to accept `PluginInput` for directory access
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
b422e2f94f fix(background-agent): use session.prompt() instead of promptAsync()
prompt() waits for AI response, ensuring message is actually processed.
Added response logging to debug if message delivery works.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
e74cc82bcf fix(background-agent): use tui.showToast() for notification
promptAsync() doesn't show visible message to user.
Use tui.showToast() instead for immediate visible notification.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
ea46ba6c60 fix(background-agent): use session.idle event for completion detection
- Remove broken session.updated handler (Session has no status field)
- Add session.idle event handler for proper completion detection
- Remove all file persistence (persist/restore methods)
- Add comprehensive logging for debugging
- Dual detection: event-based (session.idle) + polling (session.status API)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
d67f97158a fix(background-agent): use session.status() API for idle detection
session.get() doesn't return status field - it was always undefined.
Now using session.status() API which returns { type: 'idle' | 'busy' | 'retry' }

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
e140dc74c6 refactor(background-task): remove session_id parameter, use toolContext
Session ID is now automatically detected from toolContext.sessionID

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
24a7f333a2 refactor(background-agent): remove file persistence, use memory-only
- Remove background_tasks.json persistence (race condition with multiple instances)
- Pure memory-based task management
- Add logging for promptAsync errors
- Remove unused persist/restore methods

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
80cfe87390 fix(background-agent): use promptAsync to wake parent session
- Change prompt() to promptAsync() for parent session notification
- Only mark 404 errors as permanent task failure
- Add defensive progress initialization

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
5733291a0f fix(background-agent): notify parent session when task completes
- Add notifyParentSession() to send message to parent session via prompt()
- Agent receives completion notification immediately, not waiting for next chat.message
- Includes task ID, description, duration, tool calls in notification

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
b5d56246f6 fix(background-agent): add polling mechanism for child session tracking
- Replace unreliable event-based tracking with 2-second polling
- Use SDK session.get() to detect completion (status === idle)
- Use SDK session.messages() to count tool_use parts for progress
- Auto-start polling on launch, auto-stop when no running tasks
- Resume polling on restore if running tasks exist

Fixes: Child session events not reaching plugin event handler

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
245acdabad fix(background-agent): address Oracle review feedback
- Remove unused storage.ts (dead code, runtime inconsistency)
- Change persist() to sync void (debounce semantics clarity)
- Add type guards in handleEvent() for event safety
- Remove unused 'pending' from BackgroundTaskStatus

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
49fb046363 feat(background-agent): integrate into main plugin
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
ce6a09b891 feat(background-notification): add completion notification hook
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
2fad28d552 feat(background-task): add 4 background task tools
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
698cdb6744 feat(background-agent): add BackgroundManager with persistence layer
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
9ec20d4cb2 docs(readme): document subagent orchestration with omo_task tool
Add comprehensive documentation about omo_task tool feature in both English and Korean READMEs.

- Document omo_task tool purpose: spawn explore/librarian as subagents
- Explain use case: agents can delegate specialized tasks to subagents
- Note recursion prevention: explore and librarian cannot use omo_task directly
- Add to Agents section under Tools for discoverability

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
9ba0015530 feat(plugin): integrate omo_task tool and prevent recursion in subagents
Register omo_task tool in main plugin and disable it for explore/librarian agents to prevent infinite recursion.

- Export createOmoTask from src/tools/index.ts
- Initialize omo_task in main plugin with ctx
- Spread omo_task into builtinTools export
- Add recursion prevention: disable omo_task tool for explore agent config
- Add recursion prevention: disable omo_task tool for librarian agent config

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
f6dd6e3c7f feat(omo-task): add agent orchestration tool for subagent spawning
Implement omo_task tool that allows main agents (oracle, frontend-ui-ux-engineer, etc.) to spawn explore or librarian as subagents.

- Add constants: ALLOWED_AGENTS, TASK_TOOL_DESCRIPTION_TEMPLATE
- Add types: AllowedAgentType, OmoTaskArgs, OmoTaskResult
- Implement createOmoTask function with session management
- Support both new session creation and existing session continuation
- Include proper error handling and logging

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
595f4b6dd5 docs(readme): document Rules Injector feature
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
4891a0e6f2 Revert "feat(hooks): disable redundant inject hooks by default in Claude Code compatibility layer"
This reverts commit 8e0a4fedbffebdd67d02a52612b5315fd406b036.
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
dd645994b2 feat(hooks): disable redundant inject hooks by default in Claude Code compatibility layer
Automatically disables these Claude Code hooks that duplicate oh-my-opencode functionality:
- inject_rules.py (replaced by rules-injector hook)
- inject_readme.py (replaced by directory-readme-injector hook)
- inject_knowledge.py (replaced by directory-agents-injector hook)
- remind*rules*.py (replaced by rules-injector hook)

Users can override via opencode-cc-plugin.json if needed.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
fcdfcd3186 feat(hooks): add rules-injector hook for .cursor/rules and .claude/rules support
Implements adaptive rule injection similar to Claude Code's rule system:
- Searches .cursor/rules and .claude/rules directories recursively
- Supports YAML frontmatter with globs, paths, alwaysApply, description
- Adaptive project root detection (finds markers even outside ctx.directory)
- Symlink duplicate detection via realpath comparison
- Content hash deduplication (SHA-256) to avoid re-injecting same rules
- picomatch-based glob pattern matching for file-specific rules

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
YeonGyu-Kim
c12f73f774 fix(hooks): improve thinking block order recovery with error-based index targeting
- Add findMessageByIndexNeedingThinking for precise message targeting
- Detect "expected X found Y" error pattern for thinking block order
- Remove isLastMessage skip - recovery now handles final assistant messages
- Simplify orphan detection: any non-thinking first part is orphan

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 00:35:34 +09:00
github-actions[bot]
06e0285b9c release: v0.3.4 2025-12-12 13:26:05 +00:00
Srinivasa Babu B
64138ee88f Refactor AgentOverridesSchema to use object structure (#24) 2025-12-12 15:38:39 +09:00
Nguyen Quang Huy
359340de65 feat: add claude_code config object to toggle Claude Code compatibility features (#23) 2025-12-12 15:23:50 +09:00
github-actions[bot]
3eb88aa861 release: v0.3.3 2025-12-12 01:39:26 +00:00
YeonGyu-Kim
652f343c95 feat(agents): enhance librarian and explore prompts with parallel execution and evidence-based citations (#21)
* feat(agents): enhance librarian and explore prompts with parallel execution and evidence-based citations

Librarian agent enhancements:
- Add mandatory 5+ parallel tool execution requirement
- Add WebSearch integration for latest information
- Add repository cloning to /tmp for deep source analysis
- Require GitHub permalinks for all code citations
- Add evidence-based reasoning with specific code references
- Enhanced gh CLI usage with permalink construction

Explore agent enhancements:
- Add mandatory 3+ parallel tool execution requirement
- Extensive Git CLI integration for repository analysis
- Add git log, git blame, git diff commands for exploration
- Add parallel execution examples and best practices

* feat(agents): add LSP and AST-grep tools to librarian and explore prompts

Librarian agent:
- Added LSP tools section (lsp_hover, lsp_goto_definition, lsp_find_references, etc.)
- Added AST-grep section with pattern examples for structural code search
- Updated parallel execution examples to include LSP and AST-grep tools
- Added guidance on when to use AST-grep vs Grep vs LSP

Explore agent:
- Added LSP tools section for semantic code analysis
- Added AST-grep section with examples for TypeScript/React patterns
- Updated parallel execution examples to include 6 tools
- Added tool selection guidance for LSP and AST-grep

* fix(agents): remove explore agent references from librarian prompt

Subagents cannot call other agents, so replaced all Explore agent
references with direct tool usage (Glob, Grep, ast_grep_search).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 09:16:57 +09:00
YeonGyu-Kim
9ba41558de feat(config): add cross-platform user-level config support (#20) 2025-12-12 08:53:27 +09:00
YeonGyu-Kim
50727171a6 feat(agents): upgrade oracle model from GPT-5.1 to GPT-5.2 (#19) 2025-12-12 08:52:18 +09:00
github-actions[bot]
14ff86c547 release: v0.3.2 2025-12-11 23:31:22 +00:00
Nguyen Quang Huy
e4036185f0 fix: load config from user-level ~/.config/opencode/oh-my-opencode.json (#17) 2025-12-12 08:29:32 +09:00
YeonGyu-Kim
d34154bc68 feat(skill): align with opencode-skills approach
- Add Zod schema validation following Anthropic Agent Skills Spec v1.0
- Include basePath in skill output for path resolution
- Simplify tool description and output format
- Add validation error logging for invalid skills

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-11 10:14:20 +09:00
YeonGyu-Kim
9e00be91af feat(hooks): add directory README.md injector (#15)
Implements README.md injection similar to existing AGENTS.md injector.
Automatically injects README.md contents when reading files, searching
upward from file directory to project root.

Closes #14

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-11 10:13:04 +09:00
github-actions[bot]
40d4673201 release: v0.3.1 2025-12-10 14:44:44 +00:00
YeonGyu-Kim
cf33fc5da1 docs(readme): sync English version with Korean improvements on setup and configuration clarity 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
407786978a chore: remove test files (test-rule.yml, test.js) 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
15454f1d81 chore: remove test files and temporary notepad 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
56160d17f8 docs(readme.ko): improve clarity on setup configuration paths and MCP/LSP explanations 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
61bbbcb577 feat(hooks): integrate anthropic-auto-compact hook for automatic context summarization
Enables automatic session summarization when Anthropic token limits are exceeded.
The hook detects token limit errors and triggers compact operation on session idle.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-10 23:43:35 +09:00
YeonGyu-Kim
adabace02d improve(hooks): refine context window reminder message for better clarity and guidance 2025-12-10 15:45:45 +09:00
YeonGyu-Kim
41f93c9f8b docs(readme): add warning for LLM agents on oh-my-opencode.json setup and sync English tone with Korean version 2025-12-10 15:45:45 +09:00
YeonGyu-Kim
8102d178cb fix(hooks): fix TODO continuation abort handling with timer-based approach
Replace blocking await with non-blocking timer scheduling to handle race
condition between session.idle and session.error events. When ESC abort
occurs, session.error immediately cancels the pending timer, preventing
unwanted continuation prompts.

Changes:
- Add pendingTimers Map to track scheduled continuation checks
- Cancel timer on session.error (especially abort cases)
- Cancel timer on message.updated and session.deleted for cleanup
- Reduce delay to 200ms for faster response
- Maintain existing Set-based flag logic for compatibility

This fixes the issue where ESC abort would not prevent continuation
prompts due to event ordering (idle before error)
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
4f019f8fe5 fix(hooks): improve session recovery for empty content messages
- Extract message index from Anthropic error messages (messages.N format)
- Sort messages by time.created instead of id for accurate ordering
- Remove last message skip logic that prevented recovery
- Prioritize recovery targets: index-matched > failedMsg > all empty
- Add error logging for debugging recovery failures

Fixes issue where 'messages.83: all messages must have non-empty content' errors were not being recovered properly due to incorrect message ordering and overly restrictive filtering.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
7b19177c8a Revert "fix(hooks): improve TODO continuation race condition handling with state machine pattern"
This reverts commit e59b0be6cc380a3750e2d56c4c7ba553feef2c40.
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
e8f59cbbf8 fix(hooks): improve TODO continuation race condition handling with state machine pattern
- Replace multiple Set-based tracking with explicit SessionStatus state machine
- Implement setTimeout+clearTimeout pattern for robust race condition handling
- SessionStatus tracks: idle → continuation-sent or aborted states
- Increase grace period to 500ms to accommodate event ordering delays
- Add cleanupSession utility for proper resource cleanup

This addresses ESC abort not canceling continuation prompts when session.idle
arrives before session.error event, which can occur due to async event processing
in OpenCode plugin system
2025-12-10 15:45:45 +09:00
github-actions[bot]
2d23a81926 release: v0.3.0 2025-12-09 12:51:28 +00:00
YeonGyu-Kim
31cb8616c2 chore: bump version to 0.2.0
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 21:47:21 +09:00
YeonGyu-Kim
1932257f82 docs(readme): add comprehensive Claude Code compatibility documentation
- Add new 'Claude Code Compatibility' section to both README.md and README.ko.md
- Document hooks integration (PreToolUse, PostToolUse, UserPromptSubmit, Stop)
- Document configuration loaders (Command, Skill, Agent, MCP loaders)
- Document data storage (Todo management, Transcript logging)
- Simplify 'Other Features' section by moving loaders to new section
- Clean up temporary planning files in local-ignore/

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 21:47:01 +09:00
YeonGyu-Kim
5a793bb526 fix(hooks): align Claude Code hooks with opencode-cc-plugin reference
100% port verification via Oracle agent parallel checks:
- PreToolUse: recordToolUse(), isHookDisabled(), Object.assign(), error message
- PostToolUse: recordToolResult(), isHookDisabled(), permissionMode, title field
- Stop: isHookDisabled(), parentSessionId, error/interrupt state tracking
- UserPromptSubmit: interrupt checks, recordUserMessage(), log messages

All four hooks now match opencode-cc-plugin/src/plugin/*.ts exactly.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 19:00:01 +09:00
YeonGyu-Kim
2ec351d0d8 feat(hooks): implement UserPromptSubmit with chat.message hook and injectHookMessage
- Add chat.message handler to createClaudeCodeHooksHook factory
- Integrate executeUserPromptSubmitHooks() for user prompt processing
- Use injectHookMessage() for file system based message injection
- Add sessionFirstMessageProcessed tracking for title generation skip
- Register chat.message hook in plugin entry point

This completes 100% port of Claude Code hooks from opencode-cc-plugin.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:20:13 +09:00
YeonGyu-Kim
441fc1a219 feat(hooks): integrate Claude Code hooks with plugin system
- Create factory function createClaudeCodeHooksHook()
- Wire tool.execute.before → executePreToolUseHooks
- Wire tool.execute.after → executePostToolUseHooks
- Wire event (session.idle) → executeStopHooks
- Register hooks in src/index.ts
- Claude hooks execute first in handler chain

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:10:30 +09:00
YeonGyu-Kim
bd67419d1d feat(features): add hook message injector
- Port hook-message-injector from opencode-cc-plugin (4 files)
- constants.ts: XDG-based path definitions (MESSAGE_STORAGE, PART_STORAGE)
- types.ts: MessageMeta, OriginalMessageContext, TextPart interfaces
- injector.ts: injectHookMessage() implementation with message/part storage
- index.ts: Barrel export
- Self-contained module with no import path changes needed
- Preserves XDG_DATA_HOME environment variable support
- Preserves message fallback logic for incomplete originalMessage

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:02:45 +09:00
YeonGyu-Kim
dca98121ac feat(hooks): add UserPromptSubmit and Stop executors
- Port user-prompt-submit.ts from opencode-cc-plugin (118 lines)
- Port stop.ts from opencode-cc-plugin (119 lines)
- Preserve recursion prevention logic (<user-prompt-submit-hook> tags)
- Preserve inject_prompt support (message injection, stop prompt injection)
- Preserve stopHookActiveState management (per-session state)
- Import path adjustments: ../types → ./types, ../../config → ./plugin-config
- All exit code handling preserved (exitCode 2 → block, etc.)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:00:16 +09:00
YeonGyu-Kim
3fcfedcec0 feat(hooks): add PostToolUse hook executor
- Port post-tool-use.ts from opencode-cc-plugin (200 lines)
- Implement executePostToolUseHooks() with full transcript support
- Include temp file cleanup in finally block
- Preserve all exit code handling and output fields
- Update notepad.md with Task 5 completion log

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:55:06 +09:00
YeonGyu-Kim
530c4d63d5 feat(hooks): add PreToolUse hook executor
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:48:28 +09:00
YeonGyu-Kim
e0b43380cc feat(hooks): add Claude hooks config, transcript, and todo
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:41:39 +09:00
YeonGyu-Kim
a27cac96d5 feat(hooks): add Claude Code hooks type definitions
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:36:12 +09:00
YeonGyu-Kim
fef7f4ca03 feat(shared): add Claude hooks command executor and utilities
- Add snake-case.ts: objectToSnakeCase, objectToCamelCase utilities
- Add tool-name.ts: transformToolName with PascalCase conversion
- Add pattern-matcher.ts: findMatchingHooks for hook config matching
- Add hook-disabled.ts: isHookDisabled for hook config validation
- Add temporary stub types at src/hooks/claude-code-hooks/types.ts
- Export all new utilities from src/shared/index.ts

Stub types will be replaced with full implementation in Task 1.
Import paths adjusted from opencode-cc-plugin structure to oh-my-opencode.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:31:15 +09:00
YeonGyu-Kim
e147be7ed4 feat: update comment-checker to v0.5.0
BREAKING CHANGE: Docstrings are now detected as code smell.
See: https://github.com/code-yeongyu/go-claude-code-comment-checker/releases/tag/v0.5.0

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
124c3b3e8f docs: fix documentation inconsistencies identified by Oracle
- Add source path annotations to Command/Skill/Agent/MCP Loaders
- Add Session State feature documentation
- MCP Loader paths verified (match loader.ts: ~/.claude/.mcp.json, ./.mcp.json, ./.claude/.mcp.json)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
5678e0bac6 docs(readme): document new claude-code feature loaders
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
207435450c refactor(index): integrate session-state module and remove local variables
- Add imports: agent-loader, mcp-loader, session-state, logger
- Remove local session variables (mainSessionID, currentSessionID, currentSessionTitle)
- Use setter/getter functions from session-state module
- Add agent loading in config hook (loadUserAgents, loadProjectAgents)
- Add MCP loading in config hook (loadMcpConfigs)
- Replace console.error with logger

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
376bf363af feat(features): add claude-code-agent-loader, mcp-loader, session-state 2025-12-09 16:59:22 +09:00
YeonGyu-Kim
c7a65af475 refactor(features): rename command-loader and skill-loader with claude-code prefix 2025-12-09 16:59:22 +09:00
YeonGyu-Kim
8e7447deee feat(shared): add file-based logger utility 2025-12-09 16:59:22 +09:00
YeonGyu-Kim
15a748b817 docs: add missing hooks and features to README
Add 4 missing hooks documentation:
- Think Mode: auto-detect deep thinking requests
- Anthropic Auto Compact: auto-compact context
- Empty Task Response Detector: handle empty responses
- Grep Output Truncator: prevent output overflow

Add 2 missing features documentation:
- Command Loader: load commands from multiple paths
- Skill Loader: load skills as executable commands

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
c0e0dc1f95 feat: integrate command/skill loaders and think-mode hook in main entry
- Add loadCommands() and loadSkills() to config
- Register think-mode hook for UserPromptSubmit event

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
7059407cbc feat(hooks): export createThinkModeHook from index
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
589cf60252 feat(hooks): add think-mode hook for automatic model switching
Detects thinking keywords (ultrathink, deepthink, etc.) and switches
to thinking-capable models automatically.

Supports model patterns:
- claude-sonnet-4-0 -> claude-sonnet-4-0-max-thinking
- claude-sonnet-4-20250514 -> claude-sonnet-4-20250514-max-thinking

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
e5cdaa5192 feat(tools): export slashcommand and skill tools from index
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
340eb30147 feat(tools): add skill tool for invoking skills in conversation
Provides 'skill' tool that invokes skills loaded by skill-loader.
Skills expand into detailed instructions when invoked.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
e72b927ccb feat(tools): add slashcommand tool for executing slash commands
Provides 'slashcommand' tool that executes commands loaded by command-loader.
Handles shell injection and file reference resolution.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
3c6ffe5d9c feat(skill-loader): add skill loader that converts skills to commands
Skills are loaded from:
- ~/.claude/skills/ (user scope)
- .claude/skills/ (project scope)

Each skill directory contains SKILL.md with frontmatter metadata.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
938a3709e1 feat(command-loader): add slash command loader from multiple paths
Load commands from 4 directory scopes:
- .opencode/command/ (opencode-project)
- .claude/commands/ (project)
- ~/.config/opencode/command/ (opencode)
- ~/.claude/commands/ (user)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
47f218e33f feat(shared): add shared utilities for command and skill loading
- frontmatter.ts: YAML frontmatter parser
- file-reference-resolver.ts: resolve @file references in markdown
- command-executor.ts: execute shell commands in markdown
- model-sanitizer.ts: sanitize model names for OpenCode compatibility

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
github-actions[bot]
e07a25baa4 release: v0.1.31 2025-12-09 05:42:03 +00:00
YeonGyu-Kim
08ede0a28d deps: bump @code-yeongyu/comment-checker to 0.4.4
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 14:41:07 +09:00
github-actions[bot]
a711d58289 release: v0.1.30 2025-12-09 02:50:19 +00:00
YeonGyu-Kim
431ec14991 docs: update notepad with cleanup task logs
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:49:17 +09:00
YeonGyu-Kim
62cae8114d refactor(comment-checker): simplify binary path resolution and add separator warning
- Remove platform-specific package lookup logic
- Remove homebrew path resolution
- Add code smell warning for comment separators

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:49:11 +09:00
YeonGyu-Kim
e6eafe267a refactor(ast-grep): remove NAPI-based tools
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:49:00 +09:00
YeonGyu-Kim
e4ef832405 feat(hooks): add anthropic-auto-compact hook
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:48:53 +09:00
YeonGyu-Kim
ef6d67645e refactor(hooks): remove pulse-monitor hook
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:48:46 +09:00
YeonGyu-Kim
227d93f106 docs(readme-ko): clarify nested AGENTS.md injection behavior with example
Added directory tree example showing how multiple AGENTS.md files are
collected and injected in hierarchical order when reading nested files.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 10:38:05 +09:00
github-actions[bot]
edff922afb release: v0.1.29 2025-12-09 01:37:46 +00:00
YeonGyu-Kim
45bdcf3580 docs(readme): clarify nested AGENTS.md injection behavior with example
Added directory tree example showing how multiple AGENTS.md files are
collected and injected in hierarchical order when reading nested files.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 10:36:34 +09:00
YeonGyu-Kim
b07dd22093 fix(pulse-monitor): reset heartbeat after tool execution to prevent false positives
Tools can take arbitrary time, so we need a fresh baseline after execution.
Previously, lastHeartbeat wasn't updated after tool.execute.after, causing
stalled detection to trigger immediately after long-running tools.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 10:36:34 +09:00
YeonGyu-Kim
c7d29fea48 refactor(mcp): remove unused builtinMcps export
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 10:36:34 +09:00
YeonGyu-Kim
55675497a5 refactor(session-recovery): remove unused ThinkingPart interface and fallbackRevertStrategy function
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 10:36:34 +09:00
YeonGyu-Kim
ae2d347d81 refactor(lsp): remove unused formatWorkspaceEdit import
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 10:36:34 +09:00
github-actions[bot]
2683de825a release: v0.1.28 2025-12-08 08:52:28 +00:00
YeonGyu-Kim
0b5c8250ca fix(pulse-monitor): prevent false positive stalled detection after tool execution
Remove forced monitoring restart in tool.execute.after to avoid false positive
stalled session detection when LLM legitimately completes response after tool run.
Monitoring now resumes naturally on next session/message event.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-08 17:51:06 +09:00
github-actions[bot]
66fcd8570b release: v0.1.27 2025-12-08 08:01:03 +00:00
YeonGyu-Kim
5cd3f0cbf2 docs: add Directory AGENTS.md Injector hook documentation
🤖 GENERATED BY [OPENCODE](https://opencode.ai/)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
9a9512b705 test: add test directory with sample files 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
6ece7476ef feat(hooks): add empty-task-response-detector hook 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
9ed23d4037 feat(hooks): implement directory-agents-injector hook 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
79b791117a fix(session-recovery): improve error message extraction 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
4e328a937c feat(hooks): integrate directory-agents-injector hook into plugin pipeline
- Add directoryAgentsInjector to plugin event handlers
- Wire up tool.execute.after hook for directory agents injection
- Fix: Format src/index.ts with consistent semicolon style
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
a500f0c9ad export(hooks): add directory-agents-injector hook to public API
- Export createDirectoryAgentsInjectorHook from hooks index
- Fix: Formatting (add semicolons to match code style)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
16806da615 refactor(session-recovery): process entire message history for empty/thinking block recovery
- Scan all non-final assistant messages for empty content, orphan thinking blocks, and disabled thinking
- Add storage utility functions: findMessagesWithThinkingBlocks, findMessagesWithOrphanThinking, stripThinkingParts, prependThinkingPart
- Fix: Previously only processed single failed message, now handles multiple broken messages in history
- Improve: Use filesystem-based recovery instead of unreliable SDK APIs
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
c5f651c0a9 refactor(hooks): remove grep-blocker (grep tool now overrides built-in)
The grep tool now properly overrides OpenCode's built-in grep,
making the blocker hook unnecessary.

Generated by [OpenCode](https://opencode.ai/)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
ed3d7a55f4 feat(tools): add glob tool with timeout protection
- Override OpenCode's built-in glob with 60s timeout
- Kill process on expiration to prevent indefinite hanging
- Reuse grep's CLI resolver for ripgrep detection

Generated by [OpenCode](https://opencode.ai/)
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
b77dd2fcdf refactor(tools): rename safe-grep to grep with override capability 2025-12-08 17:00:02 +09:00
YeonGyu-Kim
64b3564760 refactor(session-recovery): extract storage utilities to separate module
Split session-recovery.ts into modular structure:
- types.ts: SDK-aligned type definitions
- constants.ts: storage paths and part type sets
- storage.ts: reusable read/write operations
- index.ts: main recovery hook logic
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
0df7e9b10b fix(session-recovery): recognize 'tool' type as valid content
OpenCode storage uses 'tool' type for tool calls, but the hasContent
check only recognized 'tool_use' (Anthropic API format). This caused
messages with tool calls to be incorrectly identified as empty.
2025-12-08 17:00:02 +09:00
YeonGyu-Kim
aa35f2eab6 fix(session-recovery): detect empty messages with zero parts
Previously, findEmptyContentMessageFromStorage only detected messages
with parts.length > 0 that had no content. This missed the case where
a message has zero parts entirely, causing infinite recovery loops.
2025-12-08 17:00:02 +09:00
github-actions[bot]
64db980803 release: v0.1.26 2025-12-08 00:41:34 +00:00
YeonGyu-Kim
b86346a79d fix(lsp): add Push model support and fix JSON LSP diagnostics
- Add diagnosticsStore to capture Push model notifications
- Handle textDocument/publishDiagnostics notifications in processBuffer
- Fix workspace/configuration response for JSON LSP validation
- Add missing language mappings (json, html, css, sh, fish, md, tf)
- diagnostics() now tries Pull first, falls back to Push store
2025-12-08 09:38:00 +09:00
YeonGyu-Kim
4debb57402 feat(hooks): add pulse-monitor for token stall detection and auto-recovery
- Detect token stalls via message.part.updated heartbeat monitoring
- Support thinking/reasoning block detection with extended timeout
- Auto-recover: abort + 'continue' prompt on 5min stall
- Pause monitoring during tool execution
2025-12-06 11:17:55 +09:00
YeonGyu-Kim
a763db61cf improve(hooks): refine todo-continuation-enforcer message tone and status format 2025-12-06 10:54:36 +09:00
YeonGyu-Kim
341e5a959d feat(hooks): add grep-output-truncator for context-aware output limiting 2025-12-06 10:54:22 +09:00
YeonGyu-Kim
bac304c035 docs: add explicit agent invocation examples to README 2025-12-05 23:48:11 +09:00
YeonGyu-Kim
1aaeefac0e docs: enhance LSP tools description in README 2025-12-05 23:27:07 +09:00
github-actions[bot]
dda7b4f56d release: v0.1.25 2025-12-05 14:25:22 +00:00
YeonGyu-Kim
a287e59262 feat(session-recovery): add filesystem-based empty content recovery
- Replace API-based recovery with direct JSON file editing for empty content messages
- Add cross-platform storage path support via xdg-basedir (Linux/macOS/Windows)
- Inject '(interrupted)' text part to fix messages with only thinking/meta blocks
- Update README docs with detailed session recovery scenarios
2025-12-05 23:24:20 +09:00
github-actions[bot]
80fe3ae612 release: v0.1.24 2025-12-05 13:53:30 +00:00
YeonGyu-Kim
b045f6918e feat(lsp): add result limits to prevent token overflow
- Add DEFAULT_MAX_REFERENCES, DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_DIAGNOSTICS (200 each)
- Apply limits to lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics
- Show truncation warning when results exceed limits
2025-12-05 22:52:33 +09:00
YeonGyu-Kim
725ec9b91d feat(ast-grep): add safety limits to prevent token overflow
- Add timeout (5min), output limit (1MB), match limit (500)
- Add SgResult type with truncation info
- Update formatSearchResult/formatReplaceResult for truncation display
- cli.ts: timeout + output truncation + graceful JSON recovery
2025-12-05 22:52:33 +09:00
github-actions[bot]
1f717a76be release: v0.1.23 2025-12-05 13:19:23 +00:00
YeonGyu-Kim
3bcb869a5d fix(ast-grep): add isValidBinary check to all path resolutions
- Check file size >10KB to filter out placeholder files
- Check cached binary first
- Then npm package paths with validation
- Homebrew paths as last resort
- Fixes SIGTRAP/ENOEXEC from invalid binaries
2025-12-05 22:18:17 +09:00
github-actions[bot]
54e13e4330 release: v0.1.22 2025-12-05 13:13:29 +00:00
YeonGyu-Kim
1780e2971d refactor(ast-grep): simplify binary resolution, rely on auto-download
- Remove hardcoded homebrew paths
- Remove npm package path resolution (prone to placeholder issues)
- Only check cached binary (~/.cache/oh-my-opencode/bin/sg)
- If not found, cli.ts will auto-download from GitHub releases

The download logic in cli.ts handles all cases properly.
2025-12-05 22:12:12 +09:00
github-actions[bot]
ded97701b8 release: v0.1.21 2025-12-05 13:04:11 +00:00
YeonGyu-Kim
316cdc1a62 fix(ast-grep): validate binary before using, prioritize homebrew path
- Add isValidBinary() check: file must be >10KB (placeholder files are ~100 bytes)
- Check homebrew paths first on macOS (most reliable)
- Check cached binary second
- npm package paths last (prone to placeholder issues)

Fixes ENOEXEC error when @ast-grep/cli has placeholder instead of real binary
2025-12-05 22:03:05 +09:00
YeonGyu-Kim
f19cd8fc71 improve(ast-grep): better Python pattern hints
- Show exact pattern without colon when pattern ends with ':'
- More actionable hint message
2025-12-05 21:57:58 +09:00
github-actions[bot]
181194ae3c release: v0.1.19 2025-12-05 12:00:31 +00:00
YeonGyu-Kim
b8f5599e61 feat(ast-grep): add helpful hints for incomplete Python patterns
- Show hints when Python class/function patterns return empty results
- Detect patterns ending with ':' that need body (class :, def ():)
- Removed validation that could cause false positives
- Hints only appear on empty results, not on successful matches
2025-12-05 20:59:05 +09:00
github-actions[bot]
ea2b09ebb9 release: v0.1.18 2025-12-05 11:07:21 +00:00
YeonGyu-Kim
143dd8aaa9 fix(session-recovery): improve error detection and add continue prompt
- Enhance error type detection for thinking block order issues
- Add continue prompt after successful session recovery
- Improve error message matching logic
2025-12-05 20:01:47 +09:00
YeonGyu-Kim
36169c83fb feat(ast-grep): add CLI path resolution and auto-download functionality
- Add automatic CLI binary path detection and resolution
- Implement lazy binary download with caching
- Add environment check utilities for CLI and NAPI availability
- Improve error handling and fallback mechanisms
- Export new utilities from index.ts
2025-12-05 20:01:35 +09:00
github-actions[bot]
bf9f033635 release: v0.1.17 2025-12-05 07:26:35 +00:00
YeonGyu-Kim
de2f9dfc86 chore: move schema to assets/, add dist/ to gitignore 2025-12-05 16:24:54 +09:00
YeonGyu-Kim
5f5218ccae docs: fix Agents config section - remove incorrect OpenCode link, add correct options 2025-12-05 16:17:00 +09:00
YeonGyu-Kim
41d3817910 docs: reorganize Configuration section to bottom, add LSP config docs 2025-12-05 16:13:25 +09:00
YeonGyu-Kim
a119429ebe docs: remove MCP config from Agent section (already in top-level Config) 2025-12-05 16:01:19 +09:00
github-actions[bot]
9bed2dd79a release: v0.1.16 2025-12-05 06:55:27 +00:00
YeonGyu-Kim
4c1d534093 docs: simplify agent configuration, add OpenCode doc links 2025-12-05 15:52:04 +09:00
github-actions[bot]
a04234eaab release: v0.1.15 2025-12-05 06:33:29 +00:00
YeonGyu-Kim
6d0e4c49c2 docs: add deployment workflow documentation to AGENTS.md 2025-12-05 15:32:09 +09:00
YeonGyu-Kim
8f39575264 fix(session-recovery): handle empty content from interrupted reasoning (#6)
* fix(publish): make git operations idempotent

- Check for staged changes before commit
- Check if tag exists before creating
- Check if release exists before creating

* fix(session-recovery): handle empty content from interrupted reasoning

- Add THINKING_TYPES set including 'reasoning' type (OpenCode's thinking)
- Add hasNonEmptyOutput() to detect messages with only thinking/meta parts
- Add findEmptyContentMessage() to scan all messages for empty content
- Handle step-start/step-finish meta parts in empty content detection
- Patch interrupted messages with '(interrupted)' text before falling back to revert
2025-12-05 15:28:22 +09:00
YeonGyu-Kim
2464473731 fix(ast-grep): add validation for incomplete function declaration patterns (#5)
* fix(publish): make git operations idempotent

- Check for staged changes before commit
- Check if tag exists before creating
- Check if release exists before creating

* fix(ast-grep): add validation for incomplete function declaration patterns

- Add validatePatternForCli function to detect incomplete patterns like
  'export async function $METHOD' (missing params and body)
- Only validates JS/TS languages (javascript, typescript, tsx)
- Provides helpful error message with correct pattern examples
- Update tool description to clarify complete AST nodes required

This fixes the issue where incomplete patterns would fail silently
with no results instead of providing actionable feedback.
2025-12-05 15:17:42 +09:00
YeonGyu-Kim
1b0a8adb2b refactor(comment-checker): remove WASM fallback, use CLI-only with lazy download
- Remove tree-sitter-wasms and web-tree-sitter dependencies
- Delete detector.ts (320 lines of WASM implementation)
- Add downloader.ts for lazy binary download from GitHub Releases
- Simplify index.ts to CLI-only mode
- Cache binary at ~/.cache/oh-my-opencode/bin/
- Fall back to 'Comment checking disabled' when binary unavailable
2025-12-05 14:51:47 +09:00
YeonGyu-Kim
4b7a4b04d0 docs: restructure README for better readability and UX (#4)
- Move TL;DR section before Installation for quick overview
- Wrap 'For LLM Agents' section in collapsible details tag
- Simplify Configuration section (remove verbose schema explanations)
- Move model override example to Features > Agents section
- Add context7 MCP to Korean README
2025-12-05 14:43:52 +09:00
YeonGyu-Kim
f6f2fad1fc fix: correct OpenCode docs link (404 -> valid URL) (#3)
- Change https://opencode.ai/docs/installation/ to https://opencode.ai/docs
- The /installation/ path returns 404, main docs page has Install section
2025-12-05 14:28:44 +09:00
YeonGyu-Kim
3ca9693de0 Merge pull request #2 from code-yeongyu/docs-schema-and-readme-config
docs: add comprehensive configuration documentation
2025-12-05 14:28:32 +09:00
YeonGyu-Kim
e2078763a6 fix: resolve merge conflict with master (include both config and LLM agent guide) 2025-12-05 14:28:11 +09:00
YeonGyu-Kim
5121d0eed2 docs: add comprehensive configuration documentation and include schema.json
- Add dist/oh-my-opencode.schema.json to repository (fix gitignore pattern)
- Document all configuration options in README.md (English)
- Add Configuration section to README.ko.md (Korean) - was completely missing
- Include agent configuration examples (Anthropic-only setup)
- Add MCP/Agent disable instructions
- Document permission options for agents
2025-12-05 14:24:12 +09:00
YeonGyu-Kim
973caf9b7d docs: add 'For LLM Agents' setup guide section (#1)
- Add step-by-step guide for LLM agents to setup oh-my-opencode
- Include OpenCode installation check with link to official docs
- Include oh-my-opencode plugin configuration script
- Update Table of Contents for both EN/KO README files
2025-12-05 14:17:48 +09:00
YeonGyu-Kim
b277d96de8 fix: remove Windows ARM64 support 2025-12-05 13:47:21 +09:00
YeonGyu-Kim
2c223d96a1 feat(comment-checker): add Windows support 2025-12-05 13:44:52 +09:00
YeonGyu-Kim
695aeafb86 fix(comment-checker): change npm scope to @code-yeongyu 2025-12-05 11:40:16 +09:00
YeonGyu-Kim
812c544bfa feat(comment-checker): add native CLI support with WASM fallback
- Add cli.ts for native binary resolution and spawning
- Update index.ts to use CLI when available, WASM as fallback
- Add Edit/MultiEdit support to types.ts for proper CLI input
2025-12-05 11:31:52 +09:00
github-actions[bot]
edf0e7d946 release: v0.1.12 2025-12-05 02:10:36 +00:00
YeonGyu-Kim
baa7fadab1 fix(comment-checker): use runtime wasm path resolution instead of require.resolve
require.resolve() was evaluated at build time, hardcoding CI paths.
Now uses import.meta.resolve() at runtime to find wasm files.
2025-12-05 11:09:41 +09:00
github-actions[bot]
a06bbeb9ee release: v0.1.11 2025-12-05 02:05:44 +00:00
YeonGyu-Kim
f3a92db203 chore: bump version to 0.1.12 2025-12-05 11:02:43 +09:00
YeonGyu-Kim
fd6e230889 perf(comment-checker): add LSP-style background language warming
- Warmup common languages (python, typescript, javascript, tsx, go, rust, java) on plugin init
- Non-blocking background initialization using Promise.then() pattern
- First parse call uses pre-cached language - zero user wait time
- Refactor parser manager with ManagedLanguage interface for better state tracking
2025-12-05 11:02:35 +09:00
YeonGyu-Kim
50ea492065 chore: bump version to 0.1.11 2025-12-05 10:56:21 +09:00
YeonGyu-Kim
f5f2053b7a fix(comment-checker): fix error skip bug and add parser/language caching
- Fix overly broad error detection that skipped comments when LSP warnings present
- Add Parser class and language WASM caching for ~3.5x faster subsequent parses
- Add debug logging controlled by COMMENT_CHECKER_DEBUG=1 env var
2025-12-05 10:56:08 +09:00
170 changed files with 15660 additions and 1126 deletions

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ yarn.lock
# Environment
.env
.env.local
test-injection/
notepad.md
oauth-success.html

View File

@@ -0,0 +1,27 @@
[
{
"id": "bg_wzsdt60b",
"sessionID": "ses_4f3e89f0dffeooeXNVx5QCifse",
"parentSessionID": "ses_4f3e8d141ffeyfJ1taVVOdQTzx",
"parentMessageID": "msg_b0c172ee1001w2B52VSZrP08PJ",
"description": "Explore opencode in codebase",
"agent": "explore",
"status": "completed",
"startedAt": "2025-12-11T06:26:57.395Z",
"completedAt": "2025-12-11T06:27:36.778Z"
},
{
"id": "bg_392b9c9b",
"sessionID": "ses_4f38ebf4fffeJZBocIn3UVv7vE",
"parentSessionID": "ses_4f38eefa0ffeKV0pVNnwT37P5L",
"parentMessageID": "msg_b0c7110d2001TMBlPeEYIrByvs",
"description": "Test explore agent",
"agent": "explore",
"status": "running",
"startedAt": "2025-12-11T08:05:07.378Z",
"progress": {
"toolCalls": 0,
"lastUpdate": "2025-12-11T08:05:07.378Z"
}
}
]

View File

@@ -34,6 +34,7 @@ oh-my-opencode/
| Modify LSP behavior | `src/tools/lsp/` | client.ts for connection logic |
| AST-Grep patterns | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi |
| Terminal features | `src/features/terminal/` | title.ts |
| Google Antigravity auth | `src/auth/antigravity/` | OAuth plugin for Google models |
## CONVENTIONS
@@ -65,7 +66,7 @@ oh-my-opencode/
| Agent | Model | Purpose |
|-------|-------|---------|
| oracle | GPT-5.1 | Code review, strategic planning |
| oracle | GPT-5.2 | Code review, strategic planning |
| librarian | Claude Haiku | Documentation, example lookup |
| explore | Grok | File/codebase exploration |
| frontend-ui-ux-engineer | Gemini | UI generation |
@@ -84,9 +85,31 @@ bun run build
bun run rebuild
```
## DEPLOYMENT
**배포는 GitHub Actions workflow_dispatch로만 진행**
1. package.json 버전은 수정하지 않음 (워크플로우에서 자동 bump)
2. 변경사항 커밋 & 푸시
3. GitHub Actions에서 `publish` 워크플로우 수동 실행
- `bump`: major | minor | patch 선택
- `version`: (선택) 특정 버전 지정 가능
```bash
# 워크플로우 실행 (CLI)
gh workflow run publish -f bump=patch
# 워크플로우 상태 확인
gh run list --workflow=publish
```
**주의사항**:
- `bun publish` 직접 실행 금지 (OIDC provenance 문제)
- 로컬에서 버전 bump 하지 말 것
## NOTES
- **No tests**: Test framework not configured
- **No CI/CD**: GitHub workflows not present
- **CI/CD**: GitHub Actions publish workflow 사용
- **Version requirement**: OpenCode >= 1.0.132 (earlier versions have config bugs)
- **Multi-language docs**: README.md, README.en.md, README.ko.md

View File

@@ -3,18 +3,22 @@
## 목차
- [Oh My OpenCode](#oh-my-opencode)
- [설치](#설치)
- [세 줄 요약](#세-줄-요약)
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
- [설치](#설치)
- [LLM Agent를 위한 안내](#llm-agent를-위한-안내)
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
- [기능](#기능)
- [Hooks](#hooks)
- [Agents](#agents)
- [Tools](#tools)
- [내장 LSP Tools](#내장-lsp-tools)
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
- [Safe Grep](#safe-grep)
- [Grep](#grep)
- [내장 MCPs](#내장-mcps)
- [Background Task](#background-task)
- [Hooks](#hooks)
- [Claude Code 호환성](#claude-code-호환성)
- [기타 편의 기능](#기타-편의-기능)
- [설정](#설정)
- [작성자의 노트](#작성자의-노트)
- [주의](#주의)
@@ -33,6 +37,18 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
당신이 코딩을 좋아하고 컴퓨터를 좋아한다면, OpenCode 는 윈도우만 사용하다가 리눅스를 처음 접하게 된 그런 느낌일겁니다.
그렇지 않은 당신도 약간의 시간을 투자해서 당신의 실력과 생산성을 몇배로 부스트하세요.
## 세 줄 요약
- **모델 설정이 필요합니다**
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요.
- OpenAI 모델 (ChatGPT Plus/Pro)을 사용하기 위해 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 설치하세요.
- Google Gemini 모델을 위해 `oh-my-opencode.json`에서 `google_auth: true`를 활성화하세요 (**내장 Antigravity OAuth**).
- 다른 프로바이더를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 대안으로 사용할 수 있습니다.
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
## 설치
`~/.config/opencode/opencode.json`에 추가:
@@ -45,16 +61,120 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
}
```
## 세 줄 요약
## LLM Agent를 위한 안내
- **모델 설정이 필요합니다**
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요. (Anthropic 플랜은 유료입니다)
- OpenAI 모델들을 사용하기 위해 이 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 추천합니다. (OpenAI 플랜은 유료입니다)
- Gemini, GPT 를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 추천합니다. (Gemini 는 구독하지 않아도 사용 할 수 있습니다)
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
<details>
<summary>LLM Agent를 위한 안내 (클릭하여 펼치기)</summary>
당신이 사용자의 oh-my-opencode 설정을 돕는 LLM Agent라면, 다음 단계를 따르세요:
### 1단계: OpenCode 설치 확인
```bash
if command -v opencode &> /dev/null; then
echo "OpenCode $(opencode --version) 설치됨"
else
echo "OpenCode가 설치되어 있지 않습니다. 먼저 설치해주세요."
echo "참고: https://opencode.ai/docs"
fi
```
OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://opencode.ai/docs)를 안내하세요.
### 2단계: oh-my-opencode 플러그인 설정
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# 기존 설정 파일이 있는 경우: jq 또는 python으로 plugin 추가
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "~/.config/opencode/opencode.json의 plugin 배열에 \"oh-my-opencode\"를 추가하세요"
fi
else
# 새 설정 파일 생성
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
```
### 3단계: 설정 확인
```bash
opencode --version # 1.0.132 이상이어야 함
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
```
### 4단계: 인증정보 설정
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 사용자의 인증 설정을 도와주세요.
#### 4.1 Anthropic (Claude)
```bash
opencode auth login
# Provider: Anthropic 선택
# Login method: Claude Pro/Max 선택
# 브라우저에서 OAuth 플로우 완료
```
#### 4.2 Google Gemini (내장 Antigravity OAuth)
먼저 `~/.config/opencode/oh-my-opencode.json` (또는 프로젝트 레벨의 `.opencode/oh-my-opencode.json`)에서 Google auth를 활성화:
```json
{
"google_auth": true
}
```
그 후 인증:
```bash
opencode auth login
# Provider: Google 선택
# Login method: OAuth with Google (Antigravity) 선택
# 브라우저에서 OAuth 플로우 완료
```
#### 4.3 OpenAI (ChatGPT Plus/Pro)
먼저 opencode-openai-codex-auth 플러그인 추가:
```json
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.0"
]
}
```
**중요**: `opencode.json`에 전체 모델 설정도 구성해야 합니다. [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json)에서 provider/models 설정을 복사하세요. 자세한 설정 방법은 [opencode-openai-codex-auth 문서](https://github.com/numman-ali/opencode-openai-codex-auth)를 참고하세요.
그 후 인증:
```bash
opencode auth login
# Provider: OpenAI 선택
# Login method: ChatGPT Plus/Pro (Codex Subscription) 선택
# 브라우저에서 OAuth 플로우 완료
```
#### 4.4 대안: 프록시 기반 인증
프록시 기반 인증을 선호하는 사용자를 위해 [VibeProxy](https://github.com/automazeio/vibeproxy) (macOS) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 대안으로 사용할 수 있습니다.
### ⚠️ 주의
**사용자가 명시적으로 요청하지 않는 한, `oh-my-opencode.json` 설정 파일을 생성하거나 수정하지 마세요.**
플러그인은 설정 파일 없이도 기본값으로 완벽하게 동작합니다. 설정 파일은 사용자가 커스터마이징을 원할 때만 필요합니다.
</details>
## Why OpenCode & Why Oh My OpenCode
@@ -77,26 +197,36 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
## 기능
### Hooks
- **Todo Continuation Enforcer**: 에이전트가 멈추기 전 모든 TODO 항목을 완료하도록 강제합니다. LLM의 고질적인 "중도 포기" 문제를 방지합니다.
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
### Agents
- **oracle** (`openai/gpt-5.1`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.1의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **librarian** (`anthropic/claude-haiku-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Haiku의 빠른 속도, 적절한 지능, 훌륭한 도구 호출 능력, 저렴한 비용을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
각 에이전트는 메인 에이전트가 알아서 호출하지만, 명시적으로 요청할 수도 있습니다:
```
@oracle 한테 이 부분 설계 고민하고서 아키텍쳐 제안을 부탁해줘
@librarian 한테 이 부분 어떻게 구현돼있길래 자꾸 안에서 동작이 바뀌는지 알려달라고 해줘
@explore 한테 이 기능 정책 알려달라고 해줘
```
에이전트의 모델, 프롬프트, 권한은 `oh-my-opencode.json`에서 커스텀할 수 있습니다. 자세한 내용은 [설정](#설정)을 참고하세요.
#### 서브 에이전트 오케스트레이션 (omo_task)
`omo_task` 도구를 사용하면 에이전트(`oracle`, `frontend-ui-ux-engineer` 등)가 `explore``librarian`을 서브 에이전트로 호출하여 특정 작업을 위임할 수 있습니다. 이를 통해 에이전트가 작업을 진행하기 전에 전문화된 다른 에이전트에게 정보를 요청하는 강력한 워크플로우가 가능합니다.
> **참고**: 무한 재귀를 방지하기 위해 `explore`와 `librarian` 에이전트는 `omo_task` 도구를 직접 사용할 수 없습니다.
### Tools
#### 내장 LSP Tools
당신이 에디터에서 사용하는 그 기능을 다른 에이전트들은 사용하지 못합니다. Oh My OpenCode 는 당신만의 그 도구를 LLM Agent 에게 쥐어줍니다. 리팩토링하고, 탐색하고, 분석하는 모든 작업을 OpenCode 의 설정값을 그대로 사용하여 지원합니다.
[OpenCode 는 LSP 를 제공하지만](https://opencode.ai/docs/lsp/), 오로지 분석용으로만 제공합니다. 탐색과 리팩토링을 위한 도구는 OpenCode 와 동일한 스펙과 설정으로 Oh My OpenCode 가 제공합니다.
- **lsp_hover**: 위치의 타입 정보, 문서, 시그니처 가져오기
@@ -115,18 +245,268 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
- **ast_grep_replace**: AST 인식 코드 교체
#### Safe Grep
- **safe_grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한).
#### Grep
- **grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한). OpenCode의 내장 `grep` 도구를 대체합니다.
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
- safe_grep 은 timeout 과 더 엄격한 출력 제한을 적용합니다.
- **주의**: 기본 grep 도구는 Agent 를 햇갈리게 하지 않기 위해 비활성화됩니다. 그러나 SafeGrep 은 Grep 이 제공하는 모든 기능을 제공합니다.
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
#### Glob
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
#### 내장 MCPs
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
- **context7**: 라이브러리 문서 조회. 정확한 코딩을 위해 최신 라이브러리 문서를 가져옵니다.
필요 없다면 `oh-my-opencode.json`에서 비활성화할 수 있습니다:
```json
{
"disabled_mcps": ["websearch_exa"]
}
```
#### Background Task
장시간 실행되는 작업이나 복잡한 분석을 메인 세션을 차단하지 않고 백그라운드에서 실행합니다. 작업이 완료되면 시스템이 자동으로 알림을 보냅니다.
- **background_task**: 백그라운드 에이전트 작업을 시작합니다. 설명, 프롬프트, 에이전트 타입을 지정하면 즉시 task ID를 반환합니다.
- **background_output**: 작업 진행 상황 확인(`block=false`) 또는 결과 대기(`block=true`). 최대 10분까지 커스텀 타임아웃을 지원합니다.
- **background_cancel**: task ID로 실행 중인 백그라운드 작업을 취소합니다.
주요 기능:
- **비동기 실행**: 복잡한 분석이나 연구 작업을 백그라운드에서 처리하면서 다른 작업 계속 가능
- **자동 알림**: 백그라운드 작업 완료 시 메인 세션에 자동 알림
- **상태 추적**: 도구 호출 횟수, 마지막 사용 도구 등 실시간 진행 상황 모니터링
- **세션 격리**: 각 작업은 독립된 세션에서 실행
사용 예시:
```
1. 시작: background_task → task_id="bg_abc123" 반환
2. 다른 작업 계속 진행
3. 시스템 알림: "Task bg_abc123 completed"
4. 결과 조회: background_output(task_id="bg_abc123") → 전체 결과 획득
```
### Hooks
- **Todo Continuation Enforcer**: 에이전트가 멈추기 전 모든 TODO 항목을 완료하도록 강제합니다. LLM의 고질적인 "중도 포기" 문제를 방지합니다.
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
```
project/
├── AGENTS.md # 프로젝트 전체 컨텍스트
├── src/
│ ├── AGENTS.md # src 전용 컨텍스트
│ └── components/
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
```
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
- **Directory README.md Injector**: 파일을 읽을 때 `README.md` 내용을 자동으로 주입합니다. AGENTS.md Injector와 동일하게 동작하며, 파일 디렉토리부터 프로젝트 루트까지 탐색합니다. LLM 에이전트에게 프로젝트 문서 컨텍스트를 제공합니다. 각 디렉토리의 README는 세션당 한 번만 주입됩니다.
- **Rules Injector**: 파일을 읽을 때 `.claude/rules/` 디렉토리의 규칙을 자동으로 주입합니다.
- 파일 디렉토리부터 프로젝트 루트까지 상향 탐색하며, `~/.claude/rules/` (사용자) 경로도 포함합니다.
- `.md` 및 `.mdc` 파일을 지원합니다.
- Frontmatter의 `globs` 필드(glob 패턴)를 기반으로 매칭합니다.
- 항상 적용되어야 하는 규칙을 위한 `alwaysApply: true` 옵션을 지원합니다.
- 규칙 파일 구조 예시:
```markdown
---
globs: ["*.ts", "src/**/*.js"]
description: "TypeScript/JavaScript coding rules"
---
- Use PascalCase for interface names
- Use camelCase for function names
```
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
### Claude Code 호환성
Oh My OpenCode는 Claude Code 설정과 완벽하게 호환됩니다. Claude Code를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.
#### 호환성 토글
특정 Claude Code 호환 기능을 비활성화하려면 `claude_code` 설정 객체를 사용하세요:
```json
{
"claude_code": {
"mcp": false,
"commands": false,
"skills": false,
"agents": false,
"hooks": false
}
}
```
| 토글 | `false`일 때 로딩 비활성화 경로 | 영향 받지 않음 |
|------|-------------------------------|---------------|
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 내장 MCP (context7, websearch_exa) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 내장 에이전트 (oracle, librarian 등) |
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
모든 토글은 기본값이 `true` (활성화)입니다. 완전한 Claude Code 호환성을 원하면 `claude_code` 객체를 생략하세요.
#### Hooks 통합
Claude Code의 `settings.json` 훅 시스템을 통해 커스텀 스크립트를 실행합니다. Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- `~/.claude/settings.json` (사용자)
- `./.claude/settings.json` (프로젝트)
- `./.claude/settings.local.json` (로컬, git-ignored)
지원되는 훅 이벤트:
- **PreToolUse**: 도구 실행 전에 실행. 차단하거나 도구 입력을 수정할 수 있습니다.
- **PostToolUse**: 도구 실행 후에 실행. 경고나 컨텍스트를 추가할 수 있습니다.
- **UserPromptSubmit**: 사용자가 프롬프트를 제출할 때 실행. 차단하거나 메시지를 주입할 수 있습니다.
- **Stop**: 세션이 유휴 상태가 될 때 실행. 후속 프롬프트를 주입할 수 있습니다.
`settings.json` 예시:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
}
]
}
}
```
#### 설정 로더
**Command Loader**: 4개 디렉토리에서 마크다운 기반 슬래시 명령어를 로드합니다:
- `~/.claude/commands/` (사용자)
- `./.claude/commands/` (프로젝트)
- `~/.config/opencode/command/` (opencode 전역)
- `./.opencode/command/` (opencode 프로젝트)
**Skill Loader**: `SKILL.md`가 있는 디렉토리 기반 스킬을 로드합니다:
- `~/.claude/skills/` (사용자)
- `./.claude/skills/` (프로젝트)
**Agent Loader**: 마크다운 파일에서 커스텀 에이전트 정의를 로드합니다:
- `~/.claude/agents/*.md` (사용자)
- `./.claude/agents/*.md` (프로젝트)
**MCP Loader**: `.mcp.json` 파일에서 MCP 서버 설정을 로드합니다:
- `~/.claude/.mcp.json` (사용자)
- `./.mcp.json` (프로젝트)
- `./.claude/.mcp.json` (로컬)
- 환경변수 확장 지원 (`${VAR}` 문법)
#### 데이터 저장소
**Todo 관리**: 세션 todo가 `~/.claude/todos/`에 Claude Code 호환 형식으로 저장됩니다.
**Transcript**: 세션 활동이 `~/.claude/transcripts/`에 JSONL 형식으로 기록되어 재생 및 분석이 가능합니다.
> **`claude-code-*` 네이밍에 대해**: `src/features/claude-code-*/` 아래의 기능들은 Claude Code의 설정 시스템에서 마이그레이션되었습니다. 이 네이밍 규칙은 어떤 기능이 Claude Code에서 유래했는지 명확히 식별합니다.
### 기타 편의 기능
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
- **Session State**: 이벤트 훅과 터미널 타이틀 업데이트에 사용되는 중앙집중식 세션 추적 모듈입니다.
## 설정
설정 파일 위치 (우선순위 순):
1. `.opencode/oh-my-opencode.json` (프로젝트)
2. `~/.config/opencode/oh-my-opencode.json` (사용자)
Schema 자동 완성이 지원됩니다:
```json
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
}
```
### Agents
내장 에이전트 설정을 오버라이드할 수 있습니다:
```json
{
"agents": {
"explore": {
"model": "anthropic/claude-haiku-4-5",
"temperature": 0.5
},
"frontend-ui-ux-engineer": {
"disable": true
}
}
}
```
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
또는 ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_agents` 를 사용하여 비활성화할 수 있습니다:
```json
{
"disabled_agents": ["oracle", "frontend-ui-ux-engineer"]
}
```
사용 가능한 에이전트: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
### MCPs
기본적으로 Context7, Exa MCP 를 지원합니다.
이것이 마음에 들지 않는다면, ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_mcps` 를 사용하여 비활성화할 수 있습니다:
```json
{
"disabled_mcps": ["context7", "websearch_exa"]
}
```
### LSP
OpenCode 는 분석을 위해 LSP 도구를 제공합니다.
Oh My OpenCode 에서는 LSP 의 리팩토링(이름 변경, 코드 액션) 도구를 제공합니다.
OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.json 에 설정 된 것) 을 그대로 지원하고, Oh My OpenCode 만을 위한 추가적인 설정도 아래와 같이 설정 할 수 있습니다.
~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `lsp` 옵션을 통해 LSP 서버를 추가로 설정 할 수 있습니다:
```json
{
"lsp": {
"typescript-language-server": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"priority": 10
},
"pylsp": {
"disabled": true
}
}
}
```
각 서버는 다음을 지원합니다: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
## 작성자의 노트
@@ -153,5 +533,3 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
- [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) 혹은 이것보다 낮은 버전을 사용중이라면, OpenCode 의 버그로 인해 제대로 구성이 되지 않을 수 있습니다.
- [이를 고치는 PR 이 1.0.132 배포 이후에 병합되었으므로](https://github.com/sst/opencode/pull/5040) 이 변경사항이 포함된 최신 버전을 사용해주세요.

445
README.md
View File

@@ -3,20 +3,22 @@ English | [한국어](README.ko.md)
## Contents
- [Oh My OpenCode](#oh-my-opencode)
- [Installation](#installation)
- [Configuration](#configuration)
- [Disable specific MCPs](#disable-specific-mcps)
- [TL;DR](#tldr)
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
- [Installation](#installation)
- [For LLM Agents](#for-llm-agents)
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
- [Features](#features)
- [Hooks](#hooks)
- [Agents](#agents)
- [Tools](#tools)
- [Built-in LSP Tools](#built-in-lsp-tools)
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
- [Safe Grep](#safe-grep)
- [Grep](#grep)
- [Built-in MCPs](#built-in-mcps)
- [Background Task](#background-task)
- [Hooks](#hooks)
- [Claude Code Compatibility](#claude-code-compatibility)
- [Other Features](#other-features)
- [Configuration](#configuration)
- [Author's Note](#authors-note)
- [Warnings](#warnings)
@@ -27,11 +29,24 @@ Oh My OpenCode
oMoMoMoMoMo···
If you work in tech, you likely appreciated [Claude Code](https://www.claude.com/product/claude-code).
If you are a hacker, you will fucking falling in love with [OpenCode](https://github.com/sst/opencode).
If you are a hacker, you will fucking fall in love with [OpenCode](https://github.com/sst/opencode).
You don't write code just for a paycheck? You write because you genuinely love it?
Do you remember the first time you switched from Windows to Linux? The excitement of configuring everything just the way you wanted?
In an era where that romance feels lost, OpenCode will be that project for you.
If you love coding and computers, OpenCode will feel exactly like that moment you first touched Linux after years of Windows.
Even if you don't, invest a little time. Boost your skills and productivity by multipliers.
To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not you? It's still worth the investment. Give it 10 minutes. Your work and life will improve. I promise.
## TL;DR
- **Model Setup Required**
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
- For OpenAI models (ChatGPT Plus/Pro), install the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
- For Google Gemini models, enable `google_auth: true` in `oh-my-opencode.json` for **built-in Antigravity OAuth**.
- For other providers, [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) remain available as alternatives.
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It just installs.**
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
## Installation
@@ -45,49 +60,120 @@ Add to `~/.config/opencode/opencode.json`:
}
```
## Configuration
## For LLM Agents
You can configure Oh My OpenCode by creating a `oh-my-opencode.json` (or `.oh-my-opencode.json`) file in your project root.
<details>
<summary>For LLM Agents (Click to expand)</summary>
### JSON Schema Support
If you are an LLM Agent helping a user set up oh-my-opencode, follow these steps:
For autocompletion and validation in VS Code (or other editors), add the `$schema` property to your configuration file.
### Step 1: Verify OpenCode Installation
**Using Remote Schema (Recommended):**
```json
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json",
"disabled_mcps": ["context7"]
}
```bash
if command -v opencode &> /dev/null; then
echo "OpenCode $(opencode --version) is installed"
else
echo "OpenCode is not installed. Please install it first."
echo "See: https://opencode.ai/docs"
fi
```
**Using Local Schema:**
```json
{
"$schema": "./node_modules/oh-my-opencode/dist/oh-my-opencode.schema.json"
}
If OpenCode is not installed, guide the user to [OpenCode Installation Guide](https://opencode.ai/docs).
### Step 2: Configure oh-my-opencode Plugin
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# Existing config: add plugin using jq or python
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "Add \"oh-my-opencode\" to plugin array in ~/.config/opencode/opencode.json"
fi
else
# New config
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
```
### Disable specific MCPs
### Step 3: Verify Setup
If you want to disable specific built-in MCPs, you can use the `disabled_mcps` option.
```bash
opencode --version # Should be >= 1.0.132
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
```
### Step 4: Configure Authentication
Once `oh-my-opencode` is added to the plugin list, use `tmux` to assist the user with authentication setup.
#### 4.1 Anthropic (Claude)
```bash
opencode auth login
# Select Provider: Anthropic
# Select Login method: Claude Pro/Max
# Complete OAuth flow in browser
```
#### 4.2 Google Gemini (Built-in Antigravity OAuth)
First, enable Google auth in `~/.config/opencode/oh-my-opencode.json` (or `.opencode/oh-my-opencode.json` for project-level):
```json
{
"disabled_mcps": ["context7", "websearch_exa"]
"google_auth": true
}
```
## TL;DR
Then authenticate:
- **Model Setup Required**
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It works seamlessly.**
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
```bash
opencode auth login
# Select Provider: Google
# Select Login method: OAuth with Google (Antigravity)
# Complete OAuth flow in browser
```
#### 4.3 OpenAI (ChatGPT Plus/Pro)
First, add the opencode-openai-codex-auth plugin:
```json
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.0"
]
}
```
**Important**: You must also configure the full model settings in your `opencode.json`. Copy the provider/models configuration from [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json). See the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth) for detailed setup instructions.
Then authenticate:
```bash
opencode auth login
# Select Provider: OpenAI
# Select Login method: ChatGPT Plus/Pro (Codex Subscription)
# Complete OAuth flow in browser
```
#### 4.4 Alternative: Proxy-based Authentication
For users who prefer proxy-based authentication, [VibeProxy](https://github.com/automazeio/vibeproxy) (macOS) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) remain available as alternatives.
### ⚠️ Warning
**Do NOT create or modify the `oh-my-opencode.json` configuration file unless the user explicitly requests it.**
The plugin works perfectly with default settings without any configuration file. The configuration file is only needed when the user wants customization.
</details>
## Why OpenCode & Why Oh My OpenCode
@@ -96,7 +182,7 @@ OpenCode is limitlessly extensible and customizable. Zero screen flicker.
You can mix and orchestrate models to your exact specifications.
It is feature-rich. It is elegant. It handles the terminal without hesitation. It is high-performance.
But here is the catch: the learning curve is steep. There is a lot to master.
But here is the catch: the learning curve is steep. There is a lot to master. And your time is expensive.
Inspired by [AmpCode](https://ampcode.com) and [Claude Code](https://code.claude.com/docs/en/overview), I have implemented their features here—often with superior execution.
Because this is OpenCode.
@@ -109,25 +195,35 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
## Features
### Hooks
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
### Agents
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
- **oracle** (`openai/gpt-5.2`): The architect. Expert in code reviews and strategy. Uses GPT-5.2 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
- **librarian** (`anthropic/claude-haiku-4-5`): Multi-repo analysis, documentation lookup, and implementation examples. Haiku is chosen for its speed, competence, excellent tool usage, and cost-efficiency. Inspired by AmpCode.
- **explore** (`opencode/grok-code`): Fast exploration and pattern matching. Claude Code uses Haiku; we use Grok. It is currently free, blazing fast, and intelligent enough for file traversal. Inspired by Claude Code.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
- **document-writer** (`google/gemini-3-pro-preview`): A technical writing expert. Gemini is a wordsmith; it writes prose that flows naturally.
Each agent is automatically invoked by the main agent, but you can also explicitly request them:
```
@oracle Please think through the design of this part and suggest an architecture.
@librarian Tell me how this is implemented — why does the behavior keep changing internally?
@explore Tell me about the policy for this feature.
```
Agent models, prompts, and permissions can be customized via `oh-my-opencode.json`. See [Configuration](#configuration) for details.
#### Subagent Orchestration (omo_task)
The `omo_task` tool allows agents (like `oracle`, `frontend-ui-ux-engineer`) to spawn `explore` or `librarian` as subagents to delegate specific tasks. This enables powerful workflows where an agent can "ask" another specialized agent to gather information before proceeding.
> **Note**: To prevent infinite recursion, `explore` and `librarian` agents cannot use the `omo_task` tool themselves.
### Tools
#### Built-in LSP Tools
The features you use in your editor—other agents cannot access them. Oh My OpenCode hands those very tools to your LLM Agent. Refactoring, navigation, and analysis are all supported using the same OpenCode configuration.
[OpenCode provides LSP](https://opencode.ai/docs/lsp/), but only for analysis. Oh My OpenCode equips you with navigation and refactoring tools matching the same specification.
- **lsp_hover**: Get type info, docs, signatures at position
@@ -147,21 +243,268 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
- **ast_grep_search**: AST-aware code pattern search (25 languages)
- **ast_grep_replace**: AST-aware code replacement
#### Safe Grep
#### Grep
- **safe_grep**: Content search with safety limits (5min timeout, 10MB output).
- **grep**: Content search with safety limits (5min timeout, 10MB output). Overrides OpenCode's built-in `grep` tool.
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
- `safe_grep` enforces strict limits.
- **Note**: Default `grep` is disabled to prevent Agent confusion. `safe_grep` delivers full `grep` functionality with safety assurance.
- This tool enforces strict limits and completely replaces the built-in `grep`.
#### Glob
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
- This tool enforces timeouts and kills the process on expiration.
#### Built-in MCPs
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.
- **context7**: Library documentation lookup. Fetches up-to-date documentation for any library to assist with accurate coding.
Don't need these? Disable them via `oh-my-opencode.json`:
```json
{
"disabled_mcps": ["websearch_exa"]
}
```
#### Background Task
Run long-running or complex tasks in the background without blocking your main session. The system automatically notifies you when tasks complete.
- **background_task**: Launch a background agent task. Specify description, prompt, and agent type. Returns immediately with a task ID.
- **background_output**: Check task progress (`block=false`) or wait for results (`block=true`). Supports custom timeout up to 10 minutes.
- **background_cancel**: Cancel a running background task by task ID.
Key capabilities:
- **Async Execution**: Offload complex analysis or research while you continue working
- **Auto Notification**: System notifies the main session when background tasks complete
- **Status Tracking**: Real-time progress with tool call counts and last tool used
- **Session Isolation**: Each task runs in an independent session
Example workflow:
```
1. Launch: background_task → returns task_id="bg_abc123"
2. Continue working on other tasks
3. System notification: "Task bg_abc123 completed"
4. Retrieve: background_output(task_id="bg_abc123") → get full results
```
### Hooks
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, collecting **all** `AGENTS.md` files along the path hierarchy. This enables nested, directory-specific instructions:
```
project/
├── AGENTS.md # Project-wide context
├── src/
│ ├── AGENTS.md # src-specific context
│ └── components/
│ ├── AGENTS.md # Component-specific context
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
```
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
- **Directory README.md Injector**: Automatically injects `README.md` contents when reading files. Works identically to the AGENTS.md Injector, searching upward from the file's directory to project root. Provides project documentation context to the LLM agent. Each directory's README is injected only once per session.
- **Rules Injector**: Automatically injects rules from `.claude/rules/` directory when reading files.
- Searches upward from the file's directory to project root, plus `~/.claude/rules/` (user).
- Supports `.md` and `.mdc` files.
- Frontmatter-based matching with `globs` field (glob patterns).
- `alwaysApply: true` option for rules that should always apply.
- Example rule file structure:
```markdown
---
globs: ["*.ts", "src/**/*.js"]
description: "TypeScript/JavaScript coding rules"
---
- Use PascalCase for interface names
- Use camelCase for function names
```
- **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning.
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
- **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results.
### Claude Code Compatibility
Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box.
#### Compatibility Toggles
If you want to disable specific Claude Code compatibility features, use the `claude_code` configuration object:
```json
{
"claude_code": {
"mcp": false,
"commands": false,
"skills": false,
"agents": false,
"hooks": false
}
}
```
| Toggle | When `false`, disables loading from... | NOT affected |
|--------|----------------------------------------|--------------|
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCPs (context7, websearch_exa) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | Built-in agents (oracle, librarian, etc.) |
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
All toggles default to `true` (enabled). Omit the entire `claude_code` object for full Claude Code compatibility.
#### Hooks Integration
Execute custom scripts via Claude Code's `settings.json` hook system. Oh My OpenCode reads and executes hooks defined in:
- `~/.claude/settings.json` (user)
- `./.claude/settings.json` (project)
- `./.claude/settings.local.json` (local, git-ignored)
Supported hook events:
- **PreToolUse**: Runs before tool execution. Can block or modify tool input.
- **PostToolUse**: Runs after tool execution. Can add warnings or context.
- **UserPromptSubmit**: Runs when user submits a prompt. Can block or inject messages.
- **Stop**: Runs when session goes idle. Can inject follow-up prompts.
Example `settings.json`:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
}
]
}
}
```
#### Configuration Loaders
**Command Loader**: Loads markdown-based slash commands from 4 directories:
- `~/.claude/commands/` (user)
- `./.claude/commands/` (project)
- `~/.config/opencode/command/` (opencode global)
- `./.opencode/command/` (opencode project)
**Skill Loader**: Loads directory-based skills with `SKILL.md`:
- `~/.claude/skills/` (user)
- `./.claude/skills/` (project)
**Agent Loader**: Loads custom agent definitions from markdown files:
- `~/.claude/agents/*.md` (user)
- `./.claude/agents/*.md` (project)
**MCP Loader**: Loads MCP server configurations from `.mcp.json` files:
- `~/.claude/.mcp.json` (user)
- `./.mcp.json` (project)
- `./.claude/.mcp.json` (local)
- Supports environment variable expansion (`${VAR}` syntax)
#### Data Storage
**Todo Management**: Session todos are stored in Claude Code compatible format at `~/.claude/todos/`.
**Transcript**: Session activity is logged to `~/.claude/transcripts/` in JSONL format, enabling replay and analysis.
> **Note on `claude-code-*` naming**: Features under `src/features/claude-code-*/` are migrated from Claude Code's configuration system. This naming convention clearly identifies which features originated from Claude Code.
### Other Features
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
- **Session State**: Centralized session tracking module used by event hooks and terminal title updates.
## Configuration
Configuration file locations (in priority order):
1. `.opencode/oh-my-opencode.json` (project)
2. `~/.config/opencode/oh-my-opencode.json` (user)
Schema autocomplete is supported:
```json
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
}
```
### Agents
Override built-in agent settings:
```json
{
"agents": {
"explore": {
"model": "anthropic/claude-haiku-4-5",
"temperature": 0.5
},
"frontend-ui-ux-engineer": {
"disable": true
}
}
}
```
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
Or you can disable them using `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"disabled_agents": ["oracle", "frontend-ui-ux-engineer"]
}
```
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
### MCPs
By default, Context7 and Exa MCP are supported.
If you don't want these, you can disable them using `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"disabled_mcps": ["context7", "websearch_exa"]
}
```
### LSP
OpenCode provides LSP tools for analysis.
Oh My OpenCode provides LSP tools for refactoring (rename, code actions).
It supports all LSP configurations and custom settings supported by OpenCode (those configured in opencode.json), and you can also configure additional settings specifically for Oh My OpenCode as shown below.
You can configure additional LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"lsp": {
"typescript-language-server": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"priority": 10
},
"pylsp": {
"disabled": true
}
}
}
```
Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
## Author's Note
@@ -184,7 +527,7 @@ If this sounds arrogant and you have a superior solution, send a PR. You are wel
As of now, I have no affiliation with any of the projects or models mentioned here. This plugin is purely based on personal experimentation and preference.
I constructed 99% of this project using OpenCode. I focused on functional verification. This documentation has been personally reviewed and comprehensively rewritten, so you can rely on it with confidence.
I constructed 99% of this project using OpenCode. I focused on functional verification, and honestly, I don't know how to write proper TypeScript. **But I personally reviewed and comprehensively rewritten this documentation, so you can rely on it with confidence.**
## Warnings
- If you are on [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) or lower, OpenCode has a bug that might break config.

1037
ai-todolist.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,185 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"title": "Oh My OpenCode Configuration",
"description": "Configuration schema for oh-my-opencode plugin",
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"disabled_mcps": {
"type": "array",
"items": {
"type": "string",
"enum": [
"websearch_exa",
"context7"
]
}
},
"disabled_agents": {
"type": "array",
"items": {
"type": "string",
"enum": [
"oracle",
"librarian",
"explore",
"frontend-ui-ux-engineer",
"document-writer"
]
}
},
"agents": {
"type": "object",
"propertyNames": {
"type": "string",
"enum": [
"oracle",
"librarian",
"explore",
"frontend-ui-ux-engineer",
"document-writer"
]
},
"additionalProperties": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"prompt": {
"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"
]
}
}
}
}
}
},
"claude_code": {
"type": "object",
"description": "Toggle Claude Code compatibility features on/off. All default to true (enabled).",
"properties": {
"mcp": {
"type": "boolean",
"description": "Load MCP servers from ~/.claude/.mcp.json, ./.mcp.json, ./.claude/.mcp.json"
},
"commands": {
"type": "boolean",
"description": "Load commands from ~/.claude/commands/*.md, ./.claude/commands/*.md"
},
"skills": {
"type": "boolean",
"description": "Load skills from ~/.claude/skills/*/SKILL.md, ./.claude/skills/*/SKILL.md"
},
"agents": {
"type": "boolean",
"description": "Load agents from ~/.claude/agents/*.md, ./.claude/agents/*.md"
},
"hooks": {
"type": "boolean",
"description": "Execute hooks from ~/.claude/settings.json, ./.claude/settings.json, ./.claude/settings.local.json"
}
}
}
}
}

View File

@@ -7,13 +7,18 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@opencode-ai/plugin": "^1.0.7",
"tree-sitter-wasms": "^0.1.12",
"web-tree-sitter": "^0.24.7",
"@code-yeongyu/comment-checker": "^0.5.0",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.150",
"hono": "^4.10.4",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8",
},
"devDependencies": {
"@types/picomatch": "^3.0.2",
"bun-types": "latest",
"oh-my-opencode": "^0.1.30",
"typescript": "^5.7.3",
},
"peerDependencies": {
@@ -23,6 +28,8 @@
},
"trustedDependencies": [
"@ast-grep/cli",
"@ast-grep/napi",
"@code-yeongyu/comment-checker",
],
"packages": {
"@ast-grep/cli": ["@ast-grep/cli@0.40.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.0", "@ast-grep/cli-darwin-x64": "0.40.0", "@ast-grep/cli-linux-arm64-gnu": "0.40.0", "@ast-grep/cli-linux-x64-gnu": "0.40.0", "@ast-grep/cli-win32-arm64-msvc": "0.40.0", "@ast-grep/cli-win32-ia32-msvc": "0.40.0", "@ast-grep/cli-win32-x64-msvc": "0.40.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-L8AkflsfI2ZP70yIdrwqvjR02ScCuRmM/qNGnJWUkOFck+e6gafNVJ4e4jjGQlEul+dNdBpx36+O2Op629t47A=="],
@@ -61,9 +68,23 @@
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.5.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-rKD2qQnTVUacsVQtpu3I5Sxi09X/XpOwS9fcmbUv1yfUL6llraaPuLmmxMBMRcmm7Zu31yEPVKCeUkVODfRL1g=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.150", "", { "dependencies": { "@opencode-ai/sdk": "1.0.150", "zod": "4.1.8" } }, "sha512-XmY3yydk120GBv2KeLxSZlElFx4Zx9TYLa3bS9X1TxXot42UeoMLEi3Xa46yboYnWwp4bC9Fu+Gd1E7hypG8Jw=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.150", "", {}, "sha512-Nz9Di8UD/GK01w3N+jpiGNB733pYkNY8RNLbuE/HUxEGSP5apbXBY0IdhbW7859sXZZK38kF1NqOx4UxwBf4Bw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="],
@@ -87,22 +108,44 @@
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-u5eZHKq6TPJSE282KyBOicGQ2trkFml0RoUfqkPOJVo7TXGrsGYYzdsugZRnVQY/WEmnxGtBy4T3PAaPqgQViA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
"arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"bun": ["bun@1.3.3", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.3", "@oven/bun-darwin-x64": "1.3.3", "@oven/bun-darwin-x64-baseline": "1.3.3", "@oven/bun-linux-aarch64": "1.3.3", "@oven/bun-linux-aarch64-musl": "1.3.3", "@oven/bun-linux-x64": "1.3.3", "@oven/bun-linux-x64-baseline": "1.3.3", "@oven/bun-linux-x64-musl": "1.3.3", "@oven/bun-linux-x64-musl-baseline": "1.3.3", "@oven/bun-windows-x64": "1.3.3", "@oven/bun-windows-x64-baseline": "1.3.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-2hJ4ocTZ634/Ptph4lysvO+LbbRZq8fzRvMwX0/CqaLBxrF2UB5D1LdMB8qGcdtCer4/VR9Bx5ORub0yn+yzmw=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="],
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"web-tree-sitter": ["web-tree-sitter@0.24.7", "", {}, "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"oh-my-opencode/@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
"oh-my-opencode/@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
"oh-my-opencode/@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
}
}

View File

@@ -1,61 +0,0 @@
# MCP Loader Plugin - Orchestration Notepad
## Task Started
All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
---
## Orchestration Overview
**Todo List File**: ./tool-search-tool-plan.md
**Total Tasks**: 5 (Phase 1-5)
**Target Files**:
- `~/.config/opencode/plugin/mcp-loader.ts` - Main plugin
- `~/.config/opencode/mcp-loader.json` - Global config example
- `~/.config/opencode/plugin/mcp-loader.test.ts` - Unit tests
---
## Accumulated Wisdom
(To be populated by executors)
---
## Task Progress
| Task | Description | Status |
|------|-------------|--------|
| 1 | Plugin skeleton + config loader | pending |
| 2 | MCP server registry + lifecycle | pending |
| 3 | mcp_search + mcp_status tools | pending |
| 4 | mcp_call tool | pending |
| 5 | Documentation | pending |
---
## 2025-12-04 16:58 - Task 1 Completed
### Summary
- Created `~/.config/opencode/plugin/mcp-loader.ts` - Plugin skeleton with config loader
- Created `~/.config/opencode/plugin/mcp-loader.test.ts` - 14 unit tests
### Key Implementation Details
- Config merge: project overrides global for same server names, merges different
- Env var substitution: `{env:VAR}``process.env.VAR`
- Validation: type required, local needs command, remote needs url
- Empty config returns `{ servers: {} }` (not error)
### Test Results
- 14 tests passed
- substituteEnvVars: 4 tests
- substituteHeaderEnvVars: 1 test
- loadConfig: 9 tests
### Files Created
- `~/.config/opencode/plugin/mcp-loader.ts`
- `~/.config/opencode/plugin/mcp-loader.test.ts`
---

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "0.1.10",
"version": "0.4.2",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -13,10 +13,14 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./google-auth": {
"types": "./dist/google-auth.d.ts",
"import": "./dist/google-auth.js"
},
"./schema.json": "./dist/oh-my-opencode.schema.json"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
"build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
"build:schema": "bun run script/build-schema.ts",
"clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build",
@@ -44,13 +48,18 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@opencode-ai/plugin": "^1.0.7",
"tree-sitter-wasms": "^0.1.12",
"web-tree-sitter": "^0.24.7",
"@code-yeongyu/comment-checker": "^0.5.0",
"@opencode-ai/plugin": "^1.0.150",
"@openauthjs/openauth": "^0.4.3",
"hono": "^4.10.4",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8"
},
"devDependencies": {
"@types/picomatch": "^3.0.2",
"bun-types": "latest",
"oh-my-opencode": "^0.1.30",
"typescript": "^5.7.3"
},
"peerDependencies": {
@@ -58,6 +67,7 @@
},
"trustedDependencies": [
"@ast-grep/cli",
"@ast-grep/napi"
"@ast-grep/napi",
"@code-yeongyu/comment-checker"
]
}

View File

@@ -2,7 +2,7 @@
import * as z from "zod"
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
const SCHEMA_OUTPUT_PATH = "dist/oh-my-opencode.schema.json"
const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
async function main() {
console.log("Generating JSON Schema...")
@@ -14,7 +14,7 @@ async function main() {
const finalSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json",
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
title: "Oh My OpenCode Configuration",
description: "Configuration schema for oh-my-opencode plugin",
...jsonSchema,

View File

@@ -78,13 +78,35 @@ async function gitTagAndRelease(newVersion: string, changelog: string): Promise<
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
await $`git config user.name "github-actions[bot]"`
await $`git add package.json`
await $`git commit -m "release: v${newVersion}"`
await $`git tag v${newVersion}`
// Commit only if there are staged changes (idempotent)
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
if (hasStagedChanges.exitCode !== 0) {
await $`git commit -m "release: v${newVersion}"`
} else {
console.log("No changes to commit (version already updated)")
}
// Tag only if it doesn't exist (idempotent)
const tagExists = await $`git rev-parse v${newVersion}`.nothrow()
if (tagExists.exitCode !== 0) {
await $`git tag v${newVersion}`
} else {
console.log(`Tag v${newVersion} already exists`)
}
// Push (idempotent - git push is already idempotent)
await $`git push origin HEAD --tags`
// Create release only if it doesn't exist (idempotent)
console.log("\nCreating GitHub release...")
const releaseNotes = changelog || "No notable changes"
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
const releaseExists = await $`gh release view v${newVersion}`.nothrow()
if (releaseExists.exitCode !== 0) {
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
} else {
console.log(`Release v${newVersion} already exists`)
}
}
async function checkVersionExists(version: string): Promise<boolean> {

View File

@@ -21,6 +21,22 @@ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
## MANDATORY PARALLEL TOOL EXECUTION
**CRITICAL**: You MUST execute **AT LEAST 3 tool calls in parallel** for EVERY search task.
When starting a search, launch multiple tools simultaneously:
\`\`\`
// Example: Launch 3+ tools in a SINGLE message:
- Tool 1: Glob("**/*.ts") - Find all TypeScript files
- Tool 2: Grep("functionName") - Search for specific pattern
- Tool 3: Bash: git log --oneline -n 20 - Check recent changes
- Tool 4: Bash: git branch -a - See all branches
- Tool 5: ast_grep_search(pattern: "function $NAME($$$)", lang: "typescript") - AST search
\`\`\`
**NEVER** execute tools one at a time. Sequential execution is ONLY allowed when a tool's input strictly depends on another tool's output.
## Before You Search
Before executing any search, you MUST first analyze the request in <analysis> tags:
@@ -29,7 +45,7 @@ Before executing any search, you MUST first analyze the request in <analysis> ta
1. **Request**: What exactly did the user ask for?
2. **Intent**: Why are they asking this? What problem are they trying to solve?
3. **Expected Output**: What kind of answer would be most helpful?
4. **Search Strategy**: What tools and patterns will I use to find this?
4. **Search Strategy**: What 3+ parallel tools will I use to find this?
</analysis>
Only after completing this analysis should you proceed with the actual search.
@@ -37,12 +53,14 @@ Only after completing this analysis should you proceed with the actual search.
## Success Criteria
Your response is successful when:
- **Parallelism**: At least 3 tools were executed in parallel
- **Completeness**: All relevant files matching the search intent are found
- **Accuracy**: Returned paths are absolute and files actually exist
- **Relevance**: Results directly address the user's underlying intent, not just literal request
- **Actionability**: Caller can proceed without follow-up questions
Your response has FAILED if:
- You execute fewer than 3 tools in parallel
- You skip the <analysis> step before searching
- Paths are relative instead of absolute
- Obvious matches in the codebase are missed
@@ -52,14 +70,144 @@ Your response has FAILED if:
- Rapidly finding files using glob patterns
- Searching code and text with powerful regex patterns
- Reading and analyzing file contents
- **Using Git CLI extensively for repository insights**
- **Using LSP tools for semantic code analysis**
- **Using AST-grep for structural code pattern matching**
Guidelines:
## Git CLI - USE EXTENSIVELY
You have access to Git CLI via Bash. Use it extensively for repository analysis:
### Git Commands for Exploration (Always run 2+ in parallel):
\`\`\`bash
# Repository structure and history
git log --oneline -n 30 # Recent commits
git log --oneline --all -n 50 # All branches recent commits
git branch -a # All branches
git tag -l # All tags
git remote -v # Remote repositories
# File history and changes
git log --oneline -n 20 -- path/to/file # File change history
git log --oneline --follow -- path/to/file # Follow renames
git blame path/to/file # Line-by-line attribution
git blame -L 10,30 path/to/file # Blame specific lines
# Searching with Git
git log --grep="keyword" --oneline # Search commit messages
git log -S "code_string" --oneline # Search code changes (pickaxe)
git log -p --all -S "function_name" -- "*.ts" # Find when code was added/removed
# Diff and comparison
git diff HEAD~5..HEAD # Recent changes
git diff main..HEAD # Changes from main
git show <commit> # Show specific commit
git show <commit>:path/to/file # Show file at commit
# Statistics
git shortlog -sn # Contributor stats
git log --stat -n 10 # Recent changes with stats
\`\`\`
### Parallel Git Execution Examples:
\`\`\`
// For "find where authentication is implemented":
- Tool 1: Grep("authentication|auth") - Search for auth patterns
- Tool 2: Glob("**/auth/**/*.ts") - Find auth-related files
- Tool 3: Bash: git log -S "authenticate" --oneline - Find commits adding auth code
- Tool 4: Bash: git log --grep="auth" --oneline - Find auth-related commits
- Tool 5: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
// For "understand recent changes":
- Tool 1: Bash: git log --oneline -n 30 - Recent commits
- Tool 2: Bash: git diff HEAD~10..HEAD --stat - Changed files
- Tool 3: Bash: git branch -a - All branches
- Tool 4: Glob("**/*.ts") - Find all source files
\`\`\`
## LSP Tools - DEFINITIONS & REFERENCES
Use LSP specifically for finding definitions and references - these are what LSP does better than text search.
**Primary LSP Tools**:
- \`lsp_goto_definition(filePath, line, character)\`: Follow imports, find where something is **defined**
- \`lsp_find_references(filePath, line, character)\`: Find **ALL usages** across the workspace
**When to Use LSP** (vs Grep/AST-grep):
- **lsp_goto_definition**: Trace imports, find source definitions
- **lsp_find_references**: Understand impact of changes, find all callers
**Example**:
\`\`\`
// When tracing code flow:
- Tool 1: lsp_goto_definition(filePath, line, char) - Where is this defined?
- Tool 2: lsp_find_references(filePath, line, char) - Who uses this?
- Tool 3: ast_grep_search(...) - Find similar patterns
\`\`\`
## AST-grep - STRUCTURAL CODE SEARCH
Use AST-grep for syntax-aware pattern matching (better than regex for code).
**Key Syntax**:
- \`$VAR\`: Match single AST node (identifier, expression, etc.)
- \`$$$\`: Match multiple nodes (arguments, statements, etc.)
**ast_grep_search Examples**:
\`\`\`
// Find function definitions
ast_grep_search(pattern: "function $NAME($$$) { $$$ }", lang: "typescript")
// Find async functions
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
// Find React hooks
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
// Find class definitions
ast_grep_search(pattern: "class $NAME { $$$ }", lang: "typescript")
// Find specific method calls
ast_grep_search(pattern: "console.log($$$)", lang: "typescript")
// Find imports
ast_grep_search(pattern: "import { $$$ } from $MODULE", lang: "typescript")
\`\`\`
**When to Use**:
- **AST-grep**: Structural patterns (function defs, class methods, hook usage)
- **Grep**: Text search (comments, strings, TODOs)
- **LSP**: Symbol-based search (find by name, type info)
## Guidelines
### Tool Selection:
- Use **Glob** for broad file pattern matching (e.g., \`**/*.py\`, \`src/**/*.ts\`)
- Use **Grep** for searching file contents with regex patterns
- Use **Read** when you know the specific file path you need to read
- Use **List** for exploring directory structure
- Use **Bash** ONLY for read-only operations (ls, git status, git log, git diff, find)
- NEVER use Bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
- Use **Bash** for Git commands and read-only operations
- Use **ast_grep_search** for structural code patterns (functions, classes, hooks)
- Use **lsp_goto_definition** to trace imports and find source definitions
- Use **lsp_find_references** to find all usages of a symbol
### Bash Usage:
**ALLOWED** (read-only):
- \`git log\`, \`git blame\`, \`git show\`, \`git diff\`
- \`git branch\`, \`git tag\`, \`git remote\`
- \`git log -S\`, \`git log --grep\`
- \`ls\`, \`find\` (for directory exploration)
**FORBIDDEN** (state-changing):
- \`mkdir\`, \`touch\`, \`rm\`, \`cp\`, \`mv\`
- \`git add\`, \`git commit\`, \`git push\`, \`git checkout\`
- \`npm install\`, \`pip install\`, or any installation
### Best Practices:
- **ALWAYS launch 3+ tools in parallel** in your first search action
- Use Git history to understand code evolution
- Use \`git blame\` to understand why code is written a certain way
- Use \`git log -S\` to find when specific code was added/removed
- Adapt your search approach based on the thoroughness level specified by the caller
- Return file paths as absolute paths in your final response
- For clear communication, avoid using emojis

View File

@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
export const librarianAgent: AgentConfig = {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI and Context7. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent",
model: "anthropic/claude-haiku-4-5",
temperature: 0.1,
@@ -21,72 +21,224 @@ Your role is to provide thorough, comprehensive analysis and explanations of cod
- Explain how features work end-to-end across multiple repositories
- Understand code evolution through commit history
- Create visual diagrams when helpful for understanding complex systems
- **Provide EVIDENCE with GitHub permalinks** citing specific code from the exact version being used
## CORE DIRECTIVES
1. **ACCURACY OVER SPEED**: Verify information against official documentation or source code. Do not guess APIs.
2. **CITATION REQUIRED**: Every claim about code behavior must be backed by a link to a file, a line of code, or a documentation page.
3. **SOURCE OF TRUTH**:
- For **How-To**: Use \`context7\` (Official Docs).
2. **CITATION WITH PERMALINKS REQUIRED**: Every claim about code behavior must be backed by:
- **GitHub Permalink**: \`https://github.com/owner/repo/blob/<commit-sha>/path/to/file#L10-L20\`
- Line numbers for specific code sections
- The exact version/commit being referenced
3. **EVIDENCE-BASED REASONING**: Do NOT just summarize documentation. You must:
- Show the **specific code** that implements the behavior
- Explain **WHY** it works that way by citing the actual implementation
- Provide **permalinks** so users can verify your claims
4. **SOURCE OF TRUTH**:
- For **How-To**: Use \`context7\` (Official Docs) + verify with source code.
- For **Real-World Usage**: Use \`gh search code\` (GitHub).
- For **Internal Logic**: Use \`gh repo view\` or \`read\` (Source Code).
- For **Internal Logic**: Clone repo to \`/tmp\` and read source directly.
- For **Change History/Intent**: Use \`git log\` or \`git blame\` (Commit History).
- For **Local Codebase Context**: Use \`Explore\` agent (File patterns, code search).
- For **Local Codebase Context**: Use \`Glob\`, \`Grep\`, \`ast_grep_search\` (File patterns, code search).
- For **Latest Information**: Use \`WebSearch\` for recent updates, blog posts, discussions.
## MANDATORY PARALLEL TOOL EXECUTION
**CRITICAL**: You MUST execute **AT LEAST 5 tool calls in parallel** whenever possible.
When starting a research task, launch ALL of these simultaneously:
1. \`context7_resolve-library-id\` - Get library documentation ID
2. \`gh search code\` - Search for code examples
3. \`WebSearch\` - Find latest discussions, blog posts, updates
4. \`gh repo clone\` to \`/tmp\` - Clone repo for deep analysis
5. \`Glob\` / \`Grep\` - Search local codebase for related code
6. \`lsp_goto_definition\` / \`lsp_find_references\` - Trace definitions and usages
7. \`ast_grep_search\` - AST-aware pattern matching
**Example parallel execution**:
\`\`\`
// Launch ALL 5+ tools in a SINGLE message:
- Tool 1: context7_resolve-library-id("react-query")
- Tool 2: gh search code "useQuery" --repo tanstack/query --language typescript
- Tool 3: WebSearch("tanstack query v5 migration guide 2024")
- Tool 4: bash: git clone --depth 1 https://github.com/TanStack/query.git /tmp/tanstack-query
- Tool 5: Glob("**/*query*.ts") - Find query-related files locally
- Tool 6: gh api repos/tanstack/query/releases/latest
- Tool 7: ast_grep_search(pattern: "useQuery($$$)", lang: "typescript")
\`\`\`
**NEVER** execute tools sequentially when they can run in parallel. Sequential execution is ONLY allowed when a tool's input depends on another tool's output.
## TOOL USAGE STANDARDS
### 1. GitHub CLI (\`gh\`)
You have full access to the GitHub CLI via the \`bash\` tool. Use it to search, view, and analyze remote repositories.
### 1. GitHub CLI (\`gh\`) - EXTENSIVE USE REQUIRED
You have full access to the GitHub CLI via the \`bash\` tool. Use it extensively.
- **Searching Code**:
- \`gh search code "query" --language "lang"\`
- **ALWAYS** scope searches to an organization or user if known (e.g., \`user:microsoft\`).
- **ALWAYS** include the file extension if known (e.g., \`extension:tsx\`).
- **Viewing Files**:
- \`gh repo view owner/repo --content path/to/file\`
- Use this to inspect library internals without cloning the entire repo.
- **Searching Issues**:
- \`gh search issues "error message" --state closed\`
- **Viewing Files with Permalinks**:
- \`gh api repos/owner/repo/contents/path/to/file?ref=<sha>\`
- \`gh browse owner/repo --commit <sha> -- path/to/file\`
- Use this to get exact permalinks for citation.
- **Getting Commit SHA for Permalinks**:
- \`gh api repos/owner/repo/commits/HEAD --jq '.sha'\`
- \`gh api repos/owner/repo/git/refs/tags/v1.0.0 --jq '.object.sha'\`
- **Cloning for Deep Analysis**:
- \`gh repo clone owner/repo /tmp/repo-name -- --depth 1\`
- Clone to \`/tmp\` directory for comprehensive source analysis.
- After cloning, use \`git log\`, \`git blame\`, and direct file reading.
- **Searching Issues & PRs**:
- \`gh search issues "error message" --repo owner/repo --state closed\`
- \`gh search prs "feature" --repo owner/repo --state merged\`
- Use this for debugging and finding resolved edge cases.
- **Getting Release Information**:
- \`gh api repos/owner/repo/releases/latest\`
- \`gh release list --repo owner/repo\`
### 2. Context7 (Documentation)
Use this for authoritative API references and framework guides.
- **Step 1**: Call \`context7_resolve-library-id\` with the library name.
- **Step 2**: Call \`context7_get-library-docs\` with the ID and a specific topic (e.g., "authentication", "middleware").
- **IMPORTANT**: Documentation alone is NOT sufficient. Always cross-reference with actual source code.
### 3. WebFetch
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites).
### 3. WebSearch - MANDATORY FOR LATEST INFO
Use WebSearch for:
- Latest library updates and changelogs
- Migration guides and breaking changes
- Community discussions and best practices
- Blog posts explaining implementation details
- Recent bug reports and workarounds
### 4. Git History (\`git log\`, \`git blame\`)
Use this for understanding code evolution and authorial intent in local repositories.
**Example searches**:
- \`"react 19 new features 2024"\`
- \`"tanstack query v5 breaking changes"\`
- \`"next.js app router migration guide"\`
### 4. WebFetch
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites, GitHub blob pages).
### 5. Repository Cloning to /tmp
**CRITICAL**: For deep source analysis, ALWAYS clone repositories to \`/tmp\`:
\`\`\`bash
# Clone with minimal history for speed
gh repo clone owner/repo /tmp/repo-name -- --depth 1
# Or clone specific tag/version
gh repo clone owner/repo /tmp/repo-name -- --depth 1 --branch v1.0.0
# Then explore the cloned repo
cd /tmp/repo-name
git log --oneline -n 10
cat package.json # Check version
\`\`\`
**Benefits of cloning**:
- Full file access without API rate limits
- Can use \`git blame\`, \`git log\`, \`grep\`, etc.
- Enables comprehensive code analysis
- Can check out specific versions to match user's environment
### 6. Git History (\`git log\`, \`git blame\`)
Use this for understanding code evolution and authorial intent.
- **Viewing Change History**:
- \`git log --oneline -n 20 -- path/to/file\`
- Use this to understand how a file evolved and why changes were made.
- **Line-by-Line Attribution**:
- \`git blame path/to/file\`
- \`git blame -L 10,20 path/to/file\`
- Use this to identify who wrote specific code and when.
- **Commit Details**:
- \`git show <commit-hash>\`
- Use this to see full context of a specific change.
- **Getting Permalinks from Blame**:
- Use commit SHA from blame to construct GitHub permalinks.
### 5. Explore Agent (Subagent)
Use this when searching for files, patterns, or context within the local codebase.
### 7. Local Codebase Search (Glob, Grep, Read)
Use these for searching files and patterns in the local codebase.
**PRIMARY GOAL**: Each Explore agent finds **ONE specific thing** with a clear, focused objective.
- **Glob**: Find files by pattern (e.g., \`**/*.tsx\`, \`src/**/auth*.ts\`)
- **Grep**: Search file contents with regex patterns
- **Read**: Read specific files when you know the path
- **When to Use**:
- Finding files by patterns (e.g., "src/**/*.tsx")
- Searching code for keywords (e.g., "API endpoints")
- Understanding codebase structure or architecture
- **Parallel Execution Strategy**:
- **ALWAYS** spawn multiple Explore agents in parallel for different search targets.
- Each agent should focus on ONE specific search task.
- Example: If searching for "auth logic" and "API routes", spawn TWO separate agents.
- **Context Passing**:
- When contextual search is needed, pass **ALL relevant context** to the agent.
- Include: what you're looking for, why, and any related information that helps narrow down the search.
- The agent should have enough context to find exactly what's needed without guessing.
**Parallel Search Strategy**:
\`\`\`
// Launch multiple searches in parallel:
- Tool 1: Glob("**/*auth*.ts") - Find auth-related files
- Tool 2: Grep("authentication") - Search for auth patterns
- Tool 3: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
\`\`\`
### 8. LSP Tools - DEFINITIONS & REFERENCES
Use LSP for finding definitions and references - these are its unique strengths over text search.
**Primary LSP Tools**:
- \`lsp_goto_definition\`: Jump to where a symbol is **defined** (resolves imports, type aliases, etc.)
- \`lsp_goto_definition(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
- \`lsp_find_references\`: Find **ALL usages** of a symbol across the entire workspace
- \`lsp_find_references(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
**When to Use LSP** (vs Grep/AST-grep):
- **lsp_goto_definition**: When you need to follow an import or find the source definition
- **lsp_find_references**: When you need to understand impact of changes (who calls this function?)
**Why LSP for these**:
- Grep finds text matches but can't resolve imports or type aliases
- AST-grep finds structural patterns but can't follow cross-file references
- LSP understands the full type system and can trace through imports
**Parallel Execution**:
\`\`\`
// When tracing code flow, launch in parallel:
- Tool 1: lsp_goto_definition(filePath, line, char) - Find where it's defined
- Tool 2: lsp_find_references(filePath, line, char) - Find all usages
- Tool 3: ast_grep_search(...) - Find similar patterns
- Tool 4: Grep(...) - Text fallback
\`\`\`
### 9. AST-grep - AST-AWARE PATTERN SEARCH
Use AST-grep for structural code search that understands syntax, not just text.
**Key Features**:
- Supports 25+ languages (typescript, javascript, python, rust, go, etc.)
- Uses meta-variables: \`$VAR\` (single node), \`$$$\` (multiple nodes)
- Patterns must be complete AST nodes (valid code)
**ast_grep_search Examples**:
\`\`\`
// Find all console.log calls
ast_grep_search(pattern: "console.log($MSG)", lang: "typescript")
// Find all async functions
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
// Find React useState hooks
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
// Find Python class definitions
ast_grep_search(pattern: "class $NAME($$$)", lang: "python")
// Find all export statements
ast_grep_search(pattern: "export { $$$ }", lang: "typescript")
// Find function calls with specific argument patterns
ast_grep_search(pattern: "fetch($URL, { method: $METHOD })", lang: "typescript")
\`\`\`
**When to Use AST-grep vs Grep**:
- **AST-grep**: When you need structural matching (e.g., "find all function definitions")
- **Grep**: When you need text matching (e.g., "find all occurrences of 'TODO'")
**Parallel AST-grep Execution**:
\`\`\`
// When analyzing a codebase pattern, launch in parallel:
- Tool 1: ast_grep_search(pattern: "useQuery($$$)", lang: "tsx") - Find hook usage
- Tool 2: ast_grep_search(pattern: "export function $NAME($$$)", lang: "typescript") - Find exports
- Tool 3: Grep("useQuery") - Text fallback
- Tool 4: Glob("**/*query*.ts") - Find query-related files
\`\`\`
## SEARCH STRATEGY PROTOCOL
@@ -96,50 +248,76 @@ When given a request, follow this **STRICT** workflow:
- If the user references a local file, read it first to understand imports and dependencies.
- Identify the specific library or technology version.
2. **SELECT SOURCE**:
- **Official Docs**: For "How do I use X?" or "What are the options for Y?"
- **Remote Code**: For "Show me an example of X" or "How is X implemented internally?"
- **Issues/PRs**: For "Why is X failing?" or "Is this a bug?"
- **Git History**: For "Why was this changed?" or "Who introduced this?" or "When was this added?"
- **Explore Agent**: For "Where is X defined?" or "How does this codebase handle Y?" or "Find all files matching Z pattern"
2. **PARALLEL INVESTIGATION** (Launch 5+ tools simultaneously):
- \`context7\`: Get official documentation
- \`gh search code\`: Find implementation examples
- \`WebSearch\`: Get latest updates and discussions
- \`gh repo clone\`: Clone to /tmp for deep analysis
- \`Glob\` / \`Grep\` / \`ast_grep_search\`: Search local codebase
- \`gh api\`: Get release/version information
3. **EXECUTE & REFINE**:
- Run the initial search.
- If results are too broad (>50), add filters (\`path:\`, \`filename:\`).
- If results are zero, broaden the search (remove quotes, remove language filter).
3. **DEEP SOURCE ANALYSIS**:
- Navigate to the cloned repo in /tmp
- Find the specific file implementing the feature
- Use \`git blame\` to understand why code is written that way
- Get the commit SHA for permalink construction
4. **SYNTHESIZE**:
- Present the findings clearly.
4. **SYNTHESIZE WITH EVIDENCE**:
- Present findings with **GitHub permalinks**
- **FORMAT**:
- **RESOURCE**: [Name] ([URL])
- **RELEVANCE**: Why this matters.
- **CONTENT**: The code snippet or documentation summary.
- **CLAIM**: What you're asserting about the code
- **EVIDENCE**: The specific code that proves it
- **PERMALINK**: \`https://github.com/owner/repo/blob/<sha>/path#L10-L20\`
- **EXPLANATION**: Why this code behaves this way
## CITATION FORMAT - MANDATORY
Every code-related claim MUST include:
\`\`\`markdown
**Claim**: [What you're asserting]
**Evidence** ([permalink](https://github.com/owner/repo/blob/abc123/src/file.ts#L42-L50)):
\\\`\\\`\\\`typescript
// The actual code from lines 42-50
function example() {
// ...
}
\\\`\\\`\\\`
**Explanation**: This code shows that [reason] because [specific detail from the code].
\`\`\`
## FAILURE RECOVERY
- If \`context7\` fails to find docs, use \`gh repo view\` to read the repository's \`README.md\` or \`CONTRIBUTING.md\`.
- If \`context7\` fails to find docs, clone the repo to \`/tmp\` and read the source directly.
- If code search yields nothing, search for the *concept* rather than the specific function name.
- If GitHub API has rate limits, use cloned repos in \`/tmp\` for analysis.
- If unsure, **STATE YOUR UNCERTAINTY** and propose a hypothesis based on standard conventions.
## VOICE AND TONE
- **PROFESSIONAL**: You are an expert archivist. Be concise and precise.
- **OBJECTIVE**: Present facts found in the search. Do not offer personal opinions unless asked.
- **EVIDENCE-DRIVEN**: Always back claims with permalinks and code snippets.
- **HELPFUL**: If a direct answer isn't found, provide the closest relevant examples or related documentation.
## MULTI-REPOSITORY ANALYSIS GUIDELINES
- Use available tools extensively to explore repositories
- Execute tools in parallel when possible for efficiency
- Clone multiple repos to /tmp for cross-repository analysis
- Execute AT LEAST 5 tools in parallel when possible for efficiency
- Read files thoroughly to understand implementation details
- Search for patterns and related code across multiple repositories
- Use commit search to understand how code evolved over time
- Focus on thorough understanding and comprehensive explanation across repositories
- Create mermaid diagrams to visualize complex relationships or flows
- Always provide permalinks for cross-repository references
## COMMUNICATION
You must use Markdown for formatting your responses.
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.`,
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.
**REMEMBER**: Your job is not just to find and summarize documentation. You must provide **EVIDENCE** showing exactly **WHY** the code works the way it does, with **permalinks** to the specific implementation so users can verify your claims.`,
}

View File

@@ -4,7 +4,7 @@ export const oracleAgent: AgentConfig = {
description:
"Expert AI advisor with advanced reasoning capabilities for high-quality technical guidance, code reviews, architectural advice, and strategic planning.",
mode: "subagent",
model: "openai/gpt-5.1",
model: "openai/gpt-5.2",
temperature: 0.1,
reasoningEffort: "medium",
textVerbosity: "high",

View File

@@ -5,6 +5,7 @@ import { librarianAgent } from "./librarian"
import { exploreAgent } from "./explore"
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { documentWriterAgent } from "./document-writer"
import { deepMerge } from "../shared"
const allBuiltinAgents: Record<AgentName, AgentConfig> = {
oracle: oracleAgent,
@@ -18,16 +19,7 @@ function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
return {
...base,
...override,
tools: override.tools !== undefined
? { ...(base.tools ?? {}), ...override.tools }
: base.tools,
permission: override.permission !== undefined
? { ...(base.permission ?? {}), ...override.permission }
: base.permission,
}
return deepMerge(base, override as Partial<AgentConfig>)
}
export function createBuiltinAgents(

View File

@@ -0,0 +1,74 @@
/**
* Antigravity OAuth configuration constants.
* Values sourced from cliproxyapi/sdk/auth/antigravity.go
*
* ## Logging Policy
*
* All console logging in antigravity modules follows a consistent policy:
*
* - **Debug logs**: Guard with `if (process.env.ANTIGRAVITY_DEBUG === "1")`
* - Includes: info messages, warnings, non-fatal errors
* - Enable debugging: `ANTIGRAVITY_DEBUG=1 opencode`
*
* - **Fatal errors**: None currently. All errors are handled by returning
* appropriate error responses to OpenCode's auth system.
*
* This policy ensures production silence while enabling verbose debugging
* when needed for troubleshooting OAuth flows.
*/
// OAuth 2.0 Client Credentials
export const ANTIGRAVITY_CLIENT_ID =
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
export const ANTIGRAVITY_CLIENT_SECRET = "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"
// OAuth Callback
export const ANTIGRAVITY_CALLBACK_PORT = 51121
export const ANTIGRAVITY_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_CALLBACK_PORT}/oauth-callback`
// OAuth Scopes
export const ANTIGRAVITY_SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
] as const
// API Endpoint Fallbacks (order: daily → autopush → prod)
export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [
"https://daily-cloudcode-pa.sandbox.googleapis.com", // dev
"https://autopush-cloudcode-pa.sandbox.googleapis.com", // staging
"https://cloudcode-pa.googleapis.com", // prod
] as const
// API Version
export const ANTIGRAVITY_API_VERSION = "v1internal"
// Request Headers
export const ANTIGRAVITY_HEADERS = {
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
} as const
// Default Project ID (fallback when loadCodeAssist API fails)
// From opencode-antigravity-auth reference implementation
export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
// Google OAuth endpoints
export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
export const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
// Token refresh buffer (refresh 60 seconds before expiry)
export const ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS = 60_000
// Default thought signature to skip validation (CLIProxyAPI approach)
export const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"

View File

@@ -0,0 +1,475 @@
/**
* Antigravity Fetch Interceptor
*
* Creates a custom fetch function that:
* - Checks token expiration and auto-refreshes
* - Rewrites URLs to Antigravity endpoints
* - Applies request transformation (including tool normalization)
* - Applies response transformation (including thinking extraction)
* - Implements endpoint fallback (daily → autopush → prod)
*
* **Body Type Assumption:**
* This interceptor assumes `init.body` is a JSON string (OpenAI format).
* Non-string bodies (ReadableStream, Blob, FormData, URLSearchParams, etc.)
* are passed through unchanged to the original fetch to avoid breaking
* other requests that may not be OpenAI-format API calls.
*
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
*/
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants"
import { fetchProjectContext, clearProjectContextCache } from "./project"
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
import { transformRequest } from "./request"
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
import {
transformResponse,
transformStreamingResponse,
isStreamingResponse,
extractSignatureFromSsePayload,
} from "./response"
import { normalizeToolsForGemini, type OpenAITool } from "./tools"
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
import {
getThoughtSignature,
setThoughtSignature,
getOrCreateSessionId,
} from "./thought-signature-store"
import type { AntigravityTokens } from "./types"
/**
* Auth interface matching OpenCode's auth system
*/
interface Auth {
access?: string
refresh?: string
expires?: number
}
/**
* Client interface for auth operations
*/
interface AuthClient {
set(providerId: string, auth: Auth): Promise<void>
}
/**
* Debug logging helper
* Only logs when ANTIGRAVITY_DEBUG=1
*/
function debugLog(message: string): void {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log(`[antigravity-fetch] ${message}`)
}
}
function isRetryableError(status: number): boolean {
if (status === 0) return true
if (status === 429) return true
if (status >= 500 && status < 600) return true
return false
}
async function isRetryableResponse(response: Response): Promise<boolean> {
if (isRetryableError(response.status)) return true
if (response.status === 403) {
try {
const text = await response.clone().text()
if (text.includes("SUBSCRIPTION_REQUIRED") || text.includes("Gemini Code Assist license")) {
debugLog(`[RETRY] 403 SUBSCRIPTION_REQUIRED detected, will retry with next endpoint`)
return true
}
} catch {}
}
return false
}
interface AttemptFetchOptions {
endpoint: string
url: string
init: RequestInit
accessToken: string
projectId: string
sessionId: string
modelName?: string
thoughtSignature?: string
}
async function attemptFetch(
options: AttemptFetchOptions
): Promise<Response | null | "pass-through"> {
const { endpoint, url, init, accessToken, projectId, sessionId, modelName, thoughtSignature } =
options
debugLog(`Trying endpoint: ${endpoint}`)
try {
const rawBody = init.body
if (rawBody !== undefined && typeof rawBody !== "string") {
debugLog(`Non-string body detected (${typeof rawBody}), signaling pass-through`)
return "pass-through"
}
let parsedBody: Record<string, unknown> = {}
if (rawBody) {
try {
parsedBody = JSON.parse(rawBody) as Record<string, unknown>
} catch {
parsedBody = {}
}
}
debugLog(`[BODY] Keys: ${Object.keys(parsedBody).join(", ")}`)
debugLog(`[BODY] Has contents: ${!!parsedBody.contents}, Has messages: ${!!parsedBody.messages}`)
if (parsedBody.contents) {
const contents = parsedBody.contents as Array<Record<string, unknown>>
debugLog(`[BODY] contents length: ${contents.length}`)
contents.forEach((c, i) => {
debugLog(`[BODY] contents[${i}].role: ${c.role}, parts: ${JSON.stringify(c.parts).substring(0, 200)}`)
})
}
if (parsedBody.tools && Array.isArray(parsedBody.tools)) {
const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[])
if (normalizedTools) {
parsedBody.tools = normalizedTools
}
}
if (hasOpenAIMessages(parsedBody)) {
debugLog(`[CONVERT] Converting OpenAI messages to Gemini contents`)
parsedBody = convertRequestBody(parsedBody, thoughtSignature)
debugLog(`[CONVERT] After conversion - Has contents: ${!!parsedBody.contents}`)
}
const transformed = transformRequest({
url,
body: parsedBody,
accessToken,
projectId,
sessionId,
modelName,
endpointOverride: endpoint,
thoughtSignature,
})
debugLog(`[REQ] streaming=${transformed.streaming}, url=${transformed.url}`)
const response = await fetch(transformed.url, {
method: init.method || "POST",
headers: transformed.headers,
body: JSON.stringify(transformed.body),
signal: init.signal,
})
debugLog(
`[RESP] status=${response.status} content-type=${response.headers.get("content-type") ?? ""} url=${response.url}`
)
if (!response.ok && (await isRetryableResponse(response))) {
debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`)
return null
}
return response
} catch (error) {
debugLog(
`Endpoint failed: ${endpoint} (${error instanceof Error ? error.message : "Unknown error"}), trying next`
)
return null
}
}
interface GeminiResponsePart {
thoughtSignature?: string
thought_signature?: string
functionCall?: Record<string, unknown>
text?: string
[key: string]: unknown
}
interface GeminiResponseCandidate {
content?: {
parts?: GeminiResponsePart[]
[key: string]: unknown
}
[key: string]: unknown
}
interface GeminiResponseBody {
candidates?: GeminiResponseCandidate[]
[key: string]: unknown
}
function extractSignatureFromResponse(parsed: GeminiResponseBody): string | undefined {
if (!parsed.candidates || !Array.isArray(parsed.candidates)) {
return undefined
}
for (const candidate of parsed.candidates) {
const parts = candidate.content?.parts
if (!parts || !Array.isArray(parts)) {
continue
}
for (const part of parts) {
const sig = part.thoughtSignature || part.thought_signature
if (sig && typeof sig === "string") {
return sig
}
}
}
return undefined
}
async function transformResponseWithThinking(
response: Response,
modelName: string,
fetchInstanceId: string
): Promise<Response> {
const streaming = isStreamingResponse(response)
let result
if (streaming) {
result = await transformStreamingResponse(response)
} else {
result = await transformResponse(response)
}
if (streaming) {
return result.response
}
try {
const text = await result.response.clone().text()
debugLog(`[TSIG][RESP] Response text length: ${text.length}`)
const parsed = JSON.parse(text) as GeminiResponseBody
debugLog(`[TSIG][RESP] Parsed keys: ${Object.keys(parsed).join(", ")}`)
debugLog(`[TSIG][RESP] Has candidates: ${!!parsed.candidates}, count: ${parsed.candidates?.length ?? 0}`)
const signature = extractSignatureFromResponse(parsed)
debugLog(`[TSIG][RESP] Signature extracted: ${signature ? signature.substring(0, 30) + "..." : "NONE"}`)
if (signature) {
setThoughtSignature(fetchInstanceId, signature)
debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}`)
} else {
debugLog(`[TSIG][WARN] No signature found in response!`)
}
if (shouldIncludeThinking(modelName)) {
const thinkingResult = extractThinkingBlocks(parsed)
if (thinkingResult.hasThinking) {
const transformed = transformResponseThinking(parsed)
return new Response(JSON.stringify(transformed), {
status: result.response.status,
statusText: result.response.statusText,
headers: result.response.headers,
})
}
}
} catch {}
return result.response
}
/**
* Create Antigravity fetch interceptor
*
* Factory function that creates a custom fetch function for Antigravity API.
* Handles token management, request/response transformation, and endpoint fallback.
*
* @param getAuth - Async function to retrieve current auth state
* @param client - Auth client for saving updated tokens
* @param providerId - Provider identifier (e.g., "google")
* @param clientId - Optional custom client ID for token refresh (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret for token refresh (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Custom fetch function compatible with standard fetch signature
*
* @example
* ```typescript
* const customFetch = createAntigravityFetch(
* () => auth(),
* client,
* "google",
* "custom-client-id",
* "custom-client-secret"
* )
*
* // Use like standard fetch
* const response = await customFetch("https://api.example.com/chat", {
* method: "POST",
* body: JSON.stringify({ messages: [...] })
* })
* ```
*/
export function createAntigravityFetch(
getAuth: () => Promise<Auth>,
client: AuthClient,
providerId: string,
clientId?: string,
clientSecret?: string
): (url: string, init?: RequestInit) => Promise<Response> {
let cachedTokens: AntigravityTokens | null = null
let cachedProjectId: string | null = null
const fetchInstanceId = crypto.randomUUID()
return async (url: string, init: RequestInit = {}): Promise<Response> => {
debugLog(`Intercepting request to: ${url}`)
// Get current auth state
const auth = await getAuth()
if (!auth.access || !auth.refresh) {
throw new Error("Antigravity: No authentication tokens available")
}
// Parse stored token format
const refreshParts = parseStoredToken(auth.refresh)
// Build initial token state
if (!cachedTokens) {
cachedTokens = {
type: "antigravity",
access_token: auth.access,
refresh_token: refreshParts.refreshToken,
expires_in: auth.expires ? Math.floor((auth.expires - Date.now()) / 1000) : 3600,
timestamp: auth.expires ? auth.expires - 3600 * 1000 : Date.now(),
}
} else {
// Update with fresh values
cachedTokens.access_token = auth.access
cachedTokens.refresh_token = refreshParts.refreshToken
}
// Check token expiration and refresh if needed
if (isTokenExpired(cachedTokens)) {
debugLog("Token expired, refreshing...")
try {
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
// Update cached tokens
cachedTokens = {
type: "antigravity",
access_token: newTokens.access_token,
refresh_token: newTokens.refresh_token,
expires_in: newTokens.expires_in,
timestamp: Date.now(),
}
// Clear project context cache on token refresh
clearProjectContextCache()
// Format and save new tokens
const formattedRefresh = formatTokenForStorage(
newTokens.refresh_token,
refreshParts.projectId || "",
refreshParts.managedProjectId
)
await client.set(providerId, {
access: newTokens.access_token,
refresh: formattedRefresh,
expires: Date.now() + newTokens.expires_in * 1000,
})
debugLog("Token refreshed successfully")
} catch (error) {
throw new Error(
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
)
}
}
// Fetch project ID via loadCodeAssist (CLIProxyAPI approach)
if (!cachedProjectId) {
const projectContext = await fetchProjectContext(cachedTokens.access_token)
cachedProjectId = projectContext.cloudaicompanionProject || ""
debugLog(`[PROJECT] Fetched project ID: "${cachedProjectId}"`)
}
const projectId = cachedProjectId
debugLog(`[PROJECT] Using project ID: "${projectId}"`)
// Extract model name from request body
let modelName: string | undefined
if (init.body) {
try {
const body =
typeof init.body === "string"
? (JSON.parse(init.body) as Record<string, unknown>)
: (init.body as unknown as Record<string, unknown>)
if (typeof body.model === "string") {
modelName = body.model
}
} catch {
// Ignore parsing errors
}
}
const maxEndpoints = Math.min(ANTIGRAVITY_ENDPOINT_FALLBACKS.length, 3)
const sessionId = getOrCreateSessionId(fetchInstanceId)
const thoughtSignature = getThoughtSignature(fetchInstanceId)
debugLog(`[TSIG][GET] sessionId=${sessionId}, signature=${thoughtSignature ? thoughtSignature.substring(0, 20) + "..." : "none"}`)
for (let i = 0; i < maxEndpoints; i++) {
const endpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i]
const response = await attemptFetch({
endpoint,
url,
init,
accessToken: cachedTokens.access_token,
projectId,
sessionId,
modelName,
thoughtSignature,
})
if (response === "pass-through") {
debugLog("Non-string body detected, passing through with auth headers")
const headersWithAuth = {
...init.headers,
Authorization: `Bearer ${cachedTokens.access_token}`,
}
return fetch(url, { ...init, headers: headersWithAuth })
}
if (response) {
debugLog(`Success with endpoint: ${endpoint}`)
const transformedResponse = await transformResponseWithThinking(
response,
modelName || "",
fetchInstanceId
)
return transformedResponse
}
}
// All endpoints failed
const errorMessage = `All Antigravity endpoints failed after ${maxEndpoints} attempts`
debugLog(errorMessage)
// Return error response
return new Response(
JSON.stringify({
error: {
message: errorMessage,
type: "endpoint_failure",
code: "all_endpoints_failed",
},
}),
{
status: 503,
statusText: "Service Unavailable",
headers: { "Content-Type": "application/json" },
}
)
}
}
/**
* Type export for createAntigravityFetch return type
*/
export type AntigravityFetch = (url: string, init?: RequestInit) => Promise<Response>

View File

@@ -0,0 +1,13 @@
export * from "./types"
export * from "./constants"
export * from "./oauth"
export * from "./token"
export * from "./project"
export * from "./request"
export * from "./response"
export * from "./tools"
export * from "./thinking"
export * from "./thought-signature-store"
export * from "./message-converter"
export * from "./fetch"
export * from "./plugin"

View File

@@ -0,0 +1,206 @@
/**
* OpenAI → Gemini message format converter
*
* Converts OpenAI-style messages to Gemini contents format,
* injecting thoughtSignature into functionCall parts.
*/
import { SKIP_THOUGHT_SIGNATURE_VALIDATOR } from "./constants"
function debugLog(message: string): void {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log(`[antigravity-converter] ${message}`)
}
}
interface OpenAIMessage {
role: "system" | "user" | "assistant" | "tool"
content?: string | OpenAIContentPart[]
tool_calls?: OpenAIToolCall[]
tool_call_id?: string
name?: string
}
interface OpenAIContentPart {
type: string
text?: string
image_url?: { url: string }
[key: string]: unknown
}
interface OpenAIToolCall {
id: string
type: "function"
function: {
name: string
arguments: string
}
}
interface GeminiPart {
text?: string
functionCall?: {
name: string
args: Record<string, unknown>
}
functionResponse?: {
name: string
response: Record<string, unknown>
}
inlineData?: {
mimeType: string
data: string
}
thought_signature?: string
[key: string]: unknown
}
interface GeminiContent {
role: "user" | "model"
parts: GeminiPart[]
}
export function convertOpenAIToGemini(
messages: OpenAIMessage[],
thoughtSignature?: string
): GeminiContent[] {
debugLog(`Converting ${messages.length} messages, signature: ${thoughtSignature ? "present" : "none"}`)
const contents: GeminiContent[] = []
for (const msg of messages) {
if (msg.role === "system") {
contents.push({
role: "user",
parts: [{ text: typeof msg.content === "string" ? msg.content : "" }],
})
continue
}
if (msg.role === "user") {
const parts = convertContentToParts(msg.content)
contents.push({ role: "user", parts })
continue
}
if (msg.role === "assistant") {
const parts: GeminiPart[] = []
if (msg.content) {
parts.push(...convertContentToParts(msg.content))
}
if (msg.tool_calls && msg.tool_calls.length > 0) {
for (const toolCall of msg.tool_calls) {
let args: Record<string, unknown> = {}
try {
args = JSON.parse(toolCall.function.arguments)
} catch {
args = {}
}
const part: GeminiPart = {
functionCall: {
name: toolCall.function.name,
args,
},
}
// Always inject signature: use provided or default to skip validator (CLIProxyAPI approach)
part.thoughtSignature = thoughtSignature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
debugLog(`Injected signature into functionCall: ${toolCall.function.name} (${thoughtSignature ? "provided" : "default"})`)
parts.push(part)
}
}
if (parts.length > 0) {
contents.push({ role: "model", parts })
}
continue
}
if (msg.role === "tool") {
let response: Record<string, unknown> = {}
try {
response = typeof msg.content === "string"
? JSON.parse(msg.content)
: { result: msg.content }
} catch {
response = { result: msg.content }
}
const toolName = msg.name || "unknown"
contents.push({
role: "user",
parts: [{
functionResponse: {
name: toolName,
response,
},
}],
})
continue
}
}
debugLog(`Converted to ${contents.length} content blocks`)
return contents
}
function convertContentToParts(content: string | OpenAIContentPart[] | undefined): GeminiPart[] {
if (!content) {
return [{ text: "" }]
}
if (typeof content === "string") {
return [{ text: content }]
}
const parts: GeminiPart[] = []
for (const part of content) {
if (part.type === "text" && part.text) {
parts.push({ text: part.text })
} else if (part.type === "image_url" && part.image_url?.url) {
const url = part.image_url.url
if (url.startsWith("data:")) {
const match = url.match(/^data:([^;]+);base64,(.+)$/)
if (match) {
parts.push({
inlineData: {
mimeType: match[1],
data: match[2],
},
})
}
}
}
}
return parts.length > 0 ? parts : [{ text: "" }]
}
export function hasOpenAIMessages(body: Record<string, unknown>): boolean {
return Array.isArray(body.messages) && body.messages.length > 0
}
export function convertRequestBody(
body: Record<string, unknown>,
thoughtSignature?: string
): Record<string, unknown> {
if (!hasOpenAIMessages(body)) {
debugLog("No messages array found, returning body as-is")
return body
}
const messages = body.messages as OpenAIMessage[]
const contents = convertOpenAIToGemini(messages, thoughtSignature)
const converted = { ...body }
delete converted.messages
converted.contents = contents
debugLog(`Converted body: messages → contents (${contents.length} blocks)`)
return converted
}

View File

@@ -0,0 +1,361 @@
/**
* Antigravity OAuth 2.0 flow implementation with PKCE.
* Handles Google OAuth for Antigravity authentication.
*/
import { generatePKCE } from "@openauthjs/openauth/pkce"
import {
ANTIGRAVITY_CLIENT_ID,
ANTIGRAVITY_CLIENT_SECRET,
ANTIGRAVITY_REDIRECT_URI,
ANTIGRAVITY_SCOPES,
ANTIGRAVITY_CALLBACK_PORT,
GOOGLE_AUTH_URL,
GOOGLE_TOKEN_URL,
GOOGLE_USERINFO_URL,
} from "./constants"
import type {
AntigravityTokenExchangeResult,
AntigravityUserInfo,
} from "./types"
/**
* PKCE pair containing verifier and challenge.
*/
export interface PKCEPair {
/** PKCE verifier - used during token exchange */
verifier: string
/** PKCE challenge - sent in auth URL */
challenge: string
/** Challenge method - always "S256" */
method: string
}
/**
* OAuth state encoded in the auth URL.
* Contains the PKCE verifier for later retrieval.
*/
export interface OAuthState {
/** PKCE verifier */
verifier: string
/** Optional project ID */
projectId?: string
}
/**
* Result from building an OAuth authorization URL.
*/
export interface AuthorizationResult {
/** Full OAuth URL to open in browser */
url: string
/** PKCE verifier to use during code exchange */
verifier: string
}
/**
* Result from the OAuth callback server.
*/
export interface CallbackResult {
/** Authorization code from Google */
code: string
/** State parameter from callback */
state: string
/** Error message if any */
error?: string
}
/**
* Generate PKCE verifier and challenge pair.
* Uses @openauthjs/openauth for cryptographically secure generation.
*
* @returns PKCE pair with verifier, challenge, and method
*/
export async function generatePKCEPair(): Promise<PKCEPair> {
const pkce = await generatePKCE()
return {
verifier: pkce.verifier,
challenge: pkce.challenge,
method: pkce.method,
}
}
/**
* Encode OAuth state into a URL-safe base64 string.
*
* @param state - OAuth state object
* @returns Base64URL encoded state
*/
function encodeState(state: OAuthState): string {
const json = JSON.stringify(state)
return Buffer.from(json, "utf8").toString("base64url")
}
/**
* Decode OAuth state from a base64 string.
*
* @param encoded - Base64URL or Base64 encoded state
* @returns Decoded OAuth state
*/
export function decodeState(encoded: string): OAuthState {
// Handle both base64url and standard base64
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/")
const padded = normalized.padEnd(
normalized.length + ((4 - (normalized.length % 4)) % 4),
"="
)
const json = Buffer.from(padded, "base64").toString("utf8")
const parsed = JSON.parse(json)
if (typeof parsed.verifier !== "string") {
throw new Error("Missing PKCE verifier in state")
}
return {
verifier: parsed.verifier,
projectId:
typeof parsed.projectId === "string" ? parsed.projectId : undefined,
}
}
export async function buildAuthURL(
projectId?: string,
clientId: string = ANTIGRAVITY_CLIENT_ID,
port: number = ANTIGRAVITY_CALLBACK_PORT
): Promise<AuthorizationResult> {
const pkce = await generatePKCEPair()
const state: OAuthState = {
verifier: pkce.verifier,
projectId,
}
const redirectUri = `http://localhost:${port}/oauth-callback`
const url = new URL(GOOGLE_AUTH_URL)
url.searchParams.set("client_id", clientId)
url.searchParams.set("redirect_uri", redirectUri)
url.searchParams.set("response_type", "code")
url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" "))
url.searchParams.set("state", encodeState(state))
url.searchParams.set("code_challenge", pkce.challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("access_type", "offline")
url.searchParams.set("prompt", "consent")
return {
url: url.toString(),
verifier: pkce.verifier,
}
}
/**
* Exchange authorization code for tokens.
*
* @param code - Authorization code from OAuth callback
* @param verifier - PKCE verifier from initial auth request
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Token exchange result with access and refresh tokens
*/
export async function exchangeCode(
code: string,
verifier: string,
clientId: string = ANTIGRAVITY_CLIENT_ID,
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET,
port: number = ANTIGRAVITY_CALLBACK_PORT
): Promise<AntigravityTokenExchangeResult> {
const redirectUri = `http://localhost:${port}/oauth-callback`
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
code_verifier: verifier,
})
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Token exchange failed: ${response.status} - ${errorText}`)
}
const data = (await response.json()) as {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_in: data.expires_in,
token_type: data.token_type,
}
}
/**
* Fetch user info from Google's userinfo API.
*
* @param accessToken - Valid access token
* @returns User info containing email
*/
export async function fetchUserInfo(
accessToken: string
): Promise<AntigravityUserInfo> {
const response = await fetch(`${GOOGLE_USERINFO_URL}?alt=json`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`)
}
const data = (await response.json()) as {
email?: string
name?: string
picture?: string
}
return {
email: data.email || "",
name: data.name,
picture: data.picture,
}
}
export interface CallbackServerHandle {
port: number
waitForCallback: () => Promise<CallbackResult>
close: () => void
}
export function startCallbackServer(
timeoutMs: number = 5 * 60 * 1000
): CallbackServerHandle {
let server: ReturnType<typeof Bun.serve> | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null
let resolveCallback: ((result: CallbackResult) => void) | null = null
let rejectCallback: ((error: Error) => void) | null = null
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
if (server) {
server.stop()
server = null
}
}
server = Bun.serve({
port: 0,
fetch(request: Request): Response {
const url = new URL(request.url)
if (url.pathname === "/oauth-callback") {
const code = url.searchParams.get("code") || ""
const state = url.searchParams.get("state") || ""
const error = url.searchParams.get("error") || undefined
let responseBody: string
if (code && !error) {
responseBody =
"<html><body><h1>Login successful</h1><p>You can close this window.</p></body></html>"
} else {
responseBody =
"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
}
setTimeout(() => {
cleanup()
if (resolveCallback) {
resolveCallback({ code, state, error })
}
}, 100)
return new Response(responseBody, {
status: 200,
headers: { "Content-Type": "text/html" },
})
}
return new Response("Not Found", { status: 404 })
},
})
const actualPort = server.port as number
const waitForCallback = (): Promise<CallbackResult> => {
return new Promise((resolve, reject) => {
resolveCallback = resolve
rejectCallback = reject
timeoutId = setTimeout(() => {
cleanup()
reject(new Error("OAuth callback timeout"))
}, timeoutMs)
})
}
return {
port: actualPort,
waitForCallback,
close: cleanup,
}
}
export async function performOAuthFlow(
projectId?: string,
openBrowser?: (url: string) => Promise<void>,
clientId: string = ANTIGRAVITY_CLIENT_ID,
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
): Promise<{
tokens: AntigravityTokenExchangeResult
userInfo: AntigravityUserInfo
verifier: string
}> {
const serverHandle = startCallbackServer()
try {
const auth = await buildAuthURL(projectId, clientId, serverHandle.port)
if (openBrowser) {
await openBrowser(auth.url)
}
const callback = await serverHandle.waitForCallback()
if (callback.error) {
throw new Error(`OAuth error: ${callback.error}`)
}
if (!callback.code) {
throw new Error("No authorization code received")
}
const state = decodeState(callback.state)
if (state.verifier !== auth.verifier) {
throw new Error("PKCE verifier mismatch - possible CSRF attack")
}
const tokens = await exchangeCode(callback.code, auth.verifier, clientId, clientSecret, serverHandle.port)
const userInfo = await fetchUserInfo(tokens.access_token)
return { tokens, userInfo, verifier: auth.verifier }
} catch (err) {
serverHandle.close()
throw err
}
}

View File

@@ -0,0 +1,295 @@
/**
* Google Antigravity Auth Plugin for OpenCode
*
* Provides OAuth authentication for Google models via Antigravity API.
* This plugin integrates with OpenCode's auth system to enable:
* - OAuth 2.0 with PKCE flow for Google authentication
* - Automatic token refresh
* - Request/response transformation for Antigravity API
*
* @example
* ```json
* // opencode.json
* {
* "plugin": ["oh-my-opencode"],
* "provider": {
* "google": {
* "options": {
* "clientId": "custom-client-id",
* "clientSecret": "custom-client-secret"
* }
* }
* }
* }
* ```
*/
import type { Auth, Provider } from "@opencode-ai/sdk"
import type { AuthHook, AuthOuathResult, PluginInput } from "@opencode-ai/plugin"
import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from "./constants"
import {
buildAuthURL,
exchangeCode,
startCallbackServer,
fetchUserInfo,
decodeState,
} from "./oauth"
import { createAntigravityFetch } from "./fetch"
import { fetchProjectContext } from "./project"
import { formatTokenForStorage } from "./token"
/**
* Provider ID for Google models
* Antigravity is an auth method for Google, not a separate provider
*/
const GOOGLE_PROVIDER_ID = "google"
/**
* Type guard to check if auth is OAuth type
*/
function isOAuthAuth(
auth: Auth
): auth is { type: "oauth"; access: string; refresh: string; expires: number } {
return auth.type === "oauth"
}
/**
* Creates the Google Antigravity OAuth plugin for OpenCode.
*
* This factory function creates an auth plugin that:
* 1. Provides OAuth flow for Google authentication
* 2. Creates a custom fetch interceptor for Antigravity API
* 3. Handles token management and refresh
*
* @param input - Plugin input containing the OpenCode client
* @returns Hooks object with auth configuration
*
* @example
* ```typescript
* // Used by OpenCode automatically when plugin is loaded
* const hooks = await createGoogleAntigravityAuthPlugin({ client, ... })
* ```
*/
export async function createGoogleAntigravityAuthPlugin({
client,
}: PluginInput): Promise<{ auth: AuthHook }> {
// Cache for custom credentials from provider.options
// These are populated by loader() and used by authorize()
// Falls back to defaults if loader hasn't been called yet
let cachedClientId: string = ANTIGRAVITY_CLIENT_ID
let cachedClientSecret: string = ANTIGRAVITY_CLIENT_SECRET
const authHook: AuthHook = {
/**
* Provider identifier - must be "google" as Antigravity is
* an auth method for Google models, not a separate provider
*/
provider: GOOGLE_PROVIDER_ID,
/**
* Loader function called when auth is needed.
* Reads credentials from provider.options and creates custom fetch.
*
* @param auth - Function to retrieve current auth state
* @param provider - Provider configuration including options
* @returns Object with custom fetch function
*/
loader: async (
auth: () => Promise<Auth>,
provider: Provider
): Promise<Record<string, unknown>> => {
const currentAuth = await auth()
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log("[antigravity-plugin] loader called")
console.log("[antigravity-plugin] auth type:", currentAuth?.type)
console.log("[antigravity-plugin] auth keys:", Object.keys(currentAuth || {}))
}
if (!isOAuthAuth(currentAuth)) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log("[antigravity-plugin] NOT OAuth auth, returning empty")
}
return {}
}
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log("[antigravity-plugin] OAuth auth detected, creating custom fetch")
}
cachedClientId =
(provider.options?.clientId as string) || ANTIGRAVITY_CLIENT_ID
cachedClientSecret =
(provider.options?.clientSecret as string) || ANTIGRAVITY_CLIENT_SECRET
// Log if using custom credentials (for debugging)
if (
process.env.ANTIGRAVITY_DEBUG === "1" &&
(cachedClientId !== ANTIGRAVITY_CLIENT_ID ||
cachedClientSecret !== ANTIGRAVITY_CLIENT_SECRET)
) {
console.log(
"[antigravity-plugin] Using custom credentials from provider.options"
)
}
// Create adapter for client.auth.set that matches fetch.ts AuthClient interface
const authClient = {
set: async (
providerId: string,
authData: { access?: string; refresh?: string; expires?: number }
) => {
await client.auth.set({
body: {
type: "oauth",
access: authData.access || "",
refresh: authData.refresh || "",
expires: authData.expires || 0,
},
path: { id: providerId },
})
},
}
// Create auth getter that returns compatible format for fetch.ts
const getAuth = async (): Promise<{
access?: string
refresh?: string
expires?: number
}> => {
const authState = await auth()
if (isOAuthAuth(authState)) {
return {
access: authState.access,
refresh: authState.refresh,
expires: authState.expires,
}
}
return {}
}
const antigravityFetch = createAntigravityFetch(
getAuth,
authClient,
GOOGLE_PROVIDER_ID,
cachedClientId,
cachedClientSecret
)
return {
fetch: antigravityFetch,
apiKey: "antigravity-oauth",
}
},
/**
* Authentication methods available for this provider.
* Only OAuth is supported - no prompts for credentials.
*/
methods: [
{
type: "oauth",
label: "OAuth with Google (Antigravity)",
// NO prompts - credentials come from provider.options or defaults
// OAuth flow starts immediately when user selects this method
/**
* Starts the OAuth authorization flow.
* Opens browser for Google OAuth and waits for callback.
*
* @returns Authorization result with URL and callback
*/
authorize: async (): Promise<AuthOuathResult> => {
const serverHandle = startCallbackServer()
const { url, verifier } = await buildAuthURL(undefined, cachedClientId, serverHandle.port)
return {
url,
instructions:
"Complete the sign-in in your browser. We'll automatically detect when you're done.",
method: "auto",
callback: async () => {
try {
const result = await serverHandle.waitForCallback()
if (result.error) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error(`[antigravity-plugin] OAuth error: ${result.error}`)
}
return { type: "failed" as const }
}
if (!result.code) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error("[antigravity-plugin] No authorization code received")
}
return { type: "failed" as const }
}
const state = decodeState(result.state)
if (state.verifier !== verifier) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error("[antigravity-plugin] PKCE verifier mismatch")
}
return { type: "failed" as const }
}
const tokens = await exchangeCode(result.code, verifier, cachedClientId, cachedClientSecret, serverHandle.port)
try {
const userInfo = await fetchUserInfo(tokens.access_token)
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log(`[antigravity-plugin] Authenticated as: ${userInfo.email}`)
}
} catch {
// User info is optional
}
const projectContext = await fetchProjectContext(tokens.access_token)
const formattedRefresh = formatTokenForStorage(
tokens.refresh_token,
projectContext.cloudaicompanionProject || "",
projectContext.managedProjectId
)
return {
type: "success" as const,
access: tokens.access_token,
refresh: formattedRefresh,
expires: Date.now() + tokens.expires_in * 1000,
}
} catch (error) {
serverHandle.close()
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error(
`[antigravity-plugin] OAuth flow failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
)
}
return { type: "failed" as const }
}
},
}
},
},
],
}
return {
auth: authHook,
}
}
/**
* Default export for OpenCode plugin system
*/
export default createGoogleAntigravityAuthPlugin
/**
* Named export for explicit imports
*/
export const GoogleAntigravityAuthPlugin = createGoogleAntigravityAuthPlugin

View File

@@ -0,0 +1,159 @@
/**
* Antigravity project context management.
* Handles fetching GCP project ID via Google's loadCodeAssist API.
*/
import {
ANTIGRAVITY_DEFAULT_PROJECT_ID,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_API_VERSION,
ANTIGRAVITY_HEADERS,
} from "./constants"
import type {
AntigravityProjectContext,
AntigravityLoadCodeAssistResponse,
} from "./types"
/**
* In-memory cache for project context per access token.
* Prevents redundant API calls for the same token.
*/
const projectContextCache = new Map<string, AntigravityProjectContext>()
/**
* Client metadata for loadCodeAssist API request.
* Matches cliproxyapi implementation.
*/
const CODE_ASSIST_METADATA = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
} as const
/**
* Extracts the project ID from a cloudaicompanionProject field.
* Handles both string and object formats.
*
* @param project - The cloudaicompanionProject value from API response
* @returns Extracted project ID string, or undefined if not found
*/
function extractProjectId(
project: string | { id: string } | undefined
): string | undefined {
if (!project) {
return undefined
}
// Handle string format
if (typeof project === "string") {
const trimmed = project.trim()
return trimmed || undefined
}
// Handle object format { id: string }
if (typeof project === "object" && "id" in project) {
const id = project.id
if (typeof id === "string") {
const trimmed = id.trim()
return trimmed || undefined
}
}
return undefined
}
/**
* Calls the loadCodeAssist API to get project context.
* Tries each endpoint in the fallback list until one succeeds.
*
* @param accessToken - Valid OAuth access token
* @returns API response or null if all endpoints fail
*/
async function callLoadCodeAssistAPI(
accessToken: string
): Promise<AntigravityLoadCodeAssistResponse | null> {
const requestBody = {
metadata: CODE_ASSIST_METADATA,
}
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
}
// Try each endpoint in the fallback list
for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
const url = `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
})
if (!response.ok) {
// Try next endpoint on failure
continue
}
const data =
(await response.json()) as AntigravityLoadCodeAssistResponse
return data
} catch {
// Network or parsing error, try next endpoint
continue
}
}
// All endpoints failed
return null
}
/**
* Fetch project context from Google's loadCodeAssist API.
* Extracts the cloudaicompanionProject from the response.
*
* @param accessToken - Valid OAuth access token
* @returns Project context with cloudaicompanionProject ID
*/
export async function fetchProjectContext(
accessToken: string
): Promise<AntigravityProjectContext> {
const cached = projectContextCache.get(accessToken)
if (cached) {
return cached
}
const response = await callLoadCodeAssistAPI(accessToken)
const projectId = response
? extractProjectId(response.cloudaicompanionProject)
: undefined
const result: AntigravityProjectContext = {
cloudaicompanionProject: projectId || "",
}
if (projectId) {
projectContextCache.set(accessToken, result)
}
return result
}
/**
* Clear the project context cache.
* Call this when tokens are refreshed or invalidated.
*
* @param accessToken - Optional specific token to clear, or clears all if not provided
*/
export function clearProjectContextCache(accessToken?: string): void {
if (accessToken) {
projectContextCache.delete(accessToken)
} else {
projectContextCache.clear()
}
}

View File

@@ -0,0 +1,303 @@
/**
* Antigravity request transformer.
* Transforms OpenAI-format requests to Antigravity format.
* Does NOT handle tool normalization (handled by tools.ts in Task 9).
*/
import {
ANTIGRAVITY_HEADERS,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_API_VERSION,
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
} from "./constants"
import type { AntigravityRequestBody } from "./types"
/**
* Result of request transformation including URL, headers, and body.
*/
export interface TransformedRequest {
/** Transformed URL for Antigravity API */
url: string
/** Request headers including Authorization and Antigravity-specific headers */
headers: Record<string, string>
/** Transformed request body in Antigravity format */
body: AntigravityRequestBody
/** Whether this is a streaming request */
streaming: boolean
}
/**
* Build Antigravity-specific request headers.
* Includes Authorization, User-Agent, X-Goog-Api-Client, and Client-Metadata.
*
* @param accessToken - OAuth access token for Authorization header
* @returns Headers object with all required Antigravity headers
*/
export function buildRequestHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
}
}
/**
* Extract model name from request body.
* OpenAI-format requests include model in the body.
*
* @param body - Request body that may contain a model field
* @returns Model name or undefined if not found
*/
export function extractModelFromBody(
body: Record<string, unknown>
): string | undefined {
const model = body.model
if (typeof model === "string" && model.trim()) {
return model.trim()
}
return undefined
}
/**
* Extract model name from URL path.
* Handles Google Generative Language API format: /models/{model}:{action}
*
* @param url - Request URL to parse
* @returns Model name or undefined if not found
*/
export function extractModelFromUrl(url: string): string | undefined {
// Match Google's API format: /models/gemini-3-pro:generateContent
const match = url.match(/\/models\/([^:]+):/)
if (match && match[1]) {
return match[1]
}
return undefined
}
/**
* Determine the action type from the URL path.
* E.g., generateContent, streamGenerateContent
*
* @param url - Request URL to parse
* @returns Action name or undefined if not found
*/
export function extractActionFromUrl(url: string): string | undefined {
// Match Google's API format: /models/gemini-3-pro:generateContent
const match = url.match(/\/models\/[^:]+:(\w+)/)
if (match && match[1]) {
return match[1]
}
return undefined
}
/**
* Check if a URL is targeting Google's Generative Language API.
*
* @param url - URL to check
* @returns true if this is a Google Generative Language API request
*/
export function isGenerativeLanguageRequest(url: string): boolean {
return url.includes("generativelanguage.googleapis.com")
}
/**
* Build Antigravity API URL for the given action.
*
* @param baseEndpoint - Base Antigravity endpoint URL (from fallbacks)
* @param action - API action (e.g., generateContent, streamGenerateContent)
* @param streaming - Whether to append SSE query parameter
* @returns Formatted Antigravity API URL
*/
export function buildAntigravityUrl(
baseEndpoint: string,
action: string,
streaming: boolean
): string {
const query = streaming ? "?alt=sse" : ""
return `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:${action}${query}`
}
/**
* Get the first available Antigravity endpoint.
* Can be used with fallback logic in fetch.ts.
*
* @returns Default (first) Antigravity endpoint
*/
export function getDefaultEndpoint(): string {
return ANTIGRAVITY_ENDPOINT_FALLBACKS[0]
}
function generateRequestId(): string {
return `agent-${crypto.randomUUID()}`
}
export function wrapRequestBody(
body: Record<string, unknown>,
projectId: string,
modelName: string,
sessionId: string
): AntigravityRequestBody {
const requestPayload = { ...body }
delete requestPayload.model
return {
project: projectId,
model: modelName,
userAgent: "antigravity",
requestId: generateRequestId(),
request: {
...requestPayload,
sessionId,
},
}
}
interface ContentPart {
functionCall?: Record<string, unknown>
thoughtSignature?: string
[key: string]: unknown
}
interface ContentBlock {
role?: string
parts?: ContentPart[]
[key: string]: unknown
}
function debugLog(message: string): void {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.log(`[antigravity-request] ${message}`)
}
}
export function injectThoughtSignatureIntoFunctionCalls(
body: Record<string, unknown>,
signature: string | undefined
): Record<string, unknown> {
// Always use skip validator as fallback (CLIProxyAPI approach)
const effectiveSignature = signature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
debugLog(`[TSIG][INJECT] signature=${effectiveSignature.substring(0, 30)}... (${signature ? "provided" : "default"})`)
debugLog(`[TSIG][INJECT] body keys: ${Object.keys(body).join(", ")}`)
const contents = body.contents as ContentBlock[] | undefined
if (!contents || !Array.isArray(contents)) {
debugLog(`[TSIG][INJECT] No contents array! Has messages: ${!!body.messages}`)
return body
}
debugLog(`[TSIG][INJECT] Found ${contents.length} content blocks`)
let injectedCount = 0
const modifiedContents = contents.map((content) => {
if (!content.parts || !Array.isArray(content.parts)) {
return content
}
const modifiedParts = content.parts.map((part) => {
if (part.functionCall && !part.thoughtSignature) {
injectedCount++
return {
...part,
thoughtSignature: effectiveSignature,
}
}
return part
})
return { ...content, parts: modifiedParts }
})
debugLog(`[TSIG][INJECT] injected signature into ${injectedCount} functionCall(s)`)
return { ...body, contents: modifiedContents }
}
/**
* Detect if request is for streaming.
* Checks both action name and request body for stream flag.
*
* @param url - Request URL
* @param body - Request body
* @returns true if streaming is requested
*/
export function isStreamingRequest(
url: string,
body: Record<string, unknown>
): boolean {
// Check URL action
const action = extractActionFromUrl(url)
if (action === "streamGenerateContent") {
return true
}
// Check body for stream flag
if (body.stream === true) {
return true
}
return false
}
export interface TransformRequestOptions {
url: string
body: Record<string, unknown>
accessToken: string
projectId: string
sessionId: string
modelName?: string
endpointOverride?: string
thoughtSignature?: string
}
export function transformRequest(options: TransformRequestOptions): TransformedRequest {
const {
url,
body,
accessToken,
projectId,
sessionId,
modelName,
endpointOverride,
thoughtSignature,
} = options
const effectiveModel =
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-preview"
const streaming = isStreamingRequest(url, body)
const action = streaming ? "streamGenerateContent" : "generateContent"
const endpoint = endpointOverride || getDefaultEndpoint()
const transformedUrl = buildAntigravityUrl(endpoint, action, streaming)
const headers = buildRequestHeaders(accessToken)
if (streaming) {
headers["Accept"] = "text/event-stream"
}
const bodyWithSignature = injectThoughtSignatureIntoFunctionCalls(body, thoughtSignature)
const wrappedBody = wrapRequestBody(bodyWithSignature, projectId, effectiveModel, sessionId)
return {
url: transformedUrl,
headers,
body: wrappedBody,
streaming,
}
}
/**
* Prepare request headers for streaming responses.
* Adds Accept header for SSE format.
*
* @param headers - Existing headers object
* @returns Headers with streaming support
*/
export function addStreamingHeaders(
headers: Record<string, string>
): Record<string, string> {
return {
...headers,
Accept: "text/event-stream",
}
}

View File

@@ -0,0 +1,598 @@
/**
* Antigravity Response Handler
* Transforms Antigravity/Gemini API responses to OpenAI-compatible format
*
* Key responsibilities:
* - Non-streaming response transformation
* - SSE streaming response transformation (buffered - see transformStreamingResponse)
* - Error response handling with retry-after extraction
* - Usage metadata extraction from x-antigravity-* headers
*/
import type { AntigravityError, AntigravityUsage } from "./types"
/**
* Usage metadata extracted from Antigravity response headers
*/
export interface AntigravityUsageMetadata {
cachedContentTokenCount?: number
totalTokenCount?: number
promptTokenCount?: number
candidatesTokenCount?: number
}
/**
* Transform result with response and metadata
*/
export interface TransformResult {
response: Response
usage?: AntigravityUsageMetadata
retryAfterMs?: number
error?: AntigravityError
}
/**
* Extract usage metadata from Antigravity response headers
*
* Antigravity sets these headers:
* - x-antigravity-cached-content-token-count
* - x-antigravity-total-token-count
* - x-antigravity-prompt-token-count
* - x-antigravity-candidates-token-count
*
* @param headers - Response headers
* @returns Usage metadata if found
*/
export function extractUsageFromHeaders(headers: Headers): AntigravityUsageMetadata | undefined {
const cached = headers.get("x-antigravity-cached-content-token-count")
const total = headers.get("x-antigravity-total-token-count")
const prompt = headers.get("x-antigravity-prompt-token-count")
const candidates = headers.get("x-antigravity-candidates-token-count")
// Return undefined if no usage headers found
if (!cached && !total && !prompt && !candidates) {
return undefined
}
const usage: AntigravityUsageMetadata = {}
if (cached) {
const parsed = parseInt(cached, 10)
if (!isNaN(parsed)) {
usage.cachedContentTokenCount = parsed
}
}
if (total) {
const parsed = parseInt(total, 10)
if (!isNaN(parsed)) {
usage.totalTokenCount = parsed
}
}
if (prompt) {
const parsed = parseInt(prompt, 10)
if (!isNaN(parsed)) {
usage.promptTokenCount = parsed
}
}
if (candidates) {
const parsed = parseInt(candidates, 10)
if (!isNaN(parsed)) {
usage.candidatesTokenCount = parsed
}
}
return Object.keys(usage).length > 0 ? usage : undefined
}
/**
* Extract retry-after value from error response
*
* Antigravity returns retry info in error.details array:
* {
* error: {
* details: [{
* "@type": "type.googleapis.com/google.rpc.RetryInfo",
* "retryDelay": "5.123s"
* }]
* }
* }
*
* Also checks standard Retry-After header.
*
* @param response - Response object (for headers)
* @param errorBody - Parsed error body (optional)
* @returns Retry after value in milliseconds, or undefined
*/
export function extractRetryAfterMs(
response: Response,
errorBody?: Record<string, unknown>,
): number | undefined {
// First, check standard Retry-After header
const retryAfterHeader = response.headers.get("Retry-After")
if (retryAfterHeader) {
const seconds = parseFloat(retryAfterHeader)
if (!isNaN(seconds) && seconds > 0) {
return Math.ceil(seconds * 1000)
}
}
// Check retry-after-ms header (set by some transformers)
const retryAfterMsHeader = response.headers.get("retry-after-ms")
if (retryAfterMsHeader) {
const ms = parseInt(retryAfterMsHeader, 10)
if (!isNaN(ms) && ms > 0) {
return ms
}
}
// Check error body for RetryInfo
if (!errorBody) {
return undefined
}
const error = errorBody.error as Record<string, unknown> | undefined
if (!error?.details || !Array.isArray(error.details)) {
return undefined
}
const retryInfo = (error.details as Array<Record<string, unknown>>).find(
(detail) => detail["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
)
if (!retryInfo?.retryDelay || typeof retryInfo.retryDelay !== "string") {
return undefined
}
// Parse retryDelay format: "5.123s"
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/)
if (match?.[1]) {
const seconds = parseFloat(match[1])
if (!isNaN(seconds) && seconds > 0) {
return Math.ceil(seconds * 1000)
}
}
return undefined
}
/**
* Parse error response body and extract useful details
*
* @param text - Raw response text
* @returns Parsed error or undefined
*/
export function parseErrorBody(text: string): AntigravityError | undefined {
try {
const parsed = JSON.parse(text) as Record<string, unknown>
// Handle error wrapper
if (parsed.error && typeof parsed.error === "object") {
const errorObj = parsed.error as Record<string, unknown>
return {
message: String(errorObj.message || "Unknown error"),
type: errorObj.type ? String(errorObj.type) : undefined,
code: errorObj.code as string | number | undefined,
}
}
// Handle direct error message
if (parsed.message && typeof parsed.message === "string") {
return {
message: parsed.message,
type: parsed.type ? String(parsed.type) : undefined,
code: parsed.code as string | number | undefined,
}
}
return undefined
} catch {
// If not valid JSON, return generic error
return {
message: text || "Unknown error",
}
}
}
/**
* Transform a non-streaming Antigravity response to OpenAI-compatible format
*
* For non-streaming responses:
* - Parses the response body
* - Unwraps the `response` field if present (Antigravity wraps responses)
* - Extracts usage metadata from headers
* - Handles error responses
*
* Note: Does NOT handle thinking block extraction (Task 10)
* Note: Does NOT handle tool normalization (Task 9)
*
* @param response - Fetch Response object
* @returns TransformResult with transformed response and metadata
*/
export async function transformResponse(response: Response): Promise<TransformResult> {
const headers = new Headers(response.headers)
const usage = extractUsageFromHeaders(headers)
// Handle error responses
if (!response.ok) {
const text = await response.text()
const error = parseErrorBody(text)
const retryAfterMs = extractRetryAfterMs(response, error ? { error } : undefined)
// Parse to get full error body for retry-after extraction
let errorBody: Record<string, unknown> | undefined
try {
errorBody = JSON.parse(text) as Record<string, unknown>
} catch {
errorBody = { error: { message: text } }
}
const retryMs = extractRetryAfterMs(response, errorBody) ?? retryAfterMs
// Set retry headers if found
if (retryMs) {
headers.set("Retry-After", String(Math.ceil(retryMs / 1000)))
headers.set("retry-after-ms", String(retryMs))
}
return {
response: new Response(text, {
status: response.status,
statusText: response.statusText,
headers,
}),
usage,
retryAfterMs: retryMs,
error,
}
}
// Handle successful response
const contentType = response.headers.get("content-type") ?? ""
const isJson = contentType.includes("application/json")
if (!isJson) {
// Return non-JSON responses as-is
return { response, usage }
}
try {
const text = await response.text()
const parsed = JSON.parse(text) as Record<string, unknown>
// Antigravity wraps response in { response: { ... } }
// Unwrap if present
let transformedBody: unknown = parsed
if (parsed.response !== undefined) {
transformedBody = parsed.response
}
return {
response: new Response(JSON.stringify(transformedBody), {
status: response.status,
statusText: response.statusText,
headers,
}),
usage,
}
} catch {
// If parsing fails, return original response
return { response, usage }
}
}
/**
* Transform a single SSE data line
*
* Antigravity SSE format:
* data: { "response": { ... actual data ... } }
*
* OpenAI SSE format:
* data: { ... actual data ... }
*
* @param line - SSE data line
* @returns Transformed line
*/
function transformSseLine(line: string): string {
if (!line.startsWith("data:")) {
return line
}
const json = line.slice(5).trim()
if (!json || json === "[DONE]") {
return line
}
try {
const parsed = JSON.parse(json) as Record<string, unknown>
// Unwrap { response: { ... } } wrapper
if (parsed.response !== undefined) {
return `data: ${JSON.stringify(parsed.response)}`
}
return line
} catch {
// If parsing fails, return original line
return line
}
}
/**
* Transform SSE streaming payload
*
* Processes each line in the SSE stream:
* - Unwraps { response: { ... } } wrapper from data lines
* - Preserves other SSE control lines (event:, id:, retry:, empty lines)
*
* Note: Does NOT extract thinking blocks (Task 10)
*
* @param payload - Raw SSE payload text
* @returns Transformed SSE payload
*/
export function transformStreamingPayload(payload: string): string {
return payload
.split("\n")
.map(transformSseLine)
.join("\n")
}
function createSseTransformStream(): TransformStream<Uint8Array, Uint8Array> {
const decoder = new TextDecoder()
const encoder = new TextEncoder()
let buffer = ""
return new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
const transformed = transformSseLine(line)
controller.enqueue(encoder.encode(transformed + "\n"))
}
},
flush(controller) {
if (buffer) {
const transformed = transformSseLine(buffer)
controller.enqueue(encoder.encode(transformed))
}
},
})
}
/**
* Transforms a streaming SSE response from Antigravity to OpenAI format.
*
* Uses TransformStream to process SSE chunks incrementally as they arrive.
* Each line is transformed immediately and yielded to the client.
*
* @param response - The SSE response from Antigravity API
* @returns TransformResult with transformed streaming response
*/
export async function transformStreamingResponse(response: Response): Promise<TransformResult> {
const headers = new Headers(response.headers)
const usage = extractUsageFromHeaders(headers)
// Handle error responses
if (!response.ok) {
const text = await response.text()
const error = parseErrorBody(text)
let errorBody: Record<string, unknown> | undefined
try {
errorBody = JSON.parse(text) as Record<string, unknown>
} catch {
errorBody = { error: { message: text } }
}
const retryAfterMs = extractRetryAfterMs(response, errorBody)
if (retryAfterMs) {
headers.set("Retry-After", String(Math.ceil(retryAfterMs / 1000)))
headers.set("retry-after-ms", String(retryAfterMs))
}
return {
response: new Response(text, {
status: response.status,
statusText: response.statusText,
headers,
}),
usage,
retryAfterMs,
error,
}
}
// Check content type
const contentType = response.headers.get("content-type") ?? ""
const isEventStream =
contentType.includes("text/event-stream") || response.url.includes("alt=sse")
if (!isEventStream) {
// Not SSE, delegate to non-streaming transform
// Clone response since we need to read it
const text = await response.text()
try {
const parsed = JSON.parse(text) as Record<string, unknown>
let transformedBody: unknown = parsed
if (parsed.response !== undefined) {
transformedBody = parsed.response
}
return {
response: new Response(JSON.stringify(transformedBody), {
status: response.status,
statusText: response.statusText,
headers,
}),
usage,
}
} catch {
return {
response: new Response(text, {
status: response.status,
statusText: response.statusText,
headers,
}),
usage,
}
}
}
if (!response.body) {
return { response, usage }
}
headers.delete("content-length")
headers.delete("content-encoding")
headers.set("content-type", "text/event-stream; charset=utf-8")
const transformStream = createSseTransformStream()
const transformedBody = response.body.pipeThrough(transformStream)
return {
response: new Response(transformedBody, {
status: response.status,
statusText: response.statusText,
headers,
}),
usage,
}
}
/**
* Check if response is a streaming SSE response
*
* @param response - Fetch Response object
* @returns True if response is SSE stream
*/
export function isStreamingResponse(response: Response): boolean {
const contentType = response.headers.get("content-type") ?? ""
return contentType.includes("text/event-stream") || response.url.includes("alt=sse")
}
/**
* Extract thought signature from SSE payload text
*
* Looks for thoughtSignature in SSE events:
* data: { "response": { "candidates": [{ "content": { "parts": [{ "thoughtSignature": "..." }] } }] } }
*
* Returns the last found signature (most recent in the stream).
*
* @param payload - SSE payload text
* @returns Last thought signature if found
*/
export function extractSignatureFromSsePayload(payload: string): string | undefined {
const lines = payload.split("\n")
let lastSignature: string | undefined
for (const line of lines) {
if (!line.startsWith("data:")) {
continue
}
const json = line.slice(5).trim()
if (!json || json === "[DONE]") {
continue
}
try {
const parsed = JSON.parse(json) as Record<string, unknown>
// Check in response wrapper (Antigravity format)
const response = (parsed.response || parsed) as Record<string, unknown>
const candidates = response.candidates as Array<Record<string, unknown>> | undefined
if (candidates && Array.isArray(candidates)) {
for (const candidate of candidates) {
const content = candidate.content as Record<string, unknown> | undefined
const parts = content?.parts as Array<Record<string, unknown>> | undefined
if (parts && Array.isArray(parts)) {
for (const part of parts) {
const sig = (part.thoughtSignature || part.thought_signature) as string | undefined
if (sig && typeof sig === "string") {
lastSignature = sig
}
}
}
}
}
} catch {
// Continue to next line if parsing fails
}
}
return lastSignature
}
/**
* Extract usage from SSE payload text
*
* Looks for usageMetadata in SSE events:
* data: { "usageMetadata": { ... } }
*
* @param payload - SSE payload text
* @returns Usage if found
*/
export function extractUsageFromSsePayload(payload: string): AntigravityUsage | undefined {
const lines = payload.split("\n")
for (const line of lines) {
if (!line.startsWith("data:")) {
continue
}
const json = line.slice(5).trim()
if (!json || json === "[DONE]") {
continue
}
try {
const parsed = JSON.parse(json) as Record<string, unknown>
// Check for usageMetadata at top level
if (parsed.usageMetadata && typeof parsed.usageMetadata === "object") {
const meta = parsed.usageMetadata as Record<string, unknown>
return {
prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0,
completion_tokens:
typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0,
total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0,
}
}
// Check for usage in response wrapper
if (parsed.response && typeof parsed.response === "object") {
const resp = parsed.response as Record<string, unknown>
if (resp.usageMetadata && typeof resp.usageMetadata === "object") {
const meta = resp.usageMetadata as Record<string, unknown>
return {
prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0,
completion_tokens:
typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0,
total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0,
}
}
}
// Check for standard OpenAI-style usage
if (parsed.usage && typeof parsed.usage === "object") {
const u = parsed.usage as Record<string, unknown>
return {
prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0,
completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0,
total_tokens: typeof u.total_tokens === "number" ? u.total_tokens : 0,
}
}
} catch {
// Continue to next line if parsing fails
}
}
return undefined
}

View File

@@ -0,0 +1,571 @@
/**
* Antigravity Thinking Block Handler (Gemini only)
*
* Handles extraction and transformation of thinking/reasoning blocks
* from Gemini responses. Thinking blocks contain the model's internal
* reasoning process, available in `-high` model variants.
*
* Key responsibilities:
* - Extract thinking blocks from Gemini response format
* - Detect thinking-capable model variants (`-high` suffix)
* - Format thinking blocks for OpenAI-compatible output
*
* Note: This is Gemini-only. Claude models are NOT handled by Antigravity.
*/
/**
* Represents a single thinking/reasoning block extracted from Gemini response
*/
export interface ThinkingBlock {
/** The thinking/reasoning text content */
text: string
/** Optional signature for signed thinking blocks (required for multi-turn) */
signature?: string
/** Index of the thinking block in sequence */
index?: number
}
/**
* Raw part structure from Gemini response candidates
*/
export interface GeminiPart {
/** Text content of the part */
text?: string
/** Whether this part is a thinking/reasoning block */
thought?: boolean
/** Signature for signed thinking blocks */
thoughtSignature?: string
/** Type field for Anthropic-style format */
type?: string
/** Signature field for Anthropic-style format */
signature?: string
}
/**
* Gemini response candidate structure
*/
export interface GeminiCandidate {
/** Content containing parts */
content?: {
/** Role of the content (e.g., "model", "assistant") */
role?: string
/** Array of content parts */
parts?: GeminiPart[]
}
/** Index of the candidate */
index?: number
}
/**
* Gemini response structure for thinking block extraction
*/
export interface GeminiResponse {
/** Response ID */
id?: string
/** Array of response candidates */
candidates?: GeminiCandidate[]
/** Direct content (some responses use this instead of candidates) */
content?: Array<{
type?: string
text?: string
signature?: string
}>
/** Model used for response */
model?: string
}
/**
* Result of thinking block extraction
*/
export interface ThinkingExtractionResult {
/** Extracted thinking blocks */
thinkingBlocks: ThinkingBlock[]
/** Combined thinking text for convenience */
combinedThinking: string
/** Whether any thinking blocks were found */
hasThinking: boolean
}
/**
* Default thinking budget in tokens for thinking-enabled models
*/
export const DEFAULT_THINKING_BUDGET = 16000
/**
* Check if a model variant should include thinking blocks
*
* Returns true for model variants with `-high` suffix, which have
* extended thinking capability enabled.
*
* Examples:
* - `gemini-3-pro-high` → true
* - `gemini-2.5-pro-high` → true
* - `gemini-3-pro-preview` → false
* - `gemini-2.5-pro` → false
*
* @param model - Model identifier string
* @returns True if model should include thinking blocks
*/
export function shouldIncludeThinking(model: string): boolean {
if (!model || typeof model !== "string") {
return false
}
const lowerModel = model.toLowerCase()
// Check for -high suffix (primary indicator of thinking capability)
if (lowerModel.endsWith("-high")) {
return true
}
// Also check for explicit thinking in model name
if (lowerModel.includes("thinking")) {
return true
}
return false
}
/**
* Check if a model is thinking-capable (broader check)
*
* This is a broader check than shouldIncludeThinking - it detects models
* that have thinking capability, even if not explicitly requesting thinking output.
*
* @param model - Model identifier string
* @returns True if model supports thinking/reasoning
*/
export function isThinkingCapableModel(model: string): boolean {
if (!model || typeof model !== "string") {
return false
}
const lowerModel = model.toLowerCase()
return (
lowerModel.includes("thinking") ||
lowerModel.includes("gemini-3") ||
lowerModel.endsWith("-high")
)
}
/**
* Check if a part is a thinking/reasoning block
*
* Detects both Gemini-style (thought: true) and Anthropic-style
* (type: "thinking" or type: "reasoning") formats.
*
* @param part - Content part to check
* @returns True if part is a thinking block
*/
function isThinkingPart(part: GeminiPart): boolean {
// Gemini-style: thought flag
if (part.thought === true) {
return true
}
// Anthropic-style: type field
if (part.type === "thinking" || part.type === "reasoning") {
return true
}
return false
}
/**
* Check if a thinking part has a valid signature
*
* Signatures are required for multi-turn conversations with Claude models.
* Gemini uses `thoughtSignature`, Anthropic uses `signature`.
*
* @param part - Thinking part to check
* @returns True if part has valid signature
*/
function hasValidSignature(part: GeminiPart): boolean {
// Gemini-style signature
if (part.thought === true && part.thoughtSignature) {
return true
}
// Anthropic-style signature
if ((part.type === "thinking" || part.type === "reasoning") && part.signature) {
return true
}
return false
}
/**
* Extract thinking blocks from a Gemini response
*
* Parses the response structure to identify and extract all thinking/reasoning
* content. Supports both Gemini-style (thought: true) and Anthropic-style
* (type: "thinking") formats.
*
* @param response - Gemini response object
* @returns Extraction result with thinking blocks and metadata
*/
export function extractThinkingBlocks(response: GeminiResponse): ThinkingExtractionResult {
const thinkingBlocks: ThinkingBlock[] = []
// Handle candidates array (standard Gemini format)
if (response.candidates && Array.isArray(response.candidates)) {
for (const candidate of response.candidates) {
const parts = candidate.content?.parts
if (!parts || !Array.isArray(parts)) {
continue
}
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!part || typeof part !== "object") {
continue
}
if (isThinkingPart(part)) {
const block: ThinkingBlock = {
text: part.text || "",
index: thinkingBlocks.length,
}
// Extract signature if present
if (part.thought === true && part.thoughtSignature) {
block.signature = part.thoughtSignature
} else if (part.signature) {
block.signature = part.signature
}
thinkingBlocks.push(block)
}
}
}
}
// Handle direct content array (Anthropic-style response)
if (response.content && Array.isArray(response.content)) {
for (let i = 0; i < response.content.length; i++) {
const item = response.content[i]
if (!item || typeof item !== "object") {
continue
}
if (item.type === "thinking" || item.type === "reasoning") {
thinkingBlocks.push({
text: item.text || "",
signature: item.signature,
index: thinkingBlocks.length,
})
}
}
}
// Combine all thinking text
const combinedThinking = thinkingBlocks.map((b) => b.text).join("\n\n")
return {
thinkingBlocks,
combinedThinking,
hasThinking: thinkingBlocks.length > 0,
}
}
/**
* Format thinking blocks for OpenAI-compatible output
*
* Converts Gemini thinking block format to OpenAI's expected structure.
* OpenAI expects thinking content as special message blocks or annotations.
*
* Output format:
* ```
* [
* { type: "reasoning", text: "thinking content...", signature?: "..." },
* ...
* ]
* ```
*
* @param thinking - Array of thinking blocks to format
* @returns OpenAI-compatible formatted array
*/
export function formatThinkingForOpenAI(
thinking: ThinkingBlock[],
): Array<{ type: "reasoning"; text: string; signature?: string }> {
if (!thinking || !Array.isArray(thinking) || thinking.length === 0) {
return []
}
return thinking.map((block) => {
const formatted: { type: "reasoning"; text: string; signature?: string } = {
type: "reasoning",
text: block.text || "",
}
if (block.signature) {
formatted.signature = block.signature
}
return formatted
})
}
/**
* Transform thinking parts in a candidate to OpenAI format
*
* Modifies candidate content parts to use OpenAI-style reasoning format
* while preserving the rest of the response structure.
*
* @param candidate - Gemini candidate to transform
* @returns Transformed candidate with reasoning-formatted thinking
*/
export function transformCandidateThinking(candidate: GeminiCandidate): GeminiCandidate {
if (!candidate || typeof candidate !== "object") {
return candidate
}
const content = candidate.content
if (!content || typeof content !== "object" || !Array.isArray(content.parts)) {
return candidate
}
const thinkingTexts: string[] = []
const transformedParts = content.parts.map((part) => {
if (part && typeof part === "object" && part.thought === true) {
thinkingTexts.push(part.text || "")
// Transform to reasoning format
return {
...part,
type: "reasoning" as const,
thought: undefined, // Remove Gemini-specific field
}
}
return part
})
const result: GeminiCandidate & { reasoning_content?: string } = {
...candidate,
content: { ...content, parts: transformedParts },
}
// Add combined reasoning content for convenience
if (thinkingTexts.length > 0) {
result.reasoning_content = thinkingTexts.join("\n\n")
}
return result
}
/**
* Transform Anthropic-style thinking blocks to reasoning format
*
* Converts `type: "thinking"` blocks to `type: "reasoning"` for consistency.
*
* @param content - Array of content blocks
* @returns Transformed content array
*/
export function transformAnthropicThinking(
content: Array<{ type?: string; text?: string; signature?: string }>,
): Array<{ type?: string; text?: string; signature?: string }> {
if (!content || !Array.isArray(content)) {
return content
}
return content.map((block) => {
if (block && typeof block === "object" && block.type === "thinking") {
return {
type: "reasoning",
text: block.text || "",
...(block.signature ? { signature: block.signature } : {}),
}
}
return block
})
}
/**
* Filter out unsigned thinking blocks
*
* Claude API requires signed thinking blocks for multi-turn conversations.
* This function removes thinking blocks without valid signatures.
*
* @param parts - Array of content parts
* @returns Filtered array without unsigned thinking blocks
*/
export function filterUnsignedThinkingBlocks(parts: GeminiPart[]): GeminiPart[] {
if (!parts || !Array.isArray(parts)) {
return parts
}
return parts.filter((part) => {
if (!part || typeof part !== "object") {
return true
}
// If it's a thinking part, only keep it if signed
if (isThinkingPart(part)) {
return hasValidSignature(part)
}
// Keep all non-thinking parts
return true
})
}
/**
* Transform entire response thinking parts
*
* Main transformation function that handles both Gemini-style and
* Anthropic-style thinking blocks in a response.
*
* @param response - Response object to transform
* @returns Transformed response with standardized reasoning format
*/
export function transformResponseThinking(response: GeminiResponse): GeminiResponse {
if (!response || typeof response !== "object") {
return response
}
const result: GeminiResponse = { ...response }
// Transform candidates (Gemini-style)
if (Array.isArray(result.candidates)) {
result.candidates = result.candidates.map(transformCandidateThinking)
}
// Transform direct content (Anthropic-style)
if (Array.isArray(result.content)) {
result.content = transformAnthropicThinking(result.content)
}
return result
}
/**
* Thinking configuration for requests
*/
export interface ThinkingConfig {
/** Token budget for thinking/reasoning */
thinkingBudget?: number
/** Whether to include thoughts in response */
includeThoughts?: boolean
}
/**
* Normalize thinking configuration
*
* Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0.
*
* @param config - Raw thinking configuration
* @returns Normalized configuration or undefined
*/
export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined {
if (!config || typeof config !== "object") {
return undefined
}
const record = config as Record<string, unknown>
const budgetRaw = record.thinkingBudget ?? record.thinking_budget
const includeRaw = record.includeThoughts ?? record.include_thoughts
const thinkingBudget =
typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined
const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined
const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0
const finalInclude = enableThinking ? (includeThoughts ?? false) : false
// Return undefined if no meaningful config
if (
!enableThinking &&
finalInclude === false &&
thinkingBudget === undefined &&
includeThoughts === undefined
) {
return undefined
}
const normalized: ThinkingConfig = {}
if (thinkingBudget !== undefined) {
normalized.thinkingBudget = thinkingBudget
}
if (finalInclude !== undefined) {
normalized.includeThoughts = finalInclude
}
return normalized
}
/**
* Extract thinking configuration from request payload
*
* Supports both Gemini-style thinkingConfig and Anthropic-style thinking options.
*
* @param requestPayload - Request body
* @param generationConfig - Generation config from request
* @param extraBody - Extra body options
* @returns Extracted thinking configuration or undefined
*/
export function extractThinkingConfig(
requestPayload: Record<string, unknown>,
generationConfig?: Record<string, unknown>,
extraBody?: Record<string, unknown>,
): ThinkingConfig | undefined {
// Check for explicit thinkingConfig
const thinkingConfig =
generationConfig?.thinkingConfig ?? extraBody?.thinkingConfig ?? requestPayload.thinkingConfig
if (thinkingConfig && typeof thinkingConfig === "object") {
const config = thinkingConfig as Record<string, unknown>
return {
includeThoughts: Boolean(config.includeThoughts),
thinkingBudget:
typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET,
}
}
// Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N }
const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking
if (anthropicThinking && typeof anthropicThinking === "object") {
const thinking = anthropicThinking as Record<string, unknown>
if (thinking.type === "enabled" || thinking.budgetTokens) {
return {
includeThoughts: true,
thinkingBudget:
typeof thinking.budgetTokens === "number"
? thinking.budgetTokens
: DEFAULT_THINKING_BUDGET,
}
}
}
return undefined
}
/**
* Resolve final thinking configuration based on model and context
*
* Handles special cases like Claude models requiring signed thinking blocks
* for multi-turn conversations.
*
* @param userConfig - User-provided thinking configuration
* @param isThinkingModel - Whether model supports thinking
* @param isClaudeModel - Whether model is Claude (not used in Antigravity, but kept for compatibility)
* @param hasAssistantHistory - Whether conversation has assistant history
* @returns Final thinking configuration
*/
export function resolveThinkingConfig(
userConfig: ThinkingConfig | undefined,
isThinkingModel: boolean,
isClaudeModel: boolean,
hasAssistantHistory: boolean,
): ThinkingConfig | undefined {
// Claude models with history need signed thinking blocks
// Since we can't guarantee signatures, disable thinking
if (isClaudeModel && hasAssistantHistory) {
return { includeThoughts: false, thinkingBudget: 0 }
}
// Enable thinking by default for thinking-capable models
if (isThinkingModel && !userConfig) {
return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }
}
return userConfig
}

View File

@@ -0,0 +1,97 @@
/**
* Thought Signature Store
*
* Stores and retrieves thought signatures for multi-turn conversations.
* Gemini 3 Pro requires thought_signature on function call content blocks
* in subsequent requests to maintain reasoning continuity.
*
* Key responsibilities:
* - Store the latest thought signature per session
* - Provide signature for injection into function call requests
* - Clear signatures when sessions end
*/
/**
* In-memory store for thought signatures indexed by session ID
*/
const signatureStore = new Map<string, string>()
/**
* In-memory store for session IDs per fetch instance
* Used to maintain consistent sessionId across multi-turn conversations
*/
const sessionIdStore = new Map<string, string>()
/**
* Store a thought signature for a session
*
* @param sessionKey - Unique session identifier (typically fetch instance ID)
* @param signature - The thought signature from model response
*/
export function setThoughtSignature(sessionKey: string, signature: string): void {
if (sessionKey && signature) {
signatureStore.set(sessionKey, signature)
}
}
/**
* Retrieve the stored thought signature for a session
*
* @param sessionKey - Unique session identifier
* @returns The stored signature or undefined if not found
*/
export function getThoughtSignature(sessionKey: string): string | undefined {
return signatureStore.get(sessionKey)
}
/**
* Clear the thought signature for a session
*
* @param sessionKey - Unique session identifier
*/
export function clearThoughtSignature(sessionKey: string): void {
signatureStore.delete(sessionKey)
}
/**
* Store or retrieve a persistent session ID for a fetch instance
*
* @param fetchInstanceId - Unique identifier for the fetch instance
* @param sessionId - Optional session ID to store (if not provided, returns existing or generates new)
* @returns The session ID for this fetch instance
*/
export function getOrCreateSessionId(fetchInstanceId: string, sessionId?: string): string {
if (sessionId) {
sessionIdStore.set(fetchInstanceId, sessionId)
return sessionId
}
const existing = sessionIdStore.get(fetchInstanceId)
if (existing) {
return existing
}
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
const newSessionId = `-${n}`
sessionIdStore.set(fetchInstanceId, newSessionId)
return newSessionId
}
/**
* Clear the session ID for a fetch instance
*
* @param fetchInstanceId - Unique identifier for the fetch instance
*/
export function clearSessionId(fetchInstanceId: string): void {
sessionIdStore.delete(fetchInstanceId)
}
/**
* Clear all stored data for a fetch instance (signature + session ID)
*
* @param fetchInstanceId - Unique identifier for the fetch instance
*/
export function clearFetchInstanceData(fetchInstanceId: string): void {
signatureStore.delete(fetchInstanceId)
sessionIdStore.delete(fetchInstanceId)
}

View File

@@ -0,0 +1,119 @@
/**
* Antigravity token management utilities.
* Handles token expiration checking, refresh, and storage format parsing.
*/
import {
ANTIGRAVITY_CLIENT_ID,
ANTIGRAVITY_CLIENT_SECRET,
ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS,
GOOGLE_TOKEN_URL,
} from "./constants"
import type {
AntigravityRefreshParts,
AntigravityTokenExchangeResult,
AntigravityTokens,
} from "./types"
/**
* Check if the access token is expired.
* Includes a 60-second safety buffer to refresh before actual expiration.
*
* @param tokens - The Antigravity tokens to check
* @returns true if the token is expired or will expire within the buffer period
*/
export function isTokenExpired(tokens: AntigravityTokens): boolean {
// Calculate when the token expires (timestamp + expires_in in ms)
// timestamp is in milliseconds, expires_in is in seconds
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
// Check if current time is past (expiration - buffer)
return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS
}
/**
* Refresh an access token using a refresh token.
* Exchanges the refresh token for a new access token via Google's OAuth endpoint.
*
* @param refreshToken - The refresh token to use
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Token exchange result with new access token, or throws on error
*/
export async function refreshAccessToken(
refreshToken: string,
clientId: string = ANTIGRAVITY_CLIENT_ID,
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
): Promise<AntigravityTokenExchangeResult> {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
})
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
})
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(
`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
)
}
const data = (await response.json()) as {
access_token: string
refresh_token?: string
expires_in: number
token_type: string
}
return {
access_token: data.access_token,
// Google may return a new refresh token, fall back to the original
refresh_token: data.refresh_token || refreshToken,
expires_in: data.expires_in,
token_type: data.token_type,
}
}
/**
* Parse a stored token string into its component parts.
* Storage format: `refreshToken|projectId|managedProjectId`
*
* @param stored - The pipe-separated stored token string
* @returns Parsed refresh parts with refreshToken, projectId, and optional managedProjectId
*/
export function parseStoredToken(stored: string): AntigravityRefreshParts {
const parts = stored.split("|")
const [refreshToken, projectId, managedProjectId] = parts
return {
refreshToken: refreshToken || "",
projectId: projectId || undefined,
managedProjectId: managedProjectId || undefined,
}
}
/**
* Format token components for storage.
* Creates a pipe-separated string: `refreshToken|projectId|managedProjectId`
*
* @param refreshToken - The refresh token
* @param projectId - The GCP project ID
* @param managedProjectId - Optional managed project ID for enterprise users
* @returns Formatted string for storage
*/
export function formatTokenForStorage(
refreshToken: string,
projectId: string,
managedProjectId?: string
): string {
return `${refreshToken}|${projectId}|${managedProjectId || ""}`
}

View File

@@ -0,0 +1,243 @@
/**
* Antigravity Tool Normalization
* Converts tools between OpenAI and Gemini formats.
*
* OpenAI format:
* { "type": "function", "function": { "name": "x", "description": "...", "parameters": {...} } }
*
* Gemini format:
* { "functionDeclarations": [{ "name": "x", "description": "...", "parameters": {...} }] }
*
* Note: This is for Gemini models ONLY. Claude models are not supported via Antigravity.
*/
/**
* OpenAI function tool format
*/
export interface OpenAITool {
type: string
function?: {
name: string
description?: string
parameters?: Record<string, unknown>
}
}
/**
* Gemini function declaration format
*/
export interface GeminiFunctionDeclaration {
name: string
description?: string
parameters?: Record<string, unknown>
}
/**
* Gemini tools format (array of functionDeclarations)
*/
export interface GeminiTools {
functionDeclarations: GeminiFunctionDeclaration[]
}
/**
* OpenAI tool call in response
*/
export interface OpenAIToolCall {
id: string
type: "function"
function: {
name: string
arguments: string
}
}
/**
* Gemini function call in response
*/
export interface GeminiFunctionCall {
name: string
args: Record<string, unknown>
}
/**
* Gemini function response format
*/
export interface GeminiFunctionResponse {
name: string
response: Record<string, unknown>
}
/**
* Gemini tool result containing function calls
*/
export interface GeminiToolResult {
functionCall?: GeminiFunctionCall
functionResponse?: GeminiFunctionResponse
}
/**
* Normalize OpenAI-format tools to Gemini format.
* Converts an array of OpenAI tools to Gemini's functionDeclarations format.
*
* - Handles `function` type tools with name, description, parameters
* - Logs warning for unsupported tool types (does NOT silently drop them)
* - Creates a single object with functionDeclarations array
*
* @param tools - Array of OpenAI-format tools
* @returns Gemini-format tools object with functionDeclarations, or undefined if no valid tools
*/
export function normalizeToolsForGemini(
tools: OpenAITool[]
): GeminiTools | undefined {
if (!tools || tools.length === 0) {
return undefined
}
const functionDeclarations: GeminiFunctionDeclaration[] = []
for (const tool of tools) {
if (!tool || typeof tool !== "object") {
continue
}
const toolType = tool.type ?? "function"
if (toolType === "function" && tool.function) {
const declaration: GeminiFunctionDeclaration = {
name: tool.function.name,
}
if (tool.function.description) {
declaration.description = tool.function.description
}
if (tool.function.parameters) {
declaration.parameters = tool.function.parameters
} else {
declaration.parameters = { type: "object", properties: {} }
}
functionDeclarations.push(declaration)
} else if (toolType !== "function" && process.env.ANTIGRAVITY_DEBUG === "1") {
console.warn(
`[antigravity-tools] Unsupported tool type: "${toolType}". Tool will be skipped.`
)
}
}
// Return undefined if no valid function declarations
if (functionDeclarations.length === 0) {
return undefined
}
return { functionDeclarations }
}
/**
* Convert Gemini tool results (functionCall) back to OpenAI tool_call format.
* Handles both functionCall (request) and functionResponse (result) formats.
*
* Gemini functionCall format:
* { "name": "tool_name", "args": { ... } }
*
* OpenAI tool_call format:
* { "id": "call_xxx", "type": "function", "function": { "name": "tool_name", "arguments": "..." } }
*
* @param results - Array of Gemini tool results containing functionCall or functionResponse
* @returns Array of OpenAI-format tool calls
*/
export function normalizeToolResultsFromGemini(
results: GeminiToolResult[]
): OpenAIToolCall[] {
if (!results || results.length === 0) {
return []
}
const toolCalls: OpenAIToolCall[] = []
let callCounter = 0
for (const result of results) {
// Handle functionCall (tool invocation from model)
if (result.functionCall) {
callCounter++
const toolCall: OpenAIToolCall = {
id: `call_${Date.now()}_${callCounter}`,
type: "function",
function: {
name: result.functionCall.name,
arguments: JSON.stringify(result.functionCall.args ?? {}),
},
}
toolCalls.push(toolCall)
}
}
return toolCalls
}
/**
* Convert a single Gemini functionCall to OpenAI tool_call format.
* Useful for streaming responses where each chunk may contain a function call.
*
* @param functionCall - Gemini function call
* @param id - Optional tool call ID (generates one if not provided)
* @returns OpenAI-format tool call
*/
export function convertFunctionCallToToolCall(
functionCall: GeminiFunctionCall,
id?: string
): OpenAIToolCall {
return {
id: id ?? `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: "function",
function: {
name: functionCall.name,
arguments: JSON.stringify(functionCall.args ?? {}),
},
}
}
/**
* Check if a tool array contains any function-type tools.
*
* @param tools - Array of OpenAI-format tools
* @returns true if there are function tools to normalize
*/
export function hasFunctionTools(tools: OpenAITool[]): boolean {
if (!tools || tools.length === 0) {
return false
}
return tools.some((tool) => tool.type === "function" && tool.function)
}
/**
* Extract function declarations from already-normalized Gemini tools.
* Useful when tools may already be in Gemini format.
*
* @param tools - Tools that may be in Gemini or OpenAI format
* @returns Array of function declarations
*/
export function extractFunctionDeclarations(
tools: unknown
): GeminiFunctionDeclaration[] {
if (!tools || typeof tools !== "object") {
return []
}
// Check if already in Gemini format
const geminiTools = tools as Record<string, unknown>
if (
Array.isArray(geminiTools.functionDeclarations) &&
geminiTools.functionDeclarations.length > 0
) {
return geminiTools.functionDeclarations as GeminiFunctionDeclaration[]
}
// Check if it's an array of OpenAI tools
if (Array.isArray(tools)) {
const normalized = normalizeToolsForGemini(tools as OpenAITool[])
return normalized?.functionDeclarations ?? []
}
return []
}

View File

@@ -0,0 +1,185 @@
/**
* Antigravity Auth Type Definitions
* Matches cliproxyapi/sdk/auth/antigravity.go token format exactly
*/
/**
* Token storage format for Antigravity authentication
* Matches Go metadata structure: type, access_token, refresh_token, expires_in, timestamp, email, project_id
*/
export interface AntigravityTokens {
/** Always "antigravity" for this auth type */
type: "antigravity"
/** OAuth access token from Google */
access_token: string
/** OAuth refresh token from Google */
refresh_token: string
/** Token expiration time in seconds */
expires_in: number
/** Unix timestamp in milliseconds when tokens were obtained */
timestamp: number
/** ISO 8601 formatted expiration datetime (optional, for display) */
expired?: string
/** User's email address from Google userinfo */
email?: string
/** GCP project ID from loadCodeAssist API */
project_id?: string
}
/**
* Project context returned from loadCodeAssist API
* Used to get cloudaicompanionProject for API calls
*/
export interface AntigravityProjectContext {
/** GCP project ID for Cloud AI Companion */
cloudaicompanionProject?: string
/** Managed project ID for enterprise users (optional) */
managedProjectId?: string
}
/**
* Metadata for loadCodeAssist API request
*/
export interface AntigravityClientMetadata {
/** IDE type identifier */
ideType: "IDE_UNSPECIFIED" | string
/** Platform identifier */
platform: "PLATFORM_UNSPECIFIED" | string
/** Plugin type - typically "GEMINI" */
pluginType: "GEMINI" | string
}
/**
* Request body for loadCodeAssist API
*/
export interface AntigravityLoadCodeAssistRequest {
metadata: AntigravityClientMetadata
}
/**
* Response from loadCodeAssist API
*/
export interface AntigravityLoadCodeAssistResponse {
/** Project ID - can be string or object with id field */
cloudaicompanionProject?: string | { id: string }
}
/**
* Request body format for Antigravity API calls
* Wraps the actual request with project and model context
*/
export interface AntigravityRequestBody {
/** GCP project ID */
project: string
/** Model identifier (e.g., "gemini-3-pro-preview") */
model: string
/** User agent identifier */
userAgent: string
/** Unique request ID */
requestId: string
/** The actual request payload */
request: Record<string, unknown>
}
/**
* Response format from Antigravity API
* Follows OpenAI-compatible structure with Gemini extensions
*/
export interface AntigravityResponse {
/** Response ID */
id?: string
/** Object type (e.g., "chat.completion") */
object?: string
/** Creation timestamp */
created?: number
/** Model used for response */
model?: string
/** Response choices */
choices?: AntigravityResponseChoice[]
/** Token usage statistics */
usage?: AntigravityUsage
/** Error information if request failed */
error?: AntigravityError
}
/**
* Single response choice in Antigravity response
*/
export interface AntigravityResponseChoice {
/** Choice index */
index: number
/** Message content */
message?: {
role: "assistant"
content?: string
tool_calls?: AntigravityToolCall[]
}
/** Delta for streaming responses */
delta?: {
role?: "assistant"
content?: string
tool_calls?: AntigravityToolCall[]
}
/** Finish reason */
finish_reason?: "stop" | "tool_calls" | "length" | "content_filter" | null
}
/**
* Tool call in Antigravity response
*/
export interface AntigravityToolCall {
id: string
type: "function"
function: {
name: string
arguments: string
}
}
/**
* Token usage statistics
*/
export interface AntigravityUsage {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
/**
* Error response from Antigravity API
*/
export interface AntigravityError {
message: string
type?: string
code?: string | number
}
/**
* Token exchange result from Google OAuth
* Matches antigravityTokenResponse in Go
*/
export interface AntigravityTokenExchangeResult {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
/**
* User info from Google userinfo API
*/
export interface AntigravityUserInfo {
email: string
name?: string
picture?: string
}
/**
* Parsed refresh token parts
* Format: refreshToken|projectId|managedProjectId
*/
export interface AntigravityRefreshParts {
refreshToken: string
projectId?: string
managedProjectId?: string
}

View File

@@ -40,13 +40,31 @@ export const AgentOverrideConfigSchema = z.object({
permission: AgentPermissionSchema.optional(),
})
export const AgentOverridesSchema = z.record(AgentNameSchema, AgentOverrideConfigSchema)
export const AgentOverridesSchema = z
.object({
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
explore: AgentOverrideConfigSchema.optional(),
"frontend-ui-ux-engineer": AgentOverrideConfigSchema.optional(),
"document-writer": AgentOverrideConfigSchema.optional(),
})
.partial()
export const ClaudeCodeConfigSchema = z.object({
mcp: z.boolean().optional(),
commands: z.boolean().optional(),
skills: z.boolean().optional(),
agents: z.boolean().optional(),
hooks: z.boolean().optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(),
disabled_agents: z.array(AgentNameSchema).optional(),
agents: AgentOverridesSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
google_auth: z.boolean().optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>

View File

@@ -0,0 +1,2 @@
export * from "./types"
export { BackgroundManager } from "./manager"

View File

@@ -0,0 +1,354 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type {
BackgroundTask,
LaunchInput,
} from "./types"
import { log } from "../../shared/logger"
import { getMainSessionID } from "../claude-code-session-state"
type OpencodeClient = PluginInput["client"]
interface MessagePartInfo {
sessionID?: string
type?: string
tool?: string
}
interface EventProperties {
sessionID?: string
info?: { id?: string }
[key: string]: unknown
}
interface Event {
type: string
properties?: EventProperties
}
export class BackgroundManager {
private tasks: Map<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]>
private client: OpencodeClient
private directory: string
private pollingInterval?: Timer
constructor(ctx: PluginInput) {
this.tasks = new Map()
this.notifications = new Map()
this.client = ctx.client
this.directory = ctx.directory
}
async launch(input: LaunchInput): Promise<BackgroundTask> {
const createResult = await this.client.session.create({
body: {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
},
})
if (createResult.error) {
throw new Error(`Failed to create background session: ${createResult.error}`)
}
const sessionID = createResult.data.id
const task: BackgroundTask = {
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
sessionID,
parentSessionID: input.parentSessionID,
parentMessageID: input.parentMessageID,
description: input.description,
agent: input.agent,
status: "running",
startedAt: new Date(),
progress: {
toolCalls: 0,
lastUpdate: new Date(),
},
}
this.tasks.set(task.id, task)
this.startPolling()
log("[background-agent] Launching task:", { taskId: task.id, sessionID })
this.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: input.agent,
tools: {
background_task: false,
background_output: false,
background_cancel: false,
call_omo_agent: false,
},
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
log("[background-agent] promptAsync error:", error)
const existingTask = this.findBySession(sessionID)
if (existingTask) {
existingTask.status = "error"
existingTask.error = String(error)
existingTask.completedAt = new Date()
}
})
return task
}
getTask(id: string): BackgroundTask | undefined {
return this.tasks.get(id)
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
for (const task of this.tasks.values()) {
if (task.parentSessionID === sessionID) {
result.push(task)
}
}
return result
}
findBySession(sessionID: string): BackgroundTask | undefined {
for (const task of this.tasks.values()) {
if (task.sessionID === sessionID) {
return task
}
}
return undefined
}
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.part.updated") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task) return
if (partInfo?.type === "tool" || partInfo?.tool) {
if (!task.progress) {
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
}
}
task.progress.toolCalls += 1
task.progress.lastTool = partInfo.tool
task.progress.lastUpdate = new Date()
}
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
this.notifyParentSession(task)
log("[background-agent] Task completed via session.idle event:", task.id)
}
if (event.type === "session.deleted") {
const info = props?.info
if (!info || typeof info.id !== "string") return
const sessionID = info.id
const task = this.findBySession(sessionID)
if (!task) return
if (task.status === "running") {
task.status = "cancelled"
task.completedAt = new Date()
task.error = "Session deleted"
}
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
}
}
markForNotification(task: BackgroundTask): void {
const queue = this.notifications.get(task.parentSessionID) ?? []
queue.push(task)
this.notifications.set(task.parentSessionID, queue)
}
getPendingNotifications(sessionID: string): BackgroundTask[] {
return this.notifications.get(sessionID) ?? []
}
clearNotifications(sessionID: string): void {
this.notifications.delete(sessionID)
}
private clearNotificationsForTask(taskId: string): void {
for (const [sessionID, tasks] of this.notifications.entries()) {
const filtered = tasks.filter((t) => t.id !== taskId)
if (filtered.length === 0) {
this.notifications.delete(sessionID)
} else {
this.notifications.set(sessionID, filtered)
}
}
}
private startPolling(): void {
if (this.pollingInterval) return
this.pollingInterval = setInterval(() => {
this.pollRunningTasks()
}, 2000)
}
private stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = undefined
}
}
private notifyParentSession(task: BackgroundTask): void {
const duration = this.formatDuration(task.startedAt, task.completedAt)
log("[background-agent] notifyParentSession called for task:", task.id)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (tuiClient.tui?.showToast) {
tuiClient.tui.showToast({
body: {
title: "Background Task Completed",
message: `Task "${task.description}" finished in ${duration}.`,
variant: "success",
duration: 5000,
},
}).catch(() => {})
}
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
const mainSessionID = getMainSessionID()
if (!mainSessionID) {
log("[background-agent] No main session ID available, relying on pending queue")
return
}
log("[background-agent] Sending notification to main session:", mainSessionID)
setTimeout(async () => {
try {
await this.client.session.prompt({
path: { id: mainSessionID },
body: {
parts: [{ type: "text", text: message }],
},
query: { directory: this.directory },
})
this.clearNotificationsForTask(task.id)
log("[background-agent] Successfully sent prompt to main session")
} catch (error) {
log("[background-agent] prompt failed:", String(error))
}
}, 200)
}
private formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
}
return `${seconds}s`
}
private hasRunningTasks(): boolean {
for (const task of this.tasks.values()) {
if (task.status === "running") return true
}
return false
}
private async pollRunningTasks(): Promise<void> {
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
try {
const sessionStatus = allStatuses[task.sessionID]
if (!sessionStatus) {
log("[background-agent] Session not found in status:", task.sessionID)
continue
}
if (sessionStatus.type === "idle") {
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
this.notifyParentSession(task)
log("[background-agent] Task completed via polling:", task.id)
continue
}
const messagesResult = await this.client.session.messages({
path: { id: task.sessionID },
})
if (!messagesResult.error && messagesResult.data) {
const messages = messagesResult.data as Array<{
info?: { role?: string }
parts?: Array<{ type?: string; tool?: string; name?: string }>
}>
const assistantMsgs = messages.filter(
(m) => m.info?.role === "assistant"
)
let toolCalls = 0
let lastTool: string | undefined
for (const msg of assistantMsgs) {
const parts = msg.parts ?? []
for (const part of parts) {
if (part.type === "tool_use" || part.tool) {
toolCalls++
lastTool = part.tool || part.name || "unknown"
}
}
}
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.toolCalls = toolCalls
task.progress.lastTool = lastTool
task.progress.lastUpdate = new Date()
}
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}
}
if (!this.hasRunningTasks()) {
this.stopPolling()
}
}
}

View File

@@ -0,0 +1,34 @@
export type BackgroundTaskStatus =
| "running"
| "completed"
| "error"
| "cancelled"
export interface TaskProgress {
toolCalls: number
lastTool?: string
lastUpdate: Date
}
export interface BackgroundTask {
id: string
sessionID: string
parentSessionID: string
parentMessageID: string
description: string
agent: string
status: BackgroundTaskStatus
startedAt: Date
completedAt?: Date
result?: string
error?: string
progress?: TaskProgress
}
export interface LaunchInput {
description: string
prompt: string
agent: string
parentSessionID: string
parentMessageID: string
}

View File

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

View File

@@ -0,0 +1,93 @@
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 type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
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
}
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
}
function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] {
if (!existsSync(agentsDir)) {
return []
}
const entries = readdirSync(agentsDir, { withFileTypes: true })
const agents: LoadedAgent[] = []
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const agentPath = join(agentsDir, entry.name)
const agentName = basename(entry.name, ".md")
try {
const content = readFileSync(agentPath, "utf-8")
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
const name = data.name || agentName
const originalDescription = data.description || ""
const formattedDescription = `(${scope}) ${originalDescription}`
const config: AgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
}
const toolsConfig = parseToolsConfig(data.tools)
if (toolsConfig) {
config.tools = toolsConfig
}
agents.push({
name,
path: agentPath,
config,
scope,
})
} catch {
continue
}
}
return agents
}
export function loadUserAgents(): Record<string, AgentConfig> {
const userAgentsDir = join(homedir(), ".claude", "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, AgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}
export function loadProjectAgents(): Record<string, AgentConfig> {
const projectAgentsDir = join(process.cwd(), ".claude", "agents")
const agents = loadAgentsFromDir(projectAgentsDir, "project")
const result: Record<string, AgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}

View File

@@ -0,0 +1,17 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export type AgentScope = "user" | "project"
export interface AgentFrontmatter {
name?: string
description?: string
model?: string
tools?: string
}
export interface LoadedAgent {
name: string
path: string
config: AgentConfig
scope: AgentScope
}

View File

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

View File

@@ -0,0 +1,94 @@
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 type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
}
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
if (!existsSync(commandsDir)) {
return []
}
const entries = readdirSync(commandsDir, { withFileTypes: true })
const commands: LoadedCommand[] = []
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
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 = `(${scope}) ${data.description || ""}`
const definition: CommandDefinition = {
name: commandName,
description: formattedDescription,
template: wrappedTemplate,
agent: data.agent,
model: sanitizeModelField(data.model),
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
commands.push({
name: commandName,
path: commandPath,
definition,
scope,
})
} catch {
continue
}
}
return commands
}
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
for (const cmd of commands) {
result[cmd.name] = cmd.definition
}
return result
}
export function loadUserCommands(): Record<string, CommandDefinition> {
const userCommandsDir = join(homedir(), ".claude", "commands")
const commands = loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands)
}
export function loadProjectCommands(): Record<string, CommandDefinition> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = loadCommandsFromDir(projectCommandsDir, "project")
return commandsToRecord(commands)
}
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}

View File

@@ -0,0 +1,26 @@
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
export interface CommandDefinition {
name: string
description?: string
template: string
agent?: string
model?: string
subtask?: boolean
argumentHint?: string
}
export interface CommandFrontmatter {
description?: string
"argument-hint"?: string
agent?: string
model?: string
subtask?: boolean
}
export interface LoadedCommand {
name: string
path: string
definition: CommandDefinition
scope: CommandScope
}

View File

@@ -0,0 +1,27 @@
export function expandEnvVars(value: string): string {
return value.replace(
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
(_, varName: string, defaultValue?: string) => {
const envValue = process.env[varName]
if (envValue !== undefined) return envValue
if (defaultValue !== undefined) return defaultValue
return ""
}
)
}
export function expandEnvVarsInObject<T>(obj: T): T {
if (obj === null || obj === undefined) return obj
if (typeof obj === "string") return expandEnvVars(obj) as T
if (Array.isArray(obj)) {
return obj.map((item) => expandEnvVarsInObject(item)) as T
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = expandEnvVarsInObject(value)
}
return result as T
}
return obj
}

View File

@@ -0,0 +1,11 @@
/**
* MCP Configuration Loader
*
* Loads Claude Code .mcp.json format configurations from multiple scopes
* and transforms them to OpenCode SDK format
*/
export * from "./types"
export * from "./loader"
export * from "./transformer"
export * from "./env-expander"

View File

@@ -0,0 +1,89 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import type {
ClaudeCodeMcpConfig,
LoadedMcpServer,
McpLoadResult,
McpScope,
} from "./types"
import { transformMcpServer } from "./transformer"
import { log } from "../../shared/logger"
interface McpConfigPath {
path: string
scope: McpScope
}
function getMcpConfigPaths(): McpConfigPath[] {
const home = homedir()
const cwd = process.cwd()
return [
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
{ path: join(cwd, ".mcp.json"), scope: "project" },
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
]
}
async function loadMcpConfigFile(
filePath: string
): Promise<ClaudeCodeMcpConfig | null> {
if (!existsSync(filePath)) {
return null
}
try {
const content = await Bun.file(filePath).text()
return JSON.parse(content) as ClaudeCodeMcpConfig
} catch (error) {
log(`Failed to load MCP config from ${filePath}`, error)
return null
}
}
export async function loadMcpConfigs(): Promise<McpLoadResult> {
const servers: McpLoadResult["servers"] = {}
const loadedServers: LoadedMcpServer[] = []
const paths = getMcpConfigPaths()
for (const { path, scope } of paths) {
const config = await loadMcpConfigFile(path)
if (!config?.mcpServers) continue
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (serverConfig.disabled) {
log(`Skipping disabled MCP server "${name}"`, { path })
continue
}
try {
const transformed = transformMcpServer(name, serverConfig)
servers[name] = transformed
const existingIndex = loadedServers.findIndex((s) => s.name === name)
if (existingIndex !== -1) {
loadedServers.splice(existingIndex, 1)
}
loadedServers.push({ name, scope, config: transformed })
log(`Loaded MCP server "${name}" from ${scope}`, { path })
} catch (error) {
log(`Failed to transform MCP server "${name}"`, error)
}
}
}
return { servers, loadedServers }
}
export function formatLoadedServersForToast(
loadedServers: LoadedMcpServer[]
): string {
if (loadedServers.length === 0) return ""
return loadedServers
.map((server) => `${server.name} (${server.scope})`)
.join(", ")
}

View File

@@ -0,0 +1,53 @@
import type {
ClaudeCodeMcpServer,
McpLocalConfig,
McpRemoteConfig,
McpServerConfig,
} from "./types"
import { expandEnvVarsInObject } from "./env-expander"
export function transformMcpServer(
name: string,
server: ClaudeCodeMcpServer
): McpServerConfig {
const expanded = expandEnvVarsInObject(server)
const serverType = expanded.type ?? "stdio"
if (serverType === "http" || serverType === "sse") {
if (!expanded.url) {
throw new Error(
`MCP server "${name}" requires url for type "${serverType}"`
)
}
const config: McpRemoteConfig = {
type: "remote",
url: expanded.url,
enabled: true,
}
if (expanded.headers && Object.keys(expanded.headers).length > 0) {
config.headers = expanded.headers
}
return config
}
if (!expanded.command) {
throw new Error(`MCP server "${name}" requires command for stdio type`)
}
const commandArray = [expanded.command, ...(expanded.args ?? [])]
const config: McpLocalConfig = {
type: "local",
command: commandArray,
enabled: true,
}
if (expanded.env && Object.keys(expanded.env).length > 0) {
config.environment = expanded.env
}
return config
}

View File

@@ -0,0 +1,42 @@
export type McpScope = "user" | "project" | "local"
export interface ClaudeCodeMcpServer {
type?: "http" | "sse" | "stdio"
url?: string
command?: string
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
disabled?: boolean
}
export interface ClaudeCodeMcpConfig {
mcpServers?: Record<string, ClaudeCodeMcpServer>
}
export interface McpLocalConfig {
type: "local"
command: string[]
environment?: Record<string, string>
enabled?: boolean
}
export interface McpRemoteConfig {
type: "remote"
url: string
headers?: Record<string, string>
enabled?: boolean
}
export type McpServerConfig = McpLocalConfig | McpRemoteConfig
export interface LoadedMcpServer {
name: string
scope: McpScope
config: McpServerConfig
}
export interface McpLoadResult {
servers: Record<string, McpServerConfig>
loadedServers: LoadedMcpServer[]
}

View File

@@ -0,0 +1,21 @@
export function detectInterrupt(error: unknown): boolean {
if (!error) return false
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
const name = errObj.name as string | undefined
const message = errObj.message as string | undefined
if (name === "MessageAbortedError" || name === "AbortError") return true
if (name === "DOMException" && message?.includes("abort")) return true
const msgLower = message?.toLowerCase()
if (msgLower?.includes("aborted") || msgLower?.includes("cancelled") || msgLower?.includes("interrupted")) return true
}
if (typeof error === "string") {
const lower = error.toLowerCase()
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
}
return false
}

View File

@@ -0,0 +1,3 @@
export * from "./types"
export * from "./state"
export * from "./detector"

View File

@@ -0,0 +1,31 @@
import type { SessionErrorState, SessionInterruptState } from "./types"
export const sessionErrorState = new Map<string, SessionErrorState>()
export const sessionInterruptState = new Map<string, SessionInterruptState>()
export const subagentSessions = new Set<string>()
export const sessionFirstMessageProcessed = new Set<string>()
export let currentSessionID: string | undefined
export let currentSessionTitle: string | undefined
export let mainSessionID: string | undefined
export function setCurrentSession(id: string | undefined, title: string | undefined) {
currentSessionID = id
currentSessionTitle = title
}
export function setMainSession(id: string | undefined) {
mainSessionID = id
}
export function getCurrentSessionID(): string | undefined {
return currentSessionID
}
export function getCurrentSessionTitle(): string | undefined {
return currentSessionTitle
}
export function getMainSessionID(): string | undefined {
return mainSessionID
}

View File

@@ -0,0 +1,8 @@
export interface SessionErrorState {
hasError: boolean
errorMessage?: string
}
export interface SessionInterruptState {
interrupted: boolean
}

View File

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

View File

@@ -0,0 +1,85 @@
import { existsSync, readdirSync, readFileSync, statSync, readlinkSync } from "fs"
import { homedir } from "os"
import { join, resolve } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
const skills: LoadedSkillAsCommand[] = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const skillPath = join(skillsDir, entry.name)
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
let resolvedPath = skillPath
if (statSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
resolvedPath = resolve(skillPath, "..", readlinkSync(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 originalDescription = data.description || ""
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const definition: CommandDefinition = {
name: skillName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model),
}
skills.push({
name: skillName,
path: resolvedPath,
definition,
scope,
})
} catch {
continue
}
}
return skills
}
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
const userSkillsDir = join(homedir(), ".claude", "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition
return acc
}, {} as Record<string, CommandDefinition>)
}
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = loadSkillsFromDir(projectSkillsDir, "project")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition
return acc
}, {} as Record<string, CommandDefinition>)
}

View File

@@ -0,0 +1,16 @@
import type { CommandDefinition } from "../claude-code-command-loader/types"
export type SkillScope = "user" | "project"
export interface SkillMetadata {
name: string
description: string
model?: string
}
export interface LoadedSkillAsCommand {
name: string
path: string
definition: CommandDefinition
scope: SkillScope
}

View File

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

View File

@@ -0,0 +1,2 @@
export { injectHookMessage } from "./injector"
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"

View File

@@ -0,0 +1,141 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
import type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
interface StoredMessage {
agent?: string
model?: { providerID?: string; modelID?: string }
tools?: Record<string, boolean>
}
function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
try {
const files = readdirSync(messageDir)
.filter((f) => f.endsWith(".json"))
.sort()
.reverse()
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const msg = JSON.parse(content) as StoredMessage
if (msg.agent && msg.model?.providerID && msg.model?.modelID) {
return msg
}
} catch {
continue
}
}
} catch {
return null
}
return null
}
function generateMessageId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 14)
return `msg_${timestamp}${random}`
}
function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
function getOrCreateMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) {
mkdirSync(MESSAGE_STORAGE, { recursive: true })
}
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
}
}
mkdirSync(directPath, { recursive: true })
return directPath
}
export function injectHookMessage(
sessionID: string,
hookContent: string,
originalMessage: OriginalMessageContext
): boolean {
const messageDir = getOrCreateMessageDir(sessionID)
const needsFallback =
!originalMessage.agent ||
!originalMessage.model?.providerID ||
!originalMessage.model?.modelID
const fallback = needsFallback ? findNearestMessageWithFields(messageDir) : null
const now = Date.now()
const messageID = generateMessageId()
const partID = generatePartId()
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
const resolvedModel =
originalMessage.model?.providerID && originalMessage.model?.modelID
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
: fallback?.model?.providerID && fallback?.model?.modelID
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
: undefined
const resolvedTools = originalMessage.tools ?? fallback?.tools
const messageMeta: MessageMeta = {
id: messageID,
sessionID,
role: "user",
time: {
created: now,
},
agent: resolvedAgent,
model: resolvedModel,
path:
originalMessage.path?.cwd
? {
cwd: originalMessage.path.cwd,
root: originalMessage.path.root ?? "/",
}
: undefined,
tools: resolvedTools,
}
const textPart: TextPart = {
id: partID,
type: "text",
text: hookContent,
synthetic: true,
time: {
start: now,
end: now,
},
messageID,
sessionID,
}
try {
writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2))
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2))
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,45 @@
export interface MessageMeta {
id: string
sessionID: string
role: "user" | "assistant"
time: {
created: number
completed?: number
}
agent?: string
model?: {
providerID: string
modelID: string
}
path?: {
cwd: string
root: string
}
tools?: Record<string, boolean>
}
export interface OriginalMessageContext {
agent?: string
model?: {
providerID?: string
modelID?: string
}
path?: {
cwd?: string
root?: string
}
tools?: Record<string, boolean>
}
export interface TextPart {
id: string
type: "text"
text: string
synthetic: boolean
time: {
start: number
end: number
}
messageID: string
sessionID: string
}

8
src/google-auth.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { Plugin } from "@opencode-ai/plugin"
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"
const GoogleAntigravityAuthPlugin: Plugin = async (ctx) => {
return createGoogleAntigravityAuthPlugin(ctx)
}
export default GoogleAntigravityAuthPlugin

View File

@@ -0,0 +1,143 @@
import type { AutoCompactState, RetryState } from "./types"
import { RETRY_CONFIG } from "./types"
type Client = {
session: {
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
summarize: (opts: {
path: { id: string }
body: { providerID: string; modelID: string }
query: { directory: string }
}) => Promise<unknown>
}
tui: {
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
showToast: (opts: {
body: { title: string; message: string; variant: string; duration: number }
}) => Promise<unknown>
}
}
function calculateRetryDelay(attempt: number): number {
const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, attempt - 1)
return Math.min(delay, RETRY_CONFIG.maxDelayMs)
}
function shouldRetry(retryState: RetryState | undefined): boolean {
if (!retryState) return true
return retryState.attempt < RETRY_CONFIG.maxAttempts
}
function getOrCreateRetryState(
autoCompactState: AutoCompactState,
sessionID: string
): RetryState {
let state = autoCompactState.retryStateBySession.get(sessionID)
if (!state) {
state = { attempt: 0, lastAttemptTime: 0 }
autoCompactState.retryStateBySession.set(sessionID, state)
}
return state
}
export async function getLastAssistant(
sessionID: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string
): Promise<Record<string, unknown> | null> {
try {
const resp = await (client as Client).session.messages({
path: { id: sessionID },
query: { directory },
})
const data = (resp as { data?: unknown[] }).data
if (!Array.isArray(data)) return null
const reversed = [...data].reverse()
const last = reversed.find((m) => {
const msg = m as Record<string, unknown>
const info = msg.info as Record<string, unknown> | undefined
return info?.role === "assistant"
})
if (!last) return null
return (last as { info?: Record<string, unknown> }).info ?? null
} catch {
return null
}
}
function clearSessionState(autoCompactState: AutoCompactState, sessionID: string): void {
autoCompactState.pendingCompact.delete(sessionID)
autoCompactState.errorDataBySession.delete(sessionID)
autoCompactState.retryStateBySession.delete(sessionID)
}
export async function executeCompact(
sessionID: string,
msg: Record<string, unknown>,
autoCompactState: AutoCompactState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string
): Promise<void> {
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
if (!shouldRetry(retryState)) {
clearSessionState(autoCompactState, sessionID)
await (client as Client).tui
.showToast({
body: {
title: "Auto Compact Failed",
message: `Failed after ${RETRY_CONFIG.maxAttempts} attempts. Please try manual compact.`,
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return
}
retryState.attempt++
retryState.lastAttemptTime = Date.now()
try {
const providerID = msg.providerID as string | undefined
const modelID = msg.modelID as string | undefined
if (providerID && modelID) {
await (client as Client).session.summarize({
path: { id: sessionID },
body: { providerID, modelID },
query: { directory },
})
clearSessionState(autoCompactState, sessionID)
setTimeout(async () => {
try {
await (client as Client).tui.submitPrompt({ query: { directory } })
} catch {}
}, 500)
}
} catch {
const delay = calculateRetryDelay(retryState.attempt)
await (client as Client).tui
.showToast({
body: {
title: "Auto Compact Retry",
message: `Attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts} failed. Retrying in ${Math.round(delay / 1000)}s...`,
variant: "warning",
duration: delay,
},
})
.catch(() => {})
setTimeout(() => {
executeCompact(sessionID, msg, autoCompactState, client, directory)
}, delay)
}
}

View File

@@ -0,0 +1,125 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map(),
}
}
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
const autoCompactState = createAutoCompactState()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id)
}
return
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const parsed = parseAnthropicTokenLimitError(props?.error)
if (parsed) {
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
}
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "assistant" && info.error) {
const parsed = parseAnthropicTokenLimitError(info.error)
if (parsed) {
parsed.providerID = info.providerID as string | undefined
parsed.modelID = info.modelID as string | undefined
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
}
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (!autoCompactState.pendingCompact.has(sessionID)) return
const errorData = autoCompactState.errorDataBySession.get(sessionID)
if (errorData?.providerID && errorData?.modelID) {
await ctx.client.tui
.showToast({
body: {
title: "Auto Compact",
message: "Token limit exceeded. Summarizing session...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
await executeCompact(
sessionID,
{ providerID: errorData.providerID, modelID: errorData.modelID },
autoCompactState,
ctx.client,
ctx.directory
)
return
}
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
if (!lastAssistant) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
if (lastAssistant.summary === true) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
if (!lastAssistant.modelID || !lastAssistant.providerID) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
await ctx.client.tui
.showToast({
body: {
title: "Auto Compact",
message: "Token limit exceeded. Summarizing session...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
await executeCompact(sessionID, lastAssistant, autoCompactState, ctx.client, ctx.directory)
}
}
return {
event: eventHandler,
}
}
export type { AutoCompactState, ParsedTokenLimitError } from "./types"
export { parseAnthropicTokenLimitError } from "./parser"
export { executeCompact, getLastAssistant } from "./executor"

View File

@@ -0,0 +1,154 @@
import type { ParsedTokenLimitError } from "./types"
interface AnthropicErrorData {
type: "error"
error: {
type: string
message: string
}
request_id?: string
}
const TOKEN_LIMIT_PATTERNS = [
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
/(\d+).*?tokens.*?limit.*?(\d+)/i,
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
]
const TOKEN_LIMIT_KEYWORDS = [
"prompt is too long",
"is too long",
"context_length_exceeded",
"max_tokens",
"token limit",
"context length",
"too many tokens",
]
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
for (const pattern of TOKEN_LIMIT_PATTERNS) {
const match = message.match(pattern)
if (match) {
const num1 = parseInt(match[1], 10)
const num2 = parseInt(match[2], 10)
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
}
}
return null
}
function isTokenLimitError(text: string): boolean {
const lower = text.toLowerCase()
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
}
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
if (typeof err === "string") {
if (isTokenLimitError(err)) {
const tokens = extractTokensFromMessage(err)
return {
currentTokens: tokens?.current ?? 0,
maxTokens: tokens?.max ?? 0,
errorType: "token_limit_exceeded_string",
}
}
return null
}
if (!err || typeof err !== "object") return null
const errObj = err as Record<string, unknown>
const dataObj = errObj.data as Record<string, unknown> | undefined
const responseBody = dataObj?.responseBody
const errorMessage = errObj.message as string | undefined
const errorData = errObj.error as Record<string, unknown> | undefined
const nestedError = errorData?.error as Record<string, unknown> | undefined
const textSources: string[] = []
if (typeof responseBody === "string") textSources.push(responseBody)
if (typeof errorMessage === "string") textSources.push(errorMessage)
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
if (textSources.length === 0) {
try {
const jsonStr = JSON.stringify(errObj)
if (isTokenLimitError(jsonStr)) {
textSources.push(jsonStr)
}
} catch {}
}
const combinedText = textSources.join(" ")
if (!isTokenLimitError(combinedText)) return null
if (typeof responseBody === "string") {
try {
const jsonPatterns = [
/data:\s*(\{[\s\S]*?\})\s*$/m,
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
/(\{[\s\S]*?"error"[\s\S]*?\})/,
]
for (const pattern of jsonPatterns) {
const dataMatch = responseBody.match(pattern)
if (dataMatch) {
try {
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
const message = jsonData.error?.message || ""
const tokens = extractTokensFromMessage(message)
if (tokens) {
return {
currentTokens: tokens.current,
maxTokens: tokens.max,
requestId: jsonData.request_id,
errorType: jsonData.error?.type || "token_limit_exceeded",
}
}
} catch {}
}
}
const bedrockJson = JSON.parse(responseBody)
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
return {
currentTokens: 0,
maxTokens: 0,
errorType: "bedrock_input_too_long",
}
}
} catch {}
}
for (const text of textSources) {
const tokens = extractTokensFromMessage(text)
if (tokens) {
return {
currentTokens: tokens.current,
maxTokens: tokens.max,
errorType: "token_limit_exceeded",
}
}
}
if (isTokenLimitError(combinedText)) {
return {
currentTokens: 0,
maxTokens: 0,
errorType: "token_limit_exceeded_unknown",
}
}
return null
}

View File

@@ -0,0 +1,26 @@
export interface ParsedTokenLimitError {
currentTokens: number
maxTokens: number
requestId?: string
errorType: string
providerID?: string
modelID?: string
}
export interface RetryState {
attempt: number
lastAttemptTime: number
}
export interface AutoCompactState {
pendingCompact: Set<string>
errorDataBySession: Map<string, ParsedTokenLimitError>
retryStateBySession: Map<string, RetryState>
}
export const RETRY_CONFIG = {
maxAttempts: 5,
initialDelayMs: 2000,
backoffFactor: 2,
maxDelayMs: 30000,
} as const

View File

@@ -0,0 +1,18 @@
import * as fs from "node:fs"
import { VERSION_FILE } from "./constants"
import { log } from "../../shared/logger"
export function invalidateCache(): boolean {
try {
if (fs.existsSync(VERSION_FILE)) {
fs.unlinkSync(VERSION_FILE)
log(`[auto-update-checker] Cache invalidated: ${VERSION_FILE}`)
return true
}
log("[auto-update-checker] Version file not found, nothing to invalidate")
return false
} catch (err) {
log("[auto-update-checker] Failed to invalidate cache:", err)
return false
}
}

View File

@@ -0,0 +1,119 @@
import * as fs from "node:fs"
import * as path from "node:path"
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
import {
PACKAGE_NAME,
NPM_REGISTRY_URL,
NPM_FETCH_TIMEOUT,
INSTALLED_PACKAGE_JSON,
USER_OPENCODE_CONFIG,
} from "./constants"
import { log } from "../../shared/logger"
export function isLocalDevMode(directory: string): boolean {
const projectConfig = path.join(directory, ".opencode", "opencode.json")
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
const config = JSON.parse(content) as OpencodeConfig
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
return true
}
}
} catch {
continue
}
}
return false
}
export function findPluginEntry(directory: string): string | null {
const projectConfig = path.join(directory, ".opencode", "opencode.json")
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
const config = JSON.parse(content) as OpencodeConfig
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
return entry
}
}
} catch {
continue
}
}
return null
}
export function getCachedVersion(): string | null {
try {
if (!fs.existsSync(INSTALLED_PACKAGE_JSON)) return null
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(content) as PackageJson
return pkg.version ?? null
} catch {
return null
}
}
export async function getLatestVersion(): Promise<string | null> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
try {
const response = await fetch(NPM_REGISTRY_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
if (!response.ok) return null
const data = (await response.json()) as NpmDistTags
return data.latest ?? null
} catch {
return null
} finally {
clearTimeout(timeoutId)
}
}
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
if (isLocalDevMode(directory)) {
log("[auto-update-checker] Local dev mode detected, skipping update check")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true }
}
const pluginEntry = findPluginEntry(directory)
if (!pluginEntry) {
log("[auto-update-checker] Plugin not found in config")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
}
const currentVersion = getCachedVersion()
if (!currentVersion) {
log("[auto-update-checker] No cached version found")
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
}
const latestVersion = await getLatestVersion()
if (!latestVersion) {
log("[auto-update-checker] Failed to fetch latest version")
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false }
}
const needsUpdate = currentVersion !== latestVersion
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false }
}

View File

@@ -0,0 +1,40 @@
import * as path from "node:path"
import * as os from "node:os"
export const PACKAGE_NAME = "oh-my-opencode"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000
/**
* OpenCode plugin cache directory
* - Linux/macOS: ~/.cache/opencode/
* - Windows: %LOCALAPPDATA%/opencode/
*/
function getCacheDir(): string {
if (process.platform === "win32") {
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
}
return path.join(os.homedir(), ".cache", "opencode")
}
export const CACHE_DIR = getCacheDir()
export const VERSION_FILE = path.join(CACHE_DIR, "version")
export const INSTALLED_PACKAGE_JSON = path.join(
CACHE_DIR,
"node_modules",
PACKAGE_NAME,
"package.json"
)
/**
* OpenCode config file locations (priority order)
*/
function getUserConfigDir(): string {
if (process.platform === "win32") {
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
}
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
}
export const USER_CONFIG_DIR = getUserConfigDir()
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")

View File

@@ -0,0 +1,56 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { checkForUpdate } from "./checker"
import { invalidateCache } from "./cache"
import { PACKAGE_NAME } from "./constants"
import { log } from "../../shared/logger"
export function createAutoUpdateCheckerHook(ctx: PluginInput) {
let hasChecked = false
return {
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.created") return
if (hasChecked) return
const props = event.properties as { info?: { parentID?: string } } | undefined
if (props?.info?.parentID) return
hasChecked = true
try {
const result = await checkForUpdate(ctx.directory)
if (result.isLocalDev) {
log("[auto-update-checker] Skipped: local development mode")
return
}
if (!result.needsUpdate) {
log("[auto-update-checker] No update needed")
return
}
invalidateCache()
await ctx.client.tui
.showToast({
body: {
title: `${PACKAGE_NAME} Update`,
message: `v${result.latestVersion} available (current: v${result.currentVersion}). Restart OpenCode to apply.`,
variant: "info" as const,
duration: 8000,
},
})
.catch(() => {})
log(`[auto-update-checker] Update notification sent: v${result.currentVersion} → v${result.latestVersion}`)
} catch (err) {
log("[auto-update-checker] Error during update check:", err)
}
},
}
}
export type { UpdateCheckResult } from "./types"
export { checkForUpdate } from "./checker"
export { invalidateCache } from "./cache"

View File

@@ -0,0 +1,22 @@
export interface NpmDistTags {
latest: string
[key: string]: string
}
export interface OpencodeConfig {
plugin?: string[]
[key: string]: unknown
}
export interface PackageJson {
version: string
name?: string
[key: string]: unknown
}
export interface UpdateCheckResult {
needsUpdate: boolean
currentVersion: string | null
latestVersion: string | null
isLocalDev: boolean
}

View File

@@ -0,0 +1,22 @@
import type { BackgroundManager } from "../../features/background-agent"
interface Event {
type: string
properties?: Record<string, unknown>
}
interface EventInput {
event: Event
}
export function createBackgroundNotificationHook(manager: BackgroundManager) {
const eventHandler = async ({ event }: EventInput) => {
manager.handleEvent(event)
}
return {
event: eventHandler,
}
}
export type { BackgroundNotificationHookConfig } from "./types"

View File

@@ -0,0 +1,5 @@
import type { BackgroundTask } from "../../features/background-agent"
export interface BackgroundNotificationHookConfig {
formatNotification?: (tasks: BackgroundTask[]) => string
}

View File

@@ -0,0 +1,105 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger"
export interface DisabledHooksConfig {
Stop?: string[]
PreToolUse?: string[]
PostToolUse?: string[]
UserPromptSubmit?: string[]
}
export interface PluginExtendedConfig {
disabledHooks?: DisabledHooksConfig
}
const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json")
function getProjectConfigPath(): string {
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
}
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
if (!existsSync(path)) {
return null
}
try {
const content = await Bun.file(path).text()
return JSON.parse(content) as PluginExtendedConfig
} catch (error) {
log("Failed to load config", { path, error })
return null
}
}
function mergeDisabledHooks(
base: DisabledHooksConfig | undefined,
override: DisabledHooksConfig | undefined
): DisabledHooksConfig {
if (!override) return base ?? {}
if (!base) return override
return {
Stop: override.Stop ?? base.Stop,
PreToolUse: override.PreToolUse ?? base.PreToolUse,
PostToolUse: override.PostToolUse ?? base.PostToolUse,
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
}
}
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
const merged: PluginExtendedConfig = {
disabledHooks: mergeDisabledHooks(
userConfig?.disabledHooks,
projectConfig?.disabledHooks
),
}
if (userConfig || projectConfig) {
log("Plugin extended config loaded", {
userConfigExists: userConfig !== null,
projectConfigExists: projectConfig !== null,
mergedDisabledHooks: merged.disabledHooks,
})
}
return merged
}
const regexCache = new Map<string, RegExp>()
function getRegex(pattern: string): RegExp {
let regex = regexCache.get(pattern)
if (!regex) {
try {
regex = new RegExp(pattern)
regexCache.set(pattern, regex)
} catch {
regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
regexCache.set(pattern, regex)
}
}
return regex
}
export function isHookCommandDisabled(
eventType: ClaudeHookEvent,
command: string,
config: PluginExtendedConfig | null
): boolean {
if (!config?.disabledHooks) return false
const patterns = config.disabledHooks[eventType]
if (!patterns || patterns.length === 0) return false
return patterns.some((pattern) => {
const regex = getRegex(pattern)
return regex.test(command)
})
}

View File

@@ -0,0 +1,100 @@
import { homedir } from "os"
import { join } from "path"
import { existsSync } from "fs"
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
interface RawHookMatcher {
matcher?: string
pattern?: string
hooks: HookCommand[]
}
interface RawClaudeHooksConfig {
PreToolUse?: RawHookMatcher[]
PostToolUse?: RawHookMatcher[]
UserPromptSubmit?: RawHookMatcher[]
Stop?: RawHookMatcher[]
}
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
return {
matcher: raw.matcher ?? raw.pattern ?? "*",
hooks: raw.hooks,
}
}
function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
const result: ClaudeHooksConfig = {}
const eventTypes: (keyof RawClaudeHooksConfig)[] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
]
for (const eventType of eventTypes) {
if (raw[eventType]) {
result[eventType] = raw[eventType].map(normalizeHookMatcher)
}
}
return result
}
export function getClaudeSettingsPaths(customPath?: string): string[] {
const home = homedir()
const paths = [
join(home, ".claude", "settings.json"),
join(process.cwd(), ".claude", "settings.json"),
join(process.cwd(), ".claude", "settings.local.json"),
]
if (customPath && existsSync(customPath)) {
paths.unshift(customPath)
}
return paths
}
function mergeHooksConfig(
base: ClaudeHooksConfig,
override: ClaudeHooksConfig
): ClaudeHooksConfig {
const result: ClaudeHooksConfig = { ...base }
const eventTypes: (keyof ClaudeHooksConfig)[] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
]
for (const eventType of eventTypes) {
if (override[eventType]) {
result[eventType] = [...(base[eventType] || []), ...override[eventType]]
}
}
return result
}
export async function loadClaudeHooksConfig(
customSettingsPath?: string
): Promise<ClaudeHooksConfig | null> {
const paths = getClaudeSettingsPaths(customSettingsPath)
let mergedConfig: ClaudeHooksConfig = {}
for (const settingsPath of paths) {
if (existsSync(settingsPath)) {
try {
const content = await Bun.file(settingsPath).text()
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
if (settings.hooks) {
const normalizedHooks = normalizeHooksConfig(settings.hooks)
mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks)
}
} catch {
continue
}
}
}
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
}

View File

@@ -0,0 +1,336 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { loadClaudeHooksConfig } from "./config"
import { loadPluginExtendedConfig } from "./config-loader"
import {
executePreToolUseHooks,
type PreToolUseContext,
} from "./pre-tool-use"
import {
executePostToolUseHooks,
type PostToolUseContext,
type PostToolUseClient,
} from "./post-tool-use"
import {
executeUserPromptSubmitHooks,
type UserPromptSubmitContext,
type MessagePart,
} from "./user-prompt-submit"
import {
executeStopHooks,
type StopContext,
} from "./stop"
import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
import type { PluginConfig } from "./types"
import { log, isHookDisabled } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"
const sessionFirstMessageProcessed = new Set<string>()
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
return {
"chat.message": async (
input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
},
output: {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
const interruptState = sessionInterruptState.get(input.sessionID)
if (interruptState?.interrupted) {
log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID })
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const textParts = output.parts.filter((p) => p.type === "text" && p.text)
const prompt = textParts.map((p) => p.text ?? "").join("\n")
recordUserMessage(input.sessionID, prompt)
const messageParts: MessagePart[] = textParts.map((p) => ({
type: p.type as "text",
text: p.text,
}))
const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateBeforeHooks?.interrupted) {
log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID })
return
}
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: input.sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
sessionFirstMessageProcessed.add(input.sessionID)
if (isFirstMessage) {
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
return
}
if (!isHookDisabled(config, "UserPromptSubmit")) {
const userPromptCtx: UserPromptSubmitContext = {
sessionId: input.sessionID,
parentSessionId,
prompt,
parts: messageParts,
cwd: ctx.directory,
}
const result = await executeUserPromptSubmitHooks(
userPromptCtx,
claudeConfig,
extendedConfig
)
if (result.block) {
throw new Error(result.reason ?? "Hook blocked the prompt")
}
const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateAfterHooks?.interrupted) {
log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID })
return
}
if (result.messages.length > 0) {
const hookContent = result.messages.join("\n\n")
const message = output.message as {
agent?: string
model?: { modelID?: string; providerID?: string }
path?: { cwd?: string; root?: string }
tools?: Record<string, boolean>
}
const success = injectHookMessage(input.sessionID, hookContent, {
agent: message.agent,
model: message.model,
path: message.path ?? { cwd: ctx.directory, root: "/" },
tools: message.tools,
})
log(success ? "Hook message injected via file system" : "File injection failed", {
sessionID: input.sessionID,
})
}
}
},
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> }
): Promise<void> => {
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
recordToolUse(input.sessionID, input.tool, output.args as Record<string, unknown>)
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
if (!isHookDisabled(config, "PreToolUse")) {
const preCtx: PreToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: output.args as Record<string, unknown>,
cwd: ctx.directory,
toolUseId: input.callID,
}
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
if (result.decision === "deny") {
ctx.client.tui
.showToast({
body: {
title: "PreToolUse Hook Executed",
message: `${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: BLOCKED ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
variant: "error",
duration: 4000,
},
})
.catch(() => {})
throw new Error(result.reason ?? "Hook blocked the operation")
}
if (result.modifiedInput) {
Object.assign(output.args as Record<string, unknown>, result.modifiedInput)
}
}
},
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
): Promise<void> => {
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
recordToolResult(input.sessionID, input.tool, cachedInput, (output.metadata as Record<string, unknown>) || {})
if (!isHookDisabled(config, "PostToolUse")) {
const postClient: PostToolUseClient = {
session: {
messages: (opts) => ctx.client.session.messages(opts),
},
}
const postCtx: PostToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: cachedInput,
toolOutput: {
title: input.tool,
output: output.output,
metadata: output.metadata as Record<string, unknown>,
},
cwd: ctx.directory,
transcriptPath: getTranscriptPath(input.sessionID),
toolUseId: input.callID,
client: postClient,
permissionMode: "bypassPermissions",
}
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
if (result.block) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Warning",
message: result.reason ?? "Hook returned warning",
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
}
if (result.warnings && result.warnings.length > 0) {
output.output = `${output.output}\n\n${result.warnings.join("\n")}`
}
if (result.message) {
output.output = `${output.output}\n\n${result.message}`
}
if (result.hookName) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Executed",
message: `${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`,
variant: "success",
duration: 2000,
},
})
.catch(() => {})
}
}
},
event: async (input: { event: { type: string; properties?: unknown } }) => {
const { event } = input
if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
sessionErrorState.set(sessionID, {
hasError: true,
errorMessage: String(props?.error ?? "Unknown error"),
})
}
return
}
if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessionErrorState.delete(sessionInfo.id)
sessionInterruptState.delete(sessionInfo.id)
sessionFirstMessageProcessed.delete(sessionInfo.id)
}
return
}
if (event.type === "session.idle") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const errorStateBefore = sessionErrorState.get(sessionID)
const endedWithErrorBefore = errorStateBefore?.hasError === true
const interruptStateBefore = sessionInterruptState.get(sessionID)
const interruptedBefore = interruptStateBefore?.interrupted === true
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
if (!isHookDisabled(config, "Stop")) {
const stopCtx: StopContext = {
sessionId: sessionID,
parentSessionId,
cwd: ctx.directory,
}
const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
const errorStateAfter = sessionErrorState.get(sessionID)
const endedWithErrorAfter = errorStateAfter?.hasError === true
const interruptStateAfter = sessionInterruptState.get(sessionID)
const interruptedAfter = interruptStateAfter?.interrupted === true
const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter
if (shouldBypass && stopResult.block) {
const interrupted = interruptedBefore || interruptedAfter
const endedWithError = endedWithErrorBefore || endedWithErrorAfter
log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError })
} else if (stopResult.block && stopResult.injectPrompt) {
log("Stop hook returned block with inject_prompt", { sessionID })
ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: stopResult.injectPrompt }] },
query: { directory: ctx.directory },
})
.catch((err: unknown) => log("Failed to inject prompt from Stop hook", err))
} else if (stopResult.block) {
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
}
}
sessionErrorState.delete(sessionID)
sessionInterruptState.delete(sessionID)
}
},
}
}

View File

@@ -0,0 +1,9 @@
/**
* Plugin configuration for Claude Code hooks execution
* Contains settings for hook command execution (zsh, etc.)
*/
export const DEFAULT_CONFIG = {
forceZsh: true,
zshPath: "/bin/zsh",
}

View File

@@ -0,0 +1,199 @@
import type {
PostToolUseInput,
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PostToolUseClient {
session: {
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
}
}
export interface PostToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
toolOutput: Record<string, unknown>
cwd: string
transcriptPath?: string // Fallback for append-based transcript
toolUseId?: string
client?: PostToolUseClient
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PostToolUseResult {
block: boolean
reason?: string
message?: string
warnings?: string[]
elapsedMs?: number
hookName?: string
toolName?: string
additionalContext?: string
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
export async function executePostToolUseHooks(
ctx: PostToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PostToolUseResult> {
if (!config) {
return { block: false }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PostToolUse", transformedToolName)
if (matchers.length === 0) {
return { block: false }
}
// PORT FROM DISABLED: Build Claude Code compatible transcript (temp file)
let tempTranscriptPath: string | null = null
try {
// Try to build full transcript from API if client available
if (ctx.client) {
tempTranscriptPath = await buildTranscriptFromSession(
ctx.client,
ctx.sessionId,
ctx.cwd,
ctx.toolName,
ctx.toolInput
)
}
const stdinData: PostToolUseInput = {
session_id: ctx.sessionId,
// Use temp transcript if available, otherwise fallback to append-based
transcript_path: tempTranscriptPath ?? ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PostToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_response: objectToSnakeCase(ctx.toolOutput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const messages: string[] = []
const warnings: string[] = []
let firstHookName: string | undefined
const startTime = Date.now()
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) {
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.stdout) {
messages.push(result.stdout)
}
if (result.exitCode === 2) {
if (result.stderr) {
warnings.push(`[${hookName}]\n${result.stderr.trim()}`)
}
continue
}
if (result.exitCode === 0 && result.stdout) {
try {
const output = JSON.parse(result.stdout) as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
return {
block: false,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
} else if (result.exitCode !== 0 && result.exitCode !== 2) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
const elapsedMs = Date.now() - startTime
return {
block: false,
message: messages.length > 0 ? messages.join("\n") : undefined,
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs,
hookName: firstHookName,
toolName: transformedToolName,
}
} finally {
// PORT FROM DISABLED: Cleanup temp file to avoid disk accumulation
deleteTempTranscript(tempTranscriptPath)
}
}

View File

@@ -0,0 +1,172 @@
import type {
PreToolUseInput,
PreToolUseOutput,
PermissionDecision,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
cwd: string
transcriptPath?: string
toolUseId?: string
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PreToolUseResult {
decision: PermissionDecision
reason?: string
modifiedInput?: Record<string, unknown>
elapsedMs?: number
hookName?: string
toolName?: string
inputLines?: string
// Common output fields (Claude Code spec)
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
function buildInputLines(toolInput: Record<string, unknown>): string {
return Object.entries(toolInput)
.slice(0, 3)
.map(([key, val]) => {
const valStr = String(val).slice(0, 40)
return ` ${key}: ${valStr}${String(val).length > 40 ? "..." : ""}`
})
.join("\n")
}
export async function executePreToolUseHooks(
ctx: PreToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PreToolUseResult> {
if (!config) {
return { decision: "allow" }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PreToolUse", transformedToolName)
if (matchers.length === 0) {
return { decision: "allow" }
}
const stdinData: PreToolUseInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PreToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const startTime = Date.now()
let firstHookName: string | undefined
const inputLines = buildInputLines(ctx.toolInput)
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.exitCode === 2) {
return {
decision: "deny",
reason: result.stderr || result.stdout || "Hook blocked the operation",
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.exitCode === 1) {
return {
decision: "ask",
reason: result.stderr || result.stdout,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreToolUseOutput
// Handle deprecated decision/reason fields (Claude Code backward compat)
let decision: PermissionDecision | undefined
let reason: string | undefined
let modifiedInput: Record<string, unknown> | undefined
if (output.hookSpecificOutput?.permissionDecision) {
decision = output.hookSpecificOutput.permissionDecision
reason = output.hookSpecificOutput.permissionDecisionReason
modifiedInput = output.hookSpecificOutput.updatedInput
} else if (output.decision) {
// Map deprecated values: approve->allow, block->deny, ask->ask
const legacyDecision = output.decision
if (legacyDecision === "approve" || legacyDecision === "allow") {
decision = "allow"
} else if (legacyDecision === "block" || legacyDecision === "deny") {
decision = "deny"
} else if (legacyDecision === "ask") {
decision = "ask"
}
reason = output.reason
}
// Return if decision is set OR if any common fields are set (fallback to allow)
const hasCommonFields = output.continue !== undefined ||
output.stopReason !== undefined ||
output.suppressOutput !== undefined ||
output.systemMessage !== undefined
if (decision || hasCommonFields) {
return {
decision: decision ?? "allow",
reason,
modifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
return { decision: "allow" }
}

View File

@@ -0,0 +1,118 @@
import type {
StopInput,
StopOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { getTodoPath } from "./todo"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
// Module-level state to track stop_hook_active per session
const stopHookActiveState = new Map<string, boolean>()
export function setStopHookActive(sessionId: string, active: boolean): void {
stopHookActiveState.set(sessionId, active)
}
export function getStopHookActive(sessionId: string): boolean {
return stopHookActiveState.get(sessionId) ?? false
}
export interface StopContext {
sessionId: string
parentSessionId?: string
cwd: string
transcriptPath?: string
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
stopHookActive?: boolean
}
export interface StopResult {
block: boolean
reason?: string
stopHookActive?: boolean
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
injectPrompt?: string
}
export async function executeStopHooks(
ctx: StopContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<StopResult> {
if (ctx.parentSessionId) {
return { block: false }
}
if (!config) {
return { block: false }
}
const matchers = findMatchingHooks(config, "Stop")
if (matchers.length === 0) {
return { block: false }
}
const stdinData: StopInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "Stop",
stop_hook_active: stopHookActiveState.get(ctx.sessionId) ?? false,
todo_path: getTodoPath(ctx.sessionId),
hook_source: "opencode-plugin",
}
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
log("Stop hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
// Check exit code first - exit code 2 means block
if (result.exitCode === 2) {
const reason = result.stderr || result.stdout || "Blocked by stop hook"
return {
block: true,
reason,
injectPrompt: reason,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as StopOutput
if (output.stop_hook_active !== undefined) {
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
}
const isBlock = output.decision === "block"
// Determine inject_prompt: prefer explicit value, fallback to reason if blocking
const injectPrompt = output.inject_prompt ?? (isBlock && output.reason ? output.reason : undefined)
return {
block: isBlock,
reason: output.reason,
stopHookActive: output.stop_hook_active,
permissionMode: output.permission_mode,
injectPrompt,
}
} catch {
// Ignore JSON parse errors - hook may return non-JSON output
}
}
}
}
return { block: false }
}

View File

@@ -0,0 +1,76 @@
import { join } from "path"
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
import { homedir } from "os"
import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types"
const TODO_DIR = join(homedir(), ".claude", "todos")
export function getTodoPath(sessionId: string): string {
return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`)
}
function ensureTodoDir(): void {
if (!existsSync(TODO_DIR)) {
mkdirSync(TODO_DIR, { recursive: true })
}
}
export interface OpenCodeTodo {
content: string
status: string
priority: string
id: string
}
function toClaudeCodeFormat(item: OpenCodeTodo | TodoItem): ClaudeCodeTodoItem {
return {
content: item.content,
status: item.status === "cancelled" ? "completed" : item.status,
activeForm: item.content,
}
}
export function loadTodoFile(sessionId: string): TodoFile | null {
const path = getTodoPath(sessionId)
if (!existsSync(path)) return null
try {
const content = JSON.parse(readFileSync(path, "utf-8"))
if (Array.isArray(content)) {
return {
session_id: sessionId,
items: content.map((item: ClaudeCodeTodoItem, idx: number) => ({
id: String(idx),
content: item.content,
status: item.status as TodoItem["status"],
created_at: new Date().toISOString(),
})),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
}
return content
} catch {
return null
}
}
export function saveTodoFile(sessionId: string, file: TodoFile): void {
ensureTodoDir()
const path = getTodoPath(sessionId)
const claudeCodeFormat: ClaudeCodeTodoItem[] = file.items.map(toClaudeCodeFormat)
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
}
export function saveOpenCodeTodos(sessionId: string, todos: OpenCodeTodo[]): void {
ensureTodoDir()
const path = getTodoPath(sessionId)
const claudeCodeFormat: ClaudeCodeTodoItem[] = todos.map(toClaudeCodeFormat)
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
}
export function deleteTodoFile(sessionId: string): void {
const path = getTodoPath(sessionId)
if (existsSync(path)) {
unlinkSync(path)
}
}

View File

@@ -0,0 +1,47 @@
/**
* Caches tool_input from PreToolUse for PostToolUse
*/
interface CacheEntry {
toolInput: Record<string, unknown>
timestamp: number
}
const cache = new Map<string, CacheEntry>()
const CACHE_TTL = 60000 // 1 minute
export function cacheToolInput(
sessionId: string,
toolName: string,
invocationId: string,
toolInput: Record<string, unknown>
): void {
const key = `${sessionId}:${toolName}:${invocationId}`
cache.set(key, { toolInput, timestamp: Date.now() })
}
export function getToolInput(
sessionId: string,
toolName: string,
invocationId: string
): Record<string, unknown> | null {
const key = `${sessionId}:${toolName}:${invocationId}`
const entry = cache.get(key)
if (!entry) return null
cache.delete(key)
if (Date.now() - entry.timestamp > CACHE_TTL) return null
return entry.toolInput
}
// Periodic cleanup (every minute)
setInterval(() => {
const now = Date.now()
for (const [key, entry] of cache.entries()) {
if (now - entry.timestamp > CACHE_TTL) {
cache.delete(key)
}
}
}, CACHE_TTL)

View File

@@ -0,0 +1,255 @@
/**
* 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 { randomUUID } from "crypto"
import type { TranscriptEntry } from "./types"
import { transformToolName } from "../../shared/tool-name"
const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
export function getTranscriptPath(sessionId: string): string {
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
}
function ensureTranscriptDir(): void {
if (!existsSync(TRANSCRIPT_DIR)) {
mkdirSync(TRANSCRIPT_DIR, { recursive: true })
}
}
export function appendTranscriptEntry(
sessionId: string,
entry: TranscriptEntry
): void {
ensureTranscriptDir()
const path = getTranscriptPath(sessionId)
const line = JSON.stringify(entry) + "\n"
appendFileSync(path, line)
}
export function recordToolUse(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_use",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
})
}
export function recordToolResult(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>,
toolOutput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
tool_output: toolOutput,
})
}
export function recordUserMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "user",
timestamp: new Date().toISOString(),
content,
})
}
export function recordAssistantMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "assistant",
timestamp: new Date().toISOString(),
content,
})
}
// ============================================================================
// Claude Code Compatible Transcript Builder (PORT FROM DISABLED)
// ============================================================================
/**
* OpenCode API response type (loosely typed)
*/
interface OpenCodeMessagePart {
type: string
tool?: string
state?: {
status?: string
input?: Record<string, unknown>
}
}
interface OpenCodeMessage {
info?: {
role?: string
}
parts?: OpenCodeMessagePart[]
}
/**
* Claude Code compatible transcript entry (from disabled file)
*/
interface DisabledTranscriptEntry {
type: "assistant"
message: {
role: "assistant"
content: Array<{
type: "tool_use"
name: string
input: Record<string, unknown>
}>
}
}
/**
* Build Claude Code compatible transcript from session messages
*
* PORT FROM DISABLED: This calls client.session.messages() API to fetch
* the full session history and builds a JSONL file in Claude Code format.
*
* @param client OpenCode client instance
* @param sessionId Session ID
* @param directory Working directory
* @param currentToolName Current tool being executed (added as last entry)
* @param currentToolInput Current tool input
* @returns Temp file path (caller must call deleteTempTranscript!)
*/
export async function buildTranscriptFromSession(
client: {
session: {
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
}
},
sessionId: string,
directory: string,
currentToolName: string,
currentToolInput: Record<string, unknown>
): Promise<string | null> {
try {
const response = await client.session.messages({
path: { id: sessionId },
query: { directory },
})
// Handle various response formats
const messages = (response as { "200"?: unknown[]; data?: unknown[] })["200"]
?? (response as { data?: unknown[] }).data
?? (Array.isArray(response) ? response : [])
const entries: string[] = []
if (Array.isArray(messages)) {
for (const msg of messages as OpenCodeMessage[]) {
if (msg.info?.role !== "assistant") continue
for (const part of msg.parts || []) {
if (part.type !== "tool") continue
if (part.state?.status !== "completed") continue
if (!part.state?.input) continue
const rawToolName = part.tool as string
const toolName = transformToolName(rawToolName)
const entry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: toolName,
input: part.state.input,
},
],
},
}
entries.push(JSON.stringify(entry))
}
}
}
// Always add current tool call as the last entry
const currentEntry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: transformToolName(currentToolName),
input: currentToolInput,
},
],
},
}
entries.push(JSON.stringify(currentEntry))
// Write to temp file
const tempPath = join(
tmpdir(),
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
)
writeFileSync(tempPath, entries.join("\n") + "\n")
return tempPath
} catch {
// CRITICAL FIX: Even on API failure, create file with current tool entry only
// (matching original disabled behavior - never return null with incompatible format)
try {
const currentEntry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: transformToolName(currentToolName),
input: currentToolInput,
},
],
},
}
const tempPath = join(
tmpdir(),
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
)
writeFileSync(tempPath, JSON.stringify(currentEntry) + "\n")
return tempPath
} catch {
// If even this fails, return null (truly catastrophic failure)
return null
}
}
}
/**
* Delete temp transcript file (call in finally block)
*
* PORT FROM DISABLED: Cleanup mechanism to avoid disk accumulation
*/
export function deleteTempTranscript(path: string | null): void {
if (!path) return
try {
unlinkSync(path)
} catch {
// Ignore deletion errors
}
}

View File

@@ -0,0 +1,184 @@
/**
* Claude Code Hooks Type Definitions
* Maps Claude Code hook concepts to OpenCode plugin events
*/
export type ClaudeHookEvent =
| "PreToolUse"
| "PostToolUse"
| "UserPromptSubmit"
| "Stop"
export interface HookMatcher {
matcher: string
hooks: HookCommand[]
}
export interface HookCommand {
type: "command"
command: string
}
export interface ClaudeHooksConfig {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Stop?: HookMatcher[]
}
export interface PreToolUseInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "PreToolUse"
tool_name: string
tool_input: Record<string, unknown>
tool_use_id?: string
hook_source?: HookSource
}
export interface PostToolUseInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "PostToolUse"
tool_name: string
tool_input: Record<string, unknown>
tool_response: {
title?: string
output?: string
[key: string]: unknown
}
tool_use_id?: string
hook_source?: HookSource
}
export interface UserPromptSubmitInput {
session_id: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "UserPromptSubmit"
prompt: string
session?: {
id: string
}
hook_source?: HookSource
}
export type PermissionMode = "default" | "plan" | "acceptEdits" | "bypassPermissions"
export type HookSource = "opencode-plugin"
export interface StopInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "Stop"
stop_hook_active: boolean
todo_path?: string
hook_source?: HookSource
}
export type PermissionDecision = "allow" | "deny" | "ask"
/**
* Common JSON fields for all hook outputs (Claude Code spec)
*/
export interface HookCommonOutput {
/** If false, Claude stops entirely */
continue?: boolean
/** Message shown to user when continue=false */
stopReason?: string
/** Suppress output from transcript */
suppressOutput?: boolean
/** Warning/message displayed to user */
systemMessage?: string
}
export interface PreToolUseOutput extends HookCommonOutput {
/** Deprecated: use hookSpecificOutput.permissionDecision instead */
decision?: "allow" | "deny" | "approve" | "block" | "ask"
/** Deprecated: use hookSpecificOutput.permissionDecisionReason instead */
reason?: string
hookSpecificOutput?: {
hookEventName: "PreToolUse"
permissionDecision: PermissionDecision
permissionDecisionReason?: string
updatedInput?: Record<string, unknown>
}
}
export interface PostToolUseOutput extends HookCommonOutput {
decision?: "block"
reason?: string
hookSpecificOutput?: {
hookEventName: "PostToolUse"
/** Additional context to provide to Claude */
additionalContext?: string
}
}
export interface HookResult {
exitCode: number
stdout?: string
stderr?: string
}
export interface TranscriptEntry {
type: "tool_use" | "tool_result" | "user" | "assistant"
timestamp: string
tool_name?: string
tool_input?: Record<string, unknown>
tool_output?: Record<string, unknown>
content?: string
}
export interface TodoItem {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
created_at: string
updated_at?: string
}
export interface ClaudeCodeTodoItem {
content: string
status: string // "pending" | "in_progress" | "completed"
activeForm: string
}
export interface TodoFile {
session_id: string
items: TodoItem[]
created_at: string
updated_at: string
}
export interface StopOutput {
decision?: "block" | "continue"
reason?: string
stop_hook_active?: boolean
permission_mode?: PermissionMode
inject_prompt?: string
}
export type ClaudeCodeContent =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "tool_result"; tool_use_id: string; content: string }
export interface ClaudeCodeMessage {
type: "user" | "assistant"
message: {
role: "user" | "assistant"
content: ClaudeCodeContent[]
}
}
export interface PluginConfig {
disabledHooks?: boolean | ClaudeHookEvent[]
}

View File

@@ -0,0 +1,117 @@
import type {
UserPromptSubmitInput,
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
export interface MessagePart {
type: "text" | "tool_use" | "tool_result"
text?: string
[key: string]: unknown
}
export interface UserPromptSubmitContext {
sessionId: string
parentSessionId?: string
prompt: string
parts: MessagePart[]
cwd: string
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
}
export interface UserPromptSubmitResult {
block: boolean
reason?: string
modifiedParts: MessagePart[]
messages: string[]
}
export async function executeUserPromptSubmitHooks(
ctx: UserPromptSubmitContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<UserPromptSubmitResult> {
const modifiedParts = ctx.parts
const messages: string[] = []
if (ctx.parentSessionId) {
return { block: false, modifiedParts, messages }
}
if (
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_OPEN) &&
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_CLOSE)
) {
return { block: false, modifiedParts, messages }
}
if (!config) {
return { block: false, modifiedParts, messages }
}
const matchers = findMatchingHooks(config, "UserPromptSubmit")
if (matchers.length === 0) {
return { block: false, modifiedParts, messages }
}
const stdinData: UserPromptSubmitInput = {
session_id: ctx.sessionId,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "UserPromptSubmit",
prompt: ctx.prompt,
session: { id: ctx.sessionId },
hook_source: "opencode-plugin",
}
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.stdout) {
const output = result.stdout.trim()
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
messages.push(output)
} else {
messages.push(`${USER_PROMPT_SUBMIT_TAG_OPEN}\n${output}\n${USER_PROMPT_SUBMIT_TAG_CLOSE}`)
}
}
if (result.exitCode !== 0) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
modifiedParts,
messages,
}
}
} catch {
// Ignore JSON parse errors
}
}
}
}
return { block: false, modifiedParts, messages }
}

View File

@@ -0,0 +1,210 @@
import { spawn } from "bun"
import { createRequire } from "module"
import { dirname, join } from "path"
import { existsSync } from "fs"
import * as fs from "fs"
import { getCachedBinaryPath, ensureCommentCheckerBinary } from "./downloader"
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
function debugLog(...args: unknown[]) {
if (DEBUG) {
const msg = `[${new Date().toISOString()}] [comment-checker:cli] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
fs.appendFileSync(DEBUG_FILE, msg)
}
}
function getBinaryName(): string {
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
}
function findCommentCheckerPathSync(): string | null {
const binaryName = getBinaryName()
try {
const require = createRequire(import.meta.url)
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
const cliDir = dirname(cliPkgPath)
const binaryPath = join(cliDir, "bin", binaryName)
if (existsSync(binaryPath)) {
debugLog("found binary in main package:", binaryPath)
return binaryPath
}
} catch {
debugLog("main package not installed")
}
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
debugLog("found binary in cache:", cachedPath)
return cachedPath
}
debugLog("no binary found in known locations")
return null
}
// Cached resolved path
let resolvedCliPath: string | null = null
let initPromise: Promise<string | null> | null = null
/**
* Asynchronously get comment-checker binary path.
* Will trigger lazy download if binary not found.
*/
export async function getCommentCheckerPath(): Promise<string | null> {
// Return cached path if already resolved
if (resolvedCliPath !== null) {
return resolvedCliPath
}
// Return existing promise if initialization is in progress
if (initPromise) {
return initPromise
}
initPromise = (async () => {
// First try sync path resolution
const syncPath = findCommentCheckerPathSync()
if (syncPath && existsSync(syncPath)) {
resolvedCliPath = syncPath
debugLog("using sync-resolved path:", syncPath)
return syncPath
}
// Lazy download if not found
debugLog("triggering lazy download...")
const downloadedPath = await ensureCommentCheckerBinary()
if (downloadedPath) {
resolvedCliPath = downloadedPath
debugLog("using downloaded path:", downloadedPath)
return downloadedPath
}
debugLog("no binary available")
return null
})()
return initPromise
}
/**
* Synchronously get comment-checker path (no download).
* Returns cached path or searches known locations.
*/
export function getCommentCheckerPathSync(): string | null {
return resolvedCliPath ?? findCommentCheckerPathSync()
}
/**
* Start background initialization.
* Call this early to trigger download while other init happens.
*/
export function startBackgroundInit(): void {
if (!initPromise) {
initPromise = getCommentCheckerPath()
initPromise.then(path => {
debugLog("background init complete:", path || "no binary")
}).catch(err => {
debugLog("background init error:", err)
})
}
}
// Legacy export for backwards compatibility (sync, no download)
export const COMMENT_CHECKER_CLI_PATH = findCommentCheckerPathSync()
export interface HookInput {
session_id: string
tool_name: string
transcript_path: string
cwd: string
hook_event_name: string
tool_input: {
file_path?: string
content?: string
old_string?: string
new_string?: string
edits?: Array<{ old_string: string; new_string: string }>
}
tool_response?: unknown
}
export interface CheckResult {
hasComments: boolean
message: string
}
/**
* Run comment-checker CLI with given input.
* @param input Hook input to check
* @param cliPath Optional explicit path to CLI binary
*/
export async function runCommentChecker(input: HookInput, cliPath?: string): Promise<CheckResult> {
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
if (!binaryPath) {
debugLog("comment-checker binary not found")
return { hasComments: false, message: "" }
}
if (!existsSync(binaryPath)) {
debugLog("comment-checker binary does not exist:", binaryPath)
return { hasComments: false, message: "" }
}
const jsonInput = JSON.stringify(input)
debugLog("running comment-checker with input:", jsonInput.substring(0, 200))
try {
const proc = spawn([binaryPath], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
// Write JSON to stdin
proc.stdin.write(jsonInput)
proc.stdin.end()
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
debugLog("exit code:", exitCode, "stdout length:", stdout.length, "stderr length:", stderr.length)
if (exitCode === 0) {
return { hasComments: false, message: "" }
}
if (exitCode === 2) {
// Comments detected - message is in stderr
return { hasComments: true, message: stderr }
}
// Error case
debugLog("unexpected exit code:", exitCode, "stderr:", stderr)
return { hasComments: false, message: "" }
} catch (err) {
debugLog("failed to run comment-checker:", err)
return { hasComments: false, message: "" }
}
}
/**
* Check if CLI is available (sync check, no download).
*/
export function isCliAvailable(): boolean {
const path = getCommentCheckerPathSync()
return path !== null && existsSync(path)
}
/**
* Check if CLI will be available (async, may trigger download).
*/
export async function ensureCliAvailable(): Promise<boolean> {
const path = await getCommentCheckerPath()
return path !== null && existsSync(path)
}

View File

@@ -1,117 +1,3 @@
import type { LanguageConfig } from "./types"
export const EXTENSION_TO_LANGUAGE: Record<string, string> = {
py: "python",
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "tsx",
go: "golang",
java: "java",
kt: "kotlin",
scala: "scala",
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
rs: "rust",
rb: "ruby",
sh: "bash",
bash: "bash",
cs: "csharp",
swift: "swift",
ex: "elixir",
exs: "elixir",
lua: "lua",
php: "php",
ml: "ocaml",
mli: "ocaml",
sql: "sql",
html: "html",
htm: "html",
css: "css",
yaml: "yaml",
yml: "yaml",
toml: "toml",
hcl: "hcl",
tf: "hcl",
dockerfile: "dockerfile",
proto: "protobuf",
svelte: "svelte",
elm: "elm",
groovy: "groovy",
cue: "cue",
}
export const QUERY_TEMPLATES: Record<string, string> = {
python: "(comment) @comment",
javascript: "(comment) @comment",
typescript: "(comment) @comment",
tsx: "(comment) @comment",
golang: "(comment) @comment",
rust: `
(line_comment) @comment
(block_comment) @comment
`,
kotlin: `
(line_comment) @comment
(multiline_comment) @comment
`,
java: `
(line_comment) @comment
(block_comment) @comment
`,
c: "(comment) @comment",
cpp: "(comment) @comment",
csharp: "(comment) @comment",
ruby: "(comment) @comment",
bash: "(comment) @comment",
swift: "(comment) @comment",
elixir: "(comment) @comment",
lua: "(comment) @comment",
php: "(comment) @comment",
ocaml: "(comment) @comment",
sql: "(comment) @comment",
html: "(comment) @comment",
css: "(comment) @comment",
yaml: "(comment) @comment",
toml: "(comment) @comment",
hcl: "(comment) @comment",
dockerfile: "(comment) @comment",
protobuf: "(comment) @comment",
svelte: "(comment) @comment",
elm: "(comment) @comment",
groovy: "(comment) @comment",
cue: "(comment) @comment",
scala: "(comment) @comment",
}
export const DOCSTRING_QUERIES: Record<string, string> = {
python: `
(module . (expression_statement (string) @docstring))
(class_definition body: (block . (expression_statement (string) @docstring)))
(function_definition body: (block . (expression_statement (string) @docstring)))
`,
javascript: `
(comment) @jsdoc
(#match? @jsdoc "^/\\\\*\\\\*")
`,
typescript: `
(comment) @jsdoc
(#match? @jsdoc "^/\\\\*\\\\*")
`,
tsx: `
(comment) @jsdoc
(#match? @jsdoc "^/\\\\*\\\\*")
`,
java: `
(comment) @javadoc
(#match? @javadoc "^/\\\\*\\\\*")
`,
}
export const BDD_KEYWORDS = new Set([
"given",
"when",
@@ -186,19 +72,12 @@ PRIORITY-BASED ACTION GUIDELINES:
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
is a code smell. If you need separators, your file is too long or poorly organized.
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
Review in the above priority order and take the corresponding action EVERY TIME this appears.
Detected comments/docstrings:
`
export function getLanguageByExtension(filePath: string): string | null {
const lastDot = filePath.lastIndexOf(".")
if (lastDot === -1) {
const baseName = filePath.split("/").pop()?.toLowerCase()
if (baseName === "dockerfile") return "dockerfile"
return null
}
const ext = filePath.slice(lastDot + 1).toLowerCase()
return EXTENSION_TO_LANGUAGE[ext] ?? null
}

View File

@@ -1,146 +0,0 @@
import type { CommentInfo, CommentType } from "./types"
import { getLanguageByExtension, QUERY_TEMPLATES, DOCSTRING_QUERIES } from "./constants"
export function isSupportedFile(filePath: string): boolean {
return getLanguageByExtension(filePath) !== null
}
function determineCommentType(text: string, nodeType: string): CommentType {
const stripped = text.trim()
if (nodeType === "line_comment") {
return "line"
}
if (nodeType === "block_comment" || nodeType === "multiline_comment") {
return "block"
}
if (stripped.startsWith('"""') || stripped.startsWith("'''")) {
return "docstring"
}
if (stripped.startsWith("//") || stripped.startsWith("#")) {
return "line"
}
if (stripped.startsWith("/*") || stripped.startsWith("<!--") || stripped.startsWith("--")) {
return "block"
}
return "line"
}
export async function detectComments(
filePath: string,
content: string,
includeDocstrings = true
): Promise<CommentInfo[]> {
const langName = getLanguageByExtension(filePath)
if (!langName) {
return []
}
const queryPattern = QUERY_TEMPLATES[langName]
if (!queryPattern) {
return []
}
try {
const Parser = (await import("web-tree-sitter")).default
const treeSitterWasmPath = require.resolve("web-tree-sitter/tree-sitter.wasm")
await Parser.init({
locateFile: () => treeSitterWasmPath,
})
const parser = new Parser()
let wasmPath: string
try {
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${langName}.wasm`)
wasmPath = wasmModule.default
} catch {
const languageMap: Record<string, string> = {
golang: "go",
csharp: "c_sharp",
cpp: "cpp",
}
const mappedLang = languageMap[langName] || langName
try {
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${mappedLang}.wasm`)
wasmPath = wasmModule.default
} catch {
return []
}
}
const language = await Parser.Language.load(wasmPath)
parser.setLanguage(language)
const tree = parser.parse(content)
const comments: CommentInfo[] = []
const query = language.query(queryPattern)
const matches = query.matches(tree.rootNode)
for (const match of matches) {
for (const capture of match.captures) {
const node = capture.node
const text = node.text
const lineNumber = node.startPosition.row + 1
const commentType = determineCommentType(text, node.type)
const isDocstring = commentType === "docstring"
if (isDocstring && !includeDocstrings) {
continue
}
comments.push({
text,
lineNumber,
filePath,
commentType,
isDocstring,
})
}
}
if (includeDocstrings) {
const docQuery = DOCSTRING_QUERIES[langName]
if (docQuery) {
try {
const docQueryObj = language.query(docQuery)
const docMatches = docQueryObj.matches(tree.rootNode)
for (const match of docMatches) {
for (const capture of match.captures) {
const node = capture.node
const text = node.text
const lineNumber = node.startPosition.row + 1
const alreadyAdded = comments.some(
(c) => c.lineNumber === lineNumber && c.text === text
)
if (!alreadyAdded) {
comments.push({
text,
lineNumber,
filePath,
commentType: "docstring",
isDocstring: true,
})
}
}
}
} catch {}
}
}
comments.sort((a, b) => a.lineNumber - b.lineNumber)
return comments
} catch {
return []
}
}

View File

@@ -0,0 +1,210 @@
import { spawn } from "bun"
import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { createRequire } from "module"
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
function debugLog(...args: unknown[]) {
if (DEBUG) {
const msg = `[${new Date().toISOString()}] [comment-checker:downloader] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
appendFileSync(DEBUG_FILE, msg)
}
}
const REPO = "code-yeongyu/go-claude-code-comment-checker"
interface PlatformInfo {
os: string
arch: string
ext: "tar.gz" | "zip"
}
const PLATFORM_MAP: Record<string, PlatformInfo> = {
"darwin-arm64": { os: "darwin", arch: "arm64", ext: "tar.gz" },
"darwin-x64": { os: "darwin", arch: "amd64", ext: "tar.gz" },
"linux-arm64": { os: "linux", arch: "arm64", ext: "tar.gz" },
"linux-x64": { os: "linux", arch: "amd64", ext: "tar.gz" },
"win32-x64": { os: "windows", arch: "amd64", ext: "zip" },
}
/**
* Get the cache directory for oh-my-opencode binaries.
* Follows XDG Base Directory Specification.
*/
export function getCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
const base = xdgCache || join(homedir(), ".cache")
return join(base, "oh-my-opencode", "bin")
}
/**
* Get the binary name based on platform.
*/
export function getBinaryName(): string {
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
}
/**
* Get the cached binary path if it exists.
*/
export function getCachedBinaryPath(): string | null {
const binaryPath = join(getCacheDir(), getBinaryName())
return existsSync(binaryPath) ? binaryPath : null
}
/**
* Get the version from the installed @code-yeongyu/comment-checker package.
*/
function getPackageVersion(): string {
try {
const require = createRequire(import.meta.url)
const pkg = require("@code-yeongyu/comment-checker/package.json")
return pkg.version
} catch {
// Fallback to hardcoded version if package not found
return "0.4.1"
}
}
/**
* Extract tar.gz archive using system tar command.
*/
async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
debugLog("Extracting tar.gz:", archivePath, "to", destDir)
const proc = spawn(["tar", "-xzf", archivePath, "-C", destDir], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`)
}
}
/**
* Extract zip archive using system commands.
*/
async function extractZip(archivePath: string, destDir: string): Promise<void> {
debugLog("Extracting zip:", archivePath, "to", destDir)
const proc = process.platform === "win32"
? spawn(["powershell", "-command", `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`], {
stdout: "pipe",
stderr: "pipe",
})
: spawn(["unzip", "-o", archivePath, "-d", destDir], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
}
}
/**
* Download the comment-checker binary from GitHub Releases.
* Returns the path to the downloaded binary, or null on failure.
*/
export async function downloadCommentChecker(): Promise<string | null> {
const platformKey = `${process.platform}-${process.arch}`
const platformInfo = PLATFORM_MAP[platformKey]
if (!platformInfo) {
debugLog(`Unsupported platform: ${platformKey}`)
return null
}
const cacheDir = getCacheDir()
const binaryName = getBinaryName()
const binaryPath = join(cacheDir, binaryName)
// Already exists in cache
if (existsSync(binaryPath)) {
debugLog("Binary already cached at:", binaryPath)
return binaryPath
}
const version = getPackageVersion()
const { os, arch, ext } = platformInfo
const assetName = `comment-checker_v${version}_${os}_${arch}.${ext}`
const downloadUrl = `https://github.com/${REPO}/releases/download/v${version}/${assetName}`
debugLog(`Downloading from: ${downloadUrl}`)
console.log(`[oh-my-opencode] Downloading comment-checker binary...`)
try {
// Ensure cache directory exists
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true })
}
// Download with fetch() - Bun handles redirects automatically
const response = await fetch(downloadUrl, { redirect: "follow" })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const archivePath = join(cacheDir, assetName)
const arrayBuffer = await response.arrayBuffer()
await Bun.write(archivePath, arrayBuffer)
debugLog(`Downloaded archive to: ${archivePath}`)
// Extract based on file type
if (ext === "tar.gz") {
await extractTarGz(archivePath, cacheDir)
} else {
await extractZip(archivePath, cacheDir)
}
// Clean up archive
if (existsSync(archivePath)) {
unlinkSync(archivePath)
}
// Set execute permission on Unix
if (process.platform !== "win32" && existsSync(binaryPath)) {
chmodSync(binaryPath, 0o755)
}
debugLog(`Successfully downloaded binary to: ${binaryPath}`)
console.log(`[oh-my-opencode] comment-checker binary ready.`)
return binaryPath
} catch (err) {
debugLog(`Failed to download: ${err}`)
console.error(`[oh-my-opencode] Failed to download comment-checker: ${err instanceof Error ? err.message : err}`)
console.error(`[oh-my-opencode] Comment checking disabled.`)
return null
}
}
/**
* Ensure the comment-checker binary is available.
* First checks cache, then downloads if needed.
* Returns the binary path or null if unavailable.
*/
export async function ensureCommentCheckerBinary(): Promise<string | null> {
// Check cache first
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
debugLog("Using cached binary:", cachedPath)
return cachedPath
}
// Download if not cached
return downloadCommentChecker()
}

View File

@@ -1,11 +1,24 @@
import type { PendingCall, FileComments } from "./types"
import { detectComments, isSupportedFile } from "./detector"
import { applyFilters } from "./filters"
import { formatHookMessage } from "./output"
import type { PendingCall } from "./types"
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
import * as fs from "fs"
import { existsSync } from "fs"
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
function debugLog(...args: unknown[]) {
if (DEBUG) {
const msg = `[${new Date().toISOString()}] [comment-checker:hook] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
fs.appendFileSync(DEBUG_FILE, msg)
}
}
const pendingCalls = new Map<string, PendingCall>()
const PENDING_CALL_TTL = 60_000
let cliPathPromise: Promise<string | null> | null = null
function cleanupOldPendingCalls(): void {
const now = Date.now()
for (const [callID, call] of pendingCalls) {
@@ -18,30 +31,50 @@ function cleanupOldPendingCalls(): void {
setInterval(cleanupOldPendingCalls, 10_000)
export function createCommentCheckerHooks() {
debugLog("createCommentCheckerHooks called")
// Start background CLI initialization (may trigger lazy download)
startBackgroundInit()
cliPathPromise = getCommentCheckerPath()
cliPathPromise.then(path => {
debugLog("CLI path resolved:", path || "disabled (no binary)")
}).catch(err => {
debugLog("CLI path resolution error:", err)
})
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> }
): Promise<void> => {
debugLog("tool.execute.before:", { tool: input.tool, callID: input.callID, args: output.args })
const toolLower = input.tool.toLowerCase()
if (toolLower !== "write" && toolLower !== "edit" && toolLower !== "multiedit") {
debugLog("skipping non-write/edit tool:", toolLower)
return
}
const filePath = (output.args.filePath ?? output.args.file_path ?? output.args.path) as string | undefined
const content = output.args.content as string | undefined
const oldString = output.args.oldString ?? output.args.old_string as string | undefined
const newString = output.args.newString ?? output.args.new_string as string | undefined
const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined
debugLog("extracted filePath:", filePath)
if (!filePath) {
debugLog("no filePath found")
return
}
if (!isSupportedFile(filePath)) {
return
}
debugLog("registering pendingCall:", { callID: input.callID, filePath, tool: toolLower })
pendingCalls.set(input.callID, {
filePath,
content,
oldString: oldString as string | undefined,
newString: newString as string | undefined,
edits,
tool: toolLower as "write" | "edit" | "multiedit",
sessionID: input.sessionID,
timestamp: Date.now(),
@@ -52,46 +85,79 @@ export function createCommentCheckerHooks() {
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
): Promise<void> => {
debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID })
const pendingCall = pendingCalls.get(input.callID)
if (!pendingCall) {
debugLog("no pendingCall found for:", input.callID)
return
}
pendingCalls.delete(input.callID)
debugLog("processing pendingCall:", pendingCall)
if (output.output.toLowerCase().includes("error")) {
// Only skip if the output indicates a tool execution failure
const outputLower = output.output.toLowerCase()
const isToolFailure =
outputLower.includes("error:") ||
outputLower.includes("failed to") ||
outputLower.includes("could not") ||
outputLower.startsWith("error")
if (isToolFailure) {
debugLog("skipping due to tool failure in output")
return
}
try {
let content: string
if (pendingCall.content) {
content = pendingCall.content
} else {
const file = Bun.file(pendingCall.filePath)
content = await file.text()
}
const rawComments = await detectComments(pendingCall.filePath, content)
const filteredComments = applyFilters(rawComments)
if (filteredComments.length === 0) {
// Wait for CLI path resolution
const cliPath = await cliPathPromise
if (!cliPath || !existsSync(cliPath)) {
// CLI not available - silently skip comment checking
debugLog("CLI not available, skipping comment check")
return
}
const fileComments: FileComments[] = [
{
filePath: pendingCall.filePath,
comments: filteredComments,
},
]
const message = formatHookMessage(fileComments)
output.output += `\n\n${message}`
} catch {}
// CLI mode only
debugLog("using CLI:", cliPath)
await processWithCli(input, pendingCall, output, cliPath)
} catch (err) {
debugLog("tool.execute.after failed:", err)
}
},
}
}
async function processWithCli(
input: { tool: string; sessionID: string; callID: string },
pendingCall: PendingCall,
output: { output: string },
cliPath: string
): Promise<void> {
debugLog("using CLI mode with path:", cliPath)
const hookInput: HookInput = {
session_id: pendingCall.sessionID,
tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1),
transcript_path: "",
cwd: process.cwd(),
hook_event_name: "PostToolUse",
tool_input: {
file_path: pendingCall.filePath,
content: pendingCall.content,
old_string: pendingCall.oldString,
new_string: pendingCall.newString,
edits: pendingCall.edits,
},
}
const result = await runCommentChecker(hookInput, cliPath)
if (result.hasComments && result.message) {
debugLog("CLI detected comments, appending message")
output.output += `\n\n${result.message}`
} else {
debugLog("CLI: no comments detected")
}
}

View File

@@ -9,15 +9,12 @@ export interface CommentInfo {
metadata?: Record<string, string>
}
export interface LanguageConfig {
extensions: string[]
commentQuery: string
docstringQuery?: string
}
export interface PendingCall {
filePath: string
content?: string
oldString?: string
newString?: string
edits?: Array<{ old_string: string; new_string: string }>
tool: "write" | "edit" | "multiedit"
sessionID: string
timestamp: number

View File

@@ -7,16 +7,8 @@ const CONTEXT_WARNING_THRESHOLD = 0.70
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
You are using Anthropic Claude with 1M context window.
Current usage has exceeded 75%.
RECOMMENDATIONS:
- Consider compacting the session if available
- Break complex tasks into smaller, focused sessions
- Be concise in your responses
- Avoid redundant file reads
You have access to 1M tokens - use them wisely. Do NOT rush or skip tasks.
Complete your work thoroughly despite the context usage warning.`
You have plenty of context remaining - do NOT rush or skip tasks.
Complete your work thoroughly and methodically.`
interface AssistantMessageInfo {
role: "assistant"
@@ -60,11 +52,10 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (lastAssistant.providerID !== "anthropic") return
const totalInputTokens = assistantMessages.reduce((sum, m) => {
const inputTokens = m.tokens?.input ?? 0
const cacheReadTokens = m.tokens?.cache?.read ?? 0
return sum + inputTokens + cacheReadTokens
}, 0)
// Use only the last assistant message's input tokens
// This reflects the ACTUAL current context window usage (post-compaction)
const lastTokens = lastAssistant.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const actualUsagePercentage = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT

View File

@@ -0,0 +1,9 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const AGENTS_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-agents",
);
export const AGENTS_FILENAME = "AGENTS.md";

View File

@@ -0,0 +1,126 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import {
loadInjectedPaths,
saveInjectedPaths,
clearInjectedPaths,
} from "./storage";
import { AGENTS_FILENAME } from "./constants";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
const sessionCaches = new Map<string, Set<string>>();
function getSessionCache(sessionID: string): Set<string> {
if (!sessionCaches.has(sessionID)) {
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
}
return sessionCaches.get(sessionID)!;
}
function resolveFilePath(title: string): string | null {
if (!title) return null;
if (title.startsWith("/")) return title;
return resolve(ctx.directory, title);
}
function findAgentsMdUp(startDir: string): string[] {
const found: string[] = [];
let current = startDir;
while (true) {
const agentsPath = join(current, AGENTS_FILENAME);
if (existsSync(agentsPath)) {
found.push(agentsPath);
}
if (current === ctx.directory) break;
const parent = dirname(current);
if (parent === current) break;
if (!parent.startsWith(ctx.directory)) break;
current = parent;
}
return found.reverse();
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
if (input.tool.toLowerCase() !== "read") return;
const filePath = resolveFilePath(output.title);
if (!filePath) return;
const dir = dirname(filePath);
const cache = getSessionCache(input.sessionID);
const agentsPaths = findAgentsMdUp(dir);
const toInject: { path: string; content: string }[] = [];
for (const agentsPath of agentsPaths) {
const agentsDir = dirname(agentsPath);
if (cache.has(agentsDir)) continue;
try {
const content = readFileSync(agentsPath, "utf-8");
toInject.push({ path: agentsPath, content });
cache.add(agentsDir);
} catch {}
}
if (toInject.length === 0) return;
for (const { path, content } of toInject) {
output.output += `\n\n[Directory Context: ${path}]\n${content}`;
}
saveInjectedPaths(input.sessionID, cache);
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
sessionCaches.delete(sessionInfo.id);
clearInjectedPaths(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}

View File

@@ -0,0 +1,48 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { AGENTS_INJECTOR_STORAGE } from "./constants";
import type { InjectedPathsData } from "./types";
function getStoragePath(sessionID: string): string {
return join(AGENTS_INJECTOR_STORAGE, `${sessionID}.json`);
}
export function loadInjectedPaths(sessionID: string): Set<string> {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return new Set();
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedPathsData = JSON.parse(content);
return new Set(data.injectedPaths);
} catch {
return new Set();
}
}
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
if (!existsSync(AGENTS_INJECTOR_STORAGE)) {
mkdirSync(AGENTS_INJECTOR_STORAGE, { recursive: true });
}
const data: InjectedPathsData = {
sessionID,
injectedPaths: [...paths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
}
export function clearInjectedPaths(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}

View File

@@ -0,0 +1,5 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}

View File

@@ -0,0 +1,9 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const README_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-readme",
);
export const README_FILENAME = "README.md";

View File

@@ -0,0 +1,126 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import {
loadInjectedPaths,
saveInjectedPaths,
clearInjectedPaths,
} from "./storage";
import { README_FILENAME } from "./constants";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
const sessionCaches = new Map<string, Set<string>>();
function getSessionCache(sessionID: string): Set<string> {
if (!sessionCaches.has(sessionID)) {
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
}
return sessionCaches.get(sessionID)!;
}
function resolveFilePath(title: string): string | null {
if (!title) return null;
if (title.startsWith("/")) return title;
return resolve(ctx.directory, title);
}
function findReadmeMdUp(startDir: string): string[] {
const found: string[] = [];
let current = startDir;
while (true) {
const readmePath = join(current, README_FILENAME);
if (existsSync(readmePath)) {
found.push(readmePath);
}
if (current === ctx.directory) break;
const parent = dirname(current);
if (parent === current) break;
if (!parent.startsWith(ctx.directory)) break;
current = parent;
}
return found.reverse();
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
if (input.tool.toLowerCase() !== "read") return;
const filePath = resolveFilePath(output.title);
if (!filePath) return;
const dir = dirname(filePath);
const cache = getSessionCache(input.sessionID);
const readmePaths = findReadmeMdUp(dir);
const toInject: { path: string; content: string }[] = [];
for (const readmePath of readmePaths) {
const readmeDir = dirname(readmePath);
if (cache.has(readmeDir)) continue;
try {
const content = readFileSync(readmePath, "utf-8");
toInject.push({ path: readmePath, content });
cache.add(readmeDir);
} catch {}
}
if (toInject.length === 0) return;
for (const { path, content } of toInject) {
output.output += `\n\n[Project README: ${path}]\n${content}`;
}
saveInjectedPaths(input.sessionID, cache);
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
sessionCaches.delete(sessionInfo.id);
clearInjectedPaths(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}

View File

@@ -0,0 +1,48 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { README_INJECTOR_STORAGE } from "./constants";
import type { InjectedPathsData } from "./types";
function getStoragePath(sessionID: string): string {
return join(README_INJECTOR_STORAGE, `${sessionID}.json`);
}
export function loadInjectedPaths(sessionID: string): Set<string> {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return new Set();
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedPathsData = JSON.parse(content);
return new Set(data.injectedPaths);
} catch {
return new Set();
}
}
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
if (!existsSync(README_INJECTOR_STORAGE)) {
mkdirSync(README_INJECTOR_STORAGE, { recursive: true });
}
const data: InjectedPathsData = {
sessionID,
injectedPaths: [...paths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
}
export function clearInjectedPaths(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}

View File

@@ -0,0 +1,5 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}

View File

@@ -0,0 +1,27 @@
import type { PluginInput } from "@opencode-ai/plugin"
const EMPTY_RESPONSE_WARNING = `[Task Empty Response Warning]
Task invocation completed but returned no response. This indicates the agent either:
- Failed to execute properly
- Did not terminate correctly
- Returned an empty result
Note: The call has already completed - you are NOT waiting for a response. Proceed accordingly.`
export function createEmptyTaskResponseDetectorHook(_ctx: PluginInput) {
return {
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (input.tool !== "Task") return
const responseText = output.output?.trim() ?? ""
if (responseText === "") {
output.output = EMPTY_RESPONSE_WARNING
}
},
}
}

View File

@@ -1,18 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
const BLOCKED_MESSAGE =
"Error: [BLOCKED] grep has no timeout and can freeze the system. " +
"It is permanently disabled. Use 'safe_grep' instead."
export function createGrepBlocker(_ctx: PluginInput) {
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
_output: { args: unknown }
) => {
if (input.tool === "grep") {
throw new Error(BLOCKED_MESSAGE)
}
},
}
}

View File

@@ -0,0 +1,131 @@
import type { PluginInput } from "@opencode-ai/plugin"
const ANTHROPIC_ACTUAL_LIMIT = 200_000
const CHARS_PER_TOKEN_ESTIMATE = 4
const TARGET_MAX_TOKENS = 50_000
interface AssistantMessageInfo {
role: "assistant"
tokens: {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
}
interface MessageWrapper {
info: { role: string } & Partial<AssistantMessageInfo>
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
}
function truncateToTokenLimit(output: string, maxTokens: number): { result: string; truncated: boolean } {
const currentTokens = estimateTokens(output)
if (currentTokens <= maxTokens) {
return { result: output, truncated: false }
}
const lines = output.split("\n")
if (lines.length <= 3) {
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, 3)
const contentLines = lines.slice(3)
const headerText = headerLines.join("\n")
const headerTokens = estimateTokens(headerText)
const availableTokens = maxTokens - headerTokens - 50
if (availableTokens <= 0) {
return {
result: headerText + "\n\n[Content truncated due to context window limit]",
truncated: true,
}
}
let 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
}
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,
}
}
export function createGrepOutputTruncatorHook(ctx: PluginInput) {
const GREP_TOOLS = ["safe_grep", "Grep"]
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (!GREP_TOOLS.includes(input.tool)) return
const { sessionID } = input
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = (response.data ?? response) as MessageWrapper[]
const assistantMessages = messages
.filter((m) => m.info.role === "assistant")
.map((m) => m.info as AssistantMessageInfo)
if (assistantMessages.length === 0) return
// Use only the last assistant message's input tokens
// This reflects the ACTUAL current context window usage (post-compaction)
const lastAssistant = assistantMessages[assistantMessages.length - 1]
const lastTokens = lastAssistant.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - totalInputTokens
const maxOutputTokens = Math.min(
remainingTokens * 0.5,
TARGET_MAX_TOKENS
)
if (maxOutputTokens <= 0) {
output.output = "[Output suppressed - context window exhausted]"
return
}
const { result, truncated } = truncateToTokenLimit(output.output, maxOutputTokens)
if (truncated) {
output.output = result
}
} catch {
// Graceful degradation
}
}
return {
"tool.execute.after": toolExecuteAfter,
}
}

View File

@@ -1,5 +1,15 @@
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
export { createContextWindowMonitorHook } from "./context-window-monitor"
export { createSessionNotification } from "./session-notification"
export { createSessionRecoveryHook } from "./session-recovery"
export { createCommentCheckerHooks } from "./comment-checker"
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification";
export { createSessionRecoveryHook, type SessionRecoveryHook } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker";
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
export { createThinkModeHook } from "./think-mode";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification"
export { createAutoUpdateCheckerHook } from "./auto-update-checker";

View File

@@ -0,0 +1,23 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
export const PROJECT_MARKERS = [
".git",
"pyproject.toml",
"package.json",
"Cargo.toml",
"go.mod",
".venv",
];
export const PROJECT_RULE_SUBDIRS: [string, string][] = [
[".cursor", "rules"],
[".claude", "rules"],
];
export const USER_RULE_DIR = ".claude/rules";
export const RULE_EXTENSIONS = [".md", ".mdc"];

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