feat: implement Claude Code workflows

This commit is contained in:
抱月 2026-07-06 11:03:29 +08:00
parent c7d2c74437
commit 1923d7df13
7 changed files with 1534 additions and 41 deletions

View File

@ -9,7 +9,7 @@ Last updated: 2026-04-03
- Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger).
- Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**.
- Mock parity harness stats: **12 scripted scenarios**, **21 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`.
- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, and new host-facing contracts (`Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses.
- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, workflows add `/workflows`, `/deep-research`, `ultracode` keyword, and `/effort ultracode` entry points, and the remaining host-facing contracts (`Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses.
## Mock parity harness — milestone 1
@ -153,13 +153,14 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
- Agent view now uses the Claude Code v2.1.201 background-session layout: `CLAUDE_CONFIG_DIR` or `~/.claude`, `jobs/<id>/state.json`, `jobs/<id>/tmp/`, `daemon/roster.json`, and `daemon.log`. `claw agents`, `claw agents --json [--all] [--cwd <path>]`, `claw --bg`, `claw --bg --exec`, `claw attach/logs/stop/kill/respawn/rm <id>`, and `claw daemon status/stop --any` are wired through `runtime::AgentSupervisor`; TTY `claw agents` opens a full-screen crossterm view with grouped rows, peek, dispatch, and lifecycle shortcuts.
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`; the v2.1.201 migration extends `TaskCreate`/`TaskUpdate` to the structured task-list payload shape.
- The v2.1.201 host-facing contracts include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`.
- `Workflow` now resolves inline scripts, `scriptPath`, saved scripts under `.claude/workflows` and `.claw/workflows`, user-level workflow roots, and the bundled `deep-research` workflow. Static `agent(...)` calls spawn background agents and write run manifests; `/workflows`, `claw workflows`, `/deep-research`, `claw deep-research`, `ultracode` keyword trigger, and session-only `/effort ultracode` are wired through the CLI.
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
### Still limited or intentionally shallow
- `AskUserQuestion` supports local stdin/stdout question flow, but it is not wired to an async host UI prompt surface.
- `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself.
- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime.
- `Workflow` does not yet embed a full isolated JavaScript workflow VM. The current runner is a local parity layer for static `agent(...)` orchestration, saved workflow lookup, run registry, and disable switches; arbitrary `workflow`, `parallel`, `pipeline`, `Date`, and `Math.random` script behavior remains incomplete. `Monitor` still exposes only compatibility payloads and does not schedule live monitor loops.
- `RemoteTrigger` performs direct HTTP requests, but it has not been validated against every upstream host-trigger edge case.
- Agent view has a local supervisor, official state files, shell lifecycle commands, raw `agents --json` arrays, background worker spawning, a basic full-screen TUI, queued peek replies into resumable prompt sessions without live workers, row pin/rename/reorder/collapse shortcuts, directory/state grouping, and terminal attach once workers record their managed session id. Remaining gaps are live in-process peek reply routing, generated row summaries/PR status, notification hooks for background completion/needs-input, `/bg` or left-arrow handoff from an already-running foreground session, and a real supervisor socket/pre-warmed worker pool.
- `TestingPermission` remains test-only.

View File

@ -572,6 +572,23 @@ Agent view follows the Claude Code background-session layout. `CLAUDE_CONFIG_DIR
When stdout/stdin are attached to a terminal, `claw agents` opens a full-screen view. Type a prompt and press Enter to dispatch a new background session, use Up/Down to select rows, Space to peek at captured output, type while peek is open and press Enter to send a reply to a resumable session without a live worker, Enter or Right on an empty input to attach or fold a group header, `Ctrl+S` to group by directory/state, `Ctrl+T` to pin a row, `Ctrl+R` to rename it, `Shift+Up`/`Shift+Down` to reorder within a group, `Ctrl+X` to stop and press `Ctrl+X` again to remove, `s`/`k` to stop or kill, `r` to respawn, and `d` twice to remove a row. In JSON mode or non-TTY automation it keeps the structured list behavior.
## Run workflows
`claw` supports Claude Code-style dynamic workflow entry points for local orchestration. Saved workflow scripts are discovered from `.claude/workflows/`, `.claw/workflows/`, `~/.claude/workflows/`, and `~/.claw/workflows/`. Inline scripts, `scriptPath`, saved workflow names, and the bundled `deep-research` workflow all route through the `Workflow` tool and record run manifests in `.clawd-workflows/` by default.
```bash
./target/debug/claw workflows
./target/debug/claw workflows show <workflow-run-id>
./target/debug/claw deep-research "what changed in Node.js permission model?"
./target/debug/claw /deep-research "compare these APIs"
```
In the REPL, `/workflows` lists recorded runs and `/deep-research <question>` launches the bundled research workflow in the background. `/effort ultracode` is session-scoped: it sets API reasoning to `xhigh` and enables workflow orchestration for substantive prompts until you change effort again or start a new session. The `ultracode` keyword in a normal prompt also triggers workflow orchestration when `workflowKeywordTriggerEnabled` is true.
Workflow feature switches follow Claude Code-compatible settings: `disableWorkflows`, `enableWorkflows`, `workflowKeywordTriggerEnabled`, and `CLAUDE_CODE_DISABLE_WORKFLOWS=1`. When workflows are disabled, `/workflows`, `/deep-research`, and `ultracode` orchestration return a disabled workflow payload instead of starting agents.
Current local parity is intentionally bounded: the runner executes scripts with static `agent(...)` calls, stores run state, and spawns background agents. It does not yet embed a full isolated JavaScript workflow VM for arbitrary `workflow`, `parallel`, `pipeline`, `Date`, or `Math.random` behavior.
## Session management
REPL turns are persisted under `.claw/sessions/` in the current workspace.

View File

@ -86,7 +86,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
| **Workflow / Monitor** | `tools` | v2.1.201 compatibility contracts with stable unsupported payloads; local workflow JS and live monitor scheduling are not implemented |
| **Workflow / Monitor** | `tools` | Workflow supports saved/inline/scriptPath lookup, bundled `deep-research`, static `agent(...)` orchestration, run manifests, `/workflows`, `ultracode` keyword, and `/effort ultracode`; full JS VM and live Monitor scheduling are not implemented |
| **ScheduleWakeup / PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing v2.1.201 contract payloads — **contract parity** |
### Stubs Only (surface parity, no behavior)

View File

@ -244,6 +244,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: Some("[list|show <name>|create <name>|help]"),
resume_supported: true,
},
SlashCommandSpec {
name: "workflows",
aliases: &[],
summary: "List or inspect dynamic workflow runs",
argument_hint: Some("[show <run-id>]"),
resume_supported: true,
},
SlashCommandSpec {
name: "deep-research",
aliases: &[],
summary: "Run the bundled deep research workflow",
argument_hint: Some("<question>"),
resume_supported: false,
},
SlashCommandSpec {
name: "skills",
aliases: &["skill"],
@ -1119,6 +1133,12 @@ pub enum SlashCommand {
Agents {
args: Option<String>,
},
Workflows {
args: Option<String>,
},
DeepResearch {
question: Option<String>,
},
Skills {
args: Option<String>,
},
@ -1266,6 +1286,10 @@ impl SlashCommand {
Self::Permissions { .. } => "/permissions",
Self::Session { .. } => "/session",
Self::Plugins { .. } => "/plugins",
Self::Agents { .. } => "/agents",
Self::Workflows { .. } => "/workflows",
Self::DeepResearch { .. } => "/deep-research",
Self::Skills { .. } => "/skills",
Self::Login => "/login",
Self::Logout => "/logout",
Self::Vim => "/vim",
@ -1408,6 +1432,10 @@ pub fn validate_slash_command_input(
"agents" => SlashCommand::Agents {
args: parse_list_or_help_args(command, remainder)?,
},
"workflows" => SlashCommand::Workflows { args: remainder },
"deep-research" => SlashCommand::DeepResearch {
question: Some(require_remainder(command, remainder, "<question>")?),
},
"skills" | "skill" => SlashCommand::Skills {
args: parse_skills_args(remainder.as_deref())?,
},
@ -5805,6 +5833,8 @@ pub fn handle_slash_command(
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Agents { .. }
| SlashCommand::Workflows { .. }
| SlashCommand::DeepResearch { .. }
| SlashCommand::Skills { .. }
| SlashCommand::Doctor
| SlashCommand::Login
@ -6015,6 +6045,26 @@ mod tests {
task: Some("ship both features".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/workflows show workflow-123"),
Ok(Some(SlashCommand::Workflows {
args: Some("show workflow-123".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/deep-research Node.js permission model changes"),
Ok(Some(SlashCommand::DeepResearch {
question: Some("Node.js permission model changes".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/ultracode implement login"),
Ok(Some(SlashCommand::Unknown("ultracode".to_string())))
);
assert!(
super::find_slash_command_spec("ultracode").is_none(),
"ultracode is a workflow keyword and /effort mode, not a slash command"
);
assert_eq!(
SlashCommand::parse("/teleport conversation.rs"),
Ok(Some(SlashCommand::Teleport {
@ -6470,6 +6520,8 @@ mod tests {
));
assert!(help.contains("aliases: /plugins, /marketplace"));
assert!(help.contains("/agents [list|show <name>|create <name>|help]"));
assert!(help.contains("/workflows [show <run-id>]"));
assert!(help.contains("/deep-research <question>"));
assert!(help.contains(
"/skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
@ -6477,7 +6529,7 @@ mod tests {
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 142);
assert_eq!(slash_command_specs().len(), 144);
assert!(resume_supported_slash_commands().len() >= 39);
}

View File

@ -158,6 +158,7 @@ pub struct RuntimeFeatureConfig {
plugins: RuntimePluginConfig,
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
workflows: RuntimeWorkflowConfig,
model: Option<String>,
aliases: BTreeMap<String, String>,
permission_mode: Option<ResolvedPermissionMode>,
@ -170,6 +171,24 @@ pub struct RuntimeFeatureConfig {
provider: RuntimeProviderConfig,
}
/// Dynamic workflow feature switches from Claude Code-compatible settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeWorkflowConfig {
disable_workflows: bool,
enable_workflows: Option<bool>,
workflow_keyword_trigger_enabled: bool,
}
impl Default for RuntimeWorkflowConfig {
fn default() -> Self {
Self {
disable_workflows: false,
enable_workflows: None,
workflow_keyword_trigger_enabled: true,
}
}
}
/// Controls which external AI coding framework rules are imported into the system prompt.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RulesImportConfig {
@ -796,6 +815,7 @@ fn build_runtime_config(
plugins: parse_optional_plugin_config(&merged_value)?,
mcp,
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
workflows: parse_optional_workflow_config(&merged_value)?,
model: parse_optional_model(&merged_value),
aliases: parse_optional_aliases(&merged_value)?,
permission_mode: parse_optional_permission_mode(&merged_value)?,
@ -880,6 +900,21 @@ impl RuntimeConfig {
self.feature_config.oauth.as_ref()
}
#[must_use]
pub fn workflows(&self) -> &RuntimeWorkflowConfig {
&self.feature_config.workflows
}
#[must_use]
pub fn workflows_enabled(&self) -> bool {
self.feature_config.workflows_enabled()
}
#[must_use]
pub fn workflow_keyword_trigger_enabled(&self) -> bool {
self.feature_config.workflow_keyword_trigger_enabled()
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.feature_config.model.as_deref()
@ -977,6 +1012,21 @@ impl RuntimeFeatureConfig {
self.oauth.as_ref()
}
#[must_use]
pub fn workflows(&self) -> &RuntimeWorkflowConfig {
&self.workflows
}
#[must_use]
pub fn workflows_enabled(&self) -> bool {
self.workflows.enabled()
}
#[must_use]
pub fn workflow_keyword_trigger_enabled(&self) -> bool {
self.workflows.keyword_trigger_enabled() && self.workflows_enabled()
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
@ -1024,6 +1074,34 @@ impl RuntimeFeatureConfig {
}
}
impl RuntimeWorkflowConfig {
#[must_use]
pub fn disable_workflows(&self) -> bool {
self.disable_workflows
}
#[must_use]
pub fn enable_workflows(&self) -> Option<bool> {
self.enable_workflows
}
#[must_use]
pub fn keyword_trigger_enabled(&self) -> bool {
self.workflow_keyword_trigger_enabled
}
#[must_use]
pub fn enabled(&self) -> bool {
if env_flag_enabled("CLAUDE_CODE_DISABLE_WORKFLOWS") {
return false;
}
if self.disable_workflows {
return false;
}
self.enable_workflows.unwrap_or(true)
}
}
fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Vec<String> {
let mut merged = Vec::with_capacity(config_roots.len() + per_call_roots.len());
for root in config_roots.iter().chain(per_call_roots.iter()) {
@ -1034,6 +1112,15 @@ fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Ve
merged
}
fn env_flag_enabled(name: &str) -> bool {
std::env::var(name).is_ok_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
impl ProviderFallbackConfig {
#[must_use]
pub fn new(primary: Option<String>, fallbacks: Vec<String>) -> Self {
@ -1749,6 +1836,24 @@ fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>,
Ok(optional_string_map(object, "aliases", "merged settings")?.unwrap_or_default())
}
fn parse_optional_workflow_config(root: &JsonValue) -> Result<RuntimeWorkflowConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimeWorkflowConfig::default());
};
Ok(RuntimeWorkflowConfig {
disable_workflows: optional_bool(object, "disableWorkflows", "merged settings")?
.unwrap_or(false),
enable_workflows: optional_bool(object, "enableWorkflows", "merged settings")?,
workflow_keyword_trigger_enabled: optional_bool(
object,
"workflowKeywordTriggerEnabled",
"merged settings",
)?
.unwrap_or(true),
})
}
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimeHookConfig::default());
@ -3944,4 +4049,33 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_workflow_feature_switches_and_env_disable() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"enableWorkflows": true, "workflowKeywordTriggerEnabled": false}"#,
)
.expect("write settings");
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("workflow settings should load");
assert!(loaded.workflows_enabled());
assert!(!loaded.workflow_keyword_trigger_enabled());
assert_eq!(loaded.workflows().enable_workflows(), Some(true));
std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1");
assert!(!loaded.workflows_enabled());
assert!(!loaded.workflow_keyword_trigger_enabled());
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
}

View File

@ -74,11 +74,16 @@ use runtime::{
use serde::Deserialize;
use serde_json::{json, Map, Value};
use tools::{
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
RuntimeToolDefinition, ToolSearchOutput,
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, render_workflows_report,
workflows_report_json, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput,
};
const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-5";
const API_REASONING_EFFORT_VALUES: &[&str] = &["low", "medium", "high", "xhigh", "max"];
const SESSION_EFFORT_VALUES: &[&str] =
&["auto", "low", "medium", "high", "xhigh", "max", "ultracode"];
const REASONING_EFFORT_FLAG_USAGE: &str = "--reasoning-effort low|medium|high|xhigh|max";
const SESSION_EFFORT_USAGE: &str = "/effort auto|low|medium|high|xhigh|max|ultracode";
/// #148: Model provenance for `claw status` JSON/text output. Records where
/// the resolved model string came from so claws don't have to re-read argv
@ -1037,6 +1042,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
reasoning_effort: reasoning_effort.as_deref(),
agent: agent.as_deref(),
})?,
CliAction::Workflows {
args,
output_format,
} => LiveCli::print_workflows(args.as_deref(), output_format)?,
CliAction::DeepResearch {
question,
output_format,
} => LiveCli::run_deep_research_command(&question, output_format)?,
CliAction::Background {
prompt,
exec,
@ -1239,6 +1252,14 @@ enum CliAction {
reasoning_effort: Option<String>,
agent: Option<String>,
},
Workflows {
args: Option<String>,
output_format: CliOutputFormat,
},
DeepResearch {
question: String,
output_format: CliOutputFormat,
},
Background {
prompt: Option<String>,
exec: Option<String>,
@ -1795,22 +1816,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"--reasoning-effort" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --reasoning-effort.\nUsage: --reasoning-effort low|medium|high".to_string())?;
if !matches!(value.as_str(), "low" | "medium" | "high") {
return Err(format!(
"invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high"
));
}
.ok_or_else(|| format!("missing_flag_value: missing value for --reasoning-effort.\nUsage: {REASONING_EFFORT_FLAG_USAGE}"))?;
validate_reasoning_effort_flag(value)?;
reasoning_effort = Some(value.clone());
index += 2;
}
flag if flag.starts_with("--reasoning-effort=") => {
let value = &flag[19..];
if !matches!(value, "low" | "medium" | "high") {
return Err(format!(
"invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high"
));
}
validate_reasoning_effort_flag(value)?;
reasoning_effort = Some(value.to_string());
index += 1;
}
@ -2188,6 +2201,22 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
reasoning_effort: reasoning_effort.clone(),
agent: background_agent.clone(),
}),
"workflows" => Ok(CliAction::Workflows {
args: join_optional_args(&rest[1..]),
output_format,
}),
"deep-research" => {
let Some(question) = join_optional_args(&rest[1..]) else {
return Err(
"missing_argument: deep-research requires a question.\nUsage: claw deep-research <question>"
.to_string(),
);
};
Ok(CliAction::DeepResearch {
question,
output_format,
})
}
"mcp" => Ok(CliAction::Mcp {
args: join_optional_args(&rest[1..]),
output_format,
@ -2732,6 +2761,8 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
| "remove"
| "daemon"
| "agents"
| "workflows"
| "deep-research"
| "mcp"
| "plugin"
| "plugins"
@ -2970,6 +3001,28 @@ fn join_optional_args(args: &[String]) -> Option<String> {
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn is_api_reasoning_effort(value: &str) -> bool {
API_REASONING_EFFORT_VALUES.contains(&value)
}
fn is_session_effort(value: &str) -> bool {
SESSION_EFFORT_VALUES.contains(&value)
}
fn validate_reasoning_effort_flag(value: &str) -> Result<(), String> {
if is_api_reasoning_effort(value) {
return Ok(());
}
if value == "ultracode" {
return Err(format!(
"invalid_flag_value: ultracode is session-scoped; use /effort ultracode inside the REPL.\nUsage: {REASONING_EFFORT_FLAG_USAGE}"
));
}
Err(format!(
"invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: {REASONING_EFFORT_FLAG_USAGE}"
))
}
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
fn parse_direct_slash_cli_action(
rest: &[String],
@ -3007,6 +3060,17 @@ fn parse_direct_slash_cli_action(
reasoning_effort,
agent: None,
}),
Ok(Some(SlashCommand::Workflows { args })) => Ok(CliAction::Workflows {
args,
output_format,
}),
Ok(Some(SlashCommand::DeepResearch { question })) => Ok(CliAction::DeepResearch {
question: question.unwrap_or_default(),
output_format,
}),
Ok(Some(SlashCommand::Effort { .. })) => Err(format!(
"interactive_only: /effort is session-scoped.\nStart `claw` and run `{SESSION_EFFORT_USAGE}` inside the REPL."
)),
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
args: match (action, target) {
(None, None) => None,
@ -3172,6 +3236,8 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
"dump-manifests",
"bootstrap-plan",
"agents",
"workflows",
"deep-research",
"mcp",
"skills",
"system-prompt",
@ -3217,6 +3283,8 @@ fn is_known_top_level_subcommand(value: &str) -> bool {
| "bootstrap-plan"
| "agents"
| "agent"
| "workflows"
| "deep-research"
| "mcp"
| "skills"
| "skill"
@ -7115,6 +7183,33 @@ fn run_resume_command(
),
})
}
SlashCommand::Workflows { args } => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_workflows_report(args.as_deref())?),
json: Some(workflows_report_json(args.as_deref())?),
}),
SlashCommand::DeepResearch { question } => {
let question = question.as_deref().unwrap_or_default();
let output = execute_tool(
"Workflow",
&json!({
"name": "deep-research",
"args": question,
}),
)?;
let value = serde_json::from_str::<Value>(&output).unwrap_or_else(|_| {
json!({
"kind": "workflow",
"status": "error",
"message": output,
})
});
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_workflow_tool_result(&value)),
json: Some(value),
})
}
SlashCommand::Skills { args } => {
if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) {
// #779: use interactive_only: prefix + \n hint so #776 classify/split emits
@ -7518,7 +7613,7 @@ fn run_live_cli_loop(cli: &mut LiveCli) -> Result<(), Box<dyn std::error::Error>
}
editor.push_history(input);
cli.record_prompt_history(&trimmed);
cli.run_turn(&trimmed)?;
cli.run_user_input(&trimmed)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
@ -7550,10 +7645,137 @@ struct ManagedSessionSummary {
lifecycle: SessionLifecycleSummary,
}
fn workflow_runtime_config_for_current_dir() -> runtime::RuntimeConfig {
let Ok(cwd) = env::current_dir() else {
return runtime::RuntimeConfig::empty();
};
ConfigLoader::default_for(&cwd)
.load()
.unwrap_or_else(|_| runtime::RuntimeConfig::empty())
}
fn workflows_enabled_for_current_dir() -> bool {
workflow_runtime_config_for_current_dir().workflows_enabled()
}
fn workflow_keyword_trigger_enabled_for_current_dir() -> bool {
workflow_runtime_config_for_current_dir().workflow_keyword_trigger_enabled()
}
fn workflow_disabled_value() -> Value {
json!({
"kind": "workflow",
"status": "disabled",
"message": "Dynamic workflows are disabled by settings or CLAUDE_CODE_DISABLE_WORKFLOWS.",
})
}
fn contains_ultracode_keyword(input: &str) -> bool {
input
.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-')
.any(|token| token.eq_ignore_ascii_case("ultracode"))
}
fn strip_ultracode_keyword(input: &str) -> String {
let stripped = input
.split_whitespace()
.filter(|token| {
!token
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-')
.eq_ignore_ascii_case("ultracode")
})
.collect::<Vec<_>>()
.join(" ");
if stripped.trim().is_empty() {
input.trim().to_string()
} else {
stripped
}
}
fn looks_like_substantive_workflow_task(input: &str) -> bool {
let lower = input.to_ascii_lowercase();
let substantive_verbs = [
"implement",
"fix",
"debug",
"migrate",
"upgrade",
"refactor",
"build",
"create",
"design",
"analyze",
"review",
"test",
"investigate",
"research",
"port",
"integrate",
];
input.chars().count() >= 80
|| substantive_verbs
.iter()
.any(|verb| lower.split_whitespace().any(|token| token == *verb))
}
fn escape_workflow_template_text(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${")
}
fn ultracode_workflow_script(task: &str, trigger: &str) -> String {
let task = escape_workflow_template_text(task);
format!(
"export const meta = {{ name: 'ultracode', description: 'Session-scoped ultracode workflow orchestration.' }}\n\nconst result = await agent(`Use ultracode workflow orchestration for this substantive coding task. Inspect the workspace, plan the implementation, make the necessary changes, and report verification status. Trigger: {trigger}.\\n\\nTask:\\n{task}`, {{ label: 'ultracode', agentType: 'Explore', effort: 'xhigh' }})\n\nreturn result\n"
)
}
fn format_workflow_tool_result(value: &Value) -> String {
let mut lines = vec![String::from("Workflow")];
if let Some(status) = value.get("status").and_then(Value::as_str) {
lines.push(format!(" Status {status}"));
}
if let Some(run_id) = value.get("runId").and_then(Value::as_str) {
lines.push(format!(" Run {run_id}"));
}
if let Some(name) = value.get("name").and_then(Value::as_str) {
lines.push(format!(" Name {name}"));
}
if let Some(source) = value.get("source").and_then(Value::as_str) {
lines.push(format!(" Source {source}"));
}
if let Some(script_path) = value.get("scriptPath").and_then(Value::as_str) {
lines.push(format!(" Script {script_path}"));
}
if let Some(agents) = value.get("agents").and_then(Value::as_array) {
lines.push(format!(" Agents {}", agents.len()));
for agent in agents.iter().take(5) {
let label = agent
.get("name")
.and_then(Value::as_str)
.unwrap_or("workflow-agent");
let status = agent
.get("status")
.and_then(Value::as_str)
.unwrap_or("queued");
lines.push(format!(" - {label} ({status})"));
}
}
if let Some(message) = value.get("message").and_then(Value::as_str) {
lines.push(format!(" Message {message}"));
}
lines.join("\n")
}
struct LiveCli {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
effort_mode: String,
reasoning_effort: Option<String>,
system_prompt: Vec<String>,
runtime: BuiltRuntime,
session: SessionHandle,
@ -8960,6 +9182,8 @@ impl LiveCli {
model,
allowed_tools,
permission_mode,
effort_mode: "auto".to_string(),
reasoning_effort: None,
system_prompt,
runtime,
session,
@ -8970,9 +9194,9 @@ impl LiveCli {
}
fn set_reasoning_effort(&mut self, effort: Option<String>) {
if let Some(rt) = self.runtime.runtime.as_mut() {
rt.api_client_mut().set_reasoning_effort(effort);
}
self.effort_mode = effort.clone().unwrap_or_else(|| "auto".to_string());
self.reasoning_effort = effort;
Self::apply_reasoning_effort(&mut self.runtime, self.reasoning_effort.clone());
}
fn startup_banner(&self) -> String {
@ -9035,7 +9259,7 @@ impl LiveCli {
emit_output: bool,
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
let hook_abort_signal = runtime::HookAbortSignal::new();
let runtime = build_runtime(
let mut runtime = build_runtime(
self.runtime.session().clone(),
&self.session.id,
self.model.clone(),
@ -9047,17 +9271,28 @@ impl LiveCli {
None,
)?
.with_hook_abort_signal(hook_abort_signal.clone());
Self::apply_reasoning_effort(&mut runtime, self.reasoning_effort.clone());
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
Ok((runtime, hook_abort_monitor))
}
fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box<dyn std::error::Error>> {
fn replace_runtime(
&mut self,
mut runtime: BuiltRuntime,
) -> Result<(), Box<dyn std::error::Error>> {
Self::apply_reasoning_effort(&mut runtime, self.reasoning_effort.clone());
self.runtime.shutdown_plugins()?;
self.runtime = runtime;
Ok(())
}
fn apply_reasoning_effort(runtime: &mut BuiltRuntime, effort: Option<String>) {
if let Some(rt) = runtime.runtime.as_mut() {
rt.api_client_mut().set_reasoning_effort(effort);
}
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let mut spinner = Spinner::new();
@ -9290,6 +9525,9 @@ impl LiveCli {
output_format: CliOutputFormat,
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(trigger) = self.workflow_trigger_for_input(input) {
return self.run_ultracode_workflow_with_output(input, trigger, output_format);
}
match output_format {
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
@ -9298,6 +9536,53 @@ impl LiveCli {
}
}
fn run_user_input(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
if let Some(trigger) = self.workflow_trigger_for_input(input) {
return self.run_ultracode_workflow_with_output(input, trigger, CliOutputFormat::Text);
}
self.run_turn(input)
}
fn workflow_trigger_for_input(&self, input: &str) -> Option<&'static str> {
let trimmed = input.trim();
if trimmed.is_empty() || trimmed.starts_with('/') || !workflows_enabled_for_current_dir() {
return None;
}
if contains_ultracode_keyword(trimmed) {
return workflow_keyword_trigger_enabled_for_current_dir().then_some("keyword");
}
if self.effort_mode == "ultracode" && looks_like_substantive_workflow_task(trimmed) {
return Some("effort");
}
None
}
fn run_ultracode_workflow_with_output(
&mut self,
input: &str,
trigger: &'static str,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let task = if trigger == "keyword" {
strip_ultracode_keyword(input)
} else {
input.trim().to_string()
};
let script = ultracode_workflow_script(&task, trigger);
let value = Self::execute_workflow_tool_json(&json!({
"script": script,
"args": {
"prompt": task,
"trigger": trigger,
},
}))?;
match output_format {
CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)),
CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?),
}
Ok(())
}
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
@ -9485,6 +9770,24 @@ impl LiveCli {
}
false
}
SlashCommand::Workflows { args } => {
if let Err(error) = Self::print_workflows(args.as_deref(), CliOutputFormat::Text) {
eprintln!("{error}");
}
false
}
SlashCommand::DeepResearch { question } => {
if let Some(question) = question {
if let Err(error) =
Self::run_deep_research_command(&question, CliOutputFormat::Text)
{
eprintln!("{error}");
}
} else {
eprintln!("Usage: /deep-research <question>");
}
false
}
SlashCommand::Skills { args } => {
match classify_skills_slash_command(args.as_deref()) {
SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?,
@ -9524,6 +9827,7 @@ impl LiveCli {
println!("{}", format_cost_report(usage));
false
}
SlashCommand::Effort { level } => self.handle_effort_command(level.as_deref())?,
SlashCommand::Review { scope } => {
let scope = scope.unwrap_or_else(|| "medium".to_string());
self.run_turn(&format!("$code-review {scope}"))?;
@ -9559,7 +9863,6 @@ impl LiveCli {
| SlashCommand::Hooks { .. }
| SlashCommand::Context { .. }
| SlashCommand::Color { .. }
| SlashCommand::Effort { .. }
| SlashCommand::Branch { .. }
| SlashCommand::Rewind { .. }
| SlashCommand::Ide { .. }
@ -9882,6 +10185,59 @@ impl LiveCli {
Ok(())
}
fn print_workflows(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
if !workflows_enabled_for_current_dir() {
let value = workflow_disabled_value();
match output_format {
CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)),
CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?),
}
return Ok(());
}
match output_format {
CliOutputFormat::Text => {
println!(
"{}",
render_workflows_report(args).map_err(io::Error::other)?
)
}
CliOutputFormat::Json => {
let value = workflows_report_json(args).map_err(io::Error::other)?;
println!("{}", serde_json::to_string_pretty(&value)?);
}
}
Ok(())
}
fn run_deep_research_command(
question: &str,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = Self::execute_workflow_tool_json(&json!({
"name": "deep-research",
"args": question,
}))?;
match output_format {
CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)),
CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?),
}
Ok(())
}
fn execute_workflow_tool_json(input: &Value) -> Result<Value, Box<dyn std::error::Error>> {
let output = execute_tool("Workflow", input).map_err(io::Error::other)?;
Ok(serde_json::from_str(&output).unwrap_or_else(|_| {
json!({
"kind": "workflow",
"status": "error",
"message": output,
})
}))
}
fn print_mcp(
args: Option<&str>,
output_format: CliOutputFormat,
@ -9936,6 +10292,57 @@ impl LiveCli {
Ok(())
}
fn handle_effort_command(
&mut self,
level: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(raw_level) = level.map(str::trim).filter(|value| !value.is_empty()) else {
println!(
"Effort\n Current mode {}\n API reasoning {}\n Usage {}",
self.effort_mode,
self.reasoning_effort.as_deref().unwrap_or("auto"),
SESSION_EFFORT_USAGE
);
return Ok(false);
};
let normalized = raw_level.to_ascii_lowercase();
if !is_session_effort(&normalized) {
println!(
"Effort\n Status error\n Message unsupported effort level '{}'\n Usage {}",
raw_level, SESSION_EFFORT_USAGE
);
return Ok(false);
}
if normalized == "ultracode" && !workflows_enabled_for_current_dir() {
println!(
"{}",
format_workflow_tool_result(&workflow_disabled_value())
);
return Ok(false);
}
let previous = self.effort_mode.clone();
self.effort_mode = normalized.clone();
self.reasoning_effort = match normalized.as_str() {
"auto" => None,
"ultracode" => Some("xhigh".to_string()),
other => Some(other.to_string()),
};
Self::apply_reasoning_effort(&mut self.runtime, self.reasoning_effort.clone());
println!(
"Effort\n Previous mode {}\n Current mode {}\n API reasoning {}\n Workflow mode {}",
previous,
self.effort_mode,
self.reasoning_effort.as_deref().unwrap_or("auto"),
if self.effort_mode == "ultracode" {
"enabled"
} else {
"off"
}
);
Ok(false)
}
fn print_plugins(
action: Option<&str>,
target: Option<&str>,
@ -10955,6 +11362,8 @@ fn run_attached_agent_session(
model,
allowed_tools: None,
permission_mode,
effort_mode: "auto".to_string(),
reasoning_effort: None,
system_prompt,
runtime,
session: SessionHandle {
@ -15217,7 +15626,6 @@ const STUB_COMMANDS: &[&str] = &[
"hooks",
"context",
"color",
"effort",
"branch",
"rewind",
"ide",
@ -15325,6 +15733,9 @@ fn slash_command_completion_candidates_with_sessions(
"/config hooks",
"/config model",
"/config plugins",
"/deep-research ",
"/effort ",
"/effort ultracode",
"/mcp ",
"/mcp list",
"/mcp show ",
@ -15352,6 +15763,8 @@ fn slash_command_completion_candidates_with_sessions(
"/session fork ",
"/teleport ",
"/ultraplan ",
"/workflows ",
"/workflows show ",
"/agents help",
"/mcp help",
"/skills help",
@ -19113,6 +19526,31 @@ mod tests {
output_format: CliOutputFormat::Json,
}
);
assert_eq!(
parse_args(&[
"--output-format=json".to_string(),
"/workflows".to_string(),
"show".to_string(),
"workflow-123".to_string(),
])
.expect("json /workflows show should parse"),
CliAction::Workflows {
args: Some("show workflow-123".to_string()),
output_format: CliOutputFormat::Json,
}
);
assert_eq!(
parse_args(&[
"--output-format=json".to_string(),
"/deep-research".to_string(),
"workflow docs".to_string(),
])
.expect("json /deep-research should parse"),
CliAction::DeepResearch {
question: "workflow docs".to_string(),
output_format: CliOutputFormat::Json,
}
);
}
#[test]
@ -19693,6 +20131,9 @@ mod tests {
assert!(completions.contains(&"/resume session-old".to_string()));
assert!(completions.contains(&"/mcp list".to_string()));
assert!(completions.contains(&"/ultraplan ".to_string()));
assert!(completions.contains(&"/workflows ".to_string()));
assert!(completions.contains(&"/deep-research ".to_string()));
assert!(completions.contains(&"/effort ultracode".to_string()));
}
#[test]
@ -21778,7 +22219,7 @@ UU conflicted.rs",
#[test]
fn accepts_valid_reasoning_effort_values() {
for value in ["low", "medium", "high"] {
for value in ["low", "medium", "high", "xhigh", "max"] {
let result = parse_args(&[
"--reasoning-effort".to_string(),
value.to_string(),
@ -21798,6 +22239,19 @@ UU conflicted.rs",
}
}
#[test]
fn rejects_ultracode_as_persistent_reasoning_effort_flag() {
let err = parse_args(&[
"--reasoning-effort".to_string(),
"ultracode".to_string(),
"prompt".to_string(),
"hello".to_string(),
])
.unwrap_err();
assert!(err.contains("session-scoped"), "unexpected error: {err}");
assert!(err.contains("/effort ultracode"), "unexpected error: {err}");
}
#[test]
fn stub_commands_absent_from_repl_completions() {
let candidates =

View File

@ -1254,7 +1254,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "Workflow",
description: "Run a deterministic multi-agent workflow script. v2.1.201 contract surface; local execution is reported as unsupported unless a workflow runner is configured.",
description: "Run a deterministic multi-agent workflow script by resolving inline script, saved workflow name, or scriptPath and spawning workflow agents in the background.",
input_schema: json!({
"type": "object",
"properties": {
@ -2255,18 +2255,687 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result<String, String> {
#[allow(clippy::needless_pass_by_value)]
fn run_workflow(input: WorkflowInput) -> Result<String, String> {
to_pretty_json(json!({
"status": "unsupported",
"kind": "workflow",
"message": "Workflow tool contract is registered for Claude Code v2.1.201 compatibility, but this local runtime does not execute workflow JavaScript yet.",
"name": input.name,
"has_script": input.script.as_ref().is_some_and(|script| !script.trim().is_empty()),
"scriptPath": input.script_path,
"resumeFromRunId": input.resume_from_run_id,
"args": input.args,
run_workflow_with_spawn(input, spawn_agent_job)
}
fn run_workflow_with_spawn<F>(input: WorkflowInput, spawn_fn: F) -> Result<String, String>
where
F: Fn(AgentJob) -> Result<(), String> + Copy,
{
if !workflow_feature_enabled() {
return to_pretty_json(json!({
"status": "disabled",
"kind": "workflow",
"message": "Dynamic workflows are disabled by settings or CLAUDE_CODE_DISABLE_WORKFLOWS.",
"name": input.name,
"scriptPath": input.script_path,
"resumeFromRunId": input.resume_from_run_id,
"args": input.args,
}));
}
if input.resume_from_run_id.is_some() {
return to_pretty_json(json!({
"status": "resume_requested",
"kind": "workflow",
"message": "This local workflow runner records run state, but resumeFromRunId replay is not implemented yet. Relaunching requires the original scriptPath.",
"resumeFromRunId": input.resume_from_run_id,
"scriptPath": input.script_path,
"name": input.name,
"args": input.args,
}));
}
let resolved = resolve_workflow_script(input)?;
let run = start_workflow_run(resolved, spawn_fn)?;
to_pretty_json(serde_json::to_value(run).map_err(|error| error.to_string())?)
}
fn workflow_feature_enabled() -> bool {
if workflow_env_disabled() {
return false;
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
ConfigLoader::default_for(cwd)
.load()
.map_or(true, |config| config.workflows_enabled())
}
fn workflow_env_disabled() -> bool {
std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").is_ok_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
fn resolve_workflow_script(input: WorkflowInput) -> Result<ResolvedWorkflowScript, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let args = input.args;
let requested_name = input
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned);
if let Some(script) = input
.script
.as_deref()
.map(str::trim)
.filter(|script| !script.is_empty())
{
let name = requested_name
.or_else(|| parse_workflow_meta_string(script, "name"))
.unwrap_or_else(|| String::from("inline-workflow"));
return Ok(ResolvedWorkflowScript {
name,
description: parse_workflow_meta_string(script, "description"),
script: script.to_string(),
script_path: None,
source: String::from("inline"),
args,
});
}
if let Some(script_path) = input
.script_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
{
let path = resolve_workflow_script_path(script_path, &cwd)?;
let script = read_workflow_script_file(&path)?;
let name = requested_name
.or_else(|| parse_workflow_meta_string(&script, "name"))
.or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| String::from("workflow"));
return Ok(ResolvedWorkflowScript {
name,
description: parse_workflow_meta_string(&script, "description"),
script,
script_path: Some(path),
source: String::from("scriptPath"),
args,
});
}
let Some(name) = requested_name else {
return Err(String::from("Must provide script, name, or scriptPath"));
};
if name == "deep-research" {
let script = builtin_deep_research_script(args.as_ref());
return Ok(ResolvedWorkflowScript {
name,
description: Some(String::from(
"Deep research harness that fans out source discovery and verification.",
)),
script,
script_path: None,
source: String::from("built-in"),
args,
});
}
let path = find_saved_workflow_script(&name, &cwd)?;
let script = read_workflow_script_file(&path)?;
Ok(ResolvedWorkflowScript {
name: parse_workflow_meta_string(&script, "name").unwrap_or(name),
description: parse_workflow_meta_string(&script, "description"),
script,
script_path: Some(path),
source: String::from("saved"),
args,
})
}
fn start_workflow_run<F>(
workflow: ResolvedWorkflowScript,
spawn_fn: F,
) -> Result<WorkflowRunOutput, String>
where
F: Fn(AgentJob) -> Result<(), String> + Copy,
{
let run_id = make_workflow_run_id();
let created_at = iso8601_now();
let store_dir = workflow_store_dir()?;
std::fs::create_dir_all(&store_dir).map_err(|error| error.to_string())?;
let script_path = persist_workflow_script_for_run(&store_dir, &run_id, &workflow)?;
let manifest_file = store_dir.join(format!("{run_id}.json"));
let calls = parse_workflow_agent_calls(&workflow.script);
let mut agents = Vec::new();
for (index, call) in calls.into_iter().enumerate() {
let description = call
.label
.clone()
.unwrap_or_else(|| format!("{} phase {}", workflow.name, index + 1));
let manifest = execute_agent_with_spawn(
AgentInput {
description: description.clone(),
prompt: workflow_agent_prompt(&workflow, &call),
subagent_type: call
.agent_type
.or_else(|| Some(String::from("general-purpose"))),
name: Some(format!("{}-{}", workflow.name, index + 1)),
model: call.model,
effort: call.effort,
run_in_background: Some(true),
isolation: call.isolation,
},
spawn_fn,
)?;
agents.push(WorkflowAgentRecord {
agent_id: manifest.agent_id,
name: manifest.name,
description,
status: manifest.status,
prompt_preview: preview_text(&call.prompt, 160),
manifest_file: manifest.manifest_file,
});
}
let (status, message) = if agents.is_empty() {
(
String::from("script_error"),
String::from(
"Workflow script did not contain a static agent(...) call this local runner can execute.",
),
)
} else {
(
String::from("running"),
format!(
"Running in background: {} agent(s). Use /workflows to monitor and save.",
agents.len()
),
)
};
let run = WorkflowRunOutput {
run_id,
kind: String::from("workflow"),
status,
name: workflow.name,
description: workflow.description,
source: workflow.source,
script_path: script_path.display().to_string(),
manifest_file: manifest_file.display().to_string(),
created_at,
agents,
args: workflow.args,
message,
};
write_workflow_run_manifest(&run)?;
Ok(run)
}
fn workflow_agent_prompt(workflow: &ResolvedWorkflowScript, call: &WorkflowAgentCall) -> String {
let args = workflow
.args
.as_ref()
.map(|value| format!("\n\nWorkflow args:\n{value}"))
.unwrap_or_default();
format!(
"You are a subagent spawned by workflow `{}`. Complete this workflow step and return a concise result.\n\n{}{}",
workflow.name, call.prompt, args
)
}
fn resolve_workflow_script_path(raw: &str, cwd: &Path) -> Result<PathBuf, String> {
if raw.starts_with("\\\\") {
return Err(format!(
"UNC paths are not allowed for workflow scriptPath: {raw}"
));
}
let path = PathBuf::from(raw);
if path.is_absolute() {
Ok(path)
} else {
Ok(cwd.join(path))
}
}
const WORKFLOW_SCRIPT_MAX_BYTES: u64 = 1024 * 1024;
fn read_workflow_script_file(path: &Path) -> Result<String, String> {
let metadata = std::fs::metadata(path)
.map_err(|_| format!("Workflow script file not found: {}", path.display()))?;
if metadata.len() > WORKFLOW_SCRIPT_MAX_BYTES {
return Err(format!(
"Workflow script file {} exceeds {} bytes",
path.display(),
WORKFLOW_SCRIPT_MAX_BYTES
));
}
let bytes = std::fs::read(path).map_err(|error| {
format!(
"Failed to read workflow script file {}: {error}",
path.display()
)
})?;
String::from_utf8(bytes).map_err(|error| {
format!(
"Workflow script file {} is not utf-8: {error}",
path.display()
)
})
}
fn persist_workflow_script_for_run(
store_dir: &Path,
run_id: &str,
workflow: &ResolvedWorkflowScript,
) -> Result<PathBuf, String> {
if let Some(path) = &workflow.script_path {
return Ok(path.clone());
}
let script_dir = store_dir.join("scripts");
std::fs::create_dir_all(&script_dir).map_err(|error| error.to_string())?;
let path = script_dir.join(format!("{run_id}.js"));
std::fs::write(&path, &workflow.script).map_err(|error| {
format!(
"Failed to persist workflow script to {}: {error}",
path.display()
)
})?;
Ok(path)
}
fn write_workflow_run_manifest(run: &WorkflowRunOutput) -> Result<(), String> {
std::fs::write(
&run.manifest_file,
serde_json::to_string_pretty(run).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())
}
fn make_workflow_run_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("workflow-{nanos}")
}
fn workflow_store_dir() -> Result<PathBuf, String> {
if let Ok(path) = std::env::var("CLAWD_WORKFLOW_STORE") {
return Ok(PathBuf::from(path));
}
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
if let Some(workspace_root) = cwd.ancestors().nth(2) {
return Ok(workspace_root.join(".clawd-workflows"));
}
Ok(cwd.join(".clawd-workflows"))
}
fn parse_workflow_agent_calls(script: &str) -> Vec<WorkflowAgentCall> {
let mut calls = Vec::new();
let mut offset = 0;
while let Some(relative) = script[offset..].find("agent(") {
let start = offset + relative + "agent(".len();
let Some((prompt, consumed)) = extract_js_string_literal(&script[start..]) else {
offset = start;
continue;
};
let options = &script[start + consumed..script.len().min(start + consumed + 1024)];
calls.push(WorkflowAgentCall {
prompt,
label: parse_workflow_option_string(options, "label"),
model: parse_workflow_option_string(options, "model"),
effort: parse_workflow_option_string(options, "effort"),
agent_type: parse_workflow_option_string(options, "agentType")
.or_else(|| parse_workflow_option_string(options, "subagent_type")),
isolation: parse_workflow_option_string(options, "isolation"),
});
offset = start + consumed;
}
calls
}
fn extract_js_string_literal(input: &str) -> Option<(String, usize)> {
let trimmed_start = input.len() - input.trim_start().len();
let mut chars = input[trimmed_start..].char_indices();
let (_, quote) = chars.next()?;
if !matches!(quote, '\'' | '"' | '`') {
return None;
}
let mut escaped = false;
let mut value = String::new();
for (index, ch) in chars {
if escaped {
value.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == quote {
let consumed = trimmed_start + index + ch.len_utf8();
return Some((normalize_workflow_template_literal(&value), consumed));
}
value.push(ch);
}
None
}
fn normalize_workflow_template_literal(value: &str) -> String {
value.replace("${", "{")
}
fn parse_workflow_option_string(input: &str, key: &str) -> Option<String> {
let mut offset = 0;
while let Some(relative) = input[offset..].find(key) {
let key_start = offset + relative;
let key_end = key_start + key.len();
let before_ok = key_start == 0
|| !input[..key_start]
.chars()
.next_back()
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
let after_ok = input[key_end..]
.chars()
.next()
.is_none_or(|ch| !(ch.is_ascii_alphanumeric() || ch == '_'));
if before_ok && after_ok {
let after_key = &input[key_end..];
let colon_index = after_key.find(':')?;
let after_colon = &after_key[colon_index + 1..];
return extract_js_string_literal(after_colon).map(|(value, _)| value);
}
offset = key_end;
}
None
}
fn parse_workflow_meta_string(script: &str, key: &str) -> Option<String> {
parse_workflow_option_string(script, key)
}
fn builtin_deep_research_script(args: Option<&Value>) -> String {
let question =
workflow_args_to_prompt(args).unwrap_or_else(|| String::from("the requested topic"));
let question = escape_workflow_template_text(&question);
format!(
"export const meta = {{ name: 'deep-research', description: 'Deep research harness - fan out source discovery, verification, and synthesis.' }}\n\nconst report = await agent(`Research this question deeply using available search and fetch tools, cross-check claims, and return a cited report: {question}`, {{ label: 'deep-research', agentType: 'Explore', effort: 'high' }})\n\nreturn report\n"
)
}
fn workflow_args_to_prompt(args: Option<&Value>) -> Option<String> {
match args {
Some(Value::String(value)) if !value.trim().is_empty() => Some(value.trim().to_string()),
Some(value) if !value.is_null() => Some(value.to_string()),
_ => None,
}
}
fn escape_workflow_template_text(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${")
}
fn find_saved_workflow_script(name: &str, cwd: &Path) -> Result<PathBuf, String> {
for root in workflow_lookup_roots(cwd) {
if let Some(path) = find_workflow_in_root(&root, name) {
return Ok(path);
}
}
let available = list_saved_workflow_names(cwd);
let available = if available.is_empty() {
String::from("(none)")
} else {
available.join(", ")
};
Err(format!(
"Workflow \"{name}\" not found. Available: {available}"
))
}
fn workflow_lookup_roots(cwd: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
for ancestor in cwd.ancestors() {
roots.push(ancestor.join(".claude").join("workflows"));
roots.push(ancestor.join(".claw").join("workflows"));
}
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
let home = PathBuf::from(home);
roots.push(home.join(".claude").join("workflows"));
roots.push(home.join(".claw").join("workflows"));
}
roots
}
fn find_workflow_in_root(root: &Path, requested: &str) -> Option<PathBuf> {
let candidates = [
root.join(format!("{requested}.js")),
root.join(format!("{requested}.mjs")),
root.join(requested),
];
for candidate in candidates {
if candidate.is_file() {
return Some(candidate);
}
}
for entry in std::fs::read_dir(root).ok()?.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let extension_ok = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| matches!(ext, "js" | "mjs"));
if !extension_ok {
continue;
}
let matches_stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.eq_ignore_ascii_case(requested));
if matches_stem {
return Some(path);
}
if read_workflow_script_file(&path)
.ok()
.and_then(|script| parse_workflow_meta_string(&script, "name"))
.is_some_and(|name| name.eq_ignore_ascii_case(requested))
{
return Some(path);
}
}
None
}
fn list_saved_workflow_names(cwd: &Path) -> Vec<String> {
let mut names = BTreeSet::from([String::from("deep-research")]);
for root in workflow_lookup_roots(cwd) {
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let extension_ok = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| matches!(ext, "js" | "mjs"));
if !extension_ok {
continue;
}
if let Some(name) = read_workflow_script_file(&path)
.ok()
.and_then(|script| parse_workflow_meta_string(&script, "name"))
.or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(ToOwned::to_owned)
})
{
names.insert(name);
}
}
}
names.into_iter().collect()
}
pub fn render_workflows_report(args: Option<&str>) -> Result<String, String> {
let value = workflows_report_json(args)?;
if value["status"] == "empty" {
return Ok(String::from(
"Workflows\n Status no workflow runs found\n Hint run /deep-research <question> or ask with the ultracode keyword",
));
}
if value["action"] == "show" {
return Ok(format_workflow_detail_report(&value));
}
Ok(format_workflow_list_report(&value))
}
pub fn workflows_report_json(args: Option<&str>) -> Result<Value, String> {
let requested = args.map(str::trim).filter(|value| !value.is_empty());
let runs = load_workflow_run_manifests()?;
if let Some(requested) = requested {
let requested = requested
.strip_prefix("show ")
.or_else(|| requested.strip_prefix("open "))
.unwrap_or(requested)
.trim();
let Some(run) = runs.iter().find(|run| run.run_id == requested) else {
return Ok(json!({
"kind": "workflows",
"action": "show",
"status": "not_found",
"runId": requested,
"available": runs.iter().map(|run| run.run_id.clone()).collect::<Vec<_>>(),
}));
};
return Ok(json!({
"kind": "workflows",
"action": "show",
"status": "ok",
"run": run,
}));
}
if runs.is_empty() {
return Ok(json!({
"kind": "workflows",
"action": "list",
"status": "empty",
"runs": [],
}));
}
Ok(json!({
"kind": "workflows",
"action": "list",
"status": "ok",
"count": runs.len(),
"runs": runs,
}))
}
fn load_workflow_run_manifests() -> Result<Vec<WorkflowRunOutput>, String> {
let store_dir = workflow_store_dir()?;
let Ok(entries) = std::fs::read_dir(store_dir) else {
return Ok(Vec::new());
};
let mut runs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(run) = serde_json::from_str::<WorkflowRunOutput>(&contents) {
runs.push(run);
}
}
}
runs.sort_by(|left, right| right.created_at.cmp(&left.created_at));
Ok(runs)
}
fn format_workflow_list_report(value: &Value) -> String {
let mut lines = vec![format!(
"Workflows\n Count {}",
value["count"].as_u64().unwrap_or(0)
)];
if let Some(runs) = value["runs"].as_array() {
for run in runs {
lines.push(format!(
" {} {} {} {} agent(s)",
run["runId"].as_str().unwrap_or("<unknown>"),
run["status"].as_str().unwrap_or("unknown"),
run["name"].as_str().unwrap_or("workflow"),
run["agents"].as_array().map_or(0, Vec::len)
));
}
}
lines.join("\n")
}
fn format_workflow_detail_report(value: &Value) -> String {
if value["status"] == "not_found" {
return format!(
"Workflows\n Status not found\n Run {}",
value["runId"].as_str().unwrap_or("<unknown>")
);
}
let run = &value["run"];
let mut lines = vec![
String::from("Workflow"),
format!(
" Run {}",
run["runId"].as_str().unwrap_or("<unknown>")
),
format!(
" Name {}",
run["name"].as_str().unwrap_or("workflow")
),
format!(
" Status {}",
run["status"].as_str().unwrap_or("unknown")
),
format!(
" Source {}",
run["source"].as_str().unwrap_or("unknown")
),
format!(
" Script {}",
run["scriptPath"].as_str().unwrap_or("<unknown>")
),
format!(
" Message {}",
run["message"].as_str().unwrap_or("")
),
];
if let Some(agents) = run["agents"].as_array() {
lines.push(format!(" Agents {}", agents.len()));
for agent in agents {
lines.push(format!(
" {} {} {}",
agent["agentId"].as_str().unwrap_or("<unknown>"),
agent["status"].as_str().unwrap_or("unknown"),
agent["description"].as_str().unwrap_or("")
));
}
}
lines.join("\n")
}
#[allow(clippy::needless_pass_by_value)]
fn run_monitor(input: MonitorInput) -> Result<String, String> {
to_pretty_json(json!({
@ -3487,6 +4156,62 @@ struct WorkflowInput {
args: Option<Value>,
}
#[derive(Debug, Clone)]
struct ResolvedWorkflowScript {
name: String,
description: Option<String>,
script: String,
script_path: Option<PathBuf>,
source: String,
args: Option<Value>,
}
#[derive(Debug, Clone)]
struct WorkflowAgentCall {
prompt: String,
label: Option<String>,
model: Option<String>,
effort: Option<String>,
agent_type: Option<String>,
isolation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WorkflowRunOutput {
#[serde(rename = "runId")]
run_id: String,
kind: String,
status: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
source: String,
#[serde(rename = "scriptPath")]
script_path: String,
#[serde(rename = "manifestFile")]
manifest_file: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
agents: Vec<WorkflowAgentRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
args: Option<Value>,
message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WorkflowAgentRecord {
#[serde(rename = "agentId")]
agent_id: String,
name: String,
description: String,
status: String,
#[serde(rename = "promptPreview")]
prompt_preview: String,
#[serde(rename = "manifestFile")]
manifest_file: String,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct MonitorInput {
@ -7382,9 +8107,9 @@ mod tests {
classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool,
extract_recovery_outcome, final_assistant_text, global_cron_registry, global_mcp_registry,
maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob,
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
SubagentToolExecutor,
persist_agent_terminal_state, push_output_block, run_task_packet, run_workflow_with_spawn,
AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput,
};
use api::OutputContentBlock;
use runtime::mcp_tool_bridge::{McpConnectionStatus, McpResourceInfo, McpToolInfo};
@ -7550,6 +8275,13 @@ mod tests {
#[test]
fn v201_host_contract_tools_return_stable_payloads() {
let _guard = env_guard();
let workflow_store = temp_path("workflow-contract-store");
let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok();
let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok();
std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store);
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
let workflow = execute_tool(
"Workflow",
&json!({
@ -7560,9 +8292,13 @@ mod tests {
)
.expect("Workflow contract should be registered");
let workflow: serde_json::Value = serde_json::from_str(&workflow).expect("workflow json");
assert_eq!(workflow["status"], "unsupported");
assert_eq!(workflow["status"], "script_error");
assert_eq!(workflow["kind"], "workflow");
assert_eq!(workflow["has_script"], true);
assert_eq!(workflow["name"], "release-check");
assert_eq!(workflow["source"], "inline");
let script_path = Path::new(workflow["scriptPath"].as_str().expect("script path"));
assert!(script_path.exists());
assert!(script_path.starts_with(&workflow_store));
let monitor = execute_tool(
"Monitor",
@ -7643,6 +8379,105 @@ mod tests {
.as_array()
.expect("roles")
.contains(&json!("coding")));
match original_workflow_store {
Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value),
None => std::env::remove_var("CLAWD_WORKFLOW_STORE"),
}
match original_disable_workflows {
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
}
let _ = fs::remove_dir_all(workflow_store);
}
#[test]
fn workflow_static_agent_runner_records_run_and_agents() {
fn ok_spawn(_: AgentJob) -> Result<(), String> {
Ok(())
}
let _guard = env_guard();
let root = temp_path("workflow-static-agent");
let workflow_store = root.join("workflows");
let agent_store = root.join("agents");
let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok();
let original_agent_store = std::env::var("CLAWD_AGENT_STORE").ok();
let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok();
std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store);
std::env::set_var("CLAWD_AGENT_STORE", &agent_store);
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
let output = run_workflow_with_spawn(
WorkflowInput {
script: Some(
"export const meta = { name: 'release-check', description: 'Check release readiness' }\nconst result = await agent(`Check release readiness`, { label: 'research', agentType: 'Explore', effort: 'xhigh' })\nreturn result\n"
.to_string(),
),
args: Some(json!({"target": "tools"})),
..WorkflowInput::default()
},
ok_spawn,
)
.expect("static agent workflow should run");
let output: serde_json::Value = serde_json::from_str(&output).expect("workflow json");
assert_eq!(output["status"], "running");
assert_eq!(output["kind"], "workflow");
assert_eq!(output["name"], "release-check");
assert_eq!(output["description"], "Check release readiness");
assert_eq!(output["source"], "inline");
assert_eq!(output["agents"].as_array().expect("agents").len(), 1);
assert_eq!(output["agents"][0]["description"], "research");
assert_eq!(output["agents"][0]["status"], "running");
assert!(output["message"]
.as_str()
.expect("message")
.contains("/workflows"));
assert!(Path::new(output["scriptPath"].as_str().expect("script path")).exists());
let report = super::workflows_report_json(None).expect("workflow report");
assert_eq!(report["status"], "ok");
assert_eq!(report["count"], 1);
assert_eq!(report["runs"][0]["name"], "release-check");
match original_workflow_store {
Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value),
None => std::env::remove_var("CLAWD_WORKFLOW_STORE"),
}
match original_agent_store {
Some(value) => std::env::set_var("CLAWD_AGENT_STORE", value),
None => std::env::remove_var("CLAWD_AGENT_STORE"),
}
match original_disable_workflows {
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn workflow_tool_respects_disable_env() {
let _guard = env_guard();
let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok();
std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1");
let output = execute_tool(
"Workflow",
&json!({
"name": "deep-research",
"args": "workflow docs"
}),
)
.expect("disabled workflow should return structured payload");
let output: serde_json::Value = serde_json::from_str(&output).expect("workflow json");
assert_eq!(output["status"], "disabled");
assert_eq!(output["kind"], "workflow");
assert_eq!(output["name"], "deep-research");
match original_disable_workflows {
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
}
}
#[test]