Compare commits

..

64 Commits

Author SHA1 Message Date
github-actions[bot]
68c8f3dda7 release: v3.1.5 2026-01-28 14:15:42 +00:00
justsisyphus
03f6e72c9b refactor(ultrawork): replace prometheus with plan agent, add parallel task graph output
- Change all prometheus references to plan agent in ultrawork mode
- Add MANDATORY OUTPUT section to ULTRAWORK_PLANNER_SECTION:
  - Parallel Execution Waves structure
  - Dependency Matrix format
  - TODO List with category + skills + parallel group
  - Agent Dispatch Summary table
- Plan agent now outputs parallel task graphs for orchestrator execution
2026-01-28 23:09:51 +09:00
justsisyphus
4fd9f0fd04 refactor(agents): enforce zero user intervention in QA/acceptance criteria
- Prometheus: rename 'Manual QA' to 'Automated Verification Only'
- Prometheus: add explicit ZERO USER INTERVENTION principle
- Prometheus: replace placeholder examples with concrete executable commands
- Metis: add QA automation directives in output format
- Metis: strengthen CRITICAL RULES to forbid user-intervention criteria
2026-01-28 23:00:55 +09:00
github-actions[bot]
4413336724 @youming-ai has signed the CLA in code-yeongyu/oh-my-opencode#1203 2026-01-28 13:04:28 +00:00
Doyoon Kwon
895f366a11 docs: add Ollama streaming NDJSON issue guide and workaround (#1197)
* docs: add Ollama streaming NDJSON issue troubleshooting guide

- Document problem: JSON Parse error when using Ollama with stream: true
- Explain root cause: NDJSON vs single JSON object mismatch
- Provide 3 solutions: disable streaming, avoid tool agents, wait for SDK fix
- Include NDJSON parsing code example for SDK maintainers
- Add curl testing command for verification
- Link to issue #1124 and Ollama API docs

Fixes #1124

* docs: add Ollama provider configuration with streaming workaround

- Add Ollama Provider section to configurations.md
- Document stream: false requirement for Ollama
- Explain NDJSON vs single JSON mismatch
- Provide supported models table (qwen3-coder, ministral-3, lfm2.5-thinking)
- Add troubleshooting steps and curl test command
- Link to troubleshooting guide

feat: add NDJSON parser utility for Ollama streaming responses

- Create src/shared/ollama-ndjson-parser.ts
- Implement parseOllamaStreamResponse() for merging NDJSON lines
- Implement isNDJSONResponse() for format detection
- Add TypeScript interfaces for Ollama message structures
- Include JSDoc with usage examples
- Handle edge cases: malformed lines, stats aggregation

This utility can be contributed to Claude Code SDK for proper NDJSON support.

Related to #1124

* fix: use logger instead of console, remove trailing whitespace

- Replace console.warn with log() from shared/logger
- Remove trailing whitespace from troubleshooting guide
- Ensure TypeScript compatibility
2026-01-28 19:01:33 +09:00
YeonGyu-Kim
acc19fcd41 feat(hooks): auto-disable directory-agents-injector for OpenCode 1.1.37+ native support (#1204)
* feat(delegate-task): add prometheus self-delegation block and delegate_task permission

- Block prometheus from delegating to itself via delegate_task
- Grant delegate_task permission to prometheus when called as subagent
- Other subagents still have delegate_task disabled

* feat(version): add OPENCODE_NATIVE_AGENTS_INJECTION_VERSION constant

* docs: add deprecation notes for directory-agents-injector

* feat(hooks): auto-disable directory-agents-injector for OpenCode 1.1.37+

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-28 18:46:51 +09:00
justsisyphus
68e0a32183 chore(issue-templates): add English language requirement checkbox 2026-01-28 18:24:15 +09:00
justsisyphus
dee89c1556 feat(delegate-task): add prometheus self-delegation block and delegate_task permission
- Block prometheus from delegating to itself via delegate_task
- Grant delegate_task permission to prometheus when called as subagent
- Other subagents still have delegate_task disabled
2026-01-28 18:24:15 +09:00
github-actions[bot]
315c75c51e @rooftop-Owl has signed the CLA in code-yeongyu/oh-my-opencode#1197 2026-01-28 08:47:09 +00:00
YeonGyu-Kim
3dd80889a5 fix(tools): add permission field to session.create() for consistency (#1192) (#1199)
- Add permission field to look_at and call_omo_agent session.create()
- Match pattern used in delegate_task and background-agent
- Add better error messages for Unauthorized failures
- Provide actionable guidance in error messages

This addresses potential session creation failures by ensuring
consistent session configuration across all tools that create
child sessions.
2026-01-28 17:35:25 +09:00
Sisyphus
8f6ed5b20f fix(hooks): add null guard for tool.execute.after output (#1054)
/review command and some Claude Code built-in commands trigger
tool.execute.after hooks with undefined output, causing crashes
when accessing output.metadata or output.output.

Fixes #1035

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-28 16:26:40 +09:00
TheEpTic
01500f1ebe Fix: prevent system-reminder tags from triggering mode keywords (#1155)
Automated system messages with <system-reminder> tags were incorrectly
triggering [search-mode], [analyze-mode], and other keyword modes when
they contained words like "search", "find", "explore", etc.

Changes:
- Add removeSystemReminders() to strip <system-reminder> content before keyword detection
- Add hasSystemReminder() utility function
- Update keyword-detector to clean text before pattern matching
- Add comprehensive test coverage for system-reminder filtering

Fixes issue where automated system notifications caused agents to
incorrectly enter MAXIMUM SEARCH EFFORT mode.

Co-authored-by: TheEpTic <git@eptic.me>
2026-01-28 16:26:37 +09:00
Thanh Nguyen
48f6c5e06d fix(skill): support YAML array format for allowed-tools field (#1163)
Fixes #1021

The allowed-tools field in skill frontmatter now supports both formats:
- Space-separated string: 'allowed-tools: Read Write Edit Bash'
- YAML array: 'allowed-tools: [Read, Write, Edit, Bash]'
- Multi-line YAML array format also works

Previously, skills using YAML array format would silently fail to parse,
causing them to not appear in the <available_skills> list.

Changes:
- Updated parseAllowedTools() in loader.ts, async-loader.ts, and merger.ts
  to handle both string and string[] types
- Updated SkillMetadata type to accept string | string[] for allowed-tools
- Added 4 test cases covering all allowed-tools formats
2026-01-28 16:26:34 +09:00
Moha Abdi
3e32afe646 fix(agent-variant): resolve variant based on current model, not static config (#1179) 2026-01-28 16:26:31 +09:00
Xiaoya Wang
d11c4a1f81 fix: guard JSON.parse(result.stdout) with || "{}" fallback in hook handlers (#1191)
Co-authored-by: wangxiaoya.2000 <wangxiaoya.2000@bytedance.com>
2026-01-28 16:26:28 +09:00
github-actions[bot]
5558ddf468 release: v3.1.4 2026-01-28 07:22:03 +00:00
justsisyphus
aa03d9b811 ci: sync publish.yml test isolation with ci.yml 2026-01-28 16:18:21 +09:00
YeonGyu-Kim
28a0dd06c7 fix: resolve version detection for npm global installations (#1194)
When oh-my-opencode is installed via npm global install and run as a
compiled binary, import.meta.url returns a virtual bun path ($bunfs)
instead of the actual filesystem path. This caused getCachedVersion()
to return null, resulting in 'unknown' version display.

Add fallback using process.execPath which correctly points to the actual
binary location, allowing us to walk up and find the package.json.

Fixes #1182
2026-01-28 15:54:17 +09:00
YeonGyu-Kim
995b7751af ci(cla): add repository owner to CLA allowlist (#1195)
The repository owner (code-yeongyu) was not in the CLA allowlist,
causing CLA signature requirement on their own PRs.

Added code-yeongyu to the allowlist to skip CLA for owner commits.

Co-authored-by: 김연규 <yeongyu@mengmotaMacbookAir.local>
2026-01-28 15:46:42 +09:00
justsisyphus
5087788f66 ci: split test execution to prevent mock.module pollution 2026-01-28 15:06:32 +09:00
justsisyphus
19524c8a27 ci: run tests sequentially to prevent mock.module pollution 2026-01-28 14:59:26 +09:00
justsisyphus
fbb4d46945 fix: explicit reset in mainSessionID test for parallel test safety 2026-01-28 14:40:15 +09:00
justsisyphus
5dc8d577a4 fix: add afterEach cleanup in session-state tests for parallel test isolation 2026-01-28 14:36:58 +09:00
justsisyphus
c249763d7e fix: reset sessionAgentMap in _resetForTesting for test isolation
- Add sessionAgentMap.clear() to _resetForTesting()
- Prevents test pollution when tests run in parallel in CI
2026-01-28 14:33:14 +09:00
justsisyphus
b2d618e851 fix: mock provider cache in delegate-task tests for CI stability
- Add spyOn for readConnectedProvidersCache to return connected providers
- Tests now work consistently regardless of actual provider cache state
- Fixes CI failures for category variant and unstable agent tests
2026-01-28 14:27:34 +09:00
justsisyphus
6f348a8a5c fix: resolve CI test timeouts with configurable timing
- Add timing.ts module for test-only timing configuration
- Replace hardcoded wait times with getTimingConfig()
- Enable all previously skipped tests (ralph-loop, session-state, delegate-task)
- Tests now complete in ~2s instead of timing out
2026-01-28 14:17:56 +09:00
justsisyphus
1da0adcbe8 feat(index): add provider cache missing warning toast
Show warning toast when hasConnectedProvidersCache() returns false,
indicating model filtering is disabled. Prompts user to restart
OpenCode for full functionality.
2026-01-28 13:31:11 +09:00
justsisyphus
8a9d966a3d fix(model-resolver): skip fallback chain when no cache exists
When no provider cache exists, skip the fallback chain entirely and let
OpenCode use Provider.defaultModel() as the final fallback. This prevents
incorrect model selection when the plugin loads before providers connect.

- Remove forced first-entry fallback when no cache
- Add log messages for cache miss scenarios
- Update tests for new behavior
2026-01-28 13:31:03 +09:00
justsisyphus
76f8c500cb fix(config): add 'dev-browser' to BrowserAutomationProviderSchema
Config validation was failing when 'dev-browser' was set as the browser
automation provider, causing the entire config to be rejected. This
silently disabled all config options including tmux.enabled.

- Add 'dev-browser' as valid option in BrowserAutomationProviderSchema
- Update JSDoc with dev-browser description
- Regenerate JSON schema
2026-01-28 12:05:20 +09:00
github-actions[bot]
388516bcc5 @agno01 has signed the CLA in code-yeongyu/oh-my-opencode#1188 2026-01-28 01:02:15 +00:00
github-actions[bot]
8dff875929 @zycaskevin has signed the CLA in code-yeongyu/oh-my-opencode#1184 2026-01-27 16:20:49 +00:00
github-actions[bot]
966cc90a02 release: v3.1.3 2026-01-27 16:12:43 +00:00
justsisyphus
1d27d78127 test: skip flaky sync variant test (CI timeout) 2026-01-28 01:07:14 +09:00
justsisyphus
38156d49f3 ci: use find/xargs to exclude mock-heavy test files 2026-01-28 01:01:45 +09:00
justsisyphus
897eea0263 ci: isolate mock-heavy test files to prevent parallel pollution 2026-01-28 01:00:17 +09:00
justsisyphus
9b59ef66e4 test: fix flaky tests caused by mock.module pollution across parallel test files 2026-01-28 00:54:20 +09:00
github-actions[bot]
0d938059f9 @moha-abdi has signed the CLA in code-yeongyu/oh-my-opencode#1179 2026-01-27 12:36:31 +00:00
github-actions[bot]
9d35f23725 @MoerAI has signed the CLA in code-yeongyu/oh-my-opencode#1172 2026-01-27 09:31:52 +00:00
justsisyphus
aa1646f82c fix(delegate-task): pass variant as top-level field in prompt body
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-27 17:54:58 +09:00
justsisyphus
e47ab084fd fix(keyword-detector): skip ultrawork injection for planner agents
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-27 17:54:52 +09:00
justsisyphus
baf6358736 fix(background-agent): pass variant as top-level field in prompt body 2026-01-27 16:49:03 +09:00
justsisyphus
488c89156b test(config-handler): add tests for plan demote and prometheus mode 2026-01-27 16:06:03 +09:00
justsisyphus
c4957a469d fix(prometheus): set mode to 'all' and restore plan demote logic
- Change prometheus mode from 'primary' to 'all' to allow delegate_task calls
- Restore plan agent demote logic to use prometheus config as base
- Revert d481c596 changes that broke plan agent inheritance
2026-01-27 15:57:45 +09:00
justsisyphus
d481c596bd fix(plan-agent): only inherit model from prometheus as fallback
Plan agent was incorrectly inheriting prometheus's entire config (prompt,
permission, etc.) causing it to behave as primary instead of subagent.

Now plan agent:
1. Uses plan config model if explicitly set
2. Falls back to prometheus model only if plan config has no model
3. Keeps original OpenCode plan config intact
2026-01-27 15:18:28 +09:00
justsisyphus
655d511294 Revert "docs: add v2.x to v3.x migration guide (#1057)"
This PR was incorrectly merged by AI agent without proper project owner review.

This reverts commit 1cb6b3de39a49acb43b76ac55a5b44b47ca4a9f7.
2026-01-27 14:09:37 +09:00
justsisyphus
7dedd6cf90 Revert "Add oh-my-opencode-slim (#1100)"
This PR was incorrectly merged by AI agent without proper project owner review.

The AI evaluated this as 'ULTRA SAFE' because it only modified README files,
but failed to recognize that adding external fork promotions to the project
README requires explicit project owner approval - not just technical safety.

This reverts commit 912a56db85.
2026-01-27 14:09:18 +09:00
justsisyphus
bd18f231f5 feat(sisyphus): add foundation schemas for tasks and swarm (Wave 1)
- Add SisyphusTasksConfig and SisyphusSwarmConfig to schema.ts
- Create Task JSON schema with Zod validation
- Create Mailbox IPC protocol message schemas
- Add storage utilities with Claude Code path compatibility
- 25 tests passing
2026-01-27 13:07:09 +09:00
justsisyphus
de439edc22 feat(subagent): block question tool at both SDK and hook level
- Add permission: [{ permission: 'question', action: 'deny' }] to session.create()
  in background-agent and delegate-task for SDK-level blocking
- Add subagent-question-blocker hook as backup layer to intercept question tool
  calls in tool.execute.before event
- Ensures subagents cannot ask questions to users and must work autonomously
2026-01-27 13:07:09 +09:00
github-actions[bot]
04500bae7d @code-yeongyu has signed the CLA in code-yeongyu/oh-my-opencode#1100 2026-01-27 02:59:24 +00:00
Sisyphus
1cb6b3de7d docs: add v2.x to v3.x migration guide (#1057)
Comprehensive migration guide covering:
- TL;DR quick upgrade section for most users
- What's new in v3.x (Atlas, Prometheus, categories, skills)
- Breaking changes checklist (high/medium/low impact)
- Step-by-step upgrade path
- Configuration changes (categories, permissions)
- API changes for plugin developers
- Troubleshooting common issues
- Complete agent and category reference

Consulted Oracle for migration guide strategy and structure.

Closes #1034 (item 4)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-27 11:59:15 +09:00
Alvin
912a56db85 Add oh-my-opencode-slim (#1100) 2026-01-27 11:59:12 +09:00
itsmylife44
a5d9929c0a feat: support OPENCODE_SERVER_PORT and OPENCODE_SERVER_HOSTNAME env vars (#1157)
Add support for customizing the OpenCode server port and hostname via
environment variables. This enables orchestration tools like Open Agent
to run multiple concurrent missions without port conflicts.

Environment variables:
- OPENCODE_SERVER_PORT: Custom port for the OpenCode server
- OPENCODE_SERVER_HOSTNAME: Custom hostname for the OpenCode server

When running oh-my-opencode in parallel (e.g., multiple missions in
Open Agent), each instance can now use a unique port to avoid conflicts
with the default port 4096.
2026-01-27 11:59:10 +09:00
vmlinuzx
7f43f160b5 docs: clarify category model resolution priority and fallback behavior (#1074)
The previous documentation implied that categories automatically use their
built-in default models (e.g., Gemini for visual, GPT-5.2 for ultrabrain).

This was misleading. Categories only use built-in defaults if explicitly
configured. Otherwise, they fall back to the system default model.

Changes:
- Add explicit warning about model resolution priority
- Document all 7 built-in categories (was only showing 2)
- Show complete example config with all categories
- Explain the wasteful fallback scenario
- Add 'variant' to supported category options

Fixes confusion where users expect optimized model selection but get
system default for all unconfigured categories.

Co-authored-by: DC <vmlinux@p16.tailnet.freeflight.co>
2026-01-27 11:58:59 +09:00
0ln
af67bc8592 fix(mcp): add optional Context7 Authorization header (#1133)
Context7 should mirror `websearch` by only sending auth when
`CONTEXT7_API_KEY` is set.

Change: set bearer auth in `headers` using `CONTEXT7_API_KEY` if said environment variable is set, otherwise leave `headers` to `undefined`.
2026-01-27 11:58:55 +09:00
Peter Rallojay
c74d79e28a fix: prevent builtin MCPs from overwriting user MCP configs (#956) 2026-01-27 11:58:42 +09:00
justsisyphus
fc5298d778 feat(workflow): add ZAI Coding + OpenAI provider for sisyphus-agent
- Add zai-coding-plan provider with GLM 4.7 and GLM 4.6v models
- Add OpenAI provider with GPT-5.2 models
- Configure unspecified-low category to use zai-coding-plan/glm-4.7
- Auth is provided via OPENCODE_AUTH_JSON secret
2026-01-27 10:51:24 +09:00
justsisyphus
3e8e3db961 feat(prompts): enhance plan output with TL;DR, agent profiles, and parallelization
- prometheus-prompt: Add TL;DR section with quick summary, deliverables, effort estimate
- prometheus-prompt: Add recommended agent profile (category + skills) per task
- prometheus-prompt: Enhance parallelization with execution waves and dependency matrix
- ultrawork: Change plan agent to prometheus agent invocation
- ultrawork: Add session_id resume workflow for Prometheus iteration
2026-01-27 10:50:38 +09:00
justsisyphus
6fa5cac616 fix(compaction): preserve agent verification state (#1144) 2026-01-27 10:35:20 +09:00
justsisyphus
158ccabf24 fix(notification): prevent false positive plugin detection (#1148) 2026-01-27 10:35:20 +09:00
justsisyphus
2efbf2650f fix(cli): add baseline builds for non-AVX2 CPUs (#1154) 2026-01-27 10:35:20 +09:00
justsisyphus
acded4ba2a fix(delegate-task): add clear error when model not configured (#1139) 2026-01-27 10:35:20 +09:00
github-actions[bot]
911e43445f @ghtndl has signed the CLA in code-yeongyu/oh-my-opencode#1158 2026-01-27 01:27:26 +00:00
sisyphus-dev-ai
3049e1ebfb chore: changes by sisyphus-dev-ai 2026-01-27 01:10:31 +00:00
github-actions[bot]
62921b9e44 release: v3.1.2 2026-01-27 01:07:09 +00:00
84 changed files with 3920 additions and 282 deletions

View File

@@ -14,6 +14,8 @@ body:
label: Prerequisites
description: Please confirm the following before submitting
options:
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
required: true
- label: I have searched existing issues to avoid duplicates
required: true
- label: I am using the latest version of oh-my-opencode

View File

@@ -14,6 +14,8 @@ body:
label: Prerequisites
description: Please confirm the following before submitting
options:
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
required: true
- label: I have searched existing issues and discussions to avoid duplicates
required: true
- label: This feature request is specific to oh-my-opencode (not OpenCode core)

View File

@@ -14,6 +14,8 @@ body:
label: Prerequisites
description: Please confirm the following before submitting
options:
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
required: true
- label: I have searched existing issues and discussions
required: true
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)

View File

@@ -44,8 +44,34 @@ jobs:
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Run tests
run: bun test
- name: Run mock-heavy tests (isolated)
run: |
# These files use mock.module() which pollutes module cache
# Run them in separate processes to prevent cross-file contamination
bun test src/plugin-handlers
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
- name: Run remaining tests
run: |
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
src/hooks/provider-toast \
src/hooks/session-notification \
src/hooks/sisyphus \
src/hooks/todo-continuation-enforcer \
src/features/background-agent \
src/features/builtin-commands \
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
src/features/opencode-skill-loader \
src/features/skill-mcp-manager
typecheck:
runs-on: ubuntu-latest

View File

@@ -25,7 +25,7 @@ jobs:
path-to-signatures: 'signatures/cla.json'
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
branch: 'dev'
allowlist: bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
custom-notsigned-prcomment: |
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).

View File

@@ -45,8 +45,34 @@ jobs:
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Run tests
run: bun test
- name: Run mock-heavy tests (isolated)
run: |
# These files use mock.module() which pollutes module cache
# Run them in separate processes to prevent cross-file contamination
bun test src/plugin-handlers
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
- name: Run remaining tests
run: |
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
src/hooks/provider-toast \
src/hooks/session-notification \
src/hooks/sisyphus \
src/hooks/todo-continuation-enforcer \
src/features/background-agent \
src/features/builtin-commands \
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
src/features/opencode-skill-loader \
src/features/skill-mcp-manager
typecheck:
runs-on: ubuntu-latest

View File

@@ -152,6 +152,41 @@ jobs:
"limit": { "context": 200000, "output": 64000 }
}
}
} |
.provider["zai-coding-plan"] = {
"name": "Z.AI Coding Plan",
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.z.ai/api/paas/v4"
},
"models": {
"glm-4.7": {
"id": "glm-4.7",
"name": "GLM 4.7",
"limit": { "context": 128000, "output": 16000 }
},
"glm-4.6v": {
"id": "glm-4.6v",
"name": "GLM 4.6 Vision",
"limit": { "context": 128000, "output": 16000 }
}
}
} |
.provider.openai = {
"name": "OpenAI",
"npm": "@ai-sdk/openai",
"models": {
"gpt-5.2": {
"id": "gpt-5.2",
"name": "GPT-5.2",
"limit": { "context": 128000, "output": 16000 }
},
"gpt-5.2-codex": {
"id": "gpt-5.2-codex",
"name": "GPT-5.2 Codex",
"limit": { "context": 128000, "output": 32000 }
}
}
}
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
@@ -287,6 +322,9 @@ jobs:
)
jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
# Add categories configuration for unspecified-low to use GLM 4.7
jq '.categories["unspecified-low"] = { "model": "zai-coding-plan/glm-4.7" }' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
mkdir -p ~/.local/share/opencode
echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json
chmod 600 ~/.local/share/opencode/auth.json

View File

@@ -2768,7 +2768,8 @@
"type": "string",
"enum": [
"playwright",
"agent-browser"
"agent-browser",
"dev-browser"
]
}
}
@@ -2808,6 +2809,50 @@
"minimum": 20
}
}
},
"sisyphus": {
"type": "object",
"properties": {
"tasks": {
"type": "object",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"storage_path": {
"default": ".sisyphus/tasks",
"type": "string"
},
"claude_code_compat": {
"default": false,
"type": "boolean"
}
}
},
"swarm": {
"type": "object",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"storage_path": {
"default": ".sisyphus/teams",
"type": "string"
},
"ui_mode": {
"default": "toast",
"type": "string",
"enum": [
"toast",
"tmux",
"both"
]
}
}
}
}
}
}
}

View File

@@ -27,13 +27,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.1.0",
"oh-my-opencode-darwin-x64": "3.1.0",
"oh-my-opencode-linux-arm64": "3.1.0",
"oh-my-opencode-linux-arm64-musl": "3.1.0",
"oh-my-opencode-linux-x64": "3.1.0",
"oh-my-opencode-linux-x64-musl": "3.1.0",
"oh-my-opencode-windows-x64": "3.1.0",
"oh-my-opencode-darwin-arm64": "3.1.2",
"oh-my-opencode-darwin-x64": "3.1.2",
"oh-my-opencode-linux-arm64": "3.1.2",
"oh-my-opencode-linux-arm64-musl": "3.1.2",
"oh-my-opencode-linux-x64": "3.1.2",
"oh-my-opencode-linux-x64-musl": "3.1.2",
"oh-my-opencode-windows-x64": "3.1.2",
},
},
},
@@ -225,20 +225,6 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8j7XI+n1bz7xIg35Zpjqp1AqoIoFWuVZdYyI9vTAZ0b6ta/mIlNOWPLAbFyEHfKelA9g3Xa+4sYnKPSxU5dQoA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Kd/3KpnF07cw+qBAyLwA0y8tp3S0X8b8HWH55WGlVp6m4gvQ432kKgDum/jat1vqP/3J8hm4P/sly5ibY5gMqw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-qy/QohHGM6eSQjHVEgibsDauUvlAgYPw5xrQqa9cVLo1hL4KMIhb+i4wGAxCK2p84rG2bfC2m8+IfZUxhhwcTg=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-HIO7zj3M5QAYOfgvFM7Djeuen9kdZD4RA51wzXcXiPj1FPAuBNAW9N7lTEGYBSgObgwX+vXnC3HwLSF7nqkw8w=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-zcKaibnEhvbReiTsqbg+dog/Z3pnBx4v6R3AR5nVhGBO27hRSAXgA/fviYyE5bWD591WB7Pqwduf0t854ilKjw=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-xmtHEyAhY93Djg5qEauvMqSF0x3tf8pzOGdKB6CuZmhCG69fZXk/dEwPrO0vKbOeGMV/T4K6HAg1+8Ue1N1ZaQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-pDgHd0mGWWVsiO0fT8C7bi6CziOXU38g+k2dWlGm1YXCMzyrrWZZCF7oIp+EzJB02saSCF/oJ2f1/uj/VPeLMA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],

View File

@@ -85,6 +85,66 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin (`@latest`). It provides multi-account load balancing, variant-based thinking levels, dual quota system (Antigravity + Gemini CLI), and active maintenance. See [Installation > Google Gemini](docs/guide/installation.md#google-gemini-antigravity-oauth).
## Ollama Provider
**IMPORTANT**: When using Ollama as a provider, you **must** disable streaming to avoid JSON parsing errors.
### Required Configuration
```json
{
"agents": {
"explore": {
"model": "ollama/qwen3-coder",
"stream": false
}
}
}
```
### Why `stream: false` is Required
Ollama returns NDJSON (newline-delimited JSON) when streaming is enabled, but Claude Code SDK expects a single JSON object. This causes `JSON Parse error: Unexpected EOF` when agents attempt tool calls.
**Example of the problem**:
```json
// Ollama streaming response (NDJSON - multiple lines)
{"message":{"tool_calls":[...]}, "done":false}
{"message":{"content":""}, "done":true}
// Claude Code SDK expects (single JSON object)
{"message":{"tool_calls":[...], "content":""}, "done":true}
```
### Supported Models
Common Ollama models that work with oh-my-opencode:
| Model | Best For | Configuration |
|-------|----------|---------------|
| `ollama/qwen3-coder` | Code generation, build fixes | `{"model": "ollama/qwen3-coder", "stream": false}` |
| `ollama/ministral-3:14b` | Exploration, codebase search | `{"model": "ollama/ministral-3:14b", "stream": false}` |
| `ollama/lfm2.5-thinking` | Documentation, writing | `{"model": "ollama/lfm2.5-thinking", "stream": false}` |
### Troubleshooting
If you encounter `JSON Parse error: Unexpected EOF`:
1. **Verify `stream: false` is set** in your agent configuration
2. **Check Ollama is running**: `curl http://localhost:11434/api/tags`
3. **Test with curl**:
```bash
curl -s http://localhost:11434/api/chat \
-d '{"model": "qwen3-coder", "messages": [{"role": "user", "content": "Hello"}], "stream": false}'
```
4. **See detailed troubleshooting**: [docs/troubleshooting/ollama-streaming-issue.md](troubleshooting/ollama-streaming-issue.md)
### Future SDK Fix
The proper long-term fix requires Claude Code SDK to parse NDJSON responses correctly. Until then, use `stream: false` as a workaround.
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
## Agents
Override built-in agent settings:
@@ -525,27 +585,96 @@ Configure concurrency limits for background agent tasks. This controls how many
Categories enable domain-specific task delegation via the `delegate_task` tool. Each category applies runtime presets (model, temperature, prompt additions) when calling the `Sisyphus-Junior` agent.
**Default Categories:**
### Built-in Categories
| Category | Model | Description |
| ---------------- | ----------------------------- | ---------------------------------------------------------------------------- |
| `visual` | `google/gemini-3-pro` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
All 7 categories come with optimal model defaults, but **you must configure them to use those defaults**:
**Usage:**
| Category | Built-in Default Model | Description |
| -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
| `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas |
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications|
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
### ⚠️ Critical: Model Resolution Priority
**Categories DO NOT use their built-in defaults unless configured.** Model resolution follows this priority:
```
// Via delegate_task tool
delegate_task(category="visual", prompt="Create a responsive dashboard component")
delegate_task(category="business-logic", prompt="Design the payment processing flow")
1. User-configured model (in oh-my-opencode.json)
2. Category's built-in default (if you add category to config)
3. System default model (from opencode.json)
```
// Or target a specific agent directly
**Example Problem:**
```json
// opencode.json
{ "model": "anthropic/claude-sonnet-4-5" }
// oh-my-opencode.json (empty categories section)
{}
// Result: ALL categories use claude-sonnet-4-5 (wasteful!)
// - quick tasks use Sonnet instead of Haiku (expensive)
// - ultrabrain uses Sonnet instead of GPT-5.2 (inferior reasoning)
// - visual tasks use Sonnet instead of Gemini (suboptimal for UI)
```
### Recommended Configuration
**To use optimal models for each category, add them to your config:**
```json
{
"categories": {
"visual-engineering": {
"model": "google/gemini-3-pro-preview"
},
"ultrabrain": {
"model": "openai/gpt-5.2-codex",
"variant": "xhigh"
},
"artistry": {
"model": "google/gemini-3-pro-preview",
"variant": "max"
},
"quick": {
"model": "anthropic/claude-haiku-4-5" // Fast + cheap for trivial tasks
},
"unspecified-low": {
"model": "anthropic/claude-sonnet-4-5"
},
"unspecified-high": {
"model": "anthropic/claude-opus-4-5",
"variant": "max"
},
"writing": {
"model": "google/gemini-3-flash-preview"
}
}
}
```
**Only configure categories you have access to.** Unconfigured categories fall back to your system default model.
### Usage
```javascript
// Via delegate_task tool
delegate_task(category="visual-engineering", prompt="Create a responsive dashboard component")
delegate_task(category="ultrabrain", prompt="Design the payment processing flow")
// Or target a specific agent directly (bypasses categories)
delegate_task(agent="oracle", prompt="Review this architecture")
```
**Custom Categories:**
### Custom Categories
Add custom categories in `oh-my-opencode.json`:
Add your own categories or override built-in ones:
```json
{
@@ -555,15 +684,15 @@ Add custom categories in `oh-my-opencode.json`:
"temperature": 0.2,
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
},
"visual": {
"model": "google/gemini-3-pro",
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
}
}
}
```
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`.
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`.
## Model Resolution System
@@ -699,6 +828,8 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
**Note on `directory-agents-injector`**: This hook is **automatically disabled** when running on OpenCode 1.1.37+ because OpenCode now has native support for dynamically resolving AGENTS.md files from subdirectories (PR #10678). This prevents duplicate AGENTS.md injection. For older OpenCode versions, the hook remains active to provide the same functionality.
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
## MCPs

View File

@@ -320,7 +320,7 @@ Hooks intercept and modify behavior at key points in the agent lifecycle.
| Hook | Event | Description |
|------|-------|-------------|
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. |
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. **Deprecated for OpenCode 1.1.37+** - Auto-disabled when native AGENTS.md injection is available. |
| **directory-readme-injector** | PostToolUse | Auto-injects README.md for directory context. |
| **rules-injector** | PostToolUse | Injects rules from `.claude/rules/` when conditions match. Supports globs and alwaysApply. |
| **compaction-context-injector** | Stop | Preserves critical context during session compaction. |

View File

@@ -0,0 +1,126 @@
# Ollama Streaming Issue - JSON Parse Error
## Problem
When using Ollama as a provider with oh-my-opencode agents, you may encounter:
```
JSON Parse error: Unexpected EOF
```
This occurs when agents attempt tool calls (e.g., `explore` agent using `mcp_grep_search`).
## Root Cause
Ollama returns **NDJSON** (newline-delimited JSON) when `stream: true` is used in API requests:
```json
{"message":{"tool_calls":[{"function":{"name":"read","arguments":{"filePath":"README.md"}}}]}, "done":false}
{"message":{"content":""}, "done":true}
```
Claude Code SDK expects a single JSON object, not multiple NDJSON lines, causing the parse error.
### Why This Happens
- **Ollama API**: Returns streaming responses as NDJSON by design
- **Claude Code SDK**: Doesn't properly handle NDJSON responses for tool calls
- **oh-my-opencode**: Passes through the SDK's behavior (can't fix at this layer)
## Solutions
### Option 1: Disable Streaming (Recommended - Immediate Fix)
Configure your Ollama provider to use `stream: false`:
```json
{
"provider": "ollama",
"model": "qwen3-coder",
"stream": false
}
```
**Pros:**
- Works immediately
- No code changes needed
- Simple configuration
**Cons:**
- Slightly slower response time (no streaming)
- Less interactive feedback
### Option 2: Use Non-Tool Agents Only
If you need streaming, avoid agents that use tools:
-**Safe**: Simple text generation, non-tool tasks
-**Problematic**: Any agent with tool calls (explore, librarian, etc.)
### Option 3: Wait for SDK Fix (Long-term)
The proper fix requires Claude Code SDK to:
1. Detect NDJSON responses
2. Parse each line separately
3. Merge `tool_calls` from multiple lines
4. Return a single merged response
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
## Workaround Implementation
Until the SDK is fixed, here's how to implement NDJSON parsing (for SDK maintainers):
```typescript
async function parseOllamaStreamResponse(response: string): Promise<object> {
const lines = response.split('\n').filter(line => line.trim());
const mergedMessage = { tool_calls: [] };
for (const line of lines) {
try {
const json = JSON.parse(line);
if (json.message?.tool_calls) {
mergedMessage.tool_calls.push(...json.message.tool_calls);
}
if (json.message?.content) {
mergedMessage.content = json.message.content;
}
} catch (e) {
// Skip malformed lines
console.warn('Skipping malformed NDJSON line:', line);
}
}
return mergedMessage;
}
```
## Testing
To verify the fix works:
```bash
# Test with curl (should work with stream: false)
curl -s http://localhost:11434/api/chat \
-d '{
"model": "qwen3-coder",
"messages": [{"role": "user", "content": "Read file README.md"}],
"stream": false,
"tools": [{"type": "function", "function": {"name": "read", "description": "Read a file", "parameters": {"type": "object", "properties": {"filePath": {"type": "string"}}, "required": ["filePath"]}}}]
}'
```
## Related Issues
- **oh-my-opencode**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
- **Ollama API Docs**: https://github.com/ollama/ollama/blob/main/docs/api.md
## Getting Help
If you encounter this issue:
1. Check your Ollama provider configuration
2. Set `stream: false` as a workaround
3. Report any additional errors to the issue tracker
4. Provide your configuration (without secrets) for debugging

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.1.1",
"version": "3.1.5",
"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",
@@ -73,13 +73,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.1.1",
"oh-my-opencode-darwin-x64": "3.1.1",
"oh-my-opencode-linux-arm64": "3.1.1",
"oh-my-opencode-linux-arm64-musl": "3.1.1",
"oh-my-opencode-linux-x64": "3.1.1",
"oh-my-opencode-linux-x64-musl": "3.1.1",
"oh-my-opencode-windows-x64": "3.1.1"
"oh-my-opencode-darwin-arm64": "3.1.5",
"oh-my-opencode-darwin-x64": "3.1.5",
"oh-my-opencode-linux-arm64": "3.1.5",
"oh-my-opencode-linux-arm64-musl": "3.1.5",
"oh-my-opencode-linux-x64": "3.1.5",
"oh-my-opencode-linux-x64-musl": "3.1.5",
"oh-my-opencode-windows-x64": "3.1.5"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.1.1",
"version": "3.1.5",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -0,0 +1,22 @@
{
"name": "oh-my-opencode-darwin-x64-baseline",
"version": "3.1.1",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"darwin"
],
"cpu": [
"x64"
],
"files": [
"bin"
],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.1.1",
"version": "3.1.5",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.1.1",
"version": "3.1.5",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.1.1",
"version": "3.1.5",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -0,0 +1,25 @@
{
"name": "oh-my-opencode-linux-x64-baseline",
"version": "3.1.1",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"files": [
"bin"
],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

@@ -0,0 +1,25 @@
{
"name": "oh-my-opencode-linux-x64-musl-baseline",
"version": "3.1.1",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"libc": [
"musl"
],
"files": [
"bin"
],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.1.1",
"version": "3.1.5",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.1.1",
"version": "3.1.5",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -0,0 +1,22 @@
{
"name": "oh-my-opencode-windows-x64-baseline",
"version": "3.1.1",
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/code-yeongyu/oh-my-opencode"
},
"os": [
"win32"
],
"cpu": [
"x64"
],
"files": [
"bin"
],
"bin": {
"oh-my-opencode": "./bin/oh-my-opencode.exe"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.1.1",
"version": "3.1.5",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -0,0 +1,79 @@
// script/build-binaries.test.ts
// Tests for platform binary build configuration
import { describe, expect, it } from "bun:test";
// Import PLATFORMS from build-binaries.ts
// We need to export it first, but for now we'll test the expected structure
const EXPECTED_BASELINE_TARGETS = [
"bun-linux-x64-baseline",
"bun-linux-x64-musl-baseline",
"bun-darwin-x64-baseline",
"bun-windows-x64-baseline",
];
describe("build-binaries", () => {
describe("PLATFORMS array", () => {
it("includes baseline variants for non-AVX2 CPU support", async () => {
// given
const module = await import("./build-binaries.ts");
const platforms = (module as { PLATFORMS: { target: string }[] }).PLATFORMS;
const targets = platforms.map((p) => p.target);
// when
const hasAllBaselineTargets = EXPECTED_BASELINE_TARGETS.every((baseline) =>
targets.includes(baseline)
);
// then
expect(hasAllBaselineTargets).toBe(true);
for (const baseline of EXPECTED_BASELINE_TARGETS) {
expect(targets).toContain(baseline);
}
});
it("has correct directory names for baseline platforms", async () => {
// given
const module = await import("./build-binaries.ts");
const platforms = (module as { PLATFORMS: { dir: string; target: string }[] }).PLATFORMS;
// when
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
// then
expect(baselinePlatforms.length).toBe(4);
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-baseline");
expect(baselinePlatforms.map((p) => p.dir)).toContain("linux-x64-musl-baseline");
expect(baselinePlatforms.map((p) => p.dir)).toContain("darwin-x64-baseline");
expect(baselinePlatforms.map((p) => p.dir)).toContain("windows-x64-baseline");
});
it("has correct binary names for baseline platforms", async () => {
// given
const module = await import("./build-binaries.ts");
const platforms = (module as { PLATFORMS: { dir: string; target: string; binary: string }[] }).PLATFORMS;
// when
const windowsBaseline = platforms.find((p) => p.target === "bun-windows-x64-baseline");
const linuxBaseline = platforms.find((p) => p.target === "bun-linux-x64-baseline");
// then
expect(windowsBaseline?.binary).toBe("oh-my-opencode.exe");
expect(linuxBaseline?.binary).toBe("oh-my-opencode");
});
it("has descriptions mentioning no AVX2 for baseline platforms", async () => {
// given
const module = await import("./build-binaries.ts");
const platforms = (module as { PLATFORMS: { target: string; description: string }[] }).PLATFORMS;
// when
const baselinePlatforms = platforms.filter((p) => p.target.includes("baseline"));
// then
for (const platform of baselinePlatforms) {
expect(platform.description).toContain("no AVX2");
}
});
});
});

View File

@@ -13,14 +13,18 @@ interface PlatformTarget {
description: string;
}
const PLATFORMS: PlatformTarget[] = [
export const PLATFORMS: PlatformTarget[] = [
{ dir: "darwin-arm64", target: "bun-darwin-arm64", binary: "oh-my-opencode", description: "macOS ARM64" },
{ dir: "darwin-x64", target: "bun-darwin-x64", binary: "oh-my-opencode", description: "macOS x64" },
{ dir: "darwin-x64-baseline", target: "bun-darwin-x64-baseline", binary: "oh-my-opencode", description: "macOS x64 (no AVX2)" },
{ dir: "linux-x64", target: "bun-linux-x64", binary: "oh-my-opencode", description: "Linux x64 (glibc)" },
{ dir: "linux-x64-baseline", target: "bun-linux-x64-baseline", binary: "oh-my-opencode", description: "Linux x64 (glibc, no AVX2)" },
{ dir: "linux-arm64", target: "bun-linux-arm64", binary: "oh-my-opencode", description: "Linux ARM64 (glibc)" },
{ dir: "linux-x64-musl", target: "bun-linux-x64-musl", binary: "oh-my-opencode", description: "Linux x64 (musl)" },
{ dir: "linux-x64-musl-baseline", target: "bun-linux-x64-musl-baseline", binary: "oh-my-opencode", description: "Linux x64 (musl, no AVX2)" },
{ dir: "linux-arm64-musl", target: "bun-linux-arm64-musl", binary: "oh-my-opencode", description: "Linux ARM64 (musl)" },
{ dir: "windows-x64", target: "bun-windows-x64", binary: "oh-my-opencode.exe", description: "Windows x64" },
{ dir: "windows-x64-baseline", target: "bun-windows-x64-baseline", binary: "oh-my-opencode.exe", description: "Windows x64 (no AVX2)" },
];
const ENTRY_POINT = "src/cli/index.ts";

View File

@@ -879,6 +879,70 @@
"created_at": "2026-01-26T23:20:30Z",
"repoId": 1108837393,
"pullRequestNo": 1157
},
{
"name": "ghtndl",
"id": 117787238,
"comment_id": 3802593326,
"created_at": "2026-01-27T01:27:17Z",
"repoId": 1108837393,
"pullRequestNo": 1158
},
{
"name": "alvinunreal",
"id": 204474669,
"comment_id": 3796402213,
"created_at": "2026-01-25T10:26:58Z",
"repoId": 1108837393,
"pullRequestNo": 1100
},
{
"name": "MoerAI",
"id": 26067127,
"comment_id": 3803968993,
"created_at": "2026-01-27T09:00:57Z",
"repoId": 1108837393,
"pullRequestNo": 1172
},
{
"name": "moha-abdi",
"id": 83307623,
"comment_id": 3804988070,
"created_at": "2026-01-27T12:36:21Z",
"repoId": 1108837393,
"pullRequestNo": 1179
},
{
"name": "zycaskevin",
"id": 223135116,
"comment_id": 3806137669,
"created_at": "2026-01-27T16:20:38Z",
"repoId": 1108837393,
"pullRequestNo": 1184
},
{
"name": "agno01",
"id": 4479380,
"comment_id": 3808373433,
"created_at": "2026-01-28T01:02:02Z",
"repoId": 1108837393,
"pullRequestNo": 1188
},
{
"name": "rooftop-Owl",
"id": 254422872,
"comment_id": 3809867225,
"created_at": "2026-01-28T08:46:58Z",
"repoId": 1108837393,
"pullRequestNo": 1197
},
{
"name": "youming-ai",
"id": 173424537,
"comment_id": 3811195276,
"created_at": "2026-01-28T13:04:16Z",
"repoId": 1108837393,
"pullRequestNo": 1203
}
]
}

View File

@@ -230,6 +230,8 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
- [Risk 2]: [Mitigation]
## Directives for Prometheus
### Core Directives
- MUST: [Required action]
- MUST: [Required action]
- MUST NOT: [Forbidden action]
@@ -237,6 +239,29 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
- PATTERN: Follow \`[file:lines]\`
- TOOL: Use \`[specific tool]\` for [purpose]
### QA/Acceptance Criteria Directives (MANDATORY)
> **ZERO USER INTERVENTION PRINCIPLE**: All acceptance criteria MUST be executable by agents.
- MUST: Write acceptance criteria as executable commands (curl, bun test, playwright actions)
- MUST: Include exact expected outputs, not vague descriptions
- MUST: Specify verification tool for each deliverable type (playwright for UI, curl for API, etc.)
- MUST NOT: Create criteria requiring "user manually tests..."
- MUST NOT: Create criteria requiring "user visually confirms..."
- MUST NOT: Create criteria requiring "user clicks/interacts..."
- MUST NOT: Use placeholders without concrete examples (bad: "[endpoint]", good: "/api/users")
Example of GOOD acceptance criteria:
\`\`\`
curl -s http://localhost:3000/api/health | jq '.status'
# Assert: Output is "ok"
\`\`\`
Example of BAD acceptance criteria (FORBIDDEN):
\`\`\`
User opens browser and checks if the page loads correctly.
User confirms the button works as expected.
\`\`\`
## Recommended Approach
[1-2 sentence summary of how to proceed]
\`\`\`
@@ -263,12 +288,16 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
- Ask generic questions ("What's the scope?")
- Proceed without addressing ambiguity
- Make assumptions about user's codebase
- Suggest acceptance criteria requiring user intervention ("user manually tests", "user confirms", "user clicks")
- Leave QA/acceptance criteria vague or placeholder-heavy
**ALWAYS**:
- Classify intent FIRST
- Be specific ("Should this change UserService only, or also AuthService?")
- Explore before asking (for Build/Research intents)
- Provide actionable directives for Prometheus
- Include QA automation directives in every output
- Ensure acceptance criteria are agent-executable (commands, not human actions)
`
const metisRestrictions = createAgentToolRestrictions([

View File

@@ -863,6 +863,20 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
\`\`\`markdown
# {Plan Title}
## TL;DR
> **Quick Summary**: [1-2 sentences capturing the core objective and approach]
>
> **Deliverables**: [Bullet list of concrete outputs]
> - [Output 1]
> - [Output 2]
>
> **Estimated Effort**: [Quick | Short | Medium | Large | XL]
> **Parallel Execution**: [YES - N waves | NO - sequential]
> **Critical Path**: [Task X → Task Y → Task Z]
---
## Context
### Original Request
@@ -939,53 +953,89 @@ Each TODO follows RED-GREEN-REFACTOR:
- Example: Create \`src/__tests__/example.test.ts\`
- Verify: \`bun test\` → 1 test passes
### If Manual QA Only
### If Automated Verification Only (NO User Intervention)
**CRITICAL**: Without automated tests, manual verification MUST be exhaustive.
> **CRITICAL PRINCIPLE: ZERO USER INTERVENTION**
>
> **NEVER** create acceptance criteria that require:
> - "User manually tests..." / "사용자가 직접 테스트..."
> - "User visually confirms..." / "사용자가 눈으로 확인..."
> - "User interacts with..." / "사용자가 직접 조작..."
> - "Ask user to verify..." / "사용자에게 확인 요청..."
> - ANY step that requires a human to perform an action
>
> **ALL verification MUST be automated and executable by the agent.**
> If a verification cannot be automated, find an automated alternative or explicitly note it as a known limitation.
Each TODO includes detailed verification procedures:
Each TODO includes EXECUTABLE verification procedures that agents can run directly:
**By Deliverable Type:**
| Type | Verification Tool | Procedure |
|------|------------------|-----------|
| **Frontend/UI** | Playwright browser | Navigate, interact, screenshot |
| **TUI/CLI** | interactive_bash (tmux) | Run command, verify output |
| **API/Backend** | curl / httpie | Send request, verify response |
| **Library/Module** | Node/Python REPL | Import, call, verify |
| **Config/Infra** | Shell commands | Apply, verify state |
| Type | Verification Tool | Automated Procedure |
|------|------------------|---------------------|
| **Frontend/UI** | Playwright browser via playwright skill | Agent navigates, clicks, screenshots, asserts DOM state |
| **TUI/CLI** | interactive_bash (tmux) | Agent runs command, captures output, validates expected strings |
| **API/Backend** | curl / httpie via Bash | Agent sends request, parses response, validates JSON fields |
| **Library/Module** | Node/Python REPL via Bash | Agent imports, calls function, compares output |
| **Config/Infra** | Shell commands via Bash | Agent applies config, runs state check, validates output |
**Evidence Required:**
- Commands run with actual output
- Screenshots for visual changes
- Response bodies for API changes
- Terminal output for CLI changes
**Evidence Requirements (Agent-Executable):**
- Command output captured and compared against expected patterns
- Screenshots saved to .sisyphus/evidence/ for visual verification
- JSON response fields validated with specific assertions
- Exit codes checked (0 = success)
---
## Task Flow
## Execution Strategy
### Parallel Execution Waves
> Maximize throughput by grouping independent tasks into parallel waves.
> Each wave completes before the next begins.
\`\`\`
Task 1 → Task 2 → Task 3
↘ Task 4 (parallel)
Wave 1 (Start Immediately):
├── Task 1: [no dependencies]
└── Task 5: [no dependencies]
Wave 2 (After Wave 1):
├── Task 2: [depends: 1]
├── Task 3: [depends: 1]
└── Task 6: [depends: 5]
Wave 3 (After Wave 2):
└── Task 4: [depends: 2, 3]
Critical Path: Task 1 → Task 2 → Task 4
Parallel Speedup: ~40% faster than sequential
\`\`\`
## Parallelization
### Dependency Matrix
| Group | Tasks | Reason |
|-------|-------|--------|
| A | 2, 3 | Independent files |
| Task | Depends On | Blocks | Can Parallelize With |
|------|------------|--------|---------------------|
| 1 | None | 2, 3 | 5 |
| 2 | 1 | 4 | 3, 6 |
| 3 | 1 | 4 | 2, 6 |
| 4 | 2, 3 | None | None (final) |
| 5 | None | 6 | 1 |
| 6 | 5 | None | 2, 3 |
| Task | Depends On | Reason |
|------|------------|--------|
| 4 | 1 | Requires output from 1 |
### Agent Dispatch Summary
| Wave | Tasks | Recommended Agents |
|------|-------|-------------------|
| 1 | 1, 5 | delegate_task(category="...", load_skills=[...], run_in_background=true) |
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
| 3 | 4 | final integration task |
---
## TODOs
> Implementation + Test = ONE Task. Never separate.
> Specify parallelizability for EVERY task.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
- [ ] 1. [Task Title]
@@ -996,7 +1046,21 @@ Task 1 → Task 2 → Task 3
**Must NOT do**:
- [Specific exclusions from guardrails]
**Parallelizable**: YES (with 3, 4) | NO (depends on 0)
**Recommended Agent Profile**:
> Select category + skills based on task domain. Justify each choice.
- **Category**: \`[visual-engineering | ultrabrain | artistry | quick | unspecified-low | unspecified-high | writing]\`
- Reason: [Why this category fits the task domain]
- **Skills**: [\`skill-1\`, \`skill-2\`]
- \`skill-1\`: [Why needed - domain overlap explanation]
- \`skill-2\`: [Why needed - domain overlap explanation]
- **Skills Evaluated but Omitted**:
- \`omitted-skill\`: [Why domain doesn't overlap]
**Parallelization**:
- **Can Run In Parallel**: YES | NO
- **Parallel Group**: Wave N (with Tasks X, Y) | Sequential
- **Blocks**: [Tasks that depend on this task completing]
- **Blocked By**: [Tasks this depends on] | None (can start immediately)
**References** (CRITICAL - Be Exhaustive):
@@ -1029,53 +1093,76 @@ Task 1 → Task 2 → Task 3
**Acceptance Criteria**:
> CRITICAL: Acceptance = EXECUTION, not just "it should work".
> The executor MUST run these commands and verify output.
> **CRITICAL: AGENT-EXECUTABLE VERIFICATION ONLY**
>
> - Acceptance = EXECUTION by the agent, not "user checks if it works"
> - Every criterion MUST be verifiable by running a command or using a tool
> - NO steps like "user opens browser", "user clicks", "user confirms"
> - If you write "[placeholder]" - REPLACE IT with actual values based on task context
**If TDD (tests enabled):**
- [ ] Test file created: \`[path].test.ts\`
- [ ] Test covers: [specific scenario]
- [ ] \`bun test [file]\` → PASS (N tests, 0 failures)
- [ ] Test file created: src/auth/login.test.ts
- [ ] Test covers: successful login returns JWT token
- [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures)
**Manual Execution Verification (ALWAYS include, even with tests):**
**Automated Verification (ALWAYS include, choose by deliverable type):**
*Choose based on deliverable type:*
**For Frontend/UI changes** (using playwright skill):
\\\`\\\`\\\`
# Agent executes via playwright browser automation:
1. Navigate to: http://localhost:3000/login
2. Fill: input[name="email"] with "test@example.com"
3. Fill: input[name="password"] with "password123"
4. Click: button[type="submit"]
5. Wait for: selector ".dashboard-welcome" to be visible
6. Assert: text "Welcome back" appears on page
7. Screenshot: .sisyphus/evidence/task-1-login-success.png
\\\`\\\`\\\`
**For Frontend/UI changes:**
- [ ] Using playwright browser automation:
- Navigate to: \`http://localhost:[port]/[path]\`
- Action: [click X, fill Y, scroll to Z]
- Verify: [visual element appears, animation completes, state changes]
- Screenshot: Save evidence to \`.sisyphus/evidence/[task-id]-[step].png\`
**For TUI/CLI changes** (using interactive_bash):
\\\`\\\`\\\`
# Agent executes via tmux session:
1. Command: ./my-cli --config test.yaml
2. Wait for: "Configuration loaded" in output
3. Send keys: "q" to quit
4. Assert: Exit code 0
5. Assert: Output contains "Goodbye"
\\\`\\\`\\\`
**For TUI/CLI changes:**
- [ ] Using interactive_bash (tmux session):
- Command: \`[exact command to run]\`
- Input sequence: [if interactive, list inputs]
- Expected output contains: \`[expected string or pattern]\`
- Exit code: [0 for success, specific code if relevant]
**For API/Backend changes** (using Bash curl):
\\\`\\\`\\\`bash
# Agent runs:
curl -s -X POST http://localhost:8080/api/users \\
-H "Content-Type: application/json" \\
-d '{"email":"new@test.com","name":"Test User"}' \\
| jq '.id'
# Assert: Returns non-empty UUID
# Assert: HTTP status 201
\\\`\\\`\\\`
**For API/Backend changes:**
- [ ] Request: \`curl -X [METHOD] http://localhost:[port]/[endpoint] -H "Content-Type: application/json" -d '[body]'\`
- [ ] Response status: [200/201/etc]
- [ ] Response body contains: \`{"key": "expected_value"}\`
**For Library/Module changes** (using Bash node/bun):
\\\`\\\`\\\`bash
# Agent runs:
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('test@example.com'))"
# Assert: Output is "true"
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('invalid'))"
# Assert: Output is "false"
\\\`\\\`\\\`
**For Library/Module changes:**
- [ ] REPL verification:
\`\`\`
> import { [function] } from '[module]'
> [function]([args])
Expected: [output]
\`\`\`
**For Config/Infra changes** (using Bash):
\\\`\\\`\\\`bash
# Agent runs:
docker compose up -d
# Wait 5s for containers
docker compose ps --format json | jq '.[].State'
# Assert: All states are "running"
\\\`\\\`\\\`
**For Config/Infra changes:**
- [ ] Apply: \`[command to apply config]\`
- [ ] Verify state: \`[command to check state]\`\`[expected output]\`
**Evidence Required:**
- [ ] Command output captured (copy-paste actual terminal output)
- [ ] Screenshot saved (for visual changes)
- [ ] Response body logged (for API changes)
**Evidence to Capture:**
- [ ] Terminal output from verification commands (actual output, not expected)
- [ ] Screenshot files in .sisyphus/evidence/ for UI changes
- [ ] JSON response bodies for API changes
**Commit**: YES | NO (groups with N)
- Message: \`type(scope): desc\`

View File

@@ -1,7 +1,8 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { describe, test, expect, beforeEach, spyOn, afterEach } from "bun:test"
import { createBuiltinAgents } from "./utils"
import type { AgentConfig } from "@opencode-ai/sdk"
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
import * as connectedProvidersCache from "../shared/connected-providers-cache"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
@@ -46,17 +47,32 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
})
test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => {
// #given - no available models simulates CI without model cache
test("Oracle uses connected provider when no availableModels but connected cache exists", async () => {
// #given - connected providers cache exists with openai
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then - uses first fallback entry (openai/gpt-5.2) instead of system default
// #then - uses openai from connected cache
expect(agents.oracle.model).toBe("openai/gpt-5.2")
expect(agents.oracle.reasoningEffort).toBe("medium")
expect(agents.oracle.textVerbosity).toBe("high")
expect(agents.oracle.thinking).toBeUndefined()
cacheSpy.mockRestore()
})
test("Oracle created without model field when no cache exists (first run scenario)", async () => {
// #given - no cache at all (first run)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then - oracle should be created with system default model (fallback to systemDefaultModel)
expect(agents.oracle).toBeDefined()
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
cacheSpy.mockRestore()
})
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
@@ -107,26 +123,42 @@ describe("createBuiltinAgents with model overrides", () => {
})
describe("createBuiltinAgents without systemDefaultModel", () => {
test("creates agents successfully without systemDefaultModel", async () => {
// #given - no systemDefaultModel provided
test("creates agents with connected provider when cache exists", async () => {
// #given - connected providers cache exists
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - agents should still be created using fallback chain
// #then - agents should use connected provider from fallback chain
expect(agents.oracle).toBeDefined()
expect(agents.oracle.model).toBe("openai/gpt-5.2")
cacheSpy.mockRestore()
})
test("sisyphus uses fallback chain when systemDefaultModel undefined", async () => {
// #given - no systemDefaultModel
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
// #given - no cache and no system default
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - sisyphus should use its fallback chain
// #then - oracle should NOT be created (resolveModelWithFallback returns undefined)
expect(agents.oracle).toBeUndefined()
cacheSpy.mockRestore()
})
test("sisyphus uses connected provider when cache exists", async () => {
// #given - connected providers cache exists with anthropic
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - sisyphus should use anthropic from connected cache
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
cacheSpy.mockRestore()
})
})

View File

@@ -31,8 +31,18 @@ export async function run(options: RunOptions): Promise<number> {
}
try {
// Support custom OpenCode server port via environment variable
// This allows Open Agent and other orchestrators to run multiple
// concurrent missions without port conflicts
const serverPort = process.env.OPENCODE_SERVER_PORT
? parseInt(process.env.OPENCODE_SERVER_PORT, 10)
: undefined
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined
const { client, server } = await createOpencode({
signal: abortController.signal,
...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}),
...(serverHostname ? { hostname: serverHostname } : {}),
})
const cleanup = () => {

View File

@@ -313,13 +313,14 @@ export const GitMasterConfigSchema = z.object({
include_co_authored_by: z.boolean().default(true),
})
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser"])
export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser", "dev-browser"])
export const BrowserAutomationConfigSchema = z.object({
/**
* Browser automation provider to use for the "playwright" skill.
* - "playwright": Uses Playwright MCP server (@playwright/mcp) - default
* - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser)
* - "dev-browser": Uses dev-browser skill with persistent browser state
*/
provider: BrowserAutomationProviderSchema.default("playwright"),
})
@@ -339,6 +340,29 @@ export const TmuxConfigSchema = z.object({
main_pane_min_width: z.number().min(40).default(120),
agent_pane_min_width: z.number().min(20).default(40),
})
export const SisyphusTasksConfigSchema = z.object({
/** Enable Sisyphus Tasks system (default: false) */
enabled: z.boolean().default(false),
/** Storage path for tasks (default: .sisyphus/tasks) */
storage_path: z.string().default(".sisyphus/tasks"),
/** Enable Claude Code path compatibility mode */
claude_code_compat: z.boolean().default(false),
})
export const SisyphusSwarmConfigSchema = z.object({
/** Enable Sisyphus Swarm system (default: false) */
enabled: z.boolean().default(false),
/** Storage path for teams (default: .sisyphus/teams) */
storage_path: z.string().default(".sisyphus/teams"),
/** UI mode: toast notifications, tmux panes, or both */
ui_mode: z.enum(["toast", "tmux", "both"]).default("toast"),
})
export const SisyphusConfigSchema = z.object({
tasks: SisyphusTasksConfigSchema.optional(),
swarm: SisyphusSwarmConfigSchema.optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
@@ -360,6 +384,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
tmux: TmuxConfigSchema.optional(),
sisyphus: SisyphusConfigSchema.optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
@@ -386,5 +411,8 @@ export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProvider
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
export type SisyphusSwarmConfig = z.infer<typeof SisyphusSwarmConfigSchema>
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -224,7 +224,10 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
},
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
},
@@ -294,11 +297,19 @@ export class BackgroundManager {
// 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)
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
const launchModel = input.model
? { providerID: input.model.providerID, modelID: input.model.modelID }
: undefined
const launchVariant = input.model?.variant
this.client.session.prompt({
path: { id: sessionID },
body: {
agent: input.agent,
...(input.model ? { model: input.model } : {}),
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
...getAgentToolRestrictions(input.agent),
@@ -542,11 +553,18 @@ export class BackgroundManager {
// Use prompt() instead of promptAsync() to properly initialize agent loop
// Include model if task has one (preserved from original launch with category config)
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
const resumeModel = existingTask.model
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
: undefined
const resumeVariant = existingTask.model?.variant
this.client.session.prompt({
path: { id: existingTask.sessionID },
body: {
agent: existingTask.agent,
...(existingTask.model ? { model: existingTask.model } : {}),
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(existingTask.agent),
task: false,

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import {
setSessionAgent,
getSessionAgent,
@@ -13,9 +13,11 @@ describe("claude-code-session-state", () => {
beforeEach(() => {
// #given - clean state before each test
_resetForTesting()
clearSessionAgent("test-session-1")
clearSessionAgent("test-session-2")
clearSessionAgent("test-prometheus-session")
})
afterEach(() => {
// #then - cleanup after each test to prevent pollution
_resetForTesting()
})
describe("setSessionAgent", () => {
@@ -92,9 +94,9 @@ describe("claude-code-session-state", () => {
expect(getMainSessionID()).toBe(mainID)
})
test.skip("should return undefined when not set", () => {
// #given - not set
// TODO: Fix flaky test - parallel test execution causes state pollution
test("should return undefined when not set", () => {
// #given - explicit reset to ensure clean state (parallel test isolation)
_resetForTesting()
// #then
expect(getMainSessionID()).toBeUndefined()
})

View File

@@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined {
export function _resetForTesting(): void {
_mainSessionID = undefined
subagentSessions.clear()
sessionAgentMap.clear()
}
const sessionAgentMap = new Map<string, string>()

View File

@@ -128,8 +128,15 @@ $ARGUMENTS
}
}
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined
// Handle YAML array format: already parsed as string[]
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
// Handle space-separated string format: "Read Write Edit Bash"
return allowedTools.split(/\s+/).filter(Boolean)
}

View File

@@ -268,6 +268,123 @@ Skill body.
} finally {
process.chdir(originalCwd)
}
})
})
describe("allowed-tools parsing", () => {
it("parses space-separated allowed-tools string", async () => {
// #given
const skillContent = `---
name: space-separated-tools
description: Skill with space-separated allowed-tools
allowed-tools: Read Write Edit Bash
---
Skill body.
`
createTestSkill("space-separated-tools", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "space-separated-tools")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("parses YAML inline array allowed-tools", async () => {
// #given
const skillContent = `---
name: yaml-inline-array
description: Skill with YAML inline array allowed-tools
allowed-tools: [Read, Write, Edit, Bash]
---
Skill body.
`
createTestSkill("yaml-inline-array", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "yaml-inline-array")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("parses YAML multi-line array allowed-tools", async () => {
// #given
const skillContent = `---
name: yaml-multiline-array
description: Skill with YAML multi-line array allowed-tools
allowed-tools:
- Read
- Write
- Edit
- Bash
---
Skill body.
`
createTestSkill("yaml-multiline-array", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "yaml-multiline-array")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("returns undefined for skill without allowed-tools", async () => {
// #given
const skillContent = `---
name: no-allowed-tools
description: Skill without allowed-tools field
---
Skill body.
`
createTestSkill("no-allowed-tools", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "no-allowed-tools")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toBeUndefined()
} finally {
process.chdir(originalCwd)
}
})
})
})

View File

@@ -50,8 +50,15 @@ async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | un
return undefined
}
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined
// Handle YAML array format: already parsed as string[]
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
// Handle space-separated string format: "Read Write Edit Bash"
return allowedTools.split(/\s+/).filter(Boolean)
}

View File

@@ -9,6 +9,14 @@ import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { deepMerge } from "../../shared/deep-merge"
function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
return allowedTools.split(/\s+/).filter(Boolean)
}
const SCOPE_PRIORITY: Record<SkillScope, number> = {
builtin: 1,
config: 2,
@@ -119,7 +127,7 @@ $ARGUMENTS
}
const allowedTools = entry["allowed-tools"] ||
(fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined)
(fileMetadata["allowed-tools"] ? parseAllowedToolsFromMetadata(fileMetadata["allowed-tools"]) : undefined)
return {
name,

View File

@@ -13,7 +13,7 @@ export interface SkillMetadata {
license?: string
compatibility?: string
metadata?: Record<string, string>
"allowed-tools"?: string
"allowed-tools"?: string | string[]
mcp?: SkillMcpConfig
}

View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from "bun:test"
import {
MailboxMessageSchema,
PermissionRequestSchema,
PermissionResponseSchema,
ShutdownRequestSchema,
TaskAssignmentSchema,
JoinRequestSchema,
ProtocolMessageSchema,
} from "./types"
describe("MailboxMessageSchema", () => {
//#given a valid mailbox message
//#when parsing
//#then it should succeed
it("parses valid message", () => {
const msg = {
from: "agent-001",
text: '{"type":"idle_notification"}',
timestamp: "2026-01-27T10:00:00Z",
read: false,
}
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
})
//#given a message with optional color
//#when parsing
//#then it should succeed
it("parses message with color", () => {
const msg = {
from: "agent-001",
text: "{}",
timestamp: "2026-01-27T10:00:00Z",
color: "blue",
read: true,
}
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
})
})
describe("ProtocolMessageSchema", () => {
//#given permission_request message
//#when parsing
//#then it should succeed
it("parses permission_request", () => {
const msg = {
type: "permission_request",
requestId: "req-123",
toolName: "Bash",
input: { command: "rm -rf /" },
agentId: "agent-001",
timestamp: Date.now(),
}
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
})
//#given permission_response message
//#when parsing
//#then it should succeed
it("parses permission_response", () => {
const approved = {
type: "permission_response",
requestId: "req-123",
decision: "approved",
updatedInput: { command: "ls" },
}
expect(PermissionResponseSchema.safeParse(approved).success).toBe(true)
const rejected = {
type: "permission_response",
requestId: "req-123",
decision: "rejected",
feedback: "Too dangerous",
}
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
})
//#given shutdown_request message
//#when parsing
//#then it should succeed
it("parses shutdown messages", () => {
const request = { type: "shutdown_request" }
expect(ShutdownRequestSchema.safeParse(request).success).toBe(true)
})
//#given task_assignment message
//#when parsing
//#then it should succeed
it("parses task_assignment", () => {
const msg = {
type: "task_assignment",
taskId: "1",
subject: "Fix bug",
description: "Fix the auth bug",
assignedBy: "team-lead",
timestamp: Date.now(),
}
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
})
//#given join_request message
//#when parsing
//#then it should succeed
it("parses join_request", () => {
const msg = {
type: "join_request",
agentName: "new-agent",
sessionId: "sess-123",
}
expect(JoinRequestSchema.safeParse(msg).success).toBe(true)
})
})

View File

@@ -0,0 +1,153 @@
import { z } from "zod"
export const MailboxMessageSchema = z.object({
from: z.string(),
text: z.string(),
timestamp: z.string(),
color: z.string().optional(),
read: z.boolean(),
})
export type MailboxMessage = z.infer<typeof MailboxMessageSchema>
export const PermissionRequestSchema = z.object({
type: z.literal("permission_request"),
requestId: z.string(),
toolName: z.string(),
input: z.unknown(),
agentId: z.string(),
timestamp: z.number(),
})
export type PermissionRequest = z.infer<typeof PermissionRequestSchema>
export const PermissionResponseSchema = z.object({
type: z.literal("permission_response"),
requestId: z.string(),
decision: z.enum(["approved", "rejected"]),
updatedInput: z.unknown().optional(),
feedback: z.string().optional(),
permissionUpdates: z.unknown().optional(),
})
export type PermissionResponse = z.infer<typeof PermissionResponseSchema>
export const ShutdownRequestSchema = z.object({
type: z.literal("shutdown_request"),
})
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
export const ShutdownApprovedSchema = z.object({
type: z.literal("shutdown_approved"),
})
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
export const ShutdownRejectedSchema = z.object({
type: z.literal("shutdown_rejected"),
reason: z.string().optional(),
})
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
export const TaskAssignmentSchema = z.object({
type: z.literal("task_assignment"),
taskId: z.string(),
subject: z.string(),
description: z.string(),
assignedBy: z.string(),
timestamp: z.number(),
})
export type TaskAssignment = z.infer<typeof TaskAssignmentSchema>
export const TaskCompletedSchema = z.object({
type: z.literal("task_completed"),
taskId: z.string(),
agentId: z.string(),
timestamp: z.number(),
})
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
export const IdleNotificationSchema = z.object({
type: z.literal("idle_notification"),
})
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
export const JoinRequestSchema = z.object({
type: z.literal("join_request"),
agentName: z.string(),
sessionId: z.string(),
})
export type JoinRequest = z.infer<typeof JoinRequestSchema>
export const JoinApprovedSchema = z.object({
type: z.literal("join_approved"),
agentName: z.string(),
teamName: z.string(),
})
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
export const JoinRejectedSchema = z.object({
type: z.literal("join_rejected"),
reason: z.string().optional(),
})
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
export const PlanApprovalRequestSchema = z.object({
type: z.literal("plan_approval_request"),
requestId: z.string(),
plan: z.string(),
agentId: z.string(),
})
export type PlanApprovalRequest = z.infer<typeof PlanApprovalRequestSchema>
export const PlanApprovalResponseSchema = z.object({
type: z.literal("plan_approval_response"),
requestId: z.string(),
decision: z.enum(["approved", "rejected"]),
feedback: z.string().optional(),
})
export type PlanApprovalResponse = z.infer<typeof PlanApprovalResponseSchema>
export const ModeSetRequestSchema = z.object({
type: z.literal("mode_set_request"),
mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]),
})
export type ModeSetRequest = z.infer<typeof ModeSetRequestSchema>
export const TeamPermissionUpdateSchema = z.object({
type: z.literal("team_permission_update"),
permissions: z.record(z.string(), z.unknown()),
})
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
export const ProtocolMessageSchema = z.discriminatedUnion("type", [
PermissionRequestSchema,
PermissionResponseSchema,
ShutdownRequestSchema,
ShutdownApprovedSchema,
ShutdownRejectedSchema,
TaskAssignmentSchema,
TaskCompletedSchema,
IdleNotificationSchema,
JoinRequestSchema,
JoinApprovedSchema,
JoinRejectedSchema,
PlanApprovalRequestSchema,
PlanApprovalResponseSchema,
ModeSetRequestSchema,
TeamPermissionUpdateSchema,
])
export type ProtocolMessage = z.infer<typeof ProtocolMessageSchema>

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { join } from "path"
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs"
import { z } from "zod"
import {
getTaskDir,
getTaskPath,
getTeamDir,
getInboxPath,
ensureDir,
readJsonSafe,
writeJsonAtomic,
} from "./storage"
const TEST_DIR = join(import.meta.dirname, ".test-storage")
describe("Storage Utilities", () => {
beforeEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("getTaskDir", () => {
//#given default config (no claude_code_compat)
//#when getting task directory
//#then it should return .sisyphus/tasks/{listId}
it("returns sisyphus path by default", () => {
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
const result = getTaskDir("list-123", config as any)
expect(result).toContain(".sisyphus/tasks/list-123")
})
//#given claude_code_compat enabled
//#when getting task directory
//#then it should return Claude Code path
it("returns claude code path when compat enabled", () => {
const config = {
sisyphus: {
tasks: {
storage_path: ".sisyphus/tasks",
claude_code_compat: true,
},
},
}
const result = getTaskDir("list-123", config as any)
expect(result).toContain(".cache/claude-code/tasks/list-123")
})
})
describe("getTaskPath", () => {
//#given list and task IDs
//#when getting task path
//#then it should return path to task JSON file
it("returns path to task JSON", () => {
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
const result = getTaskPath("list-123", "1", config as any)
expect(result).toContain("list-123/1.json")
})
})
describe("getTeamDir", () => {
//#given team name and default config
//#when getting team directory
//#then it should return .sisyphus/teams/{teamName}
it("returns sisyphus team path", () => {
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
const result = getTeamDir("my-team", config as any)
expect(result).toContain(".sisyphus/teams/my-team")
})
})
describe("getInboxPath", () => {
//#given team and agent names
//#when getting inbox path
//#then it should return path to inbox JSON file
it("returns path to inbox JSON", () => {
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
const result = getInboxPath("my-team", "agent-001", config as any)
expect(result).toContain("my-team/inboxes/agent-001.json")
})
})
describe("ensureDir", () => {
//#given a non-existent directory path
//#when calling ensureDir
//#then it should create the directory
it("creates directory if not exists", () => {
const dirPath = join(TEST_DIR, "new-dir", "nested")
ensureDir(dirPath)
expect(existsSync(dirPath)).toBe(true)
})
//#given an existing directory
//#when calling ensureDir
//#then it should not throw
it("does not throw for existing directory", () => {
const dirPath = join(TEST_DIR, "existing")
mkdirSync(dirPath, { recursive: true })
expect(() => ensureDir(dirPath)).not.toThrow()
})
})
describe("readJsonSafe", () => {
//#given a valid JSON file matching schema
//#when reading with readJsonSafe
//#then it should return parsed object
it("reads and parses valid JSON", () => {
const testSchema = z.object({ name: z.string(), value: z.number() })
const filePath = join(TEST_DIR, "test.json")
writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 }))
const result = readJsonSafe(filePath, testSchema)
expect(result).toEqual({ name: "test", value: 42 })
})
//#given a non-existent file
//#when reading with readJsonSafe
//#then it should return null
it("returns null for non-existent file", () => {
const testSchema = z.object({ name: z.string() })
const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema)
expect(result).toBeNull()
})
//#given invalid JSON content
//#when reading with readJsonSafe
//#then it should return null
it("returns null for invalid JSON", () => {
const testSchema = z.object({ name: z.string() })
const filePath = join(TEST_DIR, "invalid.json")
writeFileSync(filePath, "not valid json")
const result = readJsonSafe(filePath, testSchema)
expect(result).toBeNull()
})
//#given JSON that doesn't match schema
//#when reading with readJsonSafe
//#then it should return null
it("returns null for schema mismatch", () => {
const testSchema = z.object({ name: z.string(), required: z.number() })
const filePath = join(TEST_DIR, "mismatch.json")
writeFileSync(filePath, JSON.stringify({ name: "test" }))
const result = readJsonSafe(filePath, testSchema)
expect(result).toBeNull()
})
})
describe("writeJsonAtomic", () => {
//#given data to write
//#when calling writeJsonAtomic
//#then it should write to file atomically
it("writes JSON atomically", () => {
const filePath = join(TEST_DIR, "atomic.json")
const data = { key: "value", number: 123 }
writeJsonAtomic(filePath, data)
const content = readFileSync(filePath, "utf-8")
expect(JSON.parse(content)).toEqual(data)
})
//#given a deeply nested path
//#when calling writeJsonAtomic
//#then it should create parent directories
it("creates parent directories", () => {
const filePath = join(TEST_DIR, "deep", "nested", "file.json")
writeJsonAtomic(filePath, { test: true })
expect(existsSync(filePath)).toBe(true)
})
})
})

View File

@@ -0,0 +1,82 @@
import { join, dirname } from "path"
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"
import { homedir } from "os"
import type { z } from "zod"
import type { OhMyOpenCodeConfig } from "../../config/schema"
export function getTaskDir(listId: string, config: Partial<OhMyOpenCodeConfig>): string {
const tasksConfig = config.sisyphus?.tasks
if (tasksConfig?.claude_code_compat) {
return join(homedir(), ".cache", "claude-code", "tasks", listId)
}
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
return join(process.cwd(), storagePath, listId)
}
export function getTaskPath(listId: string, taskId: string, config: Partial<OhMyOpenCodeConfig>): string {
return join(getTaskDir(listId, config), `${taskId}.json`)
}
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): string {
const swarmConfig = config.sisyphus?.swarm
if (swarmConfig?.storage_path?.includes("claude")) {
return join(homedir(), ".claude", "teams", teamName)
}
const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams"
return join(process.cwd(), storagePath, teamName)
}
export function getInboxPath(teamName: string, agentName: string, config: Partial<OhMyOpenCodeConfig>): string {
return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`)
}
export function ensureDir(dirPath: string): void {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
}
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
try {
if (!existsSync(filePath)) {
return null
}
const content = readFileSync(filePath, "utf-8")
const parsed = JSON.parse(content)
const result = schema.safeParse(parsed)
if (!result.success) {
return null
}
return result.data
} catch {
return null
}
}
export function writeJsonAtomic(filePath: string, data: unknown): void {
const dir = dirname(filePath)
ensureDir(dir)
const tempPath = `${filePath}.tmp.${Date.now()}`
try {
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
renameSync(tempPath, filePath)
} catch (error) {
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
} catch {
// Ignore cleanup errors
}
throw error
}
}

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from "bun:test"
import { TaskSchema, TaskStatusSchema, type Task } from "./types"
describe("TaskSchema", () => {
//#given a valid task object
//#when parsing with TaskSchema
//#then it should succeed
it("parses valid task object", () => {
const validTask = {
id: "1",
subject: "Fix authentication bug",
description: "Users report 401 errors",
status: "pending",
blocks: [],
blockedBy: [],
}
const result = TaskSchema.safeParse(validTask)
expect(result.success).toBe(true)
})
//#given a task with all optional fields
//#when parsing with TaskSchema
//#then it should succeed
it("parses task with optional fields", () => {
const taskWithOptionals = {
id: "2",
subject: "Add unit tests",
description: "Write tests for auth module",
activeForm: "Adding unit tests",
owner: "agent-001",
status: "in_progress",
blocks: ["3"],
blockedBy: ["1"],
metadata: { priority: "high", labels: ["bug"] },
}
const result = TaskSchema.safeParse(taskWithOptionals)
expect(result.success).toBe(true)
})
//#given an invalid status value
//#when parsing with TaskSchema
//#then it should fail
it("rejects invalid status", () => {
const invalidTask = {
id: "1",
subject: "Test",
description: "Test",
status: "invalid_status",
blocks: [],
blockedBy: [],
}
const result = TaskSchema.safeParse(invalidTask)
expect(result.success).toBe(false)
})
//#given missing required fields
//#when parsing with TaskSchema
//#then it should fail
it("rejects missing required fields", () => {
const invalidTask = {
id: "1",
// missing subject, description, status, blocks, blockedBy
}
const result = TaskSchema.safeParse(invalidTask)
expect(result.success).toBe(false)
})
})
describe("TaskStatusSchema", () => {
//#given valid status values
//#when parsing
//#then all should succeed
it("accepts valid statuses", () => {
expect(TaskStatusSchema.safeParse("pending").success).toBe(true)
expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true)
expect(TaskStatusSchema.safeParse("completed").success).toBe(true)
})
})

View File

@@ -0,0 +1,41 @@
import { z } from "zod"
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
export type TaskStatus = z.infer<typeof TaskStatusSchema>
export const TaskSchema = z.object({
id: z.string(),
subject: z.string(),
description: z.string(),
activeForm: z.string().optional(),
owner: z.string().optional(),
status: TaskStatusSchema,
blocks: z.array(z.string()),
blockedBy: z.array(z.string()),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type Task = z.infer<typeof TaskSchema>
export const TaskCreateInputSchema = z.object({
subject: z.string().describe("Task title"),
description: z.string().describe("Detailed description"),
activeForm: z.string().optional().describe("Text shown when in progress"),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
export const TaskUpdateInputSchema = z.object({
taskId: z.string().describe("Task ID to update"),
subject: z.string().optional(),
description: z.string().optional(),
activeForm: z.string().optional(),
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"),
addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"),
owner: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>

View File

@@ -66,6 +66,20 @@ describe("atlas hook", () => {
})
describe("tool.execute.after handler", () => {
test("should handle undefined output gracefully (issue #1035)", async () => {
// #given - hook and undefined output (e.g., from /review command)
const hook = createAtlasHook(createMockPluginInput())
// #when - calling with undefined output
const result = await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID: "session-123" },
undefined as unknown as { title: string; output: string; metadata: Record<string, unknown> }
)
// #then - returns undefined without throwing
expect(result).toBeUndefined()
})
test("should ignore non-delegate_task tools", async () => {
// #given - hook and non-delegate_task tool
const hook = createAtlasHook(createMockPluginInput())
@@ -396,9 +410,9 @@ describe("atlas hook", () => {
)
// #then
expect(output.output).toContain("DELEGATION REQUIRED")
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).toContain("delegate_task")
expect(output.output).toContain("delegate_task")
})
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
@@ -417,7 +431,7 @@ describe("atlas hook", () => {
)
// #then
expect(output.output).toContain("DELEGATION REQUIRED")
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
@@ -438,7 +452,7 @@ describe("atlas hook", () => {
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
@@ -462,7 +476,7 @@ describe("atlas hook", () => {
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
cleanupMessageStorage(nonOrchestratorSession)
})
@@ -526,7 +540,7 @@ describe("atlas hook", () => {
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
@@ -547,7 +561,7 @@ describe("atlas hook", () => {
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
@@ -568,7 +582,7 @@ describe("atlas hook", () => {
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should append reminder for Windows path outside .sisyphus\\", async () => {
@@ -587,7 +601,7 @@ describe("atlas hook", () => {
)
// #then
expect(output.output).toContain("DELEGATION REQUIRED")
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
})
})
@@ -636,7 +650,7 @@ describe("atlas hook", () => {
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
expect(callArgs.body.parts[0].text).toContain("BOULDER CONTINUATION")
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})

View File

@@ -663,6 +663,11 @@ export function createAtlasHook(
input: ToolExecuteAfterInput,
output: ToolExecuteAfterOutput
): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!output) {
return
}
if (!isCallerOrchestrator(input.sessionID)) {
return
}

View File

@@ -170,6 +170,20 @@ export function getCachedVersion(): string | null {
log("[auto-update-checker] Failed to resolve version from current directory:", err)
}
// Fallback for compiled binaries (npm global install)
// process.execPath points to the actual binary location
try {
const execDir = path.dirname(fs.realpathSync(process.execPath))
const pkgPath = findPackageJsonUp(execDir)
if (pkgPath) {
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
}
} catch (err) {
log("[auto-update-checker] Failed to resolve version from execPath:", err)
}
return null
}

View File

@@ -237,6 +237,11 @@ export function createClaudeCodeHooksHook(
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!output) {
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()

View File

@@ -123,7 +123,7 @@ export async function executePostToolUseHooks(
if (result.exitCode === 0 && result.stdout) {
try {
const output = JSON.parse(result.stdout) as PostToolUseOutput
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,

View File

@@ -73,7 +73,7 @@ export async function executePreCompactHooks(
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreCompactOutput
const output = JSON.parse(result.stdout || "{}") as PreCompactOutput
if (output.hookSpecificOutput?.additionalContext) {
collectedContext.push(...output.hookSpecificOutput.additionalContext)

View File

@@ -117,7 +117,7 @@ export async function executePreToolUseHooks(
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreToolUseOutput
const output = JSON.parse(result.stdout || "{}") as PreToolUseOutput
// Handle deprecated decision/reason fields (Claude Code backward compat)
let decision: PermissionDecision | undefined

View File

@@ -93,7 +93,7 @@ export async function executeStopHooks(
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as StopOutput
const output = JSON.parse(result.stdout || "{}") as StopOutput
if (output.stop_hook_active !== undefined) {
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it, mock, beforeEach } from "bun:test"
// Mock dependencies before importing
const mockInjectHookMessage = mock(() => true)
mock.module("../../features/hook-message-injector", () => ({
injectHookMessage: mockInjectHookMessage,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
mock.module("../../shared/system-directive", () => ({
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
SystemDirectiveTypes: {
TODO_CONTINUATION: "TODO CONTINUATION",
RALPH_LOOP: "RALPH LOOP",
BOULDER_CONTINUATION: "BOULDER CONTINUATION",
DELEGATION_REQUIRED: "DELEGATION REQUIRED",
SINGLE_TASK_ONLY: "SINGLE TASK ONLY",
COMPACTION_CONTEXT: "COMPACTION CONTEXT",
CONTEXT_WINDOW_MONITOR: "CONTEXT WINDOW MONITOR",
PROMETHEUS_READ_ONLY: "PROMETHEUS READ-ONLY",
},
}))
import { createCompactionContextInjector } from "./index"
import type { SummarizeContext } from "./index"
describe("createCompactionContextInjector", () => {
beforeEach(() => {
mockInjectHookMessage.mockClear()
})
describe("Agent Verification State preservation", () => {
it("includes Agent Verification State section in compaction prompt", async () => {
// given
const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.85,
directory: "/test/dir",
}
// when
await injector(context)
// then
expect(mockInjectHookMessage).toHaveBeenCalledTimes(1)
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
const injectedPrompt = calls[0]?.[1] ?? ""
expect(injectedPrompt).toContain("Agent Verification State")
expect(injectedPrompt).toContain("Current Agent")
expect(injectedPrompt).toContain("Verification Progress")
})
it("includes Momus-specific context for reviewer agents", async () => {
// given
const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.9,
directory: "/test/dir",
}
// when
await injector(context)
// then
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
const injectedPrompt = calls[0]?.[1] ?? ""
expect(injectedPrompt).toContain("Previous Rejections")
expect(injectedPrompt).toContain("Acceptance Status")
expect(injectedPrompt).toContain("reviewer agents")
})
it("preserves file verification progress in compaction prompt", async () => {
// given
const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.95,
directory: "/test/dir",
}
// when
await injector(context)
// then
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
const injectedPrompt = calls[0]?.[1] ?? ""
expect(injectedPrompt).toContain("Pending Verifications")
expect(injectedPrompt).toContain("Files already verified")
})
})
})

View File

@@ -45,6 +45,15 @@ When summarizing this session, you MUST include the following sections in your s
- User's explicit restrictions or preferences
- Anti-patterns identified during the session
## 7. Agent Verification State (Critical for Reviewers)
- **Current Agent**: What agent is running (momus, oracle, etc.)
- **Verification Progress**: Files already verified/validated
- **Pending Verifications**: Files still needing verification
- **Previous Rejections**: If reviewer agent, what was rejected and why
- **Acceptance Status**: Current state of review process
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
This context is critical for maintaining continuity after compaction.
`

View File

@@ -33,3 +33,4 @@ export { createStartWorkHook } from "./start-work";
export { createAtlasHook } from "./atlas";
export { createDelegateTaskRetryHook } from "./delegate-task-retry";
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";

View File

@@ -49,13 +49,87 @@ You ARE the planner. Your job: create bulletproof work plans.
- External library APIs and constraints
- Similar implementations in OSS (via librarian)
**NEVER plan blind. Context first, plan second.**`
**NEVER plan blind. Context first, plan second.**
---
## MANDATORY OUTPUT: PARALLEL TASK GRAPH + TODO LIST
**YOUR PRIMARY OUTPUT IS A PARALLEL EXECUTION TASK GRAPH.**
When you finalize a plan, you MUST structure it for maximum parallel execution:
### 1. Parallel Execution Waves (REQUIRED)
Analyze task dependencies and group independent tasks into parallel waves:
\`\`\`
Wave 1 (Start Immediately - No Dependencies):
├── Task 1: [description] → category: X, skills: [a, b]
└── Task 4: [description] → category: Y, skills: [c]
Wave 2 (After Wave 1 Completes):
├── Task 2: [depends: 1] → category: X, skills: [a]
├── Task 3: [depends: 1] → category: Z, skills: [d]
└── Task 5: [depends: 4] → category: Y, skills: [c]
Wave 3 (After Wave 2 Completes):
└── Task 6: [depends: 2, 3] → category: X, skills: [a, b]
Critical Path: Task 1 → Task 2 → Task 6
Estimated Parallel Speedup: ~40% faster than sequential
\`\`\`
### 2. Dependency Matrix (REQUIRED)
| Task | Depends On | Blocks | Can Parallelize With |
|------|------------|--------|---------------------|
| 1 | None | 2, 3 | 4 |
| 2 | 1 | 6 | 3, 5 |
| 3 | 1 | 6 | 2, 5 |
| 4 | None | 5 | 1 |
| 5 | 4 | None | 2, 3 |
| 6 | 2, 3 | None | None (final) |
### 3. TODO List Structure (REQUIRED)
Each TODO item MUST include:
\`\`\`markdown
- [ ] N. [Task Title]
**What to do**: [Clear steps]
**Dependencies**: [Task numbers this depends on] | None
**Blocks**: [Task numbers that depend on this]
**Parallel Group**: Wave N (with Tasks X, Y)
**Recommended Agent Profile**:
- **Category**: \`[visual-engineering | ultrabrain | artistry | quick | unspecified-low | unspecified-high | writing]\`
- **Skills**: [\`skill-1\`, \`skill-2\`]
**Acceptance Criteria**: [Verifiable conditions]
\`\`\`
### 4. Agent Dispatch Summary (REQUIRED)
| Wave | Tasks | Dispatch Command |
|------|-------|------------------|
| 1 | 1, 4 | \`delegate_task(category="...", load_skills=[...], run_in_background=true)\` × 2 |
| 2 | 2, 3, 5 | \`delegate_task(...)\` × 3 after Wave 1 completes |
| 3 | 6 | \`delegate_task(...)\` final integration |
**WHY PARALLEL TASK GRAPH IS MANDATORY:**
- Orchestrator (Sisyphus) executes tasks in parallel waves
- Independent tasks run simultaneously via background agents
- Proper dependency tracking prevents race conditions
- Category + skills ensure optimal model routing per task`
/**
* Determines if the agent is a planner-type agent.
* Planner agents should NOT be told to call plan agent (they ARE the planner).
*/
function isPlannerAgent(agentName?: string): boolean {
export function isPlannerAgent(agentName?: string): boolean {
if (!agentName) return false
const lowerName = agentName.toLowerCase()
return lowerName.includes("prometheus") || lowerName.includes("planner") || lowerName === "plan"
@@ -172,21 +246,45 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
| Condition | Action |
|-----------|--------|
| Task has 2+ steps | MUST call Plan agent |
| Task scope unclear | MUST call Plan agent |
| Implementation required | MUST call Plan agent |
| Architecture decision needed | MUST call Plan agent |
| Task has 2+ steps | MUST call plan agent |
| Task scope unclear | MUST call plan agent |
| Implementation required | MUST call plan agent |
| Architecture decision needed | MUST call plan agent |
\`\`\`
delegate_task(subagent_type="plan", prompt="<gathered context + user request>")
\`\`\`
**WHY THIS IS MANDATORY:**
**WHY PLAN AGENT IS MANDATORY:**
- Plan agent analyzes dependencies and parallel execution opportunities
- Plan agent recommends CATEGORY + SKILLS for each task
- Plan agent ensures nothing is missed
- Plan agent outputs a **parallel task graph** with waves and dependencies
- Plan agent provides structured TODO list with category + skills per task
- YOU are an orchestrator, NOT an implementer
### SESSION CONTINUITY WITH PLAN AGENT (CRITICAL)
**Plan agent returns a session_id. USE IT for follow-up interactions.**
| Scenario | Action |
|----------|--------|
| Plan agent asks clarifying questions | \`delegate_task(session_id="{returned_session_id}", prompt="<your answer>")\` |
| Need to refine the plan | \`delegate_task(session_id="{returned_session_id}", prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`delegate_task(session_id="{returned_session_id}", prompt="Add more detail to Task N")\` |
**WHY SESSION_ID IS CRITICAL:**
- Plan agent retains FULL conversation context
- No repeated exploration or context gathering
- Saves 70%+ tokens on follow-ups
- Maintains interview continuity until plan is finalized
\`\`\`
// WRONG: Starting fresh loses all context
delegate_task(subagent_type="plan", prompt="Here's more info...")
// CORRECT: Resume preserves everything
delegate_task(session_id="ses_abc123", prompt="Here's my answer to your question: ...")
\`\`\`
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
---
@@ -199,7 +297,7 @@ delegate_task(subagent_type="plan", prompt="<gathered context + user request>")
|-----------|--------|-----|
| Codebase exploration | delegate_task(subagent_type="explore", run_in_background=true) | Parallel, context-efficient |
| Documentation lookup | delegate_task(subagent_type="librarian", run_in_background=true) | Specialized knowledge |
| Planning | delegate_task(subagent_type="plan") | Structured work breakdown |
| Planning | delegate_task(subagent_type="plan") | Parallel task graph + structured TODO list |
| Architecture/Debugging | delegate_task(subagent_type="oracle") | High-IQ reasoning |
| Implementation | delegate_task(category="...", load_skills=[...]) | Domain-optimized models |
@@ -261,15 +359,23 @@ delegate_task(..., run_in_background=true) // task_id_3
2. **INVOKE PLAN AGENT** (MANDATORY for non-trivial tasks):
\`\`\`
delegate_task(subagent_type="plan", prompt="<context + request>")
result = delegate_task(subagent_type="plan", prompt="<context + request>")
// STORE the session_id for follow-ups!
plan_session_id = result.session_id
\`\`\`
3. **EXECUTE VIA DELEGATION** (category + skills):
3. **ITERATE WITH PLAN AGENT** (if clarification needed):
\`\`\`
// Use session_id to continue the conversation
delegate_task(session_id=plan_session_id, prompt="<answer to plan agent's question>")
\`\`\`
4. **EXECUTE VIA DELEGATION** (category + skills from plan agent's output):
\`\`\`
delegate_task(category="...", load_skills=[...], prompt="<task from plan>")
\`\`\`
4. **VERIFY** against original requirements
5. **VERIFY** against original requirements
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
@@ -344,7 +450,8 @@ THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTIN
1. EXPLORES + LIBRARIANS (background)
2. GATHER -> delegate_task(subagent_type="plan", prompt="<context + request>")
3. WORK BY DELEGATING TO CATEGORY + SKILLS AGENTS
3. ITERATE WITH PLAN AGENT (session_id resume) UNTIL PLAN IS FINALIZED
4. WORK BY DELEGATING TO CATEGORY + SKILLS AGENTS (following plan agent's parallel task graph)
NOW.

View File

@@ -338,6 +338,197 @@ describe("keyword-detector word boundary", () => {
})
})
describe("keyword-detector system-reminder filtering", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
let logSpy: ReturnType<typeof spyOn>
beforeEach(() => {
setMainSession(undefined)
logCalls = []
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
logCalls.push({ msg, data })
})
})
afterEach(() => {
logSpy?.mockRestore()
setMainSession(undefined)
})
function createMockPluginInput() {
return {
client: {
tui: {
showToast: async () => {},
},
},
} as any
}
test("should NOT trigger search mode from keywords inside <system-reminder> tags", async () => {
// #given - message contains search keywords only inside system-reminder tags
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
const output = {
message: {} as Record<string, unknown>,
parts: [{
type: "text",
text: `<system-reminder>
The system will search for the file and find all occurrences.
Please locate and scan the directory.
</system-reminder>`
}],
}
// #when - keyword detection runs on system-reminder content
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger search mode (text should remain unchanged)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
expect(textPart!.text).toContain("<system-reminder>")
})
test("should NOT trigger analyze mode from keywords inside <system-reminder> tags", async () => {
// #given - message contains analyze keywords only inside system-reminder tags
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
const output = {
message: {} as Record<string, unknown>,
parts: [{
type: "text",
text: `<system-reminder>
You should investigate and examine the code carefully.
Research the implementation details.
</system-reminder>`
}],
}
// #when - keyword detection runs on system-reminder content
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger analyze mode
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[analyze-mode]")
expect(textPart!.text).toContain("<system-reminder>")
})
test("should detect keywords in user text even when system-reminder is present", async () => {
// #given - message contains both system-reminder and user search keyword
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
const output = {
message: {} as Record<string, unknown>,
parts: [{
type: "text",
text: `<system-reminder>
System will find and locate files.
</system-reminder>
Please search for the bug in the code.`
}],
}
// #when - keyword detection runs on mixed content
await hook["chat.message"]({ sessionID }, output)
// #then - should trigger search mode from user text only
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[search-mode]")
expect(textPart!.text).toContain("Please search for the bug in the code.")
})
test("should handle multiple system-reminder tags in message", async () => {
// #given - message contains multiple system-reminder blocks with keywords
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
const output = {
message: {} as Record<string, unknown>,
parts: [{
type: "text",
text: `<system-reminder>
First reminder with search and find keywords.
</system-reminder>
User message without keywords.
<system-reminder>
Second reminder with investigate and examine keywords.
</system-reminder>`
}],
}
// #when - keyword detection runs on message with multiple system-reminders
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger any mode (only user text exists, no keywords)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
expect(textPart!.text).not.toContain("[analyze-mode]")
})
test("should handle case-insensitive system-reminder tags", async () => {
// #given - message contains system-reminder with different casing
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
const output = {
message: {} as Record<string, unknown>,
parts: [{
type: "text",
text: `<SYSTEM-REMINDER>
System will search and find files.
</SYSTEM-REMINDER>`
}],
}
// #when - keyword detection runs on uppercase system-reminder
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger search mode
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
})
test("should handle multiline system-reminder content with search keywords", async () => {
// #given - system-reminder with multiline content containing various search keywords
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
const output = {
message: {} as Record<string, unknown>,
parts: [{
type: "text",
text: `<system-reminder>
Commands executed:
- find: searched for pattern
- grep: located file
- scan: completed
Please explore the codebase and discover patterns.
</system-reminder>`
}],
}
// #when - keyword detection runs on multiline system-reminder
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger search mode
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
})
})
describe("keyword-detector agent-specific ultrawork messages", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
let logSpy: ReturnType<typeof spyOn>
@@ -365,7 +556,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
} as any
}
test("should use planner-specific ultrawork message when agent is prometheus", async () => {
test("should skip ultrawork injection when agent is prometheus", async () => {
// #given - collector and prometheus agent
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
@@ -378,16 +569,15 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
// #when - ultrawork keyword detected with prometheus agent
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - should use planner-specific message with "YOU ARE A PLANNER" content
// #then - ultrawork should be skipped for planner agents, text unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toBe("ultrawork plan this feature")
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("plan this feature")
})
test("should use planner-specific ultrawork message when agent name contains 'planner'", async () => {
test("should skip ultrawork injection when agent name contains 'planner'", async () => {
// #given - collector and agent with 'planner' in name
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
@@ -400,12 +590,11 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
// #when - ultrawork keyword detected with planner agent
await hook["chat.message"]({ sessionID, agent: "Prometheus (Planner)" }, output)
// #then - should use planner-specific message
// #then - ultrawork should be skipped, text unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("create a work plan")
expect(textPart!.text).toBe("ulw create a work plan")
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
})
test("should use normal ultrawork message when agent is Sisyphus", async () => {
@@ -452,7 +641,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
expect(textPart!.text).toContain("do something")
})
test("should switch from planner to normal message when agent changes", async () => {
test("should skip ultrawork for prometheus but inject for sisyphus", async () => {
// #given - two sessions, one with prometheus, one with sisyphus
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
@@ -473,11 +662,9 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
}
await hook["chat.message"]({ sessionID: sisyphusSessionID, agent: "sisyphus" }, sisyphusOutput)
// #then - each session should have the correct message type
// #then - prometheus should have no injection, sisyphus should have normal ultrawork
const prometheusTextPart = prometheusOutput.parts.find(p => p.type === "text")
expect(prometheusTextPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(prometheusTextPart!.text).toContain("---")
expect(prometheusTextPart!.text).toContain("plan")
expect(prometheusTextPart!.text).toBe("ultrawork plan")
const sisyphusTextPart = sisyphusOutput.parts.find(p => p.type === "text")
expect(sisyphusTextPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
@@ -514,7 +701,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
clearSessionAgent(sessionID)
})
test("should fall back to input.agent when session state is empty", async () => {
test("should fall back to input.agent when session state is empty and skip ultrawork for prometheus", async () => {
// #given - no session state, only input.agent available
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
@@ -531,11 +718,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
// #when - hook receives input.agent="prometheus" with no session state
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - should use prometheus from input.agent as fallback
// #then - prometheus fallback from input.agent, ultrawork skipped
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("plan this")
expect(textPart!.text).toBe("ultrawork plan this")
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
})
})

View File

@@ -1,7 +1,8 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
import { isPlannerAgent } from "./constants"
import { log } from "../../shared"
import { isSystemDirective } from "../../shared/system-directive"
import { hasSystemReminder, isSystemDirective, removeSystemReminders } from "../../shared/system-directive"
import { getMainSessionID, getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import type { ContextCollector } from "../../features/context-injector"
@@ -31,7 +32,14 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC
}
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), currentAgent)
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
const cleanText = removeSystemReminders(promptText)
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(cleanText), currentAgent)
if (isPlannerAgent(currentAgent)) {
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
}
if (detectedKeywords.length === 0) {
return

View File

@@ -891,40 +891,40 @@ Original task: Build something`
})
describe("API timeout protection", () => {
// FIXME: Flaky in CI - times out intermittently
test.skip("should not hang when session.messages() times out", async () => {
// #given - slow API that takes longer than timeout
const slowMock = {
test("should not hang when session.messages() throws", async () => {
// #given - API that throws (simulates timeout error)
let apiCallCount = 0
const errorMock = {
...createMockPluginInput(),
client: {
...createMockPluginInput().client,
session: {
...createMockPluginInput().client.session,
messages: async () => {
// Simulate slow API (would hang without timeout)
await new Promise((resolve) => setTimeout(resolve, 10000))
return { data: [] }
apiCallCount++
throw new Error("API timeout")
},
},
},
}
const hook = createRalphLoopHook(slowMock as any, {
const hook = createRalphLoopHook(errorMock as any, {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
apiTimeout: 100, // 100ms timeout for test
apiTimeout: 100,
})
hook.startLoop("session-123", "Build something")
// #when - session goes idle (API will timeout)
// #when - session goes idle (API will throw)
const startTime = Date.now()
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
const elapsed = Date.now() - startTime
// #then - should complete within timeout + buffer (not hang for 10s)
expect(elapsed).toBeLessThan(500)
// #then - loop should continue (API timeout = no completion detected)
// #then - should complete quickly (not hang for 10s)
expect(elapsed).toBeLessThan(2000)
// #then - loop should continue (API error = no completion detected)
expect(promptCalls.length).toBe(1)
expect(apiCallCount).toBeGreaterThan(0)
})
})
})

View File

@@ -0,0 +1,82 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createSubagentQuestionBlockerHook } from "./index"
import { subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
describe("createSubagentQuestionBlockerHook", () => {
const hook = createSubagentQuestionBlockerHook()
beforeEach(() => {
_resetForTesting()
})
describe("tool.execute.before", () => {
test("allows question tool for non-subagent sessions", async () => {
//#given
const sessionID = "ses_main"
const input = { tool: "question", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("blocks question tool for subagent sessions", async () => {
//#given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "question", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
})
test("blocks Question tool (case insensitive) for subagent sessions", async () => {
//#given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "Question", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
})
test("blocks AskUserQuestion tool for subagent sessions", async () => {
//#given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "AskUserQuestion", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
})
test("ignores non-question tools for subagent sessions", async () => {
//#given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "bash", sessionID, callID: "call_1" }
const output = { args: { command: "ls" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
})
})

View File

@@ -0,0 +1,29 @@
import type { Hooks } from "@opencode-ai/plugin"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared"
export function createSubagentQuestionBlockerHook(): Hooks {
return {
"tool.execute.before": async (input) => {
const toolName = input.tool?.toLowerCase()
if (toolName !== "question" && toolName !== "askuserquestion") {
return
}
if (!subagentSessions.has(input.sessionID)) {
return
}
log("[subagent-question-blocker] Blocking question tool call from subagent session", {
sessionID: input.sessionID,
tool: input.tool,
})
throw new Error(
"Question tool is disabled for subagent sessions. " +
"Subagents should complete their work autonomously without asking questions to users. " +
"If you need clarification, return to the parent agent with your findings and uncertainties."
)
},
}
}

View File

@@ -34,12 +34,13 @@ import {
createPrometheusMdOnlyHook,
createSisyphusJuniorNotepadHook,
createQuestionLabelTruncatorHook,
createSubagentQuestionBlockerHook,
} from "./hooks";
import {
contextCollector,
createContextInjectorMessagesTransformHook,
} from "./features/context-injector";
import { applyAgentVariant, resolveAgentVariant } from "./shared/agent-variant";
import { applyAgentVariant, resolveAgentVariant, resolveVariantForModel } from "./shared/agent-variant";
import { createFirstMessageVariantGate } from "./shared/first-message-variant";
import {
discoverUserClaudeSkills,
@@ -77,7 +78,7 @@ import { SkillMcpManager } from "./features/skill-mcp-manager";
import { initTaskToastManager } from "./features/task-toast-manager";
import { TmuxSessionManager } from "./features/tmux-subagent";
import { type HookName } from "./config";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive } from "./shared";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive, hasConnectedProvidersCache, getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION } from "./shared";
import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState, getModelLimit } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers";
@@ -135,9 +136,26 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
experimental: pluginConfig.experimental,
})
: null;
const directoryAgentsInjector = isHookEnabled("directory-agents-injector")
? createDirectoryAgentsInjectorHook(ctx)
: null;
// Check for native OpenCode AGENTS.md injection support before creating hook
let directoryAgentsInjector = null;
if (isHookEnabled("directory-agents-injector")) {
const currentVersion = getOpenCodeVersion();
const hasNativeSupport = currentVersion !== null &&
isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION);
if (hasNativeSupport) {
console.warn(
`[oh-my-opencode] directory-agents-injector hook auto-disabled: ` +
`OpenCode ${currentVersion} has native AGENTS.md support (>= ${OPENCODE_NATIVE_AGENTS_INJECTION_VERSION})`
);
log("directory-agents-injector auto-disabled due to native OpenCode support", {
currentVersion,
nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
});
} else {
directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
}
}
const directoryReadmeInjector = isHookEnabled("directory-readme-injector")
? createDirectoryReadmeInjectorHook(ctx)
: null;
@@ -224,6 +242,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
: null;
const questionLabelTruncator = createQuestionLabelTruncatorHook();
const subagentQuestionBlocker = createSubagentQuestionBlockerHook();
const taskResumeInfo = createTaskResumeInfoHook();
@@ -382,13 +401,22 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const message = (output as { message: { variant?: string } }).message
if (firstMessageVariantGate.shouldOverride(input.sessionID)) {
const variant = resolveAgentVariant(pluginConfig, input.agent)
const variant = input.model && input.agent
? resolveVariantForModel(pluginConfig, input.agent, input.model)
: resolveAgentVariant(pluginConfig, input.agent)
if (variant !== undefined) {
message.variant = variant
}
firstMessageVariantGate.markApplied(input.sessionID)
} else {
applyAgentVariant(pluginConfig, input.agent, message)
if (input.model && input.agent && message.variant === undefined) {
const variant = resolveVariantForModel(pluginConfig, input.agent, input.model)
if (variant !== undefined) {
message.variant = variant
}
} else {
applyAgentVariant(pluginConfig, input.agent, message)
}
}
await keywordDetector?.["chat.message"]?.(input, output);
@@ -396,6 +424,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await autoSlashCommand?.["chat.message"]?.(input, output);
await startWork?.["chat.message"]?.(input, output);
if (!hasConnectedProvidersCache()) {
ctx.client.tui.showToast({
body: {
title: "⚠️ Provider Cache Missing",
message: "Model filtering disabled. RESTART OpenCode to enable full functionality.",
variant: "warning" as const,
duration: 6000,
},
}).catch(() => {});
}
if (ralphLoop) {
const parts = (
output as { parts?: Array<{ type: string; text?: string }> }
@@ -555,6 +594,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"tool.execute.before": async (input, output) => {
await subagentQuestionBlocker["tool.execute.before"]?.(input, output);
await questionLabelTruncator["tool.execute.before"]?.(input, output);
await claudeCodeHooks["tool.execute.before"](input, output);
await nonInteractiveEnv?.["tool.execute.before"](input, output);
@@ -634,6 +674,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"tool.execute.after": async (input, output) => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!output) {
return;
}
await claudeCodeHooks["tool.execute.after"](input, output);
await toolOutputTruncator?.["tool.execute.after"](input, output);
await contextWindowMonitor?.["tool.execute.after"](input, output);

View File

@@ -21,7 +21,7 @@ mcp/
| Name | URL | Purpose | Auth |
|------|-----|---------|------|
| websearch | mcp.exa.ai/mcp?tools=web_search_exa | Real-time web search | EXA_API_KEY |
| context7 | mcp.context7.com/mcp | Library docs | None |
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY |
| grep_app | mcp.grep.app | GitHub code search | None |
## THREE-TIER MCP SYSTEM
@@ -61,4 +61,5 @@ const mcps = createBuiltinMcps(["websearch"]) // Disable specific
- **Remote only**: HTTP/SSE, no stdio
- **Disable**: User can set `disabled_mcps: ["name"]` in config
- **Exa**: Requires `EXA_API_KEY` env var
- **Context7**: Optional auth using `CONTEXT7_API_KEY` env var
- **Exa**: Optional auth using `EXA_API_KEY` env var

View File

@@ -2,5 +2,9 @@ export const context7 = {
type: "remote" as const,
url: "https://mcp.context7.com/mcp",
enabled: true,
headers: process.env.CONTEXT7_API_KEY
? { Authorization: `Bearer ${process.env.CONTEXT7_API_KEY}` }
: undefined,
// Disable OAuth auto-detection - Context7 uses API key header, not OAuth
oauth: false as const,
}

View File

@@ -1,6 +1,185 @@
import { describe, test, expect } from "bun:test"
import { resolveCategoryConfig } from "./config-handler"
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { resolveCategoryConfig, createConfigHandler } from "./config-handler"
import type { CategoryConfig } from "../config/schema"
import type { OhMyOpenCodeConfig } from "../config"
mock.module("../agents", () => ({
createBuiltinAgents: async () => ({
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
}),
}))
mock.module("../agents/sisyphus-junior", () => ({
createSisyphusJuniorAgentWithOverrides: () => ({
name: "sisyphus-junior",
prompt: "test",
mode: "subagent",
}),
}))
mock.module("../features/claude-code-command-loader", () => ({
loadUserCommands: async () => ({}),
loadProjectCommands: async () => ({}),
loadOpencodeGlobalCommands: async () => ({}),
loadOpencodeProjectCommands: async () => ({}),
}))
mock.module("../features/builtin-commands", () => ({
loadBuiltinCommands: () => ({}),
}))
mock.module("../features/opencode-skill-loader", () => ({
loadUserSkills: async () => ({}),
loadProjectSkills: async () => ({}),
loadOpencodeGlobalSkills: async () => ({}),
loadOpencodeProjectSkills: async () => ({}),
discoverUserClaudeSkills: async () => [],
discoverProjectClaudeSkills: async () => [],
discoverOpencodeGlobalSkills: async () => [],
discoverOpencodeProjectSkills: async () => [],
}))
mock.module("../features/claude-code-agent-loader", () => ({
loadUserAgents: () => ({}),
loadProjectAgents: () => ({}),
}))
mock.module("../features/claude-code-mcp-loader", () => ({
loadMcpConfigs: async () => ({ servers: {} }),
}))
mock.module("../features/claude-code-plugin-loader", () => ({
loadAllPluginComponents: async () => ({
commands: {},
skills: {},
agents: {},
mcpServers: {},
hooksConfigs: [],
plugins: [],
errors: [],
}),
}))
mock.module("../mcp", () => ({
createBuiltinMcps: () => ({}),
}))
mock.module("../shared", () => ({
log: () => {},
fetchAvailableModels: async () => new Set(["anthropic/claude-opus-4-5"]),
readConnectedProvidersCache: () => null,
}))
mock.module("../shared/opencode-config-dir", () => ({
getOpenCodeConfigPaths: () => ({
global: "/tmp/.config/opencode",
project: "/tmp/.opencode",
}),
}))
mock.module("../shared/permission-compat", () => ({
migrateAgentConfig: (config: Record<string, unknown>) => config,
}))
mock.module("../shared/migration", () => ({
AGENT_NAME_MAP: {},
}))
mock.module("../shared/model-resolver", () => ({
resolveModelWithFallback: () => ({ model: "anthropic/claude-opus-4-5" }),
}))
mock.module("../shared/model-requirements", () => ({
AGENT_MODEL_REQUIREMENTS: {
sisyphus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
oracle: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
librarian: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
explore: { fallbackChain: [{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }] },
"multimodal-looker": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
prometheus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
metis: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
momus: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
atlas: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
},
CATEGORY_MODEL_REQUIREMENTS: {
"visual-engineering": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
ultrabrain: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex" }] },
artistry: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
quick: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" }] },
"unspecified-low": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
"unspecified-high": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
writing: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
},
}))
describe("Plan agent demote behavior", () => {
test("plan agent should be demoted to subagent mode when replacePlan is true", async () => {
// #given
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
replace_plan: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5",
agent: {
plan: {
name: "plan",
mode: "primary",
prompt: "original plan prompt",
},
},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agents = config.agent as Record<string, { mode?: string; name?: string }>
expect(agents.plan).toBeDefined()
expect(agents.plan.mode).toBe("subagent")
expect(agents.plan.name).toBe("plan")
})
test("prometheus should have mode 'all' to be callable via delegate_task", async () => {
// #given
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agents = config.agent as Record<string, { mode?: string }>
expect(agents.prometheus).toBeDefined()
expect(agents.prometheus.mode).toBe("all")
})
})
describe("Prometheus category config resolution", () => {
test("resolves ultrabrain category config", () => {

View File

@@ -254,7 +254,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
name: "prometheus",
...(resolvedModel ? { model: resolvedModel } : {}),
...(variantToUse ? { variant: variantToUse } : {}),
mode: "primary" as const,
mode: "all" as const,
prompt: PROMETHEUS_SYSTEM_PROMPT,
permission: PROMETHEUS_PERMISSION,
description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`,
@@ -307,7 +307,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: {};
const planDemoteConfig = replacePlan && agentConfig["prometheus"]
? { ...agentConfig["prometheus"], name: "plan", mode: "subagent" as const }
? {
...agentConfig["prometheus"],
name: "plan",
mode: "subagent" as const
}
: undefined;
config.agent = {
@@ -381,8 +385,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: { servers: {} };
config.mcp = {
...(config.mcp as Record<string, unknown>),
...createBuiltinMcps(pluginConfig.disabled_mcps),
...(config.mcp as Record<string, unknown>),
...mcpResult.servers,
...pluginComponents.mcpServers,
};

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { OhMyOpenCodeConfig } from "../config"
import { applyAgentVariant, resolveAgentVariant } from "./agent-variant"
import { applyAgentVariant, resolveAgentVariant, resolveVariantForModel } from "./agent-variant"
describe("resolveAgentVariant", () => {
test("returns undefined when agent name missing", () => {
@@ -81,3 +81,117 @@ describe("applyAgentVariant", () => {
expect(message.variant).toBe("max")
})
})
describe("resolveVariantForModel", () => {
test("returns correct variant for anthropic provider", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
// #when
const variant = resolveVariantForModel(config, "sisyphus", model)
// #then
expect(variant).toBe("max")
})
test("returns correct variant for openai provider", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "openai", modelID: "gpt-5.2" }
// #when
const variant = resolveVariantForModel(config, "sisyphus", model)
// #then
expect(variant).toBe("medium")
})
test("returns undefined for provider with no variant in chain", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "google", modelID: "gemini-3-pro" }
// #when
const variant = resolveVariantForModel(config, "sisyphus", model)
// #then
expect(variant).toBeUndefined()
})
test("returns undefined for provider not in chain", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "unknown-provider", modelID: "some-model" }
// #when
const variant = resolveVariantForModel(config, "sisyphus", model)
// #then
expect(variant).toBeUndefined()
})
test("returns undefined for unknown agent", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
// #when
const variant = resolveVariantForModel(config, "nonexistent-agent", model)
// #then
expect(variant).toBeUndefined()
})
test("returns variant for zai-coding-plan provider without variant", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "zai-coding-plan", modelID: "glm-4.7" }
// #when
const variant = resolveVariantForModel(config, "sisyphus", model)
// #then
expect(variant).toBeUndefined()
})
test("falls back to category chain when agent has no requirement", () => {
// #given
const config = {
agents: {
"custom-agent": { category: "ultrabrain" },
},
} as OhMyOpenCodeConfig
const model = { providerID: "openai", modelID: "gpt-5.2-codex" }
// #when
const variant = resolveVariantForModel(config, "custom-agent", model)
// #then
expect(variant).toBe("xhigh")
})
test("returns correct variant for oracle agent with openai", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "openai", modelID: "gpt-5.2" }
// #when
const variant = resolveVariantForModel(config, "oracle", model)
// #then
expect(variant).toBe("high")
})
test("returns correct variant for oracle agent with anthropic", () => {
// #given
const config = {} as OhMyOpenCodeConfig
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
// #when
const variant = resolveVariantForModel(config, "oracle", model)
// #then
expect(variant).toBe("max")
})
})

View File

@@ -1,5 +1,6 @@
import type { OhMyOpenCodeConfig } from "../config"
import { findCaseInsensitive } from "./case-insensitive"
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
export function resolveAgentVariant(
config: OhMyOpenCodeConfig,
@@ -29,6 +30,43 @@ export function resolveAgentVariant(
return config.categories?.[categoryName]?.variant
}
export function resolveVariantForModel(
config: OhMyOpenCodeConfig,
agentName: string,
currentModel: { providerID: string; modelID: string },
): string | undefined {
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentName]
if (agentRequirement) {
return findVariantInChain(agentRequirement.fallbackChain, currentModel.providerID)
}
const agentOverrides = config.agents as
| Record<string, { category?: string }>
| undefined
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
const categoryName = agentOverride?.category
if (categoryName) {
const categoryRequirement = CATEGORY_MODEL_REQUIREMENTS[categoryName]
if (categoryRequirement) {
return findVariantInChain(categoryRequirement.fallbackChain, currentModel.providerID)
}
}
return undefined
}
function findVariantInChain(
fallbackChain: { providers: string[]; model: string; variant?: string }[],
providerID: string,
): string | undefined {
for (const entry of fallbackChain) {
if (entry.providers.includes(providerID)) {
return entry.variant
}
}
return undefined
}
export function applyAgentVariant(
config: OhMyOpenCodeConfig,
agentName: string | undefined,

View File

@@ -118,6 +118,161 @@ describe("external-plugin-detector", () => {
})
})
describe("false positive prevention", () => {
test("should NOT match my-opencode-notifier-fork (suffix variation)", () => {
// #given - plugin with similar name but different suffix
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["my-opencode-notifier-fork"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(false)
expect(result.pluginName).toBeNull()
})
test("should NOT match some-other-plugin/opencode-notifier-like (path with similar name)", () => {
// #given - plugin path containing similar substring
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["some-other-plugin/opencode-notifier-like"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(false)
expect(result.pluginName).toBeNull()
})
test("should NOT match opencode-notifier-extended (prefix match but different package)", () => {
// #given - plugin with prefix match but extended name
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["opencode-notifier-extended"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(false)
expect(result.pluginName).toBeNull()
})
test("should match opencode-notifier exactly", () => {
// #given - exact match
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should match opencode-notifier@1.2.3 (version suffix)", () => {
// #given - version suffix
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["opencode-notifier@1.2.3"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should match @mohak34/opencode-notifier (scoped package)", () => {
// #given - scoped package
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["@mohak34/opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toContain("opencode-notifier")
})
test("should match npm:opencode-notifier (npm prefix)", () => {
// #given - npm prefix
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["npm:opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should match npm:opencode-notifier@2.0.0 (npm prefix with version)", () => {
// #given - npm prefix with version
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["npm:opencode-notifier@2.0.0"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should match file:///path/to/opencode-notifier (file path)", () => {
// #given - file path
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
})
describe("getNotificationConflictWarning", () => {
test("should generate warning message with plugin name", () => {
// #when

View File

@@ -71,14 +71,19 @@ function loadOpencodePlugins(directory: string): string[] {
function matchesNotificationPlugin(entry: string): string | null {
const normalized = entry.toLowerCase()
for (const known of KNOWN_NOTIFICATION_PLUGINS) {
if (
normalized === known ||
normalized.startsWith(`${known}@`) ||
normalized.includes(`/${known}`) ||
normalized.endsWith(`/${known}`)
) {
return known
}
// Exact match
if (normalized === known) return known
// Version suffix: "opencode-notifier@1.2.3"
if (normalized.startsWith(`${known}@`)) return known
// Scoped package: "@mohak34/opencode-notifier" or "@mohak34/opencode-notifier@1.2.3"
if (normalized === `@mohak34/${known}` || normalized.startsWith(`@mohak34/${known}@`)) return known
// npm: prefix
if (normalized === `npm:${known}` || normalized.startsWith(`npm:${known}@`)) return known
// file:// path ending exactly with package name
if (normalized.startsWith("file://") && (
normalized.endsWith(`/${known}`) ||
normalized.endsWith(`\\${known}`)
)) return known
}
return null
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test"
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
import * as logger from "./logger"
import * as connectedProvidersCache from "./connected-providers-cache"
describe("resolveModel", () => {
describe("priority chain", () => {
@@ -336,8 +337,48 @@ describe("resolveModelWithFallback", () => {
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
})
test("uses first fallback entry when availableModels is empty (no cache scenario)", () => {
// #given - empty availableModels simulates CI environment without model cache
test("returns undefined when availableModels empty and no connected providers cache exists", () => {
// #given - both model cache and connected-providers cache are missing (first run)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels: new Set(),
systemDefaultModel: undefined, // no system default configured
}
// #when
const result = resolveModelWithFallback(input)
// #then - should return undefined to let OpenCode use Provider.defaultModel()
expect(result).toBeUndefined()
cacheSpy.mockRestore()
})
test("uses connected provider when availableModels empty but connected providers cache exists", () => {
// #given - model cache missing but connected-providers cache exists
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "openai"], model: "claude-opus-4-5" },
],
availableModels: new Set(),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then - should use openai (second provider) since anthropic not in connected cache
expect(result!.model).toBe("openai/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
cacheSpy.mockRestore()
})
test("falls through to system default when no cache and systemDefaultModel is provided", () => {
// #given - no cache but system default is configured
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
@@ -349,9 +390,10 @@ describe("resolveModelWithFallback", () => {
// #when
const result = resolveModelWithFallback(input)
// #then - should use first fallback entry, not system default
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
// #then - should fall through to system default
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("system-default")
cacheSpy.mockRestore()
})
test("returns system default when fallbackChain is not provided", () => {

View File

@@ -58,25 +58,26 @@ export function resolveModelWithFallback(
const connectedProviders = readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet === null || connectedSet.has(provider)) {
const model = `${provider}/${entry.model}`
log("Model resolved via fallback chain (no model cache, using connected provider)", {
provider,
model: entry.model,
variant: entry.variant,
hasConnectedCache: connectedSet !== null
})
return { model, source: "provider-fallback", variant: entry.variant }
// When no cache exists at all, skip fallback chain and fall through to system default
// This allows OpenCode to use Provider.defaultModel() as the final fallback
if (connectedSet === null) {
log("No cache available, skipping fallback chain to use system default")
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const model = `${provider}/${entry.model}`
log("Model resolved via fallback chain (no model cache, using connected provider)", {
provider,
model: entry.model,
variant: entry.variant,
})
return { model, source: "provider-fallback", variant: entry.variant }
}
}
}
log("No matching provider in connected cache, falling through to system default")
}
const firstEntry = fallbackChain[0]
const firstProvider = firstEntry.providers[0]
const model = `${firstProvider}/${firstEntry.model}`
log("Model resolved via fallback chain (no cache at all, using first entry)", { provider: firstProvider, model: firstEntry.model, variant: firstEntry.variant })
return { model, source: "provider-fallback", variant: firstEntry.variant }
}
for (const entry of fallbackChain) {

View File

@@ -0,0 +1,198 @@
/**
* Ollama NDJSON Parser
*
* Parses newline-delimited JSON (NDJSON) responses from Ollama API.
*
* @module ollama-ndjson-parser
* @see https://github.com/code-yeongyu/oh-my-opencode/issues/1124
* @see https://github.com/ollama/ollama/blob/main/docs/api.md
*/
import { log } from "./logger"
/**
* Ollama message structure
*/
export interface OllamaMessage {
tool_calls?: Array<{
function: {
name: string
arguments: Record<string, unknown>
}
}>
content?: string
}
/**
* Ollama NDJSON line structure
*/
export interface OllamaNDJSONLine {
message?: OllamaMessage
done: boolean
total_duration?: number
load_duration?: number
prompt_eval_count?: number
prompt_eval_duration?: number
eval_count?: number
eval_duration?: number
}
/**
* Merged Ollama response
*/
export interface OllamaMergedResponse {
message: OllamaMessage
done: boolean
stats?: {
total_duration?: number
load_duration?: number
prompt_eval_count?: number
prompt_eval_duration?: number
eval_count?: number
eval_duration?: number
}
}
/**
* Parse Ollama streaming NDJSON response into a single merged object.
*
* Ollama returns streaming responses as newline-delimited JSON (NDJSON):
* ```
* {"message":{"tool_calls":[...]}, "done":false}
* {"message":{"content":""}, "done":true}
* ```
*
* This function:
* 1. Splits the response by newlines
* 2. Parses each line as JSON
* 3. Merges tool_calls and content from all lines
* 4. Returns a single merged response
*
* @param response - Raw NDJSON response string from Ollama API
* @returns Merged response with all tool_calls and content combined
* @throws {Error} If no valid JSON lines are found
*
* @example
* ```typescript
* const ndjsonResponse = `
* {"message":{"tool_calls":[{"function":{"name":"read","arguments":{"filePath":"README.md"}}}]}, "done":false}
* {"message":{"content":""}, "done":true}
* `;
*
* const merged = parseOllamaStreamResponse(ndjsonResponse);
* // Result:
* // {
* // message: {
* // tool_calls: [{ function: { name: "read", arguments: { filePath: "README.md" } } }],
* // content: ""
* // },
* // done: true
* // }
* ```
*/
export function parseOllamaStreamResponse(response: string): OllamaMergedResponse {
const lines = response.split("\n").filter((line) => line.trim())
if (lines.length === 0) {
throw new Error("No valid NDJSON lines found in response")
}
const mergedMessage: OllamaMessage = {
tool_calls: [],
content: "",
}
let done = false
let stats: OllamaMergedResponse["stats"] = {}
for (const line of lines) {
try {
const json = JSON.parse(line) as OllamaNDJSONLine
// Merge tool_calls
if (json.message?.tool_calls) {
mergedMessage.tool_calls = [
...(mergedMessage.tool_calls || []),
...json.message.tool_calls,
]
}
// Merge content (concatenate)
if (json.message?.content) {
mergedMessage.content = (mergedMessage.content || "") + json.message.content
}
// Update done flag (final line has done: true)
if (json.done) {
done = true
// Capture stats from final line
stats = {
total_duration: json.total_duration,
load_duration: json.load_duration,
prompt_eval_count: json.prompt_eval_count,
prompt_eval_duration: json.prompt_eval_duration,
eval_count: json.eval_count,
eval_duration: json.eval_duration,
}
}
} catch (error) {
log(`[ollama-ndjson-parser] Skipping malformed NDJSON line: ${line}`, { error })
continue
}
}
return {
message: mergedMessage,
done,
...(Object.keys(stats).length > 0 ? { stats } : {}),
}
}
/**
* Check if a response string is NDJSON format.
*
* NDJSON is identified by:
* - Multiple lines
* - Each line is valid JSON
* - At least one line has "done" field
*
* @param response - Response string to check
* @returns true if response appears to be NDJSON
*
* @example
* ```typescript
* const ndjson = '{"done":false}\n{"done":true}';
* const singleJson = '{"done":true}';
*
* isNDJSONResponse(ndjson); // true
* isNDJSONResponse(singleJson); // false
* ```
*/
export function isNDJSONResponse(response: string): boolean {
const lines = response.split("\n").filter((line) => line.trim())
// Single line is not NDJSON
if (lines.length <= 1) {
return false
}
let hasValidJSON = false
let hasDoneField = false
for (const line of lines) {
try {
const json = JSON.parse(line) as Record<string, unknown>
hasValidJSON = true
if ("done" in json) {
hasDoneField = true
}
} catch {
// If any line fails to parse, it's not NDJSON
return false
}
}
return hasValidJSON && hasDoneField
}

View File

@@ -9,6 +9,7 @@ import {
resetVersionCache,
setVersionCache,
MINIMUM_OPENCODE_VERSION,
OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
} from "./opencode-version"
describe("opencode-version", () => {
@@ -220,4 +221,46 @@ describe("opencode-version", () => {
expect(MINIMUM_OPENCODE_VERSION).toBe("1.1.1")
})
})
describe("OPENCODE_NATIVE_AGENTS_INJECTION_VERSION", () => {
test("is set to 1.1.37", () => {
// #given the native agents injection version constant
// #when exported
// #then it should be 1.1.37 (PR #10678)
expect(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION).toBe("1.1.37")
})
test("version detection works correctly with native agents version", () => {
// #given OpenCode version at or above native agents injection version
setVersionCache("1.1.37")
// #when checking against native agents version
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
// #then returns true (native support available)
expect(result).toBe(true)
})
test("version detection returns false for older versions", () => {
// #given OpenCode version below native agents injection version
setVersionCache("1.1.36")
// #when checking against native agents version
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
// #then returns false (no native support)
expect(result).toBe(false)
})
test("returns true when version detection fails (fail-safe)", () => {
// #given version cannot be detected
setVersionCache(null)
// #when checking against native agents version
const result = isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION)
// #then returns true (assume latest, enable native support)
expect(result).toBe(true)
})
})
})

View File

@@ -6,6 +6,15 @@ import { execSync } from "child_process"
*/
export const MINIMUM_OPENCODE_VERSION = "1.1.1"
/**
* OpenCode version that introduced native AGENTS.md injection.
* PR #10678 merged on Jan 26, 2026 - OpenCode now dynamically resolves
* AGENTS.md files from subdirectories as the agent explores them.
* When this version is detected, the directory-agents-injector hook
* is auto-disabled to prevent duplicate AGENTS.md loading.
*/
export const OPENCODE_NATIVE_AGENTS_INJECTION_VERSION = "1.1.37"
const NOT_CACHED = Symbol("NOT_CACHED")
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED

View File

@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test"
import {
hasSystemReminder,
removeSystemReminders,
isSystemDirective,
createSystemDirective,
} from "./system-directive"
describe("system-directive utilities", () => {
describe("hasSystemReminder", () => {
test("should return true for messages containing <system-reminder> tags", () => {
const text = `<system-reminder>
Some system content
</system-reminder>`
expect(hasSystemReminder(text)).toBe(true)
})
test("should return false for messages without system-reminder tags", () => {
const text = "Just a normal user message"
expect(hasSystemReminder(text)).toBe(false)
})
test("should be case-insensitive for tag names", () => {
const text = `<SYSTEM-REMINDER>content</SYSTEM-REMINDER>`
expect(hasSystemReminder(text)).toBe(true)
})
test("should detect system-reminder in mixed content", () => {
const text = `User text here
<system-reminder>
System content
</system-reminder>
More user text`
expect(hasSystemReminder(text)).toBe(true)
})
test("should handle empty system-reminder tags", () => {
const text = `<system-reminder></system-reminder>`
expect(hasSystemReminder(text)).toBe(true)
})
test("should handle multiline system-reminder content", () => {
const text = `<system-reminder>
Line 1
Line 2
Line 3
</system-reminder>`
expect(hasSystemReminder(text)).toBe(true)
})
})
describe("removeSystemReminders", () => {
test("should remove system-reminder tags and content", () => {
const text = `<system-reminder>
System content that should be removed
</system-reminder>`
expect(removeSystemReminders(text)).toBe("")
})
test("should preserve user text outside system-reminder tags", () => {
const text = `User message here
<system-reminder>
System content to remove
</system-reminder>
More user text`
const result = removeSystemReminders(text)
expect(result).toContain("User message here")
expect(result).toContain("More user text")
expect(result).not.toContain("System content to remove")
})
test("should remove multiple system-reminder blocks", () => {
const text = `<system-reminder>First block</system-reminder>
User text
<system-reminder>Second block</system-reminder>`
const result = removeSystemReminders(text)
expect(result).toContain("User text")
expect(result).not.toContain("First block")
expect(result).not.toContain("Second block")
})
test("should be case-insensitive for tag names", () => {
const text = `<SYSTEM-REMINDER>Content</SYSTEM-REMINDER>`
expect(removeSystemReminders(text)).toBe("")
})
test("should handle nested tags correctly", () => {
const text = `<system-reminder>
Outer content
<inner>Some inner tag</inner>
</system-reminder>`
expect(removeSystemReminders(text)).toBe("")
})
test("should trim whitespace from result", () => {
const text = `
<system-reminder>Remove this</system-reminder>
User text
`
const result = removeSystemReminders(text)
expect(result).toBe("User text")
})
test("should handle empty string input", () => {
expect(removeSystemReminders("")).toBe("")
})
test("should handle text with no system-reminder tags", () => {
const text = "Just normal user text without any system reminders"
expect(removeSystemReminders(text)).toBe(text)
})
test("should preserve code blocks in user text", () => {
const text = `Here's some code:
\`\`\`javascript
const x = 1;
\`\`\`
<system-reminder>System info</system-reminder>`
const result = removeSystemReminders(text)
expect(result).toContain("Here's some code:")
expect(result).toContain("```javascript")
expect(result).not.toContain("System info")
})
})
describe("isSystemDirective", () => {
test("should return true for OH-MY-OPENCODE system directives", () => {
const directive = createSystemDirective("TEST")
expect(isSystemDirective(directive)).toBe(true)
})
test("should return false for system-reminder tags", () => {
const text = `<system-reminder>content</system-reminder>`
expect(isSystemDirective(text)).toBe(false)
})
test("should return false for normal user messages", () => {
expect(isSystemDirective("Just a normal message")).toBe(false)
})
test("should handle leading whitespace", () => {
const directive = ` ${createSystemDirective("TEST")}`
expect(isSystemDirective(directive)).toBe(true)
})
})
describe("integration with keyword detection", () => {
test("should prevent search keywords in system-reminders from triggering mode", () => {
const text = `<system-reminder>
The system will search for the file and find all occurrences.
Please locate and scan the directory.
</system-reminder>`
// After removing system reminders, no search keywords should remain
const cleanText = removeSystemReminders(text)
expect(cleanText).not.toMatch(/\b(search|find|locate|scan)\b/i)
})
test("should preserve search keywords in user text while removing system-reminder keywords", () => {
const text = `<system-reminder>
System will find and locate files.
</system-reminder>
Please search for the bug in the code.`
const cleanText = removeSystemReminders(text)
expect(cleanText).toContain("search")
expect(cleanText).not.toContain("find and locate")
})
test("should handle complex mixed content with multiple modes", () => {
const text = `<system-reminder>
System will search and investigate.
</system-reminder>
User wants to explore the codebase and analyze the implementation.
<system-reminder>
Another system reminder with research keyword.
</system-reminder>`
const cleanText = removeSystemReminders(text)
expect(cleanText).toContain("explore")
expect(cleanText).toContain("analyze")
expect(cleanText).not.toContain("search and investigate")
expect(cleanText).not.toContain("research")
})
})
})

View File

@@ -26,6 +26,26 @@ export function isSystemDirective(text: string): boolean {
return text.trimStart().startsWith(SYSTEM_DIRECTIVE_PREFIX)
}
/**
* Checks if a message contains system-generated content that should be excluded
* from keyword detection and mode triggering.
* @param text - The message text to check
* @returns true if the message contains system-reminder tags
*/
export function hasSystemReminder(text: string): boolean {
return /<system-reminder>[\s\S]*?<\/system-reminder>/i.test(text)
}
/**
* Removes system-reminder tag content from text.
* This prevents automated system messages from triggering mode keywords.
* @param text - The message text to clean
* @returns text with system-reminder content removed
*/
export function removeSystemReminders(text: string): string {
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, "").trim()
}
export const SystemDirectiveTypes = {
TODO_CONTINUATION: "TODO CONTINUATION",
RALPH_LOOP: "RALPH LOOP",

View File

@@ -163,7 +163,10 @@ async function executeSync(
body: {
parentID: toolContext.sessionID,
title: `${args.description} (@${args.subagent_type} subagent)`,
},
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
},
@@ -171,6 +174,17 @@ async function executeSync(
if (createResult.error) {
log(`[call_omo_agent] Session create error:`, createResult.error)
const errorStr = String(createResult.error)
if (errorStr.toLowerCase().includes("unauthorized")) {
return `Error: Failed to create session (Unauthorized). This may be due to:
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
2. Provider authentication issues
3. Session permission inheritance problems
Try using a different provider or API key authentication.
Original error: ${createResult.error}`
}
return `Error: Failed to create session: ${createResult.error}`
}

View File

@@ -0,0 +1,39 @@
let POLL_INTERVAL_MS = 500
let MIN_STABILITY_TIME_MS = 10000
let STABILITY_POLLS_REQUIRED = 3
let WAIT_FOR_SESSION_INTERVAL_MS = 100
let WAIT_FOR_SESSION_TIMEOUT_MS = 30000
let MAX_POLL_TIME_MS = 10 * 60 * 1000
let SESSION_CONTINUATION_STABILITY_MS = 5000
export function getTimingConfig() {
return {
POLL_INTERVAL_MS,
MIN_STABILITY_TIME_MS,
STABILITY_POLLS_REQUIRED,
WAIT_FOR_SESSION_INTERVAL_MS,
WAIT_FOR_SESSION_TIMEOUT_MS,
MAX_POLL_TIME_MS,
SESSION_CONTINUATION_STABILITY_MS,
}
}
export function __resetTimingConfig(): void {
POLL_INTERVAL_MS = 500
MIN_STABILITY_TIME_MS = 10000
STABILITY_POLLS_REQUIRED = 3
WAIT_FOR_SESSION_INTERVAL_MS = 100
WAIT_FOR_SESSION_TIMEOUT_MS = 30000
MAX_POLL_TIME_MS = 10 * 60 * 1000
SESSION_CONTINUATION_STABILITY_MS = 5000
}
export function __setTimingConfig(overrides: Partial<ReturnType<typeof getTimingConfig>>): void {
if (overrides.POLL_INTERVAL_MS !== undefined) POLL_INTERVAL_MS = overrides.POLL_INTERVAL_MS
if (overrides.MIN_STABILITY_TIME_MS !== undefined) MIN_STABILITY_TIME_MS = overrides.MIN_STABILITY_TIME_MS
if (overrides.STABILITY_POLLS_REQUIRED !== undefined) STABILITY_POLLS_REQUIRED = overrides.STABILITY_POLLS_REQUIRED
if (overrides.WAIT_FOR_SESSION_INTERVAL_MS !== undefined) WAIT_FOR_SESSION_INTERVAL_MS = overrides.WAIT_FOR_SESSION_INTERVAL_MS
if (overrides.WAIT_FOR_SESSION_TIMEOUT_MS !== undefined) WAIT_FOR_SESSION_TIMEOUT_MS = overrides.WAIT_FOR_SESSION_TIMEOUT_MS
if (overrides.MAX_POLL_TIME_MS !== undefined) MAX_POLL_TIME_MS = overrides.MAX_POLL_TIME_MS
if (overrides.SESSION_CONTINUATION_STABILITY_MS !== undefined) SESSION_CONTINUATION_STABILITY_MS = overrides.SESSION_CONTINUATION_STABILITY_MS
}

View File

@@ -1,17 +1,35 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES } from "./constants"
import { resolveCategoryConfig } from "./tools"
import type { CategoryConfig } from "../../config/schema"
import { __resetModelCache } from "../../shared/model-availability"
import { clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { __setTimingConfig, __resetTimingConfig } from "./timing"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
// Test constants - systemDefaultModel is required by resolveCategoryConfig
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
describe("sisyphus-task", () => {
let cacheSpy: ReturnType<typeof spyOn>
beforeEach(() => {
__resetModelCache()
clearSkillCache()
__setTimingConfig({
POLL_INTERVAL_MS: 10,
MIN_STABILITY_TIME_MS: 50,
STABILITY_POLLS_REQUIRED: 1,
WAIT_FOR_SESSION_INTERVAL_MS: 10,
WAIT_FOR_SESSION_TIMEOUT_MS: 1000,
MAX_POLL_TIME_MS: 2000,
SESSION_CONTINUATION_STABILITY_MS: 50,
})
cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic", "google", "openai"])
})
afterEach(() => {
__resetTimingConfig()
cacheSpy?.mockRestore()
})
describe("DEFAULT_CATEGORIES", () => {
@@ -201,6 +219,56 @@ describe("sisyphus-task", () => {
// #then proceeds without error - uses fallback chain
expect(result).not.toContain("oh-my-opencode requires a default model")
})
test("returns clear error when no model can be resolved", async () => {
// #given - custom category with no model, no systemDefaultModel, no available models
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({ id: "task-123" }) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) }, // No model configured
model: { list: async () => [] }, // No available models
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
// Custom category with no model defined
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
userCategories: {
"custom-no-model": { temperature: 0.5 }, // No model field
},
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// #when delegating with a custom category that has no model
const result = await tool.execute(
{
description: "Test task",
prompt: "Do something",
category: "custom-no-model",
run_in_background: true,
load_skills: [],
},
toolContext
)
// #then returns clear error message with configuration guidance
expect(result).toContain("Model not configured")
expect(result).toContain("custom-no-model")
expect(result).toContain("Configure in one of")
})
})
describe("resolveCategoryConfig", () => {
@@ -533,12 +601,12 @@ describe("sisyphus-task", () => {
toolContext
)
// #then - variant MUST be "max" from DEFAULT_CATEGORIES
// #then - variant MUST be "max" from DEFAULT_CATEGORIES (passed as separate field)
expect(promptBody.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4-5",
variant: "max",
})
expect(promptBody.variant).toBe("max")
}, { timeout: 20000 })
})
@@ -1824,4 +1892,250 @@ describe("sisyphus-task", () => {
expect(resolved!.model).toBe(systemDefaultModel)
})
})
describe("prometheus self-delegation block", () => {
test("prometheus cannot delegate to prometheus - returns error with guidance", async () => {
// #given - current agent is prometheus
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "prometheus",
abort: new AbortController().signal,
}
// #when - prometheus tries to delegate to prometheus
const result = await tool.execute(
{
description: "Test self-delegation block",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
load_skills: [],
},
toolContext
)
// #then - should return error telling prometheus to create plan directly
expect(result).toContain("prometheus")
expect(result).toContain("directly")
})
test("non-prometheus agent CAN delegate to prometheus - proceeds normally", async () => {
// #given - current agent is sisyphus
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_allowed" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created successfully" }] }]
}),
status: async () => ({ data: { "ses_prometheus_allowed": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// #when - sisyphus delegates to prometheus
const result = await tool.execute(
{
description: "Test prometheus delegation from non-prometheus agent",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
load_skills: [],
},
toolContext
)
// #then - should proceed normally
expect(result).not.toContain("Cannot delegate")
expect(result).toContain("Plan created successfully")
}, { timeout: 20000 })
test("case-insensitive: Prometheus (capitalized) cannot delegate to prometheus", async () => {
// #given - current agent is Prometheus (capitalized)
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
status: async () => ({ data: {} }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "Prometheus",
abort: new AbortController().signal,
}
// #when - Prometheus tries to delegate to prometheus
const result = await tool.execute(
{
description: "Test case-insensitive block",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
load_skills: [],
},
toolContext
)
// #then - should still return error
expect(result).toContain("prometheus")
expect(result).toContain("directly")
})
})
describe("prometheus subagent delegate_task permission", () => {
test("prometheus subagent should have delegate_task permission enabled", async () => {
// #given - sisyphus delegates to prometheus
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_delegate" } }),
prompt: async (input: any) => {
promptBody = input.body
return { data: {} }
},
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
}),
status: async () => ({ data: { "ses_prometheus_delegate": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// #when - sisyphus delegates to prometheus
await tool.execute(
{
description: "Test prometheus delegate_task permission",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
load_skills: [],
},
toolContext
)
// #then - prometheus should have delegate_task permission
expect(promptBody.tools.delegate_task).toBe(true)
}, { timeout: 20000 })
test("non-prometheus subagent should NOT have delegate_task permission", async () => {
// #given - sisyphus delegates to oracle (non-prometheus)
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "oracle", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_oracle_no_delegate" } }),
prompt: async (input: any) => {
promptBody = input.body
return { data: {} }
},
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Consultation done" }] }]
}),
status: async () => ({ data: { "ses_oracle_no_delegate": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// #when - sisyphus delegates to oracle
await tool.execute(
{
description: "Test oracle no delegate_task permission",
prompt: "Consult on architecture",
subagent_type: "oracle",
run_in_background: false,
load_skills: [],
},
toolContext
)
// #then - oracle should NOT have delegate_task permission
expect(promptBody.tools.delegate_task).toBe(false)
}, { timeout: 20000 })
})
})

View File

@@ -5,6 +5,7 @@ import type { BackgroundManager } from "../../features/background-agent"
import type { DelegateTaskArgs } from "./types"
import type { CategoryConfig, CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, PLAN_AGENT_SYSTEM_PREPEND, isPlanAgent } from "./constants"
import { getTimingConfig } from "./timing"
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
import { discoverSkills } from "../../features/opencode-skill-loader"
@@ -409,9 +410,10 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
}
// Wait for message stability after prompt completes
const POLL_INTERVAL_MS = 500
const MIN_STABILITY_TIME_MS = 5000
const STABILITY_POLLS_REQUIRED = 3
const timing = getTimingConfig()
const POLL_INTERVAL_MS = timing.POLL_INTERVAL_MS
const MIN_STABILITY_TIME_MS = timing.SESSION_CONTINUATION_STABILITY_MS
const STABILITY_POLLS_REQUIRED = timing.STABILITY_POLLS_REQUIRED
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
@@ -573,13 +575,26 @@ To continue this session: session_id="${args.session_id}"`
}
agentToUse = SISYPHUS_JUNIOR_AGENT
if (!categoryModel && actualModel) {
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel ?? undefined
}
categoryPromptAppend = resolved.promptAppend || undefined
if (!categoryModel && actualModel) {
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel ?? undefined
}
categoryPromptAppend = resolved.promptAppend || undefined
const isUnstableAgent = resolved.config.is_unstable_agent === true || (actualModel?.toLowerCase().includes("gemini") ?? false)
if (!categoryModel && !actualModel) {
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
return `Model not configured for category "${args.category}".
Configure in one of:
1. OpenCode: Set "model" in opencode.json
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
3. Provider: Connect a provider with available models
Current category: ${args.category}
Available categories: ${categoryNames.join(", ")}`
}
const isUnstableAgent = resolved.config.is_unstable_agent === true || (actualModel?.toLowerCase().includes("gemini") ?? false)
// Handle both boolean false and string "false" due to potential serialization
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
@@ -649,10 +664,11 @@ To continue this session: session_id="${args.session_id}"`
const startTime = new Date()
// Poll for completion (same logic as sync mode)
const POLL_INTERVAL_MS = 500
const MAX_POLL_TIME_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10000
const STABILITY_POLLS_REQUIRED = 3
const timingCfg = getTimingConfig()
const POLL_INTERVAL_MS = timingCfg.POLL_INTERVAL_MS
const MAX_POLL_TIME_MS = timingCfg.MAX_POLL_TIME_MS
const MIN_STABILITY_TIME_MS = timingCfg.MIN_STABILITY_TIME_MS
const STABILITY_POLLS_REQUIRED = timingCfg.STABILITY_POLLS_REQUIRED
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
@@ -752,6 +768,12 @@ To continue this session: session_id="${sessionID}"`
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`
}
if (isPlanAgent(agentName) && isPlanAgent(parentAgent)) {
return `You are prometheus. You cannot delegate to prometheus via delegate_task.
Create the work plan directly - that's your job as the planning agent.`
}
agentToUse = agentName
// Validate agent exists and is callable (not a primary agent)
@@ -851,7 +873,10 @@ To continue this session: session_id="${task.sessionID}"`
body: {
parentID: ctx.sessionID,
title: `Task: ${args.description}`,
},
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
},
@@ -908,6 +933,7 @@ To continue this session: session_id="${task.sessionID}"`
})
try {
const allowDelegateTask = isPlanAgent(agentToUse)
await client.session.prompt({
path: { id: sessionID },
body: {
@@ -915,12 +941,13 @@ To continue this session: session_id="${task.sessionID}"`
system: systemContent,
tools: {
task: false,
delegate_task: false,
delegate_task: allowDelegateTask,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
...(categoryModel ? { model: categoryModel } : {}),
...(categoryModel ? { model: { providerID: categoryModel.providerID, modelID: categoryModel.modelID } } : {}),
...(categoryModel?.variant ? { variant: categoryModel.variant } : {}),
},
})
} catch (promptError) {
@@ -948,10 +975,11 @@ To continue this session: session_id="${task.sessionID}"`
// 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 syncTiming = getTimingConfig()
const POLL_INTERVAL_MS = syncTiming.POLL_INTERVAL_MS
const MAX_POLL_TIME_MS = syncTiming.MAX_POLL_TIME_MS
const MIN_STABILITY_TIME_MS = syncTiming.MIN_STABILITY_TIME_MS
const STABILITY_POLLS_REQUIRED = syncTiming.STABILITY_POLLS_REQUIRED
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0

View File

@@ -102,7 +102,10 @@ If the requested information is not found, clearly state what is missing.`
body: {
parentID: toolContext.sessionID,
title: `look_at: ${args.goal.substring(0, 50)}`,
},
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
},
@@ -110,6 +113,17 @@ If the requested information is not found, clearly state what is missing.`
if (createResult.error) {
log(`[look_at] Session create error:`, createResult.error)
const errorStr = String(createResult.error)
if (errorStr.toLowerCase().includes("unauthorized")) {
return `Error: Failed to create session (Unauthorized). This may be due to:
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
2. Provider authentication issues
3. Session permission inheritance problems
Try using a different provider or API key authentication.
Original error: ${createResult.error}`
}
return `Error: Failed to create session: ${createResult.error}`
}