From b9f80a87b5ae4f18eba4ca246401d9ae5f176f53 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:22:21 +0900 Subject: [PATCH 1/7] feat(background-task): add spawn limit config fields Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- assets/oh-my-opencode.schema.json | 10 +++++ src/config/schema/background-task.test.ts | 48 +++++++++++++++++++++++ src/config/schema/background-task.ts | 2 + 3 files changed, 60 insertions(+) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index a8710453c5..3c5dae067a 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3678,6 +3678,16 @@ "minimum": 0 } }, + "maxDepth": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxDescendants": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, "staleTimeoutMs": { "type": "number", "minimum": 60000 diff --git a/src/config/schema/background-task.test.ts b/src/config/schema/background-task.test.ts index 2ca2258641..9bd6c74ded 100644 --- a/src/config/schema/background-task.test.ts +++ b/src/config/schema/background-task.test.ts @@ -3,6 +3,54 @@ import { ZodError } from "zod/v4" import { BackgroundTaskConfigSchema } from "./background-task" describe("BackgroundTaskConfigSchema", () => { + describe("maxDepth", () => { + describe("#given valid maxDepth (3)", () => { + test("#when parsed #then returns correct value", () => { + const result = BackgroundTaskConfigSchema.parse({ maxDepth: 3 }) + + expect(result.maxDepth).toBe(3) + }) + }) + + describe("#given maxDepth below minimum (0)", () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ maxDepth: 0 }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + }) + + describe("maxDescendants", () => { + describe("#given valid maxDescendants (50)", () => { + test("#when parsed #then returns correct value", () => { + const result = BackgroundTaskConfigSchema.parse({ maxDescendants: 50 }) + + expect(result.maxDescendants).toBe(50) + }) + }) + + describe("#given maxDescendants below minimum (0)", () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ maxDescendants: 0 }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + }) + describe("syncPollTimeoutMs", () => { describe("#given valid syncPollTimeoutMs (120000)", () => { test("#when parsed #then returns correct value", () => { diff --git a/src/config/schema/background-task.ts b/src/config/schema/background-task.ts index b955de6b53..f53a67f6cc 100644 --- a/src/config/schema/background-task.ts +++ b/src/config/schema/background-task.ts @@ -4,6 +4,8 @@ export const BackgroundTaskConfigSchema = z.object({ defaultConcurrency: z.number().min(1).optional(), providerConcurrency: z.record(z.string(), z.number().min(0)).optional(), modelConcurrency: z.record(z.string(), z.number().min(0)).optional(), + maxDepth: z.number().int().min(1).optional(), + maxDescendants: z.number().int().min(1).optional(), /** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */ staleTimeoutMs: z.number().min(60000).optional(), /** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */ From b4aac44f0d620ecfbf4ac8b79405dc34fede15a9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:22:21 +0900 Subject: [PATCH 2/7] feat(background-agent): add subagent spawn context resolver Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../background-agent/subagent-spawn-limits.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/features/background-agent/subagent-spawn-limits.ts diff --git a/src/features/background-agent/subagent-spawn-limits.ts b/src/features/background-agent/subagent-spawn-limits.ts new file mode 100644 index 0000000000..2b0068102b --- /dev/null +++ b/src/features/background-agent/subagent-spawn-limits.ts @@ -0,0 +1,79 @@ +import type { BackgroundTaskConfig } from "../../config/schema" +import type { OpencodeClient } from "./constants" + +export const DEFAULT_MAX_SUBAGENT_DEPTH = 3 +export const DEFAULT_MAX_ROOT_DESCENDANTS = 50 + +export interface SubagentSpawnContext { + rootSessionID: string + parentDepth: number + childDepth: number +} + +export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number { + return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH +} + +export function getMaxRootDescendants(config?: BackgroundTaskConfig): number { + return config?.maxDescendants ?? DEFAULT_MAX_ROOT_DESCENDANTS +} + +export async function resolveSubagentSpawnContext( + client: OpencodeClient, + parentSessionID: string +): Promise { + const visitedSessionIDs = new Set() + let rootSessionID = parentSessionID + let currentSessionID = parentSessionID + let parentDepth = 0 + + while (true) { + if (visitedSessionIDs.has(currentSessionID)) { + throw new Error(`Detected a session parent cycle while resolving ${parentSessionID}`) + } + + visitedSessionIDs.add(currentSessionID) + + const session = await client.session.get({ + path: { id: currentSessionID }, + }).catch(() => null) + + const nextParentSessionID = session?.data?.parentID + if (!nextParentSessionID) { + rootSessionID = currentSessionID + break + } + + currentSessionID = nextParentSessionID + parentDepth += 1 + } + + return { + rootSessionID, + parentDepth, + childDepth: parentDepth + 1, + } +} + +export function createSubagentDepthLimitError(input: { + childDepth: number + maxDepth: number + parentSessionID: string + rootSessionID: string +}): Error { + const { childDepth, maxDepth, parentSessionID, rootSessionID } = input + return new Error( + `Subagent spawn blocked: child depth ${childDepth} exceeds background_task.maxDepth=${maxDepth}. Parent session: ${parentSessionID}. Root session: ${rootSessionID}. Continue in an existing subagent session instead of spawning another.` + ) +} + +export function createSubagentDescendantLimitError(input: { + rootSessionID: string + descendantCount: number + maxDescendants: number +}): Error { + const { rootSessionID, descendantCount, maxDescendants } = input + return new Error( + `Subagent spawn blocked: root session ${rootSessionID} already has ${descendantCount} descendants, which meets background_task.maxDescendants=${maxDescendants}. Reuse an existing session instead of spawning another.` + ) +} From f28d0cddde3ff1955b675a76755e39803b823c03 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:22:21 +0900 Subject: [PATCH 3/7] feat(background-agent): track spawn depth on tasks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/features/background-agent/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 6973dd7831..10269f29b8 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -19,11 +19,13 @@ export interface TaskProgress { export interface BackgroundTask { id: string sessionID?: string + rootSessionID?: string parentSessionID: string parentMessageID: string description: string prompt: string agent: string + spawnDepth?: number status: BackgroundTaskStatus queuedAt?: Date startedAt?: Date From 50a2264d759f68494ec6792e040eb8ba3239936f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:22:22 +0900 Subject: [PATCH 4/7] feat(background-agent): enforce launch depth and descendant limits Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 111 ++++++++++++++++++ src/features/background-agent/manager.ts | 57 ++++++++- 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 8a6ddc3580..e12b99375a 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1637,6 +1637,25 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { } } + function createMockClientWithSessionChain( + sessions: Record + ) { + return { + session: { + create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }), + get: async ({ path }: { path: { id: string } }) => ({ + data: sessions[path.id] ?? { directory: "/test/dir" }, + }), + prompt: async () => ({}), + promptAsync: async () => ({}), + messages: async () => ({ data: [] }), + todo: async () => ({ data: [] }), + status: async () => ({ data: {} }), + abort: async () => ({}), + }, + } + } + beforeEach(() => { // given mockClient = createMockClient() @@ -1831,6 +1850,98 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { expect(updatedTask.startedAt.getTime()).toBeGreaterThanOrEqual(queuedAt.getTime()) } }) + + test("should track rootSessionID and spawnDepth from the parent chain", async () => { + // given + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" }, + "session-depth-1": { directory: "/test/dir", parentID: "session-root" }, + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDepth: 3 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-depth-2", + parentMessageID: "parent-message", + } + + // when + const task = await manager.launch(input) + + // then + expect(task.rootSessionID).toBe("session-root") + expect(task.spawnDepth).toBe(3) + }) + + test("should block launches that exceed maxDepth", async () => { + // given + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-depth-3": { directory: "/test/dir", parentID: "session-depth-2" }, + "session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" }, + "session-depth-1": { directory: "/test/dir", parentID: "session-root" }, + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDepth: 3 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-depth-3", + parentMessageID: "parent-message", + } + + // when + const result = manager.launch(input) + + // then + await expect(result).rejects.toThrow("background_task.maxDepth=3") + }) + + test("should block launches when maxDescendants is reached", async () => { + // given + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDescendants: 1 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-root", + parentMessageID: "parent-message", + } + + await manager.launch(input) + + // when + const result = manager.launch(input) + + // then + await expect(result).rejects.toThrow("background_task.maxDescendants=1") + }) }) describe("pending task can be cancelled", () => { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 247e9f9487..eea43424e2 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -47,6 +47,14 @@ import { MESSAGE_STORAGE } from "../hook-message-injector" import { join } from "node:path" import { pruneStaleTasksAndNotifications } from "./task-poller" import { checkAndInterruptStaleTasks } from "./task-poller" +import { + createSubagentDepthLimitError, + createSubagentDescendantLimitError, + getMaxRootDescendants, + getMaxSubagentDepth, + resolveSubagentSpawnContext, + type SubagentSpawnContext, +} from "./subagent-spawn-limits" type OpencodeClient = PluginInput["client"] @@ -111,6 +119,7 @@ export class BackgroundManager { private completionTimers: Map> = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() + private rootDescendantCounts: Map private enableParentSessionNotifications: boolean readonly taskHistory = new TaskHistory() @@ -135,10 +144,42 @@ export class BackgroundManager { this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false this.onSubagentSessionCreated = options?.onSubagentSessionCreated this.onShutdown = options?.onShutdown + this.rootDescendantCounts = new Map() this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true this.registerProcessCleanup() } + async assertCanSpawn(parentSessionID: string): Promise { + const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID) + const maxDepth = getMaxSubagentDepth(this.config) + if (spawnContext.childDepth > maxDepth) { + throw createSubagentDepthLimitError({ + childDepth: spawnContext.childDepth, + maxDepth, + parentSessionID, + rootSessionID: spawnContext.rootSessionID, + }) + } + + const maxDescendants = getMaxRootDescendants(this.config) + const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0 + if (descendantCount >= maxDescendants) { + throw createSubagentDescendantLimitError({ + rootSessionID: spawnContext.rootSessionID, + descendantCount, + maxDescendants, + }) + } + + return spawnContext + } + + private registerRootDescendant(rootSessionID: string): number { + const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1 + this.rootDescendantCounts.set(rootSessionID, nextCount) + return nextCount + } + async launch(input: LaunchInput): Promise { log("[background-agent] launch() called with:", { agent: input.agent, @@ -151,16 +192,28 @@ export class BackgroundManager { throw new Error("Agent parameter is required") } + const spawnContext = await this.assertCanSpawn(input.parentSessionID) + const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID) + + log("[background-agent] spawn guard passed", { + parentSessionID: input.parentSessionID, + rootSessionID: spawnContext.rootSessionID, + childDepth: spawnContext.childDepth, + descendantCount, + }) + // Create task immediately with status="pending" const task: BackgroundTask = { id: `bg_${crypto.randomUUID().slice(0, 8)}`, status: "pending", queuedAt: new Date(), + rootSessionID: spawnContext.rootSessionID, // Do NOT set startedAt - will be set when running // Do NOT set sessionID - will be set when running description: input.description, prompt: input.prompt, agent: input.agent, + spawnDepth: spawnContext.childDepth, parentSessionID: input.parentSessionID, parentMessageID: input.parentMessageID, parentModel: input.parentModel, @@ -205,7 +258,7 @@ export class BackgroundManager { // Trigger processing (fire-and-forget) this.processKey(key) - return task + return { ...task } } private async processKey(key: string): Promise { @@ -875,6 +928,7 @@ export class BackgroundManager { } } + this.rootDescendantCounts.delete(sessionID) SessionCategoryRegistry.remove(sessionID) } @@ -1609,6 +1663,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea this.pendingNotifications.clear() this.pendingByParent.clear() this.notificationQueueByParent.clear() + this.rootDescendantCounts.clear() this.queuesByKey.clear() this.processingKeys.clear() this.unregisterProcessCleanup() From 98e24baef0c205826ae0bdc06268ac5bdf1e84e8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:22:22 +0900 Subject: [PATCH 5/7] feat(task): validate sync delegation spawn depth Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/tools/delegate-task/sync-task.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 115d2c57de..07f080c487 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -23,12 +23,19 @@ export async function executeSyncTask( fallbackChain?: import("../../shared/model-requirements").FallbackEntry[], deps: SyncTaskDeps = syncTaskDeps ): Promise { - const { client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx + const { manager, client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx const toastManager = getTaskToastManager() let taskId: string | undefined let syncSessionID: string | undefined try { + const spawnContext = typeof manager?.assertCanSpawn === "function" + ? await manager.assertCanSpawn(parentContext.sessionID) + : { + rootSessionID: parentContext.sessionID, + parentDepth: 0, + childDepth: 1, + } const createSessionResult = await deps.createSyncSession(client, { parentSessionID: parentContext.sessionID, agentToUse, @@ -90,6 +97,7 @@ export async function executeSyncTask( run_in_background: args.run_in_background, sessionId: sessionID, sync: true, + spawnDepth: spawnContext.childDepth, command: args.command, model: categoryModel ? { providerID: categoryModel.providerID, modelID: categoryModel.modelID } : undefined, }, From 461af467b35ef64712a3080a793aa6fa0cd72fea Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:22:22 +0900 Subject: [PATCH 6/7] docs(call-omo-agent): mention nested spawn depth limits Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/tools/call-omo-agent/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/call-omo-agent/constants.ts b/src/tools/call-omo-agent/constants.ts index a17eea6dd7..028b0d602b 100644 --- a/src/tools/call-omo-agent/constants.ts +++ b/src/tools/call-omo-agent/constants.ts @@ -12,4 +12,4 @@ export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent. run_in Available: {agents} -Pass \`session_id=\` to continue previous agent with full context. Prompts MUST be in English. Use \`background_output\` for async results.` +Pass \`session_id=\` to continue previous agent with full context. Nested subagent depth is tracked automatically and blocked past the configured limit. Prompts MUST be in English. Use \`background_output\` for async results.` From 7874669de07685a9ab0977eb51d1b40318887c1f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:22:22 +0900 Subject: [PATCH 7/7] feat(call-omo-agent): block sync subagent depth overflows Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/tools/call-omo-agent/tools.test.ts | 23 +++++++++++++++++++++++ src/tools/call-omo-agent/tools.ts | 8 ++++++++ 2 files changed, 31 insertions(+) diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts index a560c8bead..11af01a9a1 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -4,12 +4,14 @@ import type { BackgroundManager } from "../../features/background-agent" import { createCallOmoAgent } from "./tools" describe("createCallOmoAgent", () => { + const assertCanSpawnMock = mock(() => Promise.resolve(undefined)) const mockCtx = { client: {}, directory: "/test", } as unknown as PluginInput const mockBackgroundManager = { + assertCanSpawn: assertCanSpawnMock, launch: mock(() => Promise.resolve({ id: "test-task-id", sessionID: null, @@ -99,4 +101,25 @@ describe("createCallOmoAgent", () => { //#then expect(result).not.toContain("disabled via disabled_agents") }) + + test("should return a tool error when sync spawn depth validation fails", async () => { + //#given + assertCanSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3.")) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function + + //#when + const result = await executeFunc( + { + description: "Test", + prompt: "Test prompt", + subagent_type: "explore", + run_in_background: false, + }, + { sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }, + ) + + //#then + expect(result).toContain("background_task.maxDepth=3") + }) }) diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index c2a169e64d..5f5b5d3197 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -57,6 +57,14 @@ export function createCallOmoAgent( return await executeBackground(args, toolCtx, backgroundManager, ctx.client) } + if (!args.session_id) { + try { + await backgroundManager.assertCanSpawn(toolCtx.sessionID) + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}` + } + } + return await executeSync(args, toolCtx, ctx) }, })