mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 15:36:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae724a28d | ||
|
|
aa90a15069 |
@@ -1971,6 +1971,7 @@ export type ConfigEntry =
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
subagent?: boolean
|
||||
subtask?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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<Session.Interface, "synthetic">,
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Job.Recovery, { kind: "subagent" }>
|
||||
|
||||
interface Runner {
|
||||
start: (recovery: Recovery) => Effect.Effect<Job.Info>
|
||||
background: (recovery: Recovery) => Effect.Effect<void>
|
||||
notify: (recovery: Recovery, startedAt: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const make: Effect.Effect<Runner, never, Session.Service | Job.Service | Scope.Scope> = 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<string>()
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -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<string>()
|
||||
|
||||
// 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<typeof part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
|
||||
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,
|
||||
|
||||
@@ -172,7 +172,7 @@ export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>)
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: modelSelection(command.model, command.variant),
|
||||
subtask: command.subtask,
|
||||
subagent: command.subtask,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Fiber, Layer, Schedule, Stream } 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 { Bus } from "@opencode-ai/core/bus"
|
||||
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 { Job } from "@opencode-ai/core/job"
|
||||
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 { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
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, Job.node, Bus.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) =>
|
||||
session.model?.id === "missing"
|
||||
? Effect.fail(new SessionRunnerModel.ModelNotSelectedError({ sessionID: session.id }))
|
||||
: 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: "reviewer", model: "test/override" },
|
||||
agent: "reviewer",
|
||||
model: "override",
|
||||
},
|
||||
{
|
||||
name: "legacy JSON",
|
||||
format: "v1",
|
||||
command: { subtask: true, agent: "build" },
|
||||
agent: "build",
|
||||
model: "parent",
|
||||
},
|
||||
{
|
||||
name: "native Markdown",
|
||||
format: "markdown",
|
||||
command: { subagent: true, agent: "reviewer" },
|
||||
agent: "reviewer",
|
||||
model: "child",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "forced primary agent",
|
||||
format: "json",
|
||||
command: { subagent: true, agent: "build" },
|
||||
agent: "build",
|
||||
model: "parent",
|
||||
},
|
||||
{ name: "inherited active agent", format: "json", command: { subagent: true }, agent: "build", model: "parent" },
|
||||
] 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 jobs = yield* Job.Service
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* TestLLM.Test
|
||||
const gate = yield* llm.gate()
|
||||
const completed = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === parent.id && event.data.item.type === "synthetic"),
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
// 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" },
|
||||
])
|
||||
expect(yield* jobs.pendingBackground).toMatchObject([
|
||||
{
|
||||
id: child.id,
|
||||
status: "running",
|
||||
recovery: { kind: "subagent", parentSessionID: parent.id, childSessionID: child.id },
|
||||
},
|
||||
])
|
||||
|
||||
yield* gate.release
|
||||
const notification = (yield* Fiber.join(completed))[0]
|
||||
expect(notification?.data.item).toMatchObject({
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
metadata: { source: "subagent", childID: child.id, state: "completed" },
|
||||
},
|
||||
})
|
||||
if (notification?.data.item.type !== "synthetic") return yield* Effect.die("Expected a completion notification")
|
||||
expect(notification.data.item.payload.text).toContain("Review complete")
|
||||
yield* jobs.pendingBackground.pipe(
|
||||
Effect.repeat({ until: (pending) => pending.length === 0, schedule: Schedule.spaced("10 millis") }),
|
||||
)
|
||||
yield* sessions.wait(parent.id)
|
||||
expect(yield* jobs.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const command of [
|
||||
{ subagent: false, agent: "reviewer" },
|
||||
{ subtask: false, agent: "reviewer" },
|
||||
{ subagent: false, subtask: true, agent: "reviewer" },
|
||||
{ agent: "build" },
|
||||
]) {
|
||||
it.live(`keeps ${JSON.stringify(command)} in the current session`, () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project(command, "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: command.agent,
|
||||
model: { id: command.agent === "reviewer" ? "child" : "parent" },
|
||||
})
|
||||
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "user")).toMatchObject([
|
||||
{ text: "Review changes: ready" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("delivers background failures to the parent", () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project({ subagent: true, model: "test/missing" }, "json")
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const completed = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === parent.id && event.data.item.type === "synthetic"),
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
expect((yield* Fiber.join(completed))[0]?.data.item).toMatchObject({
|
||||
type: "synthetic",
|
||||
payload: { metadata: { source: "subagent", state: "error" } },
|
||||
})
|
||||
yield* sessions.wait(parent.id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function project(
|
||||
command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean },
|
||||
format: "json" | "v1" | "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" ? {} : { [format === "v1" ? "command" : "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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -9,5 +9,6 @@ export class Info extends Schema.Class<Info>("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),
|
||||
}) {}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import type { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { Config, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { EmbeddedHost } from "../internal/host"
|
||||
import type { SdkInstances } from "../internal/instances"
|
||||
|
||||
@@ -35,12 +35,9 @@ export const create: <R = never>(
|
||||
R = never,
|
||||
>(options: CreateOptions<R> = {}, embed: EmbedOptions = {}) {
|
||||
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
|
||||
const httpClient = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
// FetchHttpClient reads Fetch at request time; callers must not replace this host's in-process transport.
|
||||
HttpClient.transformResponse(httpClient, Effect.provideService(FetchHttpClient.Fetch, host.fetch)),
|
||||
Effect.provide(
|
||||
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Context, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { AbsolutePath, Location, OpenCode, Session } from "../src/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
for (const entrypoint of ["create", "layer"] as const) {
|
||||
it.live(`${entrypoint} keeps requests and streams on its own transport despite an ambient Fetch`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const calls: string[] = []
|
||||
const ambient = Object.assign(
|
||||
(input: RequestInfo | URL) => {
|
||||
calls.push(input instanceof Request ? input.url : String(input))
|
||||
return Promise.reject(new Error("The caller's Fetch must not receive embedded SDK requests"))
|
||||
},
|
||||
{ preconnect: () => undefined },
|
||||
)
|
||||
const parent = yield* Effect.scope
|
||||
const scope = yield* Scope.fork(parent)
|
||||
const options: OpenCode.CreateOptions = {
|
||||
app: { version: "transport-test" },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
events: { persist: true },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
}
|
||||
const client = yield* (
|
||||
entrypoint === "create"
|
||||
? OpenCode.create(options).pipe(Scope.provide(scope))
|
||||
: Layer.buildWithScope(OpenCode.layer(options), scope).pipe(Effect.map(Context.get(OpenCode.Service)))
|
||||
).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
expect(yield* client.health.get()).toMatchObject({ healthy: true, version: "transport-test" })
|
||||
const session = yield* client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
|
||||
})
|
||||
expect((yield* client.sessions.get({ sessionID: session.id })).id).toBe(session.id)
|
||||
const events = yield* client.sessions.log({ sessionID: session.id }).pipe(Stream.runCollect)
|
||||
expect(events.some((event) => event.type === "session.created")).toBe(true)
|
||||
expect(yield* client.events.subscribe().pipe(Stream.take(1), Stream.runCollect)).toMatchObject([
|
||||
{ type: "server.connected" },
|
||||
])
|
||||
expect(yield* client.sessions.get({ sessionID: Session.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "SessionNotFoundError",
|
||||
})
|
||||
// Binding the SDK's transport must not change the caller's surrounding context.
|
||||
expect(yield* FetchHttpClient.Fetch).toBe(ambient)
|
||||
}).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
expect(calls).toEqual([])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* client.health.get().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user