lilflow 0.1.0
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.
- package/AGENTS.md +140 -0
- package/README.md +112 -0
- package/package.json +50 -0
- package/src/AGENTS.md +27 -0
- package/src/agents/claude-code.js +352 -0
- package/src/agents/index.js +228 -0
- package/src/agents/ndjson.js +67 -0
- package/src/agents/opencode.js +290 -0
- package/src/agents/prompt.js +91 -0
- package/src/agents/session-store.js +91 -0
- package/src/cli.js +204 -0
- package/src/config/AGENTS.md +23 -0
- package/src/config.js +776 -0
- package/src/init-project.js +573 -0
- package/src/run-workflow.js +6274 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# lilflow
|
|
2
|
+
|
|
3
|
+
## Stack
|
|
4
|
+
|
|
5
|
+
- **Language/Runtime:** Unknown
|
|
6
|
+
|
|
7
|
+
## Build & Test Commands
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# No recognized stack — add build/test commands here
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Project Structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
lilflow/
|
|
17
|
+
├── src/ # Source code
|
|
18
|
+
├── tests/ # Test files
|
|
19
|
+
├── docs/ # Documentation
|
|
20
|
+
└── .claude/ # Codeharness state
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Conventions
|
|
24
|
+
|
|
25
|
+
- All changes must pass tests before commit
|
|
26
|
+
- Maintain test coverage targets
|
|
27
|
+
- Follow existing code style and patterns
|
|
28
|
+
## Harness Files
|
|
29
|
+
|
|
30
|
+
- `AGENTS.md` is the primary repo-local instruction file for coding agents
|
|
31
|
+
- `commands/` contains harness command playbooks the agent can read and execute directly
|
|
32
|
+
- `skills/` contains focused harness skills and operating procedures
|
|
33
|
+
- Install BMAD with `npx bmad-method install --yes --modules bmm --tools claude-code` for Claude Code
|
|
34
|
+
|
|
35
|
+
<!-- CODEHARNESS-PATCH-START:dev-workflow-enforcement -->
|
|
36
|
+
|
|
37
|
+
## Harness Enforcement (codeharness)
|
|
38
|
+
|
|
39
|
+
During implementation, the agent MUST:
|
|
40
|
+
|
|
41
|
+
### Observability
|
|
42
|
+
- Query VictoriaLogs after running the application to check for errors
|
|
43
|
+
- Verify OTLP instrumentation is active in new code paths
|
|
44
|
+
- Use `curl localhost:9428/select/logsql/query?query=level:error` to check for runtime errors
|
|
45
|
+
|
|
46
|
+
### Documentation
|
|
47
|
+
- Create or update per-subsystem AGENTS.md for any new modules (max 100 lines)
|
|
48
|
+
- Update exec-plan at `docs/exec-plans/active/{story-id}.md` with progress
|
|
49
|
+
- Ensure inline code documentation for new public APIs
|
|
50
|
+
|
|
51
|
+
### Testing
|
|
52
|
+
- Write tests AFTER implementation, BEFORE verification
|
|
53
|
+
- Achieve 100% project-wide test coverage
|
|
54
|
+
- All tests must pass before proceeding to verification
|
|
55
|
+
|
|
56
|
+
### Verification
|
|
57
|
+
- Run `/harness-verify` to produce proof document
|
|
58
|
+
- Do NOT mark story complete without passing verification
|
|
59
|
+
|
|
60
|
+
<!-- CODEHARNESS-PATCH-END:dev-workflow-enforcement -->
|
|
61
|
+
|
|
62
|
+
<!-- CODEHARNESS-PATCH-START:code-review-harness -->
|
|
63
|
+
|
|
64
|
+
## Harness Review Checklist (codeharness)
|
|
65
|
+
|
|
66
|
+
In addition to standard code review, verify:
|
|
67
|
+
|
|
68
|
+
### Documentation
|
|
69
|
+
- [ ] AGENTS.md files are fresh for all changed modules
|
|
70
|
+
- [ ] Exec-plan updated with story progress
|
|
71
|
+
- [ ] Inline documentation present for new public APIs
|
|
72
|
+
|
|
73
|
+
### Testing
|
|
74
|
+
- [ ] Tests exist for all new code
|
|
75
|
+
- [ ] Project-wide test coverage is 100%
|
|
76
|
+
- [ ] No skipped or disabled tests
|
|
77
|
+
- [ ] Test coverage report is present
|
|
78
|
+
|
|
79
|
+
### Verification
|
|
80
|
+
- [ ] Proof document exists at `verification/{story-id}-proof.md`
|
|
81
|
+
- [ ] Proof document covers all acceptance criteria
|
|
82
|
+
- [ ] Evidence is reproducible
|
|
83
|
+
|
|
84
|
+
<!-- CODEHARNESS-PATCH-END:code-review-harness -->
|
|
85
|
+
|
|
86
|
+
<!-- CODEHARNESS-PATCH-START:sprint-planning-harness -->
|
|
87
|
+
|
|
88
|
+
## Harness Pre-Sprint Checklist (codeharness)
|
|
89
|
+
|
|
90
|
+
Before starting the sprint, verify:
|
|
91
|
+
|
|
92
|
+
### Planning Docs
|
|
93
|
+
- [ ] PRD is current and reflects latest decisions
|
|
94
|
+
- [ ] Architecture doc is current (ARCHITECTURE.md)
|
|
95
|
+
- [ ] Epics and stories are fully defined with Given/When/Then ACs
|
|
96
|
+
|
|
97
|
+
### Test Infrastructure
|
|
98
|
+
- [ ] Coverage tool configured for the stack (c8/istanbul, coverage.py)
|
|
99
|
+
- [ ] Baseline coverage recorded in `.claude/codeharness.local.md`
|
|
100
|
+
- [ ] Test runner configured and working
|
|
101
|
+
|
|
102
|
+
### Harness Infrastructure
|
|
103
|
+
- [ ] Harness initialized (`/harness-init` completed)
|
|
104
|
+
- [ ] Docker stack healthy (VictoriaMetrics responding)
|
|
105
|
+
- [ ] OTLP instrumentation installed
|
|
106
|
+
- [ ] Hooks registered and active
|
|
107
|
+
|
|
108
|
+
<!-- CODEHARNESS-PATCH-END:sprint-planning-harness -->
|
|
109
|
+
|
|
110
|
+
<!-- CODEHARNESS-PATCH-START:retro-harness -->
|
|
111
|
+
|
|
112
|
+
## Harness Analysis (codeharness)
|
|
113
|
+
|
|
114
|
+
The retrospective MUST analyze the following in addition to standard retro topics:
|
|
115
|
+
|
|
116
|
+
### Verification Effectiveness
|
|
117
|
+
- Pass rates per story (how many stories verified on first attempt?)
|
|
118
|
+
- Common failure patterns (what types of verification fail most?)
|
|
119
|
+
- Iteration counts per story (how many implement→verify→fix loops?)
|
|
120
|
+
- Average iterations vs target (<3)
|
|
121
|
+
|
|
122
|
+
### Documentation Health
|
|
123
|
+
- Stale doc count (AGENTS.md files not updated since code changed)
|
|
124
|
+
- Quality grades per module (A/B/C/D/F)
|
|
125
|
+
- Doc-gardener findings summary
|
|
126
|
+
- Documentation debt trends (improving or degrading?)
|
|
127
|
+
|
|
128
|
+
### Test Quality
|
|
129
|
+
- Coverage trends per story (baseline → final, deltas)
|
|
130
|
+
- Tests that caught real bugs vs tests that never failed
|
|
131
|
+
- Flaky test detection (tests that pass/fail inconsistently)
|
|
132
|
+
- Test suite execution time trends
|
|
133
|
+
|
|
134
|
+
### Follow-up Items
|
|
135
|
+
Convert each finding into an actionable item:
|
|
136
|
+
- Code/test issues → new story for next sprint
|
|
137
|
+
- Process improvements → BMAD workflow patch
|
|
138
|
+
- Verification gaps → enforcement config update
|
|
139
|
+
|
|
140
|
+
<!-- CODEHARNESS-PATCH-END:retro-harness -->
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# lilflow
|
|
2
|
+
|
|
3
|
+
Repo-native workflow engine CLI. Step-level resumable YAML workflows with state stored under `.flow/` in your repo.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g lilflow
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Binary is exposed as both `lilflow` and `flow`.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
flow init # scaffold workflow.yaml + .flow/ state dir
|
|
17
|
+
flow run # execute the workflow
|
|
18
|
+
flow status # inspect run state
|
|
19
|
+
flow resume # resume a failed run from the last completed step
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Parallel Steps
|
|
23
|
+
|
|
24
|
+
The runner supports contiguous parallel batches inside `workflow.yaml`.
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
name: ci
|
|
28
|
+
steps:
|
|
29
|
+
- name: lint
|
|
30
|
+
run: npm run lint
|
|
31
|
+
parallel: true
|
|
32
|
+
|
|
33
|
+
- name: test
|
|
34
|
+
run: npm test
|
|
35
|
+
parallel: true
|
|
36
|
+
|
|
37
|
+
- name: build
|
|
38
|
+
run: npm run build
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Parallel step output is streamed in real time with step-name prefixes such as `[lint] ...`, and `flow status <run-id>` shows each started step with its own `running`, `completed`, or `failed` state.
|
|
42
|
+
|
|
43
|
+
Parallel batch concurrency is controlled by the resolved `parallelism` config value. The default is `4`, and you can override it in `.flow/config.yaml`, `~/.flow/config.yaml`, or with `FLOW_PARALLELISM`.
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
parallelism: 2
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Retry Logic
|
|
50
|
+
|
|
51
|
+
Steps can retry transient failures with exponential backoff.
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
name: deploy
|
|
55
|
+
steps:
|
|
56
|
+
- name: flaky-check
|
|
57
|
+
run: npm run flaky-check
|
|
58
|
+
retry: 3
|
|
59
|
+
retry_delay: 5s
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`retry` is the number of retries after the initial attempt, so `retry: 3` allows up to 4 total attempts. `retry_delay` is the base delay and doubles on each retry. The runner prints numbered attempt labels such as `[attempt 2/4]`, shows the failure reason for each retryable attempt, and prints wait lines such as `Waiting 10s before retry 2/3...`.
|
|
63
|
+
|
|
64
|
+
## For-Each Loops
|
|
65
|
+
|
|
66
|
+
Steps can expand an inline array into concrete iterations with `for_each`.
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
name: deploy
|
|
70
|
+
steps:
|
|
71
|
+
- name: deploy-target
|
|
72
|
+
run: echo "Deploying to {{item}} ({{item_number}}/{{item_count}})"
|
|
73
|
+
for_each: [dev, staging, prod]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Each iteration becomes a concrete runtime step with a stable label such as `deploy-target [item-prod]`. The templating variables `{{item}}`, `{{item_index}}`, `{{item_number}}`, `{{item_count}}`, `{{item_first}}`, and `{{item_last}}` are available in string step fields. If one iteration fails, the remaining iterations still run before the workflow stops.
|
|
77
|
+
|
|
78
|
+
## Workflow Templates
|
|
79
|
+
|
|
80
|
+
`flow init` can scaffold `workflow.yaml` from reusable local templates stored under `.flow/templates/<name>/workflow.yaml`.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
flow init --list-templates
|
|
84
|
+
flow init --template ci-pipeline
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Template files can mix prompt-backed placeholders and config-backed placeholders:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
name: {{template.workflow_name|ci-pipeline}}
|
|
91
|
+
parallelism: {{config.parallelism}}
|
|
92
|
+
steps:
|
|
93
|
+
- name: test
|
|
94
|
+
run: npm test
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `{{template.name}}` prompts for a value
|
|
98
|
+
- `{{template.name|default}}` prompts and uses the default when you press enter
|
|
99
|
+
- `{{config.parallelism}}` reads from the resolved lilflow config
|
|
100
|
+
|
|
101
|
+
Any directory matching `.flow/templates/<name>/workflow.yaml` is a usable custom template.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
- Project config: `.flow/config.yaml`
|
|
106
|
+
- Global config: `~/.flow/config.yaml`
|
|
107
|
+
- Env var override: `FLOW_*`
|
|
108
|
+
- Override order: defaults → global → project → env → flags
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lilflow",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Repo-native workflow engine CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lilflow": "./src/cli.js",
|
|
8
|
+
"flow": "./src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"AGENTS.md"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/iVintik/lilflow.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/iVintik/lilflow/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/iVintik/lilflow#readme",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"workflow",
|
|
26
|
+
"cli",
|
|
27
|
+
"automation",
|
|
28
|
+
"agents"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "c8 --reporter=text --reporter=lcov --reporter=json-summary node --test",
|
|
35
|
+
"coverage": "npm test",
|
|
36
|
+
"lint": "eslint src/"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"js-yaml": "^4.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^9.25.1",
|
|
46
|
+
"c8": "^10.1.3",
|
|
47
|
+
"eslint": "^9.25.1",
|
|
48
|
+
"globals": "^16.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/AGENTS.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# src
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Contains the `flow` CLI implementation for this repo.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
|
|
9
|
+
- `cli.js`: process entrypoint and command dispatch for `init`, `config`, `run`, `resume`, `set-step`, `status`, `list`, and `logs`, including `flow init --template` / `--list-templates`
|
|
10
|
+
- `config.js`: config defaults, YAML parsing, merge precedence across global/project/workflow/workflow-local/env/flag layers, workflow-local path discovery, and validation
|
|
11
|
+
- `init-project.js`: filesystem logic for `flow init`, reusable template discovery under `.flow/templates/<name>/workflow.yaml`, strict template-name validation before CLI/path use, template placeholder resolution for `{{template.*}}` prompts and `{{config.*}}` config-backed values, and safe non-overwriting scaffold creation
|
|
12
|
+
- `run-workflow.js`: workflow YAML parsing, workflow `parameters` definitions with string/number/boolean typing plus defaults and required flags, conditional `if` expressions with `env.*`, `steps.*.(stdout|stderr|exit_code)`, and `parameters.*` references, first-class `gate` steps with `gate_action: fail|warn` and optional messages, first-class `subflow` steps with child run IDs, parent linkage, `with` parameter passing, child `FLOW_PARAM_*` env injection, per-workflow hierarchical config resolution for top-level and nested workflows, persisted child `run_started.parameters` metadata, output aggregation, and failure propagation, `step_skipped` persistence plus deterministic resume for skipped steps, manual `step_set` overrides with interactive confirmation plus auditable resume-position changes for failed and completed runs, `for_each` loop expansion with strict loop-template validation plus `{{item}}`/iteration metadata templating, shell-escaped loop-item interpolation for `run` commands, ordered execution with optional parallel batches, loop batches that finish every iteration before surfacing failure, `depends_on` validation with cycle and same-batch dependency checks, append-only JSONL event persistence under `.flow/runs/`, optional structured harness-event emission to stdout when `HARNESS_SESSION_ID` is set, nested execution-path metadata for root and subflow runs, persisted run-status reconstruction for `flow status`, persisted run listing and status filtering for `flow list`, persisted log reconstruction and step filtering for `flow logs`, failed-run replay plus manual-reset replay for `flow resume`, run-ID validation for persisted status/log lookups, stable failure event schemas for timeouts, non-zero exits, gate failures, and subflow failures, persisted stdout/stderr for completed and failed steps, per-step timeout overrides, streamed output with per-step prefixes during parallel and loop execution, coordinated cancellation for parallel children on step failure or workflow signals, previous-step output env propagation, loop metadata env propagation, ANSI-colored run output, and failure diagnostics with recent output plus resume hints
|
|
13
|
+
|
|
14
|
+
## Conventions
|
|
15
|
+
|
|
16
|
+
- Keep source in ESM JavaScript under `src/`
|
|
17
|
+
- Export small public functions with JSDoc
|
|
18
|
+
- Prefer pure helpers around filesystem decisions where practical
|
|
19
|
+
- Preserve idempotent CLI behavior; do not overwrite user files silently
|
|
20
|
+
- Add tests in `tests/` for every new branch in CLI behavior
|
|
21
|
+
|
|
22
|
+
## Verification
|
|
23
|
+
|
|
24
|
+
- Run `npm test`
|
|
25
|
+
- Run `npm run coverage`
|
|
26
|
+
- Run `npx eslint src/ --fix`
|
|
27
|
+
- Run `npx eslint src/`
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run the `claude` CLI as a headless or interactive agent step.
|
|
5
|
+
*
|
|
6
|
+
* In headless mode, the CLI is invoked with `-p <prompt> --output-format json`.
|
|
7
|
+
* The full stdout is parsed as a single JSON object — we extract `total_cost_usd`
|
|
8
|
+
* (or `cost_usd`), `session_id`, and the final assistant text (`result` field).
|
|
9
|
+
*
|
|
10
|
+
* In interactive mode, stdio is inherited for full TTY passthrough.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} options - Invocation options.
|
|
13
|
+
* @param {string} options.bin - Resolved binary path for the `claude` CLI.
|
|
14
|
+
* @param {string} options.prompt - Final prompt text.
|
|
15
|
+
* @param {string} [options.model] - Optional model identifier.
|
|
16
|
+
* @param {string | null} [options.sessionId] - Prior session ID to resume, or null for fresh.
|
|
17
|
+
* @param {number} options.timeoutMs - Step timeout in milliseconds.
|
|
18
|
+
* @param {string} options.cwd - Working directory.
|
|
19
|
+
* @param {object} options.env - Process environment.
|
|
20
|
+
* @param {boolean} [options.interactive=false] - TTY passthrough mode.
|
|
21
|
+
* @param {string[]} [options.allowTools] - Tool names for `--allowedTools`.
|
|
22
|
+
* @param {string} [options.appendSystemPrompt] - Extra system prompt injection.
|
|
23
|
+
* @param {boolean} [options.sourceAccess=true] - When true, pass `--dangerously-skip-permissions`.
|
|
24
|
+
* @param {typeof spawn} [options.spawnProcess=spawn] - Child process factory (for tests).
|
|
25
|
+
* @param {{isCancelled: () => boolean, getReason: () => string | null, trackChild: (child: import("node:child_process").ChildProcess) => () => void}} [options.cancellation] - Cancellation controller.
|
|
26
|
+
* @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: number | null, sessionId: string | null}>} Agent run result.
|
|
27
|
+
*/
|
|
28
|
+
export function runClaudeCode(options) {
|
|
29
|
+
const {
|
|
30
|
+
bin,
|
|
31
|
+
prompt,
|
|
32
|
+
model,
|
|
33
|
+
sessionId = null,
|
|
34
|
+
timeoutMs,
|
|
35
|
+
cwd,
|
|
36
|
+
env,
|
|
37
|
+
interactive = false,
|
|
38
|
+
allowTools = [],
|
|
39
|
+
appendSystemPrompt,
|
|
40
|
+
sourceAccess = true,
|
|
41
|
+
spawnProcess = spawn,
|
|
42
|
+
cancellation = null
|
|
43
|
+
} = options;
|
|
44
|
+
|
|
45
|
+
if (interactive) {
|
|
46
|
+
// Surface the prompt to the user before launching the TUI. The claude REPL
|
|
47
|
+
// does not accept a seed prompt as a flag, so the user has to copy/paste
|
|
48
|
+
// or type it after launch.
|
|
49
|
+
if (typeof prompt === "string" && prompt.trim() !== "") {
|
|
50
|
+
process.stdout.write(`\n[agent prompt — paste into claude after launch]\n${prompt}\n\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return spawnInteractive({
|
|
54
|
+
bin,
|
|
55
|
+
prompt,
|
|
56
|
+
model,
|
|
57
|
+
sessionId,
|
|
58
|
+
cwd,
|
|
59
|
+
env,
|
|
60
|
+
timeoutMs,
|
|
61
|
+
cancellation,
|
|
62
|
+
sourceAccess,
|
|
63
|
+
spawnProcess
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const args = ["--output-format", "json"];
|
|
68
|
+
|
|
69
|
+
if (model) {
|
|
70
|
+
args.push("--model", model);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (sessionId) {
|
|
74
|
+
args.push("--resume", sessionId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (allowTools.length > 0) {
|
|
78
|
+
args.push("--allowedTools", ...allowTools);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (appendSystemPrompt) {
|
|
82
|
+
args.push("--append-system-prompt", appendSystemPrompt);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (sourceAccess) {
|
|
86
|
+
args.push("--dangerously-skip-permissions");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Place `-p` last with `--` separator so a prompt starting with `-` is not
|
|
90
|
+
// parsed as a flag by the claude CLI argv parser.
|
|
91
|
+
args.push("-p", "--", prompt);
|
|
92
|
+
|
|
93
|
+
return spawnHeadless({ bin, args, cwd, env, timeoutMs, cancellation, spawnProcess });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {object} options - Spawn options.
|
|
98
|
+
* @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: number | null, sessionId: string | null}>} Result.
|
|
99
|
+
*/
|
|
100
|
+
function spawnHeadless(options) {
|
|
101
|
+
const { bin, args, cwd, env, timeoutMs, cancellation, spawnProcess } = options;
|
|
102
|
+
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const child = spawnProcess(bin, args, {
|
|
105
|
+
cwd,
|
|
106
|
+
env,
|
|
107
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
108
|
+
});
|
|
109
|
+
const untrackChild = cancellation?.trackChild(child) ?? (() => {});
|
|
110
|
+
let stdoutCapture = "";
|
|
111
|
+
let stderrCapture = "";
|
|
112
|
+
let combinedOutput = "";
|
|
113
|
+
let settled = false;
|
|
114
|
+
let timedOut = false;
|
|
115
|
+
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
timedOut = true;
|
|
118
|
+
child.kill("SIGTERM");
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
|
|
121
|
+
child.stdout.on("data", (chunk) => {
|
|
122
|
+
const text = chunk.toString();
|
|
123
|
+
stdoutCapture += text;
|
|
124
|
+
combinedOutput += text;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
child.stderr.on("data", (chunk) => {
|
|
128
|
+
const text = chunk.toString();
|
|
129
|
+
stderrCapture += text;
|
|
130
|
+
combinedOutput += text;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
child.on("error", (error) => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
untrackChild();
|
|
139
|
+
reject(new Error(`Failed to start claude agent: ${error.message}`));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
child.on("close", (code, signal) => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
|
|
145
|
+
settled = true;
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
untrackChild();
|
|
148
|
+
|
|
149
|
+
if (timedOut) {
|
|
150
|
+
reject(Object.assign(new Error("claude agent timed out"), { code: "STEP_TIMEOUT" }));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (signal) {
|
|
155
|
+
if (cancellation?.isCancelled()) {
|
|
156
|
+
const reason = cancellation.getReason();
|
|
157
|
+
|
|
158
|
+
reject(Object.assign(new Error(`claude agent cancelled (${reason})`), {
|
|
159
|
+
code: reason === "STEP_FAILURE" ? "STEP_CANCELLED" : "WORKFLOW_CANCELLED"
|
|
160
|
+
}));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
reject(new Error(`claude agent exited due to signal ${signal}`));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const captured = parseClaudeResponse(stdoutCapture);
|
|
169
|
+
|
|
170
|
+
resolve({
|
|
171
|
+
exitCode: code ?? 1,
|
|
172
|
+
stdout: captured.text ?? stdoutCapture,
|
|
173
|
+
stderr: stderrCapture,
|
|
174
|
+
combinedOutput,
|
|
175
|
+
costUsd: captured.costUsd,
|
|
176
|
+
sessionId: captured.sessionId
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {object} options - Interactive spawn options.
|
|
184
|
+
* @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: null, sessionId: null}>} Result.
|
|
185
|
+
*/
|
|
186
|
+
function spawnInteractive(options) {
|
|
187
|
+
const {
|
|
188
|
+
bin, model, sessionId, cwd, env, timeoutMs, cancellation, sourceAccess, spawnProcess
|
|
189
|
+
} = options;
|
|
190
|
+
const args = [];
|
|
191
|
+
|
|
192
|
+
if (model) {
|
|
193
|
+
args.push("--model", model);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (sessionId) {
|
|
197
|
+
args.push("--resume", sessionId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (sourceAccess) {
|
|
201
|
+
args.push("--dangerously-skip-permissions");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const child = spawnProcess(bin, args, {
|
|
206
|
+
cwd,
|
|
207
|
+
env,
|
|
208
|
+
stdio: "inherit"
|
|
209
|
+
});
|
|
210
|
+
const untrackChild = cancellation?.trackChild(child) ?? (() => {});
|
|
211
|
+
let settled = false;
|
|
212
|
+
let timedOut = false;
|
|
213
|
+
|
|
214
|
+
const timer = setTimeout(() => {
|
|
215
|
+
timedOut = true;
|
|
216
|
+
child.kill("SIGTERM");
|
|
217
|
+
}, timeoutMs);
|
|
218
|
+
|
|
219
|
+
child.on("error", (error) => {
|
|
220
|
+
if (settled) return;
|
|
221
|
+
|
|
222
|
+
settled = true;
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
untrackChild();
|
|
225
|
+
reject(new Error(`Failed to start claude agent: ${error.message}`));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
child.on("close", (code, signal) => {
|
|
229
|
+
if (settled) return;
|
|
230
|
+
|
|
231
|
+
settled = true;
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
untrackChild();
|
|
234
|
+
|
|
235
|
+
if (timedOut) {
|
|
236
|
+
reject(Object.assign(new Error("claude agent timed out"), { code: "STEP_TIMEOUT" }));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (signal) {
|
|
241
|
+
reject(new Error(`claude agent exited due to signal ${signal}`));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
resolve({
|
|
246
|
+
exitCode: code ?? 1,
|
|
247
|
+
stdout: "",
|
|
248
|
+
stderr: "",
|
|
249
|
+
combinedOutput: "",
|
|
250
|
+
costUsd: null,
|
|
251
|
+
sessionId: null
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Parse the single JSON response produced by `claude -p ... --output-format json`.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} raw - Raw stdout from the claude CLI.
|
|
261
|
+
* @returns {{costUsd: number | null, sessionId: string | null, text: string | null}} Captured fields.
|
|
262
|
+
*/
|
|
263
|
+
function parseClaudeResponse(raw) {
|
|
264
|
+
const trimmed = raw.trim();
|
|
265
|
+
|
|
266
|
+
if (trimmed === "") {
|
|
267
|
+
return { costUsd: null, sessionId: null, text: null };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const parsed = extractJsonObject(trimmed);
|
|
271
|
+
|
|
272
|
+
if (parsed === null) {
|
|
273
|
+
return { costUsd: null, sessionId: null, text: null };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let costUsd = null;
|
|
277
|
+
|
|
278
|
+
if (typeof parsed.total_cost_usd === "number") {
|
|
279
|
+
costUsd = parsed.total_cost_usd;
|
|
280
|
+
} else if (typeof parsed.cost_usd === "number") {
|
|
281
|
+
costUsd = parsed.cost_usd;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sessionId = typeof parsed.session_id === "string" && parsed.session_id !== ""
|
|
285
|
+
? parsed.session_id
|
|
286
|
+
: null;
|
|
287
|
+
const text = typeof parsed.result === "string"
|
|
288
|
+
? parsed.result
|
|
289
|
+
: typeof parsed.text === "string"
|
|
290
|
+
? parsed.text
|
|
291
|
+
: null;
|
|
292
|
+
|
|
293
|
+
return { costUsd, sessionId, text };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Extract a single JSON object from raw stdout that may contain non-JSON
|
|
298
|
+
* preamble lines (warnings, deprecation notices). Tries the entire string
|
|
299
|
+
* first, then falls back to scanning each line, then to scanning for the
|
|
300
|
+
* largest brace-balanced substring.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} raw - Raw stdout string (already trimmed).
|
|
303
|
+
* @returns {object | null} Parsed object or null when no JSON is found.
|
|
304
|
+
*/
|
|
305
|
+
function extractJsonObject(raw) {
|
|
306
|
+
try {
|
|
307
|
+
const value = JSON.parse(raw);
|
|
308
|
+
|
|
309
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// fall through to line scan
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const lines = raw.split("\n");
|
|
317
|
+
|
|
318
|
+
for (let lineIndex = lines.length - 1; lineIndex >= 0; lineIndex -= 1) {
|
|
319
|
+
const candidate = lines[lineIndex].trim();
|
|
320
|
+
|
|
321
|
+
if (candidate === "" || !candidate.startsWith("{")) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const value = JSON.parse(candidate);
|
|
327
|
+
|
|
328
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const firstBrace = raw.indexOf("{");
|
|
337
|
+
const lastBrace = raw.lastIndexOf("}");
|
|
338
|
+
|
|
339
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
340
|
+
try {
|
|
341
|
+
const value = JSON.parse(raw.slice(firstBrace, lastBrace + 1));
|
|
342
|
+
|
|
343
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
// give up
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return null;
|
|
352
|
+
}
|