Compare commits

..

10 Commits

Author SHA1 Message Date
github-actions[bot]
f6f1a7c9b3 release: v2.5.3 2025-12-25 09:54:49 +00:00
YeonGyu-Kim
1e274eabe6 fix(session-manager): include all constants exports in storage test mocks
Add missing mock exports (SESSION_LIST_DESCRIPTION, SESSION_READ_DESCRIPTION,
SESSION_SEARCH_DESCRIPTION, SESSION_INFO_DESCRIPTION, SESSION_DELETE_DESCRIPTION,
TOOL_NAME_PREFIX) to fix test failures when other test files import from constants.

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

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

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

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

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

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

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

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

Closes #132

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

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

---------

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

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

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

* docs: add OpenCode permalinks and update multilingual README files

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

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

---------

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

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

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

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

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

View File

@@ -71,7 +71,13 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
# Install OpenCode + oh-my-opencode + auth in single step
# Build local oh-my-opencode
- name: Build oh-my-opencode
run: |
bun install
bun run build
# Install OpenCode + configure local plugin + auth in single step
- name: Setup OpenCode with oh-my-opencode
env:
OPENCODE_AUTH_JSON: ${{ secrets.OPENCODE_AUTH_JSON }}
@@ -89,12 +95,19 @@ jobs:
bash /tmp/opencode-install.sh && break
fi
echo "Download corrupted, retrying in 5s..."
sleep 5
done
fi
opencode --version
bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=no --gemini=no
# Run local oh-my-opencode install (uses built dist)
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no
# Override plugin to use local file reference
OPENCODE_JSON=~/.config/opencode/opencode.json
REPO_PATH=$(pwd)
jq --arg path "file://$REPO_PATH/src/index.ts" '
.plugin = [.plugin[] | select(. != "oh-my-opencode")] + [$path]
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
OPENCODE_JSON=~/.config/opencode/opencode.json
jq --arg baseURL "$ANTHROPIC_BASE_URL" --arg apiKey "$ANTHROPIC_API_KEY" '
@@ -264,7 +277,7 @@ jobs:
--add-label "sisyphus: working" || true
fi
- name: Run OpenCode
- name: Run oh-my-opencode
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
@@ -289,7 +302,7 @@ jobs:
Then write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to \`${{ github.event.repository.default_branch }}\` branch."
opencode run "$PROMPT"
bun run dist/cli/index.js run "$PROMPT"
# Push changes (as sisyphus-dev-ai)
- name: Push changes

View File

@@ -714,24 +714,50 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
### Sisyphus Agent
有効時デフォルト、Sisyphus は2つのプライマリエージェントを追加し、内蔵エージェントをサブエージェントに降格させます:
有効時デフォルト、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
- **Planner-Sisyphus**: OpenCode の plan エージェントの全設定を実行時に継承 (description に "OhMyOpenCode version" を追加)
- **build**: サブエージェントに降格
- **plan**: サブエージェントに降格
- **Builder-Sisyphus**: OhMyOpenCode 強化版のビルドエージェント(デフォルトで無効)
- **Planner-Sisyphus**: OhMyOpenCode 強化版のプランエージェント(デフォルトで有効)
Sisyphus を無効化して元の build/plan エージェントを復元するには:
**設定オプション:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**例Builder-Sisyphus を有効化し、デフォルトのビルドモードも維持する:**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
}
}
```
これにより、Builder-Sisyphus とデフォルトのビルドエージェントの両方を同時に利用できます。
**例:すべての Sisyphus オーケストレーションを無効化:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
他のエージェント同様、Sisyphus と Planner-Sisyphus もカスタマイズ可能です:
他のエージェント同様、Sisyphus エージェントもカスタマイズ可能です:
```json
{
@@ -740,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"
}
@@ -747,9 +776,13 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
}
```
| オプション | デフォルト | 説明 |
|------------|------------|------|
| `disabled` | `false` | `true` の場合、Sisyphus エージェントを無効化し、元の build/plan をプライマリとして復元します。`false` (デフォルト) の場合、Sisyphus と Planner-Sisyphus がプライマリエージェントになります。 |
| オプション | デフォルト | 説明 |
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
| `builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントOhMyOpenCode 強化版ビルドモード)を有効化します。デフォルトの OpenCode ビルド体験を維持するため、デフォルトでは無効です。 |
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントOhMyOpenCode 強化版プランモード)を有効化します。デフォルトで有効です。 |
| `replace_build` | `true` | `true` の場合、デフォルトのビルドエージェントをサブエージェントモードに降格させます。`false` に設定すると、Builder-Sisyphus とデフォルトのビルドの両方を利用できます。 |
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
### Hooks
@@ -812,15 +845,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`に設定して無効化します。 |
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。

View File

@@ -708,14 +708,40 @@ 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**: OhMyOpenCode 강화 버전 빌드 에이전트 (기본적으로 비활성화)
- **Planner-Sisyphus**: OhMyOpenCode 강화 버전 플랜 에이전트 (기본적으로 활성화)
Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려면:
**설정 옵션:**
```json
{
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**예시: Builder-Sisyphus를 활성화하면서 기본 빌드 모드도 유지하기:**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
}
}
```
이렇게 하면 Builder-Sisyphus와 기본 빌드 에이전트를 동시에 사용할 수 있습니다.
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
```json
{
@@ -725,7 +751,7 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
다른 에이전트처럼 Sisyphus 와 Planner-Sisyphus도 커스터마이징할 수 있습니다:
다른 에이전트처럼 Sisyphus 에이전트들도 커스터마이징할 수 있습니다:
```json
{
@@ -734,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"
}
@@ -741,9 +770,13 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
| 옵션 | 기본값 | 설명 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true`면 Sisyphus 에이전트를 비활성화하고 원래 build/plan을 primary로 복원합니다. `false`(기본값)면 Sisyphus와 Planner-Sisyphus가 primary 에이전트가 됩니다. |
| 옵션 | 기본값 | 설명 |
| ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
| `builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트 (OhMyOpenCode 강화 빌드 모드)를 활성화합니다. 기본 OpenCode 빌드 경험을 보존하기 위해 기본적으로 비활성화되어 있습니다. |
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트 (OhMyOpenCode 강화 플랜 모드)를 활성화합니다. 기본적으로 활성화되어 있습니다. |
| `replace_build` | `true` | `true`면 기본 빌드 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Builder-Sisyphus와 기본 빌드를 모두 사용할 수 있습니다. |
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
### Hooks
@@ -806,15 +839,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`로 설정하여 비활성화하세요. |
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.

View File

@@ -780,24 +780,50 @@ 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**: Optional build agent with OhMyOpenCode enhancements (disabled by default)
- **Planner-Sisyphus**: Plan agent with OhMyOpenCode enhancements (enabled by default)
To disable Sisyphus and restore the original build/plan agents:
**Configuration Options:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**Example: Enable Builder-Sisyphus and keep default build mode:**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
}
}
```
This allows you to have both Builder-Sisyphus AND the default build agent available simultaneously.
**Example: Disable all Sisyphus orchestration:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
You can also customize Sisyphus and Planner-Sisyphus like other agents:
You can also customize Sisyphus agents like other agents:
```json
{
@@ -806,6 +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"
}
@@ -813,9 +842,13 @@ 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. |
| `builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (OhMyOpenCode enhanced build mode). Disabled by default to preserve default OpenCode build experience. |
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (OhMyOpenCode enhanced plan mode). Enabled by default. |
| `replace_build` | `true` | When `true`, demotes default build agent to subagent mode. Set to `false` to keep both Builder-Sisyphus and default build available. |
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
### Hooks
@@ -878,15 +911,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.

View File

@@ -714,24 +714,50 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
### Sisyphus Agent
默认开启。Sisyphus 会加两个主 Agent把原来的降级成小弟
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent
- **Sisyphus**:主编排 AgentClaude Opus 4.5
- **Planner-Sisyphus**运行时继承 OpenCode plan Agent 所有设置(描述里加了"OhMyOpenCode version"
- **build**:降级为子 Agent
- **plan**:降级为子 Agent
- **Builder-Sisyphus**OhMyOpenCode 增强版构建 Agent(默认禁用
- **Planner-Sisyphus**OhMyOpenCode 增强版计划 Agent默认启用
想禁用 Sisyphus 恢复原来的?
**配置选项:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"builder_enabled": false,
"planner_enabled": true,
"replace_build": true,
"replace_plan": true
}
}
```
**示例:启用 Builder-Sisyphus同时保留默认构建模式**
```json
{
"sisyphus_agent": {
"builder_enabled": true,
"replace_build": false
}
}
```
这样你就能同时使用 Builder-Sisyphus 和默认构建 Agent。
**示例:禁用所有 Sisyphus 编排:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
Sisyphus 和 Planner-Sisyphus 也能自定义:
Sisyphus Agent 也能自定义:
```json
{
@@ -740,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"
}
@@ -747,9 +776,13 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
}
```
| 选项 | 默认值 | 说明 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | 设为 `true` 就禁用 Sisyphus恢复原来的 build/plan。设为 `false`(默认)就是 Sisyphus 和 Planner-Sisyphus 掌权。 |
| 选项 | 默认值 | 说明 |
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
| `builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus AgentOhMyOpenCode 增强构建模式)。为了保留默认 OpenCode 构建体验,默认禁用。 |
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus AgentOhMyOpenCode 增强计划模式)。默认启用。 |
| `replace_build` | `true` | 设为 `true` 就把默认构建 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Builder-Sisyphus 和默认构建。 |
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
### Hooks
@@ -812,15 +845,17 @@ 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` 禁用此功能。 |
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。

View File

@@ -408,6 +408,120 @@
}
}
},
"Builder-Sisyphus": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
},
"disable": {
"type": "boolean"
},
"description": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"subagent",
"primary",
"all"
]
},
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"permission": {
"type": "object",
"properties": {
"edit": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"bash": {
"anyOf": [
{
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
]
},
"webfetch": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"doom_loop": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"external_directory": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
}
}
},
"Planner-Sisyphus": {
"type": "object",
"properties": {
@@ -1236,6 +1350,18 @@
"properties": {
"disabled": {
"type": "boolean"
},
"builder_enabled": {
"type": "boolean"
},
"planner_enabled": {
"type": "boolean"
},
"replace_build": {
"type": "boolean"
},
"replace_plan": {
"type": "boolean"
}
}
},
@@ -1257,6 +1383,7 @@
"maximum": 0.95
},
"truncate_all_tool_outputs": {
"default": true,
"type": "boolean"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,92 @@
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 mainSessionIdle false and empty lastOutput", () => {
// #given / #when
const state = createEventState()
// #then
expect(state.mainSessionIdle).toBe(false)
expect(state.lastOutput).toBe("")
})
})
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,
lastOutput: "",
}
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)
})
})

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

@@ -0,0 +1,85 @@
import type {
RunContext,
EventPayload,
SessionIdleProps,
SessionStatusProps,
MessageUpdatedProps,
} from "./types"
export interface EventState {
mainSessionIdle: boolean
lastOutput: string
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
lastOutput: "",
}
}
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 { payload?: EventPayload }).payload
if (!payload) continue
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
} catch {}
}
}
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 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
const newContent = content.slice(state.lastOutput.length)
if (newContent) {
process.stdout.write(newContent)
}
state.lastOutput = content
}

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

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

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

@@ -0,0 +1,110 @@
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 = 30 * 60 * 1000
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()
const timeoutId = setTimeout(() => {
console.log(pc.yellow("\nTimeout reached. Aborting..."))
abortController.abort()
}, timeout)
try {
const { client, server } = await createOpencode({
signal: abortController.signal,
})
const cleanup = () => {
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
}
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) {
clearTimeout(timeoutId)
if (err instanceof Error && err.name === "AbortError") {
return 130
}
console.error(pc.red(`Error: ${err}`))
return 1
}
}

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

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

View File

@@ -30,6 +30,7 @@ export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"Sisyphus",
"Builder-Sisyphus",
"Planner-Sisyphus",
"oracle",
"librarian",
@@ -86,6 +87,7 @@ export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
Sisyphus: AgentOverrideConfigSchema.optional(),
"Builder-Sisyphus": AgentOverrideConfigSchema.optional(),
"Planner-Sisyphus": AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
@@ -105,6 +107,10 @@ export const ClaudeCodeConfigSchema = z.object({
export const SisyphusAgentConfigSchema = z.object({
disabled: z.boolean().optional(),
builder_enabled: z.boolean().optional(),
planner_enabled: z.boolean().optional(),
replace_build: z.boolean().optional(),
replace_plan: z.boolean().optional(),
})
export const ExperimentalConfigSchema = z.object({
@@ -114,8 +120,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({

View File

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

View File

@@ -296,7 +296,8 @@ export function createTodoContinuationEnforcer(
if (sessionID && role === "assistant" && finish) {
remindedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Cleared reminded state on assistant finish`, { sessionID })
preemptivelyInjectedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Cleared reminded/preemptive state on assistant finish`, { sessionID })
const isTerminalFinish = finish && !["tool-calls", "unknown"].includes(finish)
if (isTerminalFinish && isNonInteractive()) {

View File

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

View File

@@ -50,6 +50,7 @@ import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
import { log, deepMerge, getUserConfigDir, addConfigLoadError } from "./shared";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
import { BUILD_SYSTEM_PROMPT, BUILD_PERMISSION } from "./agents/build-prompt";
import * as fs from "fs";
import * as path from "path";
@@ -379,34 +380,60 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled = pluginConfig.sisyphus_agent?.builder_enabled ?? false;
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
const replaceBuild = pluginConfig.sisyphus_agent?.replace_build ?? true;
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
// 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 builderSisyphusOverride = pluginConfig.agents?.["Builder-Sisyphus"];
const builderSisyphusBase = {
...buildConfigWithoutName,
prompt: BUILD_SYSTEM_PROMPT,
permission: BUILD_PERMISSION,
description: `${config.agent?.build?.description ?? "Build agent"} (OhMyOpenCode version)`,
color: config.agent?.build?.color ?? "#32CD32",
};
agentConfig["Builder-Sisyphus"] = builderSisyphusOverride
? { ...builderSisyphusBase, ...builderSisyphusOverride }
: builderSisyphusBase;
}
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;
}
config.agent = {
Sisyphus: builtinAgents.Sisyphus,
"Planner-Sisyphus": plannerSisyphusConfig,
...agentConfig,
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
...userAgents,
...projectAgents,
...config.agent,
build: { ...config.agent?.build, mode: "subagent" },
plan: { ...config.agent?.plan, mode: "subagent" },
...(replaceBuild ? { build: { ...config.agent?.build, mode: "subagent" } } : {}),
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
};
} else {
config.agent = {

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

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

View File

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

View File

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

View File

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

View 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_"

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
import { tool } 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)}`
}
},
})

View File

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

View File

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

View File

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