From 33b52270b0376923622a33081b906165354a63ed Mon Sep 17 00:00:00 2001 From: Gladdonilli Date: Mon, 12 Jan 2026 00:20:04 +0800 Subject: [PATCH 01/13] fix: add subagent safety guards and apply PR #655 changes - Add max_steps limit to explore (25) and librarian (30) agents - Block sisyphus_task/call_omo_agent tools in librarian to prevent spawning - Add global 15-minute timeout for background tasks (MAX_RUN_TIME_MS) - Simplify session.idle handler - remove validateSessionHasOutput/checkSessionTodos guards - Add JSONC config file support (.jsonc checked before .json) - Fix categoryModel passing in sisyphus_task sync mode Reference: PR #655, kdcokenny/opencode-background-agents --- src/agents/explore.ts | 1 + src/agents/librarian.ts | 3 +- src/features/background-agent/manager.ts | 55 +++++++++++++++--------- src/shared/config-path.ts | 28 +++++++++--- src/tools/sisyphus-task/tools.ts | 4 +- 5 files changed, 62 insertions(+), 29 deletions(-) diff --git a/src/agents/explore.ts b/src/agents/explore.ts index bc887b314a..aef8515a25 100644 --- a/src/agents/explore.ts +++ b/src/agents/explore.ts @@ -39,6 +39,7 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig { mode: "subagent" as const, model, temperature: 0.1, + max_steps: 25, ...restrictions, prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results. diff --git a/src/agents/librarian.ts b/src/agents/librarian.ts index 27b6a6525d..f23836214c 100644 --- a/src/agents/librarian.ts +++ b/src/agents/librarian.ts @@ -27,7 +27,8 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig mode: "subagent" as const, model, temperature: 0.1, - tools: { write: false, edit: false, background_task: false }, + max_steps: 30, + tools: { write: false, edit: false, background_task: false, task: false, sisyphus_task: false, call_omo_agent: false }, prompt: `# THE LIBRARIAN You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent. diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 6d58b2592f..e7502958ca 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -14,6 +14,12 @@ import { getTaskToastManager } from "../task-toast-manager" const TASK_TTL_MS = 30 * 60 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in +// MIN_IDLE_TIME_MS: Minimum time before accepting session.idle as completion +// This prevents false positives when SDK emits idle before agent fully starts +const MIN_IDLE_TIME_MS = 5000 +// MAX_RUN_TIME_MS: Global timeout to prevent tasks from running forever +// Reference: kdcokenny/opencode-background-agents uses 15 minutes +const MAX_RUN_TIME_MS = 15 * 60 * 1000 type OpencodeClient = PluginInput["client"] @@ -178,6 +184,25 @@ export class BackgroundManager { } }) + // Global timeout: Prevent tasks from running forever (15 min max) + // Reference: kdcokenny/opencode-background-agents uses same pattern + setTimeout(() => { + const currentTask = this.tasks.get(task.id) + if (currentTask && currentTask.status === "running") { + log("[background-agent] Task timed out after 15 minutes:", task.id) + currentTask.status = "error" + currentTask.error = `Task timed out after ${MAX_RUN_TIME_MS / 1000 / 60} minutes` + currentTask.completedAt = new Date() + if (currentTask.concurrencyKey) { + this.concurrencyManager.release(currentTask.concurrencyKey) + } + this.markForNotification(currentTask) + this.notifyParentSession(currentTask).catch(err => { + log("[background-agent] Failed to notify on timeout:", err) + }) + } + }, MAX_RUN_TIME_MS) + return task } @@ -383,33 +408,21 @@ export class BackgroundManager { // Edge guard: Require minimum elapsed time (5 seconds) before accepting idle const elapsedMs = Date.now() - task.startedAt.getTime() - const MIN_IDLE_TIME_MS = 5000 if (elapsedMs < MIN_IDLE_TIME_MS) { log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id }) return } - // Edge guard: Verify session has actual assistant output before completing - this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => { - if (!hasValidOutput) { - log("[background-agent] Session.idle but no valid output yet, waiting:", task.id) - return - } - - const hasIncompleteTodos = await this.checkSessionTodos(sessionID) - if (hasIncompleteTodos) { - log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id) - return - } - - task.status = "completed" - task.completedAt = new Date() - this.markForNotification(task) - await this.notifyParentSession(task) - log("[background-agent] Task completed via session.idle event:", task.id) - }).catch(err => { - log("[background-agent] Error in session.idle handler:", err) + // SIMPLIFIED: Mark complete immediately on session.idle (after min time) + // Reference: kdcokenny/opencode-background-agents uses same pattern + // Previous guards (validateSessionHasOutput, checkSessionTodos) were causing stuck tasks + task.status = "completed" + task.completedAt = new Date() + this.markForNotification(task) + this.notifyParentSession(task).catch(err => { + log("[background-agent] Error notifying parent on completion:", err) }) + log("[background-agent] Task completed via session.idle event:", task.id) } if (event.type === "session.deleted") { diff --git a/src/shared/config-path.ts b/src/shared/config-path.ts index 7c12a4b20b..518b5a9e74 100644 --- a/src/shared/config-path.ts +++ b/src/shared/config-path.ts @@ -13,16 +13,20 @@ import * as fs from "fs" export function getUserConfigDir(): string { if (process.platform === "win32") { const crossPlatformDir = path.join(os.homedir(), ".config") - const crossPlatformConfigPath = path.join(crossPlatformDir, "opencode", "oh-my-opencode.json") + // Check JSONC first, then JSON + const crossPlatformConfigPathJsonc = path.join(crossPlatformDir, "opencode", "oh-my-opencode.jsonc") + const crossPlatformConfigPathJson = path.join(crossPlatformDir, "opencode", "oh-my-opencode.json") const appdataDir = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming") - const appdataConfigPath = path.join(appdataDir, "opencode", "oh-my-opencode.json") + const appdataConfigPathJsonc = path.join(appdataDir, "opencode", "oh-my-opencode.jsonc") + const appdataConfigPathJson = path.join(appdataDir, "opencode", "oh-my-opencode.json") - if (fs.existsSync(crossPlatformConfigPath)) { + // Priority: ~/.config (JSONC > JSON) > %APPDATA% (JSONC > JSON) + if (fs.existsSync(crossPlatformConfigPathJsonc) || fs.existsSync(crossPlatformConfigPathJson)) { return crossPlatformDir } - if (fs.existsSync(appdataConfigPath)) { + if (fs.existsSync(appdataConfigPathJsonc) || fs.existsSync(appdataConfigPathJson)) { return appdataDir } @@ -34,14 +38,26 @@ export function getUserConfigDir(): string { /** * Returns the full path to the user-level oh-my-opencode config file. + * Checks for .jsonc first, then .json */ export function getUserConfigPath(): string { - return path.join(getUserConfigDir(), "opencode", "oh-my-opencode.json") + const dir = path.join(getUserConfigDir(), "opencode") + const jsoncPath = path.join(dir, "oh-my-opencode.jsonc") + if (fs.existsSync(jsoncPath)) { + return jsoncPath + } + return path.join(dir, "oh-my-opencode.json") } /** * Returns the full path to the project-level oh-my-opencode config file. + * Checks for .jsonc first, then .json */ export function getProjectConfigPath(directory: string): string { - return path.join(directory, ".opencode", "oh-my-opencode.json") + const dir = path.join(directory, ".opencode") + const jsoncPath = path.join(dir, "oh-my-opencode.jsonc") + if (fs.existsSync(jsoncPath)) { + return jsoncPath + } + return path.join(dir, "oh-my-opencode.json") } diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index 42113ca52c..d22bb34aec 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -420,7 +420,8 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id }) // Use fire-and-forget prompt() - awaiting causes JSON parse errors with thinking models - // Note: Don't pass model in body - use agent's configured model instead + // For category-based tasks, pass the model from category config + // For agent-based tasks, use agent's configured model (don't pass model in body) let promptError: Error | undefined client.session.prompt({ path: { id: sessionID }, @@ -432,6 +433,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id sisyphus_task: false, }, parts: [{ type: "text", text: args.prompt }], + ...(categoryModel ? { model: categoryModel } : {}), }, }).catch((error) => { promptError = error instanceof Error ? error : new Error(String(error)) From 276cc215fd31b980f8061c1f43cdbbe957138451 Mon Sep 17 00:00:00 2001 From: Gladdonilli Date: Mon, 12 Jan 2026 01:39:48 +0800 Subject: [PATCH 02/13] fix: clear timeout timer on task completion to prevent memory leak --- src/features/background-agent/manager.ts | 6 +++++- src/features/background-agent/types.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index e7502958ca..6faf1ae1b9 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -186,7 +186,7 @@ export class BackgroundManager { // Global timeout: Prevent tasks from running forever (15 min max) // Reference: kdcokenny/opencode-background-agents uses same pattern - setTimeout(() => { + task.timeoutTimer = setTimeout(() => { const currentTask = this.tasks.get(task.id) if (currentTask && currentTask.status === "running") { log("[background-agent] Task timed out after 15 minutes:", task.id) @@ -416,6 +416,10 @@ export class BackgroundManager { // SIMPLIFIED: Mark complete immediately on session.idle (after min time) // Reference: kdcokenny/opencode-background-agents uses same pattern // Previous guards (validateSessionHasOutput, checkSessionTodos) were causing stuck tasks + if (task.timeoutTimer) { + clearTimeout(task.timeoutTimer) + task.timeoutTimer = undefined + } task.status = "completed" task.completedAt = new Date() this.markForNotification(task) diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index a77766f8a7..511541ffd0 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -36,6 +36,8 @@ export interface BackgroundTask { lastMsgCount?: number /** Number of consecutive polls with stable message count */ stablePolls?: number + /** Timeout timer reference for cleanup on completion */ + timeoutTimer?: ReturnType } export interface LaunchInput { From 6cd626d7bdebf328b1c83c88a09b630728784ab2 Mon Sep 17 00:00:00 2001 From: Gladdonilli Date: Mon, 12 Jan 2026 01:57:21 +0800 Subject: [PATCH 03/13] fix: address PR review issues - reset startedAt on resume - Reset startedAt when resuming tasks to prevent immediate completion (MIN_IDLE_TIME_MS check was passing immediately for resumed tasks) - Previous commit already fixed timeout.unref() and double-release prevention --- src/features/background-agent/manager.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 6faf1ae1b9..4d93719a46 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -186,15 +186,18 @@ export class BackgroundManager { // Global timeout: Prevent tasks from running forever (15 min max) // Reference: kdcokenny/opencode-background-agents uses same pattern - task.timeoutTimer = setTimeout(() => { + const timeout = setTimeout(() => { const currentTask = this.tasks.get(task.id) if (currentTask && currentTask.status === "running") { log("[background-agent] Task timed out after 15 minutes:", task.id) currentTask.status = "error" currentTask.error = `Task timed out after ${MAX_RUN_TIME_MS / 1000 / 60} minutes` currentTask.completedAt = new Date() + // Clear timeout timer first to prevent double-release + currentTask.timeoutTimer = undefined if (currentTask.concurrencyKey) { this.concurrencyManager.release(currentTask.concurrencyKey) + currentTask.concurrencyKey = undefined // Prevent double-release } this.markForNotification(currentTask) this.notifyParentSession(currentTask).catch(err => { @@ -202,6 +205,9 @@ export class BackgroundManager { }) } }, MAX_RUN_TIME_MS) + // Prevent timeout from keeping the event loop alive + timeout.unref?.() + task.timeoutTimer = timeout return task } @@ -298,6 +304,9 @@ export class BackgroundManager { existingTask.parentMessageID = input.parentMessageID existingTask.parentModel = input.parentModel existingTask.parentAgent = input.parentAgent + // P2 fix: Reset startedAt on resume to prevent immediate completion + // The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing + existingTask.startedAt = new Date() existingTask.progress = { toolCalls: existingTask.progress?.toolCalls ?? 0, From fc700879beed839902864abcec9445fe932b093d Mon Sep 17 00:00:00 2001 From: Gladdonilli Date: Mon, 12 Jan 2026 02:00:59 +0800 Subject: [PATCH 04/13] fix: prevent double-release and add timeout for resumed tasks - Set concurrencyKey = undefined after every release() call to prevent double-release when multiple code paths try to release the same key - Add 15-minute timeout timer for resumed tasks (was missing) - Fixes: promptAsync error, session.deleted, pruneStaleTasksAndNotifications --- src/features/background-agent/manager.ts | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 4d93719a46..4ade61ccef 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -176,6 +176,7 @@ export class BackgroundManager { existingTask.completedAt = new Date() if (existingTask.concurrencyKey) { this.concurrencyManager.release(existingTask.concurrencyKey) + existingTask.concurrencyKey = undefined // Prevent double-release } this.markForNotification(existingTask) this.notifyParentSession(existingTask).catch(err => { @@ -333,6 +334,32 @@ export class BackgroundManager { log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID }) + // Clear any existing timeout and create a new one for the resumed task + if (existingTask.timeoutTimer) { + clearTimeout(existingTask.timeoutTimer) + existingTask.timeoutTimer = undefined + } + const timeout = setTimeout(() => { + const currentTask = this.tasks.get(existingTask.id) + if (currentTask && currentTask.status === "running") { + log("[background-agent] Resumed task timed out after 15 minutes:", existingTask.id) + currentTask.status = "error" + currentTask.error = `Task timed out after ${MAX_RUN_TIME_MS / 1000 / 60} minutes` + currentTask.completedAt = new Date() + currentTask.timeoutTimer = undefined + if (currentTask.concurrencyKey) { + this.concurrencyManager.release(currentTask.concurrencyKey) + currentTask.concurrencyKey = undefined + } + this.markForNotification(currentTask) + this.notifyParentSession(currentTask).catch(err => { + log("[background-agent] Failed to notify on resume timeout:", err) + }) + } + }, MAX_RUN_TIME_MS) + timeout.unref?.() + existingTask.timeoutTimer = timeout + log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { sessionID: existingTask.sessionID, agent: existingTask.agent, @@ -454,6 +481,7 @@ export class BackgroundManager { if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined // Prevent double-release } this.tasks.delete(task.id) this.clearNotificationsForTask(task.id) @@ -710,6 +738,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea task.completedAt = new Date() if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined // Prevent double-release } this.clearNotificationsForTask(taskId) this.tasks.delete(taskId) From 35005bea1b00ee02642d3c3bf54b2327e24400a0 Mon Sep 17 00:00:00 2001 From: Gladdonilli Date: Mon, 12 Jan 2026 02:10:18 +0800 Subject: [PATCH 05/13] fix: clean up timeout and concurrency on resume error Address P2 feedback: resume() error handler now properly: - Clears the timeout timer created for the resumed task - Releases concurrency key to unblock queued tasks --- src/features/background-agent/manager.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 4ade61ccef..76206afa5c 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -384,6 +384,16 @@ export class BackgroundManager { const errorMessage = error instanceof Error ? error.message : String(error) existingTask.error = errorMessage existingTask.completedAt = new Date() + // Clean up timeout timer created for this resume + if (existingTask.timeoutTimer) { + clearTimeout(existingTask.timeoutTimer) + existingTask.timeoutTimer = undefined + } + // Release concurrency key to unblock queued tasks + if (existingTask.concurrencyKey) { + this.concurrencyManager.release(existingTask.concurrencyKey) + existingTask.concurrencyKey = undefined + } this.markForNotification(existingTask) this.notifyParentSession(existingTask).catch(err => { log("[background-agent] Failed to notify on resume error:", err) From 246a9447fd5fd25f0fe24afafb3345969cc739d2 Mon Sep 17 00:00:00 2001 From: chilipvlmer Date: Sun, 11 Jan 2026 19:18:28 +0100 Subject: [PATCH 06/13] fix(sisyphus-orchestrator): preserve subagent response in output transformation --- src/hooks/sisyphus-orchestrator/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/hooks/sisyphus-orchestrator/index.ts b/src/hooks/sisyphus-orchestrator/index.ts index f50a0e2c75..c6677ba7dd 100644 --- a/src/hooks/sisyphus-orchestrator/index.ts +++ b/src/hooks/sisyphus-orchestrator/index.ts @@ -640,10 +640,20 @@ export function createSisyphusOrchestratorHook( }) } + // Preserve original subagent response - critical for debugging failed tasks + const originalResponse = output.output + output.output = ` ## SUBAGENT WORK COMPLETED ${fileChanges} + +--- + +**Subagent Response:** + +${originalResponse} + ${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)} ` From b38cb24ee4dd6b4307107a9a87d88034fb5db80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jason=20K=C3=B6lker?= Date: Sat, 10 Jan 2026 21:44:20 +0000 Subject: [PATCH 07/13] feat(config): add model variant support Allow optional model variant config for agents and categories. Propagate category variants into task model payloads so category-driven runs inherit provider-specific variants. Closes: #647 --- assets/oh-my-opencode.schema.json | 45 +++++++++++++ src/agents/types.ts | 1 + src/agents/utils.test.ts | 25 +++++++ src/agents/utils.ts | 26 ++++++-- src/config/schema.test.ts | 56 +++++++++++++++- src/config/schema.ts | 2 + src/features/background-agent/types.ts | 4 +- src/hooks/keyword-detector/index.test.ts | 22 +++++++ src/hooks/keyword-detector/index.ts | 4 +- src/index.ts | 16 +++++ src/plugin-handlers/config-handler.ts | 3 +- src/shared/agent-variant.test.ts | 83 ++++++++++++++++++++++++ src/shared/agent-variant.ts | 40 ++++++++++++ src/shared/first-message-variant.test.ts | 32 +++++++++ src/shared/first-message-variant.ts | 28 ++++++++ src/shared/index.ts | 1 + src/tools/sisyphus-task/tools.test.ts | 64 ++++++++++++++++++ src/tools/sisyphus-task/tools.ts | 9 ++- 18 files changed, 449 insertions(+), 12 deletions(-) create mode 100644 src/shared/agent-variant.test.ts create mode 100644 src/shared/agent-variant.ts create mode 100644 src/shared/first-message-variant.test.ts create mode 100644 src/shared/first-message-variant.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 327c2d919d..3c4cc051d3 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -102,6 +102,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -225,6 +228,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -348,6 +354,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -471,6 +480,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -594,6 +606,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -717,6 +732,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -840,6 +858,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -963,6 +984,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1086,6 +1110,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1209,6 +1236,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1332,6 +1362,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1455,6 +1488,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1578,6 +1614,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1701,6 +1740,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1831,6 +1873,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "temperature": { "type": "number", "minimum": 0, diff --git a/src/agents/types.ts b/src/agents/types.ts index 8cbe78d906..a0f6d26d74 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -76,6 +76,7 @@ export type AgentName = BuiltinAgentName export type AgentOverrideConfig = Partial & { prompt_append?: string + variant?: string } export type AgentOverrides = Partial> diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 9f5e2d3cb6..336ed628a3 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -127,6 +127,31 @@ describe("buildAgent with category and skills", () => { expect(agent.temperature).toBe(0.7) }) + test("agent with category inherits variant", () => { + // #given + const source = { + "test-agent": () => + ({ + description: "Test agent", + category: "custom-category", + }) as AgentConfig, + } + + const categories = { + "custom-category": { + model: "openai/gpt-5.2", + variant: "xhigh", + }, + } + + // #when + const agent = buildAgent(source["test-agent"], undefined, categories) + + // #then + expect(agent.model).toBe("openai/gpt-5.2") + expect(agent.variant).toBe("xhigh") + }) + test("agent with skills has content prepended to prompt", () => { // #given const source = { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index de1765cfae..808a6ef364 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -1,5 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types" +import type { CategoriesConfig, CategoryConfig } from "../config/schema" import { createSisyphusAgent } from "./sisyphus" import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" @@ -47,12 +48,19 @@ function isFactory(source: AgentSource): source is AgentFactory { return typeof source === "function" } -export function buildAgent(source: AgentSource, model?: string): AgentConfig { +export function buildAgent( + source: AgentSource, + model?: string, + categories?: CategoriesConfig +): AgentConfig { const base = isFactory(source) ? source(model) : source + const categoryConfigs: Record = categories + ? { ...DEFAULT_CATEGORIES, ...categories } + : DEFAULT_CATEGORIES - const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[] } + const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string } if (agentWithCategory.category) { - const categoryConfig = DEFAULT_CATEGORIES[agentWithCategory.category] + const categoryConfig = categoryConfigs[agentWithCategory.category] if (categoryConfig) { if (!base.model) { base.model = categoryConfig.model @@ -60,6 +68,9 @@ export function buildAgent(source: AgentSource, model?: string): AgentConfig { if (base.temperature === undefined && categoryConfig.temperature !== undefined) { base.temperature = categoryConfig.temperature } + if (base.variant === undefined && categoryConfig.variant !== undefined) { + base.variant = categoryConfig.variant + } } } @@ -118,11 +129,16 @@ export function createBuiltinAgents( disabledAgents: BuiltinAgentName[] = [], agentOverrides: AgentOverrides = {}, directory?: string, - systemDefaultModel?: string + systemDefaultModel?: string, + categories?: CategoriesConfig ): Record { const result: Record = {} const availableAgents: AvailableAgent[] = [] + const mergedCategories = categories + ? { ...DEFAULT_CATEGORIES, ...categories } + : DEFAULT_CATEGORIES + for (const [name, source] of Object.entries(agentSources)) { const agentName = name as BuiltinAgentName @@ -133,7 +149,7 @@ export function createBuiltinAgents( const override = agentOverrides[agentName] const model = override?.model - let config = buildAgent(source, model) + let config = buildAgent(source, model, mergedCategories) if (agentName === "librarian" && directory && config.prompt) { const envContext = createEnvContext() diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 6c935d2ac9..a16dd31614 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { AgentOverrideConfigSchema, BuiltinCategoryNameSchema, OhMyOpenCodeConfigSchema } from "./schema" +import { AgentOverrideConfigSchema, BuiltinCategoryNameSchema, CategoryConfigSchema, OhMyOpenCodeConfigSchema } from "./schema" describe("disabled_mcps schema", () => { test("should accept built-in MCP names", () => { @@ -174,6 +174,33 @@ describe("AgentOverrideConfigSchema", () => { }) }) + describe("variant field", () => { + test("accepts variant as optional string", () => { + // #given + const config = { variant: "high" } + + // #when + const result = AgentOverrideConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.variant).toBe("high") + } + }) + + test("rejects non-string variant", () => { + // #given + const config = { variant: 123 } + + // #when + const result = AgentOverrideConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(false) + }) + }) + describe("skills field", () => { test("accepts skills as optional string array", () => { // #given @@ -303,6 +330,33 @@ describe("AgentOverrideConfigSchema", () => { }) }) +describe("CategoryConfigSchema", () => { + test("accepts variant as optional string", () => { + // #given + const config = { model: "openai/gpt-5.2", variant: "xhigh" } + + // #when + const result = CategoryConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.variant).toBe("xhigh") + } + }) + + test("rejects non-string variant", () => { + // #given + const config = { model: "openai/gpt-5.2", variant: 123 } + + // #when + const result = CategoryConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(false) + }) +}) + describe("BuiltinCategoryNameSchema", () => { test("accepts all builtin category names", () => { // #given diff --git a/src/config/schema.ts b/src/config/schema.ts index 07600afb42..8794a05155 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -96,6 +96,7 @@ export const BuiltinCommandNameSchema = z.enum([ export const AgentOverrideConfigSchema = z.object({ /** @deprecated Use `category` instead. Model is inherited from category defaults. */ model: z.string().optional(), + variant: z.string().optional(), /** Category name to inherit model and other settings from CategoryConfig */ category: z.string().optional(), /** Skill names to inject into agent prompt */ @@ -151,6 +152,7 @@ export const SisyphusAgentConfigSchema = z.object({ export const CategoryConfigSchema = z.object({ model: z.string(), + variant: z.string().optional(), temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), maxTokens: z.number().optional(), diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 511541ffd0..b321cb0314 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -27,7 +27,7 @@ export interface BackgroundTask { error?: string progress?: TaskProgress parentModel?: { providerID: string; modelID: string } - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } /** Agent name used for concurrency tracking */ concurrencyKey?: string /** Parent session's agent name for notification */ @@ -48,7 +48,7 @@ export interface LaunchInput { parentMessageID: string parentModel?: { providerID: string; modelID: string } parentAgent?: string - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } skills?: string[] skillContent?: string } diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 022ffe1e1b..ec470988da 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -210,4 +210,26 @@ describe("keyword-detector session filtering", () => { expect(output.message.variant).toBe("max") expect(toastCalls).toContain("Ultrawork Mode Activated") }) + + test("should not override existing variant", async () => { + // #given - main session set with pre-existing variant + setMainSession("main-123") + + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls })) + const output = { + message: { variant: "low" } as Record, + parts: [{ type: "text", text: "ultrawork mode" }], + } + + // #when - ultrawork keyword triggers + await hook["chat.message"]( + { sessionID: "main-123" }, + output + ) + + // #then - existing variant should remain + expect(output.message.variant).toBe("low") + expect(toastCalls).toContain("Ultrawork Mode Activated") + }) }) diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index e79f17b43a..48145ceda4 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -47,7 +47,9 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC if (hasUltrawork) { log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID }) - output.message.variant = "max" + if (output.message.variant === undefined) { + output.message.variant = "max" + } ctx.client.tui .showToast({ diff --git a/src/index.ts b/src/index.ts index 22e75cba6c..e36d6204ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,8 @@ import { createContextInjectorMessagesTransformHook, } from "./features/context-injector"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; +import { applyAgentVariant, resolveAgentVariant } from "./shared/agent-variant"; +import { createFirstMessageVariantGate } from "./shared/first-message-variant"; import { discoverUserClaudeSkills, discoverProjectClaudeSkills, @@ -80,6 +82,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const pluginConfig = loadPluginConfig(ctx.directory, ctx); const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); + const firstMessageVariantGate = createFirstMessageVariantGate(); const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); const modelCacheState = createModelCacheState(); @@ -314,6 +317,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }, "chat.message": async (input, output) => { + const message = (output as { message: { variant?: string } }).message + if (firstMessageVariantGate.shouldOverride(input.sessionID)) { + const variant = resolveAgentVariant(pluginConfig, input.agent) + if (variant !== undefined) { + message.variant = variant + } + firstMessageVariantGate.markApplied(input.sessionID) + } else { + applyAgentVariant(pluginConfig, input.agent, message) + } + await keywordDetector?.["chat.message"]?.(input, output); await claudeCodeHooks["chat.message"]?.(input, output); await contextInjector["chat.message"]?.(input, output); @@ -420,6 +434,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { if (!sessionInfo?.parentID) { setMainSession(sessionInfo?.id); } + firstMessageVariantGate.markSessionCreated(sessionInfo); } if (event.type === "session.deleted") { @@ -428,6 +443,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { setMainSession(undefined); } if (sessionInfo?.id) { + firstMessageVariantGate.clear(sessionInfo.id); await skillMcpManager.disconnectSession(sessionInfo.id); await lspManager.cleanupTempDirectoryClients(); } diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index b16f8fb359..38b8601cbf 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -103,7 +103,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { pluginConfig.disabled_agents, pluginConfig.agents, ctx.directory, - config.model as string | undefined + config.model as string | undefined, + pluginConfig.categories ); // Claude Code agents: Do NOT apply permission migration diff --git a/src/shared/agent-variant.test.ts b/src/shared/agent-variant.test.ts new file mode 100644 index 0000000000..7af36ccb89 --- /dev/null +++ b/src/shared/agent-variant.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import type { OhMyOpenCodeConfig } from "../config" +import { applyAgentVariant, resolveAgentVariant } from "./agent-variant" + +describe("resolveAgentVariant", () => { + test("returns undefined when agent name missing", () => { + // #given + const config = {} as OhMyOpenCodeConfig + + // #when + const variant = resolveAgentVariant(config) + + // #then + expect(variant).toBeUndefined() + }) + + test("returns agent override variant", () => { + // #given + const config = { + agents: { + Sisyphus: { variant: "low" }, + }, + } as OhMyOpenCodeConfig + + // #when + const variant = resolveAgentVariant(config, "Sisyphus") + + // #then + expect(variant).toBe("low") + }) + + test("returns category variant when agent uses category", () => { + // #given + const config = { + agents: { + Sisyphus: { category: "ultrabrain" }, + }, + categories: { + ultrabrain: { model: "openai/gpt-5.2", variant: "xhigh" }, + }, + } as OhMyOpenCodeConfig + + // #when + const variant = resolveAgentVariant(config, "Sisyphus") + + // #then + expect(variant).toBe("xhigh") + }) +}) + +describe("applyAgentVariant", () => { + test("sets variant when message is undefined", () => { + // #given + const config = { + agents: { + Sisyphus: { variant: "low" }, + }, + } as OhMyOpenCodeConfig + const message: { variant?: string } = {} + + // #when + applyAgentVariant(config, "Sisyphus", message) + + // #then + expect(message.variant).toBe("low") + }) + + test("does not override existing variant", () => { + // #given + const config = { + agents: { + Sisyphus: { variant: "low" }, + }, + } as OhMyOpenCodeConfig + const message = { variant: "max" } + + // #when + applyAgentVariant(config, "Sisyphus", message) + + // #then + expect(message.variant).toBe("max") + }) +}) diff --git a/src/shared/agent-variant.ts b/src/shared/agent-variant.ts new file mode 100644 index 0000000000..ec3e7ec500 --- /dev/null +++ b/src/shared/agent-variant.ts @@ -0,0 +1,40 @@ +import type { OhMyOpenCodeConfig } from "../config" + +export function resolveAgentVariant( + config: OhMyOpenCodeConfig, + agentName?: string +): string | undefined { + if (!agentName) { + return undefined + } + + const agentOverrides = config.agents as + | Record + | undefined + const agentOverride = agentOverrides?.[agentName] + if (!agentOverride) { + return undefined + } + + if (agentOverride.variant) { + return agentOverride.variant + } + + const categoryName = agentOverride.category + if (!categoryName) { + return undefined + } + + return config.categories?.[categoryName]?.variant +} + +export function applyAgentVariant( + config: OhMyOpenCodeConfig, + agentName: string | undefined, + message: { variant?: string } +): void { + const variant = resolveAgentVariant(config, agentName) + if (variant !== undefined && message.variant === undefined) { + message.variant = variant + } +} diff --git a/src/shared/first-message-variant.test.ts b/src/shared/first-message-variant.test.ts new file mode 100644 index 0000000000..6f7fa52597 --- /dev/null +++ b/src/shared/first-message-variant.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { createFirstMessageVariantGate } from "./first-message-variant" + +describe("createFirstMessageVariantGate", () => { + test("marks new sessions and clears after apply", () => { + // #given + const gate = createFirstMessageVariantGate() + + // #when + gate.markSessionCreated({ id: "session-1" }) + + // #then + expect(gate.shouldOverride("session-1")).toBe(true) + + // #when + gate.markApplied("session-1") + + // #then + expect(gate.shouldOverride("session-1")).toBe(false) + }) + + test("ignores forked sessions", () => { + // #given + const gate = createFirstMessageVariantGate() + + // #when + gate.markSessionCreated({ id: "session-2", parentID: "session-parent" }) + + // #then + expect(gate.shouldOverride("session-2")).toBe(false) + }) +}) diff --git a/src/shared/first-message-variant.ts b/src/shared/first-message-variant.ts new file mode 100644 index 0000000000..f8229066cb --- /dev/null +++ b/src/shared/first-message-variant.ts @@ -0,0 +1,28 @@ +type SessionInfo = { + id?: string + parentID?: string +} + +export function createFirstMessageVariantGate() { + const pending = new Set() + + return { + markSessionCreated(info?: SessionInfo) { + if (info?.id && !info.parentID) { + pending.add(info.id) + } + }, + shouldOverride(sessionID?: string) { + if (!sessionID) return false + return pending.has(sessionID) + }, + markApplied(sessionID?: string) { + if (!sessionID) return + pending.delete(sessionID) + }, + clear(sessionID?: string) { + if (!sessionID) return + pending.delete(sessionID) + }, + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index bb3601ed61..df79037994 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -21,3 +21,4 @@ export * from "./opencode-version" export * from "./permission-compat" export * from "./external-plugin-detector" export * from "./zip-extractor" +export * from "./agent-variant" diff --git a/src/tools/sisyphus-task/tools.test.ts b/src/tools/sisyphus-task/tools.test.ts index d26db75d13..90cfd39e42 100644 --- a/src/tools/sisyphus-task/tools.test.ts +++ b/src/tools/sisyphus-task/tools.test.ts @@ -207,6 +207,70 @@ describe("sisyphus-task", () => { }) }) + describe("category variant", () => { + test("passes variant to background model payload", async () => { + // #given + const { createSisyphusTask } = require("./tools") + let launchInput: any + + const mockManager = { + launch: async (input: any) => { + launchInput = input + return { + id: "task-variant", + sessionID: "session-variant", + description: "Variant task", + agent: "Sisyphus-Junior", + status: "running", + } + }, + } + + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + userCategories: { + ultrabrain: { model: "openai/gpt-5.2", variant: "xhigh" }, + }, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + await tool.execute( + { + description: "Variant task", + prompt: "Do something", + category: "ultrabrain", + run_in_background: true, + skills: [], + }, + toolContext + ) + + // #then + expect(launchInput.model).toEqual({ + providerID: "openai", + modelID: "gpt-5.2", + variant: "xhigh", + }) + }) + }) + describe("skills parameter", () => { test("SISYPHUS_TASK_DESCRIPTION documents skills parameter", () => { // #given / #when / #then diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index d22bb34aec..157370821e 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -302,7 +302,7 @@ ${textContent || "(No text output)"}` } let agentToUse: string - let categoryModel: { providerID: string; modelID: string } | undefined + let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined let categoryPromptAppend: string | undefined if (args.category) { @@ -312,7 +312,12 @@ ${textContent || "(No text output)"}` } agentToUse = SISYPHUS_JUNIOR_AGENT - categoryModel = parseModelString(resolved.config.model) + const parsedModel = parseModelString(resolved.config.model) + categoryModel = parsedModel + ? (resolved.config.variant + ? { ...parsedModel, variant: resolved.config.variant } + : parsedModel) + : undefined categoryPromptAppend = resolved.promptAppend || undefined } else { agentToUse = args.subagent_type!.trim() From 6222742b8e488b750bbe9ccc99287b3215610ac9 Mon Sep 17 00:00:00 2001 From: aw338WoWmUI <121638634+aw338WoWmUI@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:53:41 +0800 Subject: [PATCH 08/13] fix(cli): write version-aware plugin entry during installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the installer always wrote 'oh-my-opencode' without a version, causing users who installed beta versions (e.g., bunx oh-my-opencode@beta) to unexpectedly load the stable version on next OpenCode startup. Now the installer queries npm dist-tags and writes: - @latest when current version matches the latest tag - @beta when current version matches the beta tag - @ when no tag matches (pins to specific version) This ensures: - bunx oh-my-opencode install → @latest (tracks stable) - bunx oh-my-opencode@beta install → @beta (tracks beta tag) - bunx oh-my-opencode@3.0.0-beta.2 install → @3.0.0-beta.2 (pinned) --- src/cli/config-manager.test.ts | 154 ++++++++++++++++++++++++++++++++- src/cli/config-manager.ts | 50 +++++++++-- src/cli/install.ts | 7 +- 3 files changed, 199 insertions(+), 12 deletions(-) diff --git a/src/cli/config-manager.test.ts b/src/cli/config-manager.test.ts index cd95438dac..765b753238 100644 --- a/src/cli/config-manager.test.ts +++ b/src/cli/config-manager.test.ts @@ -1,6 +1,156 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test" -import { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager" +import { ANTIGRAVITY_PROVIDER_CONFIG, getPluginNameWithVersion, fetchNpmDistTags } from "./config-manager" + +describe("getPluginNameWithVersion", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("returns @latest when current version matches latest tag", async () => { + // #given npm dist-tags with latest=2.14.0 + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }), + } as Response) + ) as unknown as typeof fetch + + // #when current version is 2.14.0 + const result = await getPluginNameWithVersion("2.14.0") + + // #then should use @latest tag + expect(result).toBe("oh-my-opencode@latest") + }) + + test("returns @beta when current version matches beta tag", async () => { + // #given npm dist-tags with beta=3.0.0-beta.3 + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }), + } as Response) + ) as unknown as typeof fetch + + // #when current version is 3.0.0-beta.3 + const result = await getPluginNameWithVersion("3.0.0-beta.3") + + // #then should use @beta tag + expect(result).toBe("oh-my-opencode@beta") + }) + + test("returns @next when current version matches next tag", async () => { + // #given npm dist-tags with next=3.1.0-next.1 + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3", next: "3.1.0-next.1" }), + } as Response) + ) as unknown as typeof fetch + + // #when current version is 3.1.0-next.1 + const result = await getPluginNameWithVersion("3.1.0-next.1") + + // #then should use @next tag + expect(result).toBe("oh-my-opencode@next") + }) + + test("returns pinned version when no tag matches", async () => { + // #given npm dist-tags with beta=3.0.0-beta.3 + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }), + } as Response) + ) as unknown as typeof fetch + + // #when current version is old beta 3.0.0-beta.2 + const result = await getPluginNameWithVersion("3.0.0-beta.2") + + // #then should pin to specific version + expect(result).toBe("oh-my-opencode@3.0.0-beta.2") + }) + + test("returns pinned version when fetch fails", async () => { + // #given network failure + globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch + + // #when current version is 3.0.0-beta.3 + const result = await getPluginNameWithVersion("3.0.0-beta.3") + + // #then should fall back to pinned version + expect(result).toBe("oh-my-opencode@3.0.0-beta.3") + }) + + test("returns pinned version when npm returns non-ok response", async () => { + // #given npm returns 404 + globalThis.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 404, + } as Response) + ) as unknown as typeof fetch + + // #when current version is 2.14.0 + const result = await getPluginNameWithVersion("2.14.0") + + // #then should fall back to pinned version + expect(result).toBe("oh-my-opencode@2.14.0") + }) +}) + +describe("fetchNpmDistTags", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("returns dist-tags on success", async () => { + // #given npm returns dist-tags + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }), + } as Response) + ) as unknown as typeof fetch + + // #when fetching dist-tags + const result = await fetchNpmDistTags("oh-my-opencode") + + // #then should return the tags + expect(result).toEqual({ latest: "2.14.0", beta: "3.0.0-beta.3" }) + }) + + test("returns null on network failure", async () => { + // #given network failure + globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch + + // #when fetching dist-tags + const result = await fetchNpmDistTags("oh-my-opencode") + + // #then should return null + expect(result).toBeNull() + }) + + test("returns null on non-ok response", async () => { + // #given npm returns 404 + globalThis.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 404, + } as Response) + ) as unknown as typeof fetch + + // #when fetching dist-tags + const result = await fetchNpmDistTags("oh-my-opencode") + + // #then should return null + expect(result).toBeNull() + }) +}) describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => { test("Gemini models include full spec (limit + modalities)", () => { diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 6db09de002..fcbcfdc88c 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -109,6 +109,40 @@ export async function fetchLatestVersion(packageName: string): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`) + if (!res.ok) return null + const data = await res.json() as NpmDistTags + return data + } catch { + return null + } +} + +const PACKAGE_NAME = "oh-my-opencode" + +export async function getPluginNameWithVersion(currentVersion: string): Promise { + const distTags = await fetchNpmDistTags(PACKAGE_NAME) + + if (distTags) { + for (const [tag, tagVersion] of Object.entries(distTags)) { + if (tagVersion === currentVersion) { + return `${PACKAGE_NAME}@${tag}` + } + } + } + + return `${PACKAGE_NAME}@${currentVersion}` +} + type ConfigFormat = "json" | "jsonc" | "none" interface OpenCodeConfig { @@ -179,7 +213,7 @@ function ensureConfigDir(): void { } } -export function addPluginToOpenCodeConfig(): ConfigMergeResult { +export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { try { ensureConfigDir() } catch (err) { @@ -187,11 +221,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult { } const { format, path } = detectConfigFormat() - const pluginName = "oh-my-opencode" + const pluginEntry = await getPluginNameWithVersion(currentVersion) try { if (format === "none") { - const config: OpenCodeConfig = { plugin: [pluginName] } + const config: OpenCodeConfig = { plugin: [pluginEntry] } writeFileSync(path, JSON.stringify(config, null, 2) + "\n") return { success: true, configPath: path } } @@ -203,11 +237,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult { const config = parseResult.config const plugins = config.plugin ?? [] - if (plugins.some((p) => p.startsWith(pluginName))) { + if (plugins.some((p) => p.startsWith(PACKAGE_NAME))) { return { success: true, configPath: path } } - config.plugin = [...plugins, pluginName] + config.plugin = [...plugins, pluginEntry] if (format === "jsonc") { const content = readFileSync(path, "utf-8") @@ -217,12 +251,12 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult { if (match) { const arrayContent = match[1].trim() const newArrayContent = arrayContent - ? `${arrayContent},\n "${pluginName}"` - : `"${pluginName}"` + ? `${arrayContent},\n "${pluginEntry}"` + : `"${pluginEntry}"` const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`) writeFileSync(path, newContent) } else { - const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginName}"],`) + const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`) writeFileSync(path, newContent) } } else { diff --git a/src/cli/install.ts b/src/cli/install.ts index 58452118fb..aafdd1483c 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -11,6 +11,9 @@ import { detectCurrentConfig, } from "./config-manager" +const packageJson = await import("../../package.json") +const VERSION = packageJson.version + const SYMBOLS = { check: color.green("✓"), cross: color.red("✗"), @@ -250,7 +253,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise { const config = argsToConfig(args) printStep(step++, totalSteps, "Adding oh-my-opencode plugin...") - const pluginResult = addPluginToOpenCodeConfig() + const pluginResult = await addPluginToOpenCodeConfig(VERSION) if (!pluginResult.success) { printError(`Failed: ${pluginResult.error}`) return 1 @@ -360,7 +363,7 @@ export async function install(args: InstallArgs): Promise { if (!config) return 1 s.start("Adding oh-my-opencode to OpenCode config") - const pluginResult = addPluginToOpenCodeConfig() + const pluginResult = await addPluginToOpenCodeConfig(VERSION) if (!pluginResult.success) { s.stop(`Failed to add plugin: ${pluginResult.error}`) p.outro(color.red("Installation failed.")) From 3318bacaa050c7681d5186ed9be8d3922f60dd9d Mon Sep 17 00:00:00 2001 From: aw338WoWmUI <121638634+aw338WoWmUI@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:01:50 +0800 Subject: [PATCH 09/13] fix(cli): update existing plugin entry instead of skipping Addresses cubic review feedback: installer now replaces existing oh-my-opencode entries with the new version-aware entry, allowing users to switch between @latest, @beta, or pinned versions. --- src/cli/config-manager.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index fcbcfdc88c..a2a96fa10d 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -237,11 +237,18 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise const config = parseResult.config const plugins = config.plugin ?? [] - if (plugins.some((p) => p.startsWith(PACKAGE_NAME))) { - return { success: true, configPath: path } + const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`)) + + if (existingIndex !== -1) { + if (plugins[existingIndex] === pluginEntry) { + return { success: true, configPath: path } + } + plugins[existingIndex] = pluginEntry + } else { + plugins.push(pluginEntry) } - config.plugin = [...plugins, pluginEntry] + config.plugin = plugins if (format === "jsonc") { const content = readFileSync(path, "utf-8") @@ -249,11 +256,8 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise const match = content.match(pluginArrayRegex) if (match) { - const arrayContent = match[1].trim() - const newArrayContent = arrayContent - ? `${arrayContent},\n "${pluginEntry}"` - : `"${pluginEntry}"` - const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`) + const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ") + const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`) writeFileSync(path, newContent) } else { const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`) From c73c341e456ecdef494fa66737162b9182f377b5 Mon Sep 17 00:00:00 2001 From: Ivan Marshall Widjaja Date: Sun, 11 Jan 2026 23:40:48 +1100 Subject: [PATCH 10/13] feat(sisyphus-task): add workdir parameter to constrain spawned agents Add optional `workdir` parameter that injects strict directory constraints into spawned agents' system content. Validates absolute paths, existence, and directory type. Enables orchestrators to delegate work to specific git worktrees or project subdirectories. - Validation: absolute path, exists, is directory - Injection: system content (sync/background) or prompt prepend (resume) - Documentation: updated tool description and schema - Tests: validation, injection, and combination scenarios --- src/tools/sisyphus-task/constants.ts | 1 + src/tools/sisyphus-task/tools.test.ts | 344 ++++++++++++++++++++++++++ src/tools/sisyphus-task/tools.ts | 76 +++++- src/tools/sisyphus-task/types.ts | 1 + 4 files changed, 411 insertions(+), 11 deletions(-) diff --git a/src/tools/sisyphus-task/constants.ts b/src/tools/sisyphus-task/constants.ts index 4919b65565..dc3ec2c5b6 100644 --- a/src/tools/sisyphus-task/constants.ts +++ b/src/tools/sisyphus-task/constants.ts @@ -245,6 +245,7 @@ MUTUALLY EXCLUSIVE: Provide EITHER category OR agent, not both (unless resuming) - background: true=async (returns task_id), false=sync (waits for result). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries. - resume: Session ID to resume (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity. - skills: Array of skill names to prepend to prompt (e.g., ["playwright", "frontend-ui-ux"]). Skills will be resolved and their content prepended with a separator. Empty array = no prepending. +- workdir: Absolute directory path for the spawned agent. If provided, we inject a strict workdir constraint block into the agent's system/prompt to keep it working only within this directory. The directory must exist and be a valid directory path. Useful for git worktrees or when delegating tasks to specific project subdirectories. **WHEN TO USE resume:** - Task failed/incomplete → resume with "fix: [specific issue]" diff --git a/src/tools/sisyphus-task/tools.test.ts b/src/tools/sisyphus-task/tools.test.ts index 90cfd39e42..1f3d5c969c 100644 --- a/src/tools/sisyphus-task/tools.test.ts +++ b/src/tools/sisyphus-task/tools.test.ts @@ -491,5 +491,349 @@ describe("buildSystemContent", () => { expect(result).toContain(categoryPromptAppend) expect(result).toContain("\n\n") }) + + test("includes workdir context when workdir is provided", () => { + // #given + const { buildSystemContent } = require("./tools") + const workdir = "/path/to/worktree" + + // #when + const result = buildSystemContent({ workdir }) + + // #then + expect(result).toContain("") + expect(result).toContain(workdir) + expect(result).toContain("WORKING DIRECTORY:") + expect(result).toContain("CRITICAL CONSTRAINTS") + }) + + test("combines workdir with skill content and category promptAppend", () => { + // #given + const { buildSystemContent } = require("./tools") + const skillContent = "You are a playwright expert" + const categoryPromptAppend = "Focus on visual design" + const workdir = "/path/to/worktree" + + // #when + const result = buildSystemContent({ skillContent, categoryPromptAppend, workdir }) + + // #then + expect(result).toContain(skillContent) + expect(result).toContain(categoryPromptAppend) + expect(result).toContain("") + expect(result).toContain(workdir) + // Should have separators between all parts + const parts = result.split("\n\n") + expect(parts.length).toBeGreaterThanOrEqual(3) + }) + }) + + describe("workdir validation", () => { + test("returns error when workdir does not exist", async () => { + // #given + const { createSisyphusTask } = require("./tools") + + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Test task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir: "/nonexistent/path/that/does/not/exist", + }, + toolContext + ) + + // #then + expect(result).toContain("does not exist") + expect(result).toContain("workdir") + }) + + test("returns error when workdir is not an absolute path", async () => { + // #given + const { createSisyphusTask } = require("./tools") + + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Test task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir: "relative/path", + }, + toolContext + ) + + // #then + expect(result).toContain("must be an absolute path") + expect(result).toContain("workdir") + }) + + test("returns error when workdir is not a directory", async () => { + // #given + const { createSisyphusTask } = require("./tools") + const fs = require("node:fs") + const path = require("node:path") + const os = require("node:os") + + // Create a temporary file (not a directory) + const tmpFile = path.join(os.tmpdir(), `test-file-${Date.now()}`) + fs.writeFileSync(tmpFile, "test") + + try { + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Test task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir: tmpFile, + }, + toolContext + ) + + // #then + expect(result).toContain("not a directory") + expect(result).toContain("workdir") + } finally { + // Cleanup + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile) + } + } + }) + }) + + describe("workdir injection in background launch", () => { + test("background launch includes workdir in skillContent", async () => { + // #given + const { createSisyphusTask } = require("./tools") + const fs = require("node:fs") + const os = require("node:os") + const path = require("node:path") + + const workdir = path.join(os.tmpdir(), `test-workdir-${Date.now()}`) + fs.mkdirSync(workdir, { recursive: true }) + + try { + let launchInput: any + + const mockManager = { + launch: async (input: any) => { + launchInput = input + return { + id: "task-workdir", + sessionID: "session-workdir", + description: "Workdir task", + agent: "Sisyphus-Junior", + status: "running", + } + }, + } + + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + await tool.execute( + { + description: "Workdir task", + prompt: "Do something", + category: "general", + run_in_background: true, + skills: [], + workdir, + }, + toolContext + ) + + // #then + expect(launchInput.skillContent).toContain("") + expect(launchInput.skillContent).toContain(workdir) + } finally { + // Cleanup + if (fs.existsSync(workdir)) { + fs.rmSync(workdir, { recursive: true, force: true }) + } + } + }) + }) + + describe("workdir injection in sync execution", () => { + test("sync execution includes workdir in system content", async () => { + // #given + const { createSisyphusTask } = require("./tools") + const fs = require("node:fs") + const os = require("node:os") + const path = require("node:path") + + const workdir = path.join(os.tmpdir(), `test-workdir-sync-${Date.now()}`) + fs.mkdirSync(workdir, { recursive: true }) + + try { + let promptInput: any + const sessionId = "test-session-sync" + let pollCount = 0 + + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: sessionId } }), + prompt: async (input: any) => { + promptInput = input + return { data: {} } + }, + messages: async () => { + // Return consistent message count to allow stability detection + return { + data: [ + { + info: { role: "assistant", time: { created: Date.now() } }, + parts: [{ type: "text", text: "Task completed" }], + }, + ], + } + }, + status: async () => { + // After initial polls, return idle to allow completion + pollCount++ + return { + data: { + [sessionId]: { + type: pollCount > 5 ? "idle" : "running", + }, + }, + } + }, + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Sync workdir task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir, + }, + toolContext + ) + + // #then + expect(promptInput.body.system).toContain("") + expect(promptInput.body.system).toContain(workdir) + expect(result).toBeDefined() + } finally { + // Cleanup + if (fs.existsSync(workdir)) { + fs.rmSync(workdir, { recursive: true, force: true }) + } + } + }, { timeout: 15000 }) }) }) diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index 157370821e..b67fa5263a 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -1,6 +1,6 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" +import { existsSync, readdirSync, statSync } from "node:fs" +import { isAbsolute, join } from "node:path" import type { BackgroundManager } from "../../features/background-agent" import type { SisyphusTaskArgs } from "./types" import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema" @@ -95,20 +95,53 @@ export interface SisyphusTaskToolOptions { export interface BuildSystemContentInput { skillContent?: string categoryPromptAppend?: string + workdir?: string +} + +function buildWorkdirContext(workdir: string): string { + return ` +WORKING DIRECTORY: ${workdir} + +**CRITICAL CONSTRAINTS:** +- You MUST treat "${workdir}" as your repository root and working directory +- All file read/write operations MUST be relative to "${workdir}" or use absolute paths under this directory +- When using terminal/shell tools, ALWAYS change to "${workdir}" first (e.g., \`cd "${workdir}" && \`) +- Do NOT operate on files outside of "${workdir}" +- All paths you reference should either be absolute (starting with "${workdir}") or relative to "${workdir}" + +This directory is your workspace boundary - stay within it. +` +} + +function buildPromptWithWorkdir(prompt: string, workdir?: string): string { + if (!workdir) { + return prompt + } + return `${buildWorkdirContext(workdir)}\n\n${prompt}` } export function buildSystemContent(input: BuildSystemContentInput): string | undefined { - const { skillContent, categoryPromptAppend } = input + const { skillContent, categoryPromptAppend, workdir } = input - if (!skillContent && !categoryPromptAppend) { - return undefined + const parts: string[] = [] + + if (skillContent) { + parts.push(skillContent) + } + + if (categoryPromptAppend) { + parts.push(categoryPromptAppend) + } + + if (workdir) { + parts.push(buildWorkdirContext(workdir)) } - if (skillContent && categoryPromptAppend) { - return `${skillContent}\n\n${categoryPromptAppend}` + if (parts.length === 0) { + return undefined } - return skillContent || categoryPromptAppend + return parts.join("\n\n") } export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefinition { @@ -124,6 +157,7 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini run_in_background: tool.schema.boolean().describe("Run in background. MUST be explicitly set. Use false for task delegation, true only for parallel exploration."), resume: tool.schema.string().optional().describe("Session ID to resume - continues previous agent session with full context"), skills: tool.schema.array(tool.schema.string()).describe("Array of skill names to prepend to the prompt. Use [] if no skills needed."), + workdir: tool.schema.string().optional().describe("Working directory boundary for the spawned agent. If provided, instructions are injected into the agent's system/prompt to constrain it to this directory. Must be an absolute path to an existing directory."), }, async execute(args: SisyphusTaskArgs, toolContext) { const ctx = toolContext as ToolContextWithMetadata @@ -133,6 +167,26 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini if (args.skills === undefined) { return `❌ Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills needed.` } + + // Validate workdir if provided + if (args.workdir) { + if (!isAbsolute(args.workdir)) { + return `❌ Invalid workdir: "${args.workdir}" must be an absolute path.` + } + if (!existsSync(args.workdir)) { + return `❌ Invalid workdir: "${args.workdir}" does not exist.` + } + try { + const stats = statSync(args.workdir) + if (!stats.isDirectory()) { + return `❌ Invalid workdir: "${args.workdir}" is not a directory.` + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `❌ Error validating workdir: ${message}` + } + } + const runInBackground = args.run_in_background === true let skillContent: string | undefined @@ -157,7 +211,7 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini try { const task = await manager.resume({ sessionId: args.resume, - prompt: args.prompt, + prompt: buildPromptWithWorkdir(args.prompt, args.workdir), parentSessionID: ctx.sessionID, parentMessageID: ctx.messageID, parentModel, @@ -211,7 +265,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.` task: false, sisyphus_task: false, }, - parts: [{ type: "text", text: args.prompt }], + parts: [{ type: "text", text: buildPromptWithWorkdir(args.prompt, args.workdir) }], }, }) } catch (promptError) { @@ -350,7 +404,7 @@ ${textContent || "(No text output)"}` } } - const systemContent = buildSystemContent({ skillContent, categoryPromptAppend }) + const systemContent = buildSystemContent({ skillContent, categoryPromptAppend, workdir: args.workdir }) if (runInBackground) { try { diff --git a/src/tools/sisyphus-task/types.ts b/src/tools/sisyphus-task/types.ts index f60bbeceda..9cd6d19d55 100644 --- a/src/tools/sisyphus-task/types.ts +++ b/src/tools/sisyphus-task/types.ts @@ -6,4 +6,5 @@ export interface SisyphusTaskArgs { run_in_background: boolean resume?: string skills: string[] + workdir?: string } From 8c488796eb1ed27470ab2a20f20da729dfe66c94 Mon Sep 17 00:00:00 2001 From: Nguyen Khac Trung Kien Date: Sun, 11 Jan 2026 14:22:50 +0000 Subject: [PATCH 11/13] fix(prometheus): prevent agent fallback to build in background tasks - Created findFirstMessageWithAgent() to read original session agent from oldest message - Updated parentAgent resolution in sisyphus_task, call_omo_agent, background_task - Fixed message.updated handler to only track agent from user messages (not assistant/system) - Added debug logging for parentAgent resolution troubleshooting Fixes issue where Prometheus agent would switch to Build when background task notifications were injected, caused by OpenCode writing 'agent: build' to message files mid-session. --- .../claude-code-session-state/state.ts | 20 +++++++++++ src/features/hook-message-injector/index.ts | 2 +- .../hook-message-injector/injector.ts | 29 +++++++++++++++ src/hooks/prometheus-md-only/index.ts | 11 ++++-- src/index.ts | 13 +++++++ src/tools/background-task/tools.ts | 19 ++++++++-- src/tools/call-omo-agent/tools.ts | 35 +++++++++++++++++++ src/tools/sisyphus-task/tools.ts | 19 ++++++++-- 8 files changed, 139 insertions(+), 9 deletions(-) diff --git a/src/features/claude-code-session-state/state.ts b/src/features/claude-code-session-state/state.ts index 751ab83e13..a864b75d8a 100644 --- a/src/features/claude-code-session-state/state.ts +++ b/src/features/claude-code-session-state/state.ts @@ -9,3 +9,23 @@ export function setMainSession(id: string | undefined) { export function getMainSessionID(): string | undefined { return mainSessionID } + +const sessionAgentMap = new Map() + +export function setSessionAgent(sessionID: string, agent: string): void { + if (!sessionAgentMap.has(sessionID)) { + sessionAgentMap.set(sessionID, agent) + } +} + +export function updateSessionAgent(sessionID: string, agent: string): void { + sessionAgentMap.set(sessionID, agent) +} + +export function getSessionAgent(sessionID: string): string | undefined { + return sessionAgentMap.get(sessionID) +} + +export function clearSessionAgent(sessionID: string): void { + sessionAgentMap.delete(sessionID) +} diff --git a/src/features/hook-message-injector/index.ts b/src/features/hook-message-injector/index.ts index 2262a0b320..fcb0624d95 100644 --- a/src/features/hook-message-injector/index.ts +++ b/src/features/hook-message-injector/index.ts @@ -1,4 +1,4 @@ -export { injectHookMessage, findNearestMessageWithFields } from "./injector" +export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector" export type { StoredMessage } from "./injector" export type { MessageMeta, OriginalMessageContext, TextPart } from "./types" export { MESSAGE_STORAGE } from "./constants" diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index acc2c46a8d..f5d8701890 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -48,6 +48,35 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage return null } +/** + * Finds the FIRST (oldest) message in the session with agent field. + * This is used to get the original agent that started the session, + * avoiding issues where newer messages may have a different agent + * due to OpenCode's internal agent switching. + */ +export function findFirstMessageWithAgent(messageDir: string): string | null { + try { + const files = readdirSync(messageDir) + .filter((f) => f.endsWith(".json")) + .sort() // Oldest first (no reverse) + + for (const file of files) { + try { + const content = readFileSync(join(messageDir, file), "utf-8") + const msg = JSON.parse(content) as StoredMessage + if (msg.agent) { + return msg.agent + } + } catch { + continue + } + } + } catch { + return null + } + return null +} + function generateMessageId(): string { const timestamp = Date.now().toString(16) const random = Math.random().toString(36).substring(2, 14) diff --git a/src/hooks/prometheus-md-only/index.ts b/src/hooks/prometheus-md-only/index.ts index d5839e815e..c562e39e65 100644 --- a/src/hooks/prometheus-md-only/index.ts +++ b/src/hooks/prometheus-md-only/index.ts @@ -2,7 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin" import { existsSync, readdirSync } from "node:fs" import { join, resolve, relative, isAbsolute } from "node:path" import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" export * from "./constants" @@ -61,10 +62,14 @@ function getMessageDir(sessionID: string): string | null { const TASK_TOOLS = ["sisyphus_task", "task", "call_omo_agent"] -function getAgentFromSession(sessionID: string): string | undefined { +function getAgentFromMessageFiles(sessionID: string): string | undefined { const messageDir = getMessageDir(sessionID) if (!messageDir) return undefined - return findNearestMessageWithFields(messageDir)?.agent + return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent +} + +function getAgentFromSession(sessionID: string): string | undefined { + return getSessionAgent(sessionID) ?? getAgentFromMessageFiles(sessionID) } export function createPrometheusMdOnlyHook(ctx: PluginInput) { diff --git a/src/index.ts b/src/index.ts index e36d6204ae..24f34f9571 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,8 @@ import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; import { setMainSession, getMainSessionID, + setSessionAgent, + clearSessionAgent, } from "./features/claude-code-session-state"; import { builtinTools, @@ -444,11 +446,22 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { } if (sessionInfo?.id) { firstMessageVariantGate.clear(sessionInfo.id); + clearSessionAgent(sessionInfo.id); await skillMcpManager.disconnectSession(sessionInfo.id); await lspManager.cleanupTempDirectoryClients(); } } + if (event.type === "message.updated") { + const info = props?.info as Record | undefined; + const sessionID = info?.sessionID as string | undefined; + const agent = info?.agent as string | undefined; + const role = info?.role as string | undefined; + if (sessionID && agent && role === "user") { + setSessionAgent(sessionID, agent); + } + } + if (event.type === "session.error") { const sessionID = props?.sessionID as string | undefined; const error = props?.error; diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index 3df7b0533d..3a2eeae5b3 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -4,7 +4,9 @@ import { join } from "node:path" import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types" import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared/logger" type OpencodeClient = PluginInput["client"] @@ -63,6 +65,19 @@ export function createBackgroundTask(manager: BackgroundManager): ToolDefinition try { const messageDir = getMessageDir(ctx.sessionID) const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(ctx.sessionID) + const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[background_task] parentAgent resolution", { + sessionID: ctx.sessionID, + ctxAgent: ctx.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) + const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID ? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID } : undefined @@ -74,7 +89,7 @@ export function createBackgroundTask(manager: BackgroundManager): ToolDefinition parentSessionID: ctx.sessionID, parentMessageID: ctx.messageID, parentModel, - parentAgent: ctx.agent ?? prevMessage?.agent, + parentAgent, }) ctx.metadata?.({ diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index b30e2286b8..c9ea381445 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -1,8 +1,26 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" import type { CallOmoAgentArgs } from "./types" import type { BackgroundManager } from "../../features/background-agent" import { log } from "../../shared/logger" +import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" + +function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} type ToolContextWithMetadata = { sessionID: string @@ -60,12 +78,29 @@ async function executeBackground( manager: BackgroundManager ): Promise { try { + const messageDir = getMessageDir(toolContext.sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(toolContext.sessionID) + const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[call_omo_agent] parentAgent resolution", { + sessionID: toolContext.sessionID, + messageDir, + ctxAgent: toolContext.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) + const task = await manager.launch({ description: args.description, prompt: args.prompt, agent: args.subagent_type, parentSessionID: toolContext.sessionID, parentMessageID: toolContext.messageID, + parentAgent, }) toolContext.metadata?.({ diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index b67fa5263a..4ef69aca3c 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -5,11 +5,12 @@ import type { BackgroundManager } from "../../features/background-agent" import type { SisyphusTaskArgs } from "./types" import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema" import { SISYPHUS_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" +import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { resolveMultipleSkills } from "../../features/opencode-skill-loader/skill-content" import { createBuiltinSkills } from "../../features/builtin-skills/skills" import { getTaskToastManager } from "../../features/task-toast-manager" -import { subagentSessions } from "../../features/claude-code-session-state" +import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared/logger" type OpencodeClient = PluginInput["client"] @@ -201,7 +202,19 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini const messageDir = getMessageDir(ctx.sessionID) const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const parentAgent = ctx.agent ?? prevMessage?.agent + const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(ctx.sessionID) + const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[sisyphus_task] parentAgent resolution", { + sessionID: ctx.sessionID, + messageDir, + ctxAgent: ctx.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID ? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID } : undefined From d64af505b47a5f460a9a25e546eb28ee0ab32cb6 Mon Sep 17 00:00:00 2001 From: Sanyue Date: Mon, 12 Jan 2026 01:49:13 +0800 Subject: [PATCH 12/13] docs(zh-CN): fix typo and clarify google_auth configuration - Fixed a spelling error. - Clarify when to use google_auth: true vs false based on plugin choice --- README.zh-cn.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.zh-cn.md b/README.zh-cn.md index 41f5326018..c947b36cbc 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -396,7 +396,7 @@ opencode auth login **除非用户特别要求,否则别乱动模型设置,也别关功能(Agent、Hook、MCP)。** -插件默认配置就是最佳实践。除了推荐开个 `google_auth: true`,其他的没让改就别手贱。 +该插件默认情况下运行良好。未使用外部 Antigravity 插件时保持 `google_auth: true`;如果按上方说明接入 `opencode-antigravity-auth`,请将 `google_auth` 设为 `false` 并覆盖智能体模型。除此之外,不要在没有明确请求的情况下更改其他设置或关闭功能。 ### 检查作业 @@ -545,8 +545,8 @@ OhMyOpenCode 让这些成为可能。 这些工具使 Agent 能够引用之前的对话并保持跨会话的连续性。 -#### 上下文就是一切 (Context is all you need) -- **Directory AGENTS.md / README.md 注入器**:读文件时自动把 `AGENTS.md` 和 `README.md` 塞进去。从当前目录一路往上找,路径上**所有** `AGENTS.md` 全都带上。支持嵌套指令: +#### 上下文就是一切 +- **目录 AGENTS.md / README.md 注入器**:读取文件时自动注入 `AGENTS.md` 和 `README.md`。从文件目录向上遍历到项目根目录,收集路径上的**所有** `AGENTS.md` 文件。支持嵌套的目录特定说明: ``` project/ ├── AGENTS.md # 项目级规矩 From ec554fcbe2d9887a4c3ff96ffad9b5f5c6fe0533 Mon Sep 17 00:00:00 2001 From: Gladdonilli Date: Mon, 12 Jan 2026 21:05:40 +0800 Subject: [PATCH 13/13] fix(background-agent): prevent memory leaks on task completion - Release concurrency key immediately in session.idle handler - Clean up subagentSessions Set on normal completion - Clean up sessionAgentMap on both completion paths - Add documentation for validateSessionHasOutput usage --- src/features/background-agent/manager.ts | 41 ++++++++++++++++-------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 76206afa5c..1a7e15b543 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -9,7 +9,7 @@ import { log } from "../../shared/logger" import { ConcurrencyManager } from "./concurrency" import type { BackgroundTaskConfig } from "../../config/schema" -import { subagentSessions } from "../claude-code-session-state" +import { subagentSessions, clearSessionAgent } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" const TASK_TTL_MS = 30 * 60 * 1000 @@ -468,6 +468,17 @@ export class BackgroundManager { } task.status = "completed" task.completedAt = new Date() + + // Release concurrency key immediately on completion to free up slots + if (task.concurrencyKey) { + this.concurrencyManager.release(task.concurrencyKey) + task.concurrencyKey = undefined // Prevent double-release + } + + // Clean up session tracking to prevent memory leaks + subagentSessions.delete(sessionID) + clearSessionAgent(sessionID) + this.markForNotification(task) this.notifyParentSession(task).catch(err => { log("[background-agent] Error notifying parent on completion:", err) @@ -496,6 +507,7 @@ export class BackgroundManager { this.tasks.delete(task.id) this.clearNotificationsForTask(task.id) subagentSessions.delete(sessionID) + clearSessionAgent(sessionID) } } @@ -516,6 +528,9 @@ export class BackgroundManager { /** * Validates that a session has actual assistant/tool output before marking complete. * Prevents premature completion when session.idle fires before agent responds. + * + * NOTE: This is used in pollRunningTasks() but NOT in session.idle handler. + * Using it in session.idle was causing stuck tasks due to timing issues. */ private async validateSessionHasOutput(sessionID: string): Promise { try { @@ -546,18 +561,18 @@ export class BackgroundManager { const hasContent = messages.some((m: any) => { if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false const parts = m.parts ?? [] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return parts.some((p: any) => - // Text content (final output) - (p.type === "text" && p.text && p.text.trim().length > 0) || - // Reasoning content (thinking blocks) - (p.type === "reasoning" && p.text && p.text.trim().length > 0) || - // Tool calls (indicates work was done) - p.type === "tool" || - // Tool results (output from executed tools) - important for tool-only tasks - (p.type === "tool_result" && p.content && - (typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0)) - ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return parts.some((p: any) => + // Text content (final output) + (p.type === "text" && p.text && p.text.trim().length > 0) || + // Reasoning content (thinking blocks) + (p.type === "reasoning" && p.text && p.text.trim().length > 0) || + // Tool calls (indicates work was done) + p.type === "tool" || + // Tool results (output from executed tools) - important for tool-only tasks + (p.type === "tool_result" && p.content && + (typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0)) + ) }) if (!hasContent) {