Compare commits

..
Author SHA1 Message Date
Aiden Cline 958e7f77f6 Merge remote-tracking branch 'origin/v2' into subagent-command
# Conflicts:
#	packages/core/src/config/plugin/command.ts
2026-07-03 23:04:15 -05:00
Aiden ClineandGitHub 3baaabede8 fix(core): resolve mcp header env placeholders (#35236) 2026-07-03 23:00:10 -05:00
Dax Raad afe3ebbc35 fix(core): bust external plugin import cache 2026-07-03 23:12:09 -04:00
Kit LangtonandGitHub e2faeb84e5 fix(core): tolerate minimal FSWatcher typings (#35264) 2026-07-03 22:37:42 -04:00
Kit LangtonandGitHub 35ed09ff37 fix(tui): improve MCP error details (#35263) 2026-07-03 22:26:54 -04:00
Aiden Clineandopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> 56eddcfa1f feat(core): run subagent commands in background
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-03 20:31:39 +00:00
24 changed files with 629 additions and 182 deletions
@@ -3778,7 +3778,7 @@ export type CommandListOutput = {
readonly description?: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly subtask?: boolean
readonly subagent?: boolean
}>
}
+22 -2
View File
@@ -23,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
@@ -148,15 +149,16 @@ const layer = Layer.effect(
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
if (errors.length) return
const info = Option.getOrUndefined(
ConfigMigrateV1.isV1(input)
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
: decodeInfo(input),
: decodeInfo(normalizeCommandAliases(input)),
)
if (!info) return
return new Document({ type: "document", path: filepath, info })
@@ -257,3 +259,21 @@ export const node = makeLocationNode({
layer,
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
})
function normalizeCommandAliases(input: unknown) {
if (typeof input !== "object" || input === null || Array.isArray(input)) return input
const commands = (input as Record<string, unknown>).commands
if (typeof commands !== "object" || commands === null || Array.isArray(commands)) return input
return {
...input,
commands: Object.fromEntries(
Object.entries(commands).map(([name, command]) => {
if (typeof command !== "object" || command === null || Array.isArray(command)) return [name, command]
const data = command as Record<string, unknown>
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return [name, command]
const { subtask, ...rest } = data
return [name, { ...rest, subagent: subtask }]
}),
),
}
}
+1 -1
View File
@@ -8,5 +8,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Command")({
agent: Schema.String.pipe(Schema.optional),
model: Schema.String.pipe(Schema.optional),
variant: Schema.String.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
subagent: Schema.Boolean.pipe(Schema.optional),
}) {}
+10 -2
View File
@@ -42,7 +42,7 @@ export const Plugin = define({
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
if (command.subagent !== undefined) item.subagent = command.subagent
})
}
}
@@ -81,7 +81,9 @@ function loadDirectory(fs: FSUtil.Interface, directory: string) {
function decode(directory: string, filepath: string, content: string) {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return
const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() }))
const info = Option.getOrUndefined(
decodeCommand({ ...normalizeFrontmatter(markdown.data), template: markdown.content.trim() }),
)
if (!info) return
return {
name: path
@@ -92,3 +94,9 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function normalizeFrontmatter(data: Record<string, unknown>) {
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return data
const { subtask, ...rest } = data
return { ...rest, subagent: subtask }
}
+17 -2
View File
@@ -3,6 +3,7 @@ export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema, Stream } from "effect"
import { createRequire } from "node:module"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
@@ -35,6 +36,9 @@ const PluginPackage = Schema.Struct({
module: Schema.optional(Schema.String),
})
let importGeneration = 0
const moduleCache = createRequire(import.meta.url).cache
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
@@ -106,7 +110,7 @@ export const Plugin = define({
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(entrypoint))
const mod = yield* Effect.promise(() => import(cacheBust(entrypoint)))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
@@ -114,7 +118,11 @@ export const Plugin = define({
effect: (host: Parameters<typeof plugin.effect>[0]) =>
plugin.effect({ ...host, options: ref.options ?? {} }),
}
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
),
),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
@@ -137,6 +145,13 @@ export const Plugin = define({
}),
})
function cacheBust(entrypoint: string) {
const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint)
if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)]
url.searchParams.set("opencode-reload", String(++importGeneration))
return url.href
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
+85
View File
@@ -0,0 +1,85 @@
export * as ConfigVariable from "./variable"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "../fs-util"
import { InvalidError } from "../v1/config/error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
const text = input.text.replace(
/\{env:([^}]+)\}/g,
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
)
if (!text.includes("{file:")) return text
return yield* substituteFiles(input, text)
})
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
const fs = yield* FSUtil.Service
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
let out = ""
let cursor = 0
for (const match of matches) {
const token = match[0]
const index = match.index
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((error) => {
if (input.missing === "empty") return Effect.succeed("")
const message = `bad file reference: "${token}"`
return Effect.fail(
new InvalidError(
{
path: configSource,
message:
error._tag === "PlatformError" && error.reason._tag === "NotFound"
? `${message} ${resolvedPath} does not exist`
: message,
},
{ cause: error },
),
)
}),
)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
cursor = index + token.length
}
return out + text.slice(cursor)
})
+5 -3
View File
@@ -89,9 +89,11 @@ const layer = Layer.effect(
type: "update",
} satisfies Update)
})
subscription.on("error", (error) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })),
)
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })),
)
}
return { unsubscribe: () => Promise.resolve(subscription.close()) }
})
: subscribeDirectory(native, backend, directory, ignore, pubsub)
+1
View File
@@ -18,6 +18,7 @@ export const Plugin = define({
draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subagent = true
})
})
}),
@@ -303,7 +303,8 @@ model: anthropic/claude-sonnet-4-6
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
- Optional: `description`, `agent`, `model`, `variant`, `subagent`.
- `subtask` is still accepted for older commands and is treated like `subagent`.
## Plugins
+74 -7
View File
@@ -45,6 +45,10 @@ import { Shell } from "./shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
const SUBAGENT_COMMAND_STARTED =
"The command is running in a background subagent session. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
const SUBAGENT_COMMAND_NO_TEXT = "Subagent command completed without a text response."
export const RevertState = Revert.State
export type RevertState = Revert.State
@@ -511,14 +515,77 @@ const layer = Layer.effect(
})
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
const commandAgent = command.agent ? yield* agents.get(AgentV2.ID.make(command.agent)) : undefined
const model = command.model ?? commandAgent?.model ?? input.model ?? session.model
const subagent = command.subagent ?? false
if (subagent) {
const childAgent = AgentV2.ID.make(command.agent ?? "general")
const childAgentInfo = yield* agents.get(childAgent)
const title = command.description ?? input.command
const child = yield* result.create({
parentID: input.sessionID,
title,
agent: childAgent,
model: command.model ?? childAgentInfo?.model ?? input.model ?? session.model,
})
const completion = (state: "completed" | "error" | "cancelled", text: string) =>
result.synthetic({
sessionID: input.sessionID,
text: `<subagent id="${child.id}" state="${state}" description="${title}">\n${text}\n</subagent>`,
description: command.description,
metadata: { command: input.command, subagent: true, sessionID: child.id },
})
const admitted = yield* result.prompt({
id: input.id,
sessionID: child.id,
prompt: { text: evaluated.text, files: input.files, agents: input.agents },
delivery: input.delivery,
resume: false,
})
yield* jobs.start({
id: child.id,
type: "subagent",
title,
metadata: { command: input.command, parentID: input.sessionID, agent: childAgent.toString() },
run: Effect.gen(function* () {
yield* result.resume(child.id)
const messages = yield* result.messages({ sessionID: child.id, 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 SUBAGENT_COMMAND_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 : SUBAGENT_COMMAND_NO_TEXT
}).pipe(Effect.onInterrupt(() => result.interrupt(child.id))),
})
yield* jobs.background(child.id)
yield* jobs.wait({ id: child.id }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return completion("completed", result.info.output ?? SUBAGENT_COMMAND_NO_TEXT)
if (result.info?.status === "error")
return completion("error", result.info.error ?? "Subagent command failed")
if (result.info?.status === "cancelled") return completion("cancelled", "Subagent command cancelled")
return Effect.void
}),
Effect.forkIn(scope, { startImmediately: true }),
)
yield* result.synthetic({
sessionID: input.sessionID,
text: `<subagent id="${child.id}" state="running" description="${title}">\n${SUBAGENT_COMMAND_STARTED}\n</subagent>`,
description: command.description,
metadata: { command: input.command, subagent: true, sessionID: child.id },
})
return admitted
}
const agent = command.agent ?? input.agent
const commandAgent = yield* Effect.gen(function* () {
if (!command.agent) return undefined
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* agents.get(AgentV2.ID.make(command.agent))
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
+12 -1
View File
@@ -72,7 +72,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: info.command,
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
plugins: info.plugin?.map((plugin) =>
@@ -82,6 +82,17 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
}
}
function commands(info?: typeof ConfigV1.Info.Type.command) {
if (!info) return undefined
return Object.fromEntries(
Object.entries(info).map(([name, command]) => {
if (command?.subtask === undefined) return [name, command]
const { subtask, ...rest } = command
return [name, { ...rest, subagent: subtask }]
}),
)
}
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
tools ?? {},
+10 -2
View File
@@ -46,9 +46,16 @@ description: File review
agent: reviewer
model: anthropic/claude
variant: high
subtask: true
subagent: true
---
Review files`,
)
await fs.writeFile(
path.join(tmp.path, "commands", "legacy.md"),
`---
subtask: true
---
Legacy review`,
)
await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs")
await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
@@ -94,9 +101,10 @@ Review files`,
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
subtask: true,
subagent: true,
}),
CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "legacy", template: "Legacy review", subagent: true }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
])
+67 -1
View File
@@ -199,7 +199,7 @@ describe("Config", () => {
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
subagent: true,
},
})
}),
@@ -292,6 +292,72 @@ describe("Config", () => {
),
)
it.live("substitutes environment variables and relative file contents", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
token: process.env.OPENCODE_TEST_MCP_TOKEN,
missing: process.env.OPENCODE_TEST_MISSING,
}
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
delete process.env.OPENCODE_TEST_MISSING
return previous
}),
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Ignored reference: {file:missing.txt}
"username": "user-{env:OPENCODE_TEST_MISSING}",
"mcp": {
"servers": {
"remote": {
"type": "remote",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
"X-Token": "{file:token.txt}"
}
}
}
}
}`,
),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const document = (yield* config.entries()).find((entry) => entry.type === "document")
expect(document?.info.username).toBe("user-")
const remote = document?.info.mcp?.servers?.remote
expect(remote?.type).toBe("remote")
if (remote?.type !== "remote") return
expect(remote.headers).toEqual({
Authorization: "Bearer secret",
"X-Token": 'file\n"token"',
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
(previous) =>
Effect.sync(() => {
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
else process.env.OPENCODE_TEST_MISSING = previous.missing
}),
),
)
it.live("does not load legacy config.json files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+73
View File
@@ -1,15 +1,19 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -240,6 +244,49 @@ describe("ConfigExternalPlugin", () => {
})
}),
)
it.live("reloads changed plugin source from the same entrypoint", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const fsUtil = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
const plugin = path.join(tmp.path, "plugin.ts")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ plugins: [plugin] }),
}),
]),
})
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("First source")))
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fsUtil),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(Config.Service, config),
)
expect((yield* waitForAgent(agents, "hot-reload"))?.description).toBe("First source")
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("Second source")))
yield* events.publish(ConfigSchema.Event.Updated, {})
expect(yield* waitForAgentDescription(agents, "hot-reload", "Second source")).toBe(true)
}),
),
),
)
})
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
@@ -250,3 +297,29 @@ const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id:
}
return yield* Effect.die(`Timed out waiting for agent ${id}`)
})
const waitForAgentDescription = Effect.fnUntraced(function* (
agents: AgentV2.Interface,
id: string,
description: string,
) {
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* agents.get(AgentV2.ID.make(id)))?.description === description) return true
yield* Effect.sleep("10 millis")
}
return false
})
function pluginSource(description: string) {
return `export default {
id: "source-hot-reload",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("hot-reload", (agent) => {
agent.description = ${JSON.stringify(description)}
agent.mode = "subagent"
})
})
},
}`
}
@@ -44,6 +44,7 @@ describe("CommandPlugin.Plugin", () => {
expect(yield* command.get("review")).toMatchObject({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
subagent: true,
})
}),
)
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { CommandV2 } from "@opencode-ai/core/command"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
Job.node,
SessionProjector.node,
SessionStore.node,
SessionV2.node,
LocationServiceMap.node,
]),
[
[ProjectV2.node, projects],
[
SessionExecution.node,
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.never,
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
),
],
],
),
)
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.make("anthropic") })
function withTmp<A, E, R>(f: (location: Location.Ref) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
}
describe("SessionV2.command", () => {
it.effect("runs subagent commands as background child sessions", () =>
withTmp((location) =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const parent = yield* sessions.create({ location, model })
const locations = yield* LocationServiceMap.Service
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* commands.transform((draft) => {
draft.update("review", (command) => {
command.template = "Review this"
command.description = "review changes"
command.agent = "reviewer"
command.subagent = true
})
})
const admitted = yield* sessions.command({ sessionID: parent.id, command: "review" })
const children = yield* sessions.list({ parentID: parent.id })
expect(children.data).toHaveLength(1)
expect(children.data[0]).toMatchObject({
parentID: parent.id,
title: "review changes",
agent: AgentV2.ID.make("reviewer"),
model,
})
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { text: "Review this" } })
expect(yield* Job.Service.use((jobs) => jobs.get(children.data[0]!.id))).toMatchObject({
id: children.data[0]!.id,
type: "subagent",
status: "running",
})
expect(yield* sessions.messages({ sessionID: parent.id })).toEqual([
expect.objectContaining({ type: "synthetic", text: expect.stringContaining(children.data[0]!.id) }),
])
}),
),
)
it.effect("defaults subagent commands without an agent to the general agent", () =>
withTmp((location) =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const parent = yield* sessions.create({ location })
const locations = yield* LocationServiceMap.Service
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* commands.transform((draft) => {
draft.update("research", (command) => {
command.template = "Legacy task"
command.subagent = true
})
})
const admitted = yield* sessions.command({ sessionID: parent.id, command: "research" })
const children = yield* sessions.list({ parentID: parent.id })
expect(children.data).toHaveLength(1)
expect(children.data[0]).toMatchObject({ agent: AgentV2.ID.make("general") })
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { text: "Legacy task" } })
}),
),
)
})
+1 -1
View File
@@ -14,7 +14,7 @@ export const Info = Schema.Struct({
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: Model.Ref.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
subagent: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "CommandV2.Info" })
export const Event = {
+2 -2
View File
@@ -5512,7 +5512,7 @@ export type CommandV2Info = {
description?: string
agent?: string
model?: ModelRef
subtask?: boolean
subagent?: boolean
}
export type SkillV2Info = {
@@ -9513,7 +9513,7 @@ export type CommandV2Info2 = {
description?: string
agent?: string
model?: ModelRef2
subtask?: boolean
subagent?: boolean
}
export type SkillV2Info2 = {
+2 -2
View File
@@ -409,8 +409,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
else
toast.show({
variant: "error",
title: "MCP server failed to connect",
message: `${server.name}: ${status.error}`,
title: `MCP server failed: ${server.name}`,
message: "Open MCPs to view details.",
})
}
})
+106 -26
View File
@@ -1,12 +1,17 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useData } from "../context/data"
import { pipe, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme, type Theme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import type { McpServer } from "@opencode-ai/sdk/v2"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { useBindings } from "../keymap"
// Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last.
@@ -31,9 +36,8 @@ export function DialogMcp() {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const [focused, setFocused] = createSignal<string>()
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
const [detail, setDetail] = createSignal<McpServer>()
onMount(() => {
dialog.setSize("large")
@@ -66,9 +70,6 @@ export function DialogMcp() {
{meta.icon} {meta.label}
</span>
),
details: meta.error && expanded[server.name] ? [meta.error] : undefined,
detailsColor: theme.error,
detailsWrap: true,
}
}),
)
@@ -79,24 +80,103 @@ export function DialogMcp() {
return server ? statusMeta(server.status, theme).error : undefined
})
const open = (name: string | undefined) => {
const server = servers().find((entry) => entry.name === name)
if (!server || !statusMeta(server.status, theme).error) return
setDetail(server)
}
return (
<DialogSelect
ref={setRef}
title="MCPs"
options={options()}
preserveSelection
onMove={(option) => setFocused(option.value as string)}
onSelect={(option) => {
const name = option.value as string
const server = servers().find((entry) => entry.name === name)
if (!server || !statusMeta(server.status, theme).error) return
setExpanded(name, (open) => !open)
}}
footer={
<Show when={focusedError()}>
<text fg={theme.textMuted}>enter to {expanded[focused()!] ? "hide" : "view"} error</text>
</Show>
}
/>
<box>
<Show
when={detail()}
fallback={
<DialogSelect
title="MCPs"
options={options()}
current={focused()}
preserveSelection
onMove={(option) => setFocused(option.value as string)}
onSelect={(option) => open(option.value as string)}
footer={
<Show when={focusedError()}>
<text fg={theme.textMuted}>enter to view error</text>
</Show>
}
/>
}
>
{(server) => <DialogMcpError server={server()} onBack={() => setDetail()} />}
</Show>
</box>
)
}
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const tuiConfig = useTuiConfig()
const [copied, setCopied] = createSignal(false)
const error = () => statusMeta(props.server.status, theme).error ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
if (!clipboard.write) return
void clipboard
.write(error())
.then(() => setCopied(true))
.catch(toast.error)
}
useBindings(() => ({
bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
MCP / {props.server.name}
</text>
<text fg={theme.textMuted} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.error}> Failed</text>
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(tuiConfig)}
>
<text fg={theme.text} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}> scroll</text>
<text fg={theme.textMuted} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
@@ -160,7 +160,6 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const compaction = createMemo(() => data.session.compaction.get(props.sessionID ?? ""))
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
return data.session.family(props.sessionID).filter(
@@ -1638,18 +1637,6 @@ export function Prompt(props: PromptProps) {
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={compaction()}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Spinner>Compacting conversation...</Spinner>
</box>
<Show when={compaction() === "auto"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</Show>
</box>
</Match>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
+2 -41
View File
@@ -20,13 +20,12 @@ import type {
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
type CompactionReason = Extract<V2Event, { type: "session.compaction.started" }>["data"]["reason"]
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
@@ -52,7 +51,6 @@ type Data = {
// session ID in that family, including the key itself once its info arrives.
family: Record<string, string[]>
status: Record<string, DataSessionStatus>
compaction: Record<string, CompactionReason>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
@@ -87,7 +85,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
info: {},
family: {},
status: {},
compaction: {},
message: {},
permission: {},
question: {},
@@ -203,25 +200,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
const startCompaction = (sessionID: string, reason: CompactionReason) => {
if (store.session.status[sessionID] !== "running") setStore("session", "status", sessionID, "running")
if (store.session.compaction[sessionID] !== reason)
setStore("session", "compaction", sessionID, reason)
}
const clearCompaction = (sessionID: string, settleManual = true) => {
const reason = store.session.compaction[sessionID]
if (!reason) return
setStore(
"session",
"compaction",
produce((draft) => {
delete draft[sessionID]
}),
)
if (settleManual && reason === "manual") setStore("session", "status", sessionID, "idle")
}
function handleEvent(event: V2Event) {
switch (event.type) {
case "session.created":
@@ -343,7 +321,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.step.started":
clearCompaction(event.data.sessionID, false)
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return
@@ -540,14 +517,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.retried":
setStore("session", "status", event.data.sessionID, "running")
break
case "session.compaction.started":
startCompaction(event.data.sessionID, event.data.reason)
setStore("session", "status", event.data.sessionID, "running")
break
case "session.execution.settled":
setStore("session", "status", event.data.sessionID, "idle")
clearCompaction(event.data.sessionID, false)
break
case "session.revert.staged":
if (store.session.info[event.data.sessionID])
@@ -561,7 +535,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.compaction.delta":
break
case "session.compaction.ended":
clearCompaction(event.data.sessionID)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
@@ -682,17 +655,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status(sessionID: string) {
return store.session.status[sessionID] ?? "idle"
},
compaction: {
get(sessionID: string) {
return store.session.compaction[sessionID]
},
startManual(sessionID: string) {
startCompaction(sessionID, "manual")
},
clearManual(sessionID: string) {
if (store.session.compaction[sessionID] === "manual") clearCompaction(sessionID)
},
},
async refresh(sessionID: string) {
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
registerSession(sessionID)
@@ -878,7 +840,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
setStore("session", "compaction", reconcile({}))
bootstrapping = Promise.allSettled([
sdk.api.session
.list({
+1 -19
View File
@@ -382,25 +382,7 @@ export function Session() {
aliases: ["summarize"],
},
run: () => {
const sessionID = route.sessionID
if (data.session.status(sessionID) === "running") {
toast.show({
variant: "warning",
title: "Compaction unavailable",
message: "Wait for the current session activity to finish.",
})
dialog.clear()
return
}
data.session.compaction.startManual(sessionID)
void sdk.api.session.compact({ sessionID }).catch((error) => {
data.session.compaction.clearManual(sessionID)
toast.show({
variant: "error",
title: "Compaction failed",
message: errorMessage(error),
})
})
void sdk.api.session.compact({ sessionID: route.sessionID })
dialog.clear()
},
},
-55
View File
@@ -310,61 +310,6 @@ test("tracks session status from active sessions and execution events", async ()
})
await wait(() => data.session.status("session-live") === "idle")
emitEvent(events, {
id: "evt_compaction_started",
created: 0,
type: "session.compaction.started",
durable: durable("session-compact"),
data: {
sessionID: "session-compact",
reason: "manual",
},
})
await wait(() => data.session.compaction.get("session-compact") === "manual")
expect(data.session.status("session-compact")).toBe("running")
emitEvent(events, {
id: "evt_compaction_ended",
created: 0,
type: "session.compaction.ended",
durable: durable("session-compact", 1, 2),
data: {
sessionID: "session-compact",
reason: "manual",
text: "Summary",
recent: "",
},
})
await wait(() => data.session.compaction.get("session-compact") === undefined)
expect(data.session.status("session-compact")).toBe("idle")
emitEvent(events, {
id: "evt_auto_compaction_started",
created: 0,
type: "session.compaction.started",
durable: durable("session-auto-compact"),
data: {
sessionID: "session-auto-compact",
reason: "auto",
},
})
await wait(() => data.session.compaction.get("session-auto-compact") === "auto")
emitEvent(events, {
id: "evt_post_compaction_step",
created: 0,
type: "session.step.started",
durable: durable("session-auto-compact", 1, 2),
data: {
sessionID: "session-auto-compact",
assistantMessageID: "message-after-compaction",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.compaction.get("session-auto-compact") === undefined)
expect(data.session.status("session-auto-compact")).toBe("running")
emitEvent(events, {
id: "evt_failed_step_started",
created: 0,