cueclaw 0.0.1 → 0.0.3

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.
@@ -0,0 +1,1051 @@
1
+ import {
2
+ insertStepRun,
3
+ insertWorkflowRun,
4
+ updateStepRunStatus,
5
+ updateWorkflowPhase,
6
+ updateWorkflowRunStatus
7
+ } from "./chunk-K4PGB2DU.js";
8
+ import {
9
+ ConfigError,
10
+ ExecutorError,
11
+ PlannerError,
12
+ cueclawHome,
13
+ loadConfig
14
+ } from "./chunk-JRHM3Z4C.js";
15
+ import {
16
+ logger
17
+ } from "./chunk-E7BP6DMO.js";
18
+
19
+ // src/planner.ts
20
+ import Anthropic from "@anthropic-ai/sdk";
21
+ import { z } from "zod/v4";
22
+ import { nanoid } from "nanoid";
23
+
24
+ // src/workflow.ts
25
+ function validateDAG(steps) {
26
+ const errors = [];
27
+ const stepIds = new Set(steps.map((s) => s.id));
28
+ for (const step of steps) {
29
+ for (const dep of step.depends_on) {
30
+ if (!stepIds.has(dep)) {
31
+ errors.push(`Step "${step.id}" depends on unknown step "${dep}"`);
32
+ }
33
+ }
34
+ }
35
+ for (const step of steps) {
36
+ const refs = extractStepRefs(step.inputs);
37
+ for (const ref of refs) {
38
+ if (!stepIds.has(ref)) {
39
+ errors.push(`Step "${step.id}" references unknown step "${ref}" in inputs`);
40
+ }
41
+ if (stepIds.has(ref) && !step.depends_on.includes(ref)) {
42
+ errors.push(`Step "${step.id}" references "$steps.${ref}.output" but does not list "${ref}" in depends_on`);
43
+ }
44
+ }
45
+ }
46
+ if (hasCycle(steps)) {
47
+ errors.push("DAG contains a cycle");
48
+ }
49
+ return errors;
50
+ }
51
+ function extractStepRefs(obj) {
52
+ const refs = [];
53
+ const pattern = /\$steps\.([a-z0-9-]+)\.output/g;
54
+ function walk(value) {
55
+ if (typeof value === "string") {
56
+ let match;
57
+ while ((match = pattern.exec(value)) !== null) {
58
+ refs.push(match[1]);
59
+ }
60
+ pattern.lastIndex = 0;
61
+ } else if (Array.isArray(value)) {
62
+ for (const item of value) walk(item);
63
+ } else if (value !== null && typeof value === "object") {
64
+ for (const v of Object.values(value)) walk(v);
65
+ }
66
+ }
67
+ walk(obj);
68
+ return refs;
69
+ }
70
+ function hasCycle(steps) {
71
+ const inDegree = /* @__PURE__ */ new Map();
72
+ const adjacency = /* @__PURE__ */ new Map();
73
+ for (const step of steps) {
74
+ inDegree.set(step.id, 0);
75
+ adjacency.set(step.id, []);
76
+ }
77
+ for (const step of steps) {
78
+ for (const dep of step.depends_on) {
79
+ if (adjacency.has(dep)) {
80
+ adjacency.get(dep).push(step.id);
81
+ inDegree.set(step.id, (inDegree.get(step.id) ?? 0) + 1);
82
+ }
83
+ }
84
+ }
85
+ const queue = [];
86
+ for (const [id, deg] of inDegree) {
87
+ if (deg === 0) queue.push(id);
88
+ }
89
+ let processed = 0;
90
+ while (queue.length > 0) {
91
+ const current = queue.shift();
92
+ processed++;
93
+ for (const neighbor of adjacency.get(current) ?? []) {
94
+ const newDeg = (inDegree.get(neighbor) ?? 1) - 1;
95
+ inDegree.set(neighbor, newDeg);
96
+ if (newDeg === 0) queue.push(neighbor);
97
+ }
98
+ }
99
+ return processed !== steps.length;
100
+ }
101
+
102
+ // src/planner.ts
103
+ var PlanStepSchema = z.object({
104
+ id: z.string(),
105
+ description: z.string(),
106
+ expected_output: z.string().optional(),
107
+ agent: z.literal("claude"),
108
+ inputs: z.record(z.string(), z.any()),
109
+ depends_on: z.array(z.string())
110
+ });
111
+ var TriggerConfigSchema = z.union([
112
+ z.object({
113
+ type: z.literal("poll"),
114
+ interval_seconds: z.number(),
115
+ check_script: z.string(),
116
+ diff_mode: z.enum(["new_items", "any_change"])
117
+ }),
118
+ z.object({
119
+ type: z.literal("cron"),
120
+ expression: z.string(),
121
+ timezone: z.string().optional()
122
+ }),
123
+ z.object({ type: z.literal("manual") })
124
+ ]);
125
+ var FailurePolicySchema = z.object({
126
+ on_step_failure: z.enum(["stop", "skip_dependents", "ask_user"]),
127
+ max_retries: z.number(),
128
+ retry_delay_ms: z.number()
129
+ });
130
+ var PlannerOutputSchema = z.object({
131
+ name: z.string(),
132
+ description: z.string(),
133
+ trigger: TriggerConfigSchema,
134
+ steps: z.array(PlanStepSchema).min(1),
135
+ failure_policy: FailurePolicySchema
136
+ });
137
+ var plannerOutputJsonSchema = {
138
+ type: "object",
139
+ required: ["name", "description", "trigger", "steps", "failure_policy"],
140
+ properties: {
141
+ name: { type: "string", description: "Short workflow name" },
142
+ description: { type: "string", description: "User's original natural language description" },
143
+ trigger: {
144
+ type: "object",
145
+ description: "Trigger configuration",
146
+ properties: {
147
+ type: { type: "string", enum: ["poll", "cron", "manual"] },
148
+ interval_seconds: { type: "number" },
149
+ check_script: { type: "string" },
150
+ diff_mode: { type: "string", enum: ["new_items", "any_change"] },
151
+ expression: { type: "string" },
152
+ timezone: { type: "string" }
153
+ },
154
+ required: ["type"]
155
+ },
156
+ steps: {
157
+ type: "array",
158
+ items: {
159
+ type: "object",
160
+ required: ["id", "description", "agent", "inputs", "depends_on"],
161
+ properties: {
162
+ id: { type: "string", description: "kebab-case step ID" },
163
+ description: { type: "string", description: "Detailed step description for the agent" },
164
+ expected_output: { type: "string" },
165
+ agent: { type: "string", enum: ["claude"] },
166
+ inputs: { type: "object", description: "Step inputs, may use $steps.{id}.output or $trigger_data" },
167
+ depends_on: { type: "array", items: { type: "string" }, description: "Step IDs this step depends on" }
168
+ }
169
+ },
170
+ minItems: 1
171
+ },
172
+ failure_policy: {
173
+ type: "object",
174
+ required: ["on_step_failure", "max_retries", "retry_delay_ms"],
175
+ properties: {
176
+ on_step_failure: { type: "string", enum: ["stop", "skip_dependents", "ask_user"] },
177
+ max_retries: { type: "number" },
178
+ retry_delay_ms: { type: "number" }
179
+ }
180
+ }
181
+ }
182
+ };
183
+ var plannerTool = {
184
+ name: "create_workflow",
185
+ description: "Create a workflow definition from the user description. Generate steps as a DAG with proper dependencies.",
186
+ input_schema: plannerOutputJsonSchema
187
+ };
188
+ function buildPlannerSystemPrompt(config) {
189
+ const identity = config.identity?.name ? `
190
+ User identity: ${config.identity.name}` : "";
191
+ return `You are CueClaw Planner. Convert user's natural language into a structured Workflow.
192
+
193
+ ## Execution Environment
194
+
195
+ All steps are executed by the Claude Agent SDK. The agent has Bash and can use
196
+ any CLI tool installed locally. It auto-loads .claude/skills/.
197
+ You don't need to verify tool availability \u2014 the agent detects at runtime.
198
+
199
+ ## Rules
200
+
201
+ 1. Every step's agent field is "claude" (Claude Agent SDK execution)
202
+ 2. Step description uses natural language \u2014 the agent decides which tools to use
203
+ 3. depends_on must reference defined step IDs, forming a valid DAG (no cycles)
204
+ 4. Step IDs use kebab-case: "fetch-issues", "create-branch"
205
+ 5. Use $steps.{id}.output in inputs to reference prior step results
206
+ 6. Use $trigger_data in inputs to reference the trigger's output data
207
+ 7. Do NOT generate position, phase, schema_version, status, or timestamp fields (framework auto-fills). You MUST generate step id fields.
208
+ 8. Trigger check_script can use any shell commands
209
+ 9. Step description should be detailed enough for the agent to execute independently \u2014 not just a one-line summary
210
+ 10. Input keys use snake_case (e.g., issue_number, repo_path)
211
+ 11. Steps must NOT include a status field \u2014 the framework sets all steps to pending automatically
212
+ 12. Step output is a plain text string \u2014 downstream agents parse structure themselves
213
+
214
+ ## User Identity
215
+ ${identity}`;
216
+ }
217
+ async function generatePlan(userDescription, config) {
218
+ const anthropic = new Anthropic({ apiKey: config.claude.api_key });
219
+ const MAX_RETRIES = 2;
220
+ let retryContext = "";
221
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
222
+ const prompt = retryContext ? `${userDescription}
223
+
224
+ ${retryContext}` : userDescription;
225
+ logger.debug({ attempt, hasRetryContext: !!retryContext }, "Planner attempt");
226
+ const response = await anthropic.messages.create({
227
+ model: config.claude.planner.model,
228
+ max_tokens: 4096,
229
+ system: buildPlannerSystemPrompt(config),
230
+ messages: [{ role: "user", content: prompt }],
231
+ tools: [plannerTool],
232
+ tool_choice: { type: "tool", name: "create_workflow" }
233
+ });
234
+ const toolBlock = response.content.find((b) => b.type === "tool_use");
235
+ if (!toolBlock || toolBlock.type !== "tool_use") {
236
+ throw new PlannerError("Unexpected planner response format: no tool_use block");
237
+ }
238
+ const parseResult = PlannerOutputSchema.safeParse(toolBlock.input);
239
+ if (!parseResult.success) {
240
+ const errMsg = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
241
+ if (attempt < MAX_RETRIES) {
242
+ retryContext = `[System] Previous plan had validation issues:
243
+ ${errMsg}
244
+ Please fix and try again.`;
245
+ continue;
246
+ }
247
+ throw new PlannerError(`Invalid plan after ${MAX_RETRIES + 1} attempts: ${errMsg}`);
248
+ }
249
+ const dagErrors = validateDAG(parseResult.data.steps);
250
+ if (dagErrors.length > 0) {
251
+ if (attempt < MAX_RETRIES) {
252
+ retryContext = `[System] DAG dependency issues:
253
+ ${dagErrors.join("\n")}
254
+ Please fix the step dependencies.`;
255
+ continue;
256
+ }
257
+ throw new PlannerError(`DAG validation failed after ${MAX_RETRIES + 1} attempts: ${dagErrors.join(", ")}`);
258
+ }
259
+ const now = (/* @__PURE__ */ new Date()).toISOString();
260
+ return {
261
+ ...parseResult.data,
262
+ schema_version: "1.0",
263
+ id: `wf_${nanoid()}`,
264
+ phase: "awaiting_confirmation",
265
+ created_at: now,
266
+ updated_at: now
267
+ };
268
+ }
269
+ throw new PlannerError("Failed to generate valid plan after retries");
270
+ }
271
+ async function modifyPlan(originalWorkflow, modificationDescription, config) {
272
+ const plannerOutput = {
273
+ name: originalWorkflow.name,
274
+ description: originalWorkflow.description,
275
+ trigger: originalWorkflow.trigger,
276
+ steps: originalWorkflow.steps,
277
+ failure_policy: originalWorkflow.failure_policy
278
+ };
279
+ const combinedPrompt = `Here is the current workflow plan:
280
+ \`\`\`json
281
+ ${JSON.stringify(plannerOutput, null, 2)}
282
+ \`\`\`
283
+
284
+ The user wants to modify it as follows:
285
+ ${modificationDescription}
286
+
287
+ Preserve unmodified steps' IDs, descriptions, and dependencies \u2014 only change what the user specified.
288
+ Return the complete modified workflow using the create_workflow tool.`;
289
+ const result = await generatePlan(combinedPrompt, config);
290
+ return {
291
+ ...result,
292
+ id: originalWorkflow.id,
293
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
294
+ };
295
+ }
296
+ function confirmPlan(workflow) {
297
+ if (workflow.phase !== "awaiting_confirmation") {
298
+ throw new PlannerError(`Cannot confirm workflow in phase "${workflow.phase}"`);
299
+ }
300
+ const nextPhase = workflow.trigger.type === "manual" ? "executing" : "active";
301
+ return {
302
+ ...workflow,
303
+ phase: nextPhase,
304
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
305
+ };
306
+ }
307
+ function rejectPlan(workflow) {
308
+ return {
309
+ ...workflow,
310
+ phase: "planning",
311
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
312
+ };
313
+ }
314
+
315
+ // src/executor.ts
316
+ import { nanoid as nanoid4 } from "nanoid";
317
+
318
+ // src/hooks.ts
319
+ function checkBashSafety(command) {
320
+ const dangerousPatterns = [
321
+ [/rm\s+-rf\s+\//, "recursive delete from root"],
322
+ [/\bsudo\b/, "privilege escalation"],
323
+ [/chmod\s+777/, "world-writable permissions"],
324
+ [/>\s*\/etc\//, "write to system config"],
325
+ [/\bmkfs\b/, "format filesystem"],
326
+ [/curl\s.*\|\s*(ba)?sh/, "pipe-to-shell execution"],
327
+ [/wget\s.*\|\s*(ba)?sh/, "pipe-to-shell execution"],
328
+ [/\bdd\b.*\bif=\/dev\//, "raw disk operations"],
329
+ [/>\s*\/dev\//, "write to device files"],
330
+ [/\bnc\b.*-[lp]/, "netcat listener"],
331
+ [/\bncat\b/, "ncat listener"]
332
+ ];
333
+ for (const [pattern, reason] of dangerousPatterns) {
334
+ if (pattern.test(command)) {
335
+ return { allowed: false, reason: `Dangerous command blocked: ${reason}` };
336
+ }
337
+ }
338
+ return { allowed: true };
339
+ }
340
+
341
+ // src/container-runner.ts
342
+ import { spawn } from "child_process";
343
+ import { mkdirSync as mkdirSync2 } from "fs";
344
+ import { join as join3 } from "path";
345
+
346
+ // src/mount-security.ts
347
+ import { readFileSync, writeFileSync, existsSync } from "fs";
348
+ import { join } from "path";
349
+ import { homedir } from "os";
350
+ function expandHome(path) {
351
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
352
+ if (path === "~") return homedir();
353
+ return path;
354
+ }
355
+ function loadMountAllowlist() {
356
+ const path = join(cueclawHome(), "mount-allowlist.json");
357
+ if (!existsSync(path)) {
358
+ const defaults = generateDefaultAllowlist();
359
+ writeFileSync(path, JSON.stringify(defaults, null, 2));
360
+ return defaults;
361
+ }
362
+ return JSON.parse(readFileSync(path, "utf-8"));
363
+ }
364
+ function validateAdditionalMounts(mounts, allowlist) {
365
+ for (const mount of mounts) {
366
+ const expanded = expandHome(mount.hostPath);
367
+ for (const pattern of allowlist.blockedPatterns) {
368
+ if (expanded.includes(pattern)) {
369
+ throw new ConfigError(`Mount blocked: "${mount.hostPath}" matches blocked pattern "${pattern}"`);
370
+ }
371
+ }
372
+ const allowed = allowlist.allowedRoots.find(
373
+ (root) => expanded.startsWith(expandHome(root.path))
374
+ );
375
+ if (!allowed) {
376
+ throw new ConfigError(`Mount not in allowlist: "${mount.hostPath}". Add it to ~/.cueclaw/mount-allowlist.json`);
377
+ }
378
+ if (mount.readonly === false && !allowed.allowReadWrite) {
379
+ throw new ConfigError(`Mount "${mount.hostPath}" is read-only in allowlist but requested read-write`);
380
+ }
381
+ }
382
+ }
383
+ function generateDefaultAllowlist() {
384
+ return {
385
+ allowedRoots: [
386
+ { path: "~/projects", allowReadWrite: true, description: "User project directories" },
387
+ { path: "/tmp", allowReadWrite: true, description: "Temporary files" }
388
+ ],
389
+ blockedPatterns: [".ssh", ".gnupg", ".aws", ".env", "credentials", "private_key", ".docker"],
390
+ nonMainReadOnly: true
391
+ };
392
+ }
393
+
394
+ // src/ipc.ts
395
+ import {
396
+ existsSync as existsSync2,
397
+ readdirSync,
398
+ readFileSync as readFileSync2,
399
+ writeFileSync as writeFileSync2,
400
+ unlinkSync,
401
+ renameSync,
402
+ mkdirSync
403
+ } from "fs";
404
+ import { join as join2 } from "path";
405
+ import { nanoid as nanoid2 } from "nanoid";
406
+ var IpcWatcher = class {
407
+ constructor(workflowId, _stepId, ipcDir, onMessage, pollInterval = 500) {
408
+ this.workflowId = workflowId;
409
+ this.ipcDir = ipcDir;
410
+ this.onMessage = onMessage;
411
+ this.pollInterval = pollInterval;
412
+ }
413
+ timeout = null;
414
+ start() {
415
+ this.scheduleNext();
416
+ }
417
+ scheduleNext() {
418
+ this.timeout = setTimeout(async () => {
419
+ await this.poll();
420
+ if (this.timeout !== null) this.scheduleNext();
421
+ }, this.pollInterval);
422
+ }
423
+ stop() {
424
+ if (this.timeout) {
425
+ clearTimeout(this.timeout);
426
+ this.timeout = null;
427
+ }
428
+ }
429
+ async poll() {
430
+ const outputDir = join2(this.ipcDir, "output");
431
+ if (!existsSync2(outputDir)) return;
432
+ const files = readdirSync(outputDir).filter((f) => f.endsWith(".json")).sort();
433
+ for (const file of files) {
434
+ const filePath = join2(outputDir, file);
435
+ try {
436
+ const content = readFileSync2(filePath, "utf-8");
437
+ const msg = JSON.parse(content);
438
+ if (msg.workflowId !== this.workflowId) {
439
+ logger.warn({ file, expected: this.workflowId, got: msg.workflowId }, "IPC message workflow mismatch");
440
+ this.moveToErrors(filePath, outputDir);
441
+ continue;
442
+ }
443
+ this.onMessage(msg);
444
+ unlinkSync(filePath);
445
+ } catch (err) {
446
+ logger.error({ file, err }, "Failed to process IPC message");
447
+ this.moveToErrors(filePath, outputDir);
448
+ }
449
+ }
450
+ }
451
+ sendToContainer(msg) {
452
+ const inputDir = join2(this.ipcDir, "input");
453
+ mkdirSync(inputDir, { recursive: true });
454
+ const filename = `${Date.now()}-${nanoid2(6)}.json`;
455
+ const tempPath = join2(inputDir, `.${filename}.tmp`);
456
+ const finalPath = join2(inputDir, filename);
457
+ writeFileSync2(tempPath, JSON.stringify(msg));
458
+ renameSync(tempPath, finalPath);
459
+ }
460
+ signalClose() {
461
+ writeFileSync2(join2(this.ipcDir, "_close"), "");
462
+ }
463
+ moveToErrors(filePath, outputDir) {
464
+ const errorsDir = join2(outputDir, "..", "errors");
465
+ mkdirSync(errorsDir, { recursive: true });
466
+ const filename = filePath.split("/").pop();
467
+ try {
468
+ renameSync(filePath, join2(errorsDir, filename));
469
+ } catch {
470
+ try {
471
+ unlinkSync(filePath);
472
+ } catch {
473
+ }
474
+ }
475
+ }
476
+ };
477
+
478
+ // src/mcp-server.ts
479
+ var McpMessageHandler = class {
480
+ constructor(db, router, ipcWatcher) {
481
+ this.db = db;
482
+ this.router = router;
483
+ this.ipcWatcher = ipcWatcher;
484
+ }
485
+ log = logger.child({ module: "mcp-handler" });
486
+ handle(msg) {
487
+ switch (msg.type) {
488
+ case "progress":
489
+ this.handleProgress(msg);
490
+ break;
491
+ case "notification":
492
+ this.handleNotification(msg);
493
+ break;
494
+ case "context_request":
495
+ this.handleContextRequest(msg);
496
+ break;
497
+ default:
498
+ this.log.warn({ type: msg.type }, "Unknown IPC message type");
499
+ }
500
+ }
501
+ handleProgress(msg) {
502
+ this.db.prepare(
503
+ "UPDATE step_runs SET status = ?, output_json = ? WHERE step_id = ? AND run_id = ?"
504
+ ).run(msg.data.status, msg.data.output ?? null, msg.stepId, msg.data.runId);
505
+ this.log.info({ stepId: msg.stepId, status: msg.data.status }, "Step progress update");
506
+ }
507
+ handleNotification(msg) {
508
+ this.router.broadcastNotification(msg.data.message);
509
+ }
510
+ handleContextRequest(msg) {
511
+ const stepRun = this.db.prepare(
512
+ "SELECT output_json FROM step_runs WHERE step_id = ? AND run_id = ?"
513
+ ).get(msg.data.requestedStepId, msg.data.runId);
514
+ this.ipcWatcher.sendToContainer({
515
+ workflowId: msg.workflowId,
516
+ stepId: msg.stepId,
517
+ type: "user_message",
518
+ correlationId: msg.correlationId,
519
+ data: { context: stepRun?.output_json ?? null },
520
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
521
+ });
522
+ }
523
+ };
524
+
525
+ // src/container-runner.ts
526
+ var OUTPUT_START_MARKER = "---CUECLAW_OUTPUT_START---";
527
+ var OUTPUT_END_MARKER = "---CUECLAW_OUTPUT_END---";
528
+ async function runContainerAgent(opts) {
529
+ const config = loadConfig();
530
+ const allowlist = loadMountAllowlist();
531
+ if (opts.additionalMounts) {
532
+ validateAdditionalMounts(opts.additionalMounts, allowlist);
533
+ }
534
+ mkdirSync2(opts.workDir, { recursive: true });
535
+ mkdirSync2(join3(opts.ipcDir, "input"), { recursive: true });
536
+ mkdirSync2(join3(opts.ipcDir, "output"), { recursive: true });
537
+ const image = config.container?.image ?? "cueclaw-agent:latest";
538
+ const network = config.container?.network ?? "none";
539
+ const volumeMounts = buildVolumeMounts(opts);
540
+ const dockerArgs = [
541
+ "run",
542
+ "--rm",
543
+ "-i",
544
+ "--name",
545
+ opts.containerName,
546
+ "--network",
547
+ network,
548
+ "--user",
549
+ "1000:1000",
550
+ "--memory",
551
+ "4g",
552
+ "--cpus",
553
+ "2",
554
+ ...volumeMounts,
555
+ image
556
+ ];
557
+ let ipcWatcher;
558
+ if (opts.db && opts.router) {
559
+ ipcWatcher = new IpcWatcher(opts.workflowId, opts.stepId, opts.ipcDir, (msg) => {
560
+ const handler = new McpMessageHandler(opts.db, opts.router, ipcWatcher);
561
+ handler.handle(msg);
562
+ });
563
+ ipcWatcher.start();
564
+ }
565
+ try {
566
+ const result = await spawnContainer(dockerArgs, opts, config);
567
+ return result;
568
+ } finally {
569
+ ipcWatcher?.stop();
570
+ }
571
+ }
572
+ function buildVolumeMounts(opts) {
573
+ const mounts = [
574
+ "-v",
575
+ `${opts.cwd}:/workspace/project:ro`,
576
+ "-v",
577
+ `${opts.workDir}:/workspace/work`,
578
+ "-v",
579
+ `${opts.ipcDir}:/workspace/ipc`
580
+ ];
581
+ for (const mount of opts.additionalMounts ?? []) {
582
+ const expanded = expandHome(mount.hostPath);
583
+ const containerPath = mount.containerPath ?? `/workspace/mounts${expanded}`;
584
+ const mode = mount.readonly !== false ? "ro" : "rw";
585
+ mounts.push("-v", `${expanded}:${containerPath}:${mode}`);
586
+ }
587
+ return mounts;
588
+ }
589
+ async function spawnContainer(dockerArgs, opts, config) {
590
+ return new Promise((resolve) => {
591
+ const proc = spawn("docker", dockerArgs, {
592
+ stdio: ["pipe", "pipe", "pipe"]
593
+ });
594
+ const input = {
595
+ prompt: opts.prompt,
596
+ workflowId: opts.workflowId,
597
+ stepId: opts.stepId,
598
+ runId: opts.runId,
599
+ apiKey: config.claude.api_key,
600
+ allowedTools: opts.allowedTools
601
+ };
602
+ proc.stdin.write(JSON.stringify(input));
603
+ proc.stdin.end();
604
+ const hardTimeout = config.container?.timeout ?? 18e5;
605
+ const idleTimeout = config.container?.idle_timeout ?? 18e5;
606
+ const maxOutputSize = config.container?.max_output_size ?? 10485760;
607
+ let lastActivity = Date.now();
608
+ let totalOutput = 0;
609
+ let truncated = false;
610
+ const hardTimer = setTimeout(() => {
611
+ logger.warn({ containerName: opts.containerName }, "Container hard timeout reached");
612
+ gracefulStop(opts.containerName);
613
+ }, hardTimeout);
614
+ const idleCheck = setInterval(() => {
615
+ if (Date.now() - lastActivity > idleTimeout) {
616
+ logger.warn({ containerName: opts.containerName }, "Container idle timeout reached");
617
+ gracefulStop(opts.containerName);
618
+ }
619
+ }, 1e4);
620
+ opts.signal?.addEventListener("abort", () => gracefulStop(opts.containerName), { once: true });
621
+ let stdout = "";
622
+ let resultBuffer = "";
623
+ proc.stdout?.on("data", (chunk) => {
624
+ lastActivity = Date.now();
625
+ const text = chunk.toString();
626
+ totalOutput += text.length;
627
+ if (totalOutput > maxOutputSize) {
628
+ if (!truncated) {
629
+ truncated = true;
630
+ logger.warn({ containerName: opts.containerName, size: totalOutput }, "Container output size cap reached, stopping container");
631
+ gracefulStop(opts.containerName);
632
+ }
633
+ return;
634
+ }
635
+ stdout += text;
636
+ opts.onProgress?.({ type: "output", data: text });
637
+ });
638
+ let stderr = "";
639
+ proc.stderr?.on("data", (chunk) => {
640
+ lastActivity = Date.now();
641
+ stderr += chunk.toString();
642
+ });
643
+ proc.on("close", (code) => {
644
+ clearTimeout(hardTimer);
645
+ clearInterval(idleCheck);
646
+ if (truncated) {
647
+ resolve({ status: "failed", error: "Container output size cap exceeded" });
648
+ return;
649
+ }
650
+ const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
651
+ const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
652
+ if (startIdx !== -1 && endIdx !== -1) {
653
+ resultBuffer = stdout.slice(startIdx + OUTPUT_START_MARKER.length + 1, endIdx).trim();
654
+ }
655
+ if (code !== 0 && !resultBuffer) {
656
+ resolve({
657
+ status: "failed",
658
+ error: stderr || `Container exited with code ${code}`
659
+ });
660
+ return;
661
+ }
662
+ try {
663
+ const parsed = JSON.parse(resultBuffer);
664
+ resolve({
665
+ status: "succeeded",
666
+ output: parsed.result ?? null,
667
+ sessionId: parsed.sessionId
668
+ });
669
+ } catch {
670
+ if (resultBuffer) {
671
+ resolve({ status: "succeeded", output: resultBuffer });
672
+ } else {
673
+ resolve({
674
+ status: "failed",
675
+ error: stderr || "No output captured from container"
676
+ });
677
+ }
678
+ }
679
+ });
680
+ proc.on("error", (err) => {
681
+ clearTimeout(hardTimer);
682
+ clearInterval(idleCheck);
683
+ resolve({
684
+ status: "failed",
685
+ error: `Docker spawn error: ${err.message}`
686
+ });
687
+ });
688
+ });
689
+ }
690
+ function gracefulStop(containerName) {
691
+ try {
692
+ spawn("docker", ["stop", containerName]);
693
+ } catch {
694
+ }
695
+ }
696
+ function prepareContainerOpts(workflowId, stepId, runId, prompt, cwd, allowedTools) {
697
+ const ipcDir = join3(cueclawHome(), "ipc", workflowId, stepId);
698
+ const workDir = join3(cueclawHome(), "work", workflowId, stepId);
699
+ const containerName = `cueclaw-${workflowId}-${stepId}-${Date.now()}`;
700
+ mkdirSync2(workDir, { recursive: true });
701
+ mkdirSync2(join3(ipcDir, "input"), { recursive: true });
702
+ mkdirSync2(join3(ipcDir, "output"), { recursive: true });
703
+ return {
704
+ workflowId,
705
+ stepId,
706
+ runId,
707
+ prompt,
708
+ cwd,
709
+ workDir,
710
+ ipcDir,
711
+ containerName,
712
+ allowedTools
713
+ };
714
+ }
715
+
716
+ // src/agent-runner.ts
717
+ function runAgent(opts) {
718
+ const config = loadConfig();
719
+ if (config.container?.enabled && opts.workflowId && opts.stepId && opts.runId) {
720
+ const containerOpts = prepareContainerOpts(
721
+ opts.workflowId,
722
+ opts.stepId,
723
+ opts.runId,
724
+ opts.prompt,
725
+ opts.cwd,
726
+ opts.allowedTools
727
+ );
728
+ const resultPromise2 = runContainerAgent({
729
+ ...containerOpts,
730
+ signal: opts.signal,
731
+ onProgress: opts.onProgress
732
+ });
733
+ return {
734
+ resultPromise: resultPromise2,
735
+ abort: () => {
736
+ try {
737
+ import("child_process").then(({ spawn: spawn2 }) => {
738
+ spawn2("docker", ["stop", containerOpts.containerName]);
739
+ });
740
+ } catch {
741
+ }
742
+ }
743
+ };
744
+ }
745
+ let aborted = false;
746
+ const resultPromise = (async () => {
747
+ try {
748
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
749
+ const q = query({
750
+ prompt: opts.prompt,
751
+ options: {
752
+ cwd: opts.cwd,
753
+ model: config.claude.executor.model,
754
+ resume: opts.sessionId,
755
+ allowedTools: opts.allowedTools ?? [
756
+ "Bash",
757
+ "Read",
758
+ "Write",
759
+ "Edit",
760
+ "Glob",
761
+ "Grep",
762
+ "WebSearch",
763
+ "WebFetch"
764
+ ],
765
+ settingSources: ["project"],
766
+ permissionMode: "default"
767
+ }
768
+ });
769
+ let sessionId;
770
+ let result = null;
771
+ for await (const message of q) {
772
+ if (aborted) {
773
+ if ("interrupt" in q && typeof q.interrupt === "function") {
774
+ q.interrupt();
775
+ }
776
+ return { status: "failed", error: "Aborted by user" };
777
+ }
778
+ if (message.type === "system" && message.subtype === "init") {
779
+ sessionId = message.session_id;
780
+ }
781
+ if (message.type === "assistant") {
782
+ for (const block of message.content ?? []) {
783
+ if (block.type === "tool_use" && block.name === "Bash") {
784
+ const cmd = block.input?.command;
785
+ if (cmd) {
786
+ const check = checkBashSafety(cmd);
787
+ if (!check.allowed) {
788
+ logger.warn({ command: cmd, reason: check.reason }, "Blocked dangerous command");
789
+ return { status: "failed", error: `Blocked dangerous command: ${check.reason}` };
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }
795
+ if ("result" in message) {
796
+ result = message.result ?? null;
797
+ }
798
+ opts.onProgress?.(message);
799
+ }
800
+ return { status: "succeeded", output: result, sessionId };
801
+ } catch (err) {
802
+ const errorMsg = err instanceof Error ? err.message : String(err);
803
+ logger.error({ err, stepId: opts.stepId }, "Agent execution failed");
804
+ return { status: "failed", error: errorMsg };
805
+ }
806
+ })();
807
+ return {
808
+ resultPromise,
809
+ abort: () => {
810
+ aborted = true;
811
+ }
812
+ };
813
+ }
814
+
815
+ // src/session.ts
816
+ import { nanoid as nanoid3 } from "nanoid";
817
+ function createSession(db, stepRunId, sdkSessionId) {
818
+ const now = (/* @__PURE__ */ new Date()).toISOString();
819
+ const session = {
820
+ id: `sess_${nanoid3()}`,
821
+ step_run_id: stepRunId,
822
+ sdk_session_id: sdkSessionId,
823
+ created_at: now,
824
+ last_used_at: now,
825
+ is_active: true
826
+ };
827
+ db.prepare(`
828
+ INSERT INTO sessions (id, step_run_id, sdk_session_id, created_at, last_used_at, is_active)
829
+ VALUES (?, ?, ?, ?, ?, ?)
830
+ `).run(session.id, session.step_run_id, session.sdk_session_id ?? null, session.created_at, session.last_used_at, 1);
831
+ return session;
832
+ }
833
+ function deactivateSession(db, sessionId) {
834
+ db.prepare("UPDATE sessions SET is_active = 0, last_used_at = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), sessionId);
835
+ }
836
+ function updateSessionSdkId(db, sessionId, sdkSessionId) {
837
+ db.prepare("UPDATE sessions SET sdk_session_id = ?, last_used_at = ? WHERE id = ?").run(sdkSessionId, (/* @__PURE__ */ new Date()).toISOString(), sessionId);
838
+ }
839
+
840
+ // src/executor.ts
841
+ var STEP_REF_PATTERN = /\$steps\.([a-z0-9-]+)\.output/g;
842
+ var TRIGGER_DATA_PATTERN = /\$trigger_data/g;
843
+ var MAX_OUTPUT_SIZE = 10240;
844
+ function resolveValue(value, completedSteps, triggerData) {
845
+ if (typeof value === "string") {
846
+ let shouldSkip = false;
847
+ let resolved = value.replace(STEP_REF_PATTERN, (_match, stepId) => {
848
+ const result = completedSteps.get(stepId);
849
+ if (!result) return "null";
850
+ if (result.status === "failed" || result.status === "skipped") {
851
+ shouldSkip = true;
852
+ return "null";
853
+ }
854
+ const output = result.output ?? "null";
855
+ return output.length > MAX_OUTPUT_SIZE ? output.slice(0, MAX_OUTPUT_SIZE) + "\n[truncated]" : output;
856
+ });
857
+ if (shouldSkip) return { __skip: true };
858
+ resolved = resolved.replace(TRIGGER_DATA_PATTERN, triggerData ?? "null");
859
+ return resolved;
860
+ }
861
+ if (Array.isArray(value)) {
862
+ return value.map((item) => resolveValue(item, completedSteps, triggerData));
863
+ }
864
+ if (value !== null && typeof value === "object") {
865
+ return resolveInputs(value, completedSteps, triggerData);
866
+ }
867
+ return value;
868
+ }
869
+ function resolveInputs(inputs, completedSteps, triggerData) {
870
+ const resolved = {};
871
+ for (const [key, value] of Object.entries(inputs)) {
872
+ resolved[key] = resolveValue(value, completedSteps, triggerData);
873
+ }
874
+ return resolved;
875
+ }
876
+ function hasSkipMarker(inputs) {
877
+ for (const value of Object.values(inputs)) {
878
+ if (value && typeof value === "object" && "__skip" in value) return true;
879
+ if (typeof value === "object" && value !== null && !Array.isArray(value) && hasSkipMarker(value)) return true;
880
+ }
881
+ return false;
882
+ }
883
+ async function executeStepOnce(step, resolvedInputs, runId, db, cwd, onProgress) {
884
+ const stepRunId = `sr_${nanoid4()}`;
885
+ const now = (/* @__PURE__ */ new Date()).toISOString();
886
+ const stepRun = {
887
+ id: stepRunId,
888
+ run_id: runId,
889
+ step_id: step.id,
890
+ status: "running",
891
+ started_at: now
892
+ };
893
+ insertStepRun(db, stepRun);
894
+ const inputContext = Object.keys(resolvedInputs).length > 0 ? `
895
+
896
+ Inputs:
897
+ ${JSON.stringify(resolvedInputs, null, 2)}` : "";
898
+ const prompt = `${step.description}${inputContext}`;
899
+ const handle = runAgent({
900
+ prompt,
901
+ cwd,
902
+ workflowId: step.id,
903
+ stepId: step.id,
904
+ runId,
905
+ onProgress: onProgress ? (msg) => onProgress(step.id, msg) : void 0
906
+ });
907
+ const result = await handle.resultPromise;
908
+ if (result.sessionId) {
909
+ const session = createSession(db, stepRunId, result.sessionId);
910
+ updateSessionSdkId(db, session.id, result.sessionId);
911
+ deactivateSession(db, session.id);
912
+ }
913
+ updateStepRunStatus(db, stepRunId, result.status, result.output ?? void 0, result.error);
914
+ return result;
915
+ }
916
+ async function executeStepWithRetry(step, resolvedInputs, runId, db, cwd, policy, onProgress) {
917
+ const maxRetries = policy.max_retries ?? 0;
918
+ let delay = policy.retry_delay_ms ?? 5e3;
919
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
920
+ const result = await executeStepOnce(step, resolvedInputs, runId, db, cwd, onProgress);
921
+ if (result.status !== "failed" || attempt === maxRetries) return result;
922
+ logger.info({ stepId: step.id, attempt, delay }, "Retrying step");
923
+ await new Promise((r) => setTimeout(r, delay));
924
+ delay *= 2;
925
+ }
926
+ throw new ExecutorError("Unreachable: step retry loop exited without returning");
927
+ }
928
+ async function executeWorkflow(opts) {
929
+ const { workflow, triggerData, db, cwd, onStepFailure, onProgress, signal } = opts;
930
+ const runId = `run_${nanoid4()}`;
931
+ const run = {
932
+ id: runId,
933
+ workflow_id: workflow.id,
934
+ trigger_data: triggerData,
935
+ status: "running",
936
+ started_at: (/* @__PURE__ */ new Date()).toISOString()
937
+ };
938
+ insertWorkflowRun(db, run);
939
+ updateWorkflowPhase(db, workflow.id, "executing");
940
+ const completed = /* @__PURE__ */ new Map();
941
+ const remaining = new Set(workflow.steps.map((s) => s.id));
942
+ const stepMap = new Map(workflow.steps.map((s) => [s.id, s]));
943
+ let runFailed = false;
944
+ try {
945
+ while (remaining.size > 0) {
946
+ if (signal?.aborted) {
947
+ for (const id of remaining) {
948
+ completed.set(id, { status: "skipped", error: "Aborted" });
949
+ }
950
+ remaining.clear();
951
+ runFailed = true;
952
+ break;
953
+ }
954
+ const ready = [...remaining].filter((id) => {
955
+ const step = stepMap.get(id);
956
+ return step.depends_on.every((dep) => completed.has(dep));
957
+ });
958
+ if (ready.length === 0) {
959
+ throw new ExecutorError("Deadlock: no ready steps but remaining steps exist");
960
+ }
961
+ const executable = [];
962
+ for (const id of ready) {
963
+ const step = stepMap.get(id);
964
+ const depsFailed = step.depends_on.some(
965
+ (dep) => completed.get(dep)?.status === "failed" || completed.get(dep)?.status === "skipped"
966
+ );
967
+ if (depsFailed && workflow.failure_policy.on_step_failure !== "ask_user") {
968
+ remaining.delete(id);
969
+ completed.set(id, { status: "skipped" });
970
+ const skipRunId = `sr_${nanoid4()}`;
971
+ insertStepRun(db, { id: skipRunId, run_id: runId, step_id: id, status: "skipped" });
972
+ continue;
973
+ }
974
+ const resolvedInputs = resolveInputs(step.inputs, completed, triggerData);
975
+ if (hasSkipMarker(resolvedInputs)) {
976
+ remaining.delete(id);
977
+ completed.set(id, { status: "skipped" });
978
+ const skipRunId = `sr_${nanoid4()}`;
979
+ insertStepRun(db, { id: skipRunId, run_id: runId, step_id: id, status: "skipped" });
980
+ continue;
981
+ }
982
+ executable.push(step);
983
+ }
984
+ if (executable.length === 0) continue;
985
+ const results = await Promise.all(
986
+ executable.map(async (step) => {
987
+ remaining.delete(step.id);
988
+ const resolvedInputs = resolveInputs(step.inputs, completed, triggerData);
989
+ const result = await executeStepWithRetry(
990
+ step,
991
+ resolvedInputs,
992
+ runId,
993
+ db,
994
+ cwd,
995
+ workflow.failure_policy,
996
+ onProgress
997
+ );
998
+ return { stepId: step.id, result };
999
+ })
1000
+ );
1001
+ for (const { stepId, result } of results) {
1002
+ completed.set(stepId, result);
1003
+ if (result.status === "failed") {
1004
+ const policy = workflow.failure_policy.on_step_failure;
1005
+ if (policy === "stop") {
1006
+ for (const remainingId of remaining) {
1007
+ completed.set(remainingId, { status: "skipped" });
1008
+ const skipRunId = `sr_${nanoid4()}`;
1009
+ insertStepRun(db, { id: skipRunId, run_id: runId, step_id: remainingId, status: "skipped" });
1010
+ }
1011
+ remaining.clear();
1012
+ runFailed = true;
1013
+ break;
1014
+ }
1015
+ if (policy === "ask_user" && onStepFailure) {
1016
+ const decision = await onStepFailure(stepMap.get(stepId), result.error ?? "Unknown error");
1017
+ if (decision === "stop") {
1018
+ for (const remainingId of remaining) {
1019
+ completed.set(remainingId, { status: "skipped" });
1020
+ const skipRunId = `sr_${nanoid4()}`;
1021
+ insertStepRun(db, { id: skipRunId, run_id: runId, step_id: remainingId, status: "skipped" });
1022
+ }
1023
+ remaining.clear();
1024
+ runFailed = true;
1025
+ break;
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ } catch (err) {
1032
+ runFailed = true;
1033
+ logger.error({ err, runId }, "Workflow execution error");
1034
+ }
1035
+ const finalStatus = runFailed ? "failed" : "completed";
1036
+ updateWorkflowRunStatus(db, runId, finalStatus);
1037
+ if (workflow.trigger.type === "manual") {
1038
+ updateWorkflowPhase(db, workflow.id, runFailed ? "failed" : "completed");
1039
+ } else {
1040
+ updateWorkflowPhase(db, workflow.id, "active");
1041
+ }
1042
+ return { runId, status: finalStatus, results: completed };
1043
+ }
1044
+
1045
+ export {
1046
+ generatePlan,
1047
+ modifyPlan,
1048
+ confirmPlan,
1049
+ rejectPlan,
1050
+ executeWorkflow
1051
+ };