lilflow 0.1.0 → 0.2.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.
@@ -0,0 +1,204 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Error thrown when post-processing agent output fails (invalid JSON, write
6
+ * failure, etc.). The dispatcher converts these to step failures.
7
+ */
8
+ export class AgentOutputError extends Error {
9
+ /**
10
+ * @param {string} message - Human-readable error.
11
+ * @param {string} code - Stable machine-readable code.
12
+ */
13
+ constructor(message, code) {
14
+ super(message);
15
+ this.name = "AgentOutputError";
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Write an agent step's captured stdout to disk.
22
+ *
23
+ * When `format === 'json'`, strip surrounding markdown fences / prose, extract
24
+ * the first balanced JSON object or array, validate it parses, and write the
25
+ * cleaned JSON. When `format === 'text'` (or omitted), write raw stdout.
26
+ *
27
+ * Failures (missing JSON, invalid JSON, unwritable path) throw AgentOutputError
28
+ * so the dispatcher can fail the step cleanly.
29
+ *
30
+ * @param {object} options - Write options.
31
+ * @param {string} options.outputFile - Target file path (relative or absolute).
32
+ * @param {"text" | "json"} [options.format="text"] - Output format.
33
+ * @param {string} options.stdout - Captured agent stdout.
34
+ * @param {string} options.cwd - Workflow working directory (for relative paths).
35
+ * @param {string} options.stepName - Step name used in error messages.
36
+ * @returns {Promise<{bytesWritten: number, path: string}>} Write result.
37
+ */
38
+ export async function writeAgentOutput(options) {
39
+ const { outputFile, format = "text", stdout, cwd, stepName } = options;
40
+ const absolute = path.isAbsolute(outputFile) ? outputFile : path.join(cwd, outputFile);
41
+ const content = format === "json"
42
+ ? extractAndValidateJson(stdout, stepName)
43
+ : stdout;
44
+
45
+ try {
46
+ await mkdir(path.dirname(absolute), { recursive: true });
47
+ await writeFile(absolute, content, "utf8");
48
+ } catch (error) {
49
+ throw new AgentOutputError(
50
+ `Agent step '${stepName}' failed to write output_file '${outputFile}': ${error.message}`,
51
+ "AGENT_OUTPUT_WRITE_FAILED"
52
+ );
53
+ }
54
+
55
+ return { bytesWritten: Buffer.byteLength(content, "utf8"), path: absolute };
56
+ }
57
+
58
+ /**
59
+ * Strip markdown fences and prose, extract the first balanced JSON object or
60
+ * array, validate it parses, and return the cleaned JSON text.
61
+ *
62
+ * @param {string} raw - Captured agent stdout.
63
+ * @param {string} stepName - Step name used in error messages.
64
+ * @returns {string} Cleaned JSON text (guaranteed to JSON.parse).
65
+ */
66
+ export function extractAndValidateJson(raw, stepName) {
67
+ if (typeof raw !== "string") {
68
+ throw new AgentOutputError(
69
+ `Agent step '${stepName}' produced no stdout to write as JSON.`,
70
+ "AGENT_OUTPUT_EMPTY"
71
+ );
72
+ }
73
+
74
+ const stripped = stripMarkdownFences(raw).trim();
75
+
76
+ if (stripped === "") {
77
+ throw new AgentOutputError(
78
+ `Agent step '${stepName}' produced empty stdout — no JSON to extract.`,
79
+ "AGENT_OUTPUT_EMPTY"
80
+ );
81
+ }
82
+
83
+ const extracted = extractBalancedJson(stripped);
84
+
85
+ if (extracted === null) {
86
+ throw new AgentOutputError(
87
+ `Agent step '${stepName}' stdout contains no balanced JSON object or array.`,
88
+ "AGENT_OUTPUT_NO_JSON"
89
+ );
90
+ }
91
+
92
+ try {
93
+ JSON.parse(extracted);
94
+ } catch (error) {
95
+ throw new AgentOutputError(
96
+ `Agent step '${stepName}' stdout is not valid JSON: ${error.message}`,
97
+ "AGENT_OUTPUT_INVALID_JSON"
98
+ );
99
+ }
100
+
101
+ return extracted;
102
+ }
103
+
104
+ /**
105
+ * Strip leading/trailing markdown fences (``` or ```json) from a string.
106
+ *
107
+ * @param {string} raw - Raw text.
108
+ * @returns {string} Text with outer fences removed.
109
+ */
110
+ function stripMarkdownFences(raw) {
111
+ const fencePattern = /^```(?:json|JSON|jsonc)?\s*\n([\s\S]*?)\n```\s*$/;
112
+ const match = fencePattern.exec(raw.trim());
113
+
114
+ if (match) {
115
+ return match[1];
116
+ }
117
+
118
+ // Also handle fences that are anywhere in the string — take the content of the
119
+ // first fenced block.
120
+ const innerPattern = /```(?:json|JSON|jsonc)?\s*\n([\s\S]*?)\n```/;
121
+ const innerMatch = innerPattern.exec(raw);
122
+
123
+ if (innerMatch) {
124
+ return innerMatch[1];
125
+ }
126
+
127
+ return raw;
128
+ }
129
+
130
+ /**
131
+ * Find and return the first balanced `{...}` or `[...]` substring that parses.
132
+ *
133
+ * Scans left-to-right, tracking brace depth, respecting strings (including
134
+ * escape sequences) so that braces inside string literals don't affect depth.
135
+ *
136
+ * @param {string} input - Input text.
137
+ * @returns {string | null} Balanced JSON substring or null when absent.
138
+ */
139
+ export function extractBalancedJson(input) {
140
+ const openers = /[{[]/g;
141
+ let match;
142
+
143
+ while ((match = openers.exec(input)) !== null) {
144
+ const start = match.index;
145
+ const end = findBalancedEnd(input, start);
146
+
147
+ if (end === -1) continue;
148
+
149
+ const candidate = input.slice(start, end + 1);
150
+
151
+ try {
152
+ JSON.parse(candidate);
153
+ return candidate;
154
+ } catch {
155
+ // try next opener
156
+ }
157
+ }
158
+
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Find the index of the balanced closer for the opener at `startIndex`.
164
+ *
165
+ * @param {string} input - Input text.
166
+ * @param {number} startIndex - Index of the `{` or `[` opener.
167
+ * @returns {number} Index of the matching closer, or -1 if unbalanced.
168
+ */
169
+ function findBalancedEnd(input, startIndex) {
170
+ const opener = input[startIndex];
171
+ const closer = opener === "{" ? "}" : "]";
172
+ let depth = 0;
173
+ let inString = false;
174
+ let escape = false;
175
+
176
+ for (let i = startIndex; i < input.length; i += 1) {
177
+ const ch = input[i];
178
+
179
+ if (inString) {
180
+ if (escape) {
181
+ escape = false;
182
+ } else if (ch === "\\") {
183
+ escape = true;
184
+ } else if (ch === "\"") {
185
+ inString = false;
186
+ }
187
+ continue;
188
+ }
189
+
190
+ if (ch === "\"") {
191
+ inString = true;
192
+ continue;
193
+ }
194
+
195
+ if (ch === opener) {
196
+ depth += 1;
197
+ } else if (ch === closer) {
198
+ depth -= 1;
199
+ if (depth === 0) return i;
200
+ }
201
+ }
202
+
203
+ return -1;
204
+ }
package/src/cli.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  runWorkflowSignalCommand,
30
30
  runWorkflowStatusCommand
31
31
  } from "./run-workflow.js";
32
+ import { runSessionBridgeCommand, getSessionBridgeHelpText } from "./session-bridge.js";
32
33
 
33
34
  /**
34
35
  * Return the CLI help text for `flow`.
@@ -48,18 +49,20 @@ Usage:
48
49
  flow status
49
50
  flow list
50
51
  flow logs
52
+ flow session-bridge <subcommand>
51
53
  flow --help
52
54
 
53
55
  Commands:
54
- init Initialize flow in the current repository or from a reusable template
55
- config Show or initialize flow configuration
56
- run Execute a workflow from YAML
57
- resume Resume a failed persisted workflow run
58
- set-step Manually set which persisted workflow step to resume from
59
- signal Deliver a signal to a waiting workflow step
60
- status Show the status of a persisted workflow run
61
- list List persisted workflow runs
62
- logs Show persisted logs for a workflow run`;
56
+ init Initialize flow in the current repository or from a reusable template
57
+ config Show or initialize flow configuration
58
+ run Execute a workflow from YAML
59
+ resume Resume a failed persisted workflow run
60
+ set-step Manually set which persisted workflow step to resume from
61
+ signal Deliver a signal to a waiting workflow step
62
+ status Show the status of a persisted workflow run
63
+ list List persisted workflow runs
64
+ logs Show persisted logs for a workflow run
65
+ session-bridge Agent-facing bridge commands for session-mode workflows`;
63
66
  }
64
67
 
65
68
  /**
@@ -123,6 +126,10 @@ export async function runCli(argv, stdout = console.log, stderr = console.error,
123
126
  return runWorkflowSignalCommand(commandArgs, stdout, stderr, { cwd, env, homeDir });
124
127
  }
125
128
 
129
+ if (command === "session-bridge") {
130
+ return runSessionBridgeCommand(commandArgs, stdout, stderr, { cwd, env });
131
+ }
132
+
126
133
  stderr(`Unknown command: ${command}`);
127
134
  stderr("Run 'flow --help' to see available commands.");
128
135
  return 1;
@@ -193,6 +200,7 @@ export {
193
200
  getLogsHelpText,
194
201
  getResumeHelpText,
195
202
  getRunHelpText,
203
+ getSessionBridgeHelpText,
196
204
  getSetStepHelpText,
197
205
  getSignalHelpText,
198
206
  getStatusHelpText
@@ -8,6 +8,7 @@ import readline from "node:readline/promises";
8
8
  import yaml from "js-yaml";
9
9
  import { ConfigError, loadConfig } from "./config.js";
10
10
  import { AgentDispatchError, executeAgentStep } from "./agents/index.js";
11
+ import { executeSessionWorkflow } from "./session-runner.js";
11
12
 
12
13
  const ANSI_RESET = "\u001B[0m";
13
14
  const ANSI_RED = "\u001B[31m";
@@ -596,6 +597,8 @@ export function parseWorkflowContent(content, sourceLabel) {
596
597
  throw new WorkflowError(`${sourceLabel} must define a non-empty steps array.`);
597
598
  }
598
599
 
600
+ const mode = validateWorkflowMode(parsed.mode, sourceLabel);
601
+ const session = validateWorkflowSession(parsed.session, mode, sourceLabel);
599
602
  const parameters = validateWorkflowParameters(parsed.parameters, sourceLabel);
600
603
  const defaults = validateWorkflowDefaults(parsed.defaults, sourceLabel);
601
604
  const authoredSteps = parsed.steps.map((step, index) => validateStep(step, index, sourceLabel));
@@ -607,12 +610,110 @@ export function parseWorkflowContent(content, sourceLabel) {
607
610
  return {
608
611
  name: parsed.name.trim(),
609
612
  version: parsed.version?.trim(),
613
+ mode,
614
+ ...(session === undefined ? {} : { session }),
610
615
  parameters,
611
616
  defaults,
612
617
  steps
613
618
  };
614
619
  }
615
620
 
621
+ /**
622
+ * Validate the workflow-level `mode` field.
623
+ *
624
+ * @param {unknown} mode - Raw mode value from the YAML document.
625
+ * @param {string} sourceLabel - Error label.
626
+ * @returns {"classic" | "session"} Normalized mode, defaulting to "classic".
627
+ */
628
+ function validateWorkflowMode(mode, sourceLabel) {
629
+ if (mode === undefined) {
630
+ return "classic";
631
+ }
632
+
633
+ if (mode !== "classic" && mode !== "session") {
634
+ throw new WorkflowError(`${sourceLabel} mode must be 'classic' or 'session' when provided.`);
635
+ }
636
+
637
+ return mode;
638
+ }
639
+
640
+ /**
641
+ * Validate and normalize the workflow-level `session` block. Required when
642
+ * `mode: session`; forbidden when `mode: classic`.
643
+ *
644
+ * @param {unknown} session - Raw session block from the YAML document.
645
+ * @param {"classic" | "session"} mode - Resolved workflow mode.
646
+ * @param {string} sourceLabel - Error label.
647
+ * @returns {object | undefined} Normalized session config or undefined in classic mode.
648
+ */
649
+ function validateWorkflowSession(session, mode, sourceLabel) {
650
+ if (mode === "classic") {
651
+ if (session !== undefined) {
652
+ throw new WorkflowError(`${sourceLabel} session must be omitted when mode is 'classic'.`);
653
+ }
654
+
655
+ return undefined;
656
+ }
657
+
658
+ if (session === undefined) {
659
+ throw new WorkflowError(`${sourceLabel} session is required when mode is 'session'.`);
660
+ }
661
+
662
+ if (session == null || typeof session !== "object" || Array.isArray(session)) {
663
+ throw new WorkflowError(`${sourceLabel} session must be a YAML object.`);
664
+ }
665
+
666
+ if (session.provider !== "claude-code" && session.provider !== "opencode") {
667
+ throw new WorkflowError(`${sourceLabel} session.provider must be 'claude-code' or 'opencode'.`);
668
+ }
669
+
670
+ if (session.model !== undefined && (typeof session.model !== "string" || session.model.trim() === "")) {
671
+ throw new WorkflowError(`${sourceLabel} session.model must be a non-empty string when provided.`);
672
+ }
673
+
674
+ if (session.system_prompt !== undefined
675
+ && (typeof session.system_prompt !== "string" || session.system_prompt.trim() === "")) {
676
+ throw new WorkflowError(`${sourceLabel} session.system_prompt must be a non-empty string when provided.`);
677
+ }
678
+
679
+ if (session.compact_after !== undefined
680
+ && (typeof session.compact_after !== "number"
681
+ || !Number.isFinite(session.compact_after)
682
+ || session.compact_after <= 0
683
+ || !Number.isInteger(session.compact_after))) {
684
+ throw new WorkflowError(`${sourceLabel} session.compact_after must be a positive integer when provided.`);
685
+ }
686
+
687
+ if (session.allow_tools !== undefined) {
688
+ if (!Array.isArray(session.allow_tools) || session.allow_tools.some((entry) => typeof entry !== "string")) {
689
+ throw new WorkflowError(`${sourceLabel} session.allow_tools must be an array of strings when provided.`);
690
+ }
691
+
692
+ if (session.allow_tools.some((entry) => entry.startsWith("-"))) {
693
+ throw new WorkflowError(`${sourceLabel} session.allow_tools entries must not start with '-'.`);
694
+ }
695
+ }
696
+
697
+ if (session.plugins !== undefined) {
698
+ if (!Array.isArray(session.plugins) || session.plugins.some((entry) => typeof entry !== "string")) {
699
+ throw new WorkflowError(`${sourceLabel} session.plugins must be an array of strings when provided.`);
700
+ }
701
+
702
+ if (session.plugins.some((entry) => entry.startsWith("-"))) {
703
+ throw new WorkflowError(`${sourceLabel} session.plugins entries must not start with '-'.`);
704
+ }
705
+ }
706
+
707
+ return {
708
+ provider: session.provider,
709
+ ...(session.model === undefined ? {} : { model: session.model.trim() }),
710
+ ...(session.system_prompt === undefined ? {} : { systemPrompt: session.system_prompt.trim() }),
711
+ ...(session.compact_after === undefined ? {} : { compactAfter: session.compact_after }),
712
+ ...(session.allow_tools === undefined ? {} : { allowTools: [...session.allow_tools] }),
713
+ ...(session.plugins === undefined ? {} : { plugins: [...session.plugins] })
714
+ };
715
+ }
716
+
616
717
  /**
617
718
  * Validate and normalize the workflow-level `defaults` block.
618
719
  *
@@ -722,6 +823,20 @@ export async function runWorkflowCommand(args, stdout, stderr, options = {}) {
722
823
  stdout("Warning: .flow/ directory not found. Running in standalone mode.");
723
824
  }
724
825
 
826
+ if (workflow.mode === "session") {
827
+ const sessionResult = await executeSessionWorkflow({
828
+ workflow,
829
+ workflowPath,
830
+ cwd,
831
+ env,
832
+ stdout,
833
+ stderr,
834
+ flowConfig: config,
835
+ eventLogger
836
+ });
837
+ return sessionResult.exitCode === 0 ? 0 : 1;
838
+ }
839
+
725
840
  await executeWorkflow({
726
841
  workflow,
727
842
  workflowPath,
@@ -2906,6 +3021,37 @@ function validateStep(step, index, sourceLabel) {
2906
3021
  throw new WorkflowError(`${sourceLabel} step '${step.name}' agent.interactive must be a boolean when provided.`);
2907
3022
  }
2908
3023
 
3024
+ if (step.agent.mode !== undefined && step.agent.mode !== "autonomous" && step.agent.mode !== "conversational") {
3025
+ throw new WorkflowError(`${sourceLabel} step '${step.name}' agent.mode must be 'autonomous' or 'conversational' when provided.`);
3026
+ }
3027
+
3028
+ if (step.agent.mode !== undefined && step.agent.interactive !== undefined) {
3029
+ throw new WorkflowError(
3030
+ `${sourceLabel} step '${step.name}' cannot define both agent.mode and agent.interactive. Use agent.mode (agent.interactive is deprecated).`
3031
+ );
3032
+ }
3033
+
3034
+ if (step.agent.output_file !== undefined
3035
+ && (typeof step.agent.output_file !== "string" || step.agent.output_file.trim() === "")) {
3036
+ throw new WorkflowError(
3037
+ `${sourceLabel} step '${step.name}' agent.output_file must be a non-empty string when provided.`
3038
+ );
3039
+ }
3040
+
3041
+ if (step.agent.output_format !== undefined
3042
+ && step.agent.output_format !== "text"
3043
+ && step.agent.output_format !== "json") {
3044
+ throw new WorkflowError(
3045
+ `${sourceLabel} step '${step.name}' agent.output_format must be 'text' or 'json' when provided.`
3046
+ );
3047
+ }
3048
+
3049
+ if (step.agent.output_format !== undefined && step.agent.output_file === undefined) {
3050
+ throw new WorkflowError(
3051
+ `${sourceLabel} step '${step.name}' agent.output_format requires agent.output_file.`
3052
+ );
3053
+ }
3054
+
2909
3055
  if (step.agent.allow_tools !== undefined) {
2910
3056
  if (!Array.isArray(step.agent.allow_tools) || step.agent.allow_tools.some((entry) => typeof entry !== "string")) {
2911
3057
  throw new WorkflowError(`${sourceLabel} step '${step.name}' agent.allow_tools must be an array of strings when provided.`);
@@ -3119,9 +3265,18 @@ function resolveProviderDefaults(agentDefaults, providerName) {
3119
3265
  * @returns {object} Normalized agent spec with trimmed strings and defaults.
3120
3266
  */
3121
3267
  function normalizeAgentSpec(agent) {
3268
+ // Back-compat: agent.interactive: true maps to agent.mode: conversational.
3269
+ // Mutual exclusion is enforced earlier in validateStep.
3270
+ const resolvedMode = agent.mode !== undefined
3271
+ ? agent.mode
3272
+ : agent.interactive === true
3273
+ ? "conversational"
3274
+ : "autonomous";
3275
+
3122
3276
  return {
3123
3277
  provider: agent.provider,
3124
3278
  prompt: agent.prompt.trim(),
3279
+ mode: resolvedMode,
3125
3280
  ...(agent.session === undefined ? {} : { session: agent.session }),
3126
3281
  ...(agent.model === undefined ? {} : { model: agent.model.trim() }),
3127
3282
  ...(agent.source_access === undefined ? {} : { source_access: agent.source_access }),
@@ -3129,7 +3284,9 @@ function normalizeAgentSpec(agent) {
3129
3284
  ...(agent.allow_tools === undefined ? {} : { allow_tools: [...agent.allow_tools] }),
3130
3285
  ...(agent.plugins === undefined ? {} : { plugins: [...agent.plugins] }),
3131
3286
  ...(agent.append_system_prompt === undefined ? {} : { append_system_prompt: agent.append_system_prompt.trim() }),
3132
- ...(agent.max_budget_usd === undefined ? {} : { max_budget_usd: agent.max_budget_usd })
3287
+ ...(agent.max_budget_usd === undefined ? {} : { max_budget_usd: agent.max_budget_usd }),
3288
+ ...(agent.output_file === undefined ? {} : { output_file: agent.output_file.trim() }),
3289
+ ...(agent.output_format === undefined ? {} : { output_format: agent.output_format })
3133
3290
  };
3134
3291
  }
3135
3292
 
@@ -3906,6 +4063,50 @@ function quoteConditionLiteral(value) {
3906
4063
  * @param {Record<string, string | number | boolean | null>} [options.parameters={}] - Resolved workflow parameters.
3907
4064
  * @returns {{shouldRun: boolean, resolvedValues: {reference: string, value: string}[]}} Evaluation result.
3908
4065
  */
4066
+ /**
4067
+ * Evaluate a gate step against persisted run events. Reconstructs the
4068
+ * `completedStepOutputs` shape that `evaluateStepCondition` expects and returns
4069
+ * a plain pass/fail result for the session bridge.
4070
+ *
4071
+ * @param {object} step - Normalized gate step (must have `stepType === 'gate'`).
4072
+ * @param {object[]} events - All persisted run events.
4073
+ * @param {object} env - Environment variables available to the gate expression.
4074
+ * @returns {{passed: boolean, message: string | null}} Gate evaluation result.
4075
+ */
4076
+ export function evaluateSessionGate(step, events, env) {
4077
+ const completedStepOutputs = [];
4078
+
4079
+ for (const event of events) {
4080
+ if (event.type === "step_completed" || event.type === "step_failed") {
4081
+ completedStepOutputs.push({
4082
+ name: event.step_name,
4083
+ exitCode: event.exit_code ?? (event.type === "step_failed" ? 1 : 0),
4084
+ stdout: event.stdout ?? "",
4085
+ stderr: event.stderr ?? ""
4086
+ });
4087
+ }
4088
+ }
4089
+
4090
+ try {
4091
+ const { shouldRun } = evaluateStepCondition({
4092
+ condition: step.gate,
4093
+ stepName: step.name,
4094
+ env: env ?? {},
4095
+ completedStepOutputs,
4096
+ parameters: {}
4097
+ });
4098
+
4099
+ return {
4100
+ passed: shouldRun,
4101
+ message: shouldRun
4102
+ ? (step.message ?? null)
4103
+ : (step.message ?? `Gate '${step.name}' failed: ${step.gate}`)
4104
+ };
4105
+ } catch (error) {
4106
+ return { passed: false, message: error.message };
4107
+ }
4108
+ }
4109
+
3909
4110
  function evaluateStepCondition(options) {
3910
4111
  const {
3911
4112
  condition,