diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 81f60180d87..1717899493b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1980,6 +1980,7 @@ export type ConfigEntry = description?: string agent?: string model?: string | { providerID: string; model: string; variant?: string } + subagent?: boolean subtask?: boolean } } diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index b74eb3f702e..f7cf3647b36 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,7 +1,6 @@ export * as ConfigCommandPlugin from "./command.js" import { define } from "@opencode-ai/plugin/effect/plugin" -import { Agent } from "@opencode-ai/schema/agent" import { Info, type Entry } from "@opencode-ai/schema/config" import { ConfigCommand } from "@opencode-ai/schema/config/command" import { Model } from "@opencode-ai/schema/model" @@ -10,8 +9,11 @@ import { AppProcess } from "@opencode-ai/util/process" import path from "path" import { Effect, Option, PubSub, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" +import { Agent } from "../../agent.js" import { Config } from "../../config.js" import { Location } from "../../location.js" +import { Session } from "../../session.js" +import { SubagentJob } from "../../session/subagent-job.js" import { ShellSelect } from "../../shell/select.js" import { FSUtil } from "@opencode-ai/util/fs-util" import { ConfigMarkdown } from "../markdown.js" @@ -32,6 +34,9 @@ export const Plugin = define({ const location = yield* Location.Service const processes = yield* AppProcess.Service const shell = yield* ShellSelect.Service + const sessions = yield* Session.Service + const agents = yield* Agent.Service + const subagents = yield* SubagentJob.make const load = Effect.fn("ConfigCommandPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat())) }) @@ -67,18 +72,14 @@ export const Plugin = define({ yield* ctx.command.transform((editor) => { for (const document of loaded.documents) { for (const [name, command] of Object.entries(document.commands ?? {})) { + const subagent = command.subagent ?? command.subtask editor.add({ name, description: command.description, execute: (input) => Effect.gen(function* () { const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent) - const commandAgent = yield* Effect.gen(function* () { - if (agent === undefined) return - const session = yield* ctx.session.get({ sessionID: input.sessionID }) - if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent }) - return (yield* ctx.agent.get({ agentID: agent })).data - }) + const commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data const model = command.model === undefined ? commandAgent?.model @@ -89,15 +90,46 @@ export const Plugin = define({ ? {} : { variant: Model.VariantID.make(command.model.variant) }), } + const text = yield* evaluateTemplate(command.template, input.prompt.text, { + location, + processes, + shell, + }) + if (subagent ?? commandAgent?.mode === "subagent") { + const parent = yield* sessions.get(input.sessionID) + const selected = yield* agents.select(agent ?? parent.agent) + const child = yield* sessions.create({ + parentID: parent.id, + title: command.description ?? name, + agent: selected.id, + model: model ?? selected.info?.model ?? parent.model, + }) + yield* sessions.prompt({ + ...input.prompt, + sessionID: child.id, + text: ["You are a subagent spawned by another session.", text].join("\n"), + resume: false, + }) + const recovery = { + kind: "subagent" as const, + parentSessionID: parent.id, + childSessionID: child.id, + agent: selected.id, + description: command.description ?? name, + } + yield* subagents.start(recovery) + yield* subagents.background(recovery) + return + } + if (agent !== undefined) { + const session = yield* ctx.session.get({ sessionID: input.sessionID }) + if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent }) + } if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model }) yield* ctx.session.prompt({ ...input.prompt, sessionID: input.sessionID, - text: yield* evaluateTemplate(command.template, input.prompt.text, { - location, - processes, - shell, - }), + text, delivery: input.delivery, }) }).pipe(Effect.asVoid), @@ -196,8 +228,8 @@ function evaluateTemplate( ) .pipe( Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")), - Effect.mapError((error) => - new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`), + Effect.mapError( + (error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`), ), ) }, diff --git a/packages/core/src/session/execution/restart.ts b/packages/core/src/session/execution/restart.ts index 8f263e88213..af048a1e9da 100644 --- a/packages/core/src/session/execution/restart.ts +++ b/packages/core/src/session/execution/restart.ts @@ -176,13 +176,7 @@ export const layer = (options?: Options) => (message) => message.type === "assistant" && message.time.completed !== undefined && message.error === undefined, ) - if (assistant?.type !== "assistant") return "Subagent completed without a text response." - return ( - assistant.content - .filter((part) => part.type === "text") - .map((part) => part.text) - .join("") || "Subagent completed without a text response." - ) + return SubagentCompletion.text(assistant) }), ), }) diff --git a/packages/core/src/session/subagent-completion.ts b/packages/core/src/session/subagent-completion.ts index 9ccf24363f4..746bc5170ee 100644 --- a/packages/core/src/session/subagent-completion.ts +++ b/packages/core/src/session/subagent-completion.ts @@ -3,6 +3,19 @@ export * as SubagentCompletion from "./subagent-completion.js" import { Effect } from "effect" import type { Job } from "../job.js" import type { Session } from "../session.js" +import type { SessionMessage } from "./message.js" + +export const NO_TEXT = "Subagent completed without a text response." + +export function text(message: SessionMessage.Info | undefined) { + if (message?.type !== "assistant") return NO_TEXT + return ( + message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("") || NO_TEXT + ) +} export const deliver = Effect.fnUntraced(function* ( sessions: Pick, @@ -16,7 +29,7 @@ export const deliver = Effect.fnUntraced(function* ( const recovery = input.recovery const text = input.status === "completed" - ? (input.output ?? "Subagent completed without a text response.") + ? (input.output ?? NO_TEXT) : input.status === "error" ? (input.error ?? "Subagent failed") : "Subagent cancelled" diff --git a/packages/core/src/session/subagent-job.ts b/packages/core/src/session/subagent-job.ts new file mode 100644 index 00000000000..f8e7503fbd0 --- /dev/null +++ b/packages/core/src/session/subagent-job.ts @@ -0,0 +1,60 @@ +export * as SubagentJob from "./subagent-job.js" + +import { Effect, Scope } from "effect" +import { Job } from "../job.js" +import { Session } from "../session.js" +import { SubagentCompletion } from "./subagent-completion.js" + +type Recovery = Extract + +interface Runner { + start: (recovery: Recovery) => Effect.Effect + background: (recovery: Recovery) => Effect.Effect + notify: (recovery: Recovery, startedAt: number) => Effect.Effect +} + +export const make: Effect.Effect = Effect.gen(function* () { + const sessions = yield* Session.Service + const jobs = yield* Job.Service + const scope = yield* Scope.Scope + // One observer per job generation, including continuations of the same child. + const notifications = new Set() + + const notify = Effect.fn("SubagentJob.notify")(function* (recovery: Recovery, startedAt: number) { + const key = `${recovery.childSessionID}:${startedAt}` + if (notifications.has(key)) return + notifications.add(key) + yield* Effect.gen(function* () { + const info = (yield* jobs.wait({ id: recovery.childSessionID })).info + if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery }) + }).pipe( + Effect.ensuring(Effect.sync(() => notifications.delete(key))), + Effect.forkIn(scope, { startImmediately: true }), + ) + }) + + return { + start: (recovery: Recovery) => + jobs.start({ + id: recovery.childSessionID, + type: "subagent", + title: recovery.description, + metadata: {}, + recovery, + run: Effect.gen(function* () { + yield* sessions.resume(recovery.childSessionID) + const messages = yield* sessions.messages({ sessionID: recovery.childSessionID, order: "desc", limit: 20 }) + const assistant = messages.find( + (message) => + message.type === "assistant" && message.time.completed !== undefined && message.error === undefined, + ) + return SubagentCompletion.text(assistant) + }), + }), + background: Effect.fn("SubagentJob.background")(function* (recovery: Recovery) { + const info = yield* jobs.background(recovery.childSessionID) + if (info) yield* notify(recovery, info.started_at) + }), + notify, + } +}) diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 58d245931d6..c12eaeccbe3 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -2,7 +2,7 @@ export * as SubagentTool from "./subagent.js" import { ToolFailure } from "@opencode-ai/ai" import type { Context } from "@opencode-ai/plugin/effect/plugin" -import { Effect, Schema, Scope } from "effect" +import { Effect, Schema } from "effect" import { Agent } from "../../agent.js" import { Config } from "../../config.js" import { Job } from "../../job.js" @@ -10,10 +10,10 @@ import { Permission } from "../../permission.js" import { Session } from "../../session.js" import { SessionSchema } from "../../session/schema.js" import { SubagentCompletion } from "../../session/subagent-completion.js" +import { SubagentJob } from "../../session/subagent-job.js" export const name = "subagent" -const NO_TEXT = "Subagent completed without a text response." const backgroundResult = (sessionID: SessionSchema.ID) => ({ sessionID, status: "running" as const, @@ -60,42 +60,7 @@ export const Plugin = { const agents = yield* Agent.Service const config = yield* Config.Service const permission = yield* Permission.Service - const scope = yield* Scope.Scope - // One completion observer per job generation. Keyed by child plus start time so a fresh - // continuation job is observable even while a settled generation's observer is finalizing. - const notifications = new Set() - - // Concatenate the child's final completed assistant text. Distinguishes "completed with no - // text" (generic string) from "failed" (the run effect fails, surfaced as a job error). - const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) { - const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 }) - const assistant = messages.find( - (message) => - message.type === "assistant" && message.time.completed !== undefined && message.error === undefined, - ) - if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT - const text = assistant.content - .filter((part): part is Extract => part.type === "text") - .map((part) => part.text) - .join("") - return text.length > 0 ? text : NO_TEXT - }) - - const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* ( - recovery: Extract, - startedAt: number, - ) { - const key = `${recovery.childSessionID}:${startedAt}` - if (notifications.has(key)) return - notifications.add(key) - yield* Effect.gen(function* () { - const info = (yield* jobs.wait({ id: recovery.childSessionID })).info - if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery }) - }).pipe( - Effect.ensuring(Effect.sync(() => notifications.delete(key))), - Effect.forkIn(scope, { startImmediately: true }), - ) - }) + const subagents = yield* SubagentJob.make yield* ctx.tool .transform((editor) => @@ -225,18 +190,10 @@ export const Plugin = { agent: agent.name, description: input.description, } - const info = yield* jobs.start({ - id: child.id, - type: name, - title: input.description, - metadata: {}, - recovery, - run: sessions.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))), - }) + yield* subagents.start(recovery) if (background) { - yield* jobs.background(info.id) - yield* notifyWhenDone(recovery, info.started_at) + yield* subagents.background(recovery) return backgroundResult(child.id) } @@ -248,7 +205,7 @@ export const Plugin = { ), ) if (result?.type === "backgrounded") { - yield* notifyWhenDone(recovery, result.info.started_at) + yield* subagents.notify(recovery, result.info.started_at) return backgroundResult(child.id) } // Failure surfaces keep the sessionID visible so the model can continue the child. @@ -258,7 +215,11 @@ export const Plugin = { }) if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` }) - return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT } + return { + sessionID: child.id, + status: "completed" as const, + output: result?.info.output ?? SubagentCompletion.NO_TEXT, + } }).pipe( Effect.map((output) => ({ output, diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 63715995dbd..271689ce412 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -172,7 +172,7 @@ export function commands(info?: Readonly>) description: command.description, agent: command.agent, model: modelSelection(command.model, command.variant), - subtask: command.subtask, + subagent: command.subtask, }, ]), ) diff --git a/packages/core/test/config/command-subagent.test.ts b/packages/core/test/config/command-subagent.test.ts new file mode 100644 index 00000000000..44db54dbede --- /dev/null +++ b/packages/core/test/config/command-subagent.test.ts @@ -0,0 +1,164 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { LanguageModel } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat" +import { TestLLM } from "@opencode-ai/ai/testing" +import { Agent } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { Model } from "@opencode-ai/core/model" +import { Provider } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Session } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Global } from "@opencode-ai/util/global" +import { tempGlobalLayer } from "../fixture/global" +import { offlineModels } from "../fixture/models" +import { tmpdirScoped } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Review complete", "review") }) +const it = testEffect( + Layer.merge( + llmLayer, + AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [ + Global.node.replace(tempGlobalLayer), + offlineModels, + Watcher.node.replace(Watcher.configured({ enabled: false })), + LayerNodePlatform.llmClient.replace(llmLayer), + SessionRunnerModel.node.replace( + Layer.succeed(SessionRunnerModel.Service, { + resolve: (session) => + Effect.succeed( + SessionRunnerModel.resolved( + LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }), + { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + limit: { context: 200_000, output: 32_000 }, + }, + ), + ), + }), + ), + ]), + ), +) + +const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") }) + +describe("command subagents", () => { + for (const fixture of [ + { + name: "native JSON", + format: "json", + command: { subagent: true, agent: "build", model: "test/override" }, + agent: "build", + model: "override", + }, + { + name: "legacy Markdown", + format: "markdown", + command: { subtask: true, agent: "build" }, + agent: "build", + model: "parent", + }, + { + name: "subagent mode by default", + format: "json", + command: { agent: "reviewer" }, + agent: "reviewer", + model: "child", + }, + ] as const) { + it.live(`runs ${fixture.name} in the background without switching the parent`, () => + Effect.gen(function* () { + const parent = yield* project(fixture.command, fixture.format) + const sessions = yield* Session.Service + const llm = yield* TestLLM.Test + const gate = yield* llm.gate() + + // This must return while the child's model is still blocked. + yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" }) + yield* gate.started + const children = (yield* sessions.list({ parentID: parent.id })).data + expect(children).toHaveLength(1) + const child = children[0] + if (!child) return yield* Effect.die("Expected a child session") + expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" }) + expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel }) + expect(yield* sessions.context(parent.id)).toEqual([]) + expect(yield* llm.requests()).toHaveLength(1) + expect((yield* sessions.context(child.id)).filter((message) => message.type === "user")).toMatchObject([ + { text: "You are a subagent spawned by another session.\nReview changes: ready" }, + ]) + yield* gate.release + yield* llm.wait(2) + yield* sessions.wait(parent.id) + const notices = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic") + expect(notices).toMatchObject([{ metadata: { source: "subagent", childID: child.id, state: "completed" } }]) + expect(notices[0]?.text).toContain("Review complete") + }), + ) + } + + it.live("subagent: false overrides subagent mode and the legacy alias", () => + Effect.gen(function* () { + const parent = yield* project({ subagent: false, subtask: true, agent: "reviewer" }, "json") + const sessions = yield* Session.Service + yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" }) + yield* sessions.wait(parent.id) + expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([]) + expect(yield* sessions.get(parent.id)).toMatchObject({ + agent: "reviewer", + model: { id: "child" }, + }) + expect((yield* sessions.context(parent.id)).filter((message) => message.type === "user")).toMatchObject([ + { text: "Review changes: ready" }, + ]) + }), + ) +}) + +function project( + command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean }, + format: "json" | "markdown", +) { + return Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const definition = { description: "Review code", template: "Review $ARGUMENTS: !`printf ready`", ...command } + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + agents: { reviewer: { mode: "subagent", model: "test/child" } }, + ...(format === "markdown" ? {} : { commands: { review: definition } }), + }), + ), + ) + if (format === "markdown") + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, ".opencode/commands/review.md"), + [ + "---", + "description: Review code", + ...Object.entries(command).map(([key, value]) => `${key}: ${value}`), + "---", + definition.template, + ].join("\n"), + ), + ) + const sessions = yield* Session.Service + return yield* sessions.create({ + location: { directory: AbsolutePath.make(tmp.path) }, + title: "Parent session", + agent: Agent.ID.make("build"), + model: parentModel, + }) + }) +} diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index c8764ead26a..86fdd0582f7 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -4,7 +4,10 @@ import { describe, expect } from "bun:test" import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect" import { advance, drain } from "../lib/clock" import { Directory, Document, Event, Info } from "@opencode-ai/schema/config" -import { Session } from "@opencode-ai/schema/session" +import { Session } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { Job } from "@opencode-ai/core/job" +import { Agent } from "@opencode-ai/core/agent" import { SessionInbox } from "@opencode-ai/schema/session-inbox" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Command } from "@opencode-ai/core/command" @@ -27,6 +30,8 @@ import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp" import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" +import { tempGlobalLayer } from "../fixture/global" +import { offlineModels } from "../fixture/models" import { testEffect } from "../lib/effect" import { host } from "../plugin/host" @@ -41,12 +46,25 @@ const shellLayer = Layer.succeed( const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]), + LayerNode.group([ + Command.node, + Bus.node, + FSUtil.node, + AppProcess.node, + Location.node, + ShellSelect.node, + Session.node, + Job.node, + Agent.node, + ]), [ Mcp.node.replace(emptyMcpLayer), Config.node.replace(emptyConfigLayer), Location.node.replace(testLocationLayer), ShellSelect.node.replace(shellLayer), + Global.node.replace(tempGlobalLayer), + SessionExecution.node.replace(SessionExecution.noopLayer), + offlineModels, ], ), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 68b4a27824e..2533ba603bc 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -828,30 +828,32 @@ describe("Config", () => { expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined() }) - test("migrates v1 command configuration", () => { - expect( - ConfigMigrateV1.migrate({ - command: { - review: { - template: "Review changes", - description: "Review code", - agent: "reviewer", - model: "anthropic/claude", - variant: "high", - subtask: true, + for (const subtask of [true, false]) { + test(`migrates v1 command configuration with subtask: ${subtask}`, () => { + expect( + ConfigMigrateV1.migrate({ + command: { + review: { + template: "Review changes", + description: "Review code", + agent: "reviewer", + model: "anthropic/claude", + variant: "high", + subtask, + }, }, + }).commands, + ).toEqual({ + review: { + template: "Review changes", + description: "Review code", + agent: "reviewer", + model: { providerID: "anthropic", model: "claude", variant: "high" }, + subagent: subtask, }, - }).commands, - ).toEqual({ - review: { - template: "Review changes", - description: "Review code", - agent: "reviewer", - model: { providerID: "anthropic", model: "claude", variant: "high" }, - subtask: true, - }, + }) }) - }) + } test("normalizes renamed permission actions when migrating v1 permissions", () => { expect( diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index 5f22a8e80bf..dedb1c8928f 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -21,6 +21,7 @@ import { Provider } from "@opencode-ai/core/provider" import { Reference } from "@opencode-ai/core/reference" import { Skill } from "@opencode-ai/core/skill" import { ShellSelect } from "@opencode-ai/core/shell/select" +import { Job } from "@opencode-ai/core/job" import { Global } from "@opencode-ai/util/global" import { Location } from "@opencode-ai/core/location" import { Credential } from "@opencode-ai/core/credential" @@ -37,7 +38,7 @@ import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" const it = testEffect( - Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))), + Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node, Job.node]))), ) const decode = Schema.decodeUnknownSync(Info) const document = path.join(import.meta.dir, "opencode.json") diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 97b3282be2d..345ee740ca9 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -8890,7 +8890,8 @@ { "type": "null" } - ] + ], + "description": "An absolute path or a path relative to the requested location. Defaults to the location directory." }, "required": false } @@ -8941,7 +8942,7 @@ } } }, - "description": "List direct children of one directory relative to the requested location.", + "description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.", "summary": "List directory" } }, @@ -13777,8 +13778,12 @@ } ] }, - "subtask": { + "subagent": { "type": "boolean" + }, + "subtask": { + "type": "boolean", + "description": "Deprecated alias for subagent." } }, "required": ["template"], diff --git a/packages/schema/src/config/command.ts b/packages/schema/src/config/command.ts index 511b5984174..01c1e0a47ac 100644 --- a/packages/schema/src/config/command.ts +++ b/packages/schema/src/config/command.ts @@ -9,5 +9,6 @@ export class Info extends Schema.Class("Config.Command")({ description: Schema.String.pipe(optional), agent: Schema.String.pipe(optional), model: ConfigModel.Selection.pipe(optional), - subtask: Schema.Boolean.pipe(optional), + subagent: Schema.Boolean.pipe(optional), + subtask: Schema.Boolean.annotate({ description: "Deprecated alias for subagent." }).pipe(optional), }) {} diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 97b3282be2d..345ee740ca9 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -8890,7 +8890,8 @@ { "type": "null" } - ] + ], + "description": "An absolute path or a path relative to the requested location. Defaults to the location directory." }, "required": false } @@ -8941,7 +8942,7 @@ } } }, - "description": "List direct children of one directory relative to the requested location.", + "description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.", "summary": "List directory" } }, @@ -13777,8 +13778,12 @@ } ] }, - "subtask": { + "subagent": { "type": "boolean" + }, + "subtask": { + "type": "boolean", + "description": "Deprecated alias for subagent." } }, "required": ["template"], diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 97b3282be2d..345ee740ca9 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -8890,7 +8890,8 @@ { "type": "null" } - ] + ], + "description": "An absolute path or a path relative to the requested location. Defaults to the location directory." }, "required": false } @@ -8941,7 +8942,7 @@ } } }, - "description": "List direct children of one directory relative to the requested location.", + "description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.", "summary": "List directory" } }, @@ -13777,8 +13778,12 @@ } ] }, - "subtask": { + "subagent": { "type": "boolean" + }, + "subtask": { + "type": "boolean", + "description": "Deprecated alias for subagent." } }, "required": ["template"], diff --git a/packages/www/src/docs/content/commands.mdx b/packages/www/src/docs/content/commands.mdx index 01fd860dc58..4cc0a955628 100644 --- a/packages/www/src/docs/content/commands.mdx +++ b/packages/www/src/docs/content/commands.mdx @@ -55,15 +55,16 @@ Add commands under the `commands` key in any OpenCode JSON or JSONC ## Fields -| Field | Required | Behavior | -| ------------- | --------- | ---------------------------------------------------------------------- | -| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. | -| `description` | No | Text shown with the command in command listings and discovery. | -| `agent` | No | Agent activated before the prompt runs. | -| `model` | No | Model override in `provider/model` or `provider/model#variant` format. | -| `subtask` | No | Accepted as a boolean, but currently has no execution effect in V2. | +| Field | Required | Behavior | +| ------------- | --------- | --------------------------------------------------------------------------------- | +| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. | +| `description` | No | Text shown with the command in command listings and discovery. | +| `agent` | No | Agent that runs the command. | +| `model` | No | Model override in `provider/model` or `provider/model#variant` format. | +| `subagent` | No | Run in a background child session, or use `false` to stay in the current session. | +| `subtask` | No | Deprecated alias for `subagent`. | -The four optional fields can be used in JSON or YAML frontmatter. Do not put +The optional fields can be used in JSON or YAML frontmatter. Do not put `template` in frontmatter because the Markdown body always supplies it. ## Arguments @@ -128,16 +129,33 @@ automatically attach that file. ## Agent, model, and execution -Running a command evaluates its arguments and shell blocks, submits the result -as a durable user prompt in the current session, and schedules normal model -execution. +Commands evaluate their arguments and shell blocks before submitting a durable +user prompt. Commands run in the current session unless background delegation +is enabled as described below. -If `agent` is set, it overrides the active agent when the command is invoked +For current-session commands, `agent` overrides the active agent when the command is invoked and becomes the session's active agent. If `model` is set, it overrides the model. Otherwise, a model configured on the command's agent takes precedence over the model active at invocation. -Although `subtask` is accepted in JSON and frontmatter, V2 currently ignores -it: commands run in the current session and do not create a child session. -Selecting an agent whose mode is `subagent` also does not turn the command into -a subtask. +### Background subagents + +Set `subagent: true` to run a command in a background child session. The parent +keeps its agent and model, stays available for other work, and receives the +child's result or failure when it finishes. + +```md title=".opencode/commands/review.md" +--- +description: Review changes in the background +agent: general +subagent: true +--- + +Review $ARGUMENTS for bugs and missing tests. +``` + +- `true` forces child execution, including for an agent with `mode: primary`. +- `false` forces execution in the current session. +- When omitted, a command targeting an agent with `mode: subagent` runs in the background. +- The child uses the command's model override, then the selected agent's model, then the parent's model. +- Legacy `subtask` is still accepted in JSON and Markdown. If both fields are present, `subagent` takes precedence. diff --git a/packages/www/src/docs/content/migrate-v1.mdx b/packages/www/src/docs/content/migrate-v1.mdx index 0a2ccf3ae03..838ea805cc1 100644 --- a/packages/www/src/docs/content/migrate-v1.mdx +++ b/packages/www/src/docs/content/migrate-v1.mdx @@ -272,7 +272,7 @@ Existing skill files and automatic `.opencode/skills/` discovery do not change. ### Commands -Rename the singular `command` map to `commands`. Join a separate model `variant` to the model reference: +Rename the singular `command` map to `commands` and `subtask` to `subagent`. Join a separate model `variant` to the model reference: ```jsonc // V1 @@ -281,7 +281,8 @@ Rename the singular `command` map to `commands`. Join a separate model `variant` "review": { "template": "Review the current changes.", "model": "anthropic/claude-sonnet-4-5", - "variant": "high" + "variant": "high", + "subtask": true } } } @@ -291,13 +292,15 @@ Rename the singular `command` map to `commands`. Join a separate model `variant` "commands": { "review": { "template": "Review the current changes.", - "model": "anthropic/claude-sonnet-4-5#high" + "model": "anthropic/claude-sonnet-4-5#high", + "subagent": true } } } ``` -`template`, `description`, `agent`, and `subtask` keep their names. Existing Markdown command definitions remain supported. +`template`, `description`, and `agent` keep their names. Legacy `subtask` remains accepted; delegated commands now run +automatically in the background and report their results to the parent session. Existing Markdown command definitions remain supported. See [Commands](/commands). ### References @@ -462,16 +465,19 @@ V1 command files may use `command/` or `commands/`. V2 discovers both. The prefe ``` Move files from `command/` to the same relative path under `commands/` to preserve command names. The Markdown body remains -the command template, and `description`, `agent`, and `subtask` frontmatter keep the same names. If frontmatter has separate +the command template, and `description` and `agent` frontmatter keep the same names. Rename `subtask` to `subagent` to use +the native name for background delegation. If frontmatter has separate `model` and `variant` fields, append the variant to the model and remove `variant`: ```yaml # V1 model: anthropic/claude-sonnet-4-5 variant: high +subtask: true # V2 model: anthropic/claude-sonnet-4-5#high +subagent: true ``` See [Commands](/commands).