@synergenius/flow-weaver 0.24.4 → 0.25.1
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.
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
import { type ChildProcess } from 'node:child_process';
|
|
23
23
|
import type { StreamEvent, CliSessionOptions } from './types.js';
|
|
24
24
|
export declare class CliSession {
|
|
25
|
-
private readonly options;
|
|
26
25
|
readonly sessionId: `${string}-${string}-${string}-${string}-${string}`;
|
|
27
26
|
private child;
|
|
28
27
|
private alive;
|
|
@@ -35,8 +34,14 @@ export declare class CliSession {
|
|
|
35
34
|
private readonly log;
|
|
36
35
|
private readonly spawnFn;
|
|
37
36
|
private readonly idleTimeout;
|
|
37
|
+
private readonly opts;
|
|
38
38
|
constructor(options: CliSessionOptions);
|
|
39
39
|
get ready(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Check if this session was created with equivalent CLI-relevant options.
|
|
42
|
+
* Used by the session cache to detect option drift.
|
|
43
|
+
*/
|
|
44
|
+
matchesOptions(other: CliSessionOptions): boolean;
|
|
40
45
|
/**
|
|
41
46
|
* Inject a mock child process for testing.
|
|
42
47
|
* @internal — test only
|
|
@@ -49,12 +54,22 @@ export declare class CliSession {
|
|
|
49
54
|
/**
|
|
50
55
|
* Send a user message and stream back events.
|
|
51
56
|
* Auto-respawns if the process has died.
|
|
57
|
+
*
|
|
58
|
+
* Phase 1.3: Concurrent send() guard — throws if a previous turn is still active.
|
|
59
|
+
* The activeTurn lock is claimed synchronously BEFORE any async work (spawn)
|
|
60
|
+
* to prevent TOCTOU races.
|
|
52
61
|
*/
|
|
53
62
|
send(userMessage: string, systemPromptPrefix?: string): AsyncGenerator<StreamEvent>;
|
|
54
63
|
/**
|
|
55
64
|
* Kill the CLI process.
|
|
56
65
|
*/
|
|
57
66
|
kill(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Compute a fingerprint of CLI-relevant options for cache comparison.
|
|
69
|
+
* Field order is significant for JSON.stringify comparison.
|
|
70
|
+
* Add new CLI-relevant fields here when they're added to CliSessionOptions.
|
|
71
|
+
*/
|
|
72
|
+
private static fingerprint;
|
|
58
73
|
private pushEvent;
|
|
59
74
|
private completeTurn;
|
|
60
75
|
private markDead;
|
|
@@ -64,6 +79,8 @@ export declare class CliSession {
|
|
|
64
79
|
}
|
|
65
80
|
/**
|
|
66
81
|
* Get an existing session or create a new one.
|
|
82
|
+
* Phase 1.4: Validates that cached sessions have matching CLI-relevant options.
|
|
83
|
+
* If options changed on the same key, the old session is killed and recreated.
|
|
67
84
|
*/
|
|
68
85
|
export declare function getOrCreateCliSession(key: string, options: CliSessionOptions): CliSession;
|
|
69
86
|
/**
|
|
@@ -24,7 +24,6 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|
|
24
24
|
import { StreamJsonParser } from './streaming.js';
|
|
25
25
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
26
26
|
export class CliSession {
|
|
27
|
-
options;
|
|
28
27
|
sessionId = randomUUID();
|
|
29
28
|
child = null;
|
|
30
29
|
alive = false;
|
|
@@ -37,8 +36,9 @@ export class CliSession {
|
|
|
37
36
|
log;
|
|
38
37
|
spawnFn;
|
|
39
38
|
idleTimeout;
|
|
39
|
+
opts;
|
|
40
40
|
constructor(options) {
|
|
41
|
-
this.
|
|
41
|
+
this.opts = options;
|
|
42
42
|
this.log = options.logger;
|
|
43
43
|
this.spawnFn = options.spawnFn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, { ...opts, stdio: opts.stdio }));
|
|
44
44
|
this.idleTimeout = options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
@@ -48,6 +48,13 @@ export class CliSession {
|
|
|
48
48
|
get ready() {
|
|
49
49
|
return this.alive;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Check if this session was created with equivalent CLI-relevant options.
|
|
53
|
+
* Used by the session cache to detect option drift.
|
|
54
|
+
*/
|
|
55
|
+
matchesOptions(other) {
|
|
56
|
+
return CliSession.fingerprint(this.opts) === CliSession.fingerprint(other);
|
|
57
|
+
}
|
|
51
58
|
/**
|
|
52
59
|
* Inject a mock child process for testing.
|
|
53
60
|
* @internal — test only
|
|
@@ -67,7 +74,11 @@ export class CliSession {
|
|
|
67
74
|
* Spawn the CLI process. Must be called before send().
|
|
68
75
|
*/
|
|
69
76
|
async spawn() {
|
|
70
|
-
|
|
77
|
+
// Kill existing child to prevent orphaned processes from concurrent spawn
|
|
78
|
+
if (this.child && this.alive) {
|
|
79
|
+
this.child.kill('SIGTERM');
|
|
80
|
+
}
|
|
81
|
+
const { binPath, cwd, env, model, mcpConfigPath } = this.opts;
|
|
71
82
|
const args = [
|
|
72
83
|
'-p',
|
|
73
84
|
'--input-format',
|
|
@@ -87,6 +98,21 @@ export class CliSession {
|
|
|
87
98
|
if (mcpConfigPath) {
|
|
88
99
|
args.push('--mcp-config', mcpConfigPath, '--strict-mcp-config');
|
|
89
100
|
}
|
|
101
|
+
const { disallowedTools, tools, systemPrompt, appendSystemPrompt } = this.opts;
|
|
102
|
+
if (disallowedTools && disallowedTools.length > 0) {
|
|
103
|
+
args.push('--disallowed-tools', disallowedTools.join(','));
|
|
104
|
+
}
|
|
105
|
+
// Phase 1.1: --tools flag (whitelist / disable all)
|
|
106
|
+
if (tools !== undefined) {
|
|
107
|
+
args.push('--tools', tools);
|
|
108
|
+
}
|
|
109
|
+
if (systemPrompt) {
|
|
110
|
+
args.push('--system-prompt', systemPrompt);
|
|
111
|
+
}
|
|
112
|
+
// Phase 1.2: --append-system-prompt flag
|
|
113
|
+
if (appendSystemPrompt) {
|
|
114
|
+
args.push('--append-system-prompt', appendSystemPrompt);
|
|
115
|
+
}
|
|
90
116
|
const spawnResult = this.spawnFn(binPath, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: env ?? process.env });
|
|
91
117
|
const child = 'child' in spawnResult ? spawnResult.child : spawnResult;
|
|
92
118
|
const cleanup = 'cleanup' in spawnResult ? spawnResult.cleanup : undefined;
|
|
@@ -115,91 +141,110 @@ export class CliSession {
|
|
|
115
141
|
/**
|
|
116
142
|
* Send a user message and stream back events.
|
|
117
143
|
* Auto-respawns if the process has died.
|
|
144
|
+
*
|
|
145
|
+
* Phase 1.3: Concurrent send() guard — throws if a previous turn is still active.
|
|
146
|
+
* The activeTurn lock is claimed synchronously BEFORE any async work (spawn)
|
|
147
|
+
* to prevent TOCTOU races.
|
|
118
148
|
*/
|
|
119
149
|
async *send(userMessage, systemPromptPrefix) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
150
|
+
// Phase 1.3: Guard against concurrent sends
|
|
151
|
+
if (this.activeTurn) {
|
|
152
|
+
throw new Error('CliSession: concurrent send() calls are not supported. Previous turn still active.');
|
|
123
153
|
}
|
|
124
|
-
|
|
125
|
-
this.parser.reset();
|
|
126
|
-
const content = systemPromptPrefix ? `${systemPromptPrefix}\n\n${userMessage}` : userMessage;
|
|
127
|
-
// Write NDJSON message to stdin
|
|
128
|
-
const ndjsonMessage = JSON.stringify({
|
|
129
|
-
type: 'user',
|
|
130
|
-
message: { role: 'user', content },
|
|
131
|
-
parent_tool_use_id: null,
|
|
132
|
-
}) + '\n';
|
|
154
|
+
// Claim the turn synchronously BEFORE any await to prevent TOCTOU race
|
|
133
155
|
const turn = { resolve: () => { }, events: [], done: false };
|
|
134
156
|
this.activeTurn = turn;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
this.parser = new StreamJsonParser((event) => {
|
|
140
|
-
// The result event emits message_stop — but in session mode,
|
|
141
|
-
// we need to detect it as the turn boundary
|
|
142
|
-
if (event.type === 'message_stop' && !sawResult) {
|
|
143
|
-
// This is a stream_event message_stop (API turn), not CLI turn end.
|
|
144
|
-
// Push it but don't complete the turn.
|
|
145
|
-
originalPush(event);
|
|
146
|
-
return;
|
|
157
|
+
try {
|
|
158
|
+
if (!this.alive) {
|
|
159
|
+
this.log?.info('CLI session dead, respawning', { sessionId: this.sessionId });
|
|
160
|
+
await this.spawn();
|
|
147
161
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
this.resetIdleTimer();
|
|
163
|
+
this.parser.reset();
|
|
164
|
+
const content = systemPromptPrefix ? `${systemPromptPrefix}\n\n${userMessage}` : userMessage;
|
|
165
|
+
// Write NDJSON message to stdin
|
|
166
|
+
const ndjsonMessage = JSON.stringify({
|
|
167
|
+
type: 'user',
|
|
168
|
+
message: { role: 'user', content },
|
|
169
|
+
parent_tool_use_id: null,
|
|
170
|
+
}) + '\n';
|
|
171
|
+
// Track whether this turn saw a 'result' event (definitive turn end)
|
|
172
|
+
let sawResult = false;
|
|
173
|
+
// Wrap pushEvent to detect result-driven message_stop as turn end
|
|
174
|
+
const originalPush = this.pushEvent.bind(this);
|
|
175
|
+
this.parser = new StreamJsonParser((event) => {
|
|
176
|
+
// The result event emits message_stop — but in session mode,
|
|
177
|
+
// we need to detect it as the turn boundary
|
|
178
|
+
if (event.type === 'message_stop' && !sawResult) {
|
|
179
|
+
// This is a stream_event message_stop (API turn), not CLI turn end.
|
|
180
|
+
// Push it but don't complete the turn.
|
|
181
|
+
originalPush(event);
|
|
182
|
+
return;
|
|
160
183
|
}
|
|
184
|
+
originalPush(event);
|
|
185
|
+
});
|
|
186
|
+
// Override parser feed to detect result events for turn completion
|
|
187
|
+
let sawTerminal = false;
|
|
188
|
+
const baseFeed = this.parser.feed.bind(this.parser);
|
|
189
|
+
this.parser.feed = (line) => {
|
|
190
|
+
// Check if this line is a turn-ending event before parsing
|
|
191
|
+
try {
|
|
192
|
+
let parsed = JSON.parse(line);
|
|
193
|
+
if (parsed.type === 'stream_event' && parsed.event)
|
|
194
|
+
parsed = parsed.event;
|
|
195
|
+
if (parsed.type === 'result') {
|
|
196
|
+
sawResult = true;
|
|
197
|
+
sawTerminal = true;
|
|
198
|
+
}
|
|
199
|
+
// authentication_failed is also a terminal event
|
|
200
|
+
if (parsed.type === 'assistant' && parsed.error === 'authentication_failed') {
|
|
201
|
+
sawTerminal = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Not JSON, let parser handle it
|
|
206
|
+
}
|
|
207
|
+
baseFeed(line);
|
|
208
|
+
if (sawTerminal) {
|
|
209
|
+
this.completeTurn();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
this.child.stdin.write(ndjsonMessage, (err) => {
|
|
214
|
+
if (err) {
|
|
215
|
+
this.log?.error('stdin write error', { sessionId: this.sessionId, err });
|
|
216
|
+
this.markDead();
|
|
217
|
+
turn.done = true;
|
|
218
|
+
turn.resolve();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
161
221
|
}
|
|
162
|
-
catch {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (sawResult) {
|
|
167
|
-
this.completeTurn();
|
|
222
|
+
catch (err) {
|
|
223
|
+
this.log?.error('stdin write exception', { sessionId: this.sessionId, err });
|
|
224
|
+
this.markDead();
|
|
225
|
+
throw new Error('CLI session stdin write failed');
|
|
168
226
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
227
|
+
// Yield events as they arrive
|
|
228
|
+
while (!turn.done || turn.events.length > 0) {
|
|
229
|
+
if (turn.events.length > 0) {
|
|
230
|
+
yield turn.events.shift();
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
await new Promise((r) => {
|
|
234
|
+
turn.resolve = r;
|
|
235
|
+
setTimeout(r, 50);
|
|
236
|
+
});
|
|
177
237
|
}
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
catch (err) {
|
|
181
|
-
this.log?.error('stdin write exception', { sessionId: this.sessionId, err });
|
|
182
|
-
this.markDead();
|
|
183
|
-
throw new Error('CLI session stdin write failed');
|
|
184
|
-
}
|
|
185
|
-
// Yield events as they arrive
|
|
186
|
-
while (!turn.done || turn.events.length > 0) {
|
|
187
|
-
if (turn.events.length > 0) {
|
|
188
|
-
yield turn.events.shift();
|
|
189
238
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
setTimeout(r, 50);
|
|
194
|
-
});
|
|
239
|
+
// Yield any remaining events
|
|
240
|
+
while (turn.events.length > 0) {
|
|
241
|
+
yield turn.events.shift();
|
|
195
242
|
}
|
|
196
243
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
244
|
+
finally {
|
|
245
|
+
this.activeTurn = null;
|
|
246
|
+
this.resetIdleTimer();
|
|
200
247
|
}
|
|
201
|
-
this.activeTurn = null;
|
|
202
|
-
this.resetIdleTimer();
|
|
203
248
|
}
|
|
204
249
|
/**
|
|
205
250
|
* Kill the CLI process.
|
|
@@ -209,17 +254,34 @@ export class CliSession {
|
|
|
209
254
|
if (this.child && this.alive) {
|
|
210
255
|
this.log?.info('Killing CLI session', { sessionId: this.sessionId });
|
|
211
256
|
this.child.kill('SIGTERM');
|
|
212
|
-
|
|
257
|
+
// Phase 1.5: unref the SIGKILL fallback timer so it doesn't keep Node alive
|
|
258
|
+
const sigkillTimer = setTimeout(() => {
|
|
213
259
|
if (this.child && !this.child.killed) {
|
|
214
260
|
this.child.kill('SIGKILL');
|
|
215
261
|
}
|
|
216
262
|
}, 2000);
|
|
263
|
+
sigkillTimer.unref();
|
|
217
264
|
}
|
|
218
265
|
this.markDead();
|
|
219
266
|
}
|
|
220
267
|
// ---------------------------------------------------------------------------
|
|
221
268
|
// Private
|
|
222
269
|
// ---------------------------------------------------------------------------
|
|
270
|
+
/**
|
|
271
|
+
* Compute a fingerprint of CLI-relevant options for cache comparison.
|
|
272
|
+
* Field order is significant for JSON.stringify comparison.
|
|
273
|
+
* Add new CLI-relevant fields here when they're added to CliSessionOptions.
|
|
274
|
+
*/
|
|
275
|
+
static fingerprint(options) {
|
|
276
|
+
return JSON.stringify({
|
|
277
|
+
model: options.model,
|
|
278
|
+
mcpConfigPath: options.mcpConfigPath,
|
|
279
|
+
disallowedTools: options.disallowedTools,
|
|
280
|
+
tools: options.tools,
|
|
281
|
+
systemPrompt: options.systemPrompt,
|
|
282
|
+
appendSystemPrompt: options.appendSystemPrompt,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
223
285
|
pushEvent(event) {
|
|
224
286
|
if (!this.activeTurn)
|
|
225
287
|
return;
|
|
@@ -233,6 +295,8 @@ export class CliSession {
|
|
|
233
295
|
this.activeTurn.resolve();
|
|
234
296
|
}
|
|
235
297
|
markDead() {
|
|
298
|
+
// Phase 1.5: Clear idle timer to prevent dangling timer on process crash
|
|
299
|
+
this.clearIdleTimer();
|
|
236
300
|
this.alive = false;
|
|
237
301
|
this.cleanupFn?.();
|
|
238
302
|
this.cleanupFn = null;
|
|
@@ -258,6 +322,8 @@ export class CliSession {
|
|
|
258
322
|
this.log?.info('CLI session idle timeout, killing', { sessionId: this.sessionId });
|
|
259
323
|
this.kill();
|
|
260
324
|
}, this.idleTimeout);
|
|
325
|
+
// Phase 1.5: unref so idle timer doesn't keep Node alive on shutdown
|
|
326
|
+
this.idleTimer.unref();
|
|
261
327
|
}
|
|
262
328
|
clearIdleTimer() {
|
|
263
329
|
if (this.idleTimer) {
|
|
@@ -272,14 +338,22 @@ export class CliSession {
|
|
|
272
338
|
const sessions = new Map();
|
|
273
339
|
/**
|
|
274
340
|
* Get an existing session or create a new one.
|
|
341
|
+
* Phase 1.4: Validates that cached sessions have matching CLI-relevant options.
|
|
342
|
+
* If options changed on the same key, the old session is killed and recreated.
|
|
275
343
|
*/
|
|
276
344
|
export function getOrCreateCliSession(key, options) {
|
|
277
345
|
const existing = sessions.get(key);
|
|
278
346
|
if (existing && existing.ready) {
|
|
279
|
-
|
|
347
|
+
// Phase 1.4: Check if CLI-relevant options match
|
|
348
|
+
if (existing.matchesOptions(options)) {
|
|
349
|
+
return existing;
|
|
350
|
+
}
|
|
351
|
+
// Options changed — kill old session and recreate
|
|
352
|
+
existing.kill();
|
|
353
|
+
sessions.delete(key);
|
|
280
354
|
}
|
|
281
|
-
|
|
282
|
-
|
|
355
|
+
else if (existing) {
|
|
356
|
+
// Kill stale (dead) session
|
|
283
357
|
existing.kill();
|
|
284
358
|
sessions.delete(key);
|
|
285
359
|
}
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -191,6 +191,21 @@ export interface CliSessionOptions {
|
|
|
191
191
|
mcpConfigPath?: string;
|
|
192
192
|
/** Disable specific built-in tools (e.g. ['Read', 'Edit', 'Write', 'Bash'] to force MCP tools). */
|
|
193
193
|
disallowedTools?: string[];
|
|
194
|
+
/**
|
|
195
|
+
* Restrict built-in tools via --tools. Empty string "" disables all. Comma-separated PascalCase names.
|
|
196
|
+
* Case-sensitive — use exact names: "Read", "Bash", "Edit" (not "read", "bash").
|
|
197
|
+
* MCP tools are unaffected by this flag.
|
|
198
|
+
*/
|
|
199
|
+
tools?: string;
|
|
200
|
+
/** System prompt passed to the CLI via --system-prompt. Overrides the default Claude Code prompt. */
|
|
201
|
+
systemPrompt?: string;
|
|
202
|
+
/**
|
|
203
|
+
* Appended to the active system prompt via --append-system-prompt.
|
|
204
|
+
* Keeps Claude Code's built-in guidance and adds custom instructions.
|
|
205
|
+
* WARNING: If used with systemPrompt, appends to the custom prompt (not the default).
|
|
206
|
+
* Built-in Claude Code guidance is lost when systemPrompt is set.
|
|
207
|
+
*/
|
|
208
|
+
appendSystemPrompt?: string;
|
|
194
209
|
/** Custom spawn function. Defaults to child_process.spawn. */
|
|
195
210
|
spawnFn?: SpawnFn;
|
|
196
211
|
/** Idle timeout in milliseconds. Defaults to 600000 (10 minutes). */
|
package/dist/cli/flow-weaver.mjs
CHANGED
|
@@ -9886,7 +9886,7 @@ var VERSION;
|
|
|
9886
9886
|
var init_generated_version = __esm({
|
|
9887
9887
|
"src/generated-version.ts"() {
|
|
9888
9888
|
"use strict";
|
|
9889
|
-
VERSION = "0.
|
|
9889
|
+
VERSION = "0.25.1";
|
|
9890
9890
|
}
|
|
9891
9891
|
});
|
|
9892
9892
|
|
|
@@ -95973,7 +95973,7 @@ function parseIntStrict(value) {
|
|
|
95973
95973
|
// src/cli/index.ts
|
|
95974
95974
|
init_logger();
|
|
95975
95975
|
init_error_utils();
|
|
95976
|
-
var version2 = true ? "0.
|
|
95976
|
+
var version2 = true ? "0.25.1" : "0.0.0-dev";
|
|
95977
95977
|
var program2 = new Command();
|
|
95978
95978
|
program2.name("fw").description("Flow Weaver Annotations - Compile and validate workflow files").option("-v, --version", "Output the current version").option("--no-color", "Disable colors").option("--color", "Force colors").on("option:version", () => {
|
|
95979
95979
|
logger.banner(version2);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.25.1";
|
|
2
2
|
//# sourceMappingURL=generated-version.d.ts.map
|
package/package.json
CHANGED