@jiggai/recipes 0.4.39 → 0.4.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,9 +116,39 @@ Tool nodes call a tool by name with JSON args. Example:
116
116
  - `fs.append`
117
117
  - `outbound.post`
118
118
  - `message.send`
119
+ - `exec` (shell command execution)
119
120
 
120
121
  Tool nodes support template vars inside string args.
121
122
 
123
+ #### Exec tool nodes
124
+
125
+ Nodes with `"tool": "exec"` run shell commands via the plugin runtime (not the gateway). This means **any agent** can execute them — there is no need to assign exec nodes to `main`.
126
+
127
+ Config fields:
128
+ - `args.command` (string): the shell command to run (passed to `bash -c`)
129
+ - `args.workdir` (string, optional): working directory (defaults to the team workspace)
130
+ - `args.timeout` (number, optional): timeout in seconds (default: 120)
131
+
132
+ **Agent assignment:** Assign exec nodes to the same agent that handles the surrounding workflow — typically the team lead. Avoid assigning to `main` unless the node specifically needs the personal workspace context.
133
+
134
+ ```json
135
+ {
136
+ "id": "run_script",
137
+ "type": "tool",
138
+ "name": "Run deploy script",
139
+ "config": {
140
+ "tool": "exec",
141
+ "args": {
142
+ "command": "bash scripts/deploy.sh --env={{run.id}}",
143
+ "timeout": 60
144
+ },
145
+ "agentId": "my-team-lead"
146
+ }
147
+ }
148
+ ```
149
+
150
+ > **Why not `main`?** Each agent has its own worker queue and cron. If you assign a node to `main` but there's no worker cron for `main` on that team, the task will sit in the queue indefinitely. Use the team's existing agents to keep things flowing.
151
+
122
152
  ---
123
153
 
124
154
  ### Human approval nodes
package/index.ts CHANGED
@@ -1034,6 +1034,19 @@ workflows
1034
1034
  logScaffoldResult(res, String(options.recipe));
1035
1035
  });
1036
1036
 
1037
+ cmd
1038
+ .command("kitchen-manifest")
1039
+ .description("Generate the Kitchen manifest file (pre-computed nav/shell data for ClawKitchen)")
1040
+ .option("--output <path>", "Override output path (default: ~/.openclaw/kitchen-manifest.json)")
1041
+ .action(async (options: { output?: string }) => {
1042
+ const { generateKitchenManifest } = await import("./src/lib/kitchen-manifest");
1043
+ const manifest = await generateKitchenManifest({
1044
+ api,
1045
+ outputPath: options.output || undefined,
1046
+ });
1047
+ console.log(JSON.stringify({ ok: true, generatedAt: manifest.generatedAt, teams: Object.keys(manifest.teams).length, agents: manifest.agents.length, recipes: manifest.recipes.length }));
1048
+ });
1049
+
1037
1050
  },
1038
1051
  { commands: ["recipes"] },
1039
1052
  );
@@ -2,7 +2,7 @@
2
2
  "id": "recipes",
3
3
  "name": "Recipes",
4
4
  "description": "Markdown recipes that scaffold agents and teams (workspace-local).",
5
- "version": "0.4.39",
5
+ "version": "0.4.41",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.4.39",
3
+ "version": "0.4.41",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -13,6 +13,7 @@ import { pickRecipeId } from "../lib/recipe-id";
13
13
  import { recipeIdTakenForTeam, validateRecipeAndSkills, writeWorkspaceRecipeFile } from "../lib/scaffold-utils";
14
14
  import { scaffoldAgentFromRecipe } from "./scaffold";
15
15
  import { renderTemplate } from "../lib/template";
16
+ import { scheduleManifestRegeneration } from "../lib/kitchen-manifest";
16
17
  import { reconcileRecipeCronJobs } from "./cron";
17
18
  import { lintRecipe } from "../lib/recipe-lint";
18
19
 
@@ -510,6 +511,7 @@ export async function handleScaffoldTeam(
510
511
  scope: { kind: "team", teamId, recipeId: recipe.id, stateDir: teamDir },
511
512
  cronInstallation: cfg.cronInstallation,
512
513
  });
514
+ scheduleManifestRegeneration(api);
513
515
  return {
514
516
  ok: true as const,
515
517
  teamId,
@@ -645,6 +647,7 @@ export async function executeMigrateTeamPlan(
645
647
  await applyAgentSnippetsToOpenClawConfig(api, agentSnippets);
646
648
  }
647
649
 
650
+ scheduleManifestRegeneration(api);
648
651
  return { ok: true as const, migrated: plan.teamId, destTeamDir: dest.teamDir, agentIds: plan.agentIds };
649
652
  }
650
653
 
@@ -707,5 +710,6 @@ export async function handleRemoveTeam(
707
710
  });
708
711
  await writeOpenClawConfig(api, cfgObj);
709
712
  await saveCronStore(cronJobsPath, cronStore);
713
+ scheduleManifestRegeneration(api);
710
714
  return { ok: true as const, result };
711
715
  }
@@ -7,6 +7,7 @@ import { ticketStageDir } from "../lib/lanes";
7
7
  import { computeNextTicketNumber, TICKET_FILENAME_REGEX } from "../lib/ticket-finder";
8
8
  import { resolveTeamContext } from "../lib/workspace";
9
9
  import { VALID_ROLES, VALID_STAGES } from "../lib/constants";
10
+ import { scheduleManifestRegeneration } from "../lib/kitchen-manifest";
10
11
 
11
12
  export function patchTicketField(md: string, key: string, value: string): string {
12
13
  const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
@@ -110,6 +111,7 @@ export async function handleMoveTicket(
110
111
 
111
112
  // Assignment stubs are deprecated; no archival behavior.
112
113
 
114
+ scheduleManifestRegeneration(api);
113
115
  return { ok: true, from: srcPath, to: destPath };
114
116
  }
115
117
 
@@ -293,6 +295,7 @@ export async function handleDispatch(
293
295
  // Dispatch still succeeds; nudgeQueued stays false so caller knows the nudge was skipped.
294
296
  nudgeQueued = false;
295
297
  }
298
+ scheduleManifestRegeneration(api);
296
299
  return { ok: true as const, wrote: plan.files.map((f) => f.path), nudgeQueued };
297
300
  }
298
301
 
@@ -0,0 +1,222 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
5
+
6
+ // ── Manifest types ──────────────────────────────────────────────────────────
7
+
8
+ export interface KitchenManifest {
9
+ version: 1;
10
+ generatedAt: string;
11
+ teams: Record<string, TeamManifestEntry>;
12
+ agents: AgentManifestEntry[];
13
+ recipes: RecipeManifestEntry[];
14
+ }
15
+
16
+ export interface TeamManifestEntry {
17
+ teamId: string;
18
+ displayName: string | null;
19
+ roles: string[];
20
+ ticketCounts: {
21
+ backlog: number;
22
+ 'in-progress': number;
23
+ testing: number;
24
+ done: number;
25
+ total: number;
26
+ };
27
+ activeRunCount: number;
28
+ }
29
+
30
+ export interface AgentManifestEntry {
31
+ id: string;
32
+ identityName?: string;
33
+ workspace?: string;
34
+ model?: string;
35
+ isDefault?: boolean;
36
+ }
37
+
38
+ export interface RecipeManifestEntry {
39
+ id: string;
40
+ name: string;
41
+ kind: 'agent' | 'team';
42
+ source: 'builtin' | 'workspace';
43
+ }
44
+
45
+ // ── Helpers ─────────────────────────────────────────────────────────────────
46
+
47
+ const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
48
+ const MANIFEST_FILENAME = 'kitchen-manifest.json';
49
+
50
+ export function defaultManifestPath(): string {
51
+ return path.join(OPENCLAW_DIR, MANIFEST_FILENAME);
52
+ }
53
+
54
+ async function countMdFiles(dir: string): Promise<number> {
55
+ try {
56
+ const entries = await fs.readdir(dir);
57
+ return entries.filter((e) => e.endsWith('.md')).length;
58
+ } catch {
59
+ return 0;
60
+ }
61
+ }
62
+
63
+ async function countActiveRuns(teamDir: string): Promise<number> {
64
+ const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
65
+ let runDirs: string[];
66
+ try {
67
+ runDirs = await fs.readdir(runsDir);
68
+ } catch {
69
+ return 0;
70
+ }
71
+
72
+ let count = 0;
73
+ for (const d of runDirs) {
74
+ const runJson = path.join(runsDir, d, 'run.json');
75
+ try {
76
+ const raw = await fs.readFile(runJson, 'utf8');
77
+ const run = JSON.parse(raw) as { status?: string };
78
+ const s = run.status ?? '';
79
+ if (s === 'running' || s === 'waiting_workers' || s === 'waiting_for_approval') {
80
+ count++;
81
+ }
82
+ } catch {
83
+ // run.json missing or malformed — skip
84
+ }
85
+ }
86
+ return count;
87
+ }
88
+
89
+ async function listRoles(teamDir: string): Promise<string[]> {
90
+ const rolesDir = path.join(teamDir, 'roles');
91
+ try {
92
+ const entries = await fs.readdir(rolesDir, { withFileTypes: true });
93
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
98
+
99
+ async function readTeamDisplayName(teamDir: string): Promise<string | null> {
100
+ const teamJsonPath = path.join(teamDir, 'shared-context', 'workflows', 'team.json');
101
+ try {
102
+ const raw = await fs.readFile(teamJsonPath, 'utf8');
103
+ const parsed = JSON.parse(raw) as { recipeName?: string };
104
+ return parsed.recipeName?.trim() || null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ // ── Generator ───────────────────────────────────────────────────────────────
111
+
112
+ export interface GenerateManifestOptions {
113
+ api: OpenClawPluginApi;
114
+ outputPath?: string;
115
+ }
116
+
117
+ export async function generateKitchenManifest(opts: GenerateManifestOptions): Promise<KitchenManifest> {
118
+ const { api } = opts;
119
+ const outputPath = opts.outputPath ?? defaultManifestPath();
120
+
121
+ // Discover teams
122
+ let dirEntries: string[];
123
+ try {
124
+ dirEntries = await fs.readdir(OPENCLAW_DIR);
125
+ } catch {
126
+ dirEntries = [];
127
+ }
128
+
129
+ const teamDirNames = dirEntries.filter((e) => e.startsWith('workspace-'));
130
+ const teams: Record<string, TeamManifestEntry> = {};
131
+
132
+ for (const dirName of teamDirNames) {
133
+ const teamId = dirName.slice('workspace-'.length);
134
+ if (!teamId) continue;
135
+
136
+ const teamDir = path.join(OPENCLAW_DIR, dirName);
137
+ const workDir = path.join(teamDir, 'work');
138
+
139
+ const [backlog, inProgress, testing, done, activeRunCount, roles, displayName] = await Promise.all([
140
+ countMdFiles(path.join(workDir, 'backlog')),
141
+ countMdFiles(path.join(workDir, 'in-progress')),
142
+ countMdFiles(path.join(workDir, 'testing')),
143
+ countMdFiles(path.join(workDir, 'done')),
144
+ countActiveRuns(teamDir),
145
+ listRoles(teamDir),
146
+ readTeamDisplayName(teamDir),
147
+ ]);
148
+
149
+ teams[teamId] = {
150
+ teamId,
151
+ displayName,
152
+ roles,
153
+ ticketCounts: {
154
+ backlog,
155
+ 'in-progress': inProgress,
156
+ testing,
157
+ done,
158
+ total: backlog + inProgress + testing + done,
159
+ },
160
+ activeRunCount,
161
+ };
162
+ }
163
+
164
+ // Fetch agents and recipes via CLI (reuses existing OpenClaw infrastructure)
165
+ let agents: AgentManifestEntry[] = [];
166
+ try {
167
+ const res = await api.runtime.system.runCommandWithTimeout(
168
+ ['openclaw', 'agents', 'list', '--json'],
169
+ { timeoutMs: 15_000 },
170
+ );
171
+ if (res.code === 0 && res.stdout) {
172
+ agents = JSON.parse(res.stdout) as AgentManifestEntry[];
173
+ }
174
+ } catch { /* best-effort */ }
175
+
176
+ let recipes: RecipeManifestEntry[] = [];
177
+ try {
178
+ const res = await api.runtime.system.runCommandWithTimeout(
179
+ ['openclaw', 'recipes', 'list'],
180
+ { timeoutMs: 15_000 },
181
+ );
182
+ if (res.code === 0 && res.stdout) {
183
+ recipes = JSON.parse(res.stdout) as RecipeManifestEntry[];
184
+ }
185
+ } catch { /* best-effort */ }
186
+
187
+ const manifest: KitchenManifest = {
188
+ version: 1,
189
+ generatedAt: new Date().toISOString(),
190
+ teams,
191
+ agents,
192
+ recipes,
193
+ };
194
+
195
+ // Atomic write: tmp file + rename
196
+ const tmpPath = outputPath + '.tmp';
197
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
198
+ await fs.writeFile(tmpPath, JSON.stringify(manifest, null, 2), 'utf8');
199
+ await fs.rename(tmpPath, outputPath);
200
+
201
+ return manifest;
202
+ }
203
+
204
+ // ── Debounced regeneration ──────────────────────────────────────────────────
205
+
206
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
207
+ const DEBOUNCE_MS = 500;
208
+
209
+ /**
210
+ * Schedule a manifest regeneration. Multiple calls within DEBOUNCE_MS are
211
+ * coalesced into a single generation. Fire-and-forget — errors are logged
212
+ * but never propagated.
213
+ */
214
+ export function scheduleManifestRegeneration(api: OpenClawPluginApi): void {
215
+ if (debounceTimer) clearTimeout(debounceTimer);
216
+ debounceTimer = setTimeout(() => {
217
+ debounceTimer = null;
218
+ generateKitchenManifest({ api }).catch((err) => {
219
+ console.error('[kitchen-manifest] regeneration failed:', err instanceof Error ? err.message : String(err));
220
+ });
221
+ }, DEBOUNCE_MS);
222
+ }
@@ -43,7 +43,8 @@ export class GenericDriver implements MediaDriver {
43
43
  }
44
44
 
45
45
  // Execute the script with stdin input (most common interface)
46
- const scriptOutput = runScript({
46
+ const scriptOutput = await runScript({
47
+ api: opts.api,
47
48
  runner,
48
49
  script: scriptPath,
49
50
  stdin: prompt,
@@ -78,7 +78,8 @@ export class KlingVideo implements MediaDriver {
78
78
  // The official skill is a Node.js script (not Python)
79
79
  const runner = 'node';
80
80
 
81
- const scriptOutput = runScript({
81
+ const scriptOutput = await runScript({
82
+ api: opts.api,
82
83
  runner,
83
84
  script: scriptPath,
84
85
  args: [
@@ -26,7 +26,8 @@ export class LumaVideo implements MediaDriver {
26
26
  const runner = await findVenvPython(skillDir);
27
27
 
28
28
  // Execute the script with stdin input
29
- const scriptOutput = runScript({
29
+ const scriptOutput = await runScript({
30
+ api: opts.api,
30
31
  runner,
31
32
  script: scriptPath,
32
33
  stdin: prompt,
@@ -42,7 +43,7 @@ export class LumaVideo implements MediaDriver {
42
43
 
43
44
  // Parse the MEDIA: output
44
45
  const filePath = parseMediaOutput(scriptOutput);
45
-
46
+
46
47
  if (!filePath) {
47
48
  throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
48
49
  }
@@ -39,7 +39,8 @@ export class NanoBananaPro implements MediaDriver {
39
39
  const resolution = maxDim >= 3840 ? '4K' : maxDim >= 1792 ? '2K' : '1K';
40
40
 
41
41
  // Execute the script with argparse CLI interface
42
- const scriptOutput = runScript({
42
+ const scriptOutput = await runScript({
43
+ api: opts.api,
43
44
  runner,
44
45
  script: scriptPath,
45
46
  args: ['--prompt', prompt, '--filename', filename, '--resolution', resolution],
@@ -28,7 +28,8 @@ export class OpenAIImageGen implements MediaDriver {
28
28
  const size = String(config?.size ?? '1024x1024');
29
29
 
30
30
  // Execute the script with stdin input
31
- const scriptOutput = runScript({
31
+ const scriptOutput = await runScript({
32
+ api: opts.api,
32
33
  runner,
33
34
  script: scriptPath,
34
35
  stdin: prompt,
@@ -26,7 +26,8 @@ export class RunwayVideo implements MediaDriver {
26
26
  const runner = await findVenvPython(skillDir);
27
27
 
28
28
  // Execute the script with stdin input
29
- const scriptOutput = runScript({
29
+ const scriptOutput = await runScript({
30
+ api: opts.api,
30
31
  runner,
31
32
  script: scriptPath,
32
33
  stdin: prompt,
@@ -42,7 +43,7 @@ export class RunwayVideo implements MediaDriver {
42
43
 
43
44
  // Parse the MEDIA: output
44
45
  const filePath = parseMediaOutput(scriptOutput);
45
-
46
+
46
47
  if (!filePath) {
47
48
  throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
48
49
  }
@@ -1,4 +1,7 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
+
1
3
  export interface MediaDriverInvokeOpts {
4
+ api: OpenClawPluginApi;
2
5
  prompt: string;
3
6
  outputDir: string;
4
7
  env: Record<string, string>;
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
- import { execFileSync } from 'child_process';
3
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
4
 
5
5
  /**
6
6
  * Find a skill directory by searching common skill roots
@@ -33,7 +33,7 @@ export async function findSkillDir(slug: string): Promise<string | null> {
33
33
  */
34
34
  export async function findVenvPython(skillDir: string): Promise<string> {
35
35
  const venvPython = path.join(skillDir, '.venv', 'bin', 'python');
36
-
36
+
37
37
  try {
38
38
  await fs.access(venvPython);
39
39
  return venvPython;
@@ -48,7 +48,7 @@ export async function findVenvPython(skillDir: string): Promise<string> {
48
48
  export async function loadConfigEnv(): Promise<Record<string, string>> {
49
49
  const homedir = process.env.HOME || '/home/control';
50
50
  const configPath = path.join(homedir, '.openclaw', 'openclaw.json');
51
-
51
+
52
52
  try {
53
53
  const cfgRaw = await fs.readFile(configPath, 'utf8');
54
54
  const cfgParsed = JSON.parse(cfgRaw);
@@ -82,9 +82,12 @@ export function parseMediaOutput(stdout: string): string {
82
82
  }
83
83
 
84
84
  /**
85
- * Execute a script with proper error handling and output capture
85
+ * Execute a script via the OpenClaw exec tool so this plugin package does not
86
+ * directly import child_process. We still pass argv as discrete args and feed
87
+ * prompt text via stdin through a small Python wrapper script.
86
88
  */
87
89
  export interface RunScriptOpts {
90
+ api: OpenClawPluginApi;
88
91
  runner: string;
89
92
  script: string;
90
93
  args?: string[];
@@ -94,26 +97,75 @@ export interface RunScriptOpts {
94
97
  timeout: number;
95
98
  }
96
99
 
97
- export function runScript(opts: RunScriptOpts): string {
100
+ function buildPythonExecSnippet(opts: RunScriptOpts): string {
98
101
  const { runner, script, args = [], stdin, env, cwd, timeout } = opts;
102
+ const mergedEnv = {
103
+ ...env,
104
+ MEDIA_OUTPUT_DIR: cwd,
105
+ };
106
+
107
+ const payload = {
108
+ runner,
109
+ script,
110
+ args,
111
+ stdin: stdin ?? '',
112
+ env: mergedEnv,
113
+ cwd,
114
+ timeoutMs: timeout,
115
+ };
116
+
117
+ // Base64-encode the payload to avoid shell injection and heredoc delimiter collisions.
118
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64');
119
+
120
+ return [
121
+ `python3 -c '`,
122
+ `import base64, json, os, subprocess, sys;`,
123
+ `payload = json.loads(base64.b64decode("${payloadB64}").decode());`,
124
+ `env = os.environ.copy();`,
125
+ `env.update({k: str(v) for k, v in payload["env"].items()});`,
126
+ `res = subprocess.run(`,
127
+ ` [payload["runner"], payload["script"], *payload.get("args", [])],`,
128
+ ` input=payload.get("stdin", ""),`,
129
+ ` text=True,`,
130
+ ` capture_output=True,`,
131
+ ` cwd=payload["cwd"],`,
132
+ ` env=env,`,
133
+ ` timeout=max(1, int(payload.get("timeoutMs", 1000) / 1000))`,
134
+ `);`,
135
+ `sys.stdout.write(res.stdout);`,
136
+ `sys.stderr.write(res.stderr);`,
137
+ `raise SystemExit(res.returncode)`,
138
+ `'`,
139
+ ].join('\n');
140
+ }
141
+
142
+ export async function runScript(opts: RunScriptOpts): Promise<string> {
143
+ const { api, timeout } = opts;
144
+ const timeoutMs = Math.max(1000, timeout + 5000);
145
+ const command = buildPythonExecSnippet(opts);
99
146
 
100
147
  try {
101
- return execFileSync(runner, [script, ...args], {
102
- cwd,
103
- timeout,
104
- encoding: 'utf8',
105
- input: stdin,
106
- env: {
107
- ...process.env,
108
- ...env,
109
- MEDIA_OUTPUT_DIR: cwd,
110
- },
111
- }).trim();
148
+ // Use the plugin SDK's runtime exec — available to all plugins without
149
+ // gateway tool permissions (unlike toolsInvoke('exec') which is session-gated).
150
+ const result = await api.runtime.system.runCommandWithTimeout(
151
+ ['bash', '-c', command],
152
+ { timeoutMs, cwd: opts.cwd },
153
+ );
154
+
155
+ if (result.code !== 0) {
156
+ const msg = [
157
+ `Script execution failed with exit code ${result.code}`,
158
+ result.stdout ? `\n--- stdout ---\n${result.stdout.trim()}` : '',
159
+ result.stderr ? `\n--- stderr ---\n${result.stderr.trim()}` : '',
160
+ ].filter(Boolean).join('');
161
+ throw new Error(msg);
162
+ }
163
+
164
+ return (result.stdout || '').trim();
112
165
  } catch (err) {
113
- // Surface stderr/stdout to make debugging skill scripts possible
114
- const e = err as any;
115
- const stdout = typeof e?.stdout === 'string' ? e.stdout : (Buffer.isBuffer(e?.stdout) ? e.stdout.toString('utf8') : '');
116
- const stderr = typeof e?.stderr === 'string' ? e.stderr : (Buffer.isBuffer(e?.stderr) ? e.stderr.toString('utf8') : '');
166
+ const e = err as Error & { stdout?: string; stderr?: string };
167
+ const stdout = e?.stdout ?? '';
168
+ const stderr = e?.stderr ?? '';
117
169
  const msg = [
118
170
  e?.message ? String(e.message) : 'Script execution failed',
119
171
  stdout ? `\n--- stdout ---\n${stdout.trim()}` : '',
@@ -128,7 +180,7 @@ export function runScript(opts: RunScriptOpts): string {
128
180
  */
129
181
  export async function findScriptInSkill(skillDir: string, scriptCandidates: string[]): Promise<string | null> {
130
182
  const searchDirs = [skillDir, path.join(skillDir, 'scripts')];
131
-
183
+
132
184
  for (const dir of searchDirs) {
133
185
  for (const candidate of scriptCandidates) {
134
186
  const scriptPath = path.join(dir, candidate);
@@ -140,6 +192,6 @@ export async function findScriptInSkill(skillDir: string, scriptCandidates: stri
140
192
  }
141
193
  }
142
194
  }
143
-
195
+
144
196
  return null;
145
- }
197
+ }
@@ -0,0 +1,69 @@
1
+ import { ToolsInvokeError } from '../../toolsInvoke.js';
2
+
3
+ export type ErrorCategory = 'funding' | 'rate-limit' | 'auth' | 'timeout' | 'unknown';
4
+
5
+ const FUNDING_PATTERNS = [
6
+ /insufficient.*(credits?|funds?|balance)/i,
7
+ /billing/i,
8
+ /payment\s+required/i,
9
+ /quota\s+exceeded/i,
10
+ /out\s+of\s+credits/i,
11
+ /budget\s+(exceeded|limit)/i,
12
+ /no\s+(active\s+)?subscription/i,
13
+ /plan\s+(limit|exceeded)/i,
14
+ ];
15
+
16
+ const RATE_LIMIT_PATTERNS = [
17
+ /rate\s+limit/i,
18
+ /too\s+many\s+requests/i,
19
+ /throttl/i,
20
+ ];
21
+
22
+ const AUTH_PATTERNS = [
23
+ /unauthorized/i,
24
+ /invalid.*api.?key/i,
25
+ /forbidden/i,
26
+ /authentication\s+failed/i,
27
+ /access\s+denied/i,
28
+ ];
29
+
30
+ function classifyByHttpStatus(status: number): ErrorCategory | null {
31
+ if (status === 402) return 'funding';
32
+ if (status === 429) return 'rate-limit';
33
+ if (status === 401 || status === 403) return 'auth';
34
+ if (status === 408 || status === 504) return 'timeout';
35
+ return null;
36
+ }
37
+
38
+ function classifyByMessage(message: string, error: unknown): ErrorCategory | null {
39
+ if (FUNDING_PATTERNS.some((p) => p.test(message))) return 'funding';
40
+ if (RATE_LIMIT_PATTERNS.some((p) => p.test(message))) return 'rate-limit';
41
+ if (AUTH_PATTERNS.some((p) => p.test(message))) return 'auth';
42
+ if (error instanceof Error && error.name === 'AbortError') return 'timeout';
43
+ if (/timed?\s*out/i.test(message)) return 'timeout';
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Classify an error into a category based on HTTP status and message content.
49
+ * Returns 'unknown' if the error doesn't match any known pattern.
50
+ */
51
+ export function classifyError(error: unknown): ErrorCategory {
52
+ const httpStatus = error instanceof ToolsInvokeError ? error.httpStatus : 0;
53
+ const message = error instanceof Error ? error.message : String(error ?? '');
54
+
55
+ return classifyByHttpStatus(httpStatus) ?? classifyByMessage(message, error) ?? 'unknown';
56
+ }
57
+
58
+ const CATEGORY_LABELS: Record<ErrorCategory, string> = {
59
+ 'funding': 'Funding issue — the model provider may be out of credits or require payment',
60
+ 'rate-limit': 'Rate limit — the model provider is throttling requests',
61
+ 'auth': 'Authentication failure — the API key may be invalid or expired',
62
+ 'timeout': 'Timeout — the request took too long to complete',
63
+ 'unknown': 'Unknown error',
64
+ };
65
+
66
+ /** Human-readable label for an error category. */
67
+ export function errorCategoryLabel(category: ErrorCategory): string {
68
+ return CATEGORY_LABELS[category] ?? CATEGORY_LABELS['unknown'];
69
+ }
@@ -192,7 +192,7 @@ export async function moveRunTicket(opts: {
192
192
  return { ticketPath: dest };
193
193
  }
194
194
 
195
- export function loadNodeStatesFromRun(run: RunLog): Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> {
195
+ export function loadNodeStatesFromRun(run: RunLog, opts?: { workflow?: Workflow }): Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> {
196
196
  const out: Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> = {};
197
197
 
198
198
  const cur = run.nodeStates;
@@ -217,6 +217,19 @@ export function loadNodeStatesFromRun(run: RunLog): Record<string, { status: 'su
217
217
  if (type === 'node.approved') out[nodeId] = { status: 'success', ts };
218
218
  }
219
219
 
220
+ // Revision semantics: when a run is in needs_revision, the approval handler
221
+ // clears nodeStates from nextNodeIndex onward, but events are append-only.
222
+ // The event-based reconstruction above would re-populate states that were
223
+ // deliberately cleared. Remove them so the stale-task guard in the worker
224
+ // does not reject re-enqueued revision tasks.
225
+ if (run.status === 'needs_revision' && typeof run.nextNodeIndex === 'number' && opts?.workflow) {
226
+ const nodes = Array.isArray(opts.workflow.nodes) ? opts.workflow.nodes : [];
227
+ for (let i = Math.max(0, run.nextNodeIndex); i < nodes.length; i++) {
228
+ const id = asString(nodes[i]?.id).trim();
229
+ if (id) delete out[id];
230
+ }
231
+ }
232
+
220
233
  return out;
221
234
  }
222
235
 
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
5
5
  import type { ToolTextResult } from '../../toolsInvoke';
6
6
  import { toolsInvoke } from '../../toolsInvoke';
7
+ import { classifyError, errorCategoryLabel } from './workflow-error-classify';
7
8
  import { resolveTeamDir } from '../workspace';
8
9
  import { getDriver } from './media-drivers/registry';
9
10
  import { GenericDriver } from './media-drivers/generic.driver';
@@ -668,7 +669,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
668
669
  // cursor. Before executing a dequeued task, verify that this node is still actually runnable
669
670
  // for the current run state. Otherwise we can resurrect pre-approval work and overwrite
670
671
  // canonical node outputs for runs that already advanced.
671
- const currentNodeStates = loadNodeStatesFromRun(run);
672
+ const currentNodeStates = loadNodeStatesFromRun(run, { workflow });
672
673
  const currentStatus = currentNodeStates[String(node.id)]?.status;
673
674
  const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run });
674
675
  if (
@@ -748,9 +749,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
748
749
  'workflow.id': String(workflow.id ?? ''),
749
750
  'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
750
751
  };
751
-
752
+
752
753
  // Load node outputs and make them available as template variables
753
754
  const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
755
+
756
+ // Expose triggerInput as template variables (for handoff-injected data)
757
+ if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
758
+ for (const [key, value] of Object.entries(runSnap.triggerInput)) {
759
+ if (typeof value === 'string') {
760
+ vars[`trigger.${key}`] = value;
761
+ } else if (value !== null && value !== undefined) {
762
+ vars[`trigger.${key}`] = JSON.stringify(value);
763
+ }
764
+ }
765
+ }
754
766
  for (const nr of (runSnap.nodeResults ?? [])) {
755
767
  const nid = String((nr as Record<string, unknown>).nodeId ?? '');
756
768
  const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
@@ -911,6 +923,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
911
923
  text = JSON.stringify(payload, null, 2);
912
924
  } catch (e) {
913
925
  const eRec = asRecord(e);
926
+ const errorCategory = classifyError(e);
914
927
  const errorDetails = {
915
928
  message: e instanceof Error ? e.message : String(e),
916
929
  name: e instanceof Error ? e.name : undefined,
@@ -919,6 +932,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
919
932
  details: eRec['details'],
920
933
  data: eRec['data'],
921
934
  cause: e instanceof Error && 'cause' in e ? (e as Error & { cause?: unknown }).cause : undefined,
935
+ errorCategory,
936
+ errorCategoryLabel: errorCategory !== 'unknown' ? errorCategoryLabel(errorCategory) : undefined,
922
937
  };
923
938
  const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${errorDetails.message}`;
924
939
  const errorTs = new Date().toISOString();
@@ -928,18 +943,18 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
928
943
  updatedAt: errorTs,
929
944
  nodeStates: {
930
945
  ...(cur.nodeStates ?? {}),
931
- [node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails },
946
+ [node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails, errorCategory },
932
947
  },
933
948
  events: [
934
949
  ...cur.events,
935
- { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails },
950
+ { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails, errorCategory },
936
951
  ],
937
952
  nodeResults: [
938
953
  ...(cur.nodeResults ?? []),
939
- { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails },
954
+ { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails, errorCategory },
940
955
  ],
941
956
  }));
942
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
957
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
943
958
  continue;
944
959
  }
945
960
 
@@ -1254,9 +1269,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1254
1269
  'workflow.id': String(workflow.id ?? ''),
1255
1270
  'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
1256
1271
  };
1257
-
1272
+
1258
1273
  // Load node outputs and make them available as template variables
1259
1274
  const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
1275
+
1276
+ // Expose triggerInput as template variables (for handoff-injected data)
1277
+ if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
1278
+ for (const [key, value] of Object.entries(runSnap.triggerInput)) {
1279
+ if (typeof value === 'string') {
1280
+ vars[`trigger.${key}`] = value;
1281
+ } else if (value !== null && value !== undefined) {
1282
+ vars[`trigger.${key}`] = JSON.stringify(value);
1283
+ }
1284
+ }
1285
+ }
1260
1286
  for (const nr of (runSnap.nodeResults ?? [])) {
1261
1287
  const nid = String((nr as Record<string, unknown>).nodeId ?? '');
1262
1288
  const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
@@ -1325,10 +1351,28 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1325
1351
  }
1326
1352
  }
1327
1353
 
1328
- const toolRes = await toolsInvoke<unknown>(api, {
1329
- tool: toolName,
1330
- args: processedToolArgs,
1331
- });
1354
+ let toolRes: unknown;
1355
+ if (toolName === 'exec') {
1356
+ // Route exec tool calls through the plugin SDK runtime instead of
1357
+ // the gateway — the gateway exec tool is session-gated and unavailable
1358
+ // to most workflow worker sessions.
1359
+ const command = String((processedToolArgs as Record<string, unknown>).command ?? '');
1360
+ const workdir = String((processedToolArgs as Record<string, unknown>).workdir ?? teamDir);
1361
+ const timeoutSec = Number((processedToolArgs as Record<string, unknown>).timeout) || 120;
1362
+ const result = await api.runtime.system.runCommandWithTimeout(
1363
+ ['bash', '-c', command],
1364
+ { timeoutMs: timeoutSec * 1000, cwd: workdir },
1365
+ );
1366
+ if (result.code !== 0) {
1367
+ throw new Error(`exec failed (code=${result.code}):\n${result.stderr || result.stdout}`);
1368
+ }
1369
+ toolRes = { stdout: result.stdout, stderr: result.stderr, code: result.code };
1370
+ } else {
1371
+ toolRes = await toolsInvoke<unknown>(api, {
1372
+ tool: toolName,
1373
+ args: processedToolArgs,
1374
+ });
1375
+ }
1332
1376
 
1333
1377
  await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: processedToolArgs, result: toolRes }, null, 2) + '\n', 'utf8');
1334
1378
  }
@@ -1356,16 +1400,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1356
1400
  nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
1357
1401
  }));
1358
1402
  } catch (e) {
1359
- await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message }, null, 2) + '\n', 'utf8');
1403
+ const errorCategory = classifyError(e);
1404
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message, errorCategory }, null, 2) + '\n', 'utf8');
1360
1405
  const errorTs = new Date().toISOString();
1361
1406
  await appendRunLog(runPath, (cur) => ({
1362
1407
  ...cur,
1363
1408
  status: 'error',
1364
- nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs } },
1365
- events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
1366
- nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
1409
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, errorCategory } },
1410
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
1411
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
1367
1412
  }));
1368
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
1413
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message, errorCategory });
1369
1414
  continue;
1370
1415
  }
1371
1416
  } else if (kind === 'media-image' || kind === 'media-video' || kind === 'media-audio') {
@@ -1477,6 +1522,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1477
1522
  let payload: Record<string, unknown>;
1478
1523
  if (driver) {
1479
1524
  const result = await driver.invoke({
1525
+ api,
1480
1526
  prompt: refinedPrompt,
1481
1527
  outputDir: mediaDir,
1482
1528
  env: mergedEnv,
@@ -1503,6 +1549,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1503
1549
  }
1504
1550
  text = JSON.stringify(payload, null, 2);
1505
1551
  } catch (e) {
1552
+ const errorCategory = classifyError(e);
1506
1553
  const errDetails = e instanceof Error
1507
1554
  ? { message: e.message, name: e.name, stack: e.stack?.split('\n').slice(0, 5).join(' | ') }
1508
1555
  : { message: String(e) };
@@ -1512,11 +1559,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1512
1559
  ...cur,
1513
1560
  status: 'error',
1514
1561
  updatedAt: errorTs,
1515
- nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
1516
- events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
1517
- nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg }],
1562
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg, errorCategory } },
1563
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, errorCategory }],
1564
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg, errorCategory }],
1518
1565
  }));
1519
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
1566
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
1520
1567
  continue;
1521
1568
  }
1522
1569
 
@@ -6,6 +6,19 @@ export const TOOLS_INVOKE_TIMEOUT_MS = 120_000;
6
6
  export const RETRY_DELAY_BASE_MS = 150;
7
7
  export const GATEWAY_DEFAULT_PORT = 18789;
8
8
 
9
+ /**
10
+ * Custom error class that preserves HTTP status from gateway responses.
11
+ * Used downstream to classify errors (e.g. 402 → funding, 429 → rate-limit).
12
+ */
13
+ export class ToolsInvokeError extends Error {
14
+ httpStatus: number;
15
+ constructor(message: string, httpStatus: number) {
16
+ super(message);
17
+ this.name = 'ToolsInvokeError';
18
+ this.httpStatus = httpStatus;
19
+ }
20
+ }
21
+
9
22
  export type ToolTextResult = { content?: Array<{ type: string; text?: string }> };
10
23
 
11
24
  export type ToolsInvokeRequest = {
@@ -43,7 +56,7 @@ async function doSingleToolsInvoke<T>(url: string, token: string, req: ToolsInvo
43
56
  }).finally(() => clearTimeout(t));
44
57
 
45
58
  const json = (await res.json()) as ToolsInvokeResponse;
46
- if (!res.ok || !json.ok) throw new Error(parseToolsInvokeError(json, res.status));
59
+ if (!res.ok || !json.ok) throw new ToolsInvokeError(parseToolsInvokeError(json, res.status), res.status);
47
60
  return json.result as T;
48
61
  }
49
62