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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runOpencode } from "./opencode.js";
|
|
5
|
+
import { runClaudeCode } from "./claude-code.js";
|
|
6
|
+
import { resolvePromptInput } from "./prompt.js";
|
|
7
|
+
import { getSessionId, loadSessionStore, saveSessionId } from "./session-store.js";
|
|
8
|
+
|
|
9
|
+
const PROVIDERS = {
|
|
10
|
+
opencode: {
|
|
11
|
+
defaultBin: "opencode",
|
|
12
|
+
configKey: "agent.opencode.bin",
|
|
13
|
+
run: runOpencode
|
|
14
|
+
},
|
|
15
|
+
"claude-code": {
|
|
16
|
+
defaultBin: "claude",
|
|
17
|
+
configKey: "agent.claude.bin",
|
|
18
|
+
run: runClaudeCode
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return the list of supported agent providers.
|
|
24
|
+
*
|
|
25
|
+
* @returns {string[]} Provider names.
|
|
26
|
+
*/
|
|
27
|
+
export function getSupportedProviders() {
|
|
28
|
+
return Object.keys(PROVIDERS);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Execute an agent step. Resolves the binary, loads any prior session for
|
|
33
|
+
* `session: continue`, resolves the prompt input, delegates to the provider
|
|
34
|
+
* adapter, and persists any new session ID returned by the agent.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} options - Dispatch options.
|
|
37
|
+
* @param {{name: string, agent: object}} options.step - Normalized step definition.
|
|
38
|
+
* @param {string} options.cwd - Workflow working directory.
|
|
39
|
+
* @param {object} options.env - Step environment.
|
|
40
|
+
* @param {number} options.timeoutMs - Step timeout in milliseconds.
|
|
41
|
+
* @param {object} options.config - Flow config (used to resolve binary overrides).
|
|
42
|
+
* @param {string} options.runId - Current workflow run ID.
|
|
43
|
+
* @param {(message: string) => void} options.warn - Warning sink used when session continue fails.
|
|
44
|
+
* @param {typeof import("node:child_process").spawn} [options.spawnProcess] - Child process factory (tests).
|
|
45
|
+
* @param {{isCancelled: () => boolean, getReason: () => string | null, trackChild: (child: import("node:child_process").ChildProcess) => () => void}} [options.cancellation] - Cancellation controller.
|
|
46
|
+
* @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: number | null, sessionId: string | null}>} Agent run result.
|
|
47
|
+
*/
|
|
48
|
+
export async function executeAgentStep(options) {
|
|
49
|
+
const {
|
|
50
|
+
step,
|
|
51
|
+
cwd,
|
|
52
|
+
env,
|
|
53
|
+
timeoutMs,
|
|
54
|
+
config,
|
|
55
|
+
runId,
|
|
56
|
+
warn,
|
|
57
|
+
spawnProcess,
|
|
58
|
+
cancellation = null,
|
|
59
|
+
templateFn = (value) => value
|
|
60
|
+
} = options;
|
|
61
|
+
const agentSpec = step.agent;
|
|
62
|
+
const providerName = agentSpec.provider;
|
|
63
|
+
const provider = PROVIDERS[providerName];
|
|
64
|
+
|
|
65
|
+
if (!provider) {
|
|
66
|
+
throw new AgentDispatchError(
|
|
67
|
+
`Unknown agent provider '${providerName}'. Supported: ${getSupportedProviders().join(", ")}.`,
|
|
68
|
+
"AGENT_UNKNOWN_PROVIDER"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const binary = await resolveAgentBinary(providerName, config, env);
|
|
73
|
+
|
|
74
|
+
let sessionId = null;
|
|
75
|
+
|
|
76
|
+
if (agentSpec.session === "continue") {
|
|
77
|
+
const store = await loadSessionStore(cwd, runId);
|
|
78
|
+
sessionId = getSessionId(store, step.name);
|
|
79
|
+
|
|
80
|
+
if (sessionId === null && typeof warn === "function") {
|
|
81
|
+
warn(
|
|
82
|
+
`Agent step '${step.name}' requested session: continue but no prior session exists for this step; starting fresh.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Resolve prompt input from the RAW (untemplated) value first, then template
|
|
88
|
+
// the resolved text. This prevents templated content (e.g. a `{{steps.x.stdout}}`
|
|
89
|
+
// that happens to start with `./`) from being mis-interpreted as a file path.
|
|
90
|
+
const rawPrompt = agentSpec.prompt;
|
|
91
|
+
const promptText = await resolvePromptInput(rawPrompt, cwd);
|
|
92
|
+
const templatedPrompt = templateFn(promptText);
|
|
93
|
+
|
|
94
|
+
if (typeof templatedPrompt !== "string" || templatedPrompt.trim() === "") {
|
|
95
|
+
throw new AgentDispatchError(
|
|
96
|
+
`Agent step '${step.name}' resolved to an empty prompt after templating; refusing to invoke ${providerName} with no input.`,
|
|
97
|
+
"AGENT_EMPTY_PROMPT"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const templatedAppendSystemPrompt = agentSpec.append_system_prompt === undefined
|
|
102
|
+
? undefined
|
|
103
|
+
: templateFn(agentSpec.append_system_prompt);
|
|
104
|
+
const result = await provider.run({
|
|
105
|
+
bin: binary,
|
|
106
|
+
prompt: templatedPrompt,
|
|
107
|
+
model: agentSpec.model,
|
|
108
|
+
sessionId,
|
|
109
|
+
timeoutMs,
|
|
110
|
+
cwd,
|
|
111
|
+
env,
|
|
112
|
+
interactive: agentSpec.interactive === true,
|
|
113
|
+
plugins: agentSpec.plugins ?? [],
|
|
114
|
+
allowTools: agentSpec.allow_tools ?? [],
|
|
115
|
+
appendSystemPrompt: templatedAppendSystemPrompt,
|
|
116
|
+
sourceAccess: agentSpec.source_access !== false,
|
|
117
|
+
spawnProcess,
|
|
118
|
+
cancellation
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (result.sessionId) {
|
|
122
|
+
if (isSafeSessionId(result.sessionId)) {
|
|
123
|
+
await saveSessionId(cwd, runId, step.name, providerName, result.sessionId);
|
|
124
|
+
} else if (typeof warn === "function") {
|
|
125
|
+
warn(
|
|
126
|
+
`Agent step '${step.name}' returned an unsafe session_id; not persisting (would risk CLI flag injection on resume).`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate that a session ID is safe to pass back to a CLI as a `--continue`
|
|
136
|
+
* or `--resume` argument value on the next run.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} sessionId - Session identifier captured from agent output.
|
|
139
|
+
* @returns {boolean} True when the ID is alphanumeric/dash/underscore/dot only.
|
|
140
|
+
*/
|
|
141
|
+
export function isSafeSessionId(sessionId) {
|
|
142
|
+
return typeof sessionId === "string"
|
|
143
|
+
&& sessionId.length > 0
|
|
144
|
+
&& sessionId.length <= 256
|
|
145
|
+
&& /^[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(sessionId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the absolute path (or bare command name) used to launch an agent.
|
|
150
|
+
*
|
|
151
|
+
* Resolution order:
|
|
152
|
+
* 1. `config.agent.<provider>.bin` override
|
|
153
|
+
* 2. `$PATH` lookup for the provider's default binary
|
|
154
|
+
*
|
|
155
|
+
* @param {string} providerName - Provider name (e.g. `opencode`).
|
|
156
|
+
* @param {object} config - Flow config.
|
|
157
|
+
* @param {object} env - Process environment used for PATH lookup.
|
|
158
|
+
* @returns {Promise<string>} Resolved binary path.
|
|
159
|
+
*/
|
|
160
|
+
export async function resolveAgentBinary(providerName, config, env) {
|
|
161
|
+
const provider = PROVIDERS[providerName];
|
|
162
|
+
|
|
163
|
+
if (!provider) {
|
|
164
|
+
throw new AgentDispatchError(
|
|
165
|
+
`Unknown agent provider '${providerName}'.`,
|
|
166
|
+
"AGENT_UNKNOWN_PROVIDER"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const configKey = providerName === "claude-code" ? "claude" : providerName;
|
|
171
|
+
const override = config?.agent?.[configKey]?.bin;
|
|
172
|
+
|
|
173
|
+
if (typeof override === "string" && override.trim() !== "") {
|
|
174
|
+
return override;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const found = await findOnPath(provider.defaultBin, env);
|
|
178
|
+
|
|
179
|
+
if (found === null) {
|
|
180
|
+
throw new AgentDispatchError(
|
|
181
|
+
`Could not find '${provider.defaultBin}' on PATH. Set '${provider.configKey}' in flow config or install the CLI.`,
|
|
182
|
+
"AGENT_BINARY_NOT_FOUND"
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Locate a binary on `$PATH`. Returns the absolute path or `null` when absent.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} binary - Bare binary name.
|
|
193
|
+
* @param {object} env - Environment providing `PATH`.
|
|
194
|
+
* @returns {Promise<string | null>} Resolved path or null.
|
|
195
|
+
*/
|
|
196
|
+
async function findOnPath(binary, env) {
|
|
197
|
+
const pathValue = env?.PATH ?? "";
|
|
198
|
+
const separator = process.platform === "win32" ? ";" : ":";
|
|
199
|
+
const segments = pathValue.split(separator).filter((segment) => segment !== "");
|
|
200
|
+
|
|
201
|
+
for (const segment of segments) {
|
|
202
|
+
const candidate = path.join(segment, binary);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await access(candidate, constants.X_OK);
|
|
206
|
+
return candidate;
|
|
207
|
+
} catch {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Error thrown when an agent step cannot be dispatched.
|
|
217
|
+
*/
|
|
218
|
+
export class AgentDispatchError extends Error {
|
|
219
|
+
/**
|
|
220
|
+
* @param {string} message - Human-readable error message.
|
|
221
|
+
* @param {string} code - Stable machine-readable error code.
|
|
222
|
+
*/
|
|
223
|
+
constructor(message, code) {
|
|
224
|
+
super(message);
|
|
225
|
+
this.name = "AgentDispatchError";
|
|
226
|
+
this.code = code;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-buffered NDJSON parser that tolerates malformed lines and chunk splits.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const buffer = createNdjsonBuffer();
|
|
6
|
+
* for (const chunk of stdoutChunks) {
|
|
7
|
+
* const events = buffer.push(chunk);
|
|
8
|
+
* for (const event of events) { ... }
|
|
9
|
+
* }
|
|
10
|
+
* const trailingEvents = buffer.flush();
|
|
11
|
+
*
|
|
12
|
+
* Malformed lines are surfaced as `{ __malformed: "<raw line>" }` so callers
|
|
13
|
+
* can log or ignore without losing the stream.
|
|
14
|
+
*
|
|
15
|
+
* @returns {{push: (chunk: string) => object[], flush: () => object[]}} Buffer API.
|
|
16
|
+
*/
|
|
17
|
+
export function createNdjsonBuffer() {
|
|
18
|
+
let pending = "";
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
push(chunk) {
|
|
22
|
+
if (chunk === undefined || chunk === null) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pending += String(chunk);
|
|
27
|
+
const lines = pending.split("\n");
|
|
28
|
+
pending = lines.pop() ?? "";
|
|
29
|
+
|
|
30
|
+
return parseLines(lines);
|
|
31
|
+
},
|
|
32
|
+
flush() {
|
|
33
|
+
if (pending.trim() === "") {
|
|
34
|
+
pending = "";
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const events = parseLines([pending]);
|
|
39
|
+
pending = "";
|
|
40
|
+
return events;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string[]} lines - Raw NDJSON lines.
|
|
47
|
+
* @returns {object[]} Parsed objects; malformed lines surfaced as `{__malformed}`.
|
|
48
|
+
*/
|
|
49
|
+
function parseLines(lines) {
|
|
50
|
+
const events = [];
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
|
|
55
|
+
if (trimmed === "") {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
events.push(JSON.parse(trimmed));
|
|
61
|
+
} catch {
|
|
62
|
+
events.push({ __malformed: trimmed });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return events;
|
|
67
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createNdjsonBuffer } from "./ndjson.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run the `opencode` CLI as a headless or interactive agent step.
|
|
6
|
+
*
|
|
7
|
+
* In headless mode, output is captured and NDJSON events are parsed to
|
|
8
|
+
* extract `cost_usd` and `session_id` from the final `result` event. The
|
|
9
|
+
* assistant's final text (if present) becomes the step stdout.
|
|
10
|
+
*
|
|
11
|
+
* In interactive mode, stdio is inherited so the user can drive the
|
|
12
|
+
* agent directly from the terminal. No NDJSON parsing occurs.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} options - Invocation options.
|
|
15
|
+
* @param {string} options.bin - Resolved binary path for the `opencode` CLI.
|
|
16
|
+
* @param {string} options.prompt - Final prompt text (already templated and resolved).
|
|
17
|
+
* @param {string} [options.model] - Optional model identifier.
|
|
18
|
+
* @param {string | null} [options.sessionId] - Prior session ID to continue, or null for fresh.
|
|
19
|
+
* @param {number} options.timeoutMs - Step timeout in milliseconds.
|
|
20
|
+
* @param {string} options.cwd - Working directory.
|
|
21
|
+
* @param {object} options.env - Process environment.
|
|
22
|
+
* @param {boolean} [options.interactive=false] - Whether to run with TTY passthrough.
|
|
23
|
+
* @param {string[]} [options.plugins] - Optional plugin names passed with `--plugin`.
|
|
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 runOpencode(options) {
|
|
29
|
+
const {
|
|
30
|
+
bin,
|
|
31
|
+
prompt,
|
|
32
|
+
model,
|
|
33
|
+
sessionId = null,
|
|
34
|
+
timeoutMs,
|
|
35
|
+
cwd,
|
|
36
|
+
env,
|
|
37
|
+
interactive = false,
|
|
38
|
+
plugins = [],
|
|
39
|
+
spawnProcess = spawn,
|
|
40
|
+
cancellation = null
|
|
41
|
+
} = options;
|
|
42
|
+
|
|
43
|
+
const args = ["run", "--format", "json"];
|
|
44
|
+
|
|
45
|
+
if (model) {
|
|
46
|
+
args.push("--model", model);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (sessionId) {
|
|
50
|
+
args.push("--continue", sessionId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const plugin of plugins) {
|
|
54
|
+
args.push("--plugin", plugin);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `--` separator ensures a prompt starting with `-` is not parsed as a flag.
|
|
58
|
+
args.push("--", prompt);
|
|
59
|
+
|
|
60
|
+
if (interactive) {
|
|
61
|
+
return spawnInteractive({ bin, args, cwd, env, timeoutMs, cancellation, spawnProcess });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return spawnHeadless({ bin, args, cwd, env, timeoutMs, cancellation, spawnProcess });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} options - Spawn options.
|
|
69
|
+
* @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: number | null, sessionId: string | null}>} Result.
|
|
70
|
+
*/
|
|
71
|
+
function spawnHeadless(options) {
|
|
72
|
+
const { bin, args, cwd, env, timeoutMs, cancellation, spawnProcess } = options;
|
|
73
|
+
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawnProcess(bin, args, {
|
|
76
|
+
cwd,
|
|
77
|
+
env,
|
|
78
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
79
|
+
});
|
|
80
|
+
const untrackChild = cancellation?.trackChild(child) ?? (() => {});
|
|
81
|
+
const ndjson = createNdjsonBuffer();
|
|
82
|
+
let stdoutCapture = "";
|
|
83
|
+
let stderrCapture = "";
|
|
84
|
+
let combinedOutput = "";
|
|
85
|
+
let costUsd = null;
|
|
86
|
+
let sessionId = null;
|
|
87
|
+
let finalText = "";
|
|
88
|
+
let settled = false;
|
|
89
|
+
let timedOut = false;
|
|
90
|
+
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
timedOut = true;
|
|
93
|
+
child.kill("SIGTERM");
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
|
|
96
|
+
child.stdout.on("data", (chunk) => {
|
|
97
|
+
const text = chunk.toString();
|
|
98
|
+
stdoutCapture += text;
|
|
99
|
+
combinedOutput += text;
|
|
100
|
+
|
|
101
|
+
for (const event of ndjson.push(text)) {
|
|
102
|
+
const captured = captureOpencodeEvent(event);
|
|
103
|
+
|
|
104
|
+
if (captured.costUsd !== undefined) {
|
|
105
|
+
costUsd = captured.costUsd;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (captured.sessionId !== undefined) {
|
|
109
|
+
sessionId = captured.sessionId;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (captured.text !== undefined) {
|
|
113
|
+
finalText = captured.text;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
child.stderr.on("data", (chunk) => {
|
|
119
|
+
const text = chunk.toString();
|
|
120
|
+
stderrCapture += text;
|
|
121
|
+
combinedOutput += text;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
child.on("error", (error) => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
untrackChild();
|
|
130
|
+
reject(new Error(`Failed to start opencode agent: ${error.message}`));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
child.on("close", (code, signal) => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
untrackChild();
|
|
139
|
+
|
|
140
|
+
for (const event of ndjson.flush()) {
|
|
141
|
+
const captured = captureOpencodeEvent(event);
|
|
142
|
+
|
|
143
|
+
if (captured.costUsd !== undefined) {
|
|
144
|
+
costUsd = captured.costUsd;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (captured.sessionId !== undefined) {
|
|
148
|
+
sessionId = captured.sessionId;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (captured.text !== undefined) {
|
|
152
|
+
finalText = captured.text;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (timedOut) {
|
|
157
|
+
reject(Object.assign(new Error("opencode agent timed out"), { code: "STEP_TIMEOUT" }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (signal) {
|
|
162
|
+
if (cancellation?.isCancelled()) {
|
|
163
|
+
const reason = cancellation.getReason();
|
|
164
|
+
|
|
165
|
+
reject(Object.assign(new Error(`opencode agent cancelled (${reason})`), {
|
|
166
|
+
code: reason === "STEP_FAILURE" ? "STEP_CANCELLED" : "WORKFLOW_CANCELLED"
|
|
167
|
+
}));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
reject(new Error(`opencode agent exited due to signal ${signal}`));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
resolve({
|
|
176
|
+
exitCode: code ?? 1,
|
|
177
|
+
stdout: finalText !== "" ? finalText : stdoutCapture,
|
|
178
|
+
stderr: stderrCapture,
|
|
179
|
+
combinedOutput,
|
|
180
|
+
costUsd,
|
|
181
|
+
sessionId
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {object} options - Spawn options for interactive mode.
|
|
189
|
+
* @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: null, sessionId: null}>} Result.
|
|
190
|
+
*/
|
|
191
|
+
function spawnInteractive(options) {
|
|
192
|
+
const { bin, args, cwd, env, timeoutMs, cancellation, spawnProcess } = options;
|
|
193
|
+
// Strip headless-only flags (`--format json`) and the `--` separator that
|
|
194
|
+
// only matters when the prompt is a positional arg in headless mode.
|
|
195
|
+
const interactiveArgs = args.filter((arg, index, array) => {
|
|
196
|
+
if (arg === "--format" || arg === "--") {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (array[index - 1] === "--format") {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return true;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const child = spawnProcess(bin, interactiveArgs, {
|
|
209
|
+
cwd,
|
|
210
|
+
env,
|
|
211
|
+
stdio: "inherit"
|
|
212
|
+
});
|
|
213
|
+
const untrackChild = cancellation?.trackChild(child) ?? (() => {});
|
|
214
|
+
let settled = false;
|
|
215
|
+
let timedOut = false;
|
|
216
|
+
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
timedOut = true;
|
|
219
|
+
child.kill("SIGTERM");
|
|
220
|
+
}, timeoutMs);
|
|
221
|
+
|
|
222
|
+
child.on("error", (error) => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
|
|
225
|
+
settled = true;
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
untrackChild();
|
|
228
|
+
reject(new Error(`Failed to start opencode agent: ${error.message}`));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
child.on("close", (code, signal) => {
|
|
232
|
+
if (settled) return;
|
|
233
|
+
|
|
234
|
+
settled = true;
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
untrackChild();
|
|
237
|
+
|
|
238
|
+
if (timedOut) {
|
|
239
|
+
reject(Object.assign(new Error("opencode agent timed out"), { code: "STEP_TIMEOUT" }));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (signal) {
|
|
244
|
+
reject(new Error(`opencode agent exited due to signal ${signal}`));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
resolve({
|
|
249
|
+
exitCode: code ?? 1,
|
|
250
|
+
stdout: "",
|
|
251
|
+
stderr: "",
|
|
252
|
+
combinedOutput: "",
|
|
253
|
+
costUsd: null,
|
|
254
|
+
sessionId: null
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @param {object} event - Parsed NDJSON event from opencode.
|
|
262
|
+
* @returns {{costUsd?: number, sessionId?: string, text?: string}} Captured fields.
|
|
263
|
+
*/
|
|
264
|
+
function captureOpencodeEvent(event) {
|
|
265
|
+
const captured = {};
|
|
266
|
+
|
|
267
|
+
if (event == null || typeof event !== "object") {
|
|
268
|
+
return captured;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (event.type === "result") {
|
|
272
|
+
if (typeof event.cost_usd === "number") {
|
|
273
|
+
captured.costUsd = event.cost_usd;
|
|
274
|
+
} else if (typeof event.total_cost_usd === "number") {
|
|
275
|
+
captured.costUsd = event.total_cost_usd;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (typeof event.session_id === "string" && event.session_id !== "") {
|
|
279
|
+
captured.sessionId = event.session_id;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (typeof event.text === "string") {
|
|
283
|
+
captured.text = event.text;
|
|
284
|
+
} else if (typeof event.result === "string") {
|
|
285
|
+
captured.text = event.result;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return captured;
|
|
290
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const FILE_EXTENSION_WHITELIST = new Set([".md", ".txt", ".prompt"]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a prompt input value to its raw text.
|
|
8
|
+
*
|
|
9
|
+
* A value is treated as a file path when:
|
|
10
|
+
* 1. It starts with `./`, `../`, or `/`, OR its extension is in the whitelist
|
|
11
|
+
* (`.md`, `.txt`, `.prompt`)
|
|
12
|
+
* 2. AND the resolved path exists on disk as a regular file
|
|
13
|
+
*
|
|
14
|
+
* Otherwise the value is treated as an inline string and returned as-is.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} value - Inline prompt text or a path to a prompt file.
|
|
17
|
+
* @param {string} cwd - Working directory used to resolve relative paths.
|
|
18
|
+
* @returns {Promise<string>} Raw prompt text (caller applies flow templating).
|
|
19
|
+
*/
|
|
20
|
+
export async function resolvePromptInput(value, cwd) {
|
|
21
|
+
if (typeof value !== "string") {
|
|
22
|
+
throw new TypeError("resolvePromptInput requires a string value");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!looksLikeFilePath(value)) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const absolute = path.resolve(cwd, value);
|
|
30
|
+
|
|
31
|
+
let stats;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
stats = await stat(absolute);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code === "ENOENT") {
|
|
37
|
+
if (isUnambiguousFilePath(value)) {
|
|
38
|
+
throw new Error(`Prompt file not found: ${value}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!stats.isFile()) {
|
|
48
|
+
if (isUnambiguousFilePath(value)) {
|
|
49
|
+
throw new Error(`Prompt path is not a regular file: ${value}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return readFile(absolute, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} value - Candidate prompt value.
|
|
60
|
+
* @returns {boolean} True when the value has file-path markers.
|
|
61
|
+
*
|
|
62
|
+
* Rules:
|
|
63
|
+
* - Unambiguous prefix (`./`, `../`, `/`) → always treated as a path
|
|
64
|
+
* - Single-token value (no whitespace, no newlines) with whitelisted extension
|
|
65
|
+
* → treated as a path
|
|
66
|
+
* - Anything else → inline text
|
|
67
|
+
*
|
|
68
|
+
* The single-token requirement prevents inline prompts like "see notes.txt for
|
|
69
|
+
* details" from being mis-detected as file paths.
|
|
70
|
+
*/
|
|
71
|
+
function looksLikeFilePath(value) {
|
|
72
|
+
if (isUnambiguousFilePath(value)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (/\s/.test(value)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const extension = path.extname(value).toLowerCase();
|
|
81
|
+
|
|
82
|
+
return FILE_EXTENSION_WHITELIST.has(extension);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} value - Candidate prompt value.
|
|
87
|
+
* @returns {boolean} True when the value is clearly intended as a path.
|
|
88
|
+
*/
|
|
89
|
+
function isUnambiguousFilePath(value) {
|
|
90
|
+
return value.startsWith("./") || value.startsWith("../") || value.startsWith("/");
|
|
91
|
+
}
|