Compare commits

...

22 Commits

Author SHA1 Message Date
github-actions[bot]
5f823b0f8e release: v2.14.1 2026-01-11 02:23:00 +00:00
YeonGyu-Kim
e35a488cf6 fix(test): extend timeout for resume sync test
MIN_STABILITY_TIME_MS is 5000ms in implementation, but test timeout was only 5000ms.
Extended to 10000ms to allow proper polling completion.
2026-01-11 11:20:00 +09:00
YeonGyu-Kim
adb1a9fcb9 docs: fix model names in config examples to use valid antigravity models 2026-01-11 11:14:15 +09:00
YeonGyu-Kim
9bfed238b9 docs: update agent model catalog - librarian now uses GLM-4.7 Free 2026-01-11 11:11:34 +09:00
YeonGyu-Kim
61abd553fb fix wrong merge. 2026-01-11 11:07:46 +09:00
github-actions[bot]
6425d9d97e @KNN-07 has signed the CLA in code-yeongyu/oh-my-opencode#679 2026-01-11 01:11:47 +00:00
github-actions[bot]
d57744905f @arthur404dev has signed the CLA in code-yeongyu/oh-my-opencode#676 2026-01-10 23:51:55 +00:00
github-actions[bot]
c7ae2d7be6 @ashir6892 has signed the CLA in code-yeongyu/oh-my-opencode#675 2026-01-10 19:50:19 +00:00
github-actions[bot]
358f7f439d @kargnas has signed the CLA in code-yeongyu/oh-my-opencode#653 2026-01-10 10:25:35 +00:00
github-actions[bot]
4fde139dd8 @GollyJer has signed the CLA in code-yeongyu/oh-my-opencode#649 2026-01-10 09:57:54 +00:00
github-actions[bot]
b10703ec9a @imarshallwidjaja has signed the CLA in code-yeongyu/oh-my-opencode#648 2026-01-10 07:58:53 +00:00
Brian Li
8b12257729 fix: remove author name from agent system prompts (#634)
The author name "Named by [YeonGyu Kim]" in the Sisyphus role section
causes LLMs to sometimes infer Korean language output, even when the
user's locale is en-US.

This happens because the model sees a Korean name in the system prompt
and may interpret it as a signal to respond in Korean.

Removing the author attribution from the runtime prompt fixes this issue.
The attribution is preserved in README, LICENSE, and package.json.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:11:48 +09:00
github-actions[bot]
7536a12754 @Luodian has signed the CLA in code-yeongyu/oh-my-opencode#634 2026-01-10 05:01:31 +00:00
Gladdonilli
0fb765732a fix: improve background task completion detection and message extraction (#638)
* fix: background task completion detection and silent notifications

- Fix TS2742 by adding explicit ToolDefinition type annotations
- Add stability detection (3 consecutive stable polls after 10s minimum)
- Remove early continue when sessionStatus is undefined
- Add silent notification system via tool.execute.after hook injection
- Change task retention from 200ms to 5 minutes for background_output retrieval
- Fix formatTaskResult to sort messages by time descending

Fixes hanging background tasks that never complete due to missing sessionStatus.

* fix: improve background task completion detection and message extraction

- Add stability-based completion detection (10s min + 3 stable polls)
- Fix message extraction to recognize 'reasoning' parts from thinking models
- Switch from promptAsync() to prompt() for proper agent initialization
- Remove model parameter from prompt body (use agent's configured model)
- Add fire-and-forget prompt pattern for sisyphus_task sync mode
- Add silent notification via tool.execute.after hook injection
- Fix indentation issues in manager.ts and index.ts

Incorporates fixes from:
- PR #592: Stability detection mechanism
- PR #610: Model parameter passing (partially)
- PR #628: Completion detection improvements

Known limitation: Thinking models (e.g. claude-*-thinking-*) cause
JSON Parse errors in child sessions. Use non-thinking models for
background agents until OpenCode core resolves this.

* fix: add tool_result handling and pendingByParent tracking for resume/external tasks

Addresses code review feedback from PR #638:

P1: Add tool_result type to validateSessionHasOutput() to prevent
    false negatives for tool-only background tasks that would otherwise
    timeout after 30 minutes despite having valid results.

P2: Add pendingByParent tracking to resume() and registerExternalTask()
    to prevent premature 'ALL COMPLETE' notifications when mixing
    launched and resumed tasks.

* fix: address code review feedback - log messages, model passthrough, sorting, race condition

- Fix misleading log messages: 'promptAsync' -> 'prompt (fire-and-forget)'
- Restore model passthrough in launch() for Sisyphus category configs
- Fix call-omo-agent sorting: use time.created number instead of String(time)
- Fix race condition: check promptError inside polling loop, not just after 100ms
2026-01-10 14:00:25 +09:00
github-actions[bot]
d4c8ec6690 @ElwinLiu has signed the CLA in code-yeongyu/oh-my-opencode#645 2026-01-10 04:32:31 +00:00
github-actions[bot]
d6416082a2 @kdcokenny has signed the CLA in code-yeongyu/oh-my-opencode#629 2026-01-09 12:54:17 +00:00
github-actions[bot]
e6aaf57a21 @SJY0917032 has signed the CLA in code-yeongyu/oh-my-opencode#625 2026-01-09 10:01:29 +00:00
YeonGyu-Kim
5242f3daef fix(docs): correct plan invocation syntax from /plan to @plan
OpenCode uses @agent-name syntax for agent invocation, not /command.
The /plan command does not exist - it should be @plan to invoke
the Prometheus planner agent.
2026-01-09 17:45:25 +09:00
YeonGyu-Kim
3f2ded54ee fix(docs): escape special chars in Mermaid diagram
Quote node label containing special characters to prevent
Mermaid lexer error on line 9.
2026-01-09 17:24:03 +09:00
YeonGyu-Kim
aa5018583e docs(orchestration): add TL;DR section for quick reference 2026-01-09 16:47:04 +09:00
YeonGyu-Kim
185d4e1e54 test(ralph-loop): add tests for loop restart scenarios
- Add test for starting new loop while previous loop active (different session)
- Add test for restarting loop in same session
- Verifies startLoop properly overwrites state and resets iteration
2026-01-09 16:39:53 +09:00
YeonGyu-Kim
79e9fd82c5 fix(background-agent): preserve parent agent context in completion notifications
When parentAgent is undefined, omit the agent field entirely from
session.prompt body instead of passing undefined. This prevents the
OpenCode SDK from falling back to defaultAgent(), which would change
the parent session's agent context.

Changes:
- manager.ts: Build prompt body conditionally, only include agent/model
  when defined
- background-task/tools.ts: Use ctx.agent as primary source for
  parentAgent (consistent with sisyphus-task)
- registerExternalTask: Add parentAgent parameter support
- Added tests for agent context preservation scenarios
2026-01-09 15:53:55 +09:00
25 changed files with 840 additions and 119 deletions

View File

@@ -124,7 +124,7 @@ oh-my-opencode/
|-------|---------------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
| oracle | openai/gpt-5.2 | Read-only consultation. High-IQ debugging, architecture |
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
| librarian | opencode/glm-4.7-free | 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 |

View File

@@ -322,9 +322,9 @@ opencode auth login
{
"google_auth": false,
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-3-flash" }
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
}
}
```
@@ -461,7 +461,7 @@ oh-my-opencode を削除するには:
- **Sisyphus** (`anthropic/claude-opus-4-5`): **デフォルトエージェントです。** OpenCode のための強力な AI オーケストレーターです。専門のサブエージェントを活用して、複雑なタスクを計画、委任、実行します。バックグラウンドタスクへの委任と Todo ベースのワークフローを重視します。最大の推論能力を発揮するため、Claude Opus 4.5 と拡張思考 (32k token budget) を使用します。
- **oracle** (`openai/gpt-5.2`): アーキテクチャ、コードレビュー、戦略立案のための専門アドバイザー。GPT-5.2 の卓越した論理的推論と深い分析能力を活用します。AmpCode からインスピレーションを得ました。
- **librarian** (`anthropic/claude-sonnet-4-5` または `google/gemini-3-flash`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、それ以外は Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
- **librarian** (`opencode/glm-4.7-free`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。GLM-4.7 Free を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
- **explore** (`opencode/grok-code`、`google/gemini-3-flash`、または `anthropic/claude-haiku-4-5`): 高速なコードベース探索、ファイルパターンマッチング。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、Claude max20 が利用可能な場合は Haiku を使用し、それ以外は Grok を使います。Claude Code からインスピレーションを得ました。
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 開発者に転身したデザイナーという設定です。素晴らしい UI を作ります。美しく独創的な UI コードを生成することに長けた Gemini を使用します。
- **document-writer** (`google/gemini-3-pro-preview`): テクニカルライティングの専門家という設定です。Gemini は文筆家であり、流れるような文章を書きます。
@@ -774,9 +774,9 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
{
"google_auth": false,
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-3-flash" }
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
}
}
```

View File

@@ -361,9 +361,9 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
{
"google_auth": false,
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-3-flash" }
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
}
}
```
@@ -499,9 +499,9 @@ To remove oh-my-opencode:
- **Sisyphus** (`anthropic/claude-opus-4-5`): **The default agent.** A powerful AI orchestrator for OpenCode. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Emphasizes background task delegation and todo-driven workflow. Uses Claude Opus 4.5 with extended thinking (32k budget) for maximum reasoning capability.
- **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` or `google/gemini-3-flash`): Multi-repo analysis, doc lookup, implementation examples. Uses Gemini 3 Flash when Antigravity auth is configured, otherwise Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
- **librarian** (`opencode/glm-4.7-free`): Multi-repo analysis, doc lookup, implementation examples. Uses GLM-4.7 Free for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, or `anthropic/claude-haiku-4-5`): Fast codebase exploration and pattern matching. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-high`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI 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-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.
@@ -830,9 +830,9 @@ When using `opencode-antigravity-auth`, disable the built-in auth and override a
{
"google_auth": false,
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-3-flash" }
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
}
}
```
@@ -1092,7 +1092,7 @@ Add custom categories in `oh-my-opencode.json`:
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
},
"visual": {
"model": "google/gemini-3-pro-high",
"model": "google/gemini-3-pro-preview",
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
}
}

View File

@@ -327,9 +327,9 @@ opencode auth login
{
"google_auth": false,
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-3-flash" }
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
}
}
```
@@ -466,7 +466,7 @@ gh repo star code-yeongyu/oh-my-opencode
- **Sisyphus** (`anthropic/claude-opus-4-5`)**默认 Agent。** OpenCode 专属的强力 AI 编排器。指挥专业子 Agent 搞定复杂任务。主打后台任务委派和 Todo 驱动。用 Claude Opus 4.5 加上扩展思考32k token 预算),智商拉满。
- **oracle** (`openai/gpt-5.2`)架构师、代码审查员、战略家。GPT-5.2 的逻辑推理和深度分析能力不是盖的。致敬 AmpCode。
- **librarian** (`anthropic/claude-sonnet-4-5` 或 `google/gemini-3-flash`):多仓库分析、查文档、找示例。配置 Antigravity 认证时使用 Gemini 3 Flash否则使用 Claude Sonnet 4.5 深入理解代码库GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
- **librarian** (`opencode/glm-4.7-free`):多仓库分析、查文档、找示例。使用 GLM-4.7 Free 深入理解代码库GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
- **explore** (`opencode/grok-code`、`google/gemini-3-flash` 或 `anthropic/claude-haiku-4-5`):极速代码库扫描、模式匹配。配置 Antigravity 认证时使用 Gemini 3 FlashClaude max20 可用时使用 Haiku否则用 Grok。致敬 Claude Code。
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`)设计师出身的程序员。UI 做得那是真漂亮。Gemini 写这种创意美观的代码是一绝。
- **document-writer** (`google/gemini-3-pro-preview`)技术写作专家。Gemini 文笔好,写出来的东西读着顺畅。
@@ -775,9 +775,9 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
{
"google_auth": false,
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-3-flash" }
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
}
}
```

View File

@@ -1,5 +1,26 @@
# Oh-My-OpenCode Orchestration Guide
## TL;DR - When to Use What
| Complexity | Approach | When to Use |
|------------|----------|-------------|
| **Simple** | Just prompt | Simple tasks, quick fixes, single-file changes |
| **Complex + Lazy** | Just type `ulw` or `ultrawork` | Complex tasks where explaining context is tedious. Agent figures it out. |
| **Complex + Precise** | `@plan``/start-work` | Precise, multi-step work requiring true orchestration. Prometheus plans, Sisyphus executes. |
**Decision Flow:**
```
Is it a quick fix or simple task?
└─ YES → Just prompt normally
└─ NO → Is explaining the full context tedious?
└─ YES → Type "ulw" and let the agent figure it out
└─ NO → Do you need precise, verifiable execution?
└─ YES → Use @plan for Prometheus planning, then /start-work
└─ NO → Just use "ulw"
```
---
This document provides a comprehensive guide to the orchestration system that implements Oh-My-OpenCode's core philosophy: **"Separation of Planning and Execution"**.
## 1. Overview
@@ -24,7 +45,7 @@ graph TD
Metis --> Prometheus
Prometheus --> Momus[Momus<br>Reviewer]
Momus --> Prometheus
Prometheus --> PlanFile[/.sisyphus/plans/*.md]
Prometheus --> PlanFile["/.sisyphus/plans/{name}.md"]
end
PlanFile --> StartWork[/start-work]
@@ -93,9 +114,9 @@ When the user enters `/start-work`, the execution phase begins.
## 5. Commands and Usage
### `/plan [request]`
### `@plan [request]`
Invokes Prometheus to start a planning session.
- Example: `/plan "I want to refactor the authentication system to NextAuth"`
- Example: `@plan "I want to refactor the authentication system to NextAuth"`
### `/start-work`
Executes the generated plan.

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.0.0-beta.2",
"version": "2.14.1",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -319,6 +319,86 @@
"created_at": "2026-01-08T20:18:27Z",
"repoId": 1108837393,
"pullRequestNo": 603
},
{
"name": "SJY0917032",
"id": 88534701,
"comment_id": 3728199745,
"created_at": "2026-01-09T10:01:19Z",
"repoId": 1108837393,
"pullRequestNo": 625
},
{
"name": "kdcokenny",
"id": 99611484,
"comment_id": 3728801075,
"created_at": "2026-01-09T12:54:05Z",
"repoId": 1108837393,
"pullRequestNo": 629
},
{
"name": "ElwinLiu",
"id": 87802244,
"comment_id": 3731812585,
"created_at": "2026-01-10T04:32:16Z",
"repoId": 1108837393,
"pullRequestNo": 645
},
{
"name": "Luodian",
"id": 15847405,
"comment_id": 3731833107,
"created_at": "2026-01-10T05:01:16Z",
"repoId": 1108837393,
"pullRequestNo": 634
},
{
"name": "imarshallwidjaja",
"id": 60992624,
"comment_id": 3732124681,
"created_at": "2026-01-10T07:58:43Z",
"repoId": 1108837393,
"pullRequestNo": 648
},
{
"name": "GollyJer",
"id": 689204,
"comment_id": 3732253764,
"created_at": "2026-01-10T09:33:21Z",
"repoId": 1108837393,
"pullRequestNo": 649
},
{
"name": "kargnas",
"id": 1438533,
"comment_id": 3732344143,
"created_at": "2026-01-10T10:25:25Z",
"repoId": 1108837393,
"pullRequestNo": 653
},
{
"name": "ashir6892",
"id": 52703606,
"comment_id": 3733435826,
"created_at": "2026-01-10T19:50:07Z",
"repoId": 1108837393,
"pullRequestNo": 675
},
{
"name": "arthur404dev",
"id": 59490008,
"comment_id": 3733697071,
"created_at": "2026-01-10T23:51:44Z",
"repoId": 1108837393,
"pullRequestNo": 676
},
{
"name": "KNN-07",
"id": 55886589,
"comment_id": 3733788592,
"created_at": "2026-01-11T01:11:38Z",
"repoId": 1108837393,
"pullRequestNo": 679
}
]
}

View File

@@ -33,7 +33,7 @@ agents/
|-------|---------------|----------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
| oracle | openai/gpt-5.2 | - | Read-only consultation. High-IQ debugging, architecture |
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
| librarian | opencode/glm-4.7-free | - | Docs, OSS research, GitHub examples |
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
| document-writer | google/gemini-3-pro-preview | - | Technical writing |

View File

@@ -1,7 +1,7 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
const DEFAULT_MODEL = "opencode/glm-4.7-free"
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
category: "exploration",
@@ -129,15 +129,15 @@ Tool 3: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
\`\`\`
Step 1: Clone to temp directory
gh repo clone owner/repo \${TMPDIR:-/tmp}/repo-name -- --depth 1
Step 2: Get commit SHA for permalinks
cd \${TMPDIR:-/tmp}/repo-name && git rev-parse HEAD
Step 3: Find the implementation
- grep/ast_grep_search for function/class
- read the specific file
- git blame for context if needed
Step 4: Construct permalink
https://github.com/owner/repo/blob/<sha>/path/to/file#L10-L20
\`\`\`
@@ -272,7 +272,7 @@ Use OS-appropriate temp directory:
| TYPE B (Implementation) | 2-3 NO |
| TYPE C (Context) | 2-3 NO |
| TYPE D (Comprehensive) | 3-5 | YES (Phase 0.5 first) |
| Request Type | Minimum Parallel Calls
| Request Type | Minimum Parallel Calls
**Doc Discovery is SEQUENTIAL** (websearch → version check → sitemap → investigate).
**Main phase is PARALLEL** once you know where to look.
@@ -308,7 +308,7 @@ grep_app_searchGitHub(query: "useQuery")
## COMMUNICATION RULES
1. **NO TOOL NAMES**: Say "I'll search the codebase" not "I'll use grep_app"
2. **NO PREAMBLE**: Answer directly, skip "I'll help you with..."
2. **NO PREAMBLE**: Answer directly, skip "I'll help you with..."
3. **ALWAYS CITE**: Every code claim needs a permalink
4. **USE MARKDOWN**: Code blocks with language identifiers
5. **BE CONCISE**: Facts > opinions, evidence > speculation

View File

@@ -132,7 +132,6 @@ ${rows.join("\n")}
}
export const ORCHESTRATOR_SISYPHUS_SYSTEM_PROMPT = `You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
Named by [YeonGyu Kim](https://github.com/code-yeongyu).
**Why Sisyphus?**: Humans roll their boulder every day. So do you. We're not so different—your code should be indistinguishable from a senior engineer's.

View File

@@ -18,7 +18,6 @@ const DEFAULT_MODEL = "anthropic/claude-opus-4-5"
const SISYPHUS_ROLE_SECTION = `<Role>
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
Named by [YeonGyu Kim](https://github.com/code-yeongyu).
**Why Sisyphus?**: Humans roll their boulder every day. So do you. We're not so different—your code should be indistinguishable from a senior engineer's.

View File

@@ -296,6 +296,7 @@ export const GitMasterConfigSchema = z.object({
/** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */
include_co_authored_by: z.boolean().default(true),
})
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),

View File

@@ -674,3 +674,95 @@ describe("LaunchInput.skillContent", () => {
expect(input.skillContent).toBe("You are a playwright expert")
})
})
describe("BackgroundManager.notifyParentSession - agent context preservation", () => {
test("should not pass agent field when parentAgent is undefined", async () => {
// #given
const task: BackgroundTask = {
id: "task-no-agent",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task without agent context",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: undefined,
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
}
// #when
const promptBody = buildNotificationPromptBody(task)
// #then
expect("agent" in promptBody).toBe(false)
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus" })
})
test("should include agent field when parentAgent is defined", async () => {
// #given
const task: BackgroundTask = {
id: "task-with-agent",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task with agent context",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "Sisyphus",
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
}
// #when
const promptBody = buildNotificationPromptBody(task)
// #then
expect(promptBody.agent).toBe("Sisyphus")
})
test("should not pass model field when parentModel is undefined", async () => {
// #given
const task: BackgroundTask = {
id: "task-no-model",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task without model context",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "Sisyphus",
parentModel: undefined,
}
// #when
const promptBody = buildNotificationPromptBody(task)
// #then
expect("model" in promptBody).toBe(false)
expect(promptBody.agent).toBe("Sisyphus")
})
})
function buildNotificationPromptBody(task: BackgroundTask): Record<string, unknown> {
const body: Record<string, unknown> = {
parts: [{ type: "text", text: `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished.` }],
}
if (task.parentAgent !== undefined) {
body.agent = task.parentAgent
}
if (task.parentModel?.providerID && task.parentModel?.modelID) {
body.model = { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID }
}
return body
}

View File

@@ -13,6 +13,7 @@ import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
const TASK_TTL_MS = 30 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
type OpencodeClient = PluginInput["client"]
@@ -43,6 +44,7 @@ interface Todo {
export class BackgroundManager {
private tasks: Map<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]>
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
private client: OpencodeClient
private directory: string
private pollingInterval?: ReturnType<typeof setInterval>
@@ -51,12 +53,20 @@ export class BackgroundManager {
constructor(ctx: PluginInput, config?: BackgroundTaskConfig) {
this.tasks = new Map()
this.notifications = new Map()
this.pendingByParent = new Map()
this.client = ctx.client
this.directory = ctx.directory
this.concurrencyManager = new ConcurrencyManager(config)
}
async launch(input: LaunchInput): Promise<BackgroundTask> {
log("[background-agent] launch() called with:", {
agent: input.agent,
model: input.model,
description: input.description,
parentSessionID: input.parentSessionID,
})
if (!input.agent || input.agent.trim() === "") {
throw new Error("Agent parameter is required")
}
@@ -106,6 +116,11 @@ export class BackgroundManager {
this.tasks.set(task.id, task)
this.startPolling()
// Track for batched notifications
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
pending.add(task.id)
this.pendingByParent.set(input.parentSessionID, pending)
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
const toastManager = getTaskToastManager()
@@ -119,10 +134,21 @@ export class BackgroundManager {
})
}
this.client.session.promptAsync({
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
sessionID,
agent: input.agent,
model: input.model,
hasSkillContent: !!input.skillContent,
promptLength: input.prompt.length,
})
// Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget)
// Include model if caller provided one (e.g., from Sisyphus category configs)
this.client.session.prompt({
path: { id: sessionID },
body: {
agent: input.agent,
...(input.model ? { model: input.model } : {}),
system: input.skillContent,
tools: {
task: false,
@@ -146,7 +172,9 @@ export class BackgroundManager {
this.concurrencyManager.release(existingTask.concurrencyKey)
}
this.markForNotification(existingTask)
this.notifyParentSession(existingTask)
this.notifyParentSession(existingTask).catch(err => {
log("[background-agent] Failed to notify on error:", err)
})
}
})
@@ -199,6 +227,7 @@ export class BackgroundManager {
parentSessionID: string
description: string
agent?: string
parentAgent?: string
}): BackgroundTask {
const task: BackgroundTask = {
id: input.taskId,
@@ -214,12 +243,18 @@ export class BackgroundManager {
toolCalls: 0,
lastUpdate: new Date(),
},
parentAgent: input.parentAgent,
}
this.tasks.set(task.id, task)
subagentSessions.add(input.sessionID)
this.startPolling()
// Track for batched notifications (external tasks need tracking too)
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
pending.add(task.id)
this.pendingByParent.set(input.parentSessionID, pending)
log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
return task
@@ -247,6 +282,11 @@ export class BackgroundManager {
this.startPolling()
subagentSessions.add(existingTask.sessionID)
// Track for batched notifications (P2 fix: resumed tasks need tracking too)
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
pending.add(existingTask.id)
this.pendingByParent.set(input.parentSessionID, pending)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.addTask({
@@ -259,7 +299,15 @@ export class BackgroundManager {
log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID })
this.client.session.promptAsync({
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
sessionID: existingTask.sessionID,
agent: existingTask.agent,
promptLength: input.prompt.length,
})
// Note: Don't pass model in body - use agent's configured model instead
// Use prompt() instead of promptAsync() to properly initialize agent loop
this.client.session.prompt({
path: { id: existingTask.sessionID },
body: {
agent: existingTask.agent,
@@ -270,13 +318,15 @@ export class BackgroundManager {
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
log("[background-agent] resume promptAsync error:", error)
log("[background-agent] resume prompt error:", error)
existingTask.status = "error"
const errorMessage = error instanceof Error ? error.message : String(error)
existingTask.error = errorMessage
existingTask.completedAt = new Date()
this.markForNotification(existingTask)
this.notifyParentSession(existingTask)
this.notifyParentSession(existingTask).catch(err => {
log("[background-agent] Failed to notify on resume error:", err)
})
})
return existingTask
@@ -331,7 +381,22 @@ export class BackgroundManager {
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
this.checkSessionTodos(sessionID).then((hasIncompleteTodos) => {
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
const elapsedMs = Date.now() - task.startedAt.getTime()
const MIN_IDLE_TIME_MS = 5000
if (elapsedMs < MIN_IDLE_TIME_MS) {
log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id })
return
}
// Edge guard: Verify session has actual assistant output before completing
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
@@ -340,8 +405,10 @@ export class BackgroundManager {
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
this.notifyParentSession(task)
await this.notifyParentSession(task)
log("[background-agent] Task completed via session.idle event:", task.id)
}).catch(err => {
log("[background-agent] Error in session.idle handler:", err)
})
}
@@ -382,6 +449,66 @@ export class BackgroundManager {
this.notifications.delete(sessionID)
}
/**
* Validates that a session has actual assistant/tool output before marking complete.
* Prevents premature completion when session.idle fires before agent responds.
*/
private async validateSessionHasOutput(sessionID: string): Promise<boolean> {
try {
const response = await this.client.session.messages({
path: { id: sessionID },
})
const messages = response.data ?? []
// Check for at least one assistant or tool message
const hasAssistantOrToolMessage = messages.some(
(m: { info?: { role?: string } }) =>
m.info?.role === "assistant" || m.info?.role === "tool"
)
if (!hasAssistantOrToolMessage) {
log("[background-agent] No assistant/tool messages found in session:", sessionID)
return false
}
// Additionally check that at least one message has content (not just empty)
// OpenCode API uses different part types than Anthropic's API:
// - "reasoning" with .text property (thinking/reasoning content)
// - "tool" with .state.output property (tool call results)
// - "text" with .text property (final text output)
// - "step-start"/"step-finish" (metadata, no content)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasContent = messages.some((m: any) => {
if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false
const parts = m.parts ?? []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return parts.some((p: any) =>
// Text content (final output)
(p.type === "text" && p.text && p.text.trim().length > 0) ||
// Reasoning content (thinking blocks)
(p.type === "reasoning" && p.text && p.text.trim().length > 0) ||
// Tool calls (indicates work was done)
p.type === "tool" ||
// Tool results (output from executed tools) - important for tool-only tasks
(p.type === "tool_result" && p.content &&
(typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0))
)
})
if (!hasContent) {
log("[background-agent] Messages exist but no content found in session:", sessionID)
return false
}
return true
} catch (error) {
log("[background-agent] Error validating session output:", error)
// On error, allow completion to proceed (don't block indefinitely)
return true
}
}
private clearNotificationsForTask(taskId: string): void {
for (const [sessionID, tasks] of this.notifications.entries()) {
const filtered = tasks.filter((t) => t.id !== taskId)
@@ -409,17 +536,33 @@ export class BackgroundManager {
}
}
cleanup(): void {
cleanup(): void {
this.stopPolling()
this.tasks.clear()
this.notifications.clear()
this.pendingByParent.clear()
}
private notifyParentSession(task: BackgroundTask): void {
/**
* Get all running tasks (for compaction hook)
*/
getRunningTasks(): BackgroundTask[] {
return Array.from(this.tasks.values()).filter(t => t.status === "running")
}
/**
* Get all completed tasks still in memory (for compaction hook)
*/
getCompletedTasks(): BackgroundTask[] {
return Array.from(this.tasks.values()).filter(t => t.status !== "running")
}
private async notifyParentSession(task: BackgroundTask): Promise<void> {
const duration = this.formatDuration(task.startedAt, task.completedAt)
log("[background-agent] notifyParentSession called for task:", task.id)
// Show toast notification
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.showCompletionToast({
@@ -429,41 +572,83 @@ export class BackgroundManager {
})
}
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
// Update pending tracking and check if all tasks complete
const pendingSet = this.pendingByParent.get(task.parentSessionID)
if (pendingSet) {
pendingSet.delete(task.id)
if (pendingSet.size === 0) {
this.pendingByParent.delete(task.parentSessionID)
}
}
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
const allComplete = !pendingSet || pendingSet.size === 0
const remainingCount = pendingSet?.size ?? 0
// Build notification message
const statusText = task.status === "error" ? "FAILED" : "COMPLETED"
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
let notification: string
if (allComplete) {
// All tasks complete - build summary
const completedTasks = Array.from(this.tasks.values())
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running")
.map(t => `- \`${t.id}\`: ${t.description}`)
.join("\n")
notification = `<system-reminder>
[ALL BACKGROUND TASKS COMPLETE]
**Completed:**
${completedTasks || `- \`${task.id}\`: ${task.description}`}
Use \`background_output(task_id="<id>")\` to retrieve each result.
</system-reminder>`
} else {
// Individual completion - silent notification
notification = `<system-reminder>
[BACKGROUND TASK ${statusText}]
**ID:** \`${task.id}\`
**Description:** ${task.description}
**Duration:** ${duration}${errorInfo}
**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete.
Do NOT poll - continue productive work.
Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready.
</system-reminder>`
}
// Inject notification via session.prompt with noReply
try {
await this.client.session.prompt({
path: { id: task.parentSessionID },
body: {
noReply: !allComplete, // Silent unless all complete
agent: task.parentAgent,
parts: [{ type: "text", text: notification }],
},
})
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
noReply: !allComplete,
})
} catch (error) {
log("[background-agent] Failed to send notification:", error)
}
// Cleanup after retention period
const taskId = task.id
setTimeout(async () => {
setTimeout(() => {
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
try {
// Use only parentModel/parentAgent - don't fallback to prevMessage
// This prevents accidentally changing parent session's model/agent
const modelField = task.parentModel?.providerID && task.parentModel?.modelID
? { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID }
: undefined
await this.client.session.prompt({
path: { id: task.parentSessionID },
body: {
agent: task.parentAgent,
model: modelField,
parts: [{ type: "text", text: message }],
},
query: { directory: this.directory },
})
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
} catch (error) {
log("[background-agent] prompt failed:", String(error))
} finally {
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
}, 200)
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}, 5 * 60 * 1000)
}
private formatDuration(start: Date, end?: Date): string {
@@ -532,15 +717,18 @@ export class BackgroundManager {
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
try {
try {
const sessionStatus = allStatuses[task.sessionID]
if (!sessionStatus) {
log("[background-agent] Session not found in status:", task.sessionID)
continue
}
// Don't skip if session not in status - fall through to message-based detection
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(task.sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
continue
}
if (sessionStatus.type === "idle") {
const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
@@ -550,7 +738,7 @@ export class BackgroundManager {
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
this.notifyParentSession(task)
await this.notifyParentSession(task)
log("[background-agent] Task completed via polling:", task.id)
continue
}
@@ -591,10 +779,41 @@ export class BackgroundManager {
task.progress.toolCalls = toolCalls
task.progress.lastTool = lastTool
task.progress.lastUpdate = new Date()
if (lastMessage) {
if (lastMessage) {
task.progress.lastMessage = lastMessage
task.progress.lastMessageAt = new Date()
}
// Stability detection: complete when message count unchanged for 3 polls
const currentMsgCount = messages.length
const elapsedMs = Date.now() - task.startedAt.getTime()
if (elapsedMs >= MIN_STABILITY_TIME_MS) {
if (task.lastMsgCount === currentMsgCount) {
task.stablePolls = (task.stablePolls ?? 0) + 1
if (task.stablePolls >= 3) {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(task.sessionID)
if (!hasValidOutput) {
log("[background-agent] Stability reached but no valid output, waiting:", task.id)
continue
}
const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID)
if (!hasIncompleteTodos) {
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
await this.notifyParentSession(task)
log("[background-agent] Task completed via stability detection:", task.id)
continue
}
}
} else {
task.stablePolls = 0
}
}
task.lastMsgCount = currentMsgCount
}
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })

View File

@@ -32,6 +32,10 @@ export interface BackgroundTask {
concurrencyKey?: string
/** Parent session's agent name for notification */
parentAgent?: string
/** Last message count for stability detection */
lastMsgCount?: number
/** Number of consecutive polls with stable message count */
stablePolls?: number
}
export interface LaunchInput {

View File

@@ -0,0 +1,85 @@
import type { BackgroundManager } from "../../features/background-agent"
interface CompactingInput {
sessionID: string
}
interface CompactingOutput {
context: string[]
prompt?: string
}
/**
* Background agent compaction hook - preserves task state during context compaction.
*
* When OpenCode compacts session context to save tokens, this hook injects
* information about running and recently completed background tasks so the
* agent doesn't lose awareness of delegated work.
*/
export function createBackgroundCompactionHook(manager: BackgroundManager) {
return {
"experimental.session.compacting": async (
input: CompactingInput,
output: CompactingOutput
): Promise<void> => {
const { sessionID } = input
// Get running tasks for this session
const running = manager.getRunningTasks()
.filter(t => t.parentSessionID === sessionID)
.map(t => ({
id: t.id,
agent: t.agent,
description: t.description,
startedAt: t.startedAt,
}))
// Get recently completed tasks (still in memory within 5-min retention)
const completed = manager.getCompletedTasks()
.filter(t => t.parentSessionID === sessionID)
.slice(-10) // Last 10 completed
.map(t => ({
id: t.id,
agent: t.agent,
description: t.description,
status: t.status,
}))
// Early exit if nothing to preserve
if (running.length === 0 && completed.length === 0) return
const sections: string[] = ["<background-tasks>"]
// Running tasks section
if (running.length > 0) {
sections.push("## Running Background Tasks")
sections.push("")
for (const t of running) {
const elapsed = Math.floor((Date.now() - t.startedAt.getTime()) / 1000)
sections.push(`- **\`${t.id}\`** (${t.agent}): ${t.description} [${elapsed}s elapsed]`)
}
sections.push("")
sections.push("> **Note:** You WILL be notified when tasks complete.")
sections.push("> Do NOT poll - continue productive work.")
sections.push("")
}
// Completed tasks section
if (completed.length > 0) {
sections.push("## Recently Completed Tasks")
sections.push("")
for (const t of completed) {
const statusEmoji = t.status === "completed" ? "✅" : t.status === "error" ? "❌" : "⏱️"
sections.push(`- ${statusEmoji} **\`${t.id}\`**: ${t.description}`)
}
sections.push("")
}
sections.push("## Retrieval")
sections.push('Use `background_output(task_id="<id>")` to retrieve task results.')
sections.push("</background-tasks>")
output.context.push(sections.join("\n"))
}
}
}

View File

@@ -9,6 +9,12 @@ interface EventInput {
event: Event
}
/**
* Background notification hook - handles event routing to BackgroundManager.
*
* Notifications are now delivered directly via session.prompt({ noReply })
* from the manager, so this hook only needs to handle event routing.
*/
export function createBackgroundNotificationHook(manager: BackgroundManager) {
const eventHandler = async ({ event }: EventInput) => {
manager.handleEvent(event)

View File

@@ -14,6 +14,7 @@ export { createThinkModeHook } from "./think-mode";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification"
export { createBackgroundCompactionHook } from "./background-compaction"
export { createAutoUpdateCheckerHook } from "./auto-update-checker";
export { createAgentUsageReminderHook } from "./agent-usage-reminder";

View File

@@ -591,6 +591,73 @@ describe("ralph-loop", () => {
expect(hook.getState()).toBeNull()
})
test("should allow starting new loop while previous loop is active (different session)", async () => {
// #given - active loop in session A
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-A", "First task", { maxIterations: 10 })
expect(hook.getState()?.session_id).toBe("session-A")
expect(hook.getState()?.prompt).toBe("First task")
// #when - start new loop in session B (without completing A)
hook.startLoop("session-B", "Second task", { maxIterations: 20 })
// #then - state should be overwritten with session B's loop
expect(hook.getState()?.session_id).toBe("session-B")
expect(hook.getState()?.prompt).toBe("Second task")
expect(hook.getState()?.max_iterations).toBe(20)
expect(hook.getState()?.iteration).toBe(1)
// #when - session B goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-B" } },
})
// #then - continuation should be injected for session B
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-B")
expect(promptCalls[0].text).toContain("Second task")
expect(promptCalls[0].text).toContain("2/20")
// #then - iteration incremented
expect(hook.getState()?.iteration).toBe(2)
})
test("should allow starting new loop in same session (restart)", async () => {
// #given - active loop in session A at iteration 5
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-A", "First task", { maxIterations: 10 })
// Simulate some iterations
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
expect(hook.getState()?.iteration).toBe(3)
expect(promptCalls.length).toBe(2)
// #when - start NEW loop in same session (restart)
hook.startLoop("session-A", "Restarted task", { maxIterations: 50 })
// #then - state should be reset to iteration 1 with new prompt
expect(hook.getState()?.session_id).toBe("session-A")
expect(hook.getState()?.prompt).toBe("Restarted task")
expect(hook.getState()?.max_iterations).toBe(50)
expect(hook.getState()?.iteration).toBe(1)
// #when - session goes idle
promptCalls = [] // Reset to check new continuation
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
// #then - continuation should use new task
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("Restarted task")
expect(promptCalls[0].text).toContain("2/50")
})
test("should check transcript BEFORE API to optimize performance", async () => {
// #given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")

View File

@@ -74,7 +74,7 @@ export function createBackgroundTask(manager: BackgroundManager): ToolDefinition
parentSessionID: ctx.sessionID,
parentMessageID: ctx.messageID,
parentModel,
parentAgent: prevMessage?.agent,
parentAgent: ctx.agent ?? prevMessage?.agent,
})
ctx.metadata?.({
@@ -176,8 +176,13 @@ async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): P
// Handle both SDK response structures: direct array or wrapped in .data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages = ((messagesResult as any).data ?? messagesResult) as Array<{
info?: { role?: string }
parts?: Array<{ type?: string; text?: string }>
info?: { role?: string; time?: string }
parts?: Array<{
type?: string
text?: string
content?: string | Array<{ type: string; text?: string }>
name?: string
}>
}>
if (!Array.isArray(messages) || messages.length === 0) {
@@ -193,11 +198,13 @@ Session ID: ${task.sessionID}
(No messages found)`
}
const assistantMessages = messages.filter(
(m) => m.info?.role === "assistant"
// Include both assistant messages AND tool messages
// Tool results (grep, glob, bash output) come from role "tool"
const relevantMessages = messages.filter(
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
)
if (assistantMessages.length === 0) {
if (relevantMessages.length === 0) {
return `Task Result
Task ID: ${task.id}
@@ -207,17 +214,46 @@ Session ID: ${task.sessionID}
---
(No assistant response found)`
(No assistant or tool response found)`
}
const lastMessage = assistantMessages[assistantMessages.length - 1]
const textParts = lastMessage?.parts?.filter(
(p) => p.type === "text"
) ?? []
const textContent = textParts
.map((p) => p.text ?? "")
// Sort by time ascending (oldest first) to process messages in order
const sortedMessages = [...relevantMessages].sort((a, b) => {
const timeA = String((a as { info?: { time?: string } }).info?.time ?? "")
const timeB = String((b as { info?: { time?: string } }).info?.time ?? "")
return timeA.localeCompare(timeB)
})
// Extract content from ALL messages, not just the last one
// Tool results may be in earlier messages while the final message is empty
const extractedContent: string[] = []
for (const message of sortedMessages) {
for (const part of message.parts ?? []) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extractedContent.push(part.text)
} else if (part.type === "tool_result") {
// Tool results contain the actual output from tool calls
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
if (typeof toolResult.content === "string" && toolResult.content) {
extractedContent.push(toolResult.content)
} else if (Array.isArray(toolResult.content)) {
// Handle array of content blocks
for (const block of toolResult.content) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((block.type === "text" || block.type === "reasoning") && block.text) {
extractedContent.push(block.text)
}
}
}
}
}
}
const textContent = extractedContent
.filter((text) => text.length > 0)
.join("\n")
.join("\n\n")
const duration = formatDuration(task.startedAt, task.completedAt)

View File

@@ -170,23 +170,59 @@ async function executeSync(
const messages = messagesResult.data
log(`[call_omo_agent] Got ${messages.length} messages`)
// Include both assistant messages AND tool messages
// Tool results (grep, glob, bash output) come from role "tool"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lastAssistantMessage = messages
.filter((m: any) => m.info.role === "assistant")
.sort((a: any, b: any) => (b.info.time?.created || 0) - (a.info.time?.created || 0))[0]
const relevantMessages = messages.filter(
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
)
if (!lastAssistantMessage) {
log(`[call_omo_agent] No assistant message found`)
if (relevantMessages.length === 0) {
log(`[call_omo_agent] No assistant or tool messages found`)
log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2))
return `Error: No assistant response found\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
return `Error: No assistant or tool response found\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
}
log(`[call_omo_agent] Found assistant message with ${lastAssistantMessage.parts.length} parts`)
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
// Sort by time ascending (oldest first) to process messages in order
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const textParts = lastAssistantMessage.parts.filter((p: any) => p.type === "text")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const responseText = textParts.map((p: any) => p.text).join("\n")
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
const timeA = a.info?.time?.created ?? 0
const timeB = b.info?.time?.created ?? 0
return timeA - timeB
})
// Extract content from ALL messages, not just the last one
// Tool results may be in earlier messages while the final message is empty
const extractedContent: string[] = []
for (const message of sortedMessages) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const part of (message as any).parts ?? []) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extractedContent.push(part.text)
} else if (part.type === "tool_result") {
// Tool results contain the actual output from tool calls
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
if (typeof toolResult.content === "string" && toolResult.content) {
extractedContent.push(toolResult.content)
} else if (Array.isArray(toolResult.content)) {
// Handle array of content blocks
for (const block of toolResult.content) {
if ((block.type === "text" || block.type === "reasoning") && block.text) {
extractedContent.push(block.text)
}
}
}
}
}
}
const responseText = extractedContent
.filter((text) => text.length > 0)
.join("\n\n")
log(`[call_omo_agent] Got response, length: ${responseText.length}`)

View File

@@ -259,6 +259,7 @@ describe("sisyphus-task", () => {
describe("resume with background parameter", () => {
test("resume with background=false should wait for result and return content", async () => {
// Note: This test needs extended timeout because the implementation has MIN_STABILITY_TIME_MS = 5000
// #given
const { createSisyphusTask } = require("./tools")
@@ -319,7 +320,7 @@ describe("sisyphus-task", () => {
// #then - should contain actual result, not just "Background task resumed"
expect(result).toContain("This is the resumed task result")
expect(result).not.toContain("Background task resumed")
})
}, { timeout: 10000 })
test("resume with background=true should return immediately without waiting", async () => {
// #given

View File

@@ -221,6 +221,33 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
return `❌ Failed to send resume prompt: ${errorMessage}\n\nSession ID: ${args.resume}`
}
// Wait for message stability after prompt completes
const POLL_INTERVAL_MS = 500
const MIN_STABILITY_TIME_MS = 5000
const STABILITY_POLLS_REQUIRED = 3
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < 60000) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
const elapsed = Date.now() - pollStart
if (elapsed < MIN_STABILITY_TIME_MS) continue
const messagesCheck = await client.session.messages({ path: { id: args.resume } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({
path: { id: args.resume },
})
@@ -250,7 +277,8 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
return `❌ No assistant response found.\n\nSession ID: ${args.resume}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text") ?? []
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
@@ -390,13 +418,13 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
metadata: { sessionId: sessionID, category: args.category, sync: true },
})
// Use promptAsync to avoid changing main session's active state
// Use fire-and-forget prompt() - awaiting causes JSON parse errors with thinking models
// Note: Don't pass model in body - use agent's configured model instead
let promptError: Error | undefined
await client.session.promptAsync({
client.session.prompt({
path: { id: sessionID },
body: {
agent: agentToUse,
model: categoryModel,
system: systemContent,
tools: {
task: false,
@@ -408,6 +436,9 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
promptError = error instanceof Error ? error : new Error(String(error))
})
// Small delay to let the prompt start
await new Promise(resolve => setTimeout(resolve, 100))
if (promptError) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
@@ -419,21 +450,63 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}`
}
// Poll for session completion
// Poll for session completion with stability detection
// The session may show as "idle" before messages appear, so we also check message stability
const POLL_INTERVAL_MS = 500
const MAX_POLL_TIME_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10000 // Minimum 10s before accepting completion
const STABILITY_POLLS_REQUIRED = 3
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
// Check for async errors that may have occurred after the initial 100ms delay
// TypeScript doesn't understand async mutation, so we cast to check
const asyncError = promptError as Error | undefined
if (asyncError) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
const errorMessage = asyncError.message
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
return `❌ Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\nSession ID: ${sessionID}`
}
return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}`
}
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
// Break if session is idle OR no longer in status (completed and removed)
if (!sessionStatus || sessionStatus.type === "idle") {
break
// If session is actively running, reset stability
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
// Session is idle or not in status - check message stability
const elapsed = Date.now() - pollStart
if (elapsed < MIN_STABILITY_TIME_MS) {
continue // Don't accept completion too early
}
// Get current message count
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= STABILITY_POLLS_REQUIRED) {
break // Messages stable for 3 polls - task complete
}
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
@@ -459,7 +532,8 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
return `❌ No assistant response found.\n\nSession ID: ${sessionID}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text") ?? []
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)

View File

@@ -194,4 +194,4 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
})
}
export const skill = createSkillTool()
export const skill: ToolDefinition = createSkillTool()

View File

@@ -249,4 +249,4 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
}
// Default instance for backward compatibility (lazy loading)
export const slashcommand = createSlashcommandTool()
export const slashcommand: ToolDefinition = createSlashcommandTool()