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 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
+ }