@synergenius/flow-weaver 0.24.3 → 0.25.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.
@@ -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,104 @@ 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
+ const baseFeed = this.parser.feed.bind(this.parser);
188
+ this.parser.feed = (line) => {
189
+ // Check if this line is a result event before parsing
190
+ try {
191
+ let parsed = JSON.parse(line);
192
+ if (parsed.type === 'stream_event' && parsed.event)
193
+ parsed = parsed.event;
194
+ if (parsed.type === 'result') {
195
+ sawResult = true;
196
+ }
197
+ }
198
+ catch {
199
+ // Not JSON, let parser handle it
200
+ }
201
+ baseFeed(line);
202
+ if (sawResult) {
203
+ this.completeTurn();
204
+ }
205
+ };
206
+ try {
207
+ this.child.stdin.write(ndjsonMessage, (err) => {
208
+ if (err) {
209
+ this.log?.error('stdin write error', { sessionId: this.sessionId, err });
210
+ this.markDead();
211
+ turn.done = true;
212
+ turn.resolve();
213
+ }
214
+ });
161
215
  }
162
- catch {
163
- // Not JSON, let parser handle it
164
- }
165
- baseFeed(line);
166
- if (sawResult) {
167
- this.completeTurn();
216
+ catch (err) {
217
+ this.log?.error('stdin write exception', { sessionId: this.sessionId, err });
218
+ this.markDead();
219
+ throw new Error('CLI session stdin write failed');
168
220
  }
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();
221
+ // Yield events as they arrive
222
+ while (!turn.done || turn.events.length > 0) {
223
+ if (turn.events.length > 0) {
224
+ yield turn.events.shift();
225
+ }
226
+ else {
227
+ await new Promise((r) => {
228
+ turn.resolve = r;
229
+ setTimeout(r, 50);
230
+ });
177
231
  }
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
232
  }
190
- else {
191
- await new Promise((r) => {
192
- turn.resolve = r;
193
- setTimeout(r, 50);
194
- });
233
+ // Yield any remaining events
234
+ while (turn.events.length > 0) {
235
+ yield turn.events.shift();
195
236
  }
196
237
  }
197
- // Yield any remaining events
198
- while (turn.events.length > 0) {
199
- yield turn.events.shift();
238
+ finally {
239
+ this.activeTurn = null;
240
+ this.resetIdleTimer();
200
241
  }
201
- this.activeTurn = null;
202
- this.resetIdleTimer();
203
242
  }
204
243
  /**
205
244
  * Kill the CLI process.
@@ -209,17 +248,34 @@ export class CliSession {
209
248
  if (this.child && this.alive) {
210
249
  this.log?.info('Killing CLI session', { sessionId: this.sessionId });
211
250
  this.child.kill('SIGTERM');
212
- setTimeout(() => {
251
+ // Phase 1.5: unref the SIGKILL fallback timer so it doesn't keep Node alive
252
+ const sigkillTimer = setTimeout(() => {
213
253
  if (this.child && !this.child.killed) {
214
254
  this.child.kill('SIGKILL');
215
255
  }
216
256
  }, 2000);
257
+ sigkillTimer.unref();
217
258
  }
218
259
  this.markDead();
219
260
  }
220
261
  // ---------------------------------------------------------------------------
221
262
  // Private
222
263
  // ---------------------------------------------------------------------------
264
+ /**
265
+ * Compute a fingerprint of CLI-relevant options for cache comparison.
266
+ * Field order is significant for JSON.stringify comparison.
267
+ * Add new CLI-relevant fields here when they're added to CliSessionOptions.
268
+ */
269
+ static fingerprint(options) {
270
+ return JSON.stringify({
271
+ model: options.model,
272
+ mcpConfigPath: options.mcpConfigPath,
273
+ disallowedTools: options.disallowedTools,
274
+ tools: options.tools,
275
+ systemPrompt: options.systemPrompt,
276
+ appendSystemPrompt: options.appendSystemPrompt,
277
+ });
278
+ }
223
279
  pushEvent(event) {
224
280
  if (!this.activeTurn)
225
281
  return;
@@ -233,6 +289,8 @@ export class CliSession {
233
289
  this.activeTurn.resolve();
234
290
  }
235
291
  markDead() {
292
+ // Phase 1.5: Clear idle timer to prevent dangling timer on process crash
293
+ this.clearIdleTimer();
236
294
  this.alive = false;
237
295
  this.cleanupFn?.();
238
296
  this.cleanupFn = null;
@@ -258,6 +316,8 @@ export class CliSession {
258
316
  this.log?.info('CLI session idle timeout, killing', { sessionId: this.sessionId });
259
317
  this.kill();
260
318
  }, this.idleTimeout);
319
+ // Phase 1.5: unref so idle timer doesn't keep Node alive on shutdown
320
+ this.idleTimer.unref();
261
321
  }
262
322
  clearIdleTimer() {
263
323
  if (this.idleTimer) {
@@ -272,14 +332,22 @@ export class CliSession {
272
332
  const sessions = new Map();
273
333
  /**
274
334
  * Get an existing session or create a new one.
335
+ * Phase 1.4: Validates that cached sessions have matching CLI-relevant options.
336
+ * If options changed on the same key, the old session is killed and recreated.
275
337
  */
276
338
  export function getOrCreateCliSession(key, options) {
277
339
  const existing = sessions.get(key);
278
340
  if (existing && existing.ready) {
279
- return existing;
341
+ // Phase 1.4: Check if CLI-relevant options match
342
+ if (existing.matchesOptions(options)) {
343
+ return existing;
344
+ }
345
+ // Options changed — kill old session and recreate
346
+ existing.kill();
347
+ sessions.delete(key);
280
348
  }
281
- // Kill stale session if present
282
- if (existing) {
349
+ else if (existing) {
350
+ // Kill stale (dead) session
283
351
  existing.kill();
284
352
  sessions.delete(key);
285
353
  }
@@ -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.3";
9889
+ VERSION = "0.25.0";
9890
9890
  }
9891
9891
  });
9892
9892
 
@@ -36231,9 +36231,22 @@ var init_parser2 = __esm({
36231
36231
  const cacheKey = `npm:${imp.importSource}`;
36232
36232
  if (this.importCache.has(cacheKey)) {
36233
36233
  const cached2 = this.importCache.get(cacheKey);
36234
- const found = cached2.nodeTypes.find((nt) => nt.functionName === imp.functionName);
36235
- if (found) {
36236
- return { ...found, name: imp.name, importSource: imp.importSource };
36234
+ const resolvedDts = resolvePackageTypesPath(imp.importSource, currentDir);
36235
+ let cacheValid = false;
36236
+ if (resolvedDts) {
36237
+ try {
36238
+ const dtsStats = fs5.statSync(resolvedDts);
36239
+ cacheValid = cached2.mtime === dtsStats.mtimeMs;
36240
+ } catch {
36241
+ }
36242
+ } else {
36243
+ cacheValid = true;
36244
+ }
36245
+ if (cacheValid) {
36246
+ const found = cached2.nodeTypes.find((nt) => nt.functionName === imp.functionName);
36247
+ if (found) {
36248
+ return { ...found, name: imp.name, importSource: imp.importSource };
36249
+ }
36237
36250
  }
36238
36251
  }
36239
36252
  const dtsPath = resolvePackageTypesPath(imp.importSource, currentDir);
@@ -95960,7 +95973,7 @@ function parseIntStrict(value) {
95960
95973
  // src/cli/index.ts
95961
95974
  init_logger();
95962
95975
  init_error_utils();
95963
- var version2 = true ? "0.24.3" : "0.0.0-dev";
95976
+ var version2 = true ? "0.25.0" : "0.0.0-dev";
95964
95977
  var program2 = new Command();
95965
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", () => {
95966
95979
  logger.banner(version2);
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.24.3";
1
+ export declare const VERSION = "0.25.0";
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.3';
2
+ export const VERSION = '0.25.0';
3
3
  //# sourceMappingURL=generated-version.js.map
package/dist/parser.js CHANGED
@@ -610,14 +610,28 @@ export class AnnotationParser {
610
610
  * Resolve an npm package @fwImport to a node type by reading .d.ts declarations.
611
611
  */
612
612
  resolveNpmImportAnnotation(imp, currentDir) {
613
- // Check cache
613
+ // Check cache (with mtime validation — same pattern as resolveNpmImports)
614
614
  const cacheKey = `npm:${imp.importSource}`;
615
615
  if (this.importCache.has(cacheKey)) {
616
616
  const cached = this.importCache.get(cacheKey);
617
- const found = cached.nodeTypes.find((nt) => nt.functionName === imp.functionName);
618
- if (found) {
619
- // Return a copy with the correct name from @fwImport
620
- return { ...found, name: imp.name, importSource: imp.importSource };
617
+ const resolvedDts = resolvePackageTypesPath(imp.importSource, currentDir);
618
+ let cacheValid = false;
619
+ if (resolvedDts) {
620
+ try {
621
+ const dtsStats = fs.statSync(resolvedDts);
622
+ cacheValid = cached.mtime === dtsStats.mtimeMs;
623
+ }
624
+ catch { /* file gone — re-parse */ }
625
+ }
626
+ else {
627
+ // No .d.ts found — trust cache (package may have been removed)
628
+ cacheValid = true;
629
+ }
630
+ if (cacheValid) {
631
+ const found = cached.nodeTypes.find((nt) => nt.functionName === imp.functionName);
632
+ if (found) {
633
+ return { ...found, name: imp.name, importSource: imp.importSource };
634
+ }
621
635
  }
622
636
  }
623
637
  // Resolve .d.ts path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synergenius/flow-weaver",
3
- "version": "0.24.3",
3
+ "version": "0.25.0",
4
4
  "description": "Deterministic workflow compiler for AI agents. Compiles to standalone TypeScript, no runtime dependencies.",
5
5
  "private": false,
6
6
  "type": "module",