@@ -25,6 +25,14 @@ import { Filesystem } from "@/util/filesystem"
2525import { createOpencodeClient , type OpencodeClient , type ToolPart } from "@opencode-ai/sdk/v2"
2626import { FormatError , FormatUnknownError } from "../error"
2727import { 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
2937type 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