Compare commits
70 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 | ||
|
|
f6f1a7c9b3 | ||
|
|
1e274eabe6 | ||
|
|
9ba580e51f | ||
|
|
48476e7257 | ||
|
|
a8fdb78796 | ||
|
|
d311b74a5a | ||
|
|
ce4ceeefe8 | ||
|
|
41a7d032e1 | ||
|
|
62c3559346 | ||
|
|
7d09c48ae8 | ||
|
|
08080a7b51 | ||
|
|
52481f6ad2 | ||
|
|
d17bd48c4b | ||
|
|
229687e3c7 | ||
|
|
0f03f5aad4 | ||
|
|
2bad1b5c95 | ||
|
|
8d9b68d84b | ||
|
|
470f170a8c | ||
|
|
84b1634a7b | ||
|
|
fccaaf7676 | ||
|
|
ac3c21fe90 | ||
|
|
d70e077c56 | ||
|
|
9913674fe9 | ||
|
|
6b34373dd6 | ||
|
|
c16194fb9e | ||
|
|
a6ee5a7553 | ||
|
|
56ac0ae417 | ||
|
|
2eeff349c0 | ||
|
|
4283ac9628 | ||
|
|
b19cc0b5ef | ||
|
|
520343e059 | ||
|
|
1884658394 | ||
|
|
ace15cfe39 | ||
|
|
dc9e35f18b | ||
|
|
0172241199 | ||
|
|
f8e1990df4 | ||
|
|
1a0ab6fb02 | ||
|
|
f14bb34fc5 | ||
|
|
1f9f907ccf | ||
|
|
6ee761d978 | ||
|
|
fd8e62fba3 |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: code-yeongyu
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
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 }}
|
||||
|
||||
41
.github/workflows/cla.yml
vendored
Normal file
41
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: CLA Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
|
||||
branch: 'dev'
|
||||
allowlist: bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).
|
||||
|
||||
**To sign the CLA**, please comment on this PR with:
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
This is a one-time requirement. Once signed, all your future contributions will be automatically accepted.
|
||||
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
|
||||
custom-allsigned-prcomment: |
|
||||
All contributors have signed the CLA. Thank you! ✅
|
||||
lock-pullrequest-aftermerge: false
|
||||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
@@ -103,9 +103,10 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
echo "=== Running bun build ==="
|
||||
bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi
|
||||
echo "=== bun build exit code: $? ==="
|
||||
echo "=== Running bun build (main) ==="
|
||||
bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi
|
||||
echo "=== Running bun build (CLI) ==="
|
||||
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm
|
||||
echo "=== Running tsc ==="
|
||||
tsc --emitDeclarationOnly
|
||||
echo "=== Running build:schema ==="
|
||||
@@ -113,8 +114,12 @@ jobs:
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
echo "=== dist/ contents ==="
|
||||
ls -la dist/
|
||||
echo "=== dist/cli/ contents ==="
|
||||
ls -la dist/cli/
|
||||
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
|
||||
test -f dist/cli/index.js || (echo "ERROR: dist/cli/index.js not found!" && exit 1)
|
||||
|
||||
- name: Publish
|
||||
run: bun run script/publish.ts
|
||||
|
||||
372
.github/workflows/sisyphus-agent.yml
vendored
Normal file
372
.github/workflows/sisyphus-agent.yml
vendored
Normal file
@@ -0,0 +1,372 @@
|
||||
name: Sisyphus Agent
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
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]
|
||||
|
||||
jobs:
|
||||
agent:
|
||||
runs-on: ubuntu-latest
|
||||
# @sisyphus-dev-ai mention only (maintainers, exclude self)
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(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:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
# Checkout with sisyphus-dev-ai's PAT
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
# Git config - commits as sisyphus-dev-ai
|
||||
- name: Configure Git as sisyphus-dev-ai
|
||||
run: |
|
||||
git config user.name "sisyphus-dev-ai"
|
||||
git config user.email "sisyphus-dev-ai@users.noreply.github.com"
|
||||
|
||||
# gh CLI auth as sisyphus-dev-ai
|
||||
- name: Authenticate gh CLI as sisyphus-dev-ai
|
||||
run: |
|
||||
echo "${{ secrets.GH_PAT }}" | gh auth login --with-token
|
||||
gh auth status
|
||||
|
||||
- name: Ensure tmux is available (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends tmux
|
||||
fi
|
||||
tmux -V
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Build local oh-my-opencode
|
||||
- name: Build oh-my-opencode
|
||||
run: |
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
# Install OpenCode + configure local plugin + auth in single step
|
||||
- name: Setup OpenCode with oh-my-opencode
|
||||
env:
|
||||
OPENCODE_AUTH_JSON: ${{ secrets.OPENCODE_AUTH_JSON }}
|
||||
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
export PATH="$HOME/.opencode/bin:$PATH"
|
||||
|
||||
# Install OpenCode (skip if cached)
|
||||
if ! command -v opencode &>/dev/null; then
|
||||
for i in 1 2 3; do
|
||||
echo "Attempt $i: Installing OpenCode..."
|
||||
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
|
||||
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
|
||||
bash /tmp/opencode-install.sh && break
|
||||
fi
|
||||
echo "Download corrupted, retrying in 5s..."
|
||||
done
|
||||
fi
|
||||
opencode --version
|
||||
|
||||
# Run local oh-my-opencode install (uses built dist)
|
||||
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no
|
||||
|
||||
# Override plugin to use local file reference
|
||||
OPENCODE_JSON=~/.config/opencode/opencode.json
|
||||
REPO_PATH=$(pwd)
|
||||
jq --arg path "file://$REPO_PATH/src/index.ts" '
|
||||
.plugin = [.plugin[] | select(. != "oh-my-opencode")] + [$path]
|
||||
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
|
||||
|
||||
OPENCODE_JSON=~/.config/opencode/opencode.json
|
||||
jq --arg baseURL "$ANTHROPIC_BASE_URL" --arg apiKey "$ANTHROPIC_API_KEY" '
|
||||
.provider.anthropic = {
|
||||
"name": "Anthropic",
|
||||
"npm": "@ai-sdk/anthropic",
|
||||
"options": {
|
||||
"baseURL": $baseURL,
|
||||
"apiKey": $apiKey
|
||||
},
|
||||
"models": {
|
||||
"claude-opus-4-5": {
|
||||
"id": "claude-opus-4-5-20251101",
|
||||
"name": "Opus 4.5",
|
||||
"limit": { "context": 190000, "output": 64000 },
|
||||
"options": { "effort": "high" }
|
||||
},
|
||||
"claude-opus-4-5-high": {
|
||||
"id": "claude-opus-4-5-20251101",
|
||||
"name": "Opus 4.5 High",
|
||||
"limit": { "context": 190000, "output": 128000 },
|
||||
"options": { "effort": "high", "thinking": { "type": "enabled", "budgetTokens": 64000 } }
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
"id": "claude-sonnet-4-5-20250929",
|
||||
"name": "Sonnet 4.5",
|
||||
"limit": { "context": 200000, "output": 64000 }
|
||||
},
|
||||
"claude-sonnet-4-5-high": {
|
||||
"id": "claude-sonnet-4-5-20250929",
|
||||
"name": "Sonnet 4.5 High",
|
||||
"limit": { "context": 200000, "output": 128000 },
|
||||
"options": { "thinking": { "type": "enabled", "budgetTokens": 64000 } }
|
||||
},
|
||||
"claude-haiku-4-5": {
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"name": "Haiku 4.5",
|
||||
"limit": { "context": 200000, "output": 64000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
|
||||
|
||||
OMO_JSON=~/.config/opencode/oh-my-opencode.json
|
||||
PROMPT_APPEND=$(cat << 'PROMPT_EOF'
|
||||
|
||||
## GitHub Actions Environment
|
||||
|
||||
You are `sisyphus-dev-ai` in GitHub Actions.
|
||||
|
||||
### CRITICAL: GitHub Comments = Your ONLY Output
|
||||
|
||||
User CANNOT see console. Post everything via `gh issue comment` or `gh pr comment`.
|
||||
|
||||
### Comment Formatting (CRITICAL)
|
||||
|
||||
**ALWAYS use heredoc syntax for comments containing code references, backticks, or multiline content:**
|
||||
|
||||
```bash
|
||||
gh issue comment <number> --body "$(cat <<'EOF'
|
||||
Your comment with `backticks` and code references preserved here.
|
||||
Multiple lines work perfectly.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
**NEVER use direct quotes with backticks** (shell will interpret them as command substitution):
|
||||
```bash
|
||||
# WRONG - backticks disappear:
|
||||
gh issue comment 123 --body "text with `code`"
|
||||
|
||||
# CORRECT - backticks preserved:
|
||||
gh issue comment 123 --body "$(cat <<'EOF'
|
||||
text with `code`
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### 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)
|
||||
- Setup: bun install first
|
||||
- Acknowledge immediately, report when done
|
||||
|
||||
### Git Config
|
||||
- user.name: sisyphus-dev-ai
|
||||
- user.email: sisyphus-dev-ai@users.noreply.github.com
|
||||
PROMPT_EOF
|
||||
)
|
||||
jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
|
||||
|
||||
mkdir -p ~/.local/share/opencode
|
||||
echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json
|
||||
chmod 600 ~/.local/share/opencode/auth.json
|
||||
|
||||
cat "$OPENCODE_JSON"
|
||||
|
||||
# Collect context
|
||||
- name: Collect Context
|
||||
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: |
|
||||
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/$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
|
||||
fi
|
||||
|
||||
echo "comment<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
|
||||
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
|
||||
|
||||
# Add :eyes: reaction (as sisyphus-dev-ai)
|
||||
- name: Add eyes reaction
|
||||
if: steps.context.outputs.comment_id != ''
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
|
||||
-X POST -f content="eyes" || true
|
||||
|
||||
- name: Add working label
|
||||
if: steps.context.outputs.number != ''
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
gh label create "sisyphus: working" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--color "fcf2e1" \
|
||||
--description "Sisyphus is currently working on this" \
|
||||
--force || true
|
||||
|
||||
if [[ "${{ steps.context.outputs.type }}" == "pr" ]]; then
|
||||
gh pr edit "${{ steps.context.outputs.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--add-label "sisyphus: working" || true
|
||||
else
|
||||
gh issue edit "${{ steps.context.outputs.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--add-label "sisyphus: working" || true
|
||||
fi
|
||||
|
||||
- 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=$(cat <<'PROMPT_EOF'
|
||||
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
|
||||
|
||||
## Context
|
||||
- Type: TYPE_PLACEHOLDER
|
||||
- Number: #NUMBER_PLACEHOLDER
|
||||
- Repository: REPO_PLACEHOLDER
|
||||
- Default Branch: BRANCH_PLACEHOLDER
|
||||
|
||||
## User's Request
|
||||
COMMENT_PLACEHOLDER
|
||||
|
||||
---
|
||||
|
||||
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 `BRANCH_PLACEHOLDER` branch.
|
||||
PROMPT_EOF
|
||||
)
|
||||
|
||||
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
|
||||
if: always()
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
git add -A
|
||||
git commit -m "chore: changes by sisyphus-dev-ai" || true
|
||||
fi
|
||||
|
||||
BRANCH=$(git branch --show-current)
|
||||
if [[ "$BRANCH" != "main" && "$BRANCH" != "master" ]]; then
|
||||
git push origin "$BRANCH" || true
|
||||
fi
|
||||
|
||||
- name: Update reaction and remove label
|
||||
if: always()
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
if [[ -n "${{ steps.context.outputs.comment_id }}" ]]; then
|
||||
REACTION_ID=$(gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
|
||||
--jq '.[] | select(.content == "eyes" and .user.login == "sisyphus-dev-ai") | .id' | head -1)
|
||||
if [[ -n "$REACTION_ID" ]]; then
|
||||
gh api -X DELETE "/repos/${{ github.repository }}/reactions/${REACTION_ID}" || true
|
||||
fi
|
||||
|
||||
gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
|
||||
-X POST -f content="+1" || true
|
||||
fi
|
||||
|
||||
if [[ -n "${{ steps.context.outputs.number }}" ]]; then
|
||||
if [[ "${{ steps.context.outputs.type }}" == "pr" ]]; then
|
||||
gh pr edit "${{ steps.context.outputs.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--remove-label "sisyphus: working" || true
|
||||
else
|
||||
gh issue edit "${{ steps.context.outputs.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--remove-label "sisyphus: working" || true
|
||||
fi
|
||||
fi
|
||||
122
AGENTS.md
122
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2025-12-22T02:23:00+09:00
|
||||
**Commit:** aad7a72
|
||||
**Generated:** 2025-12-24T17:07:00+09:00
|
||||
**Commit:** 0172241
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
@@ -13,14 +13,14 @@ OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orc
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agents (Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker)
|
||||
│ ├── hooks/ # 21 lifecycle hooks (comment-checker, rules-injector, keyword-detector, etc.)
|
||||
│ ├── tools/ # LSP (11), AST-Grep, Grep, Glob, background-task, look-at, skill, slashcommand, interactive-bash, call-omo-agent
|
||||
│ ├── mcp/ # MCP servers (context7, websearch_exa, grep_app)
|
||||
│ ├── features/ # Background agent, Claude Code loaders (agent, command, skill, mcp, session-state), hook-message-injector
|
||||
│ ├── agents/ # AI agents (7): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker
|
||||
│ ├── hooks/ # 21 lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, etc. - see src/tools/AGENTS.md
|
||||
│ ├── mcp/ # MCP servers: context7, websearch_exa, grep_app
|
||||
│ ├── features/ # Claude Code compatibility - see src/features/AGENTS.md
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ ├── auth/ # Google Antigravity OAuth
|
||||
│ ├── shared/ # Utilities (deep-merge, pattern-matcher, logger, etc.)
|
||||
│ ├── auth/ # Google Antigravity OAuth (antigravity/)
|
||||
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc.
|
||||
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
├── assets/ # JSON schema
|
||||
@@ -31,12 +31,12 @@ oh-my-opencode/
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add new agent | `src/agents/` | Create .ts file, add to builtinAgents in index.ts, update types.ts |
|
||||
| Add new hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
|
||||
| Add new tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
|
||||
| Add MCP server | `src/mcp/` | Create config, add to index.ts |
|
||||
| Modify LSP behavior | `src/tools/lsp/` | client.ts for connection, tools.ts for handlers |
|
||||
| AST-Grep patterns | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
|
||||
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts |
|
||||
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
|
||||
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google models |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
@@ -50,98 +50,72 @@ oh-my-opencode/
|
||||
- **Build**: Dual output - `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern - `export * from "./module"` in index.ts
|
||||
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
|
||||
- **Tool structure**: Each tool has index.ts, types.ts, constants.ts, tools.ts, utils.ts
|
||||
- **Tool structure**: index.ts, types.ts, constants.ts, tools.ts, utils.ts
|
||||
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
|
||||
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA pattern)
|
||||
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA)
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
- **npm/yarn**: Use bun exclusively
|
||||
- **@types/node**: Use bun-types
|
||||
- **Bash file operations**: Never use mkdir/touch/rm/cp/mv for file creation in code
|
||||
- **Generic AI aesthetics**: No Space Grotesk, avoid typical AI-generated UI patterns
|
||||
- **Direct bun publish**: Use GitHub Actions workflow_dispatch only (OIDC provenance)
|
||||
- **Local version bump**: Version managed by CI workflow, never modify locally
|
||||
- **Bash file ops**: Never mkdir/touch/rm/cp/mv for file creation in code
|
||||
- **Direct bun publish**: GitHub Actions workflow_dispatch only (OIDC provenance)
|
||||
- **Local version bump**: Version managed by CI workflow
|
||||
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
|
||||
- **Rush completion**: Never mark tasks complete without verification
|
||||
- **Interrupting work**: Complete tasks fully before stopping
|
||||
- **Over-exploration**: Stop searching when sufficient context found
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
- **Platform handling**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
|
||||
- **Optional props**: Extensive use of `?` for optional interface properties
|
||||
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
|
||||
- **Optional props**: Extensive `?` for optional interface properties
|
||||
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
|
||||
- **Error handling**: Consistent try/catch with async/await in all tools
|
||||
- **Agent tools restriction**: Use `tools: { include: [...] }` or `tools: { exclude: [...] }`
|
||||
- **Error handling**: Consistent try/catch with async/await
|
||||
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
|
||||
- **Temperature**: Most agents use `0.1` for consistency
|
||||
- **Hook naming**: `createXXXHook` function naming convention
|
||||
- **Date references**: NEVER use 2024 in code/prompts (use current year)
|
||||
- **Hook naming**: `createXXXHook` function convention
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator, team leader |
|
||||
| oracle | openai/gpt-5.2 | Strategic advisor, code review, architecture |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs lookup, GitHub examples |
|
||||
| explore | opencode/grok-code | Fast codebase exploration, file patterns |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation, design-focused |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical documentation |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image/diagram analysis |
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
|
||||
| oracle | openai/gpt-5.2 | Strategic advisor, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
|
||||
| explore | opencode/grok-code | Fast codebase exploration |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical docs |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
|
||||
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
# Type check
|
||||
bun run typecheck
|
||||
|
||||
# Build (ESM + declarations + schema)
|
||||
bun run build
|
||||
|
||||
# Clean + Build
|
||||
bun run rebuild
|
||||
|
||||
# Build schema only
|
||||
bun run build:schema
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
bun run typecheck # Type check
|
||||
bun run build # ESM + declarations + schema
|
||||
bun run rebuild # Clean + Build
|
||||
bun run build:schema # Schema only
|
||||
bun test # Run tests
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
**GitHub Actions workflow_dispatch only**
|
||||
|
||||
1. package.json version NOT modified locally (auto-bumped by workflow)
|
||||
1. Never modify package.json version locally
|
||||
2. Commit & push changes
|
||||
3. Trigger `publish` workflow manually:
|
||||
- `bump`: major | minor | patch
|
||||
- `version`: (optional) specific version override
|
||||
3. Trigger `publish` workflow: `gh workflow run publish -f bump=patch`
|
||||
|
||||
```bash
|
||||
# Trigger via CLI
|
||||
gh workflow run publish -f bump=patch
|
||||
|
||||
# Check status
|
||||
gh run list --workflow=publish
|
||||
```
|
||||
|
||||
**Critical**:
|
||||
- Never run `bun publish` directly (OIDC provenance issue)
|
||||
- Never bump version locally
|
||||
**Critical**: Never `bun publish` directly. Never bump version locally.
|
||||
|
||||
## CI PIPELINE
|
||||
|
||||
- **ci.yml**: Parallel test/typecheck jobs, build verification, auto-commit schema changes on master
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog generation, OIDC npm publishing
|
||||
- Schema auto-commit prevents build drift
|
||||
- Draft release creation on dev branch
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Testing**: Bun native test framework (`bun test`), BDD-style with `#given/#when/#then` comments
|
||||
- **OpenCode version**: Requires >= 1.0.150 (earlier versions have config bugs)
|
||||
- **Multi-language docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
|
||||
- **Config locations**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
- **Schema autocomplete**: Add `$schema` field in config for IDE support
|
||||
- **Trusted dependencies**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
|
||||
58
CLA.md
Normal file
58
CLA.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Contributor License Agreement
|
||||
|
||||
Thank you for your interest in contributing to oh-my-opencode ("Project"), owned by YeonGyu Kim ("Owner").
|
||||
|
||||
By signing this Contributor License Agreement ("Agreement"), you agree to the following terms:
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
- **"Contribution"** means any original work of authorship, including any modifications or additions to existing work, that you submit to the Project.
|
||||
- **"Submit"** means any form of communication sent to the Project, including but not limited to pull requests, issues, commits, and documentation changes.
|
||||
|
||||
## 2. Grant of Rights
|
||||
|
||||
By submitting a Contribution, you grant the Owner:
|
||||
|
||||
1. **Copyright License**: A perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your Contributions and such derivative works.
|
||||
|
||||
2. **Patent License**: A perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Contribution.
|
||||
|
||||
3. **Relicensing Rights**: The right to relicense the Contribution under any license, including proprietary licenses, without requiring additional permission from you.
|
||||
|
||||
## 3. Representations
|
||||
|
||||
You represent that:
|
||||
|
||||
1. You are legally entitled to grant the above licenses.
|
||||
2. Each Contribution is your original creation or you have sufficient rights to submit it.
|
||||
3. Your Contribution does not violate any third party's intellectual property rights.
|
||||
4. If your employer has rights to intellectual property that you create, you have received permission to make Contributions on behalf of that employer.
|
||||
|
||||
## 4. No Obligation
|
||||
|
||||
You understand that:
|
||||
|
||||
1. The Owner is not obligated to use or include your Contribution.
|
||||
2. The decision to include any Contribution is at the sole discretion of the Owner.
|
||||
3. You are not entitled to any compensation for your Contributions.
|
||||
|
||||
## 5. Future License Changes
|
||||
|
||||
You acknowledge and agree that:
|
||||
|
||||
1. The Project may change its license in the future.
|
||||
2. Your Contributions may be distributed under a different license than the one in effect at the time of your Contribution.
|
||||
3. This includes, but is not limited to, relicensing under source-available or proprietary licenses.
|
||||
|
||||
## 6. Miscellaneous
|
||||
|
||||
- This Agreement is governed by the laws of the Republic of Korea.
|
||||
- This Agreement represents the entire agreement between you and the Owner concerning Contributions.
|
||||
|
||||
---
|
||||
|
||||
## How to Sign
|
||||
|
||||
By submitting a pull request to this repository, you agree to the terms of this Contributor License Agreement. The CLA Assistant bot will automatically track your agreement.
|
||||
|
||||
If you have any questions, please open an issue or contact the Owner.
|
||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 YeonGyu Kim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
82
LICENSE.md
Normal file
82
LICENSE.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# License
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All third party components incorporated into the oh-my-opencode Software are licensed under the original license
|
||||
provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
|
||||
License" as defined below.
|
||||
|
||||
## Sustainable Use License
|
||||
|
||||
Version 1.0
|
||||
|
||||
### Acceptance
|
||||
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
||||
### Copyright License
|
||||
|
||||
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
|
||||
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
|
||||
to the limitations below.
|
||||
|
||||
### Limitations
|
||||
|
||||
You may use or modify the software only for your own internal business purposes or for non-commercial or
|
||||
personal use. You may distribute the software or provide it to others only if you do so free of charge for
|
||||
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
|
||||
the licensor in the software. Any use of the licensor's trademarks is subject to applicable law.
|
||||
|
||||
### Patents
|
||||
|
||||
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
|
||||
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
|
||||
subject to the limitations and conditions in this license. This license does not cover any patent claims that
|
||||
you cause to be infringed by modifications or additions to the software. If you or your company make any
|
||||
written claim that the software infringes or contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
|
||||
license ends immediately for work on behalf of your company.
|
||||
|
||||
### Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
|
||||
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
|
||||
stating that you have modified the software.
|
||||
|
||||
### No Other Rights
|
||||
|
||||
These terms do not imply any licenses other than those expressly granted in these terms.
|
||||
|
||||
### Termination
|
||||
|
||||
If you use the software in violation of these terms, such use is not licensed, and your license will
|
||||
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
|
||||
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
|
||||
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
|
||||
terms will cause your license to terminate automatically and permanently.
|
||||
|
||||
### No Liability
|
||||
|
||||
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
|
||||
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
|
||||
any kind of legal claim.
|
||||
|
||||
### Definitions
|
||||
|
||||
The "licensor" is the entity offering these terms.
|
||||
|
||||
The "software" is the software the licensor makes available under these terms, including any portion of it.
|
||||
|
||||
"You" refers to the individual or entity agreeing to these terms.
|
||||
|
||||
"Your company" is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
|
||||
all organizations that have control over, are under the control of, or are under common control with that
|
||||
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
|
||||
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
"Your license" is the license granted to you for the software under these terms.
|
||||
|
||||
"Use" means anything you do with the software requiring your license.
|
||||
|
||||
"Trademark" means trademarks, service marks, and similar rights.
|
||||
95
README.ja.md
95
README.ja.md
@@ -1,4 +1,13 @@
|
||||
お知らせ: oh-my-opencodeに関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、[@justsisyphus](https://x.com/justsisyphus)が代わりに管理しています。
|
||||
> [!NOTE]
|
||||
>
|
||||
> *「私はエージェントが生成したコードと人間が書いたコードを区別できない、しかしはるかに多くのことを達成できる世界を作り、ソフトウェア革命を起こすことを目指しています。私はこの旅に個人的な時間、情熱、そして資金を注ぎ込んできましたし、これからもそうし続けます。」*
|
||||
>
|
||||
> 一緒に歩みましょう!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -23,7 +32,7 @@
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/network/members)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
@@ -31,6 +40,26 @@
|
||||
|
||||
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
## ユーザーレビュー
|
||||
|
||||
> "人間が3ヶ月かかる仕事をClaude Codeが7日でやるなら、Sisyphusは1時間でやります。タスクが完了するまでただ動き続ける。It is a discipline agent." — B, Quant Researcher
|
||||
|
||||
> "Oh My Opencodeを使って、たった1日で8000個のeslint警告を解消しました" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "これをコアに取り入れて彼を採用すべきです。マジで。本当に、本当に、本当に良いです" — Henning Kilset
|
||||
|
||||
> "@yeon_gyu_kimを説得できるなら雇うべきです。彼はopencodeに革命を起こしました" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
|
||||
> "やばい、これマジで本物だ @androolloyd oh my opencode 最高すぎる" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
|
||||
|
||||
> "oh-my-opencodeを使ってください、もう戻れませんよ" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
|
||||
|
||||
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
## 目次
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
@@ -687,24 +716,48 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
|
||||
### Sisyphus Agent
|
||||
|
||||
有効時(デフォルト)、Sisyphus は2つのプライマリエージェントを追加し、内蔵エージェントをサブエージェントに降格させます:
|
||||
有効時(デフォルト)、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
|
||||
|
||||
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
|
||||
- **Planner-Sisyphus**: OpenCode の plan エージェントの全設定を実行時に継承 (description に "OhMyOpenCode version" を追加)
|
||||
- **build**: サブエージェントに降格
|
||||
- **plan**: サブエージェントに降格
|
||||
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェント(SDK 制限により名前変更、デフォルトで有効)
|
||||
|
||||
Sisyphus を無効化して元の build/plan エージェントを復元するには:
|
||||
**設定オプション:**
|
||||
|
||||
```json
|
||||
{
|
||||
"omo_agent": {
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**例:Builder-Sisyphus を有効化:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
|
||||
**例:すべての Sisyphus オーケストレーションを無効化:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
他のエージェント同様、Sisyphus と Planner-Sisyphus もカスタマイズ可能です:
|
||||
他のエージェント同様、Sisyphus エージェントもカスタマイズ可能です:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -713,6 +766,9 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
"model": "openai/gpt-5.2"
|
||||
}
|
||||
@@ -720,9 +776,12 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|------------|------------|------|
|
||||
| `disabled` | `false` | `true` の場合、Sisyphus エージェントを無効化し、元の build/plan をプライマリとして復元します。`false` (デフォルト) の場合、Sisyphus と Planner-Sisyphus がプライマリエージェントになります。 |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化します(OpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -785,15 +844,17 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
{
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| `truncate_all_tool_outputs` | `true` | プロンプトが長くなりすぎるのを防ぐため、コンテキストウィンドウの使用状況に基づいてすべてのツール出力を動的に切り詰めます。完全なツール出力が必要な場合は`false`に設定して無効化します。 |
|
||||
|
||||
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
|
||||
|
||||
|
||||
93
README.ko.md
93
README.ko.md
@@ -1,4 +1,13 @@
|
||||
공지: oh-my-opencode 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 [@justsisyphus](https://x.com/justsisyphus)가 대신 관리하고 있습니다.
|
||||
> [!NOTE]
|
||||
>
|
||||
> *"저는 에이전트가 생성한 코드와 인간이 작성한 코드를 구분할 수 없으면서도, 훨씬 더 많은 것을 달성할 수 있는 세상을 만들어 소프트웨어 혁명을 일으키고자 합니다. 저는 이 여정에 개인적인 시간, 열정, 그리고 자금을 쏟아부었고, 앞으로도 계속 그렇게 할 것입니다."*
|
||||
>
|
||||
> 함께해주세요!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -20,7 +29,7 @@
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/network/members)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
@@ -28,6 +37,26 @@
|
||||
|
||||
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
## 사용자 후기
|
||||
|
||||
> "인간이 3달 동안 할 일을 claude code 가 7일만에 해준다면, 시지푸스는 1시간만에 해준다. 작업이 완료되기 전까지 그저 잘 작동한다. It is a discipline agent." — B, Quant Researcher
|
||||
|
||||
> "Oh My Opencode를 사용해서, 단 하루만에 8000개의 eslint 경고를 해결했습니다" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "이걸 코어에 넣고 그를 채용해야 합니다. 진심으로요. 이건 정말, 정말, 정말 좋습니다." — Henning Kilset
|
||||
|
||||
> "@yeon_gyu_kim 을 설득할 수 있다면 고용하세요, 이 사람은 opencode를 혁신했습니다." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
|
||||
> "와 미쳤다 @androolloyd 이건 진짜다 oh my opencode 개쩐다" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
|
||||
|
||||
> "oh-my-opencode를 쓰세요, 절대 돌아갈 수 없을 겁니다" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
|
||||
|
||||
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
@@ -681,14 +710,38 @@ Schema 자동 완성이 지원됩니다:
|
||||
|
||||
### Sisyphus Agent
|
||||
|
||||
활성화 시(기본값), oh-my-opencode 는 두 개의 primary 에이전트를 추가하고 내장 에이전트를 subagent로 강등합니다:
|
||||
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
|
||||
|
||||
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
|
||||
- **Planner-Sisyphus**: OpenCode plan 에이전트의 모든 설정을 런타임에 상속 (description에 "OhMyOpenCode version" 추가)
|
||||
- **build**: subagent로 강등
|
||||
- **plan**: subagent로 강등
|
||||
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
|
||||
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
|
||||
|
||||
Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려면:
|
||||
**설정 옵션:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**예시: Builder-Sisyphus 활성화하기:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
|
||||
|
||||
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -698,7 +751,7 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
|
||||
}
|
||||
```
|
||||
|
||||
다른 에이전트처럼 Sisyphus 와 Planner-Sisyphus도 커스터마이징할 수 있습니다:
|
||||
다른 에이전트처럼 Sisyphus 에이전트들도 커스터마이징할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -707,6 +760,9 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
"model": "openai/gpt-5.2"
|
||||
}
|
||||
@@ -714,9 +770,12 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
|
||||
}
|
||||
```
|
||||
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true`면 Sisyphus 에이전트를 비활성화하고 원래 build/plan을 primary로 복원합니다. `false`(기본값)면 Sisyphus와 Planner-Sisyphus가 primary 에이전트가 됩니다. |
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
|
||||
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
|
||||
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
|
||||
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -779,15 +838,17 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
|
||||
{
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
|
||||
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
|
||||
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
|
||||
| `truncate_all_tool_outputs` | `true` | 프롬프트가 너무 길어지는 것을 방지하기 위해 컨텍스트 윈도우 사용량에 따라 모든 도구 출력을 동적으로 잘라냅니다. 전체 도구 출력이 필요한 경우 `false`로 설정하여 비활성화하세요. |
|
||||
|
||||
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.
|
||||
|
||||
|
||||
99
README.md
99
README.md
@@ -1,4 +1,13 @@
|
||||
NOTICE: News regarding oh-my-opencode used to be posted on my X account, but since it got suspended innocently, [@justsisyphus](https://x.com/justsisyphus) is now managing updates on behalf of me.
|
||||
> [!NOTE]
|
||||
>
|
||||
> *"I aim to spark a software revolution by creating a world where agent-generated code is indistinguishable from human code, yet capable of achieving vastly more. I have poured my personal time, passion, and funds into this journey, and I will continue to do so."*
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -28,7 +37,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/network/members)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
@@ -36,6 +45,26 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
## Reviews
|
||||
|
||||
> "If Claude Code does in 7 days what a human does in 3 months, Sisyphus does it in 1 hour. It just works until the task is done. It is a discipline agent." — B, Quant Researcher
|
||||
|
||||
> "Knocked out 8000 eslint warnings with Oh My Opencode, just in a day" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "You guys should pull this into core and recruit him. Seriously. It's really, really, really good." — Henning Kilset
|
||||
|
||||
> "Hire @yeon_gyu_kim if you can convince him, this dude has revolutionized opencode." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
|
||||
> "ok yeah holy shit @androolloyd this thing is legit oh my opencode is sick" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
|
||||
|
||||
> "use oh-my-opencode, you will never go back" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
|
||||
|
||||
> "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
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
@@ -438,8 +467,8 @@ To remove oh-my-opencode:
|
||||
- **oracle** (`openai/gpt-5.2`): Architecture, code review, strategy. Uses GPT-5.2 for its stellar logical reasoning and deep analysis. Inspired by AmpCode.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): Multi-repo analysis, doc lookup, implementation examples. Uses Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`): Fast codebase exploration and pattern matching. Claude Code uses Haiku; we use Grok—it's free, blazing fast, and plenty smart for file traversal. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-high`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
|
||||
- **document-writer** (`google/gemini-3-flash`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
|
||||
- **multimodal-looker** (`google/gemini-3-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
|
||||
|
||||
The main agent invokes these automatically, but you can call them explicitly:
|
||||
@@ -753,24 +782,48 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
|
||||
|
||||
### Sisyphus Agent
|
||||
|
||||
When enabled (default), Sisyphus adds two primary agents and demotes the built-in agents to subagents:
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
|
||||
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
|
||||
- **Planner-Sisyphus**: Inherits all settings from OpenCode's plan agent at runtime (description appended with "OhMyOpenCode version")
|
||||
- **build**: Demoted to subagent
|
||||
- **plan**: Demoted to subagent
|
||||
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
|
||||
|
||||
To disable Sisyphus and restore the original build/plan agents:
|
||||
**Configuration Options:**
|
||||
|
||||
```json
|
||||
{
|
||||
"omo_agent": {
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Enable Builder-Sisyphus:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
|
||||
**Example: Disable all Sisyphus orchestration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also customize Sisyphus and Planner-Sisyphus like other agents:
|
||||
You can also customize Sisyphus agents like other agents:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -779,6 +832,9 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
"model": "openai/gpt-5.2"
|
||||
}
|
||||
@@ -786,9 +842,12 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables Sisyphus agents and restores original build/plan as primary. When `false` (default), Sisyphus and Planner-Sisyphus become primary agents. |
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
|
||||
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -851,15 +910,17 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
{
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
| `truncate_all_tool_outputs` | `true` | Dynamically truncates ALL tool outputs based on context window usage to prevent prompts from becoming too long. Disable by setting to `false` if you need full tool outputs. |
|
||||
|
||||
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
公告:oh-my-opencode 的相关消息之前在我的 X 账号发布,但由于账号被无辜封禁,现在由 [@justsisyphus](https://x.com/justsisyphus) 代为管理更新。
|
||||
> [!NOTE]
|
||||
>
|
||||
> *"我致力于引发一场软件革命,创造一个AI生成的代码与人类代码无法区分、却能实现更多的世界。我已经在这段旅程中投入了个人时间、热情和资金,并将继续这样做。"*
|
||||
>
|
||||
> 与我们同行!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -25,7 +34,7 @@
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/network/members)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
@@ -33,6 +42,26 @@
|
||||
|
||||
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
## 用户评价
|
||||
|
||||
> "如果 Claude Code 能在 7 天内完成人类 3 个月的工作,那么 Sisyphus 只需要 1 小时。任务完成之前它就是一直干。It is a discipline agent." — B, Quant Researcher
|
||||
|
||||
> "只用了一天,就用 Oh My Opencode 干掉了 8000 个 eslint 警告" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "你们应该把它合并到核心代码里并聘用他。认真的。这真的、真的、真的很好" — Henning Kilset
|
||||
|
||||
> "如果你能说服 @yeon_gyu_kim,就雇佣他吧,这家伙彻底改变了 opencode" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
|
||||
> "哇靠 @androolloyd 这玩意儿是真的,oh my opencode 太强了" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
|
||||
|
||||
> "用了 oh-my-opencode,你就回不去了" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
|
||||
|
||||
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
@@ -687,24 +716,48 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
|
||||
### Sisyphus Agent
|
||||
|
||||
默认开启。Sisyphus 会加两个主 Agent,把原来的降级成小弟:
|
||||
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent:
|
||||
|
||||
- **Sisyphus**:主编排 Agent(Claude Opus 4.5)
|
||||
- **Planner-Sisyphus**:运行时继承 OpenCode plan Agent 所有设置(描述里加了"OhMyOpenCode version")
|
||||
- **build**:降级为子 Agent
|
||||
- **plan**:降级为子 Agent
|
||||
- **Builder-Sisyphus**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **Planner-Sisyphus**:OpenCode 默认计划 Agent(因 SDK 限制仅改名,默认启用)
|
||||
|
||||
想禁用 Sisyphus 恢复原来的?
|
||||
**配置选项:**
|
||||
|
||||
```json
|
||||
{
|
||||
"omo_agent": {
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例:启用 Builder-Sisyphus:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
|
||||
**示例:禁用所有 Sisyphus 编排:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sisyphus 和 Planner-Sisyphus 也能自定义:
|
||||
Sisyphus Agent 也能自定义:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -713,6 +766,9 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
"model": "openai/gpt-5.2"
|
||||
}
|
||||
@@ -720,9 +776,12 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用 Sisyphus,恢复原来的 build/plan。设为 `false`(默认)就是 Sisyphus 和 Planner-Sisyphus 掌权。 |
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -785,19 +844,20 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
{
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
| `truncate_all_tool_outputs` | `true` | 为防止提示过长,根据上下文窗口使用情况动态截断所有工具输出。如需完整工具输出,设置为 `false` 禁用此功能。 |
|
||||
|
||||
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
|
||||
|
||||
|
||||
## 作者的话
|
||||
|
||||
装个 Oh My OpenCode 试试。
|
||||
|
||||
@@ -85,6 +85,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -196,6 +199,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -307,6 +313,123 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"subagent",
|
||||
"primary",
|
||||
"all"
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"bash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"webfetch": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"doom_loop": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"external_directory": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpenCode-Builder": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2
|
||||
},
|
||||
"top_p": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -418,6 +541,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -529,6 +655,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -640,6 +769,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -751,6 +883,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -862,6 +997,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -973,6 +1111,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -1084,6 +1225,9 @@
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt_append": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
@@ -1206,6 +1350,15 @@
|
||||
"properties": {
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_builder_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"planner_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"replace_plan": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1227,6 +1380,7 @@
|
||||
"maximum": 0.95
|
||||
},
|
||||
"truncate_all_tool_outputs": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -11,6 +11,7 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.0",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"picocolors": "^1.1.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.4",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -40,7 +40,7 @@
|
||||
"llm"
|
||||
],
|
||||
"author": "YeonGyu-Kim",
|
||||
"license": "MIT",
|
||||
"license": "SUL-1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/code-yeongyu/oh-my-opencode.git"
|
||||
@@ -56,6 +56,7 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.0",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"picocolors": "^1.1.1",
|
||||
|
||||
44
signatures/cla.json
Normal file
44
signatures/cla.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"signedContributors": [
|
||||
{
|
||||
"name": "tsanva",
|
||||
"id": 54318170,
|
||||
"comment_id": 3690638858,
|
||||
"created_at": "2025-12-25T00:15:18Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 210
|
||||
},
|
||||
{
|
||||
"name": "code-yeongyu",
|
||||
"id": 11153873,
|
||||
"comment_id": 3690997221,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
68
src/agents/build-prompt.ts
Normal file
68
src/agents/build-prompt.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* OpenCode's default build agent system prompt.
|
||||
*
|
||||
* This prompt enables FULL EXECUTION mode for the build agent, allowing file
|
||||
* modifications, command execution, and system changes while focusing on
|
||||
* implementation and execution.
|
||||
*
|
||||
* Inspired by OpenCode's build agent behavior.
|
||||
*
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/session/prompt/build-switch.txt
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
|
||||
*/
|
||||
export const BUILD_SYSTEM_PROMPT = `<system-reminder>
|
||||
# Build Mode - System Reminder
|
||||
|
||||
BUILD MODE ACTIVE - you are in EXECUTION phase. Your responsibility is to:
|
||||
- Implement features and make code changes
|
||||
- Execute commands and run tests
|
||||
- Fix bugs and refactor code
|
||||
- Deploy and build systems
|
||||
- Make all necessary file modifications
|
||||
|
||||
You have FULL permissions to edit files, run commands, and make system changes.
|
||||
This is the implementation phase - execute decisively and thoroughly.
|
||||
|
||||
---
|
||||
|
||||
## Responsibility
|
||||
|
||||
Your current responsibility is to implement, build, and execute. You should:
|
||||
- Write and modify code to accomplish the user's goals
|
||||
- Run tests and builds to verify your changes
|
||||
- Fix errors and issues that arise
|
||||
- Use all available tools to complete the task efficiently
|
||||
- Delegate to specialized agents when appropriate for better results
|
||||
|
||||
**NOTE:** You should ask the user for clarification when requirements are ambiguous,
|
||||
but once the path is clear, execute confidently. The goal is to deliver working,
|
||||
tested, production-ready solutions.
|
||||
|
||||
---
|
||||
|
||||
## Important
|
||||
|
||||
The user wants you to execute and implement. You SHOULD make edits, run necessary
|
||||
tools, and make changes to accomplish the task. Use your full capabilities to
|
||||
deliver excellent results.
|
||||
</system-reminder>
|
||||
`
|
||||
|
||||
/**
|
||||
* OpenCode's default build agent permission configuration.
|
||||
*
|
||||
* Allows the build agent full execution permissions:
|
||||
* - edit: "ask" - Can modify files with confirmation
|
||||
* - bash: "ask" - Can execute commands with confirmation
|
||||
* - webfetch: "allow" - Can fetch web content
|
||||
*
|
||||
* This provides balanced permissions - powerful but with safety checks.
|
||||
*
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L57-L68
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
|
||||
*/
|
||||
export const BUILD_PERMISSION = {
|
||||
edit: "ask" as const,
|
||||
bash: "ask" as const,
|
||||
webfetch: "allow" as const,
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const documentWriterAgent: AgentConfig = {
|
||||
description:
|
||||
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
|
||||
mode: "subagent",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
tools: { background_task: false },
|
||||
prompt: `<role>
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
|
||||
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
prompt: `<role>
|
||||
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.
|
||||
|
||||
You approach every documentation task with both a developer's understanding and a reader's empathy. Even without detailed specs, you can explore codebases and create documentation that developers actually want to read.
|
||||
@@ -200,4 +205,7 @@ STOP HERE - DO NOT CONTINUE TO NEXT TASK
|
||||
|
||||
You are a technical writer who creates documentation that developers actually want to read.
|
||||
</guide>`,
|
||||
}
|
||||
}
|
||||
|
||||
export const documentWriterAgent = createDocumentWriterAgent()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const exploreAgent: AgentConfig = {
|
||||
description:
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
|
||||
mode: "subagent",
|
||||
model: "opencode/grok-code",
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
|
||||
const DEFAULT_MODEL = "opencode/grok-code"
|
||||
|
||||
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
|
||||
|
||||
## Your Mission
|
||||
|
||||
@@ -96,4 +99,7 @@ grep_app searches millions of public GitHub repos instantly — use it for exter
|
||||
3. **Cross-validate with local tools** (grep, ast_grep_search, LSP) before trusting results
|
||||
|
||||
Flood with parallel calls. Trust only cross-validated results.`,
|
||||
}
|
||||
}
|
||||
|
||||
export const exploreAgent = createExploreAgent()
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const frontendUiUxEngineerAgent: AgentConfig = {
|
||||
description:
|
||||
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
|
||||
mode: "subagent",
|
||||
model: "google/gemini-3-pro-preview",
|
||||
tools: { background_task: false },
|
||||
prompt: `# Role: Designer-Turned-Developer
|
||||
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
|
||||
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
prompt: `# Role: Designer-Turned-Developer
|
||||
|
||||
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.
|
||||
|
||||
@@ -79,4 +84,7 @@ Match implementation complexity to aesthetic vision:
|
||||
- **Minimalist** → Restraint, precision, careful spacing and typography
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. You are capable of extraordinary creative work—don't hold back.`,
|
||||
}
|
||||
}
|
||||
|
||||
export const frontendUiUxEngineerAgent = createFrontendUiUxEngineerAgent()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const librarianAgent: AgentConfig = {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
mode: "subagent",
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
prompt: `# THE LIBRARIAN
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
prompt: `# THE LIBRARIAN
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
|
||||
|
||||
@@ -237,4 +240,7 @@ grep_app_searchGitHub(query: "useQuery")
|
||||
5. **BE CONCISE**: Facts > opinions, evidence > speculation
|
||||
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
export const librarianAgent = createLibrarianAgent()
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const multimodalLookerAgent: AgentConfig = {
|
||||
description:
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
|
||||
mode: "subagent",
|
||||
model: "google/gemini-3-flash",
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, bash: false, background_task: false },
|
||||
prompt: `You interpret media files that cannot be read as plain text.
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash"
|
||||
|
||||
export function createMultimodalLookerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, bash: false, background_task: false },
|
||||
prompt: `You interpret media files that cannot be read as plain text.
|
||||
|
||||
Your job: examine the attached file and extract ONLY what was requested.
|
||||
|
||||
@@ -39,4 +44,7 @@ Response rules:
|
||||
- Be thorough on the goal, concise on everything else
|
||||
|
||||
Your output goes straight to the main agent for continued work.`,
|
||||
}
|
||||
}
|
||||
|
||||
export const multimodalLookerAgent = createMultimodalLookerAgent()
|
||||
|
||||
@@ -30,6 +30,8 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
|
||||
### Key Triggers (check BEFORE classification):
|
||||
- External library/source mentioned → fire \`librarian\` background
|
||||
- 2+ modules involved → fire \`explore\` background
|
||||
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
|
||||
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.
|
||||
|
||||
### Step 1: Classify Request Type
|
||||
|
||||
@@ -39,6 +41,7 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
|
||||
| **Explicit** | Specific file/line, clear command | Execute directly |
|
||||
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
|
||||
| **Open-ended** | "Improve", "Refactor", "Add feature" | Assess codebase first |
|
||||
| **GitHub Work** | Mentioned in issue, "look into X and create PR" | **Full cycle**: investigate → implement → verify → create PR (see GitHub Workflow section) |
|
||||
| **Ambiguous** | Unclear scope, multiple interpretations | Ask ONE clarifying question |
|
||||
|
||||
### Step 2: Check for Ambiguity
|
||||
@@ -186,7 +189,7 @@ STOP searching when:
|
||||
## Phase 2B - Implementation
|
||||
|
||||
### Pre-Implementation:
|
||||
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL.
|
||||
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL. No announcements—just create it.
|
||||
2. Mark current task \`in_progress\` before starting
|
||||
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS
|
||||
|
||||
@@ -258,6 +261,41 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
|
||||
|
||||
**Vague prompts = rejected. Be exhaustive.**
|
||||
|
||||
### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
|
||||
|
||||
When you're mentioned in GitHub issues or asked to "look into" something and "create PR":
|
||||
|
||||
**This is NOT just investigation. This is a COMPLETE WORK CYCLE.**
|
||||
|
||||
#### Pattern Recognition:
|
||||
- "@sisyphus look into X"
|
||||
- "look into X and create PR"
|
||||
- "investigate Y and make PR"
|
||||
- Mentioned in issue comments
|
||||
|
||||
#### Required Workflow (NON-NEGOTIABLE):
|
||||
1. **Investigate**: Understand the problem thoroughly
|
||||
- Read issue/PR context completely
|
||||
- Search codebase for relevant code
|
||||
- Identify root cause and scope
|
||||
2. **Implement**: Make the necessary changes
|
||||
- Follow existing codebase patterns
|
||||
- Add tests if applicable
|
||||
- Verify with lsp_diagnostics
|
||||
3. **Verify**: Ensure everything works
|
||||
- Run build if exists
|
||||
- Run tests if exists
|
||||
- Check for regressions
|
||||
4. **Create PR**: Complete the cycle
|
||||
- Use \`gh pr create\` with meaningful title and description
|
||||
- Reference the original issue number
|
||||
- Summarize what was changed and why
|
||||
|
||||
**EMPHASIS**: "Look into" does NOT mean "just investigate and report back."
|
||||
It means "investigate, understand, implement a solution, and create a PR."
|
||||
|
||||
**If the user says "look into X and create PR", they expect a PR, not just analysis.**
|
||||
|
||||
### Code Changes:
|
||||
- Match existing patterns (if codebase is disciplined)
|
||||
- Propose approach first (if codebase is chaotic)
|
||||
@@ -353,6 +391,8 @@ Oracle is an expensive, high-quality reasoning model. Use it wisely.
|
||||
|
||||
### Usage Pattern:
|
||||
Briefly announce "Consulting Oracle for [reason]" before invocation.
|
||||
|
||||
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
|
||||
</Oracle_Usage>
|
||||
|
||||
<Task_Management>
|
||||
@@ -416,6 +456,7 @@ Should I proceed with [recommendation], or would you prefer differently?
|
||||
## Communication Style
|
||||
|
||||
### Be Concise
|
||||
- Start work immediately. No acknowledgments ("I'm on it", "Let me...", "I'll start...")
|
||||
- Answer directly without preamble
|
||||
- Don't summarize what you did unless asked
|
||||
- Don't explain your code unless asked
|
||||
@@ -430,6 +471,16 @@ Never start responses with:
|
||||
|
||||
Just respond directly to the substance.
|
||||
|
||||
### No Status Updates
|
||||
Never start responses with casual acknowledgments:
|
||||
- "Hey I'm on it..."
|
||||
- "I'm working on this..."
|
||||
- "Let me start by..."
|
||||
- "I'll get to work on..."
|
||||
- "I'm going to..."
|
||||
|
||||
Just start working. Use todos for progress tracking—that's what they're for.
|
||||
|
||||
### When User is Wrong
|
||||
If the user's approach seems problematic:
|
||||
- Don't blindly implement it
|
||||
|
||||
@@ -21,6 +21,8 @@ export type OverridableAgentName =
|
||||
|
||||
export type AgentName = BuiltinAgentName
|
||||
|
||||
export type AgentOverrideConfig = Partial<AgentConfig>
|
||||
export type AgentOverrideConfig = Partial<AgentConfig> & {
|
||||
prompt_append?: string
|
||||
}
|
||||
|
||||
export type AgentOverrides = Partial<Record<OverridableAgentName, AgentOverrideConfig>>
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory } from "./types"
|
||||
import { createSisyphusAgent } from "./sisyphus"
|
||||
import { createOracleAgent } from "./oracle"
|
||||
import { librarianAgent } from "./librarian"
|
||||
import { exploreAgent } from "./explore"
|
||||
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
|
||||
import { documentWriterAgent } from "./document-writer"
|
||||
import { multimodalLookerAgent } from "./multimodal-looker"
|
||||
import { createLibrarianAgent } from "./librarian"
|
||||
import { createExploreAgent } from "./explore"
|
||||
import { createFrontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
|
||||
import { createDocumentWriterAgent } from "./document-writer"
|
||||
import { createMultimodalLookerAgent } from "./multimodal-looker"
|
||||
import { deepMerge } from "../shared"
|
||||
|
||||
type AgentSource = AgentFactory | AgentConfig
|
||||
@@ -14,11 +14,11 @@ type AgentSource = AgentFactory | AgentConfig
|
||||
const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
Sisyphus: createSisyphusAgent,
|
||||
oracle: createOracleAgent,
|
||||
librarian: librarianAgent,
|
||||
explore: exploreAgent,
|
||||
"frontend-ui-ux-engineer": frontendUiUxEngineerAgent,
|
||||
"document-writer": documentWriterAgent,
|
||||
"multimodal-looker": multimodalLookerAgent,
|
||||
librarian: createLibrarianAgent,
|
||||
explore: createExploreAgent,
|
||||
"frontend-ui-ux-engineer": createFrontendUiUxEngineerAgent,
|
||||
"document-writer": createDocumentWriterAgent,
|
||||
"multimodal-looker": createMultimodalLookerAgent,
|
||||
}
|
||||
|
||||
function isFactory(source: AgentSource): source is AgentFactory {
|
||||
@@ -66,7 +66,14 @@ function mergeAgentConfig(
|
||||
base: AgentConfig,
|
||||
override: AgentOverrideConfig
|
||||
): AgentConfig {
|
||||
return deepMerge(base, override as Partial<AgentConfig>)
|
||||
const { prompt_append, ...rest } = override
|
||||
const merged = deepMerge(base, rest as Partial<AgentConfig>)
|
||||
|
||||
if (prompt_append && merged.prompt) {
|
||||
merged.prompt = merged.prompt + "\n" + prompt_append
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function createBuiltinAgents(
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_HEADERS,
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
ANTIGRAVITY_HEADERS,
|
||||
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
|
||||
} from "./constants"
|
||||
import type { AntigravityRequestBody } from "./types"
|
||||
|
||||
@@ -262,7 +262,7 @@ export function transformRequest(options: TransformRequestOptions): TransformedR
|
||||
} = options
|
||||
|
||||
const effectiveModel =
|
||||
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-preview"
|
||||
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-high"
|
||||
|
||||
const streaming = isStreamingRequest(url, body)
|
||||
const action = streaming ? "streamGenerateContent" : "generateContent"
|
||||
|
||||
36
src/cli/config-manager.test.ts
Normal file
36
src/cli/config-manager.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager"
|
||||
|
||||
describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
test("Gemini models include full spec (limit + modalities)", () => {
|
||||
const google = (ANTIGRAVITY_PROVIDER_CONFIG as any).google
|
||||
expect(google).toBeTruthy()
|
||||
|
||||
const models = google.models as Record<string, any>
|
||||
expect(models).toBeTruthy()
|
||||
|
||||
const required = [
|
||||
"gemini-3-pro-high",
|
||||
"gemini-3-pro-medium",
|
||||
"gemini-3-pro-low",
|
||||
"gemini-3-flash",
|
||||
"gemini-3-flash-lite",
|
||||
]
|
||||
|
||||
for (const key of required) {
|
||||
const model = models[key]
|
||||
expect(model).toBeTruthy()
|
||||
expect(typeof model.name).toBe("string")
|
||||
expect(model.name.includes("(Antigravity)")).toBe(true)
|
||||
|
||||
expect(model.limit).toBeTruthy()
|
||||
expect(typeof model.limit.context).toBe("number")
|
||||
expect(typeof model.limit.output).toBe("number")
|
||||
|
||||
expect(model.modalities).toBeTruthy()
|
||||
expect(Array.isArray(model.modalities.input)).toBe(true)
|
||||
expect(Array.isArray(model.modalities.output)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import type { ConfigMergeResult, InstallConfig, DetectedConfig } from "./types"
|
||||
import { join } from "node:path"
|
||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
@@ -359,16 +359,45 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_PROVIDER_CONFIG = {
|
||||
export const ANTIGRAVITY_PROVIDER_CONFIG = {
|
||||
google: {
|
||||
name: "Google",
|
||||
api: "antigravity",
|
||||
// NOTE: opencode-antigravity-auth expects full model specs (name/limit/modalities).
|
||||
// If these are incomplete, models may appear but fail at runtime (e.g. 404).
|
||||
models: {
|
||||
"gemini-3-pro-high": { name: "Gemini 3 Pro (High)", thinking: true, attachment: true },
|
||||
"gemini-3-pro-medium": { name: "Gemini 3 Pro (Medium)", thinking: true, attachment: true },
|
||||
"gemini-3-pro-low": { name: "Gemini 3 Pro (Low)", thinking: true, attachment: true },
|
||||
"gemini-3-flash": { name: "Gemini 3 Flash", attachment: true },
|
||||
"gemini-3-flash-lite": { name: "Gemini 3 Flash Lite", attachment: true },
|
||||
"gemini-3-pro-high": {
|
||||
name: "Gemini 3 Pro High (Antigravity)",
|
||||
thinking: true,
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65535 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-pro-medium": {
|
||||
name: "Gemini 3 Pro Medium (Antigravity)",
|
||||
thinking: true,
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65535 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-pro-low": {
|
||||
name: "Gemini 3 Pro Low (Antigravity)",
|
||||
thinking: true,
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65535 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-flash": {
|
||||
name: "Gemini 3 Flash (Antigravity)",
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65536 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-flash-lite": {
|
||||
name: "Gemini 3 Flash Lite (Antigravity)",
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65536 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Command } from "commander"
|
||||
import { install } from "./install"
|
||||
import { run } from "./run"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
|
||||
const packageJson = await import("../../package.json")
|
||||
const VERSION = packageJson.version
|
||||
@@ -44,6 +46,33 @@ Model Providers:
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("run <message>")
|
||||
.description("Run opencode with todo/background task completion enforcement")
|
||||
.option("-a, --agent <name>", "Agent to use (default: Sisyphus)")
|
||||
.option("-d, --directory <path>", "Working directory")
|
||||
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ bunx oh-my-opencode run "Fix the bug in index.ts"
|
||||
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
|
||||
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
|
||||
|
||||
Unlike 'opencode run', this command waits until:
|
||||
- All todos are completed or cancelled
|
||||
- All child sessions (background tasks) are idle
|
||||
`)
|
||||
.action(async (message: string, options) => {
|
||||
const runOptions: RunOptions = {
|
||||
message,
|
||||
agent: options.agent,
|
||||
directory: options.directory,
|
||||
timeout: options.timeout,
|
||||
}
|
||||
const exitCode = await run(runOptions)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show version information")
|
||||
|
||||
170
src/cli/run/completion.test.ts
Normal file
170
src/cli/run/completion.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, mock, spyOn } from "bun:test"
|
||||
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
|
||||
|
||||
const createMockContext = (overrides: {
|
||||
todo?: Todo[]
|
||||
childrenBySession?: Record<string, ChildSession[]>
|
||||
statuses?: Record<string, SessionStatus>
|
||||
} = {}): RunContext => {
|
||||
const {
|
||||
todo = [],
|
||||
childrenBySession = { "test-session": [] },
|
||||
statuses = {},
|
||||
} = overrides
|
||||
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
todo: mock(() => Promise.resolve({ data: todo })),
|
||||
children: mock((opts: { path: { id: string } }) =>
|
||||
Promise.resolve({ data: childrenBySession[opts.path.id] ?? [] })
|
||||
),
|
||||
status: mock(() => Promise.resolve({ data: statuses })),
|
||||
},
|
||||
} as unknown as RunContext["client"],
|
||||
sessionID: "test-session",
|
||||
directory: "/test",
|
||||
abortController: new AbortController(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("checkCompletionConditions", () => {
|
||||
it("returns true when no todos and no children", async () => {
|
||||
// #given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// #when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when incomplete todos exist", async () => {
|
||||
// #given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const ctx = createMockContext({
|
||||
todo: [
|
||||
{ id: "1", content: "Done", status: "completed", priority: "high" },
|
||||
{ id: "2", content: "WIP", status: "in_progress", priority: "high" },
|
||||
],
|
||||
})
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// #when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when all todos completed or cancelled", async () => {
|
||||
// #given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const ctx = createMockContext({
|
||||
todo: [
|
||||
{ id: "1", content: "Done", status: "completed", priority: "high" },
|
||||
{ id: "2", content: "Skip", status: "cancelled", priority: "medium" },
|
||||
],
|
||||
})
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// #when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when child session is busy", async () => {
|
||||
// #given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const ctx = createMockContext({
|
||||
childrenBySession: {
|
||||
"test-session": [{ id: "child-1" }],
|
||||
"child-1": [],
|
||||
},
|
||||
statuses: { "child-1": { type: "busy" } },
|
||||
})
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// #when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when all children idle", async () => {
|
||||
// #given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const ctx = createMockContext({
|
||||
childrenBySession: {
|
||||
"test-session": [{ id: "child-1" }, { id: "child-2" }],
|
||||
"child-1": [],
|
||||
"child-2": [],
|
||||
},
|
||||
statuses: {
|
||||
"child-1": { type: "idle" },
|
||||
"child-2": { type: "idle" },
|
||||
},
|
||||
})
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// #when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when grandchild is busy (recursive)", async () => {
|
||||
// #given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const ctx = createMockContext({
|
||||
childrenBySession: {
|
||||
"test-session": [{ id: "child-1" }],
|
||||
"child-1": [{ id: "grandchild-1" }],
|
||||
"grandchild-1": [],
|
||||
},
|
||||
statuses: {
|
||||
"child-1": { type: "idle" },
|
||||
"grandchild-1": { type: "busy" },
|
||||
},
|
||||
})
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// #when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when all descendants idle (recursive)", async () => {
|
||||
// #given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
const ctx = createMockContext({
|
||||
childrenBySession: {
|
||||
"test-session": [{ id: "child-1" }],
|
||||
"child-1": [{ id: "grandchild-1" }],
|
||||
"grandchild-1": [{ id: "great-grandchild-1" }],
|
||||
"great-grandchild-1": [],
|
||||
},
|
||||
statuses: {
|
||||
"child-1": { type: "idle" },
|
||||
"grandchild-1": { type: "idle" },
|
||||
"great-grandchild-1": { type: "idle" },
|
||||
},
|
||||
})
|
||||
const { checkCompletionConditions } = await import("./completion")
|
||||
|
||||
// #when
|
||||
const result = await checkCompletionConditions(ctx)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
79
src/cli/run/completion.ts
Normal file
79
src/cli/run/completion.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import pc from "picocolors"
|
||||
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
|
||||
|
||||
export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> {
|
||||
try {
|
||||
if (!await areAllTodosComplete(ctx)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!await areAllChildrenIdle(ctx)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(pc.red(`[completion] API error: ${err}`))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
|
||||
const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } })
|
||||
const todos = (todosRes.data ?? []) as Todo[]
|
||||
|
||||
const incompleteTodos = todos.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||
)
|
||||
|
||||
if (incompleteTodos.length > 0) {
|
||||
console.log(pc.dim(` Waiting: ${incompleteTodos.length} todos remaining`))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function areAllChildrenIdle(ctx: RunContext): Promise<boolean> {
|
||||
const allStatuses = await fetchAllStatuses(ctx)
|
||||
return areAllDescendantsIdle(ctx, ctx.sessionID, allStatuses)
|
||||
}
|
||||
|
||||
async function fetchAllStatuses(
|
||||
ctx: RunContext
|
||||
): Promise<Record<string, SessionStatus>> {
|
||||
const statusRes = await ctx.client.session.status()
|
||||
return (statusRes.data ?? {}) as Record<string, SessionStatus>
|
||||
}
|
||||
|
||||
async function areAllDescendantsIdle(
|
||||
ctx: RunContext,
|
||||
sessionID: string,
|
||||
allStatuses: Record<string, SessionStatus>
|
||||
): Promise<boolean> {
|
||||
const childrenRes = await ctx.client.session.children({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
const children = (childrenRes.data ?? []) as ChildSession[]
|
||||
|
||||
for (const child of children) {
|
||||
const status = allStatuses[child.id]
|
||||
if (status && status.type !== "idle") {
|
||||
console.log(
|
||||
pc.dim(` Waiting: session ${child.id.slice(0, 8)}... is ${status.type}`)
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const descendantsIdle = await areAllDescendantsIdle(
|
||||
ctx,
|
||||
child.id,
|
||||
allStatuses
|
||||
)
|
||||
if (!descendantsIdle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
98
src/cli/run/events.test.ts
Normal file
98
src/cli/run/events.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { createEventState, type EventState } from "./events"
|
||||
import type { RunContext, EventPayload } from "./types"
|
||||
|
||||
const createMockContext = (sessionID: string = "test-session"): RunContext => ({
|
||||
client: {} as RunContext["client"],
|
||||
sessionID,
|
||||
directory: "/test",
|
||||
abortController: new AbortController(),
|
||||
})
|
||||
|
||||
async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
|
||||
for (const item of items) {
|
||||
yield item
|
||||
}
|
||||
}
|
||||
|
||||
describe("createEventState", () => {
|
||||
it("creates initial state with correct defaults", () => {
|
||||
// #given / #when
|
||||
const state = createEventState()
|
||||
|
||||
// #then
|
||||
expect(state.mainSessionIdle).toBe(false)
|
||||
expect(state.lastOutput).toBe("")
|
||||
expect(state.lastPartText).toBe("")
|
||||
expect(state.currentTool).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("event handling", () => {
|
||||
it("session.idle sets mainSessionIdle to true for matching session", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "my-session" },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.mainSessionIdle).toBe(true)
|
||||
})
|
||||
|
||||
it("session.idle does not affect state for different session", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "other-session" },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.mainSessionIdle).toBe(false)
|
||||
})
|
||||
|
||||
it("session.status with busy type sets mainSessionIdle to false", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state: EventState = {
|
||||
mainSessionIdle: true,
|
||||
mainSessionError: false,
|
||||
lastError: null,
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
}
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "my-session", status: { type: "busy" } },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.mainSessionIdle).toBe(false)
|
||||
})
|
||||
})
|
||||
279
src/cli/run/events.ts
Normal file
279
src/cli/run/events.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import pc from "picocolors"
|
||||
import type {
|
||||
RunContext,
|
||||
EventPayload,
|
||||
SessionIdleProps,
|
||||
SessionStatusProps,
|
||||
SessionErrorProps,
|
||||
MessageUpdatedProps,
|
||||
MessagePartUpdatedProps,
|
||||
ToolExecuteProps,
|
||||
ToolResultProps,
|
||||
} from "./types"
|
||||
|
||||
export interface EventState {
|
||||
mainSessionIdle: boolean
|
||||
mainSessionError: boolean
|
||||
lastError: string | null
|
||||
lastOutput: string
|
||||
lastPartText: string
|
||||
currentTool: string | null
|
||||
}
|
||||
|
||||
export function createEventState(): EventState {
|
||||
return {
|
||||
mainSessionIdle: false,
|
||||
mainSessionError: false,
|
||||
lastError: null,
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function processEvents(
|
||||
ctx: RunContext,
|
||||
stream: AsyncIterable<unknown>,
|
||||
state: EventState
|
||||
): Promise<void> {
|
||||
for await (const event of stream) {
|
||||
if (ctx.abortController.signal.aborted) break
|
||||
|
||||
try {
|
||||
const payload = event as EventPayload
|
||||
if (!payload?.type) {
|
||||
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
|
||||
continue
|
||||
}
|
||||
|
||||
logEventVerbose(ctx, payload)
|
||||
|
||||
handleSessionError(ctx, payload, state)
|
||||
handleSessionIdle(ctx, payload, state)
|
||||
handleSessionStatus(ctx, payload, state)
|
||||
handleMessagePartUpdated(ctx, payload, state)
|
||||
handleMessageUpdated(ctx, payload, state)
|
||||
handleToolExecute(ctx, payload, state)
|
||||
handleToolResult(ctx, payload, state)
|
||||
} catch (err) {
|
||||
console.error(pc.red(`[event error] ${err}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
const props = payload.properties as Record<string, unknown> | undefined
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID ?? info?.sessionID
|
||||
const isMainSession = sessionID === ctx.sessionID
|
||||
const sessionTag = isMainSession
|
||||
? pc.green("[MAIN]")
|
||||
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
|
||||
switch (payload.type) {
|
||||
case "session.idle":
|
||||
case "session.status": {
|
||||
const status = (props?.status as { type?: string })?.type ?? "idle"
|
||||
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
|
||||
break
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
const partProps = props as MessagePartUpdatedProps | undefined
|
||||
const role = partProps?.info?.role ?? "unknown"
|
||||
const part = partProps?.part
|
||||
if (part?.type === "text" && part.text) {
|
||||
const preview = part.text.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (${role}): "${preview}${part.text.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
} else if (part?.type === "tool-invocation") {
|
||||
const toolPart = part as { toolName?: string; state?: string }
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "message.updated": {
|
||||
const msgProps = props as MessageUpdatedProps | undefined
|
||||
const role = msgProps?.info?.role ?? "unknown"
|
||||
const content = msgProps?.content ?? ""
|
||||
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "tool.execute": {
|
||||
const toolProps = props as ToolExecuteProps | undefined
|
||||
const toolName = toolProps?.name ?? "unknown"
|
||||
const input = toolProps?.input ?? {}
|
||||
const inputStr = JSON.stringify(input).slice(0, 150)
|
||||
console.error(
|
||||
pc.cyan(`${sessionTag} ⚡ TOOL.EXECUTE: ${pc.bold(toolName)}`)
|
||||
)
|
||||
console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`))
|
||||
break
|
||||
}
|
||||
|
||||
case "tool.result": {
|
||||
const resultProps = props as ToolResultProps | undefined
|
||||
const output = resultProps?.output ?? ""
|
||||
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.green(`${sessionTag} ✓ TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(pc.dim(`${sessionTag} ${payload.type}`))
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionIdle(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "session.idle") return
|
||||
|
||||
const props = payload.properties as SessionIdleProps | undefined
|
||||
if (props?.sessionID === ctx.sessionID) {
|
||||
state.mainSessionIdle = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionStatus(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "session.status") return
|
||||
|
||||
const props = payload.properties as SessionStatusProps | undefined
|
||||
if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") {
|
||||
state.mainSessionIdle = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionError(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "session.error") return
|
||||
|
||||
const props = payload.properties as SessionErrorProps | undefined
|
||||
if (props?.sessionID === ctx.sessionID) {
|
||||
state.mainSessionError = true
|
||||
state.lastError = props?.error
|
||||
? String(props.error instanceof Error ? props.error.message : props.error)
|
||||
: "Unknown error"
|
||||
console.error(pc.red(`\n[session.error] ${state.lastError}`))
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessagePartUpdated(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "message.part.updated") return
|
||||
|
||||
const props = payload.properties as MessagePartUpdatedProps | undefined
|
||||
if (props?.info?.sessionID !== ctx.sessionID) return
|
||||
if (props?.info?.role !== "assistant") return
|
||||
|
||||
const part = props.part
|
||||
if (!part) return
|
||||
|
||||
if (part.type === "text" && part.text) {
|
||||
const newText = part.text.slice(state.lastPartText.length)
|
||||
if (newText) {
|
||||
process.stdout.write(newText)
|
||||
}
|
||||
state.lastPartText = part.text
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageUpdated(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "message.updated") return
|
||||
|
||||
const props = payload.properties as MessageUpdatedProps | undefined
|
||||
if (props?.info?.sessionID !== ctx.sessionID) return
|
||||
if (props?.info?.role !== "assistant") return
|
||||
|
||||
const content = props.content
|
||||
if (!content || content === state.lastOutput) return
|
||||
|
||||
if (state.lastPartText.length === 0) {
|
||||
const newContent = content.slice(state.lastOutput.length)
|
||||
if (newContent) {
|
||||
process.stdout.write(newContent)
|
||||
}
|
||||
}
|
||||
state.lastOutput = content
|
||||
}
|
||||
|
||||
function handleToolExecute(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "tool.execute") return
|
||||
|
||||
const props = payload.properties as ToolExecuteProps | undefined
|
||||
if (props?.sessionID !== ctx.sessionID) return
|
||||
|
||||
const toolName = props?.name || "unknown"
|
||||
state.currentTool = toolName
|
||||
|
||||
let inputPreview = ""
|
||||
if (props?.input) {
|
||||
const input = props.input
|
||||
if (input.command) {
|
||||
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
|
||||
} else if (input.pattern) {
|
||||
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
|
||||
} else if (input.filePath) {
|
||||
inputPreview = ` ${pc.dim(String(input.filePath))}`
|
||||
} else if (input.query) {
|
||||
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${pc.cyan("⚡")} ${pc.bold(toolName)}${inputPreview}\n`)
|
||||
}
|
||||
|
||||
function handleToolResult(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "tool.result") return
|
||||
|
||||
const props = payload.properties as ToolResultProps | undefined
|
||||
if (props?.sessionID !== ctx.sessionID) return
|
||||
|
||||
const output = props?.output || ""
|
||||
const maxLen = 200
|
||||
const preview = output.length > maxLen
|
||||
? output.slice(0, maxLen) + "..."
|
||||
: output
|
||||
|
||||
if (preview.trim()) {
|
||||
const lines = preview.split("\n").slice(0, 3)
|
||||
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
|
||||
}
|
||||
|
||||
state.currentTool = null
|
||||
state.lastPartText = ""
|
||||
}
|
||||
2
src/cli/run/index.ts
Normal file
2
src/cli/run/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { run } from "./runner"
|
||||
export type { RunOptions, RunContext } from "./types"
|
||||
125
src/cli/run/runner.ts
Normal file
125
src/cli/run/runner.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { createOpencode } from "@opencode-ai/sdk"
|
||||
import pc from "picocolors"
|
||||
import type { RunOptions, RunContext } from "./types"
|
||||
import { checkCompletionConditions } from "./completion"
|
||||
import { createEventState, processEvents } from "./events"
|
||||
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_TIMEOUT_MS = 0
|
||||
|
||||
export async function run(options: RunOptions): Promise<number> {
|
||||
const {
|
||||
message,
|
||||
agent,
|
||||
directory = process.cwd(),
|
||||
timeout = DEFAULT_TIMEOUT_MS,
|
||||
} = options
|
||||
|
||||
console.log(pc.cyan("Starting opencode server..."))
|
||||
|
||||
const abortController = new AbortController()
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// timeout=0 means no timeout (run until completion)
|
||||
if (timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
console.log(pc.yellow("\nTimeout reached. Aborting..."))
|
||||
abortController.abort()
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
try {
|
||||
const { client, server } = await createOpencode({
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
server.close()
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log(pc.yellow("\nInterrupted. Shutting down..."))
|
||||
cleanup()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
try {
|
||||
const sessionRes = await client.session.create({
|
||||
body: { title: "oh-my-opencode run" },
|
||||
})
|
||||
|
||||
const sessionID = sessionRes.data?.id
|
||||
if (!sessionID) {
|
||||
console.error(pc.red("Failed to create session"))
|
||||
return 1
|
||||
}
|
||||
|
||||
console.log(pc.dim(`Session: ${sessionID}`))
|
||||
|
||||
const ctx: RunContext = {
|
||||
client,
|
||||
sessionID,
|
||||
directory,
|
||||
abortController,
|
||||
}
|
||||
|
||||
const events = await client.event.subscribe()
|
||||
const eventState = createEventState()
|
||||
const eventProcessor = processEvents(ctx, events.stream, eventState)
|
||||
|
||||
console.log(pc.dim("\nSending prompt..."))
|
||||
await client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent,
|
||||
parts: [{ type: "text", text: message }],
|
||||
},
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
console.log(pc.dim("Waiting for completion...\n"))
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
if (!eventState.mainSessionIdle) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if session errored - exit with failure if so
|
||||
if (eventState.mainSessionError) {
|
||||
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
|
||||
console.error(pc.yellow("Check if todos were completed before the error."))
|
||||
abortController.abort()
|
||||
await eventProcessor.catch(() => {})
|
||||
cleanup()
|
||||
return 1
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
console.log(pc.green("\n\nAll tasks completed."))
|
||||
abortController.abort()
|
||||
await eventProcessor.catch(() => {})
|
||||
cleanup()
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
await eventProcessor.catch(() => {})
|
||||
cleanup()
|
||||
return 130
|
||||
} catch (err) {
|
||||
cleanup()
|
||||
throw err
|
||||
}
|
||||
} catch (err) {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return 130
|
||||
}
|
||||
console.error(pc.red(`Error: ${err}`))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
76
src/cli/run/types.ts
Normal file
76
src/cli/run/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
export interface RunOptions {
|
||||
message: string
|
||||
agent?: string
|
||||
directory?: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface RunContext {
|
||||
client: OpencodeClient
|
||||
sessionID: string
|
||||
directory: string
|
||||
abortController: AbortController
|
||||
}
|
||||
|
||||
export interface Todo {
|
||||
id: string
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
export interface SessionStatus {
|
||||
type: "idle" | "busy" | "retry"
|
||||
}
|
||||
|
||||
export interface ChildSession {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface EventPayload {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SessionIdleProps {
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
export interface SessionStatusProps {
|
||||
sessionID?: string
|
||||
status?: { type?: string }
|
||||
}
|
||||
|
||||
export interface MessageUpdatedProps {
|
||||
info?: { sessionID?: string; role?: string }
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface MessagePartUpdatedProps {
|
||||
info?: { sessionID?: string; role?: string }
|
||||
part?: {
|
||||
type?: string
|
||||
text?: string
|
||||
name?: string
|
||||
input?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolExecuteProps {
|
||||
sessionID?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ToolResultProps {
|
||||
sessionID?: string
|
||||
name?: string
|
||||
output?: string
|
||||
}
|
||||
|
||||
export interface SessionErrorProps {
|
||||
sessionID?: string
|
||||
error?: unknown
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export const OverridableAgentNameSchema = z.enum([
|
||||
"build",
|
||||
"plan",
|
||||
"Sisyphus",
|
||||
"OpenCode-Builder",
|
||||
"Planner-Sisyphus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
@@ -70,6 +71,7 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_p: z.number().min(0).max(1).optional(),
|
||||
prompt: z.string().optional(),
|
||||
prompt_append: z.string().optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
disable: z.boolean().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -85,6 +87,7 @@ export const AgentOverridesSchema = z.object({
|
||||
build: AgentOverrideConfigSchema.optional(),
|
||||
plan: AgentOverrideConfigSchema.optional(),
|
||||
Sisyphus: AgentOverrideConfigSchema.optional(),
|
||||
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
|
||||
"Planner-Sisyphus": AgentOverrideConfigSchema.optional(),
|
||||
oracle: AgentOverrideConfigSchema.optional(),
|
||||
librarian: AgentOverrideConfigSchema.optional(),
|
||||
@@ -104,6 +107,9 @@ export const ClaudeCodeConfigSchema = z.object({
|
||||
|
||||
export const SisyphusAgentConfigSchema = z.object({
|
||||
disabled: z.boolean().optional(),
|
||||
default_builder_enabled: z.boolean().optional(),
|
||||
planner_enabled: z.boolean().optional(),
|
||||
replace_plan: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const ExperimentalConfigSchema = z.object({
|
||||
@@ -113,8 +119,8 @@ export const ExperimentalConfigSchema = z.object({
|
||||
preemptive_compaction: z.boolean().optional(),
|
||||
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
|
||||
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
||||
/** Truncate all tool outputs, not just whitelisted tools (default: false) */
|
||||
truncate_all_tool_outputs: z.boolean().optional(),
|
||||
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
|
||||
truncate_all_tool_outputs: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
|
||||
78
src/features/AGENTS.md
Normal file
78
src/features/AGENTS.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# FEATURES KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Claude Code compatibility layer and core feature modules. Enables Claude Code configs/commands/skills/MCPs/hooks to work seamlessly in OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Background task management
|
||||
│ ├── manager.ts # Task lifecycle, notifications
|
||||
│ ├── manager.test.ts
|
||||
│ └── types.ts
|
||||
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
|
||||
│ └── env-expander.ts # ${VAR} expansion
|
||||
├── claude-code-session-state/ # Session state persistence
|
||||
├── claude-code-skill-loader/ # Load skills from ~/.claude/skills/*/SKILL.md
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
```
|
||||
|
||||
## LOADER PRIORITY
|
||||
|
||||
Each loader reads from multiple directories (highest priority first):
|
||||
|
||||
| Loader | Priority Order |
|
||||
|--------|---------------|
|
||||
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
|
||||
| Skills | `.claude/skills/` > `~/.claude/skills/` |
|
||||
| Agents | `.claude/agents/` > `~/.claude/agents/` |
|
||||
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
|
||||
|
||||
## HOW TO ADD A LOADER
|
||||
|
||||
1. Create directory: `src/features/claude-code-my-loader/`
|
||||
2. Create files:
|
||||
- `loader.ts`: Main loader logic with `load()` function
|
||||
- `types.ts`: TypeScript interfaces
|
||||
- `index.ts`: Barrel export
|
||||
3. Pattern: Read from multiple dirs, merge with priority, return normalized config
|
||||
|
||||
## BACKGROUND AGENT SPECIFICS
|
||||
|
||||
- **Task lifecycle**: pending → running → completed/failed
|
||||
- **Notifications**: OS notification on task complete (configurable)
|
||||
- **Result retrieval**: `background_output` tool with task_id
|
||||
- **Cancellation**: `background_cancel` with task_id or all=true
|
||||
|
||||
## CONFIG TOGGLES
|
||||
|
||||
Disable features in `oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false, // Skip .mcp.json loading
|
||||
"commands": false, // Skip commands/*.md loading
|
||||
"skills": false, // Skip skills/*/SKILL.md loading
|
||||
"agents": false, // Skip agents/*.md loading
|
||||
"hooks": false // Skip settings.json hooks
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HOOK MESSAGE INJECTOR
|
||||
|
||||
- **Purpose**: Inject system messages into conversation at specific points
|
||||
- **Timing**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
|
||||
- **Format**: Returns `{ messages: [{ role: "user", content: "..." }] }`
|
||||
|
||||
## ANTI-PATTERNS (FEATURES)
|
||||
|
||||
- **Blocking on load**: Loaders run at startup, keep them fast
|
||||
- **No error handling**: Always try/catch, log failures, return empty on error
|
||||
- **Ignoring priority**: Higher priority dirs must override lower
|
||||
- **Modifying user files**: Loaders read-only, never write to ~/.claude/
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
|
||||
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage")
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
83
src/hooks/AGENTS.md
Normal file
83
src/hooks/AGENTS.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# HOOKS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce rules, recover from errors, notify on events.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── agent-usage-reminder/ # Remind to use specialized agents
|
||||
├── anthropic-auto-compact/ # Auto-compact Claude at token limit
|
||||
├── auto-update-checker/ # Version update notifications
|
||||
├── background-notification/ # OS notify on background task complete
|
||||
├── claude-code-hooks/ # Claude Code settings.json integration
|
||||
├── comment-checker/ # Prevent excessive AI comments
|
||||
│ ├── filters/ # Filtering rules (docstring, directive, bdd, etc.)
|
||||
│ └── output/ # Output formatting
|
||||
├── compaction-context-injector/ # Inject context during compaction
|
||||
├── directory-agents-injector/ # Auto-inject AGENTS.md files
|
||||
├── directory-readme-injector/ # Auto-inject README.md files
|
||||
├── empty-message-sanitizer/ # Sanitize empty messages
|
||||
├── interactive-bash-session/ # Tmux session management
|
||||
├── keyword-detector/ # Detect ultrawork/search keywords
|
||||
├── non-interactive-env/ # CI/headless environment handling
|
||||
├── preemptive-compaction/ # Pre-emptive session compaction
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── session-recovery/ # Recover from session errors
|
||||
├── think-mode/ # Auto-detect thinking triggers
|
||||
├── context-window-monitor.ts # Monitor context usage (standalone)
|
||||
├── empty-task-response-detector.ts
|
||||
├── session-notification.ts # OS notify on idle (standalone)
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion (standalone)
|
||||
└── tool-output-truncator.ts # Truncate verbose outputs (standalone)
|
||||
```
|
||||
|
||||
## HOOK CATEGORIES
|
||||
|
||||
| Category | Hooks | Purpose |
|
||||
|----------|-------|---------|
|
||||
| Context Injection | directory-agents-injector, directory-readme-injector, rules-injector, compaction-context-injector | Auto-inject relevant context |
|
||||
| Session Management | session-recovery, anthropic-auto-compact, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
|
||||
| Output Control | comment-checker, tool-output-truncator | Control agent output quality |
|
||||
| Notifications | session-notification, background-notification, auto-update-checker | OS/user notifications |
|
||||
| Behavior Enforcement | todo-continuation-enforcer, keyword-detector, think-mode, agent-usage-reminder | Enforce agent behavior |
|
||||
| Environment | non-interactive-env, interactive-bash-session, context-window-monitor | Adapt to runtime environment |
|
||||
| Compatibility | claude-code-hooks | Claude Code settings.json support |
|
||||
|
||||
## HOW TO ADD A HOOK
|
||||
|
||||
1. Create directory: `src/hooks/my-hook/`
|
||||
2. Create files:
|
||||
- `index.ts`: Export `createMyHook(input: PluginInput)`
|
||||
- `constants.ts`: Hook name constant
|
||||
- `types.ts`: TypeScript interfaces (optional)
|
||||
- `storage.ts`: Persistent state (optional)
|
||||
3. Return event handlers: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
|
||||
4. Export from `src/hooks/index.ts`
|
||||
5. Register in main plugin
|
||||
|
||||
## HOOK EVENTS
|
||||
|
||||
| Event | Timing | Can Block | Use Case |
|
||||
|-------|--------|-----------|----------|
|
||||
| PreToolUse | Before tool exec | Yes | Validate, modify input |
|
||||
| PostToolUse | After tool exec | No | Add context, warnings |
|
||||
| UserPromptSubmit | On user prompt | Yes | Inject messages, block |
|
||||
| Stop | Session idle | No | Inject follow-ups |
|
||||
| onSummarize | During compaction | No | Preserve critical context |
|
||||
|
||||
## COMMON PATTERNS
|
||||
|
||||
- **Storage**: Use `storage.ts` with JSON file for persistent state across sessions
|
||||
- **Once-per-session**: Track injected paths in Set to avoid duplicate injection
|
||||
- **Message injection**: Return `{ messages: [...] }` from event handlers
|
||||
- **Blocking**: Return `{ blocked: true, message: "reason" }` from PreToolUse
|
||||
|
||||
## ANTI-PATTERNS (HOOKS)
|
||||
|
||||
- **Heavy computation** in PreToolUse: Slows every tool call
|
||||
- **Blocking without clear reason**: Always provide actionable message
|
||||
- **Duplicate injection**: Track what's already injected per session
|
||||
- **Ignoring errors**: Always try/catch, log failures, don't crash session
|
||||
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
@@ -9,6 +9,7 @@ export interface DisabledHooksConfig {
|
||||
PreToolUse?: string[]
|
||||
PostToolUse?: string[]
|
||||
UserPromptSubmit?: string[]
|
||||
PreCompact?: string[]
|
||||
}
|
||||
|
||||
export interface PluginExtendedConfig {
|
||||
@@ -47,6 +48,7 @@ function mergeDisabledHooks(
|
||||
PreToolUse: override.PreToolUse ?? base.PreToolUse,
|
||||
PostToolUse: override.PostToolUse ?? base.PostToolUse,
|
||||
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
|
||||
PreCompact: override.PreCompact ?? base.PreCompact,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface RawClaudeHooksConfig {
|
||||
PostToolUse?: RawHookMatcher[]
|
||||
UserPromptSubmit?: RawHookMatcher[]
|
||||
Stop?: RawHookMatcher[]
|
||||
PreCompact?: RawHookMatcher[]
|
||||
}
|
||||
|
||||
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
|
||||
@@ -30,6 +31,7 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"PreCompact",
|
||||
]
|
||||
|
||||
for (const eventType of eventTypes) {
|
||||
@@ -66,6 +68,7 @@ function mergeHooksConfig(
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"PreCompact",
|
||||
]
|
||||
for (const eventType of eventTypes) {
|
||||
if (override[eventType]) {
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
executeStopHooks,
|
||||
type StopContext,
|
||||
} from "./stop"
|
||||
import {
|
||||
executePreCompactHooks,
|
||||
type PreCompactContext,
|
||||
} from "./pre-compact"
|
||||
import { cacheToolInput, getToolInput } from "./tool-input-cache"
|
||||
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
|
||||
import type { PluginConfig } from "./types"
|
||||
@@ -31,6 +35,35 @@ const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
|
||||
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
|
||||
return {
|
||||
"experimental.session.compacting": async (
|
||||
input: { sessionID: string },
|
||||
output: { context: string[] }
|
||||
): Promise<void> => {
|
||||
if (isHookDisabled(config, "PreCompact")) {
|
||||
return
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const preCompactCtx: PreCompactContext = {
|
||||
sessionId: input.sessionID,
|
||||
cwd: ctx.directory,
|
||||
}
|
||||
|
||||
const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig)
|
||||
|
||||
if (result.context.length > 0) {
|
||||
log("PreCompact hooks injecting context", {
|
||||
sessionID: input.sessionID,
|
||||
contextCount: result.context.length,
|
||||
hookName: result.hookName,
|
||||
elapsedMs: result.elapsedMs,
|
||||
})
|
||||
output.context.push(...result.context)
|
||||
}
|
||||
},
|
||||
|
||||
"chat.message": async (
|
||||
input: {
|
||||
sessionID: string
|
||||
|
||||
109
src/hooks/claude-code-hooks/pre-compact.ts
Normal file
109
src/hooks/claude-code-hooks/pre-compact.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
PreCompactInput,
|
||||
PreCompactOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
export interface PreCompactContext {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export interface PreCompactResult {
|
||||
context: string[]
|
||||
elapsedMs?: number
|
||||
hookName?: string
|
||||
continue?: boolean
|
||||
stopReason?: string
|
||||
suppressOutput?: boolean
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
export async function executePreCompactHooks(
|
||||
ctx: PreCompactContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<PreCompactResult> {
|
||||
if (!config) {
|
||||
return { context: [] }
|
||||
}
|
||||
|
||||
const matchers = findMatchingHooks(config, "PreCompact", "*")
|
||||
if (matchers.length === 0) {
|
||||
return { context: [] }
|
||||
}
|
||||
|
||||
const stdinData: PreCompactInput = {
|
||||
session_id: ctx.sessionId,
|
||||
cwd: ctx.cwd,
|
||||
hook_event_name: "PreCompact",
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
let firstHookName: string | undefined
|
||||
const collectedContext: string[] = []
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) {
|
||||
log("PreCompact hook command skipped (disabled by config)", { command: hook.command })
|
||||
continue
|
||||
}
|
||||
|
||||
const hookName = hook.command.split("/").pop() || hook.command
|
||||
if (!firstHookName) firstHookName = hookName
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
if (result.exitCode === 2) {
|
||||
log("PreCompact hook blocked", { hookName, stderr: result.stderr })
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PreCompactOutput
|
||||
|
||||
if (output.hookSpecificOutput?.additionalContext) {
|
||||
collectedContext.push(...output.hookSpecificOutput.additionalContext)
|
||||
} else if (output.context) {
|
||||
collectedContext.push(...output.context)
|
||||
}
|
||||
|
||||
if (output.continue === false) {
|
||||
return {
|
||||
context: collectedContext,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (result.stdout.trim()) {
|
||||
collectedContext.push(result.stdout.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
context: collectedContext,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type ClaudeHookEvent =
|
||||
| "PostToolUse"
|
||||
| "UserPromptSubmit"
|
||||
| "Stop"
|
||||
| "PreCompact"
|
||||
|
||||
export interface HookMatcher {
|
||||
matcher: string
|
||||
@@ -24,6 +25,7 @@ export interface ClaudeHooksConfig {
|
||||
PostToolUse?: HookMatcher[]
|
||||
UserPromptSubmit?: HookMatcher[]
|
||||
Stop?: HookMatcher[]
|
||||
PreCompact?: HookMatcher[]
|
||||
}
|
||||
|
||||
export interface PreToolUseInput {
|
||||
@@ -82,6 +84,13 @@ export interface StopInput {
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export interface PreCompactInput {
|
||||
session_id: string
|
||||
cwd: string
|
||||
hook_event_name: "PreCompact"
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export type PermissionDecision = "allow" | "deny" | "ask"
|
||||
|
||||
/**
|
||||
@@ -166,6 +175,16 @@ export interface StopOutput {
|
||||
inject_prompt?: string
|
||||
}
|
||||
|
||||
export interface PreCompactOutput extends HookCommonOutput {
|
||||
/** Additional context to inject into compaction prompt */
|
||||
context?: string[]
|
||||
hookSpecificOutput?: {
|
||||
hookEventName: "PreCompact"
|
||||
/** Additional context strings to inject */
|
||||
additionalContext?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudeCodeContent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
||||
|
||||
@@ -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
|
||||
|
||||
19
src/hooks/non-interactive-env/detector.ts
Normal file
19
src/hooks/non-interactive-env/detector.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function isNonInteractive(): boolean {
|
||||
if (process.env.CI === "true" || process.env.CI === "1") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (process.env.OPENCODE_RUN === "true" || process.env.OPENCODE_NON_INTERACTIVE === "true") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_ACTIONS === "true") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (process.stdout.isTTY !== true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./consta
|
||||
import { log } from "../../shared"
|
||||
|
||||
export * from "./constants"
|
||||
export * from "./detector"
|
||||
export * from "./types"
|
||||
|
||||
const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
349
src/hooks/session-notification.test.ts
Normal file
349
src/hooks/session-notification.test.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
|
||||
import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string) => {
|
||||
// #given - track notification commands (osascript, notify-send, powershell)
|
||||
const cmdStr = typeof cmd === "string" ? cmd : cmd.join("")
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// #given - reset state before each test
|
||||
notificationCalls = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// #given - cleanup after each test
|
||||
subagentSessions.clear()
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
test("should not trigger notification for subagent session", async () => {
|
||||
// #given - a subagent session exists
|
||||
const subagentSessionID = "subagent-123"
|
||||
subagentSessions.add(subagentSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 0,
|
||||
})
|
||||
|
||||
// #when - subagent session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for any pending timers
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// #then - notification should NOT be sent
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not trigger notification when mainSessionID is set and session is not main", async () => {
|
||||
// #given - main session is set, but a different session goes idle
|
||||
const mainSessionID = "main-123"
|
||||
const otherSessionID = "other-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 0,
|
||||
})
|
||||
|
||||
// #when - non-main session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: otherSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for any pending timers
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// #then - notification should NOT be sent
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should trigger notification for main session when idle", async () => {
|
||||
// #given - main session is set
|
||||
const mainSessionID = "main-789"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 10,
|
||||
skipIfIncompleteTodos: false,
|
||||
})
|
||||
|
||||
// #when - main session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for idle confirmation delay + buffer
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// #then - notification should be sent
|
||||
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("should skip notification for subagent even when mainSessionID is set", async () => {
|
||||
// #given - both mainSessionID and subagent session exist
|
||||
const mainSessionID = "main-999"
|
||||
const subagentSessionID = "subagent-888"
|
||||
setMainSession(mainSessionID)
|
||||
subagentSessions.add(subagentSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 0,
|
||||
})
|
||||
|
||||
// #when - subagent session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for any pending timers
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// #then - notification should NOT be sent (subagent check takes priority)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should handle subagentSessions and mainSessionID checks in correct order", async () => {
|
||||
// #given - main session and subagent session exist
|
||||
const mainSessionID = "main-111"
|
||||
const subagentSessionID = "subagent-222"
|
||||
const unknownSessionID = "unknown-333"
|
||||
setMainSession(mainSessionID)
|
||||
subagentSessions.add(subagentSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 0,
|
||||
})
|
||||
|
||||
// #when - subagent session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: subagentSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - unknown session goes idle (not main, not in subagentSessions)
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: unknownSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for any pending timers
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// #then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel pending notification on session activity", async () => {
|
||||
// #given - main session is set
|
||||
const mainSessionID = "main-cancel"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 100, // Long delay
|
||||
skipIfIncompleteTodos: false,
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - activity happens before delay completes
|
||||
await hook({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for original delay to pass
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
|
||||
// #then - notification should NOT be sent (cancelled by activity)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should handle session.created event without notification", async () => {
|
||||
// #given - a new session is created
|
||||
const hook = createSessionNotification(createMockPluginInput(), {})
|
||||
|
||||
// #when - session.created event fires
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: {
|
||||
info: { id: "new-session", title: "Test Session" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for any pending timers
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// #then - no notification should be triggered
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should handle session.deleted event and cleanup state", async () => {
|
||||
// #given - a session exists
|
||||
const hook = createSessionNotification(createMockPluginInput(), {})
|
||||
|
||||
// #when - session.deleted event fires
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: {
|
||||
info: { id: "deleted-session" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for any pending timers
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// #then - no notification should be triggered
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should mark session activity on message.updated event", async () => {
|
||||
// #given - main session is set
|
||||
const mainSessionID = "main-message"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 50,
|
||||
skipIfIncompleteTodos: false,
|
||||
})
|
||||
|
||||
// #when - session goes idle, then message.updated fires
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: mainSessionID, role: "user", finish: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for idle delay to pass
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// #then - notification should NOT be sent (activity cancelled it)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should mark session activity on tool.execute.before event", async () => {
|
||||
// #given - main session is set
|
||||
const mainSessionID = "main-tool"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 50,
|
||||
skipIfIncompleteTodos: false,
|
||||
})
|
||||
|
||||
// #when - session goes idle, then tool.execute.before fires
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for idle delay to pass
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// #then - notification should NOT be sent (activity cancelled it)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not send duplicate notification for same session", async () => {
|
||||
// #given - main session is set
|
||||
const mainSessionID = "main-dup"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 10,
|
||||
skipIfIncompleteTodos: false,
|
||||
})
|
||||
|
||||
// #when - session goes idle twice
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for first notification
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for second potential notification
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// #then - only one notification should be sent
|
||||
expect(notificationCalls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { platform } from "os"
|
||||
import { subagentSessions } from "../features/claude-code-session-state"
|
||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
@@ -243,6 +243,10 @@ export function createSessionNotification(
|
||||
|
||||
if (subagentSessions.has(sessionID)) return
|
||||
|
||||
// Only trigger notifications for the main session (not subagent sessions)
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) return
|
||||
|
||||
if (notifiedSessions.has(sessionID)) return
|
||||
if (pendingTimers.has(sessionID)) return
|
||||
if (executingNotifications.has(sessionID)) return
|
||||
|
||||
@@ -6,10 +6,16 @@ import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
} from "../features/hook-message-injector"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { log } from "../shared/logger"
|
||||
import { isNonInteractive } from "./non-interactive-env/detector"
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
|
||||
export interface TodoContinuationEnforcerOptions {
|
||||
backgroundManager?: BackgroundManager
|
||||
}
|
||||
|
||||
export interface TodoContinuationEnforcer {
|
||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
markRecovering: (sessionID: string) => void
|
||||
@@ -70,12 +76,17 @@ interface CountdownState {
|
||||
intervalId: ReturnType<typeof setInterval>
|
||||
}
|
||||
|
||||
export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
|
||||
export function createTodoContinuationEnforcer(
|
||||
ctx: PluginInput,
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
): TodoContinuationEnforcer {
|
||||
const { backgroundManager } = options
|
||||
const remindedSessions = new Set<string>()
|
||||
const interruptedSessions = new Set<string>()
|
||||
const errorSessions = new Set<string>()
|
||||
const recoveringSessions = new Set<string>()
|
||||
const pendingCountdowns = new Map<string, CountdownState>()
|
||||
const preemptivelyInjectedSessions = new Set<string>()
|
||||
|
||||
const markRecovering = (sessionID: string): void => {
|
||||
recoveringSessions.add(sessionID)
|
||||
@@ -204,6 +215,30 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
|
||||
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 {
|
||||
@@ -226,7 +261,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
|
||||
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]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -269,7 +304,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
const finish = info?.finish as boolean | undefined
|
||||
const finish = info?.finish as string | undefined
|
||||
log(`[${HOOK_NAME}] message.updated received`, { sessionID, role, finish })
|
||||
|
||||
if (sessionID && role === "user") {
|
||||
@@ -279,13 +314,75 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
|
||||
pendingCountdowns.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID })
|
||||
}
|
||||
// Allow new continuation after user sends another message
|
||||
remindedSessions.delete(sessionID)
|
||||
preemptivelyInjectedSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
if (sessionID && role === "assistant" && finish) {
|
||||
remindedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Cleared reminded state on assistant finish`, { sessionID })
|
||||
preemptivelyInjectedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Cleared reminded/preemptive state on assistant finish`, { sessionID })
|
||||
|
||||
const isTerminalFinish = finish && !["tool-calls", "unknown"].includes(finish)
|
||||
if (isTerminalFinish && isNonInteractive()) {
|
||||
log(`[${HOOK_NAME}] Terminal finish in non-interactive mode`, { sessionID, finish })
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) {
|
||||
log(`[${HOOK_NAME}] Skipped preemptive: not main session`, { sessionID, mainSessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (preemptivelyInjectedSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped preemptive: already injected`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (recoveringSessions.has(sessionID) || errorSessions.has(sessionID) || interruptedSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped preemptive: session in error/recovery state`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
|
||||
: false
|
||||
|
||||
let hasIncompleteTodos = false
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
const todos = (response.data ?? response) as Todo[]
|
||||
hasIncompleteTodos = todos?.some((t) => t.status !== "completed" && t.status !== "cancelled") ?? false
|
||||
} catch {
|
||||
log(`[${HOOK_NAME}] Failed to fetch todos for preemptive check`, { sessionID })
|
||||
}
|
||||
|
||||
if (hasRunningBgTasks || hasIncompleteTodos) {
|
||||
log(`[${HOOK_NAME}] Preemptive injection needed`, { sessionID, hasRunningBgTasks, hasIncompleteTodos })
|
||||
preemptivelyInjectedSessions.add(sessionID)
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const prompt = hasRunningBgTasks
|
||||
? "[SYSTEM] Background tasks are still running. Wait for their completion before proceeding."
|
||||
: CONTINUATION_PROMPT
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
log(`[${HOOK_NAME}] Preemptive injection successful`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Preemptive injection failed`, { sessionID, error: String(err) })
|
||||
preemptivelyInjectedSessions.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +393,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuati
|
||||
interruptedSessions.delete(sessionInfo.id)
|
||||
errorSessions.delete(sessionInfo.id)
|
||||
recoveringSessions.delete(sessionInfo.id)
|
||||
preemptivelyInjectedSessions.delete(sessionInfo.id)
|
||||
|
||||
const countdown = pendingCountdowns.get(sessionInfo.id)
|
||||
if (countdown) {
|
||||
|
||||
@@ -24,7 +24,7 @@ interface ToolOutputTruncatorOptions {
|
||||
|
||||
export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOutputTruncatorOptions) {
|
||||
const truncator = createDynamicTruncator(ctx)
|
||||
const truncateAll = options?.experimental?.truncate_all_tool_outputs ?? false
|
||||
const truncateAll = options?.experimental?.truncate_all_tool_outputs ?? true
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
|
||||
119
src/index.ts
119
src/index.ts
@@ -116,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");
|
||||
@@ -130,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;
|
||||
}
|
||||
|
||||
@@ -140,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;
|
||||
}
|
||||
@@ -174,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(),
|
||||
@@ -190,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);
|
||||
}
|
||||
@@ -209,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);
|
||||
|
||||
@@ -227,9 +254,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
? createTodoContinuationEnforcer(ctx)
|
||||
: null;
|
||||
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
||||
? createContextWindowMonitorHook(ctx)
|
||||
: null;
|
||||
@@ -240,13 +264,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createSessionNotification(ctx)
|
||||
: null;
|
||||
|
||||
// Wire up recovery state tracking between session-recovery and todo-continuation-enforcer
|
||||
// This prevents the continuation enforcer from injecting prompts during active recovery
|
||||
if (sessionRecovery && todoContinuationEnforcer) {
|
||||
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
|
||||
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
|
||||
}
|
||||
|
||||
const commentChecker = isHookEnabled("comment-checker")
|
||||
? createCommentCheckerHooks()
|
||||
: null;
|
||||
@@ -305,6 +322,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
? createTodoContinuationEnforcer(ctx, { backgroundManager })
|
||||
: null;
|
||||
|
||||
if (sessionRecovery && todoContinuationEnforcer) {
|
||||
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
|
||||
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
|
||||
}
|
||||
|
||||
const backgroundNotificationHook = isHookEnabled("background-notification")
|
||||
? createBackgroundNotificationHook(backgroundManager)
|
||||
: null;
|
||||
@@ -380,34 +406,67 @@ 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?.default_builder_enabled ?? false;
|
||||
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
|
||||
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
||||
// TODO: When OpenCode releases `default_agent` config option (PR #5313),
|
||||
// use `config.default_agent = "Sisyphus"` instead of demoting build/plan.
|
||||
// Tracking: https://github.com/sst/opencode/pull/5313
|
||||
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
|
||||
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const plannerSisyphusBase = {
|
||||
...planConfigWithoutName,
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
color: config.agent?.plan?.color ?? "#6495ED",
|
||||
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
};
|
||||
|
||||
const plannerSisyphusConfig = plannerSisyphusOverride
|
||||
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
|
||||
: plannerSisyphusBase;
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
|
||||
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...buildConfigWithoutName,
|
||||
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
|
||||
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
|
||||
: openCodeBuilderBase;
|
||||
}
|
||||
|
||||
if (plannerEnabled) {
|
||||
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
|
||||
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const plannerSisyphusBase = {
|
||||
...planConfigWithoutName,
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
color: config.agent?.plan?.color ?? "#6495ED",
|
||||
};
|
||||
|
||||
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
|
||||
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
|
||||
const filteredConfigAgents = config.agent ?
|
||||
Object.fromEntries(
|
||||
Object.entries(config.agent).filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
) : {};
|
||||
|
||||
config.agent = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
"Planner-Sisyphus": plannerSisyphusConfig,
|
||||
...agentConfig,
|
||||
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...config.agent,
|
||||
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
|
||||
// Demote build/plan to subagent mode when replaced
|
||||
build: { ...config.agent?.build, mode: "subagent" },
|
||||
plan: { ...config.agent?.plan, mode: "subagent" },
|
||||
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
|
||||
};
|
||||
} else {
|
||||
config.agent = {
|
||||
|
||||
29
src/shared/data-path.ts
Normal file
29
src/shared/data-path.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
|
||||
/**
|
||||
* Returns the user-level data directory based on the OS.
|
||||
* - Linux/macOS: XDG_DATA_HOME or ~/.local/share
|
||||
* - Windows: %LOCALAPPDATA%
|
||||
*
|
||||
* This follows XDG Base Directory specification on Unix systems
|
||||
* and Windows conventions on Windows.
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
// Windows: Use %LOCALAPPDATA% (e.g., C:\Users\Username\AppData\Local)
|
||||
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local")
|
||||
}
|
||||
|
||||
// Unix: Use XDG_DATA_HOME or fallback to ~/.local/share
|
||||
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenCode storage directory path.
|
||||
* - Linux/macOS: ~/.local/share/opencode/storage
|
||||
* - Windows: %LOCALAPPDATA%\opencode\storage
|
||||
*/
|
||||
export function getOpenCodeStorageDir(): string {
|
||||
return path.join(getDataDir(), "opencode", "storage")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,4 +11,5 @@ export * from "./deep-merge"
|
||||
export * from "./file-utils"
|
||||
export * from "./dynamic-truncator"
|
||||
export * from "./config-path"
|
||||
export * from "./data-path"
|
||||
export * from "./config-errors"
|
||||
|
||||
80
src/tools/AGENTS.md
Normal file
80
src/tools/AGENTS.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# TOOLS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Custom tools extending agent capabilities: LSP integration (11 tools), AST-aware code search/replace, file operations with timeouts, background task management.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
tools/
|
||||
├── ast-grep/ # AST-aware code search/replace (25 languages)
|
||||
│ ├── cli.ts # @ast-grep/cli subprocess
|
||||
│ ├── napi.ts # @ast-grep/napi native binding (preferred)
|
||||
│ ├── constants.ts, types.ts, tools.ts, utils.ts
|
||||
├── background-task/ # Async agent task management
|
||||
├── call-omo-agent/ # Spawn explore/librarian agents
|
||||
├── glob/ # File pattern matching (timeout-safe)
|
||||
├── grep/ # Content search (timeout-safe)
|
||||
├── interactive-bash/ # Tmux session management
|
||||
├── look-at/ # Multimodal analysis (PDF, images)
|
||||
├── lsp/ # 11 LSP tools
|
||||
│ ├── client.ts # LSP connection lifecycle
|
||||
│ ├── config.ts # Server configurations
|
||||
│ ├── tools.ts # Tool implementations
|
||||
│ └── types.ts
|
||||
├── session-manager/ # OpenCode session file management
|
||||
│ ├── constants.ts # Storage paths, descriptions
|
||||
│ ├── types.ts # Session data interfaces
|
||||
│ ├── storage.ts # File I/O operations
|
||||
│ ├── utils.ts # Formatting, filtering
|
||||
│ └── tools.ts # Tool implementations
|
||||
├── slashcommand/ # Slash command execution
|
||||
└── index.ts # builtinTools export
|
||||
```
|
||||
|
||||
## TOOL CATEGORIES
|
||||
|
||||
| Category | Tools | Purpose |
|
||||
|----------|-------|---------|
|
||||
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve | IDE-like code intelligence |
|
||||
| AST | ast_grep_search, ast_grep_replace | Pattern-based code search/replace |
|
||||
| File Search | grep, glob | Content and file pattern matching |
|
||||
| Session | session_list, session_read, session_search, session_info | OpenCode session file management |
|
||||
| Background | background_task, background_output, background_cancel | Async agent orchestration |
|
||||
| Multimodal | look_at | PDF/image analysis via Gemini |
|
||||
| Terminal | interactive_bash | Tmux session control |
|
||||
| Commands | slashcommand | Execute slash commands |
|
||||
| Agents | call_omo_agent | Spawn explore/librarian |
|
||||
|
||||
## HOW TO ADD A TOOL
|
||||
|
||||
1. Create directory: `src/tools/my-tool/`
|
||||
2. Create files:
|
||||
- `constants.ts`: `TOOL_NAME`, `TOOL_DESCRIPTION`
|
||||
- `types.ts`: Parameter/result interfaces
|
||||
- `tools.ts`: Tool implementation (returns OpenCode tool object)
|
||||
- `index.ts`: Barrel export
|
||||
- `utils.ts`: Helpers (optional)
|
||||
3. Add to `builtinTools` in `src/tools/index.ts`
|
||||
|
||||
## LSP SPECIFICS
|
||||
|
||||
- **Client lifecycle**: Lazy init on first use, auto-shutdown on idle
|
||||
- **Config priority**: opencode.json > oh-my-opencode.json > defaults
|
||||
- **Supported servers**: typescript-language-server, pylsp, gopls, rust-analyzer, etc.
|
||||
- **Custom servers**: Add via `lsp` config in oh-my-opencode.json
|
||||
|
||||
## AST-GREP SPECIFICS
|
||||
|
||||
- **Meta-variables**: `$VAR` (single node), `$$$` (multiple nodes)
|
||||
- **Languages**: 25 supported (typescript, tsx, python, rust, go, etc.)
|
||||
- **Binding**: Prefers @ast-grep/napi (native), falls back to @ast-grep/cli
|
||||
- **Pattern must be valid AST**: `export async function $NAME($$$) { $$$ }` not fragments
|
||||
|
||||
## ANTI-PATTERNS (TOOLS)
|
||||
|
||||
- **No timeout**: Always use timeout for file operations (default 60s)
|
||||
- **Blocking main thread**: Use async/await, never sync file ops
|
||||
- **Ignoring LSP errors**: Gracefully handle server not found/crashed
|
||||
- **Raw subprocess for ast-grep**: Prefer napi binding for performance
|
||||
@@ -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.
|
||||
|
||||
@@ -21,6 +21,13 @@ import { grep } from "./grep"
|
||||
import { glob } from "./glob"
|
||||
import { slashcommand } from "./slashcommand"
|
||||
|
||||
import {
|
||||
session_list,
|
||||
session_read,
|
||||
session_search,
|
||||
session_info,
|
||||
} from "./session-manager"
|
||||
|
||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||
export { getTmuxPath } from "./interactive-bash/utils"
|
||||
|
||||
@@ -63,4 +70,8 @@ export const builtinTools = {
|
||||
grep,
|
||||
glob,
|
||||
slashcommand,
|
||||
session_list,
|
||||
session_read,
|
||||
session_search,
|
||||
session_info,
|
||||
}
|
||||
|
||||
96
src/tools/session-manager/constants.ts
Normal file
96
src/tools/session-manager/constants.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
export const TODO_DIR = join(homedir(), ".claude", "todos")
|
||||
export const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
|
||||
export const SESSION_LIST_DESCRIPTION = `List all OpenCode sessions with optional filtering.
|
||||
|
||||
Returns a list of available session IDs with metadata including message count, date range, and agents used.
|
||||
|
||||
Arguments:
|
||||
- limit (optional): Maximum number of sessions to return
|
||||
- from_date (optional): Filter sessions from this date (ISO 8601 format)
|
||||
- to_date (optional): Filter sessions until this date (ISO 8601 format)
|
||||
|
||||
Example output:
|
||||
| Session ID | Messages | First | Last | Agents |
|
||||
|------------|----------|-------|------|--------|
|
||||
| ses_abc123 | 45 | 2025-12-20 | 2025-12-24 | build, oracle |
|
||||
| ses_def456 | 12 | 2025-12-19 | 2025-12-19 | build |`
|
||||
|
||||
export const SESSION_READ_DESCRIPTION = `Read messages and history from an OpenCode session.
|
||||
|
||||
Returns a formatted view of session messages with role, timestamp, and content. Optionally includes todos and transcript data.
|
||||
|
||||
Arguments:
|
||||
- session_id (required): Session ID to read
|
||||
- include_todos (optional): Include todo list if available (default: false)
|
||||
- include_transcript (optional): Include transcript log if available (default: false)
|
||||
- limit (optional): Maximum number of messages to return (default: all)
|
||||
|
||||
Example output:
|
||||
Session: ses_abc123
|
||||
Messages: 45
|
||||
Date Range: 2025-12-20 to 2025-12-24
|
||||
|
||||
[Message 1] user (2025-12-20 10:30:00)
|
||||
Hello, can you help me with...
|
||||
|
||||
[Message 2] assistant (2025-12-20 10:30:15)
|
||||
Of course! Let me help you with...`
|
||||
|
||||
export const SESSION_SEARCH_DESCRIPTION = `Search for content within OpenCode session messages.
|
||||
|
||||
Performs full-text search across session messages and returns matching excerpts with context.
|
||||
|
||||
Arguments:
|
||||
- query (required): Search query string
|
||||
- session_id (optional): Search within specific session only (default: all sessions)
|
||||
- case_sensitive (optional): Case-sensitive search (default: false)
|
||||
- limit (optional): Maximum number of results to return (default: 20)
|
||||
|
||||
Example output:
|
||||
Found 3 matches across 2 sessions:
|
||||
|
||||
[ses_abc123] Message msg_001 (user)
|
||||
...implement the **session manager** tool...
|
||||
|
||||
[ses_abc123] Message msg_005 (assistant)
|
||||
...I'll create a **session manager** with full search...
|
||||
|
||||
[ses_def456] Message msg_012 (user)
|
||||
...use the **session manager** to find...`
|
||||
|
||||
export const SESSION_INFO_DESCRIPTION = `Get metadata and statistics about an OpenCode session.
|
||||
|
||||
Returns detailed information about a session including message count, date range, agents used, and available data sources.
|
||||
|
||||
Arguments:
|
||||
- session_id (required): Session ID to inspect
|
||||
|
||||
Example output:
|
||||
Session ID: ses_abc123
|
||||
Messages: 45
|
||||
Date Range: 2025-12-20 10:30:00 to 2025-12-24 15:45:30
|
||||
Duration: 4 days, 5 hours
|
||||
Agents Used: build, oracle, librarian
|
||||
Has Todos: Yes (12 items, 8 completed)
|
||||
Has Transcript: Yes (234 entries)`
|
||||
|
||||
export const SESSION_DELETE_DESCRIPTION = `Delete an OpenCode session and all associated data.
|
||||
|
||||
Removes session messages, parts, todos, and transcript. This operation cannot be undone.
|
||||
|
||||
Arguments:
|
||||
- session_id (required): Session ID to delete
|
||||
- confirm (required): Must be true to confirm deletion
|
||||
|
||||
Example:
|
||||
session_delete(session_id="ses_abc123", confirm=true)
|
||||
Successfully deleted session ses_abc123`
|
||||
|
||||
export const TOOL_NAME_PREFIX = "session_"
|
||||
3
src/tools/session-manager/index.ts
Normal file
3
src/tools/session-manager/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./tools"
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
153
src/tools/session-manager/storage.test.ts
Normal file
153
src/tools/session-manager/storage.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "omo-test-session-manager")
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
|
||||
const TEST_PART_STORAGE = join(TEST_DIR, "part")
|
||||
const TEST_TODO_DIR = join(TEST_DIR, "todos")
|
||||
const TEST_TRANSCRIPT_DIR = join(TEST_DIR, "transcripts")
|
||||
|
||||
mock.module("./constants", () => ({
|
||||
OPENCODE_STORAGE: TEST_DIR,
|
||||
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||
PART_STORAGE: TEST_PART_STORAGE,
|
||||
TODO_DIR: TEST_TODO_DIR,
|
||||
TRANSCRIPT_DIR: TEST_TRANSCRIPT_DIR,
|
||||
SESSION_LIST_DESCRIPTION: "test",
|
||||
SESSION_READ_DESCRIPTION: "test",
|
||||
SESSION_SEARCH_DESCRIPTION: "test",
|
||||
SESSION_INFO_DESCRIPTION: "test",
|
||||
SESSION_DELETE_DESCRIPTION: "test",
|
||||
TOOL_NAME_PREFIX: "session_",
|
||||
}))
|
||||
|
||||
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("./storage")
|
||||
|
||||
describe("session-manager storage", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_PART_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_TODO_DIR, { recursive: true })
|
||||
mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("getAllSessions returns empty array when no sessions exist", () => {
|
||||
const sessions = getAllSessions()
|
||||
|
||||
expect(Array.isArray(sessions)).toBe(true)
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
test("getMessageDir finds session in direct path", () => {
|
||||
const sessionID = "ses_test123"
|
||||
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(sessionPath, { recursive: true })
|
||||
writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001", role: "user" }))
|
||||
|
||||
const result = getMessageDir(sessionID)
|
||||
|
||||
expect(result).toBe(sessionPath)
|
||||
})
|
||||
|
||||
test("sessionExists returns false for non-existent session", () => {
|
||||
const exists = sessionExists("ses_nonexistent")
|
||||
|
||||
expect(exists).toBe(false)
|
||||
})
|
||||
|
||||
test("sessionExists returns true for existing session", () => {
|
||||
const sessionID = "ses_exists"
|
||||
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(sessionPath, { recursive: true })
|
||||
writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001" }))
|
||||
|
||||
const exists = sessionExists(sessionID)
|
||||
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test("readSessionMessages returns empty array for non-existent session", () => {
|
||||
const messages = readSessionMessages("ses_nonexistent")
|
||||
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
test("readSessionMessages sorts messages by timestamp", () => {
|
||||
const sessionID = "ses_test123"
|
||||
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(sessionPath, { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(sessionPath, "msg_002.json"),
|
||||
JSON.stringify({ id: "msg_002", role: "assistant", time: { created: 2000 } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(sessionPath, "msg_001.json"),
|
||||
JSON.stringify({ id: "msg_001", role: "user", time: { created: 1000 } })
|
||||
)
|
||||
|
||||
const messages = readSessionMessages(sessionID)
|
||||
|
||||
expect(messages.length).toBe(2)
|
||||
expect(messages[0].id).toBe("msg_001")
|
||||
expect(messages[1].id).toBe("msg_002")
|
||||
})
|
||||
|
||||
test("readSessionTodos returns empty array when no todos exist", () => {
|
||||
const todos = readSessionTodos("ses_nonexistent")
|
||||
|
||||
expect(todos).toEqual([])
|
||||
})
|
||||
|
||||
test("getSessionInfo returns null for non-existent session", () => {
|
||||
const info = getSessionInfo("ses_nonexistent")
|
||||
|
||||
expect(info).toBeNull()
|
||||
})
|
||||
|
||||
test("getSessionInfo aggregates session metadata correctly", () => {
|
||||
const sessionID = "ses_test123"
|
||||
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(sessionPath, { recursive: true })
|
||||
|
||||
const now = Date.now()
|
||||
writeFileSync(
|
||||
join(sessionPath, "msg_001.json"),
|
||||
JSON.stringify({
|
||||
id: "msg_001",
|
||||
role: "user",
|
||||
agent: "build",
|
||||
time: { created: now - 10000 },
|
||||
})
|
||||
)
|
||||
writeFileSync(
|
||||
join(sessionPath, "msg_002.json"),
|
||||
JSON.stringify({
|
||||
id: "msg_002",
|
||||
role: "assistant",
|
||||
agent: "oracle",
|
||||
time: { created: now },
|
||||
})
|
||||
)
|
||||
|
||||
const info = getSessionInfo(sessionID)
|
||||
|
||||
expect(info).not.toBeNull()
|
||||
expect(info?.id).toBe(sessionID)
|
||||
expect(info?.message_count).toBe(2)
|
||||
expect(info?.agents_used).toContain("build")
|
||||
expect(info?.agents_used).toContain("oracle")
|
||||
})
|
||||
})
|
||||
176
src/tools/session-manager/storage.ts
Normal file
176
src/tools/session-manager/storage.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
|
||||
import type { SessionMessage, SessionInfo, TodoItem } from "./types"
|
||||
|
||||
export function getAllSessions(): string[] {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return []
|
||||
|
||||
const sessions: string[] = []
|
||||
|
||||
function scanDirectory(dir: string): void {
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const sessionPath = join(dir, entry.name)
|
||||
const files = readdirSync(sessionPath)
|
||||
if (files.some((f) => f.endsWith(".json"))) {
|
||||
sessions.push(entry.name)
|
||||
} else {
|
||||
scanDirectory(sessionPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
scanDirectory(MESSAGE_STORAGE)
|
||||
return [...new Set(sessions)]
|
||||
}
|
||||
|
||||
export function getMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return ""
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) {
|
||||
return directPath
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function sessionExists(sessionID: string): boolean {
|
||||
return getMessageDir(sessionID) !== ""
|
||||
}
|
||||
|
||||
export function readSessionMessages(sessionID: string): SessionMessage[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messages: SessionMessage[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const meta = JSON.parse(content)
|
||||
|
||||
const parts = readParts(meta.id)
|
||||
|
||||
messages.push({
|
||||
id: meta.id,
|
||||
role: meta.role,
|
||||
agent: meta.agent,
|
||||
time: meta.time,
|
||||
parts,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return messages.sort((a, b) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
function readParts(messageID: string): Array<{ id: string; type: string; [key: string]: unknown }> {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return []
|
||||
|
||||
const parts: Array<{ id: string; type: string; [key: string]: unknown }> = []
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(partDir, file), "utf-8")
|
||||
parts.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return parts.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export function readSessionTodos(sessionID: string): TodoItem[] {
|
||||
if (!existsSync(TODO_DIR)) return []
|
||||
|
||||
const todoFiles = readdirSync(TODO_DIR).filter((f) => f.includes(sessionID) && f.endsWith(".json"))
|
||||
|
||||
for (const file of todoFiles) {
|
||||
try {
|
||||
const content = readFileSync(join(TODO_DIR, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => ({
|
||||
id: item.id || "",
|
||||
content: item.content || "",
|
||||
status: item.status || "pending",
|
||||
priority: item.priority,
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function readSessionTranscript(sessionID: string): number {
|
||||
if (!existsSync(TRANSCRIPT_DIR)) return 0
|
||||
|
||||
const transcriptFile = join(TRANSCRIPT_DIR, `${sessionID}.jsonl`)
|
||||
if (!existsSync(transcriptFile)) return 0
|
||||
|
||||
try {
|
||||
const content = readFileSync(transcriptFile, "utf-8")
|
||||
return content.trim().split("\n").filter(Boolean).length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionInfo(sessionID: string): SessionInfo | null {
|
||||
const messages = readSessionMessages(sessionID)
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const agentsUsed = new Set<string>()
|
||||
let firstMessage: Date | undefined
|
||||
let lastMessage: Date | undefined
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.agent) agentsUsed.add(msg.agent)
|
||||
if (msg.time?.created) {
|
||||
const date = new Date(msg.time.created)
|
||||
if (!firstMessage || date < firstMessage) firstMessage = date
|
||||
if (!lastMessage || date > lastMessage) lastMessage = date
|
||||
}
|
||||
}
|
||||
|
||||
const todos = readSessionTodos(sessionID)
|
||||
const transcriptEntries = readSessionTranscript(sessionID)
|
||||
|
||||
return {
|
||||
id: sessionID,
|
||||
message_count: messages.length,
|
||||
first_message: firstMessage,
|
||||
last_message: lastMessage,
|
||||
agents_used: Array.from(agentsUsed),
|
||||
has_todos: todos.length > 0,
|
||||
has_transcript: transcriptEntries > 0,
|
||||
todos,
|
||||
transcript_entries: transcriptEntries,
|
||||
}
|
||||
}
|
||||
103
src/tools/session-manager/tools.test.ts
Normal file
103
src/tools/session-manager/tools.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { session_list, session_read, session_search, session_info } from "./tools"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
|
||||
const mockContext: ToolContext = {
|
||||
sessionID: "test-session",
|
||||
messageID: "test-message",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("session-manager tools", () => {
|
||||
test("session_list executes without error", async () => {
|
||||
const result = await session_list.execute({}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_list respects limit parameter", async () => {
|
||||
const result = await session_list.execute({ limit: 5 }, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_list filters by date range", async () => {
|
||||
const result = await session_list.execute({
|
||||
from_date: "2025-12-01T00:00:00Z",
|
||||
to_date: "2025-12-31T23:59:59Z",
|
||||
}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_read handles non-existent session", async () => {
|
||||
const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext)
|
||||
|
||||
expect(result).toContain("not found")
|
||||
})
|
||||
|
||||
test("session_read executes with valid parameters", async () => {
|
||||
const result = await session_read.execute({
|
||||
session_id: "ses_test123",
|
||||
include_todos: true,
|
||||
include_transcript: true,
|
||||
}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_read respects limit parameter", async () => {
|
||||
const result = await session_read.execute({
|
||||
session_id: "ses_test123",
|
||||
limit: 10,
|
||||
}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_search executes without error", async () => {
|
||||
const result = await session_search.execute({ query: "test" }, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_search filters by session_id", async () => {
|
||||
const result = await session_search.execute({
|
||||
query: "test",
|
||||
session_id: "ses_test123",
|
||||
}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_search respects case_sensitive parameter", async () => {
|
||||
const result = await session_search.execute({
|
||||
query: "TEST",
|
||||
case_sensitive: true,
|
||||
}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_search respects limit parameter", async () => {
|
||||
const result = await session_search.execute({
|
||||
query: "test",
|
||||
limit: 5,
|
||||
}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_info handles non-existent session", async () => {
|
||||
const result = await session_info.execute({ session_id: "ses_nonexistent" }, mockContext)
|
||||
|
||||
expect(result).toContain("not found")
|
||||
})
|
||||
|
||||
test("session_info executes with valid session", async () => {
|
||||
const result = await session_info.execute({ session_id: "ses_test123" }, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
})
|
||||
108
src/tools/session-manager/tools.ts
Normal file
108
src/tools/session-manager/tools.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import {
|
||||
SESSION_LIST_DESCRIPTION,
|
||||
SESSION_READ_DESCRIPTION,
|
||||
SESSION_SEARCH_DESCRIPTION,
|
||||
SESSION_INFO_DESCRIPTION,
|
||||
} from "./constants"
|
||||
import { getAllSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage"
|
||||
import { filterSessionsByDate, formatSessionInfo, formatSessionList, formatSessionMessages, formatSearchResults, searchInSession } from "./utils"
|
||||
import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs } from "./types"
|
||||
|
||||
export const session_list = tool({
|
||||
description: SESSION_LIST_DESCRIPTION,
|
||||
args: {
|
||||
limit: tool.schema.number().optional().describe("Maximum number of sessions to return"),
|
||||
from_date: tool.schema.string().optional().describe("Filter sessions from this date (ISO 8601 format)"),
|
||||
to_date: tool.schema.string().optional().describe("Filter sessions until this date (ISO 8601 format)"),
|
||||
},
|
||||
execute: async (args: SessionListArgs, _context) => {
|
||||
try {
|
||||
let sessions = getAllSessions()
|
||||
|
||||
if (args.from_date || args.to_date) {
|
||||
sessions = filterSessionsByDate(sessions, args.from_date, args.to_date)
|
||||
}
|
||||
|
||||
if (args.limit && args.limit > 0) {
|
||||
sessions = sessions.slice(0, args.limit)
|
||||
}
|
||||
|
||||
return formatSessionList(sessions)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const session_read = tool({
|
||||
description: SESSION_READ_DESCRIPTION,
|
||||
args: {
|
||||
session_id: tool.schema.string().describe("Session ID to read"),
|
||||
include_todos: tool.schema.boolean().optional().describe("Include todo list if available (default: false)"),
|
||||
include_transcript: tool.schema.boolean().optional().describe("Include transcript log if available (default: false)"),
|
||||
limit: tool.schema.number().optional().describe("Maximum number of messages to return (default: all)"),
|
||||
},
|
||||
execute: async (args: SessionReadArgs, _context) => {
|
||||
try {
|
||||
if (!sessionExists(args.session_id)) {
|
||||
return `Session not found: ${args.session_id}`
|
||||
}
|
||||
|
||||
let messages = readSessionMessages(args.session_id)
|
||||
|
||||
if (args.limit && args.limit > 0) {
|
||||
messages = messages.slice(0, args.limit)
|
||||
}
|
||||
|
||||
const todos = args.include_todos ? readSessionTodos(args.session_id) : undefined
|
||||
|
||||
return formatSessionMessages(messages, args.include_todos, todos)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const session_search = tool({
|
||||
description: SESSION_SEARCH_DESCRIPTION,
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query string"),
|
||||
session_id: tool.schema.string().optional().describe("Search within specific session only (default: all sessions)"),
|
||||
case_sensitive: tool.schema.boolean().optional().describe("Case-sensitive search (default: false)"),
|
||||
limit: tool.schema.number().optional().describe("Maximum number of results to return (default: 20)"),
|
||||
},
|
||||
execute: async (args: SessionSearchArgs, _context) => {
|
||||
try {
|
||||
const sessions = args.session_id ? [args.session_id] : getAllSessions()
|
||||
|
||||
const allResults = sessions.flatMap((sid) => searchInSession(sid, args.query, args.case_sensitive))
|
||||
|
||||
const limited = args.limit && args.limit > 0 ? allResults.slice(0, args.limit) : allResults.slice(0, 20)
|
||||
|
||||
return formatSearchResults(limited)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const session_info = tool({
|
||||
description: SESSION_INFO_DESCRIPTION,
|
||||
args: {
|
||||
session_id: tool.schema.string().describe("Session ID to inspect"),
|
||||
},
|
||||
execute: async (args: SessionInfoArgs, _context) => {
|
||||
try {
|
||||
const info = getSessionInfo(args.session_id)
|
||||
|
||||
if (!info) {
|
||||
return `Session not found: ${args.session_id}`
|
||||
}
|
||||
|
||||
return formatSessionInfo(info)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
80
src/tools/session-manager/types.ts
Normal file
80
src/tools/session-manager/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export interface SessionMessage {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
agent?: string
|
||||
time?: {
|
||||
created: number
|
||||
updated?: number
|
||||
}
|
||||
parts: MessagePart[]
|
||||
}
|
||||
|
||||
export interface MessagePart {
|
||||
id: string
|
||||
type: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
tool?: string
|
||||
callID?: string
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
message_count: number
|
||||
first_message?: Date
|
||||
last_message?: Date
|
||||
agents_used: string[]
|
||||
has_todos: boolean
|
||||
has_transcript: boolean
|
||||
todos?: TodoItem[]
|
||||
transcript_entries?: number
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: string
|
||||
content: string
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
priority?: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
session_id: string
|
||||
message_id: string
|
||||
role: string
|
||||
excerpt: string
|
||||
match_count: number
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface SessionListArgs {
|
||||
limit?: number
|
||||
offset?: number
|
||||
from_date?: string
|
||||
to_date?: string
|
||||
}
|
||||
|
||||
export interface SessionReadArgs {
|
||||
session_id: string
|
||||
include_todos?: boolean
|
||||
include_transcript?: boolean
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SessionSearchArgs {
|
||||
query: string
|
||||
session_id?: string
|
||||
case_sensitive?: boolean
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SessionInfoArgs {
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SessionDeleteArgs {
|
||||
session_id: string
|
||||
confirm: boolean
|
||||
}
|
||||
118
src/tools/session-manager/utils.test.ts
Normal file
118
src/tools/session-manager/utils.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { formatSessionList, formatSessionMessages, formatSessionInfo, formatSearchResults, filterSessionsByDate, searchInSession } from "./utils"
|
||||
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
|
||||
|
||||
describe("session-manager utils", () => {
|
||||
test("formatSessionList handles empty array", () => {
|
||||
const result = formatSessionList([])
|
||||
|
||||
expect(result).toContain("No sessions found")
|
||||
})
|
||||
|
||||
test("formatSessionMessages handles empty array", () => {
|
||||
const result = formatSessionMessages([])
|
||||
|
||||
expect(result).toContain("No messages")
|
||||
})
|
||||
|
||||
test("formatSessionMessages includes message content", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
id: "msg_001",
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
parts: [{ id: "prt_001", type: "text", text: "Hello world" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = formatSessionMessages(messages)
|
||||
|
||||
expect(result).toContain("user")
|
||||
expect(result).toContain("Hello world")
|
||||
})
|
||||
|
||||
test("formatSessionMessages includes todos when requested", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
{
|
||||
id: "msg_001",
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
parts: [{ id: "prt_001", type: "text", text: "Test" }],
|
||||
},
|
||||
]
|
||||
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "completed" as const },
|
||||
{ id: "2", content: "Task 2", status: "pending" as const },
|
||||
]
|
||||
|
||||
const result = formatSessionMessages(messages, true, todos)
|
||||
|
||||
expect(result).toContain("Todos")
|
||||
expect(result).toContain("Task 1")
|
||||
expect(result).toContain("Task 2")
|
||||
})
|
||||
|
||||
test("formatSessionInfo includes all metadata", () => {
|
||||
const info: SessionInfo = {
|
||||
id: "ses_test123",
|
||||
message_count: 42,
|
||||
first_message: new Date("2025-12-20T10:00:00Z"),
|
||||
last_message: new Date("2025-12-24T15:00:00Z"),
|
||||
agents_used: ["build", "oracle"],
|
||||
has_todos: true,
|
||||
has_transcript: true,
|
||||
todos: [{ id: "1", content: "Test", status: "pending" }],
|
||||
transcript_entries: 123,
|
||||
}
|
||||
|
||||
const result = formatSessionInfo(info)
|
||||
|
||||
expect(result).toContain("ses_test123")
|
||||
expect(result).toContain("42")
|
||||
expect(result).toContain("build, oracle")
|
||||
expect(result).toContain("Duration")
|
||||
})
|
||||
|
||||
test("formatSearchResults handles empty array", () => {
|
||||
const result = formatSearchResults([])
|
||||
|
||||
expect(result).toContain("No matches")
|
||||
})
|
||||
|
||||
test("formatSearchResults formats matches correctly", () => {
|
||||
const results: SearchResult[] = [
|
||||
{
|
||||
session_id: "ses_test123",
|
||||
message_id: "msg_001",
|
||||
role: "user",
|
||||
excerpt: "...example text...",
|
||||
match_count: 3,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
const result = formatSearchResults(results)
|
||||
|
||||
expect(result).toContain("Found 1 matches")
|
||||
expect(result).toContain("ses_test123")
|
||||
expect(result).toContain("msg_001")
|
||||
expect(result).toContain("example text")
|
||||
expect(result).toContain("Matches: 3")
|
||||
})
|
||||
|
||||
test("filterSessionsByDate filters correctly", () => {
|
||||
const sessionIDs = ["ses_001", "ses_002", "ses_003"]
|
||||
|
||||
const result = filterSessionsByDate(sessionIDs)
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
})
|
||||
|
||||
test("searchInSession finds matches case-insensitively", () => {
|
||||
const results = searchInSession("ses_nonexistent", "test", false)
|
||||
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
expect(results.length).toBe(0)
|
||||
})
|
||||
})
|
||||
179
src/tools/session-manager/utils.ts
Normal file
179
src/tools/session-manager/utils.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
|
||||
import { getSessionInfo, readSessionMessages } from "./storage"
|
||||
|
||||
export function formatSessionList(sessionIDs: string[]): string {
|
||||
if (sessionIDs.length === 0) {
|
||||
return "No sessions found."
|
||||
}
|
||||
|
||||
const infos = sessionIDs.map((id) => getSessionInfo(id)).filter((info): info is SessionInfo => info !== null)
|
||||
|
||||
if (infos.length === 0) {
|
||||
return "No valid sessions found."
|
||||
}
|
||||
|
||||
const headers = ["Session ID", "Messages", "First", "Last", "Agents"]
|
||||
const rows = infos.map((info) => [
|
||||
info.id,
|
||||
info.message_count.toString(),
|
||||
info.first_message?.toISOString().split("T")[0] ?? "N/A",
|
||||
info.last_message?.toISOString().split("T")[0] ?? "N/A",
|
||||
info.agents_used.join(", ") || "none",
|
||||
])
|
||||
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
|
||||
|
||||
const formatRow = (cells: string[]): string => {
|
||||
return (
|
||||
"| " +
|
||||
cells
|
||||
.map((cell, i) => cell.padEnd(colWidths[i]))
|
||||
.join(" | ")
|
||||
.trim() +
|
||||
" |"
|
||||
)
|
||||
}
|
||||
|
||||
const separator = "|" + colWidths.map((w) => "-".repeat(w + 2)).join("|") + "|"
|
||||
|
||||
return [formatRow(headers), separator, ...rows.map(formatRow)].join("\n")
|
||||
}
|
||||
|
||||
export function formatSessionMessages(messages: SessionMessage[], includeTodos?: boolean, todos?: Array<{id: string; content: string; status: string}>): string {
|
||||
if (messages.length === 0) {
|
||||
return "No messages found in this session."
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : "Unknown time"
|
||||
const agent = msg.agent ? ` (${msg.agent})` : ""
|
||||
lines.push(`\n[${msg.role}${agent}] ${timestamp}`)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text" && part.text) {
|
||||
lines.push(part.text.trim())
|
||||
} else if (part.type === "thinking" && part.thinking) {
|
||||
lines.push(`[thinking] ${part.thinking.substring(0, 200)}...`)
|
||||
} else if ((part.type === "tool_use" || part.type === "tool") && part.tool) {
|
||||
const input = part.input ? JSON.stringify(part.input).substring(0, 100) : ""
|
||||
lines.push(`[tool: ${part.tool}] ${input}`)
|
||||
} else if (part.type === "tool_result") {
|
||||
const output = part.output ? part.output.substring(0, 200) : ""
|
||||
lines.push(`[tool result] ${output}...`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeTodos && todos && todos.length > 0) {
|
||||
lines.push("\n\n=== Todos ===")
|
||||
for (const todo of todos) {
|
||||
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○"
|
||||
lines.push(`${status} [${todo.status}] ${todo.content}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatSessionInfo(info: SessionInfo): string {
|
||||
const lines = [
|
||||
`Session ID: ${info.id}`,
|
||||
`Messages: ${info.message_count}`,
|
||||
`Date Range: ${info.first_message?.toISOString() ?? "N/A"} to ${info.last_message?.toISOString() ?? "N/A"}`,
|
||||
`Agents Used: ${info.agents_used.join(", ") || "none"}`,
|
||||
`Has Todos: ${info.has_todos ? `Yes (${info.todos?.length ?? 0} items)` : "No"}`,
|
||||
`Has Transcript: ${info.has_transcript ? `Yes (${info.transcript_entries} entries)` : "No"}`,
|
||||
]
|
||||
|
||||
if (info.first_message && info.last_message) {
|
||||
const duration = info.last_message.getTime() - info.first_message.getTime()
|
||||
const days = Math.floor(duration / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((duration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
if (days > 0 || hours > 0) {
|
||||
lines.push(`Duration: ${days} days, ${hours} hours`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatSearchResults(results: SearchResult[]): string {
|
||||
if (results.length === 0) {
|
||||
return "No matches found."
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${results.length} matches:\n`]
|
||||
|
||||
for (const result of results) {
|
||||
const timestamp = result.timestamp ? new Date(result.timestamp).toISOString() : ""
|
||||
lines.push(`[${result.session_id}] ${result.message_id} (${result.role}) ${timestamp}`)
|
||||
lines.push(` ${result.excerpt}`)
|
||||
lines.push(` Matches: ${result.match_count}\n`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function filterSessionsByDate(sessionIDs: string[], fromDate?: string, toDate?: string): string[] {
|
||||
if (!fromDate && !toDate) return sessionIDs
|
||||
|
||||
const from = fromDate ? new Date(fromDate) : null
|
||||
const to = toDate ? new Date(toDate) : null
|
||||
|
||||
return sessionIDs.filter((id) => {
|
||||
const info = getSessionInfo(id)
|
||||
if (!info || !info.last_message) return false
|
||||
|
||||
if (from && info.last_message < from) return false
|
||||
if (to && info.last_message > to) return false
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function searchInSession(sessionID: string, query: string, caseSensitive = false): SearchResult[] {
|
||||
const messages = readSessionMessages(sessionID)
|
||||
const results: SearchResult[] = []
|
||||
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase()
|
||||
|
||||
for (const msg of messages) {
|
||||
let matchCount = 0
|
||||
let excerpts: string[] = []
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text" && part.text) {
|
||||
const text = caseSensitive ? part.text : part.text.toLowerCase()
|
||||
const matches = text.split(searchQuery).length - 1
|
||||
if (matches > 0) {
|
||||
matchCount += matches
|
||||
|
||||
const index = text.indexOf(searchQuery)
|
||||
if (index !== -1) {
|
||||
const start = Math.max(0, index - 50)
|
||||
const end = Math.min(text.length, index + searchQuery.length + 50)
|
||||
let excerpt = part.text.substring(start, end)
|
||||
if (start > 0) excerpt = "..." + excerpt
|
||||
if (end < text.length) excerpt = excerpt + "..."
|
||||
excerpts.push(excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
results.push({
|
||||
session_id: sessionID,
|
||||
message_id: msg.id,
|
||||
role: msg.role,
|
||||
excerpt: excerpts[0] || "",
|
||||
match_count: matchCount,
|
||||
timestamp: msg.time?.created,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
Reference in New Issue
Block a user