Compare commits

..

29 Commits

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

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

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

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

Closes #250

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

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

Fixes #241

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Fixes #234

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 19:05:15 +09:00
YeonGyu-Kim
c804da43cf ulw 2025-12-25 19:05:15 +09:00
27 changed files with 1295 additions and 562 deletions

View File

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

View File

@@ -6,12 +6,10 @@ on:
prompt:
description: "Custom prompt"
required: false
# Only issue_comment works for fork PRs (secrets available)
# pull_request_review/pull_request_review_comment do NOT get secrets for fork PRs
issue_comment:
types: [created]
pull_request_review:
types: [submitted]
pull_request_review_comment:
types: [created]
jobs:
agent:
@@ -19,9 +17,9 @@ jobs:
# @sisyphus-dev-ai mention only (maintainers, exclude self)
if: |
github.event_name == 'workflow_dispatch' ||
(contains(github.event.comment.body || github.event.review.body, '@sisyphus-dev-ai') &&
(github.event.comment.user.login || github.event.review.user.login) != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association))
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
github.event.comment.user.login != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
# Minimal default GITHUB_TOKEN permissions
permissions:
@@ -186,6 +184,25 @@ jobs:
)"
```
### GitHub Markdown Rules (MUST FOLLOW)
**Code blocks MUST have EXACTLY 3 backticks and language identifier:**
- CORRECT: ` ```bash ` ... ` ``` `
- WRONG: ` ``` ` (no language), ` ```` ` (4 backticks), ` `` ` (2 backticks)
**Every opening ` ``` ` MUST have a closing ` ``` ` on its own line:**
```
```bash
code here
```
```
**NO trailing backticks or spaces after closing ` ``` `**
**For inline code, use SINGLE backticks:** `code` not ```code```
**Lists inside code blocks break rendering - avoid them or use plain text**
### Rules
- EVERY response = GitHub comment (use heredoc for proper escaping)
- Code changes = PR (never push main/master)
@@ -210,39 +227,30 @@ jobs:
id: context
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
EVENT_NAME: ${{ github.event_name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
COMMENT_ID_VAL: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: |
EVENT="${{ github.event_name }}"
if [[ "$EVENT" == "issue_comment" ]]; then
ISSUE_NUM="${{ github.event.issue.number }}"
COMMENT="${{ github.event.comment.body }}"
AUTHOR="${{ github.event.comment.user.login }}"
COMMENT_ID="${{ github.event.comment.id }}"
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
ISSUE_NUM="$ISSUE_NUMBER"
AUTHOR="$COMMENT_AUTHOR"
COMMENT_ID="$COMMENT_ID_VAL"
# Check if PR or Issue
if gh api "repos/${{ github.repository }}/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
else
echo "type=issue" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
fi
elif [[ "$EVENT" == "pull_request_review_comment" ]]; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
COMMENT="${{ github.event.comment.body }}"
AUTHOR="${{ github.event.comment.user.login }}"
COMMENT_ID="${{ github.event.comment.id }}"
elif [[ "$EVENT" == "pull_request_review" ]]; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
COMMENT="${{ github.event.review.body }}"
AUTHOR="${{ github.event.review.user.login }}"
COMMENT_ID=""
fi
echo "comment<<EOF" >> $GITHUB_OUTPUT
echo "$COMMENT" >> $GITHUB_OUTPUT
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
@@ -280,29 +288,44 @@ jobs:
- name: Run oh-my-opencode
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
USER_COMMENT: ${{ steps.context.outputs.comment }}
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
REPO_NAME: ${{ github.repository }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
export PATH="$HOME/.opencode/bin:$PATH"
PROMPT="
Your username is @sisyphus-dev-ai, mentioned by @${{ steps.context.outputs.author }} in ${{ github.repository }}.
PROMPT=$(cat <<'PROMPT_EOF'
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
## Context
- Type: ${{ steps.context.outputs.type }}
- Number: #${{ steps.context.outputs.number }}
- Repository: ${{ github.repository }}
- Default Branch: ${{ github.event.repository.default_branch }}
- Type: TYPE_PLACEHOLDER
- Number: #NUMBER_PLACEHOLDER
- Repository: REPO_PLACEHOLDER
- Default Branch: BRANCH_PLACEHOLDER
## User's Request
${{ steps.context.outputs.comment }}
COMMENT_PLACEHOLDER
---
First, acknowledge with \`gh issue comment ${{ steps.context.outputs.number }} --body \"👋 Hey @${{ steps.context.outputs.author }}! I'm on it...\"\`
First, acknowledge with `gh issue comment NUMBER_PLACEHOLDER --body "👋 Hey @AUTHOR_PLACEHOLDER! I'm on it..."`
Then write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to \`${{ github.event.repository.default_branch }}\` branch."
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
PROMPT_EOF
)
bun run dist/cli/index.js run "$PROMPT"
PROMPT="${PROMPT//AUTHOR_PLACEHOLDER/$COMMENT_AUTHOR}"
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
stdbuf -oL -eL bun run dist/cli/index.js run "$PROMPT"
# Push changes (as sisyphus-dev-ai)
- name: Push changes

View File

@@ -56,6 +56,8 @@
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目次
@@ -717,8 +719,8 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
有効時デフォルト、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
- **Builder-Sisyphus**: OhMyOpenCode 強化版のビルドエージェント(デフォルトで無効)
- **Planner-Sisyphus**: OhMyOpenCode 強化版のプランエージェント(デフォルトで有効)
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェント(SDK 制限により名前変更、デフォルトで有効)
**設定オプション:**
@@ -726,26 +728,24 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
{
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**例Builder-Sisyphus を有効化し、デフォルトのビルドモードも維持する**
**例Builder-Sisyphus を有効化:**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
"default_builder_enabled": true
}
}
```
これにより、Builder-Sisyphus とデフォルトのビルドエージェントの両方を同時に利用できます。
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
**例:すべての Sisyphus オーケストレーションを無効化:**
@@ -776,13 +776,12 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
| オプション | デフォルト | 説明 |
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
| `builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントOhMyOpenCode 強化版ビルドモード)を有効化します。デフォルトの OpenCode ビルド体験を維持するため、デフォルトでは無効です。 |
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントOhMyOpenCode 強化版プランモード)を有効化します。デフォルトで有効です。 |
| `replace_build` | `true` | `true` の場合、デフォルトのビルドエージェントをサブエージェントモードに降格させます。`false` に設定すると、Builder-Sisyphus とデフォルトのビルドの両方を利用できます。 |
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
| オプション | デフォルト | 説明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化しますOpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化しますOpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
### Hooks

View File

@@ -53,6 +53,8 @@
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 목차
@@ -711,8 +713,8 @@ Schema 자동 완성이 지원됩니다:
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
- **Builder-Sisyphus**: OhMyOpenCode 강화 버전 빌드 에이전트 (기본적으로 비활성화)
- **Planner-Sisyphus**: OhMyOpenCode 강화 버전 플랜 에이전트 (기본적으로 활성화)
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
**설정 옵션:**
@@ -720,26 +722,24 @@ Schema 자동 완성이 지원됩니다:
{
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**예시: Builder-Sisyphus 활성화하면서 기본 빌드 모드도 유지하기:**
**예시: Builder-Sisyphus 활성화하기:**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
"default_builder_enabled": true
}
}
```
이렇게 하면 Builder-Sisyphus와 기본 빌드 에이전트를 동시에 사용할 수 있습니다.
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
@@ -770,13 +770,12 @@ Schema 자동 완성이 지원됩니다:
}
```
| 옵션 | 기본값 | 설명 |
| ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
| `builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트 (OhMyOpenCode 강화 빌드 모드)를 활성화합니다. 기본 OpenCode 빌드 경험을 보존하기 위해 기본적으로 비활성화되어 있습니다. |
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트 (OhMyOpenCode 강화 플랜 모드)를 활성화합니다. 기본적으로 활성화되어 있습니다. |
| `replace_build` | `true` | `true`면 기본 빌드 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Builder-Sisyphus와 기본 빌드를 모두 사용할 수 있습니다. |
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
### Hooks

View File

@@ -61,6 +61,8 @@ No stupid token consumption massive subagents here. No bloat tools here.
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## Contents
@@ -783,8 +785,8 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
- **Builder-Sisyphus**: Optional build agent with OhMyOpenCode enhancements (disabled by default)
- **Planner-Sisyphus**: Plan agent with OhMyOpenCode enhancements (enabled by default)
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
**Configuration Options:**
@@ -792,26 +794,24 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
{
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**Example: Enable Builder-Sisyphus and keep default build mode:**
**Example: Enable Builder-Sisyphus:**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
"default_builder_enabled": true
}
}
```
This allows you to have both Builder-Sisyphus AND the default build agent available simultaneously.
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
**Example: Disable all Sisyphus orchestration:**
@@ -842,13 +842,12 @@ You can also customize Sisyphus agents like other agents:
}
```
| Option | Default | Description |
| ------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
| `builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (OhMyOpenCode enhanced build mode). Disabled by default to preserve default OpenCode build experience. |
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (OhMyOpenCode enhanced plan mode). Enabled by default. |
| `replace_build` | `true` | When `true`, demotes default build agent to subagent mode. Set to `false` to keep both Builder-Sisyphus and default build available. |
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
| Option | Default | Description |
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
### Hooks

View File

@@ -58,6 +58,8 @@
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目录
@@ -717,8 +719,8 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent
- **Sisyphus**:主编排 AgentClaude Opus 4.5
- **Builder-Sisyphus**OhMyOpenCode 增强版构建 Agent默认禁用
- **Planner-Sisyphus**OhMyOpenCode 增强版计划 Agent默认启用
- **Builder-Sisyphus**OpenCode 默认构建 Agent因 SDK 限制仅改名,默认禁用)
- **Planner-Sisyphus**OpenCode 默认计划 Agent因 SDK 限制仅改名,默认启用)
**配置选项:**
@@ -726,26 +728,24 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
{
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**示例:启用 Builder-Sisyphus,同时保留默认构建模式**
**示例:启用 Builder-Sisyphus**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
"default_builder_enabled": true
}
}
```
这样你就能同时使用 Builder-Sisyphus 和默认构建 Agent
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式
**示例:禁用所有 Sisyphus 编排:**
@@ -776,13 +776,12 @@ Sisyphus Agent 也能自定义:
}
```
| 选项 | 默认值 | 说明 |
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
| `builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus AgentOhMyOpenCode 增强构建模式)。为了保留默认 OpenCode 构建体验,默认禁用。 |
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus AgentOhMyOpenCode 增强计划模式)。默认启用。 |
| `replace_build` | `true` | 设为 `true` 就把默认构建 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Builder-Sisyphus 和默认构建。 |
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus AgentOpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus AgentOpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
### Hooks

View File

@@ -408,7 +408,7 @@
}
}
},
"Builder-Sisyphus": {
"OpenCode-Builder": {
"type": "object",
"properties": {
"model": {
@@ -1351,15 +1351,12 @@
"disabled": {
"type": "boolean"
},
"builder_enabled": {
"default_builder_enabled": {
"type": "boolean"
},
"planner_enabled": {
"type": "boolean"
},
"replace_build": {
"type": "boolean"
},
"replace_plan": {
"type": "boolean"
}

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "2.5.3",
"version": "2.5.4",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

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

View File

@@ -12,8 +12,8 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
}
return true
} catch {
// API errors are transient - silently continue polling
} catch (err) {
console.error(pc.red(`[completion] API error: ${err}`))
return false
}
}

View File

@@ -16,13 +16,15 @@ async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
}
describe("createEventState", () => {
it("creates initial state with mainSessionIdle false and empty lastOutput", () => {
it("creates initial state with correct defaults", () => {
// #given / #when
const state = createEventState()
// #then
expect(state.mainSessionIdle).toBe(false)
expect(state.lastOutput).toBe("")
expect(state.lastPartText).toBe("")
expect(state.currentTool).toBe(null)
})
})
@@ -37,7 +39,7 @@ describe("event handling", () => {
properties: { sessionID: "my-session" },
}
const events = toAsyncIterable([{ payload }])
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
@@ -57,7 +59,7 @@ describe("event handling", () => {
properties: { sessionID: "other-session" },
}
const events = toAsyncIterable([{ payload }])
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
@@ -72,7 +74,11 @@ describe("event handling", () => {
const ctx = createMockContext("my-session")
const state: EventState = {
mainSessionIdle: true,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
}
const payload: EventPayload = {
@@ -80,7 +86,7 @@ describe("event handling", () => {
properties: { sessionID: "my-session", status: { type: "busy" } },
}
const events = toAsyncIterable([{ payload }])
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when

View File

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

View File

@@ -5,7 +5,7 @@ import { checkCompletionConditions } from "./completion"
import { createEventState, processEvents } from "./events"
const POLL_INTERVAL_MS = 500
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
const DEFAULT_TIMEOUT_MS = 0
export async function run(options: RunOptions): Promise<number> {
const {
@@ -18,10 +18,15 @@ export async function run(options: RunOptions): Promise<number> {
console.log(pc.cyan("Starting opencode server..."))
const abortController = new AbortController()
const timeoutId = setTimeout(() => {
console.log(pc.yellow("\nTimeout reached. Aborting..."))
abortController.abort()
}, timeout)
let timeoutId: ReturnType<typeof setTimeout> | null = null
// timeout=0 means no timeout (run until completion)
if (timeout > 0) {
timeoutId = setTimeout(() => {
console.log(pc.yellow("\nTimeout reached. Aborting..."))
abortController.abort()
}, timeout)
}
try {
const { client, server } = await createOpencode({
@@ -29,7 +34,7 @@ export async function run(options: RunOptions): Promise<number> {
})
const cleanup = () => {
clearTimeout(timeoutId)
if (timeoutId) clearTimeout(timeoutId)
server.close()
}
@@ -82,6 +87,16 @@ export async function run(options: RunOptions): Promise<number> {
continue
}
// Check if session errored - exit with failure if so
if (eventState.mainSessionError) {
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
console.error(pc.yellow("Check if todos were completed before the error."))
abortController.abort()
await eventProcessor.catch(() => {})
cleanup()
return 1
}
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
console.log(pc.green("\n\nAll tasks completed."))
@@ -100,7 +115,7 @@ export async function run(options: RunOptions): Promise<number> {
throw err
}
} catch (err) {
clearTimeout(timeoutId)
if (timeoutId) clearTimeout(timeoutId)
if (err instanceof Error && err.name === "AbortError") {
return 130
}

View File

@@ -47,3 +47,30 @@ export interface MessageUpdatedProps {
info?: { sessionID?: string; role?: string }
content?: string
}
export interface MessagePartUpdatedProps {
info?: { sessionID?: string; role?: string }
part?: {
type?: string
text?: string
name?: string
input?: unknown
}
}
export interface ToolExecuteProps {
sessionID?: string
name?: string
input?: Record<string, unknown>
}
export interface ToolResultProps {
sessionID?: string
name?: string
output?: string
}
export interface SessionErrorProps {
sessionID?: string
error?: unknown
}

View File

@@ -30,7 +30,7 @@ export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"Sisyphus",
"Builder-Sisyphus",
"OpenCode-Builder",
"Planner-Sisyphus",
"oracle",
"librarian",
@@ -87,7 +87,7 @@ export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
Sisyphus: AgentOverrideConfigSchema.optional(),
"Builder-Sisyphus": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
"Planner-Sisyphus": AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
@@ -107,9 +107,8 @@ export const ClaudeCodeConfigSchema = z.object({
export const SisyphusAgentConfigSchema = z.object({
disabled: z.boolean().optional(),
builder_enabled: z.boolean().optional(),
default_builder_enabled: z.boolean().optional(),
planner_enabled: z.boolean().optional(),
replace_build: z.boolean().optional(),
replace_plan: z.boolean().optional(),
})

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -215,6 +215,30 @@ export function createTodoContinuationEnforcer(
return
}
let freshTodos: Todo[] = []
try {
log(`[${HOOK_NAME}] Re-verifying todos after countdown`, { sessionID })
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
freshTodos = (response.data ?? response) as Todo[]
log(`[${HOOK_NAME}] Fresh todo count`, { sessionID, todosCount: freshTodos?.length ?? 0 })
} catch (err) {
log(`[${HOOK_NAME}] Failed to re-verify todos`, { sessionID, error: String(err) })
return
}
const freshIncomplete = freshTodos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (freshIncomplete.length === 0) {
log(`[${HOOK_NAME}] Abort: no incomplete todos after countdown`, { sessionID, total: freshTodos.length })
return
}
log(`[${HOOK_NAME}] Confirmed incomplete todos, proceeding with injection`, { sessionID, incomplete: freshIncomplete.length, total: freshTodos.length })
remindedSessions.add(sessionID)
try {
@@ -237,7 +261,7 @@ export function createTodoContinuationEnforcer(
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
text: `${CONTINUATION_PROMPT}\n\n[Status: ${freshTodos.length - freshIncomplete.length}/${freshTodos.length} completed, ${freshIncomplete.length} remaining]`,
},
],
},

View File

@@ -50,7 +50,6 @@ import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
import { log, deepMerge, getUserConfigDir, addConfigLoadError } from "./shared";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
import { BUILD_SYSTEM_PROMPT, BUILD_PERMISSION } from "./agents/build-prompt";
import * as fs from "fs";
import * as path from "path";
@@ -117,7 +116,7 @@ function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown
return needsWrite;
}
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
@@ -131,6 +130,20 @@ function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
const errorMsg = result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join(", ");
log(`Config validation error in ${configPath}:`, result.error.issues);
addConfigLoadError({ path: configPath, error: `Validation error: ${errorMsg}` });
const errorList = result.error.issues
.map(issue => `${issue.path.join(".")}: ${issue.message}`)
.join("\n");
ctx.client.tui.showToast({
body: {
title: "❌ OhMyOpenCode: Config Validation Failed",
message: `Failed to load ${configPath}\n\nValidation errors:\n${errorList}\n\nConfig will be ignored. Please fix the errors above.`,
variant: "error" as const,
duration: 10000,
},
}).catch(() => {});
return null;
}
@@ -141,6 +154,19 @@ function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Error loading config from ${configPath}:`, err);
addConfigLoadError({ path: configPath, error: errorMsg });
const hint = err instanceof SyntaxError
? "\n\nHint: Check for syntax errors in your JSON file (missing commas, quotes, brackets, etc.)"
: "";
ctx.client.tui.showToast({
body: {
title: "❌ OhMyOpenCode: Config Load Failed",
message: `Failed to load ${configPath}\n\nError: ${errorMsg}${hint}\n\nConfig will be ignored. Please fix the error above.`,
variant: "error" as const,
duration: 10000,
},
}).catch(() => {});
}
return null;
}
@@ -175,7 +201,7 @@ function mergeConfigs(
};
}
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
// User-level config path (OS-specific)
const userConfigPath = path.join(
getUserConfigDir(),
@@ -191,10 +217,10 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
);
// Load user config first (base)
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath) ?? {};
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
// Override with project config
const projectConfig = loadConfigFromPath(projectConfigPath);
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
@@ -210,7 +236,7 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
}
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory);
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
@@ -380,9 +406,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled = pluginConfig.sisyphus_agent?.builder_enabled ?? false;
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
const replaceBuild = pluginConfig.sisyphus_agent?.replace_build ?? true;
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
@@ -396,18 +421,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (builderEnabled) {
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
const builderSisyphusOverride = pluginConfig.agents?.["Builder-Sisyphus"];
const builderSisyphusBase = {
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
const openCodeBuilderBase = {
...buildConfigWithoutName,
prompt: BUILD_SYSTEM_PROMPT,
permission: BUILD_PERMISSION,
description: `${config.agent?.build?.description ?? "Build agent"} (OhMyOpenCode version)`,
color: config.agent?.build?.color ?? "#32CD32",
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
};
agentConfig["Builder-Sisyphus"] = builderSisyphusOverride
? { ...builderSisyphusBase, ...builderSisyphusOverride }
: builderSisyphusBase;
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
: openCodeBuilderBase;
}
if (plannerEnabled) {
@@ -426,13 +448,24 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
: plannerSisyphusBase;
}
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
const filteredConfigAgents = config.agent ?
Object.fromEntries(
Object.entries(config.agent).filter(([key]) => {
if (key === "build") return false;
if (key === "plan" && replacePlan) return false;
return true;
})
) : {};
config.agent = {
...agentConfig,
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
...userAgents,
...projectAgents,
...config.agent,
...(replaceBuild ? { build: { ...config.agent?.build, mode: "subagent" } } : {}),
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
// Demote build/plan to subagent mode when replaced
build: { ...config.agent?.build, mode: "subagent" },
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
};
} else {

View File

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

View File

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