@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.options = options;
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
- const { binPath, cwd, env, model, mcpConfigPath } = this.options;
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
- if (!this.alive) {
121
- this.log?.info('CLI session dead, respawning', { sessionId: this.sessionId });
122
- await this.spawn();
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
- this.resetIdleTimer();
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
- // Track whether this turn saw a 'result' event (definitive turn end)
136
- let sawResult = false;
137
- // Wrap pushEvent to detect result-driven message_stop as turn end
138
- const originalPush = this.pushEvent.bind(this);
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
- originalPush(event);
149
- });
150
- // Override parser feed to detect result events for turn completion
151
- const baseFeed = this.parser.feed.bind(this.parser);
152
- this.parser.feed = (line) => {
153
- // Check if this line is a result event before parsing
154
- try {
155
- let parsed = JSON.parse(line);
156
- if (parsed.type === 'stream_event' && parsed.event)
157
- parsed = parsed.event;
158
- if (parsed.type === 'result') {
159
- sawResult = true;
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
- // Not JSON, let parser handle it
164
- }
165
- baseFeed(line);
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
- try {
171
- this.child.stdin.write(ndjsonMessage, (err) => {
172
- if (err) {
173
- this.log?.error('stdin write error', { sessionId: this.sessionId, err });
174
- this.markDead();
175
- turn.done = true;
176
- turn.resolve();
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
- else {
191
- await new Promise((r) => {
192
- turn.resolve = r;
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
- // Yield any remaining events
198
- while (turn.events.length > 0) {
199
- yield turn.events.shift();
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
- setTimeout(() => {
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
- return existing;
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
- // Kill stale session if present
282
- if (existing) {
355
+ else if (existing) {
356
+ // Kill stale (dead) session
283
357
  existing.kill();
284
358
  sessions.delete(key);
285
359
  }
@@ -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). */
@@ -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.24.4";
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.24.4" : "0.0.0-dev";
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.24.4";
1
+ export declare const VERSION = "0.25.1";
2
2
  //# sourceMappingURL=generated-version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.ts — do not edit manually
2
- export const VERSION = '0.24.4';
2
+ export const VERSION = '0.25.1';
3
3
  //# sourceMappingURL=generated-version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synergenius/flow-weaver",
3
- "version": "0.24.4",
3
+ "version": "0.25.1",
4
4
  "description": "Deterministic workflow compiler for AI agents. Compiles to standalone TypeScript, no runtime dependencies.",
5
5
  "private": false,
6
6
  "type": "module",