Skip to content

Commit 5fae7ab

Browse files
mguttmannclaudeVasyaYovbak
committed
feat(workflow): tool + context + plugin wiring (PR-5) — workflow tool, session/agent/skill integration
Brings the remaining net-state delta: the workflow TOOL (tool/workflow.ts + workflow.txt) and its registry/task wiring; session integration (system, prompt, tools, processor, mcp-lazy); agent/agent.ts, skill/index.ts; the core plugin skill (plugin/skill.ts + workflows-instructions.md) and core v1/session; acp/service, cli/cmd/run + run/workflow.shared; util/process; opencode/config.json; and the workflow Layer wiring in effect/app-runtime.ts + storage/schema.ts. The workflow engine itself (workflow/{workflow,errors,schema,types}.ts) stays at PR-1's modularized form — its Workflow.* namespace already provides every symbol the tool/session code imports, so nothing in workflow/ is re-touched here. Excluded as base drift (0 workflow content, would revert current dev): all unrelated package.json version bumps, mcp/{catalog,oauth-callback}, plugin/{index, openai/codex,xai}, server/server.ts, stats/*. The 4 net-state workflow migrations remain superseded by PR-1's single consolidated migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: VasyaYovbak <87126061+VasyaYovbak@users.noreply.github.com>
1 parent 6b0c7fd commit 5fae7ab

35 files changed

Lines changed: 6323 additions & 350 deletions

packages/core/src/plugin/skill.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { Effect } from "effect"
77
import { AbsolutePath } from "../schema"
88
import { SkillV2 } from "../skill"
99
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
10+
import workflowsInstructionsContent from "./skill/workflows-instructions.md" with { type: "text" }
1011

1112
export const CustomizeOpencodeContent = customizeOpencodeContent
13+
export const WorkflowsInstructionsContent = workflowsInstructionsContent
1214

1315
export const Plugin = define({
1416
id: "skill",
@@ -26,6 +28,18 @@ export const Plugin = define({
2628
}),
2729
}),
2830
)
31+
draft.source(
32+
new SkillV2.EmbeddedSource({
33+
type: "embedded",
34+
skill: new SkillV2.Info({
35+
name: "workflows-instructions",
36+
description:
37+
"Use when the user asks to create, modify, run, debug, or review opencode workflows. Explains workflow authoring, the native workflow tool, foreground/background execution, permissions, and how to inspect logs, agents, and results. Do not use for ordinary tasks unless the user explicitly wants workflow automation.",
38+
location: AbsolutePath.make("/builtin/workflows-instructions.md"),
39+
content: WorkflowsInstructionsContent,
40+
}),
41+
}),
42+
)
2943
})
3044
}),
3145
})

packages/core/src/plugin/skill/workflows-instructions.md

Lines changed: 398 additions & 0 deletions
Large diffs are not rendered by default.

packages/core/src/v1/session.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,17 @@ export const ContentFilterError = NamedError.create("ContentFilterError", {
5656
message: Schema.String,
5757
})
5858

59-
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
59+
export const OutputFormatText = Schema.Struct({
6060
type: Schema.Literal("text"),
61-
}) {}
61+
}).annotate({ identifier: "OutputFormatText" })
62+
export type OutputFormatText = Schema.Schema.Type<typeof OutputFormatText>
6263

63-
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
64+
export const OutputFormatJsonSchema = Schema.Struct({
6465
type: Schema.Literal("json_schema"),
6566
schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
6667
retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
67-
}) {}
68+
}).annotate({ identifier: "OutputFormatJsonSchema" })
69+
export type OutputFormatJsonSchema = Schema.Schema.Type<typeof OutputFormatJsonSchema>
6870

6971
export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
7072
discriminator: "type",

packages/opencode/config.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"$schema": "https://opencode.ai/config.json"
3+
}

packages/opencode/src/acp/service.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -717,23 +717,28 @@ function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>,
717717

718718
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
719719
return ACPProfile.measure("acp.directory.load", async () => {
720-
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
721-
ACPProfile.measure("acp.directory.provider.list", () =>
722-
sdk.config.providers({ directory }, { throwOnError: true }),
723-
),
724-
ACPProfile.measure("acp.directory.mode.defaultAgent.load", () =>
725-
sdk.app.agents({ directory }, { throwOnError: true }),
726-
),
727-
ACPProfile.measure("acp.directory.command.list", () => sdk.command.list({ directory }, { throwOnError: true })),
728-
ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
729-
ACPProfile.measure("acp.directory.defaultModel.config", () =>
730-
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
731-
),
732-
])
720+
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, workflowsResponse, configResponse] =
721+
await Promise.all([
722+
ACPProfile.measure("acp.directory.provider.list", () =>
723+
sdk.config.providers({ directory }, { throwOnError: true }),
724+
),
725+
ACPProfile.measure("acp.directory.mode.defaultAgent.load", () =>
726+
sdk.app.agents({ directory }, { throwOnError: true }),
727+
),
728+
ACPProfile.measure("acp.directory.command.list", () => sdk.command.list({ directory }, { throwOnError: true })),
729+
ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
730+
ACPProfile.measure("acp.directory.workflow.list", () =>
731+
sdk.workflow.list({ directory }, { throwOnError: true }),
732+
),
733+
ACPProfile.measure("acp.directory.defaultModel.config", () =>
734+
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
735+
),
736+
])
733737
const providersData = providersResponse.data!
734738
const agents = agentsResponse.data!
735739
const commandsData = commandsResponse.data!
736740
const skills = skillsResponse.data!
741+
const workflows = workflowsResponse.data!
737742
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
738743
ProviderV2.ID,
739744
Provider.Info
@@ -761,6 +766,33 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
761766
})),
762767
] as Command.Info[]
763768

769+
// Discovery (Spec §5.3 T-ACP, Task 1): surface workflows in availableCommands
770+
// with an argument hint derived from `meta.arguments`. Workflows reach the ACP
771+
// layer here directly (not via Command.Info, which drops `arguments`) so the
772+
// hint can be built. Invalid (broken) workflows cannot be started → skipped.
773+
// Dedup by name: a workflow already present via command.list (the T-TUI path,
774+
// hint-less) keeps its entry but inherits the hint here so the hint-bearing
775+
// form wins; otherwise the workflow is appended as a new entry.
776+
for (const wf of workflows) {
777+
if (wf.valid === false) continue
778+
const hints = argumentHints(wf.meta.arguments)
779+
const existingIndex = commands.findIndex((command) => command.name === wf.name)
780+
if (existingIndex !== -1) {
781+
const existing = commands[existingIndex]
782+
if ((existing.hints?.length ?? 0) === 0 && hints.length > 0) {
783+
commands[existingIndex] = { ...existing, hints }
784+
}
785+
continue
786+
}
787+
commands.push({
788+
name: wf.name,
789+
description: wf.meta.description,
790+
source: "workflow",
791+
template: "",
792+
hints,
793+
})
794+
}
795+
764796
return Directory.build({
765797
directory,
766798
providers,
@@ -772,6 +804,19 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
772804
})
773805
}
774806

807+
// Build a deterministic, sorted `name=<value>` argument hint for a workflow's
808+
// declared arguments. Empty/absent arguments yield no hint so the `input` field
809+
// is omitted (back-compat for hint-less entries). Sorted output keeps the wire
810+
// response stable across runs (analogous to the stableStringify convention).
811+
function argumentHints(args: Record<string, { default?: unknown }> | undefined): string[] {
812+
if (!args) return []
813+
const hint = Object.keys(args)
814+
.toSorted((a, b) => a.localeCompare(b))
815+
.map((key) => `${key}=<value>`)
816+
.join(" ")
817+
return hint ? [hint] : []
818+
}
819+
775820
function defaultModelFromConfig(
776821
configuredModel: string | undefined,
777822
providers: Record<ProviderV2.ID, Provider.Info>,
@@ -888,10 +933,14 @@ function sendAvailableCommands(
888933
sessionId,
889934
update: {
890935
sessionUpdate: "available_commands_update",
891-
availableCommands: snapshot.availableCommands.map((command) => ({
892-
name: command.name,
893-
description: command.description ?? "",
894-
})),
936+
availableCommands: snapshot.availableCommands.map((command) => {
937+
const hint = command.hints?.[0]
938+
return {
939+
name: command.name,
940+
description: command.description ?? "",
941+
...(hint ? { input: { hint } } : {}),
942+
}
943+
}),
895944
},
896945
})
897946
}, 0)

packages/opencode/src/agent/agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ export const layer = Layer.effect(
163163
task: {
164164
general: "deny",
165165
},
166+
// Workflow subagent sessions run with their own (default: build)
167+
// permissions and would bypass plan mode's edit denies — same
168+
// rationale as the `task.general` deny above (#31696 parity).
169+
workflow: "deny",
166170
external_directory: {
167171
[path.join(Global.Path.data, "plans", "*")]: "allow",
168172
},

packages/opencode/src/cli/cmd/run.ts

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ import { Filesystem } from "@/util/filesystem"
2525
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
2626
import { FormatError, FormatUnknownError } from "../error"
2727
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
28+
import {
29+
detectUltracodeKeyword,
30+
formatParkedQuestion,
31+
parseHeadlessWorkflowArgs,
32+
RUN_ULTRACODE_DIRECTIVE,
33+
stripUltracodeKeyword,
34+
workflowExitCode,
35+
} from "./run/workflow.shared"
2836

2937
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
3038

@@ -144,6 +152,10 @@ export const RunCommand = effectCmd({
144152
describe: "the command to run, use message for args",
145153
type: "string",
146154
})
155+
.option("workflow", {
156+
describe: "run a workflow by name instead of a prompt; positional message becomes key=value args",
157+
type: "string",
158+
})
147159
.option("continue", {
148160
alias: ["c"],
149161
describe: "continue the last session",
@@ -280,6 +292,18 @@ export const RunCommand = effectCmd({
280292
die("--mini must be used without the run subcommand")
281293
}
282294

295+
// Delta 7a: --workflow is an orthogonal start path (not a session prompt),
296+
// so it is mutually exclusive with the session/prompt flags. (Dev renamed
297+
// the interactive split-footer flag to `--mini`; the local `interactive`
298+
// is driven by it, so the interactive exclusion guards on that.)
299+
if (args.workflow) {
300+
if (args.command) die("--workflow cannot be used with --command")
301+
if (interactive) die("--workflow cannot be used with --mini")
302+
if (args.continue) die("--workflow cannot be used with --continue")
303+
if (args.session) die("--workflow cannot be used with --session")
304+
if (args.fork) die("--workflow cannot be used with --fork")
305+
}
306+
283307
if (args.demo && !interactive) {
284308
die("--demo requires --mini")
285309
}
@@ -400,7 +424,8 @@ export const RunCommand = effectCmd({
400424
message = resolveRunInput(message, piped) ?? ""
401425
const initialInput = resolveRunInput(rawMessage, piped)
402426

403-
if (message.trim().length === 0 && !args.command && !interactive) {
427+
// Delta 7b: --workflow needs no prompt message (its positionals are args).
428+
if (message.trim().length === 0 && !args.command && !interactive && !args.workflow) {
404429
UI.error("You must provide a message or a command")
405430
process.exit(1)
406431
}
@@ -650,6 +675,88 @@ export const RunCommand = effectCmd({
650675
return localAgent()
651676
}
652677

678+
// Headless --workflow path (Spec §5.2 (5), Delta 7): orthogonal to sessions.
679+
// Start the workflow via the SDK (start/get ARE in the generated client;
680+
// only `answer` is not — Delta 2), poll to a STOP status (robust in a
681+
// short-lived headless process; the run.* events are not in the SDK either),
682+
// print result/error, and exit with workflowExitCode. No permissionSessionID
683+
// (no interactive session).
684+
//
685+
// Finding 6: `paused` is a NON-terminal status the engine parks to when a
686+
// `ctx.question` step times out waiting for an answer. Headless mode has no
687+
// interactive answerer, so polling for ONLY the terminal statuses would spin
688+
// forever on such a run. We therefore stop polling on `paused` too and, when
689+
// it carries a pending_question, print the question + the exact (resumable)
690+
// answer command and exit with the distinct parked code (2) — we never
691+
// auto-answer.
692+
async function runWorkflow(sdk: OpencodeClient) {
693+
const wfArgs = parseHeadlessWorkflowArgs([...args.message, ...(args["--"] || [])])
694+
const started = await sdk.workflow
695+
.start({ name: args.workflow!, workflowStartPayload: { args: wfArgs } })
696+
.catch((error) => ({ error, data: undefined }) as { error: unknown; data: undefined })
697+
if ((started as { error?: unknown }).error || !started.data) {
698+
const error = (started as { error?: unknown }).error
699+
UI.error(`Failed to start workflow ${args.workflow}: ${formatRunError(error) || "unknown error"}`)
700+
process.exit(1)
701+
}
702+
const id = started.data.id
703+
// `paused` is a stop status here even though the engine treats it as
704+
// non-terminal: a headless run can never be answered, so we must not poll
705+
// past it (Finding 6).
706+
const stop = new Set(["completed", "failed", "cancelled", "interrupted", "paused"])
707+
let final = started.data
708+
while (!stop.has(final.status)) {
709+
await Bun.sleep(500)
710+
const polled = await sdk.workflow.get({ id }).catch(() => undefined)
711+
if (polled?.data) final = polled.data
712+
}
713+
// A run that parked on an unanswerable question gets its own guidance +
714+
// exit code; everything else falls through to the normal result print.
715+
if (final.status === "paused" && final.pending_question) {
716+
const guidance = formatParkedQuestion({
717+
id,
718+
question: final.pending_question.question,
719+
options: final.pending_question.options,
720+
})
721+
if (args.format === "json") {
722+
process.stdout.write(
723+
JSON.stringify({
724+
type: "workflow_parked",
725+
timestamp: Date.now(),
726+
id,
727+
workflow: final.workflow,
728+
status: final.status,
729+
question: final.pending_question.question,
730+
options: final.pending_question.options,
731+
}) + EOL,
732+
)
733+
} else {
734+
UI.error(guidance)
735+
}
736+
process.exitCode = workflowExitCode(final.status)
737+
return
738+
}
739+
if (args.format === "json") {
740+
process.stdout.write(
741+
JSON.stringify({
742+
type: "workflow_finished",
743+
timestamp: Date.now(),
744+
id,
745+
workflow: final.workflow,
746+
status: final.status,
747+
result: final.result,
748+
...(final.error && { error: final.error }),
749+
}) + EOL,
750+
)
751+
} else {
752+
UI.println(`Workflow ${final.workflow} ${final.status}`)
753+
if (final.result !== undefined)
754+
UI.println(typeof final.result === "string" ? final.result : JSON.stringify(final.result, null, 2))
755+
if (final.error) UI.error(final.error)
756+
}
757+
process.exitCode = workflowExitCode(final.status)
758+
}
759+
653760
async function execute(sdk: OpencodeClient) {
654761
const sess = await session(sdk)
655762
if (!sess?.id) {
@@ -839,12 +946,25 @@ export const RunCommand = effectCmd({
839946
}
840947

841948
const model = pick(args.model)
949+
// Ultracode keyword in the headless prompt path (Spec §5.2 (5)): when a
950+
// standalone `ultracode` keyword is present (non-interactive only),
951+
// strip it from the visible prompt and PREPEND the directive as a
952+
// synthetic text part, mirroring the TUI prompt submit. Default-on like
953+
// the TUI (config.workflows.ultracode_keyword is not easily read here
954+
// before the workflow loads — Delta 6a note); a non-matching message is
955+
// untouched.
956+
const ultracode = detectUltracodeKeyword(message)
957+
const promptText = ultracode ? stripUltracodeKeyword(message) : message
842958
const result = await client.session.prompt({
843959
sessionID,
844960
agent,
845961
model,
846962
variant: args.variant,
847-
parts: [...files, { type: "text", text: message }],
963+
parts: [
964+
...(ultracode ? [{ type: "text" as const, text: RUN_ULTRACODE_DIRECTIVE }] : []),
965+
...files,
966+
{ type: "text" as const, text: promptText },
967+
],
848968
})
849969
if (result.error) {
850970
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
@@ -920,7 +1040,7 @@ export const RunCommand = effectCmd({
9201040

9211041
if (args.attach) {
9221042
const sdk = attachSDK(directory)
923-
return await execute(sdk)
1043+
return args.workflow ? await runWorkflow(sdk) : await execute(sdk)
9241044
}
9251045

9261046
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -936,6 +1056,7 @@ export const RunCommand = effectCmd({
9361056
fetch: fetchFn,
9371057
directory,
9381058
})
1059+
if (args.workflow) return await runWorkflow(sdk)
9391060
await execute(sdk)
9401061
})
9411062
}),
@@ -964,6 +1085,8 @@ export async function runMini(input: MiniCommandInput) {
9641085
_: ["mini"],
9651086
message: input.prompt ? [input.prompt] : [],
9661087
command: undefined,
1088+
// --workflow is mutually exclusive with --mini (interactive); mini never runs one.
1089+
workflow: undefined,
9671090
continue: input.continue,
9681091
session: input.session,
9691092
fork: input.fork,

0 commit comments

Comments
 (0)