Compare commits

..

22 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
31 changed files with 1294 additions and 147 deletions

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.2",
"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.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"
"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.2",
"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.2",
"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.2",
"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.2",
"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.2",
"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.2",
"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.2",
"version": "3.5.3",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1359,6 +1359,86 @@
"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

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

@@ -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")
@@ -2505,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
@@ -729,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
@@ -1281,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
@@ -1292,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()
@@ -1316,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

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

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

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

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

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

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

@@ -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.`
},
})
}