Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77bdefbf9d | ||
|
|
6db44cdbf4 | ||
|
|
7c24f657e7 | ||
|
|
1b427570c8 | ||
|
|
109fb50028 | ||
|
|
e1a9e7e76a | ||
|
|
6160730f24 | ||
|
|
f9234a6a5e | ||
|
|
27b5c1fda3 | ||
|
|
9bc2360d31 | ||
|
|
ad2bd673c4 | ||
|
|
57ef5df932 | ||
|
|
101299ebec | ||
|
|
0b4821cfdf | ||
|
|
9bfe7d8a1d | ||
|
|
d9cfc1ec97 | ||
|
|
accedb59b7 | ||
|
|
1bff5f7966 | ||
|
|
dacecfd3b2 | ||
|
|
0399c1f4ed | ||
|
|
ebdce7972e | ||
|
|
3de2a9f113 | ||
|
|
8897697887 | ||
|
|
06b77643ba | ||
|
|
3b17ee9bd0 | ||
|
|
0734167516 | ||
|
|
419416deb8 | ||
|
|
695f9e03fc | ||
|
|
c804da43cf |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||
|
||||
99
.github/workflows/sisyphus-agent.yml
vendored
99
.github/workflows/sisyphus-agent.yml
vendored
@@ -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
|
||||
|
||||
29
README.ja.md
29
README.ja.md
@@ -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
|
||||
|
||||
|
||||
29
README.ko.md
29
README.ko.md
@@ -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
|
||||
|
||||
|
||||
29
README.md
29
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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**:主编排 Agent(Claude 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 Agent(OhMyOpenCode 增强构建模式)。为了保留默认 OpenCode 构建体验,默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(OhMyOpenCode 增强计划模式)。默认启用。 |
|
||||
| `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 Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
|
||||
|
||||
### Hooks
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
260
src/hooks/anthropic-auto-compact/executor.test.ts
Normal file
260
src/hooks/anthropic-auto-compact/executor.test.ts
Normal 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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
71
src/index.ts
71
src/index.ts
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user