Compare commits

...

37 Commits

Author SHA1 Message Date
github-actions[bot]
50afb6b2de release: v3.5.3 2026-02-12 15:31:06 +00:00
github-actions[bot]
41d790dc04 @jardo5 has signed the CLA in code-yeongyu/oh-my-opencode#1802 2026-02-12 12:57:17 +00:00
github-actions[bot]
2ac2241367 @bvanderhorn has signed the CLA in code-yeongyu/oh-my-opencode#1799 2026-02-12 11:17:51 +00:00
YeonGyu-Kim
283c7e6cb7 Merge pull request #1798 from code-yeongyu/feat/subagent-metadata-on-resume 2026-02-12 19:18:45 +09:00
YeonGyu-Kim
95aa7595f8 feat: include subagent in task_metadata when resuming sessions
When delegate-task resumes a session via session_id, the response
task_metadata now includes a subagent field identifying which agent
was running in the resumed session. This allows the parent agent to
know what type of subagent it is continuing.

- sync-continuation: uses resumeAgent extracted from session messages
- background-continuation: uses task.agent from BackgroundTask object
- Gracefully omits subagent when agent info is unavailable
2026-02-12 19:09:15 +09:00
YeonGyu-Kim
c6349dc38a Merge pull request #1795 from code-yeongyu/fix/background-agent-session-error
fix: handle session.error and prevent zombie task starts in background-agent
2026-02-12 18:43:49 +09:00
github-actions[bot]
17b475eefd @solssak has signed the CLA in code-yeongyu/oh-my-opencode#1794 2026-02-12 09:28:23 +00:00
YeonGyu-Kim
3a019792e9 test(background-agent): use createMockTask in session.error tests 2026-02-12 18:26:47 +09:00
YeonGyu-Kim
1ceaaa4311 fix(background-agent): handle session.error and prevent zombie queue starts
Marks background tasks as error on session.error to release concurrency immediately, and skips/removes error tasks from queues to avoid zombie starts.
2026-02-12 18:26:03 +09:00
YeonGyu-Kim
ff8a5f343a fix(auth): add multi-layer auth injection for desktop app compatibility
Desktop app sets OPENCODE_SERVER_PASSWORD which activates basicAuth on
the server, but the SDK client provided to plugins lacks auth headers.
The previous setConfig-only approach may silently fail depending on SDK
version.

Add belt-and-suspenders fallback chain:
1. setConfig headers (existing)
2. request interceptors
3. fetch wrapper via getConfig/setConfig
4. mutable _config.fetch wrapper
5. top-level client.fetch wrapper

Replace console.warn with structured log() for better diagnostics.
2026-02-12 18:12:54 +09:00
github-actions[bot]
118150035c @G36maid has signed the CLA in code-yeongyu/oh-my-opencode#1791 2026-02-12 07:56:30 +00:00
github-actions[bot]
157952f293 @raki-1203 has signed the CLA in code-yeongyu/oh-my-opencode#1790 2026-02-12 07:27:50 +00:00
YeonGyu-Kim
d358e6e48e Merge pull request #1783 from code-yeongyu/fix/run-event-stream
fix(run): pass directory to event.subscribe for session-scoped SSE events
2026-02-12 11:55:56 +09:00
YeonGyu-Kim
9afd0d1d41 fix(run): pass directory to event.subscribe for session-scoped events
The SSE event stream subscription was missing the directory parameter,
causing the OpenCode server to only emit global events (heartbeat,
connected, toast) but not session-scoped events (session.idle,
session.status, tool.execute, message.updated, message.part.updated).

Without session events:
- hasReceivedMeaningfulWork stays false (no message/tool events)
- mainSessionIdle never updates (no session.idle/status events)
- pollForCompletion either hangs or exits for unrelated reasons

Fix: Pass { directory } to client.event.subscribe(), matching the
pattern already used by client.session.promptAsync().

Also adds a stabilization period (10s) after first meaningful work
as defense-in-depth against early exit race conditions.
2026-02-12 11:52:31 +09:00
github-actions[bot]
e4be8cea75 @youngbinkim0 has signed the CLA in code-yeongyu/oh-my-opencode#1777 2026-02-11 22:04:42 +00:00
YeonGyu-Kim
306c7f4c8e Merge pull request #1770 from code-yeongyu/fix/prometheus-md-only-agent-name-matching
fix: use case-insensitive matching for prometheus agent detection
2026-02-12 03:42:21 +09:00
YeonGyu-Kim
c12c6fa0c0 fix: use case-insensitive matching for prometheus agent detection in prometheus-md-only hook
The hook used exact string equality (agentName !== "prometheus") which fails
when display names like "Prometheus (Plan Builder)" are stored in session state.
Replace with case-insensitive substring matching via isPrometheusAgent() helper,
consistent with the pattern used in keyword-detector hook.

Closes #1764 (Bug 3)
2026-02-12 03:36:58 +09:00
YeonGyu-Kim
ef1baea163 fix: improve error message for marketplace plugin commands
- Detect namespaced commands (containing ':') from Claude marketplace plugins
- Provide clear error message explaining marketplace plugins are not supported
- Point users to .claude/commands/ as alternative for custom commands
- Fixes issue where /daplug:run-prompt gave ambiguous 'command not found'

Closes #1682
2026-02-12 03:05:55 +09:00
github-actions[bot]
d33af1d27f @tcarac has signed the CLA in code-yeongyu/oh-my-opencode#1766 2026-02-11 15:03:39 +00:00
github-actions[bot]
b2f019a987 @COLDTURNIP has signed the CLA in code-yeongyu/oh-my-opencode#1765 2026-02-11 14:54:57 +00:00
github-actions[bot]
ce7fb00847 @WietRob has signed the CLA in code-yeongyu/oh-my-opencode#1529 2026-02-11 13:55:56 +00:00
github-actions[bot]
63d3fa7439 @uyu423 has signed the CLA in code-yeongyu/oh-my-opencode#1762 2026-02-11 12:31:15 +00:00
github-actions[bot]
2df61a2199 release: v3.5.2 2026-02-11 08:38:47 +00:00
YeonGyu-Kim
96f0e787e7 Merge pull request #1754 from code-yeongyu/fix/issue-1745-auto-update-pin
fix: respect user-pinned plugin version, skip auto-update when explicitly pinned
2026-02-11 16:07:57 +09:00
YeonGyu-Kim
4ef6188a41 Merge pull request #1756 from code-yeongyu/fix/mcp-tool-output-guard
fix: guard output.output in tool after-hooks for MCP tools
2026-02-11 16:03:59 +09:00
YeonGyu-Kim
d5fd918bff fix: guard output.output in tool after-hooks for MCP tools (#1720)
MCP tool responses can have undefined output.output, causing TypeError
crashes in tool.execute.after hooks.

Changes:
- comment-checker/hook.ts: guard output.output with ?? '' before toLowerCase()
- edit-error-recovery/hook.ts: guard output.output with ?? '' before toLowerCase()
- task-resume-info/hook.ts: extract output.output ?? '' into outputText before all string operations
- Added tests for undefined output.output in edit-error-recovery and task-resume-info
2026-02-11 15:49:56 +09:00
YeonGyu-Kim
5d3215167a fix: respect user-pinned plugin version, skip auto-update when explicitly pinned
When a user pins oh-my-opencode to a specific version (e.g., oh-my-opencode@3.4.0),
the auto-update checker now respects that choice and only shows a notification toast
instead of overwriting the pinned version with latest.

- Skip updatePinnedVersion() when pluginInfo.isPinned is true
- Show update-available toast only (notification, no modification)
- Added comprehensive tests for pinned/unpinned/autoUpdate scenarios

Fixes #1745
2026-02-11 15:39:15 +09:00
github-actions[bot]
3b2d3acd17 @ojh102 has signed the CLA in code-yeongyu/oh-my-opencode#1750 2026-02-11 05:30:01 +00:00
YeonGyu-Kim
bfe1730e9f feat(categories): add disable field to CategoryConfigSchema
Allow individual categories to be disabled via `disable: true` in
config. Introduce shared `mergeCategories()` utility to centralize
category merging and disabled filtering across all 7 consumption sites.
2026-02-11 13:52:20 +09:00
YeonGyu-Kim
67b4665c28 fix(auto-update): revert config pin on install failure to prevent version mismatch
When bun install fails after updating the config pin, the config now shows the
new version but the actual package is the old one. Add revertPinnedVersion() to
roll back the config entry on install failure, keeping config and installed
version in sync.

Ref #1472
2026-02-11 13:52:20 +09:00
YeonGyu-Kim
b0c570e054 fix(subagent): remove permission.question=deny override that caused zombie sessions
Child session creation was injecting permission: { question: 'deny' } which
conflicted with OpenCode's child session permission handling, causing subagent
sessions to hang with 0 messages after creation (zombie state).

Remove the permission override from all session creators (BackgroundManager,
sync-session-creator, call-omo-agent) and rely on prompt-level tool restrictions
(tools.question=false) to maintain the intended policy.

Closes #1711
2026-02-11 13:52:20 +09:00
YeonGyu-Kim
fd99a29d6e feat(atlas): add notepad reading step to boulder verification reminders
Instructs the orchestrator to read subagent notepad files
(.sisyphus/notepads/{planName}/) after task completion, ensuring
learnings, issues, and problems are propagated to subsequent delegations.
2026-02-11 13:52:20 +09:00
YeonGyu-Kim
308ad1e98e Merge pull request #1683 from code-yeongyu/fix/issue-1672
fix: guard session_ids with optional chaining to prevent crash (#1672)
2026-02-11 13:33:38 +09:00
YeonGyu-Kim
d60697bb13 fix: guard session_ids with optional chaining to prevent crash
boulderState?.session_ids.includes() only guards boulderState, not
session_ids. If boulder.json is corrupted or missing the field,
session_ids is undefined and .includes() crashes silently, losing
subagent results.

Changes:
- readBoulderState: validate parsed JSON is object, default session_ids to []
- atlas hook line 427: boulderState?.session_ids?.includes
- atlas hook line 655: boulderState?.session_ids?.includes
- prometheus-md-only line 93: boulderState?.session_ids?.includes
- appendSessionId: guard with ?. and initialize to [] if missing

Fixes #1672
2026-02-11 13:27:18 +09:00
YeonGyu-Kim
95a4e971a0 test: add validation tests for readBoulderState session_ids handling
Add tests for corrupted/incomplete boulder.json:
- null JSON value returns null
- primitive JSON value returns null
- missing session_ids defaults to []
- non-array session_ids defaults to []
- empty object defaults session_ids to []
- appendSessionId with missing session_ids does not crash

Refs #1672
2026-02-11 13:25:39 +09:00
github-actions[bot]
d8901fa658 @danpung2 has signed the CLA in code-yeongyu/oh-my-opencode#1741 2026-02-11 02:52:47 +00:00
YeonGyu-Kim
82c71425a0 fix(ci): add web-flow to CLA allowlist
GitHub Web UI commits have web-flow as the author/committer,
causing CLA checks to fail even after the contributor signs.
Adding web-flow to the allowlist resolves this for all
contributors who edit files via the GitHub web interface.
2026-02-11 10:59:17 +09:00
69 changed files with 2129 additions and 239 deletions

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: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
allowlist: code-yeongyu,bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai,web-flow
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

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.3.1",
"oh-my-opencode-darwin-x64": "3.3.1",
"oh-my-opencode-linux-arm64": "3.3.1",
"oh-my-opencode-linux-arm64-musl": "3.3.1",
"oh-my-opencode-linux-x64": "3.3.1",
"oh-my-opencode-linux-x64-musl": "3.3.1",
"oh-my-opencode-windows-x64": "3.3.1",
"oh-my-opencode-darwin-arm64": "3.5.2",
"oh-my-opencode-darwin-x64": "3.5.2",
"oh-my-opencode-linux-arm64": "3.5.2",
"oh-my-opencode-linux-arm64-musl": "3.5.2",
"oh-my-opencode-linux-x64": "3.5.2",
"oh-my-opencode-linux-x64-musl": "3.5.2",
"oh-my-opencode-windows-x64": "3.5.2",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-R+o42Km6bsIaW6D3I8uu2HCF3BjIWqa/fg38W5y4hJEOw4mL0Q7uV4R+0vtrXRHo9crXTK9ag0fqVQUm+Y6iAQ=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oIS3lB2F9/N+3mF5wCKk6/EPVSz516XWN+mNdquSSeddw+xqMxGdhKY6K/XeYbHJzeN2Z8IOikNEJ6psR2/a8g=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7VTbpR1vH3OEkoJxBKtYuxFPX8M3IbJKoeHWME9iK6FpT11W1ASsjyuhvzB1jcxSeqF8ddMnjitlG5ub6h5EVw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OAdXo4ZCCYO4kRWtnyz3tdmaGYPUB3WcXimXAxp+/sEZxAnh7n1RQkpLn6UxWX4AIAdRT9dfrOfRic6VoCYv2g=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BZ/r/CFlvbOxkdZZrRoT16xFOjibRZHuwQnaE4f0JvOzgK6/HWp3zJI1+2/aX/oK5GA6lZxNWRrJC/SKUi8LEg=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5XXNMFhp1VsyrGNRBoXcOyoaUeVkbrWkBRPDGZfpiq+kRXH3aaSWdR5G7Pl/TadOQv9Bl8/8YaxsuHRTFT1aXw=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-U90Wruf21h+CJbtcrS7MeTAc/5VOF6RI+5jr7qj/cCxjXNJtjhyJdz/maehArjtgf304+lYCM/Mh1i+G2D3YFQ=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/woIpqvEI85MgJvEVnz4g5FBLeiQNK7srRsueIFPBmtTahh42HFleCDaIltOl/ndjsE5nCHacQVJHkC9W9/F3Q=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sYzohSNdwsAhivbXcbhPdF1qqQi2CCI7FSgbmvvfBOMyZ8HAgqOFqYW2r3GPdmtywzkjOTvCzTG56FZwEjx15w=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vTL2A+6zzGhi+m7sC8peLDq5OAp2dRR0UEb4RbZAOHtlEruF7qFEmcK3ccWxwc3+Z3G/ITfwn5VNa72ZS4pNTg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aG5pZ4eWS0YSGUicOnjMkUPrIqQV4poYF+d9SIvrfvlaMcK6WlQn7jXzgNCwJsfGn5lyhSmjshZBEU+v79Ua3w=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bOAA55snLsK2QB00IkQy8le0Oqh/GJ7pxEHtm1oUezlQrW/nX5SS/hJ7dPHMmOd9FoiqnqyqWZxNkLmFoG463A=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-FGH7cnzBqNwjSkzCDglMsVttaq+MsykAxa7ehaFK+0dnBZArvllS3W13a3dGaANHMZzfK0vz8hNDUdVi7Z63cA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-fnHiAPYglw3unPckmQBoCT6+VqjSWCE3S3J551mRo0ZFrxuEP2ZKyHZeFMMOtKwDepCvmKgd1W040+KmuVUXOA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.5.1",
"version": "3.5.3",
"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.5.1",
"version": "3.5.3",
"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.5.1",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.5.1",
"version": "3.5.3",
"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.5.1",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

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

View File

@@ -1343,6 +1343,102 @@
"created_at": "2026-02-10T18:03:41Z",
"repoId": 1108837393,
"pullRequestNo": 1726
},
{
"name": "danpung2",
"id": 75434746,
"comment_id": 3881834946,
"created_at": "2026-02-11T02:52:34Z",
"repoId": 1108837393,
"pullRequestNo": 1741
},
{
"name": "ojh102",
"id": 14901903,
"comment_id": 3882254163,
"created_at": "2026-02-11T05:29:51Z",
"repoId": 1108837393,
"pullRequestNo": 1750
},
{
"name": "uyu423",
"id": 8033320,
"comment_id": 3884127858,
"created_at": "2026-02-11T12:30:37Z",
"repoId": 1108837393,
"pullRequestNo": 1762
},
{
"name": "WietRob",
"id": 203506602,
"comment_id": 3859280254,
"created_at": "2026-02-06T10:00:03Z",
"repoId": 1108837393,
"pullRequestNo": 1529
},
{
"name": "COLDTURNIP",
"id": 46220,
"comment_id": 3884966424,
"created_at": "2026-02-11T14:54:46Z",
"repoId": 1108837393,
"pullRequestNo": 1765
},
{
"name": "tcarac",
"id": 64477810,
"comment_id": 3885026481,
"created_at": "2026-02-11T15:03:25Z",
"repoId": 1108837393,
"pullRequestNo": 1766
},
{
"name": "youngbinkim0",
"id": 64558592,
"comment_id": 3887466814,
"created_at": "2026-02-11T22:03:00Z",
"repoId": 1108837393,
"pullRequestNo": 1777
},
{
"name": "raki-1203",
"id": 52475378,
"comment_id": 3889111683,
"created_at": "2026-02-12T07:27:39Z",
"repoId": 1108837393,
"pullRequestNo": 1790
},
{
"name": "G36maid",
"id": 53391375,
"comment_id": 3889208379,
"created_at": "2026-02-12T07:56:21Z",
"repoId": 1108837393,
"pullRequestNo": 1791
},
{
"name": "solssak",
"id": 107416133,
"comment_id": 3889740003,
"created_at": "2026-02-12T09:28:09Z",
"repoId": 1108837393,
"pullRequestNo": 1794
},
{
"name": "bvanderhorn",
"id": 9591412,
"comment_id": 3890297580,
"created_at": "2026-02-12T11:17:38Z",
"repoId": 1108837393,
"pullRequestNo": 1799
},
{
"name": "jardo5",
"id": 22041729,
"comment_id": 3890810423,
"created_at": "2026-02-12T12:57:06Z",
"repoId": 1108837393,
"pullRequestNo": 1802
}
]
}

View File

@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentFactory } from "./types"
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
import type { BrowserAutomationProvider } from "../config/schema"
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
import { mergeCategories } from "../shared/merge-categories"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
export type AgentSource = AgentFactory | AgentConfig
@@ -20,9 +20,7 @@ export function buildAgent(
disabledSkills?: Set<string>
): AgentConfig {
const base = isFactory(source) ? source(model) : { ...source }
const categoryConfigs: Record<string, CategoryConfig> = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const categoryConfigs: Record<string, CategoryConfig> = mergeCategories(categories)
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
if (agentWithCategory.category) {

View File

@@ -15,7 +15,7 @@ import { isGptModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
import { mergeCategories } from "../../shared/merge-categories"
import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default"
@@ -70,7 +70,7 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
const userCategories = ctx?.userCategories
const model = ctx?.model
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const allCategories = mergeCategories(userCategories)
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
name,
description: getCategoryDescription(name, userCategories),

View File

@@ -7,7 +7,8 @@
import type { CategoryConfig } from "../../config/schema"
import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
import { CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
import { mergeCategories } from "../../shared/merge-categories"
import { truncateDescription } from "../../shared/truncate-description"
export const getCategoryDescription = (name: string, userCategories?: Record<string, CategoryConfig>) =>
@@ -33,7 +34,7 @@ ${rows.join("\n")}`
}
export function buildCategorySection(userCategories?: Record<string, CategoryConfig>): string {
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const allCategories = mergeCategories(userCategories)
const categoryRows = Object.entries(allCategories).map(([name, config]) => {
const temp = config.temperature ?? 0.5
return `| \`${name}\` | ${temp} | ${getCategoryDescription(name, userCategories)} |`
@@ -116,7 +117,7 @@ task(category="[category]", load_skills=["skill-1", "skill-2"], run_in_backgroun
}
export function buildDecisionMatrix(agents: AvailableAgent[], userCategories?: Record<string, CategoryConfig>): string {
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const allCategories = mergeCategories(userCategories)
const categoryRows = Object.entries(allCategories).map(([name]) =>
`| ${getCategoryDescription(name, userCategories)} | \`category="${name}", load_skills=[...]\` |`

View File

@@ -14,7 +14,8 @@ import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import { fetchAvailableModels, readConnectedProvidersCache } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { mergeCategories } from "../shared/merge-categories"
import { buildAvailableSkills } from "./builtin-agents/available-skills"
import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents"
import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"
@@ -78,9 +79,7 @@ export async function createBuiltinAgents(
const result: Record<string, AgentConfig> = {}
const mergedCategories = categories
? { ...DEFAULT_CATEGORIES, ...categories }
: DEFAULT_CATEGORIES
const mergedCategories = mergeCategories(categories)
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
name,

View File

@@ -45,6 +45,7 @@ describe("pollForCompletion", () => {
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 0,
})
//#then - exits with 0 but only after 3 consecutive checks
@@ -53,6 +54,30 @@ describe("pollForCompletion", () => {
expect(todoCallCount).toBeGreaterThanOrEqual(3)
})
it("does not check completion during stabilization period after first meaningful work", async () => {
//#given - session idle, meaningful work done, but stabilization period not elapsed
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when - abort after 50ms (within the 60ms stabilization period)
setTimeout(() => abortController.abort(), 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 60,
})
//#then - should be aborted, not completed (stabilization blocked completion check)
expect(result).toBe(130)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBe(0)
})
it("does not exit when currentTool is set - resets consecutive counter", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
@@ -110,6 +135,7 @@ describe("pollForCompletion", () => {
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 0,
})
const elapsedMs = Date.now() - startMs

View File

@@ -6,10 +6,12 @@ import { checkCompletionConditions } from "./completion"
const DEFAULT_POLL_INTERVAL_MS = 500
const DEFAULT_REQUIRED_CONSECUTIVE = 3
const ERROR_GRACE_CYCLES = 3
const MIN_STABILIZATION_MS = 10_000
export interface PollOptions {
pollIntervalMs?: number
requiredConsecutive?: number
minStabilizationMs?: number
}
export async function pollForCompletion(
@@ -21,8 +23,11 @@ export async function pollForCompletion(
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
const requiredConsecutive =
options.requiredConsecutive ?? DEFAULT_REQUIRED_CONSECUTIVE
const minStabilizationMs =
options.minStabilizationMs ?? MIN_STABILIZATION_MS
let consecutiveCompleteChecks = 0
let errorCycleCount = 0
let firstWorkTimestamp: number | null = null
while (!abortController.signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
@@ -61,6 +66,17 @@ export async function pollForCompletion(
continue
}
// Track when first meaningful work was received
if (firstWorkTimestamp === null) {
firstWorkTimestamp = Date.now()
}
// Don't check completion during stabilization period
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
consecutiveCompleteChecks = 0
continue
}
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
consecutiveCompleteChecks++

View File

@@ -65,7 +65,7 @@ export async function run(options: RunOptions): Promise<number> {
console.log(pc.dim(`Session: ${sessionID}`))
const ctx: RunContext = { client, sessionID, directory, abortController }
const events = await client.event.subscribe()
const events = await client.event.subscribe({ query: { directory } })
const eventState = createEventState()
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
() => {},

View File

@@ -20,6 +20,8 @@ export const CategoryConfigSchema = z.object({
prompt_append: z.string().optional(),
/** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */
is_unstable_agent: z.boolean().optional(),
/** Disable this category. Disabled categories are excluded from task delegation. */
disable: z.boolean().optional(),
})
export const BuiltinCategoryNameSchema = z.enum([

View File

@@ -1,7 +1,7 @@
import { log } from "../../shared"
import { MIN_IDLE_TIME_MS } from "./constants"
import { subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask } from "./types"
import { cleanupTaskAfterSessionEnds } from "./session-task-cleanup"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
type Event = { type: string; properties?: Record<string, unknown> }
@@ -18,6 +18,7 @@ export function handleBackgroundEvent(args: {
event: Event
findBySession: (sessionID: string) => BackgroundTask | undefined
getAllDescendantTasks: (sessionID: string) => BackgroundTask[]
releaseConcurrencyKey?: (key: string) => void
cancelTask: (
taskId: string,
options: { source: string; reason: string; skipNotification: true }
@@ -36,6 +37,7 @@ export function handleBackgroundEvent(args: {
event,
findBySession,
getAllDescendantTasks,
releaseConcurrencyKey,
cancelTask,
tryCompleteTask,
validateSessionHasOutput,
@@ -78,6 +80,19 @@ export function handleBackgroundEvent(args: {
}
if (event.type === "session.idle") {
if (!props || !isRecord(props)) return
handleSessionIdleBackgroundEvent({
properties: props,
findBySession,
idleDeferralTimers,
validateSessionHasOutput,
checkSessionTodos,
tryCompleteTask,
emitIdleEvent,
})
}
if (event.type === "session.error") {
if (!props || !isRecord(props)) return
const sessionID = getString(props, "sessionID")
if (!sessionID) return
@@ -85,64 +100,26 @@ export function handleBackgroundEvent(args: {
const task = findBySession(sessionID)
if (!task || task.status !== "running") return
const startedAt = task.startedAt
if (!startedAt) return
const errorRaw = props["error"]
const dataRaw = isRecord(errorRaw) ? errorRaw["data"] : undefined
const message =
(isRecord(dataRaw) ? getString(dataRaw, "message") : undefined) ??
(isRecord(errorRaw) ? getString(errorRaw, "message") : undefined) ??
"Session error"
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", {
elapsedMs,
remainingMs,
taskId: task.id,
})
const timer = setTimeout(() => {
idleDeferralTimers.delete(task.id)
emitIdleEvent(sessionID)
}, remainingMs)
idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
task.status = "error"
task.error = message
task.completedAt = new Date()
validateSessionHasOutput(sessionID)
.then(async (hasValidOutput) => {
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await checkSessionTodos(sessionID)
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await tryCompleteTask(task, "session.idle event")
})
.catch((err) => {
log("[background-agent] Error in session.idle handler:", err)
})
cleanupTaskAfterSessionEnds({
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
})
}
if (event.type === "session.deleted") {
@@ -176,24 +153,15 @@ export function handleBackgroundEvent(args: {
})
}
const completionTimer = completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
completionTimers.delete(task.id)
}
const idleTimer = idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
idleDeferralTimers.delete(task.id)
}
cleanupPendingByParent(task)
tasks.delete(task.id)
clearNotificationsForTask(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
cleanupTaskAfterSessionEnds({
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
})
}
}
}

View File

@@ -190,6 +190,22 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
}
function getQueuesByKey(
manager: BackgroundManager
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
return (manager as unknown as {
queuesByKey: Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>>
}).queuesByKey
}
async function processKeyForTest(manager: BackgroundManager, key: string): Promise<void> {
return (manager as unknown as { processKey: (key: string) => Promise<void> }).processKey(key)
}
function pruneStaleTasksAndNotificationsForTest(manager: BackgroundManager): void {
;(manager as unknown as { pruneStaleTasksAndNotifications: () => void }).pruneStaleTasksAndNotifications()
}
async function tryCompleteTaskForTest(manager: BackgroundManager, task: BackgroundTask): Promise<boolean> {
return (manager as unknown as { tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> })
.tryCompleteTask(task, "test")
@@ -1520,7 +1536,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
})
describe("task transitions pending→running when slot available", () => {
test("should inherit parent session permission rules (and force deny question)", async () => {
test("does not override parent session permission when creating child session", async () => {
// given
const createCalls: any[] = []
const parentPermission = [
@@ -1562,11 +1578,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
// then
expect(createCalls).toHaveLength(1)
const permission = createCalls[0]?.body?.permission
expect(permission).toEqual([
{ permission: "plan_enter", action: "deny", pattern: "*" },
{ permission: "question", action: "deny", pattern: "*" },
])
expect(createCalls[0]?.body?.permission).toBeUndefined()
})
test("should transition first task to running immediately", async () => {
@@ -2509,6 +2521,198 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
})
})
describe("BackgroundManager.handleEvent - session.error", () => {
test("sets task to error, releases concurrency, and cleans up", async () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "test-provider/test-model"
await concurrencyManager.acquire(concurrencyKey)
const sessionID = "ses_error_1"
const task = createMockTask({
id: "task-session-error",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task that errors",
agent: "explore",
status: "running",
concurrencyKey,
})
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: { message: "Model not found: kimi-for-coding/k2p5." },
},
},
})
//#then
expect(task.status).toBe("error")
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
expect(task.completedAt).toBeInstanceOf(Date)
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
manager.shutdown()
})
test("ignores session.error for non-running tasks", () => {
//#given
const manager = createBackgroundManager()
const sessionID = "ses_error_ignored"
const task = createMockTask({
id: "task-non-running",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task already done",
agent: "explore",
status: "completed",
})
task.completedAt = new Date()
task.error = "previous"
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: { name: "UnknownError", message: "should not matter" },
},
})
//#then
expect(task.status).toBe("completed")
expect(task.error).toBe("previous")
expect(getTaskMap(manager).has(task.id)).toBe(true)
manager.shutdown()
})
test("ignores session.error for unknown session", () => {
//#given
const manager = createBackgroundManager()
//#when
const handler = () =>
manager.handleEvent({
type: "session.error",
properties: {
sessionID: "ses_unknown",
error: { name: "UnknownError", message: "Model not found" },
},
})
//#then
expect(handler).not.toThrow()
manager.shutdown()
})
})
describe("BackgroundManager queue processing - error tasks are skipped", () => {
test("does not start tasks with status=error", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
{ defaultConcurrency: 1 }
)
const key = "test-key"
const task: BackgroundTask = {
id: "task-error-queued",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "queued error task",
prompt: "test",
agent: "test-agent",
status: "error",
queuedAt: new Date(),
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
let startCalled = false
;(manager as unknown as { startTask: (item: unknown) => Promise<void> }).startTask = async () => {
startCalled = true
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(key, [{ task, input }])
//#when
await processKeyForTest(manager, key)
//#then
expect(startCalled).toBe(false)
expect(getQueuesByKey(manager).get(key)?.length ?? 0).toBe(0)
manager.shutdown()
})
})
describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tasks from queuesByKey", () => {
test("removes stale pending task from queue", () => {
//#given
const manager = createBackgroundManager()
const queuedAt = new Date(Date.now() - 31 * 60 * 1000)
const task: BackgroundTask = {
id: "task-stale-pending",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "stale pending",
prompt: "test",
agent: "test-agent",
status: "pending",
queuedAt,
}
const key = task.agent
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(key, [{ task, input }])
//#when
pruneStaleTasksAndNotificationsForTest(manager)
//#then
expect(getQueuesByKey(manager).get(key)).toBeUndefined()
manager.shutdown()
})
})
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers

View File

@@ -192,7 +192,7 @@ export class BackgroundManager {
await this.concurrencyManager.acquire(key)
if (item.task.status === "cancelled") {
if (item.task.status === "cancelled" || item.task.status === "error") {
this.concurrencyManager.release(key)
queue.shift()
continue
@@ -236,17 +236,10 @@ export class BackgroundManager {
const parentDirectory = parentSession?.data?.directory ?? this.directory
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
const inheritedPermission = (parentSession as any)?.data?.permission
const permissionRules = Array.isArray(inheritedPermission)
? inheritedPermission.filter((r: any) => r?.permission !== "question")
: []
permissionRules.push({ permission: "question", action: "deny" as const, pattern: "*" })
const createResult = await this.client.session.create({
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
permission: permissionRules,
} as any,
query: {
directory: parentDirectory,
@@ -736,6 +729,44 @@ export class BackgroundManager {
})
}
if (event.type === "session.error") {
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const errorMessage = props ? this.getSessionErrorMessage(props) : undefined
task.status = "error"
task.error = errorMessage ?? "Session error"
task.completedAt = new Date()
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
const completionTimer = this.completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
this.completionTimers.delete(task.id)
}
const idleTimer = this.idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}
if (event.type === "session.deleted") {
const info = props?.info
if (!info || typeof info.id !== "string") return
@@ -1288,6 +1319,24 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
return ""
}
private isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
private getSessionErrorMessage(properties: EventProperties): string | undefined {
const errorRaw = properties["error"]
if (!this.isRecord(errorRaw)) return undefined
const dataRaw = errorRaw["data"]
if (this.isRecord(dataRaw)) {
const message = dataRaw["message"]
if (typeof message === "string") return message
}
const message = errorRaw["message"]
return typeof message === "string" ? message : undefined
}
private hasRunningTasks(): boolean {
for (const task of this.tasks.values()) {
if (task.status === "running") return true
@@ -1299,6 +1348,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
const now = Date.now()
for (const [taskId, task] of this.tasks.entries()) {
const wasPending = task.status === "pending"
const timestamp = task.status === "pending"
? task.queuedAt?.getTime()
: task.startedAt?.getTime()
@@ -1323,6 +1373,21 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
// Clean up pendingByParent to prevent stale entries
this.cleanupPendingByParent(task)
if (wasPending) {
const key = task.model
? `${task.model.providerID}/${task.model.modelID}`
: task.agent
const queue = this.queuesByKey.get(key)
if (queue) {
const index = queue.findIndex((item) => item.task.id === taskId)
if (index !== -1) {
queue.splice(index, 1)
if (queue.length === 0) {
this.queuesByKey.delete(key)
}
}
}
}
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
if (task.sessionID) {

View File

@@ -0,0 +1,93 @@
import { log } from "../../shared"
import { MIN_IDLE_TIME_MS } from "./constants"
import type { BackgroundTask } from "./types"
function getString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key]
return typeof value === "string" ? value : undefined
}
export function handleSessionIdleBackgroundEvent(args: {
properties: Record<string, unknown>
findBySession: (sessionID: string) => BackgroundTask | undefined
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
emitIdleEvent: (sessionID: string) => void
}): void {
const {
properties,
findBySession,
idleDeferralTimers,
validateSessionHasOutput,
checkSessionTodos,
tryCompleteTask,
emitIdleEvent,
} = args
const sessionID = getString(properties, "sessionID")
if (!sessionID) return
const task = findBySession(sessionID)
if (!task || task.status !== "running") return
const startedAt = task.startedAt
if (!startedAt) return
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", {
elapsedMs,
remainingMs,
taskId: task.id,
})
const timer = setTimeout(() => {
idleDeferralTimers.delete(task.id)
emitIdleEvent(sessionID)
}, remainingMs)
idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
validateSessionHasOutput(sessionID)
.then(async (hasValidOutput) => {
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await checkSessionTodos(sessionID)
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await tryCompleteTask(task, "session.idle event")
})
.catch((err) => {
log("[background-agent] Error in session.idle handler:", err)
})
}

View File

@@ -0,0 +1,46 @@
import { subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask } from "./types"
export function cleanupTaskAfterSessionEnds(args: {
task: BackgroundTask
tasks: Map<string, BackgroundTask>
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
completionTimers: Map<string, ReturnType<typeof setTimeout>>
cleanupPendingByParent: (task: BackgroundTask) => void
clearNotificationsForTask: (taskId: string) => void
releaseConcurrencyKey?: (key: string) => void
}): void {
const {
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
} = args
const completionTimer = completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
completionTimers.delete(task.id)
}
const idleTimer = idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
idleDeferralTimers.delete(task.id)
}
if (task.concurrencyKey && releaseConcurrencyKey) {
releaseConcurrencyKey(task.concurrencyKey)
task.concurrencyKey = undefined
}
cleanupPendingByParent(task)
clearNotificationsForTask(task.id)
tasks.delete(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}

View File

@@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test"
import { createTask, startTask } from "./spawner"
describe("background-agent spawner.startTask", () => {
test("should inherit parent session permission rules (and force deny question)", async () => {
test("does not override parent session permission rules when creating child session", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
@@ -57,9 +57,6 @@ describe("background-agent spawner.startTask", () => {
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "plan_enter", action: "deny", pattern: "*" },
{ permission: "question", action: "deny", pattern: "*" },
])
expect(createCalls[0]?.body?.permission).toBeUndefined()
})
})

View File

@@ -58,17 +58,10 @@ export async function startTask(
const parentDirectory = parentSession?.data?.directory ?? directory
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
const inheritedPermission = (parentSession as any)?.data?.permission
const permissionRules = Array.isArray(inheritedPermission)
? inheritedPermission.filter((r: any) => r?.permission !== "question")
: []
permissionRules.push({ permission: "question", action: "deny" as const, pattern: "*" })
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
permission: permissionRules,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
query: {

View File

@@ -15,7 +15,6 @@ export async function createBackgroundSession(options: {
const body = {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
permission: [{ permission: "question", action: "deny" as const, pattern: "*" }],
}
const createResult = await client.session

View File

@@ -4,12 +4,15 @@ import { TASK_TTL_MS } from "./constants"
import { subagentSessions } from "../claude-code-session-state"
import { pruneStaleTasksAndNotifications } from "./task-poller"
import type { BackgroundTask } from "./types"
import type { BackgroundTask, LaunchInput } from "./types"
import type { ConcurrencyManager } from "./concurrency"
type QueueItem = { task: BackgroundTask; input: LaunchInput }
export function pruneStaleState(args: {
tasks: Map<string, BackgroundTask>
notifications: Map<string, BackgroundTask[]>
queuesByKey: Map<string, QueueItem[]>
concurrencyManager: ConcurrencyManager
cleanupPendingByParent: (task: BackgroundTask) => void
clearNotificationsForTask: (taskId: string) => void
@@ -17,6 +20,7 @@ export function pruneStaleState(args: {
const {
tasks,
notifications,
queuesByKey,
concurrencyManager,
cleanupPendingByParent,
clearNotificationsForTask,
@@ -26,6 +30,7 @@ export function pruneStaleState(args: {
tasks,
notifications,
onTaskPruned: (taskId, task, errorMessage) => {
const wasPending = task.status === "pending"
const now = Date.now()
const timestamp = task.status === "pending"
? task.queuedAt?.getTime()
@@ -47,6 +52,21 @@ export function pruneStaleState(args: {
}
cleanupPendingByParent(task)
if (wasPending) {
const key = task.model
? `${task.model.providerID}/${task.model.modelID}`
: task.agent
const queue = queuesByKey.get(key)
if (queue) {
const index = queue.findIndex((item) => item.task.id === taskId)
if (index !== -1) {
queue.splice(index, 1)
if (queue.length === 0) {
queuesByKey.delete(key)
}
}
}
}
clearNotificationsForTask(taskId)
tasks.delete(taskId)
if (task.sessionID) {

View File

@@ -27,7 +27,7 @@ export async function processConcurrencyKeyQueue(args: {
await concurrencyManager.acquire(key)
if (item.task.status === "cancelled") {
if (item.task.status === "cancelled" || item.task.status === "error") {
concurrencyManager.release(key)
queue.shift()
continue

View File

@@ -69,7 +69,6 @@ export async function startQueuedTask(args: {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
permission: [{ permission: "question", action: "deny" as const, pattern: "*" }],
} as any,
query: {
directory: parentDirectory,

View File

@@ -43,6 +43,78 @@ describe("boulder-state", () => {
expect(result).toBeNull()
})
test("should return null for JSON null value", () => {
//#given - boulder.json containing null
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, "null")
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).toBeNull()
})
test("should return null for JSON primitive value", () => {
//#given - boulder.json containing a string
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, '"just a string"')
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).toBeNull()
})
test("should default session_ids to [] when missing from JSON", () => {
//#given - boulder.json without session_ids field
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
plan_name: "plan",
}))
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).not.toBeNull()
expect(result!.session_ids).toEqual([])
})
test("should default session_ids to [] when not an array", () => {
//#given - boulder.json with session_ids as a string
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-01T00:00:00Z",
session_ids: "not-an-array",
plan_name: "plan",
}))
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).not.toBeNull()
expect(result!.session_ids).toEqual([])
})
test("should default session_ids to [] for empty object", () => {
//#given - boulder.json with empty object
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({}))
//#when
const result = readBoulderState(TEST_DIR)
//#then
expect(result).not.toBeNull()
expect(result!.session_ids).toEqual([])
})
test("should read valid boulder state", () => {
// given - valid boulder.json
const state: BoulderState = {
@@ -129,6 +201,23 @@ describe("boulder-state", () => {
// then
expect(result).toBeNull()
})
test("should not crash when boulder.json has no session_ids field", () => {
//#given - boulder.json without session_ids
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderFile, JSON.stringify({
active_plan: "/plan.md",
started_at: "2026-01-01T00:00:00Z",
plan_name: "plan",
}))
//#when
const result = appendSessionId(TEST_DIR, "ses-new")
//#then - should not crash and should contain the new session
expect(result).not.toBeNull()
expect(result!.session_ids).toContain("ses-new")
})
})
describe("clearBoulderState", () => {

View File

@@ -22,7 +22,14 @@ export function readBoulderState(directory: string): BoulderState | null {
try {
const content = readFileSync(filePath, "utf-8")
return JSON.parse(content) as BoulderState
const parsed = JSON.parse(content)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null
}
if (!Array.isArray(parsed.session_ids)) {
parsed.session_ids = []
}
return parsed as BoulderState
} catch {
return null
}
@@ -48,7 +55,10 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
const state = readBoulderState(directory)
if (!state) return null
if (!state.session_ids.includes(sessionId)) {
if (!state.session_ids?.includes(sessionId)) {
if (!Array.isArray(state.session_ids)) {
state.session_ids = []
}
state.session_ids.push(sessionId)
if (writeBoulderState(directory, state)) {
return state

View File

@@ -41,7 +41,7 @@ export function createAtlasEventHandler(input: {
// Read boulder state FIRST to check if this session is part of an active boulder
const boulderState = readBoulderState(ctx.directory)
const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false
const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false
const isBackgroundTaskSession = subagentSessions.has(sessionID)

View File

@@ -65,7 +65,7 @@ export function createToolExecuteAfterHandler(input: {
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
if (toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)) {
if (toolInput.sessionID && !boulderState.session_ids?.includes(toolInput.sessionID)) {
appendSessionId(ctx.directory, toolInput.sessionID)
log(`[${HOOK_NAME}] Appended session to boulder`, {
sessionID: toolInput.sessionID,

View File

@@ -26,7 +26,23 @@ export function buildOrchestratorReminder(
${buildVerificationReminder(sessionId)}
**STEP 5: CHECK BOULDER STATE DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
**STEP 5: READ SUBAGENT NOTEPAD (LEARNINGS, ISSUES, PROBLEMS)**
The subagent was instructed to record findings in notepad files. Read them NOW:
\`\`\`
Glob(".sisyphus/notepads/${planName}/*.md")
\`\`\`
Then \`Read\` each file found — especially:
- **learnings.md**: Patterns, conventions, successful approaches discovered
- **issues.md**: Problems, blockers, gotchas encountered during work
- **problems.md**: Unresolved issues, technical debt flagged
**USE this information to:**
- Inform your next delegation (avoid known pitfalls)
- Adjust your plan if blockers were discovered
- Propagate learnings to subsequent subagents
**STEP 6: CHECK BOULDER STATE DIRECTLY (EVERY TIME — NO EXCEPTIONS)**
Do NOT rely on cached progress. Read the plan file NOW:
\`\`\`
@@ -35,7 +51,7 @@ Read(".sisyphus/plans/${planName}.md")
Count exactly: how many \`- [ ]\` remain? How many \`- [x]\` completed?
This is YOUR ground truth. Use it to decide what comes next.
**STEP 6: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
**STEP 7: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
@@ -45,12 +61,12 @@ Update the plan file \`.sisyphus/plans/${planName}.md\`:
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
**STEP 7: COMMIT ATOMIC UNIT**
**STEP 8: COMMIT ATOMIC UNIT**
- Stage ONLY the verified changes
- Commit with clear message describing what was done
**STEP 8: PROCEED TO NEXT TASK**
**STEP 9: PROCEED TO NEXT TASK**
- Read the plan file AGAIN to identify the next \`- [ ]\` task
- Start immediately - DO NOT STOP

View File

@@ -202,7 +202,7 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
if (!command) {
return {
success: false,
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
error: parsed.command.includes(":") ? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.` : `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
}
}

View File

@@ -3,6 +3,6 @@ export { getLocalDevVersion } from "./checker/local-dev-version"
export { findPluginEntry } from "./checker/plugin-entry"
export type { PluginEntryInfo } from "./checker/plugin-entry"
export { getCachedVersion } from "./checker/cached-version"
export { updatePinnedVersion } from "./checker/pinned-version-updater"
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
export { getLatestVersion } from "./checker/latest-version"
export { checkForUpdate } from "./checker/check-for-update"

View File

@@ -0,0 +1,133 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import { updatePinnedVersion, revertPinnedVersion } from "./pinned-version-updater"
describe("pinned-version-updater", () => {
let tmpDir: string
let configPath: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omo-updater-test-"))
configPath = path.join(tmpDir, "opencode.json")
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe("updatePinnedVersion", () => {
test("updates pinned version in config", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.1.8"],
})
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
//#then
expect(result).toBe(true)
const updated = fs.readFileSync(configPath, "utf-8")
expect(updated).toContain("oh-my-opencode@3.4.0")
expect(updated).not.toContain("oh-my-opencode@3.1.8")
})
test("returns false when entry not found", () => {
//#given
const config = JSON.stringify({
plugin: ["some-other-plugin"],
})
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
//#then
expect(result).toBe(false)
})
test("returns false when no plugin array exists", () => {
//#given
const config = JSON.stringify({ agent: {} })
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
//#then
expect(result).toBe(false)
})
})
describe("revertPinnedVersion", () => {
test("reverts from failed version back to original entry", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.4.0"],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
//#then
expect(result).toBe(true)
const reverted = fs.readFileSync(configPath, "utf-8")
expect(reverted).toContain("oh-my-opencode@3.1.8")
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
})
test("reverts to unpinned entry", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.4.0"],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode")
//#then
expect(result).toBe(true)
const reverted = fs.readFileSync(configPath, "utf-8")
expect(reverted).toContain('"oh-my-opencode"')
expect(reverted).not.toContain("oh-my-opencode@3.4.0")
})
test("returns false when failed version not found", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-opencode@3.1.8"],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
//#then
expect(result).toBe(false)
})
})
describe("update then revert roundtrip", () => {
test("config returns to original state after update + revert", () => {
//#given
const originalConfig = JSON.stringify({
plugin: ["oh-my-opencode@3.1.8"],
})
fs.writeFileSync(configPath, originalConfig)
//#when
updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0")
revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8")
//#then
const finalConfig = fs.readFileSync(configPath, "utf-8")
expect(finalConfig).toContain("oh-my-opencode@3.1.8")
expect(finalConfig).not.toContain("oh-my-opencode@3.4.0")
})
})
})

View File

@@ -2,10 +2,9 @@ import * as fs from "node:fs"
import { log } from "../../../shared/logger"
import { PACKAGE_NAME } from "../constants"
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
function replacePluginEntry(configPath: string, oldEntry: string, newEntry: string): boolean {
try {
const content = fs.readFileSync(configPath, "utf-8")
const newEntry = `${PACKAGE_NAME}@${newVersion}`
const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
if (!pluginMatch || pluginMatch.index === undefined) {
@@ -51,3 +50,13 @@ export function updatePinnedVersion(configPath: string, oldEntry: string, newVer
return false
}
}
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
const newEntry = `${PACKAGE_NAME}@${newVersion}`
return replacePluginEntry(configPath, oldEntry, newEntry)
}
export function revertPinnedVersion(configPath: string, failedVersion: string, originalEntry: string): boolean {
const failedEntry = `${PACKAGE_NAME}@${failedVersion}`
return replacePluginEntry(configPath, failedEntry, originalEntry)
}

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
// Mock modules before importing
const mockFindPluginEntry = mock(() => null as any)
const mockGetCachedVersion = mock(() => null as string | null)
const mockGetLatestVersion = mock(async () => null as string | null)
const mockUpdatePinnedVersion = mock(() => false)
const mockExtractChannel = mock(() => "latest")
const mockInvalidatePackage = mock(() => {})
const mockRunBunInstall = mock(async () => true)
const mockShowUpdateAvailableToast = mock(async () => {})
const mockShowAutoUpdatedToast = mock(async () => {})
mock.module("../checker", () => ({
findPluginEntry: mockFindPluginEntry,
getCachedVersion: mockGetCachedVersion,
getLatestVersion: mockGetLatestVersion,
updatePinnedVersion: mockUpdatePinnedVersion,
}))
mock.module("../version-channel", () => ({
extractChannel: mockExtractChannel,
}))
mock.module("../cache", () => ({
invalidatePackage: mockInvalidatePackage,
}))
mock.module("../../../cli/config-manager", () => ({
runBunInstall: mockRunBunInstall,
}))
mock.module("./update-toasts", () => ({
showUpdateAvailableToast: mockShowUpdateAvailableToast,
showAutoUpdatedToast: mockShowAutoUpdatedToast,
}))
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
const { runBackgroundUpdateCheck } = await import("./background-update-check")
describe("runBackgroundUpdateCheck", () => {
const mockCtx = { directory: "/test" } as any
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
isUpdate ? `Update to ${version}` : "Up to date"
beforeEach(() => {
mockFindPluginEntry.mockReset()
mockGetCachedVersion.mockReset()
mockGetLatestVersion.mockReset()
mockUpdatePinnedVersion.mockReset()
mockExtractChannel.mockReset()
mockInvalidatePackage.mockReset()
mockRunBunInstall.mockReset()
mockShowUpdateAvailableToast.mockReset()
mockShowAutoUpdatedToast.mockReset()
mockExtractChannel.mockReturnValue("latest")
mockRunBunInstall.mockResolvedValue(true)
})
describe("#given user has pinned a specific version", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode@3.4.0",
isPinned: true,
pinnedVersion: "3.4.0",
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should NOT call updatePinnedVersion", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
})
it("#then should show update-available toast instead", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(
mockCtx,
"3.5.0",
mockGetToastMessage
)
})
it("#then should NOT run bun install", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockRunBunInstall).not.toHaveBeenCalled()
})
it("#then should NOT invalidate package cache", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockInvalidatePackage).not.toHaveBeenCalled()
})
})
describe("#given user has NOT pinned a version (unpinned)", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should proceed with auto-update", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockInvalidatePackage).toHaveBeenCalled()
expect(mockRunBunInstall).toHaveBeenCalled()
})
it("#then should show auto-updated toast on success", async () => {
mockRunBunInstall.mockResolvedValue(true)
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockShowAutoUpdatedToast).toHaveBeenCalled()
})
})
describe("#given autoUpdate is false", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should only show notification toast", async () => {
await runBackgroundUpdateCheck(mockCtx, false, mockGetToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
})
})
describe("#given already on latest version", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode@3.5.0",
isPinned: true,
pinnedVersion: "3.5.0",
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.5.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should not update or show toast", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
})

View File

@@ -4,7 +4,7 @@ import { log } from "../../../shared/logger"
import { invalidatePackage } from "../cache"
import { PACKAGE_NAME } from "../constants"
import { extractChannel } from "../version-channel"
import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion } from "../checker"
import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion, revertPinnedVersion } from "../checker"
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
async function runBunInstallSafe(): Promise<boolean> {
@@ -56,13 +56,9 @@ export async function runBackgroundUpdateCheck(
}
if (pluginInfo.isPinned) {
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
if (!updated) {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] Failed to update pinned version in config")
return
}
log(`[auto-update-checker] Config updated: ${pluginInfo.entry}${PACKAGE_NAME}@${latestVersion}`)
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log(`[auto-update-checker] User-pinned version detected (${pluginInfo.entry}), skipping auto-update. Notification only.`)
return
}
invalidatePackage(PACKAGE_NAME)
@@ -72,8 +68,14 @@ export async function runBackgroundUpdateCheck(
if (installSuccess) {
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
log(`[auto-update-checker] Update installed: ${currentVersion}${latestVersion}`)
} else {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
return
}
if (pluginInfo.isPinned) {
revertPinnedVersion(pluginInfo.configPath, latestVersion, pluginInfo.entry)
log("[auto-update-checker] Config reverted due to install failure")
}
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
}

View File

@@ -92,7 +92,7 @@ export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
const toolLower = input.tool.toLowerCase()
// Only skip if the output indicates a tool execution failure
const outputLower = output.output.toLowerCase()
const outputLower = (output.output ?? "").toLowerCase()
const isToolFailure =
outputLower.includes("error:") ||
outputLower.includes("failed to") ||

View File

@@ -44,7 +44,7 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) {
) => {
if (input.tool.toLowerCase() !== "edit") return
const outputLower = output.output.toLowerCase()
const outputLower = (output.output ?? "").toLowerCase()
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
outputLower.includes(pattern.toLowerCase())
)

View File

@@ -102,6 +102,23 @@ describe("createEditErrorRecoveryHook", () => {
})
})
describe("#given MCP tool with undefined output.output", () => {
describe("#when output.output is undefined", () => {
it("#then should not crash", async () => {
const input = createInput("Edit")
const output = {
title: "Edit",
output: undefined as unknown as string,
metadata: {},
}
await hook["tool.execute.after"](input, output)
expect(output.output).toBeUndefined()
})
})
})
describe("#given case insensitive tool name", () => {
describe("#when tool is 'edit' lowercase", () => {
it("#then should still detect and append reminder", async () => {

View File

@@ -0,0 +1,5 @@
import { PROMETHEUS_AGENT } from "./constants"
export function isPrometheusAgent(agentName: string | undefined): boolean {
return agentName?.toLowerCase().includes(PROMETHEUS_AGENT) ?? false
}

View File

@@ -43,7 +43,7 @@ export function getAgentFromSession(sessionID: string, directory: string): strin
// Check boulder state (persisted across restarts) - fixes #927
const boulderState = readBoulderState(directory)
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
if (boulderState?.session_ids?.includes(sessionID) && boulderState.agent) {
return boulderState.agent
}

View File

@@ -1,9 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { HOOK_NAME, PROMETHEUS_AGENT, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
import { HOOK_NAME, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { getAgentDisplayName } from "../../shared/agent-display-names"
import { getAgentFromSession } from "./agent-resolution"
import { isPrometheusAgent } from "./agent-matcher"
import { isAllowedFile } from "./path-policy"
const TASK_TOOLS = ["task", "call_omo_agent"]
@@ -16,7 +17,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
): Promise<void> => {
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
if (agentName !== PROMETHEUS_AGENT) {
if (!isPrometheusAgent(agentName)) {
return
}

View File

@@ -30,11 +30,11 @@ describe("prometheus-md-only", () => {
} as never
}
function setupMessageStorage(sessionID: string, agent: string): void {
function setupMessageStorage(sessionID: string, agent: string | undefined): void {
testMessageDir = join(MESSAGE_STORAGE, sessionID)
mkdirSync(testMessageDir, { recursive: true })
const messageContent = {
agent,
...(agent ? { agent } : {}),
model: { providerID: "test", modelID: "test-model" },
}
writeFileSync(
@@ -55,6 +55,122 @@ describe("prometheus-md-only", () => {
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
})
describe("agent name matching", () => {
test("should enforce md-only restriction for exact prometheus agent name", async () => {
//#given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/file.ts" },
}
//#when //#then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
test("should enforce md-only restriction for Prometheus display name Plan Builder", async () => {
//#given
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Plan Builder)")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/file.ts" },
}
//#when //#then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
test("should enforce md-only restriction for Prometheus display name Planner", async () => {
//#given
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/file.ts" },
}
//#when //#then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
test("should enforce md-only restriction for uppercase PROMETHEUS", async () => {
//#given
setupMessageStorage(TEST_SESSION_ID, "PROMETHEUS")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/file.ts" },
}
//#when //#then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
test("should not enforce restriction for non-Prometheus agent", async () => {
//#given
setupMessageStorage(TEST_SESSION_ID, "sisyphus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/file.ts" },
}
//#when //#then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should not enforce restriction when agent name is undefined", async () => {
//#given
setupMessageStorage(TEST_SESSION_ID, undefined)
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/file.ts" },
}
//#when //#then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
})
describe("with Prometheus agent in message storage", () => {
beforeEach(() => {
setupMessageStorage(TEST_SESSION_ID, "prometheus")

View File

@@ -21,14 +21,15 @@ export function createTaskResumeInfoHook() {
output: { title: string; output: string; metadata: unknown }
) => {
if (!TARGET_TOOLS.includes(input.tool)) return
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
if (output.output.includes("\nto continue:")) return
const outputText = output.output ?? ""
if (outputText.startsWith("Error:") || outputText.startsWith("Failed")) return
if (outputText.includes("\nto continue:")) return
const sessionId = extractSessionId(output.output)
const sessionId = extractSessionId(outputText)
if (!sessionId) return
output.output =
output.output.trimEnd() +
outputText.trimEnd() +
`\n\nto continue: task(session_id="${sessionId}", prompt="...")`
}

View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from "bun:test"
import { createTaskResumeInfoHook } from "./index"
describe("createTaskResumeInfoHook", () => {
const hook = createTaskResumeInfoHook()
const afterHook = hook["tool.execute.after"]
const createInput = (tool: string) => ({
tool,
sessionID: "test-session",
callID: "test-call-id",
})
describe("#given MCP tool with undefined output.output", () => {
describe("#when tool.execute.after is called", () => {
it("#then should not crash", async () => {
const input = createInput("task")
const output = {
title: "delegate_task",
output: undefined as unknown as string,
metadata: {},
}
await afterHook(input, output)
expect(output.output).toBeUndefined()
})
})
})
describe("#given non-target tool", () => {
describe("#when tool is not in TARGET_TOOLS", () => {
it("#then should not modify output", async () => {
const input = createInput("Read")
const output = {
title: "Read",
output: "some output",
metadata: {},
}
await afterHook(input, output)
expect(output.output).toBe("some output")
})
})
})
describe("#given target tool with session ID in output", () => {
describe("#when output contains a session ID", () => {
it("#then should append resume info", async () => {
const input = createInput("call_omo_agent")
const output = {
title: "delegate_task",
output: "Task completed.\nSession ID: ses_abc123",
metadata: {},
}
await afterHook(input, output)
expect(output.output).toContain("to continue:")
expect(output.output).toContain("ses_abc123")
})
})
})
describe("#given target tool with error output", () => {
describe("#when output starts with Error:", () => {
it("#then should not modify output", async () => {
const input = createInput("task")
const output = {
title: "task",
output: "Error: something went wrong",
metadata: {},
}
await afterHook(input, output)
expect(output.output).toBe("Error: something went wrong")
})
})
})
describe("#given target tool with already-continued output", () => {
describe("#when output already contains continuation info", () => {
it("#then should not add duplicate", async () => {
const input = createInput("task")
const output = {
title: "task",
output:
'Done.\nSession ID: ses_abc123\nto continue: task(session_id="ses_abc123", prompt="...")',
metadata: {},
}
await afterHook(input, output)
const matches = output.output.match(/to continue:/g)
expect(matches?.length).toBe(1)
})
})
})
})

View File

@@ -1,19 +1,14 @@
import type { AvailableCategory } from "../agents/dynamic-agent-prompt-builder"
import type { OhMyOpenCodeConfig } from "../config"
import {
CATEGORY_DESCRIPTIONS,
DEFAULT_CATEGORIES,
} from "../tools/delegate-task/constants"
import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { mergeCategories } from "../shared/merge-categories"
export function createAvailableCategories(
pluginConfig: OhMyOpenCodeConfig,
): AvailableCategory[] {
const mergedCategories = pluginConfig.categories
? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories }
: DEFAULT_CATEGORIES
const categories = mergeCategories(pluginConfig.categories)
return Object.entries(mergedCategories).map(([name, categoryConfig]) => {
return Object.entries(categories).map(([name, categoryConfig]) => {
const model =
typeof categoryConfig.model === "string" ? categoryConfig.model : undefined

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from "bun:test"
import { mergeCategories } from "./merge-categories"
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
describe("mergeCategories", () => {
it("returns all default categories when no user config provided", () => {
//#given
const userCategories = undefined
//#when
const result = mergeCategories(userCategories)
//#then
expect(Object.keys(result)).toEqual(Object.keys(DEFAULT_CATEGORIES))
})
it("filters out categories with disable: true", () => {
//#given
const userCategories = {
"quick": { disable: true },
}
//#when
const result = mergeCategories(userCategories)
//#then
expect(result["quick"]).toBeUndefined()
expect(Object.keys(result).length).toBe(Object.keys(DEFAULT_CATEGORIES).length - 1)
})
it("keeps categories with disable: false", () => {
//#given
const userCategories = {
"quick": { disable: false },
}
//#when
const result = mergeCategories(userCategories)
//#then
expect(result["quick"]).toBeDefined()
})
it("allows user to add custom categories", () => {
//#given
const userCategories = {
"my-custom": { model: "openai/gpt-5.2", description: "Custom category" },
}
//#when
const result = mergeCategories(userCategories)
//#then
expect(result["my-custom"]).toBeDefined()
expect(result["my-custom"].model).toBe("openai/gpt-5.2")
})
it("allows user to disable custom categories", () => {
//#given
const userCategories = {
"my-custom": { model: "openai/gpt-5.2", disable: true },
}
//#when
const result = mergeCategories(userCategories)
//#then
expect(result["my-custom"]).toBeUndefined()
})
it("user overrides merge with defaults", () => {
//#given
const userCategories = {
"ultrabrain": { model: "anthropic/claude-opus-4-6" },
}
//#when
const result = mergeCategories(userCategories)
//#then
expect(result["ultrabrain"]).toBeDefined()
expect(result["ultrabrain"].model).toBe("anthropic/claude-opus-4-6")
})
})

View File

@@ -0,0 +1,18 @@
import type { CategoriesConfig, CategoryConfig } from "../config/schema"
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
/**
* Merge default and user categories, filtering out disabled ones.
* Single source of truth for category merging across the codebase.
*/
export function mergeCategories(
userCategories?: CategoriesConfig,
): Record<string, CategoryConfig> {
const merged = userCategories
? { ...DEFAULT_CATEGORIES, ...userCategories }
: { ...DEFAULT_CATEGORIES }
return Object.fromEntries(
Object.entries(merged).filter(([, config]) => !config.disable),
)
}

View File

@@ -53,24 +53,194 @@ describe("opencode-server-auth", () => {
process.env.OPENCODE_SERVER_PASSWORD = "secret"
delete process.env.OPENCODE_SERVER_USERNAME
let receivedConfig: { headers: Record<string, string> } | undefined
let receivedHeadersConfig: { headers: Record<string, string> } | undefined
const client = {
_client: {
setConfig: (config: { headers: Record<string, string> }) => {
receivedConfig = config
setConfig: (config: { headers?: Record<string, string> }) => {
if (config.headers) {
receivedHeadersConfig = { headers: config.headers }
}
},
},
}
injectServerAuthIntoClient(client)
expect(receivedConfig).toEqual({
expect(receivedHeadersConfig).toEqual({
headers: {
Authorization: "Basic b3BlbmNvZGU6c2VjcmV0",
},
})
})
test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => {
//#given
process.env.OPENCODE_SERVER_PASSWORD = "secret"
delete process.env.OPENCODE_SERVER_USERNAME
let receivedAuthorization: string | null = null
const baseFetch = async (request: Request): Promise<Response> => {
receivedAuthorization = request.headers.get("Authorization")
return new Response("ok")
}
type InternalConfig = {
fetch?: (request: Request) => Promise<Response>
headers?: Record<string, string>
}
let currentConfig: InternalConfig = {
fetch: baseFetch,
headers: {},
}
const client = {
_client: {
getConfig: (): InternalConfig => ({ ...currentConfig }),
setConfig: (config: InternalConfig): InternalConfig => {
currentConfig = { ...currentConfig, ...config }
return { ...currentConfig }
},
},
}
//#when
injectServerAuthIntoClient(client)
if (!currentConfig.fetch) {
throw new Error("expected fetch to be set")
}
await currentConfig.fetch(new Request("http://example.com"))
//#then
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
})
test("#given server password #when internal has _config.fetch but no setConfig #then fetch is wrapped and injects Authorization", async () => {
//#given
process.env.OPENCODE_SERVER_PASSWORD = "secret"
delete process.env.OPENCODE_SERVER_USERNAME
let receivedAuthorization: string | null = null
const baseFetch = async (request: Request): Promise<Response> => {
receivedAuthorization = request.headers.get("Authorization")
return new Response("ok")
}
const internal = {
_config: {
fetch: baseFetch,
},
}
const client = {
_client: internal,
}
//#when
injectServerAuthIntoClient(client)
await internal._config.fetch(new Request("http://example.com"))
//#then
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
})
test("#given server password #when client has top-level fetch #then fetch is wrapped and injects Authorization", async () => {
//#given
process.env.OPENCODE_SERVER_PASSWORD = "secret"
delete process.env.OPENCODE_SERVER_USERNAME
let receivedAuthorization: string | null = null
const baseFetch = async (request: Request): Promise<Response> => {
receivedAuthorization = request.headers.get("Authorization")
return new Response("ok")
}
const client = {
fetch: baseFetch,
}
//#when
injectServerAuthIntoClient(client)
await client.fetch(new Request("http://example.com"))
//#then
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
})
test("#given server password #when interceptors are available #then request interceptor injects Authorization", async () => {
//#given
process.env.OPENCODE_SERVER_PASSWORD = "secret"
delete process.env.OPENCODE_SERVER_USERNAME
let registeredInterceptor:
| ((request: Request, options: { headers?: Headers }) => Promise<Request> | Request)
| undefined
const client = {
_client: {
interceptors: {
request: {
use: (
interceptor: (request: Request, options: { headers?: Headers }) => Promise<Request> | Request
): number => {
registeredInterceptor = interceptor
return 0
},
},
},
},
}
//#when
injectServerAuthIntoClient(client)
if (!registeredInterceptor) {
throw new Error("expected interceptor to be registered")
}
const request = new Request("http://example.com")
const result = await registeredInterceptor(request, {})
//#then
expect(result.headers.get("Authorization")).toBe("Basic b3BlbmNvZGU6c2VjcmV0")
})
test("#given no server password #when injecting into client with fetch #then does not wrap fetch", async () => {
//#given
delete process.env.OPENCODE_SERVER_PASSWORD
delete process.env.OPENCODE_SERVER_USERNAME
let receivedAuthorization: string | null = null
const baseFetch = async (request: Request): Promise<Response> => {
receivedAuthorization = request.headers.get("Authorization")
return new Response("ok")
}
type InternalConfig = { fetch?: (request: Request) => Promise<Response> }
let currentConfig: InternalConfig = { fetch: baseFetch }
let setConfigCalled = false
const client = {
_client: {
getConfig: (): InternalConfig => ({ ...currentConfig }),
setConfig: (config: InternalConfig): InternalConfig => {
setConfigCalled = true
currentConfig = { ...currentConfig, ...config }
return { ...currentConfig }
},
},
}
//#when
injectServerAuthIntoClient(client)
if (!currentConfig.fetch) {
throw new Error("expected fetch to exist")
}
await currentConfig.fetch(new Request("http://example.com"))
//#then
expect(setConfigCalled).toBe(false)
expect(receivedAuthorization).toBeNull()
})
test("#given server password #when client has no _client #then does not throw", () => {
process.env.OPENCODE_SERVER_PASSWORD = "secret"
const client = {}

View File

@@ -1,3 +1,5 @@
import { log } from "./logger"
/**
* Builds HTTP Basic Auth header from environment variables.
*
@@ -15,6 +17,132 @@ export function getServerBasicAuthHeader(): string | undefined {
return `Basic ${token}`
}
type UnknownRecord = Record<string, unknown>
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null
}
function isRequestFetch(value: unknown): value is (request: Request) => Promise<Response> {
return typeof value === "function"
}
function wrapRequestFetch(
baseFetch: (request: Request) => Promise<Response>,
auth: string
): (request: Request) => Promise<Response> {
return async (request: Request): Promise<Response> => {
const headers = new Headers(request.headers)
headers.set("Authorization", auth)
return baseFetch(new Request(request, { headers }))
}
}
function getInternalClient(client: unknown): UnknownRecord | null {
if (!isRecord(client)) {
return null
}
const internal = client["_client"]
return isRecord(internal) ? internal : null
}
function tryInjectViaSetConfigHeaders(internal: UnknownRecord, auth: string): boolean {
const setConfig = internal["setConfig"]
if (typeof setConfig !== "function") {
return false
}
setConfig({
headers: {
Authorization: auth,
},
})
return true
}
function tryInjectViaInterceptors(internal: UnknownRecord, auth: string): boolean {
const interceptors = internal["interceptors"]
if (!isRecord(interceptors)) {
return false
}
const requestInterceptors = interceptors["request"]
if (!isRecord(requestInterceptors)) {
return false
}
const use = requestInterceptors["use"]
if (typeof use !== "function") {
return false
}
use((request: Request): Request => {
if (!request.headers.get("Authorization")) {
request.headers.set("Authorization", auth)
}
return request
})
return true
}
function tryInjectViaFetchWrapper(internal: UnknownRecord, auth: string): boolean {
const getConfig = internal["getConfig"]
const setConfig = internal["setConfig"]
if (typeof getConfig !== "function" || typeof setConfig !== "function") {
return false
}
const config = getConfig()
if (!isRecord(config)) {
return false
}
const fetchValue = config["fetch"]
if (!isRequestFetch(fetchValue)) {
return false
}
setConfig({
fetch: wrapRequestFetch(fetchValue, auth),
})
return true
}
function tryInjectViaMutableInternalConfig(internal: UnknownRecord, auth: string): boolean {
const configValue = internal["_config"]
if (!isRecord(configValue)) {
return false
}
const fetchValue = configValue["fetch"]
if (!isRequestFetch(fetchValue)) {
return false
}
configValue["fetch"] = wrapRequestFetch(fetchValue, auth)
return true
}
function tryInjectViaTopLevelFetch(client: unknown, auth: string): boolean {
if (!isRecord(client)) {
return false
}
const fetchValue = client["fetch"]
if (!isRequestFetch(fetchValue)) {
return false
}
client["fetch"] = wrapRequestFetch(fetchValue, auth)
return true
}
/**
* Injects HTTP Basic Auth header into the OpenCode SDK client.
*
@@ -34,36 +162,29 @@ export function injectServerAuthIntoClient(client: unknown): void {
}
try {
if (
typeof client !== "object" ||
client === null ||
!("_client" in client) ||
typeof (client as { _client: unknown })._client !== "object" ||
(client as { _client: unknown })._client === null
) {
throw new Error(
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible. " +
"This may indicate an OpenCode SDK version mismatch."
)
const internal = getInternalClient(client)
if (internal) {
const injectedHeaders = tryInjectViaSetConfigHeaders(internal, auth)
const injectedInterceptors = tryInjectViaInterceptors(internal, auth)
const injectedFetch = tryInjectViaFetchWrapper(internal, auth)
const injectedMutable = tryInjectViaMutableInternalConfig(internal, auth)
const injected = injectedHeaders || injectedInterceptors || injectedFetch || injectedMutable
if (!injected) {
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible", {
keys: Object.keys(internal),
})
}
return
}
const internal = (client as { _client: { setConfig?: (config: { headers: Record<string, string> }) => void } })
._client
if (typeof internal.setConfig !== "function") {
throw new Error(
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client._client.setConfig is not a function. " +
"This may indicate an OpenCode SDK version mismatch."
)
const injected = tryInjectViaTopLevelFetch(client, auth)
if (!injected) {
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but no compatible SDK client found")
}
internal.setConfig({
headers: {
Authorization: auth,
},
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.warn(`[opencode-server-auth] Failed to inject server auth: ${message}`)
log("[opencode-server-auth] Failed to inject server auth", { message })
}
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import { createOrGetSession } from "./session-creator"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
describe("call-omo-agent createOrGetSession", () => {
test("creates child session without overriding permission and tracks it as subagent session", async () => {
// given
_resetForTesting()
const createCalls: Array<unknown> = []
const ctx = {
directory: "/project",
client: {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (args: unknown) => {
createCalls.push(args)
return { data: { id: "ses_child" } }
},
},
},
}
const toolContext = {
sessionID: "ses_parent",
messageID: "msg_parent",
agent: "sisyphus",
abort: new AbortController().signal,
}
const args = {
description: "test",
prompt: "hello",
subagent_type: "explore",
run_in_background: true,
}
// when
const result = await createOrGetSession(args as any, toolContext as any, ctx as any)
// then
expect(result).toEqual({ sessionID: "ses_child", isNew: true })
expect(createCalls).toHaveLength(1)
const createBody = (createCalls[0] as any)?.body
expect(createBody?.parentID).toBe("ses_parent")
expect(createBody?.permission).toBeUndefined()
expect(subagentSessions.has("ses_child")).toBe(true)
})
})

View File

@@ -1,5 +1,6 @@
import type { CallOmoAgentArgs } from "./types"
import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared"
export async function createOrGetSession(
@@ -38,9 +39,6 @@ export async function createOrGetSession(
body: {
parentID: toolContext.sessionID,
title: `${args.description} (@${args.subagent_type} subagent)`,
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
@@ -65,6 +63,7 @@ Original error: ${createResult.error}`)
const sessionID = createResult.data.id
log(`[call_omo_agent] Created session: ${sessionID}`)
subagentSessions.add(sessionID)
return { sessionID, isNew: true }
}
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test"
import { resolveOrCreateSessionId } from "./subagent-session-creator"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
describe("call-omo-agent resolveOrCreateSessionId", () => {
test("tracks newly created child session as subagent session", async () => {
// given
_resetForTesting()
const createCalls: Array<unknown> = []
const ctx = {
directory: "/project",
client: {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (args: unknown) => {
createCalls.push(args)
return { data: { id: "ses_child_sync" } }
},
},
},
}
const args = {
description: "sync test",
prompt: "hello",
subagent_type: "explore",
run_in_background: false,
}
const toolContext = {
sessionID: "ses_parent",
messageID: "msg_parent",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when
const result = await resolveOrCreateSessionId(ctx as any, args as any, toolContext as any)
// then
expect(result).toEqual({ ok: true, sessionID: "ses_child_sync" })
expect(createCalls).toHaveLength(1)
expect(subagentSessions.has("ses_child_sync")).toBe(true)
})
})

View File

@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { subagentSessions } from "../../features/claude-code-session-state"
import type { CallOmoAgentArgs } from "./types"
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
@@ -63,5 +64,6 @@ Original error: ${createResult.error}`,
const sessionID = createResult.data.id
log(`[call_omo_agent] Created session: ${sessionID}`)
subagentSessions.add(sessionID)
return { ok: true, sessionID }
}

View File

@@ -13,6 +13,7 @@ export async function promptSubagentSession(
tools: {
...getAgentToolRestrictions(options.agent),
task: false,
question: false,
},
parts: [{ type: "text", text: options.prompt }],
},

View File

@@ -0,0 +1,95 @@
const { describe, test, expect, mock } = require("bun:test")
describe("executeBackgroundContinuation - subagent metadata", () => {
test("includes subagent in task_metadata when task has agent", async () => {
//#given - mock manager.resume returning task with agent info
const mockManager = {
resume: async () => ({
id: "bg_task_001",
description: "oracle consultation",
agent: "oracle",
status: "running",
sessionID: "ses_resumed_123",
}),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-456",
metadata: mock(() => Promise.resolve()),
}
const mockExecutorCtx = {
manager: mockManager,
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-parent",
agent: "sisyphus",
}
const args = {
session_id: "ses_resumed_123",
prompt: "continue working",
description: "resume oracle",
load_skills: [],
run_in_background: true,
}
//#when - executeBackgroundContinuation completes
const { executeBackgroundContinuation } = require("./background-continuation")
const result = await executeBackgroundContinuation(args, mockCtx, mockExecutorCtx, parentContext)
//#then - task_metadata should contain subagent field
expect(result).toContain("<task_metadata>")
expect(result).toContain("subagent: oracle")
expect(result).toContain("session_id: ses_resumed_123")
})
test("omits subagent from task_metadata when task agent is undefined", async () => {
//#given - mock manager.resume returning task without agent
const mockManager = {
resume: async () => ({
id: "bg_task_002",
description: "unknown task",
agent: undefined,
status: "running",
sessionID: "ses_resumed_456",
}),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-789",
metadata: mock(() => Promise.resolve()),
}
const mockExecutorCtx = {
manager: mockManager,
}
const parentContext = {
sessionID: "parent-session",
messageID: "msg-parent",
agent: "sisyphus",
}
const args = {
session_id: "ses_resumed_456",
prompt: "continue",
description: "resume task",
load_skills: [],
run_in_background: true,
}
//#when - executeBackgroundContinuation completes without agent
const { executeBackgroundContinuation } = require("./background-continuation")
const result = await executeBackgroundContinuation(args, mockCtx, mockExecutorCtx, parentContext)
//#then - task_metadata should NOT contain subagent field
expect(result).toContain("<task_metadata>")
expect(result).toContain("session_id: ses_resumed_456")
expect(result).not.toContain("subagent:")
})
})

View File

@@ -50,7 +50,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.
<task_metadata>
session_id: ${task.sessionID}
</task_metadata>`
${task.agent ? `subagent: ${task.agent}\n` : ""}</task_metadata>`
} catch (error) {
return formatDetailedError(error, {
operation: "Continue background task",

View File

@@ -32,7 +32,10 @@ export function resolveCategoryConfig(
const userConfig = userCategories?.[categoryName]
const hasExplicitUserConfig = userConfig !== undefined
// Check if category requires a specific model - bypass if user explicitly provides config
if (userConfig?.disable) {
return null
}
const categoryReq = CATEGORY_MODEL_REQUIREMENTS[categoryName]
if (categoryReq?.requiresModel && availableModels && !hasExplicitUserConfig) {
if (!isModelAvailable(categoryReq.requiresModel, availableModels)) {

View File

@@ -1,7 +1,7 @@
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import { DEFAULT_CATEGORIES } from "./constants"
import { mergeCategories } from "../../shared/merge-categories"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories"
import { parseModelString } from "./model-string-parser"
@@ -30,7 +30,8 @@ export async function resolveCategoryExecution(
const availableModels = await getAvailableModelsForDelegateTask(client)
const categoryName = args.category!
const categoryExists = DEFAULT_CATEGORIES[categoryName] !== undefined || userCategories?.[categoryName] !== undefined
const enabledCategories = mergeCategories(userCategories)
const categoryExists = enabledCategories[categoryName] !== undefined
const resolved = resolveCategoryConfig(categoryName, {
userCategories,
@@ -41,7 +42,7 @@ export async function resolveCategoryExecution(
if (!resolved) {
const requirement = CATEGORY_MODEL_REQUIREMENTS[categoryName]
const allCategoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")
const allCategoryNames = Object.keys(enabledCategories).join(", ")
if (categoryExists && requirement?.requiresModel) {
return {
@@ -146,7 +147,7 @@ Available categories: ${allCategoryNames}`,
const categoryPromptAppend = resolved.promptAppend || undefined
if (!categoryModel && !actualModel) {
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
const categoryNames = Object.keys(enabledCategories)
return {
agentToUse: "",
categoryModel: undefined,

View File

@@ -356,4 +356,112 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
expect(addTaskCalls.length).toBe(0)
expect(removeTaskCalls.length).toBe(0)
})
test("includes subagent in task_metadata when agent info is present in session messages", async () => {
//#given - mock session messages with agent info on the last assistant message
const mockClient = {
session: {
messages: async () => ({
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 }, agent: "oracle" } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn", agent: "oracle", providerID: "openai", modelID: "gpt-5.2" },
parts: [{ type: "text", text: "Response" }],
},
],
}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
}),
},
}
const { executeSyncContinuation } = require("./sync-continuation")
const deps = {
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
}
const args = {
session_id: "ses_test_12345678",
prompt: "continue working",
description: "resume oracle task",
load_skills: [],
run_in_background: false,
}
//#when - executeSyncContinuation completes with agent info in messages
const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, deps)
//#then - task_metadata should contain subagent field with the agent name
expect(result).toContain("<task_metadata>")
expect(result).toContain("subagent: oracle")
expect(result).toContain("session_id: ses_test_12345678")
})
test("omits subagent from task_metadata when no agent info in session messages", async () => {
//#given - mock session messages without any agent info
const mockClient = {
session: {
messages: async () => ({
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
parts: [{ type: "text", text: "Response" }],
},
],
}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
}),
},
}
const { executeSyncContinuation } = require("./sync-continuation")
const deps = {
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
}
const args = {
session_id: "ses_test_12345678",
prompt: "continue working",
description: "resume task",
load_skills: [],
run_in_background: false,
}
//#when - executeSyncContinuation completes without agent info
const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, deps)
//#then - task_metadata should NOT contain subagent field
expect(result).toContain("<task_metadata>")
expect(result).toContain("session_id: ses_test_12345678")
expect(result).not.toContain("subagent:")
})
})

View File

@@ -128,7 +128,7 @@ ${result.textContent || "(No text output)"}
<task_metadata>
session_id: ${args.session_id}
</task_metadata>`
${resumeAgent ? `subagent: ${resumeAgent}\n` : ""}</task_metadata>`
} finally {
if (toastManager) {
toastManager.removeTask(taskId)

View File

@@ -13,9 +13,6 @@ export async function createSyncSession(
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agentToUse} subagent)`,
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,

View File

@@ -1,6 +1,7 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants"
import { CATEGORY_DESCRIPTIONS } from "./constants"
import { mergeCategories } from "../../shared/merge-categories"
import { log } from "../../shared/logger"
import { buildSystemContent } from "./prompt-builder"
import type {
@@ -26,7 +27,7 @@ export { buildSystemContent } from "./prompt-builder"
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
const { userCategories } = options
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const allCategories = mergeCategories(userCategories)
const categoryNames = Object.keys(allCategories)
const categoryExamples = categoryNames.map(k => `'${k}'`).join(", ")

View File

@@ -88,7 +88,9 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
return `No exact match for "/${commandName}". Did you mean: ${matchList}?\n\n${formatCommandList(allItems)}`
}
return `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.`
return commandName.includes(":")
? `Marketplace plugin commands like "/${commandName}" are not supported. Use .claude/commands/ for custom commands.\n\n${formatCommandList(allItems)}`
: `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.`
},
})
}