@synergenius/flow-weaver-pack-weaver 0.9.199 → 0.9.201

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.
Files changed (181) hide show
  1. package/dist/ai-chat-provider.js +5 -5
  2. package/dist/ai-chat-provider.js.map +1 -1
  3. package/dist/bot/acceptance-merge.d.ts +21 -0
  4. package/dist/bot/acceptance-merge.d.ts.map +1 -0
  5. package/dist/bot/acceptance-merge.js +46 -0
  6. package/dist/bot/acceptance-merge.js.map +1 -0
  7. package/dist/bot/ai-client.d.ts +14 -2
  8. package/dist/bot/ai-client.d.ts.map +1 -1
  9. package/dist/bot/ai-client.js +71 -24
  10. package/dist/bot/ai-client.js.map +1 -1
  11. package/dist/bot/assistant-tools.js +3 -3
  12. package/dist/bot/assistant-tools.js.map +1 -1
  13. package/dist/bot/audit-logger.d.ts.map +1 -1
  14. package/dist/bot/audit-logger.js +34 -14
  15. package/dist/bot/audit-logger.js.map +1 -1
  16. package/dist/bot/audit-trail.d.ts +67 -0
  17. package/dist/bot/audit-trail.d.ts.map +1 -0
  18. package/dist/bot/audit-trail.js +153 -0
  19. package/dist/bot/audit-trail.js.map +1 -0
  20. package/dist/bot/behavior-defaults.d.ts +1 -1
  21. package/dist/bot/behavior-defaults.d.ts.map +1 -1
  22. package/dist/bot/behavior-defaults.js +7 -3
  23. package/dist/bot/behavior-defaults.js.map +1 -1
  24. package/dist/bot/capability-registry.d.ts +9 -0
  25. package/dist/bot/capability-registry.d.ts.map +1 -1
  26. package/dist/bot/capability-registry.js +81 -27
  27. package/dist/bot/capability-registry.js.map +1 -1
  28. package/dist/bot/capability-types.d.ts +10 -0
  29. package/dist/bot/capability-types.d.ts.map +1 -1
  30. package/dist/bot/cli-provider.d.ts.map +1 -1
  31. package/dist/bot/cli-provider.js +8 -7
  32. package/dist/bot/cli-provider.js.map +1 -1
  33. package/dist/bot/preflight.d.ts +48 -0
  34. package/dist/bot/preflight.d.ts.map +1 -0
  35. package/dist/bot/preflight.js +247 -0
  36. package/dist/bot/preflight.js.map +1 -0
  37. package/dist/bot/provider-shim.d.ts +74 -0
  38. package/dist/bot/provider-shim.d.ts.map +1 -0
  39. package/dist/bot/provider-shim.js +176 -0
  40. package/dist/bot/provider-shim.js.map +1 -0
  41. package/dist/bot/runner.d.ts +2 -0
  42. package/dist/bot/runner.d.ts.map +1 -1
  43. package/dist/bot/runner.js +60 -17
  44. package/dist/bot/runner.js.map +1 -1
  45. package/dist/bot/step-executor.d.ts.map +1 -1
  46. package/dist/bot/step-executor.js +72 -115
  47. package/dist/bot/step-executor.js.map +1 -1
  48. package/dist/bot/swarm-controller.d.ts +2 -0
  49. package/dist/bot/swarm-controller.d.ts.map +1 -1
  50. package/dist/bot/swarm-controller.js +92 -20
  51. package/dist/bot/swarm-controller.js.map +1 -1
  52. package/dist/bot/task-create-handler.d.ts +37 -0
  53. package/dist/bot/task-create-handler.d.ts.map +1 -0
  54. package/dist/bot/task-create-handler.js +124 -0
  55. package/dist/bot/task-create-handler.js.map +1 -0
  56. package/dist/bot/task-store.d.ts +1 -0
  57. package/dist/bot/task-store.d.ts.map +1 -1
  58. package/dist/bot/task-store.js +67 -0
  59. package/dist/bot/task-store.js.map +1 -1
  60. package/dist/bot/types.d.ts +1 -1
  61. package/dist/bot/types.d.ts.map +1 -1
  62. package/dist/bot/weaver-tools.d.ts.map +1 -1
  63. package/dist/bot/weaver-tools.js +7 -39
  64. package/dist/bot/weaver-tools.js.map +1 -1
  65. package/dist/node-types/agent-execute.d.ts +25 -8
  66. package/dist/node-types/agent-execute.d.ts.map +1 -1
  67. package/dist/node-types/agent-execute.js +89 -23
  68. package/dist/node-types/agent-execute.js.map +1 -1
  69. package/dist/node-types/bot-report.d.ts.map +1 -1
  70. package/dist/node-types/bot-report.js +24 -3
  71. package/dist/node-types/bot-report.js.map +1 -1
  72. package/dist/node-types/plan-task.d.ts +8 -17
  73. package/dist/node-types/plan-task.d.ts.map +1 -1
  74. package/dist/node-types/plan-task.js +217 -256
  75. package/dist/node-types/plan-task.js.map +1 -1
  76. package/dist/node-types/review-result.js +8 -6
  77. package/dist/node-types/review-result.js.map +1 -1
  78. package/dist/palindrome.d.ts +9 -0
  79. package/dist/palindrome.d.ts.map +1 -0
  80. package/dist/palindrome.js +14 -0
  81. package/dist/palindrome.js.map +1 -0
  82. package/dist/ui/approval-card.js +91 -82
  83. package/dist/ui/bot-activity.js +73 -56
  84. package/dist/ui/bot-config.js +48 -31
  85. package/dist/ui/bot-dashboard.js +52 -36
  86. package/dist/ui/bot-panel.js +230 -228
  87. package/dist/ui/bot-slot-card.js +100 -90
  88. package/dist/ui/bot-status.js +37 -15
  89. package/dist/ui/budget-bar.js +57 -31
  90. package/dist/ui/capability-editor.js +447 -378
  91. package/dist/ui/chat-task-result.js +78 -71
  92. package/dist/ui/decision-log.js +68 -81
  93. package/dist/ui/genesis-block.js +86 -95
  94. package/dist/ui/instance-stream-view.js +722 -0
  95. package/dist/ui/profile-card.js +96 -221
  96. package/dist/ui/profile-editor.js +532 -575
  97. package/dist/ui/settings-section.js +41 -45
  98. package/dist/ui/swarm-controls.js +212 -135
  99. package/dist/ui/swarm-dashboard.js +3992 -2715
  100. package/dist/ui/task-detail-view.js +415 -521
  101. package/dist/ui/task-editor.js +339 -390
  102. package/dist/ui/task-pool-list.js +60 -55
  103. package/dist/workflows/src/palindrome.d.ts +11 -0
  104. package/dist/workflows/src/palindrome.d.ts.map +1 -0
  105. package/dist/workflows/src/palindrome.js +16 -0
  106. package/dist/workflows/src/palindrome.js.map +1 -0
  107. package/dist/workflows/tests/palindrome.test.d.ts +2 -0
  108. package/dist/workflows/tests/palindrome.test.d.ts.map +1 -0
  109. package/dist/workflows/tests/palindrome.test.js +41 -0
  110. package/dist/workflows/tests/palindrome.test.js.map +1 -0
  111. package/dist/workflows/weaver-bot-batch.js +1 -1
  112. package/dist/workflows/weaver-bot-batch.js.map +1 -1
  113. package/dist/workflows/weaver-bot.js +1 -1
  114. package/dist/workflows/weaver-bot.js.map +1 -1
  115. package/flowweaver.manifest.json +1 -1
  116. package/package.json +8 -2
  117. package/src/ai-chat-provider.ts +5 -5
  118. package/src/bot/acceptance-merge.ts +62 -0
  119. package/src/bot/ai-client.ts +77 -21
  120. package/src/bot/assistant-tools.ts +3 -3
  121. package/src/bot/audit-logger.ts +42 -14
  122. package/src/bot/audit-trail.ts +211 -0
  123. package/src/bot/behavior-defaults.ts +7 -2
  124. package/src/bot/capability-registry.ts +84 -28
  125. package/src/bot/capability-types.ts +11 -0
  126. package/src/bot/cli-provider.ts +8 -7
  127. package/src/bot/preflight.ts +285 -0
  128. package/src/bot/provider-shim.ts +218 -0
  129. package/src/bot/runner.ts +68 -20
  130. package/src/bot/step-executor.ts +69 -127
  131. package/src/bot/swarm-controller.ts +94 -20
  132. package/src/bot/task-create-handler.ts +164 -0
  133. package/src/bot/task-store.ts +83 -0
  134. package/src/bot/types.ts +4 -1
  135. package/src/bot/weaver-tools.ts +7 -45
  136. package/src/node-types/agent-execute.ts +102 -16
  137. package/src/node-types/bot-report.ts +24 -3
  138. package/src/node-types/plan-task.ts +238 -280
  139. package/src/node-types/review-result.ts +8 -6
  140. package/src/palindrome.ts +14 -0
  141. package/src/ui/approval-card.tsx +78 -62
  142. package/src/ui/bot-activity.tsx +12 -10
  143. package/src/ui/bot-config.tsx +12 -10
  144. package/src/ui/bot-dashboard.tsx +13 -11
  145. package/src/ui/bot-panel.tsx +189 -171
  146. package/src/ui/bot-slot-card.tsx +125 -70
  147. package/src/ui/bot-status.tsx +4 -4
  148. package/src/ui/budget-bar.tsx +86 -25
  149. package/src/ui/capability-editor.tsx +392 -257
  150. package/src/ui/chat-task-result.tsx +81 -78
  151. package/src/ui/decision-log.tsx +76 -73
  152. package/src/ui/genesis-block.tsx +91 -61
  153. package/src/ui/instance-stream-view.tsx +861 -0
  154. package/src/ui/profile-card.tsx +195 -168
  155. package/src/ui/profile-editor.tsx +453 -370
  156. package/src/ui/settings-section.tsx +46 -39
  157. package/src/ui/swarm-controls.tsx +252 -123
  158. package/src/ui/swarm-dashboard.tsx +999 -466
  159. package/src/ui/task-detail-view.tsx +485 -428
  160. package/src/ui/task-editor.tsx +329 -271
  161. package/src/ui/task-pool-list.tsx +68 -62
  162. package/src/workflows/src/palindrome.ts +16 -0
  163. package/src/workflows/tests/palindrome.test.ts +49 -0
  164. package/src/workflows/weaver-bot-batch.ts +1 -1
  165. package/src/workflows/weaver-bot.ts +1 -1
  166. package/dist/ui/bot-constants.d.ts +0 -14
  167. package/dist/ui/bot-constants.d.ts.map +0 -1
  168. package/dist/ui/bot-constants.js +0 -189
  169. package/dist/ui/bot-constants.js.map +0 -1
  170. package/dist/ui/steer-api.d.ts +0 -7
  171. package/dist/ui/steer-api.d.ts.map +0 -1
  172. package/dist/ui/steer-api.js +0 -11
  173. package/dist/ui/steer-api.js.map +0 -1
  174. package/dist/ui/trace-to-timeline.d.ts +0 -91
  175. package/dist/ui/trace-to-timeline.d.ts.map +0 -1
  176. package/dist/ui/trace-to-timeline.js +0 -116
  177. package/dist/ui/trace-to-timeline.js.map +0 -1
  178. package/dist/ui/use-stream-timeline.d.ts +0 -50
  179. package/dist/ui/use-stream-timeline.d.ts.map +0 -1
  180. package/dist/ui/use-stream-timeline.js +0 -245
  181. package/dist/ui/use-stream-timeline.js.map +0 -1
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Provider Shim — bridges callPlatformWithMessages to ANY provider.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Node-types (plan-task, review-result, etc.) call `callPlatformWithMessages`
7
+ * which checks `globalThis.__fw_llm_provider__`. This global is normally set
8
+ * by the platform via IPC (pack-worker-entry.mjs). When running WITHOUT the
9
+ * platform (CLI standalone, benchmarks, tests), it's missing and all AI calls
10
+ * throw "Platform AI provider not available".
11
+ *
12
+ * ## MCP Bridge
13
+ *
14
+ * The CLI session with --tools "" has zero built-in tools. Pack tools must be
15
+ * registered via MCP bridge so the model can call them as structured tool_use
16
+ * (not text). The shim creates a lightweight MCP bridge per worker using
17
+ * core's createMcpBridge with the pack's tool definitions and executor.
18
+ *
19
+ * ## Concurrency model
20
+ *
21
+ * The swarm runs multiple workers concurrently. Each worker calls runWorkflow
22
+ * which needs its own CLI session (its own `claude -p` process). We use
23
+ * AsyncLocalStorage to track the current worker ID so the single global shim
24
+ * can route each `.chat()` call to the correct per-worker CLI session.
25
+ *
26
+ * DO NOT overwrite `globalThis.__fw_llm_provider__` per worker — that causes
27
+ * race conditions where the last writer wins and all other workers share one
28
+ * session, producing zero-token ghost runs.
29
+ *
30
+ * ## DO NOT
31
+ *
32
+ * - Do NOT try to make individual nodes dispatch based on provider type.
33
+ * - Do NOT call callCliAsync directly — it's single-turn text-only.
34
+ * - Do NOT store per-worker shims on the global — use setActiveWorkerId().
35
+ */
36
+
37
+ import { AsyncLocalStorage } from 'node:async_hooks';
38
+ import type { BotProviderConfig } from './types.js';
39
+ import type { AiTool, ChatMessage } from './ai-client.js';
40
+ import type { McpBridge } from '@synergenius/flow-weaver/agent';
41
+
42
+ export interface ProviderShim {
43
+ chat: (
44
+ messages: ChatMessage[],
45
+ options?: { model?: string; maxTokens?: number; tools?: AiTool[] },
46
+ ) => Promise<{
47
+ content: string;
48
+ toolCalls?: Array<{ id: string; name: string; arguments: Record<string, unknown> }>;
49
+ thinking?: string;
50
+ usage?: Record<string, unknown>;
51
+ }>;
52
+ }
53
+
54
+ /**
55
+ * AsyncLocalStorage for the current worker ID.
56
+ * The runner sets this before each workflow execution so the routing shim
57
+ * dispatches .chat() to the correct per-worker CLI session.
58
+ */
59
+ const workerIdStorage = new AsyncLocalStorage<string>();
60
+
61
+ /** Run a function with the given worker ID in the async context. */
62
+ export function runWithWorkerId<T>(workerId: string, fn: () => T): T {
63
+ return workerIdStorage.run(workerId, fn);
64
+ }
65
+
66
+ /** Get the current worker ID from the async context. */
67
+ export function getActiveWorkerId(): string | undefined {
68
+ return workerIdStorage.getStore();
69
+ }
70
+
71
+ // Per-worker MCP bridges — created once, reused across chat() calls
72
+ const bridges = new Map<string, McpBridge>();
73
+
74
+ /**
75
+ * Get or create an MCP bridge for the given worker/project.
76
+ * The bridge registers pack tools (read_file, write_file, run_shell, etc.)
77
+ * as MCP tools so the CLI session model can call them via structured tool_use.
78
+ */
79
+ async function getOrCreateBridge(cwd: string, bridgeKey: string): Promise<McpBridge> {
80
+ const existing = bridges.get(bridgeKey);
81
+ if (existing) return existing;
82
+
83
+ const { createMcpBridge } = await import('@synergenius/flow-weaver/agent');
84
+ const { WEAVER_TOOLS, createWeaverExecutor } = await import('./weaver-tools.js');
85
+
86
+ const executor = createWeaverExecutor(cwd);
87
+ const bridge = await createMcpBridge(
88
+ WEAVER_TOOLS,
89
+ executor,
90
+ undefined, // no tool event callback — audit emits are handled by plan-task
91
+ );
92
+
93
+ bridges.set(bridgeKey, bridge);
94
+ return bridge;
95
+ }
96
+
97
+ /**
98
+ * Build a routing shim that dispatches .chat() calls to per-worker CLI sessions.
99
+ * One instance is installed on globalThis; it reads the current worker ID from
100
+ * AsyncLocalStorage to select the right session.
101
+ */
102
+ export function buildProviderShim(
103
+ providerConfig: BotProviderConfig,
104
+ projectDir?: string,
105
+ ): ProviderShim {
106
+ return {
107
+ async chat(messages, options) {
108
+ const { getOrCreateCliSession } = await import(
109
+ '@synergenius/flow-weaver/agent'
110
+ );
111
+
112
+ const cwd = projectDir ?? process.cwd();
113
+ const model = options?.model ?? providerConfig.model ?? 'claude-sonnet-4-6';
114
+ const workerId = getActiveWorkerId();
115
+ const sessionKey = workerId ? `shim:${cwd}:${workerId}` : `shim:${cwd}`;
116
+ const bridgeKey = workerId ? `bridge:${cwd}:${workerId}` : `bridge:${cwd}`;
117
+
118
+ // Create MCP bridge so the CLI session has pack tools registered
119
+ const bridge = await getOrCreateBridge(cwd, bridgeKey);
120
+
121
+ const { getCliSessionConfig } = await import('@synergenius/flow-weaver/agent');
122
+ const session = getOrCreateCliSession(sessionKey, getCliSessionConfig({
123
+ binPath: (providerConfig.options?.binPath as string | undefined),
124
+ cwd,
125
+ model,
126
+ mcpConfigPath: bridge.configPath,
127
+ appendSystemPrompt: 'You are Weaver, an AI workflow bot. Use the provided tools to complete tasks. Never attempt to use built-in tools.',
128
+ }));
129
+
130
+ if (!session.ready) await session.spawn();
131
+
132
+ // Build prompt from messages
133
+ const prompt = messages
134
+ .map((m: ChatMessage) => {
135
+ if (m.role === 'system') return '';
136
+ if (m.role === 'user') return typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
137
+ if (m.role === 'tool') return `Tool result (${(m as unknown as Record<string, unknown>).tool_use_id ?? 'unknown'}): ${m.content}`;
138
+ return '';
139
+ })
140
+ .filter(Boolean)
141
+ .join('\n');
142
+
143
+ const systemMsg = messages.find(m => m.role === 'system');
144
+ const systemPrompt = typeof systemMsg?.content === 'string' ? systemMsg.content : undefined;
145
+
146
+ // Collect stream events into the platform response shape
147
+ let content = '';
148
+ let thinking = '';
149
+ const toolCalls: Array<{ id: string; name: string; arguments: Record<string, unknown> }> = [];
150
+ const pendingTools = new Map<string, { id: string; name: string }>();
151
+ let usage: { input_tokens: number; output_tokens: number } | undefined;
152
+
153
+ for await (const event of session.send(prompt, systemPrompt)) {
154
+ switch (event.type) {
155
+ case 'text_delta':
156
+ content += event.text;
157
+ break;
158
+ case 'thinking_delta':
159
+ thinking += event.text;
160
+ break;
161
+ case 'tool_use_start':
162
+ pendingTools.set(event.id, { id: event.id, name: event.name });
163
+ break;
164
+ case 'tool_use_end':
165
+ toolCalls.push({
166
+ id: event.id,
167
+ name: pendingTools.get(event.id)?.name ?? 'unknown',
168
+ arguments: event.arguments,
169
+ });
170
+ pendingTools.delete(event.id);
171
+ break;
172
+ case 'usage':
173
+ usage = { input_tokens: event.promptTokens, output_tokens: event.completionTokens };
174
+ break;
175
+ }
176
+ }
177
+
178
+ return {
179
+ content,
180
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
181
+ thinking: thinking || undefined,
182
+ usage,
183
+ };
184
+ },
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Install the provider shim as `globalThis.__fw_llm_provider__`.
190
+ * No-op if the platform already set it (avoids overriding IPC proxy).
191
+ * Called ONCE — the shim routes per-worker via AsyncLocalStorage.
192
+ */
193
+ export async function installProviderShim(
194
+ providerConfig: BotProviderConfig,
195
+ projectDir?: string,
196
+ ): Promise<void> {
197
+ if ((globalThis as Record<string, unknown>).__fw_llm_provider__) return;
198
+
199
+ // Kill any cached CLI sessions from previous runs that may have different
200
+ // options. The cache now validates options via fingerprint, but a clean
201
+ // start is still safest.
202
+ try {
203
+ const { killAllCliSessions } = await import('@synergenius/flow-weaver/agent');
204
+ killAllCliSessions();
205
+ } catch { /* non-fatal */ }
206
+
207
+ (globalThis as Record<string, unknown>).__fw_llm_provider__ = buildProviderShim(providerConfig, projectDir);
208
+ }
209
+
210
+ /**
211
+ * Clean up all MCP bridges (for shutdown).
212
+ */
213
+ export function cleanupBridges(): void {
214
+ for (const [, bridge] of bridges) {
215
+ bridge.cleanup();
216
+ }
217
+ bridges.clear();
218
+ }
package/src/bot/runner.ts CHANGED
@@ -36,6 +36,8 @@ import { RunStore } from './run-store.js';
36
36
  import { CostTracker } from './cost-tracker.js';
37
37
  import { CostStore } from './cost-store.js';
38
38
  import { runRegistry } from './run-registry.js';
39
+ import { buildProviderShim, installProviderShim, runWithWorkerId, getActiveWorkerId } from './provider-shim.js';
40
+ export { buildProviderShim };
39
41
 
40
42
  function resolveApproval(
41
43
  approval: BotConfig['approval'],
@@ -246,6 +248,18 @@ export async function runWorkflow(
246
248
  /** Bot slot that executes this run. */
247
249
  botId?: string;
248
250
  },
251
+ ): Promise<WorkflowResult> {
252
+ // Wrap in AsyncLocalStorage context so the provider shim routes
253
+ // .chat() calls to the correct per-worker CLI session.
254
+ if (options?.botId) {
255
+ return runWithWorkerId(options.botId, () => runWorkflowInner(filePath, options));
256
+ }
257
+ return runWorkflowInner(filePath, options);
258
+ }
259
+
260
+ async function runWorkflowInner(
261
+ filePath: string,
262
+ options?: Parameters<typeof runWorkflow>[1],
249
263
  ): Promise<WorkflowResult> {
250
264
  const absPath = path.resolve(filePath);
251
265
  const verbose = options?.verbose ?? false;
@@ -293,6 +307,15 @@ export async function runWorkflow(
293
307
 
294
308
  const provider = await createProvider(providerConfig);
295
309
 
310
+ // Install provider shim so callPlatformWithMessages works for ALL providers.
311
+ // See src/bot/provider-shim.ts for full documentation on why this is needed.
312
+ // Use params.projectDir (workspace) when available (swarm runs), else workflow dir.
313
+ // Install ONE routing shim on the global. The shim uses AsyncLocalStorage
314
+ // to read the current worker ID and dispatch to per-worker CLI sessions.
315
+ // See src/bot/provider-shim.ts for full documentation.
316
+ const shimDir = (options?.params?.projectDir as string) ?? path.dirname(absPath);
317
+ installProviderShim(providerConfig, shimDir);
318
+
296
319
  const costTracker = new CostTracker(providerConfig.model ?? 'unknown', providerConfig.name);
297
320
  // Track cost per AI call, emit to EventLog for live streaming (throttled to 1/sec)
298
321
  let lastCostEmit = 0;
@@ -309,25 +332,47 @@ export async function runWorkflow(
309
332
  });
310
333
  }
311
334
  };
312
- // Set global usage callback so callPlatform (used by node types) can report
313
- // token usage to the CostTracker. This bridges the gap between direct callAI
314
- // calls in node types and the runner's cost tracking infrastructure.
315
- (globalThis as Record<string, unknown>).__fw_ai_usage_callback__ = (
316
- model: string,
317
- usage: { inputTokens: number; outputTokens: number },
318
- ) => {
319
- costTracker.track('platform-call', model, usage);
320
- const now = Date.now();
321
- if (options?.eventLog && now - lastCostEmit >= 1000) {
322
- lastCostEmit = now;
323
- const summary = costTracker.getRunSummary();
324
- options.eventLog.emit({
325
- type: 'cost-update',
326
- timestamp: now,
327
- data: { totalCost: summary.totalCost, totalInputTokens: summary.totalInputTokens, totalOutputTokens: summary.totalOutputTokens },
328
- });
329
- }
330
- };
335
+ // Set global usage callback. Uses AsyncLocalStorage (via getActiveWorkerId)
336
+ // to route usage to the correct worker's CostTracker in concurrent swarm mode.
337
+ // The callback is set once and routes per-worker NOT overwritten per worker.
338
+ if (!(globalThis as Record<string, unknown>).__fw_ai_usage_callback__) {
339
+ // Map of workerId → { costTracker, eventLog, lastCostEmit }
340
+ const workerTrackers = new Map<string, { costTracker: CostTracker; eventLog?: typeof options extends { eventLog?: infer E } ? E : never; lastCostEmit: number }>();
341
+ (globalThis as Record<string, unknown>).__fw_ai_usage_callback__ = (
342
+ model: string,
343
+ usage: { inputTokens: number; outputTokens: number },
344
+ ) => {
345
+ const workerId = getActiveWorkerId() ?? '__default__';
346
+ const tracker = workerTrackers.get(workerId);
347
+ if (!tracker) return;
348
+ tracker.costTracker.track('platform-call', model, usage);
349
+ const now = Date.now();
350
+ if (tracker.eventLog && now - tracker.lastCostEmit >= 1000) {
351
+ tracker.lastCostEmit = now;
352
+ const summary = tracker.costTracker.getRunSummary();
353
+ (tracker.eventLog as any).emit({
354
+ type: 'cost-update',
355
+ timestamp: now,
356
+ data: { totalCost: summary.totalCost, totalInputTokens: summary.totalInputTokens, totalOutputTokens: summary.totalOutputTokens },
357
+ });
358
+ }
359
+ };
360
+ (globalThis as Record<string, unknown>).__fw_usage_trackers__ = workerTrackers;
361
+ }
362
+ // Register this worker's tracker
363
+ const workerId = getActiveWorkerId() ?? '__default__';
364
+ const workerTrackers = (globalThis as Record<string, unknown>).__fw_usage_trackers__ as Map<string, { costTracker: CostTracker; eventLog?: unknown; lastCostEmit: number }>;
365
+ workerTrackers.set(workerId, { costTracker, eventLog: options?.eventLog, lastCostEmit: 0 });
366
+
367
+ // Set global stream event callback so callCliAsync (used by node types)
368
+ // emits conversation events (thinking_delta, text_delta, tool_use_start, etc.)
369
+ // to the EventLog for the InstanceStreamView to consume.
370
+ if (options?.eventLog) {
371
+ const { setStreamEventCallback } = await import('./ai-client.js');
372
+ setStreamEventCallback((event) => {
373
+ options.eventLog!.emit(event);
374
+ });
375
+ }
331
376
 
332
377
  const channels = notifyConfigs.map(
333
378
  (c) => new WebhookNotificationChannel(c, options?.onNotificationError),
@@ -632,7 +677,10 @@ export async function runWorkflow(
632
677
  registryPromiseResolve?.();
633
678
  try { options?.eventLog?.done(); } catch { /* already done or no log */ }
634
679
  teardownAuditLogger();
635
- delete (globalThis as Record<string, unknown>).__fw_ai_usage_callback__;
680
+ // Unregister this worker's tracker — don't delete the globals (other workers may still be active)
681
+ const cleanupWorkerId = getActiveWorkerId() ?? '__default__';
682
+ const cleanupTrackers = (globalThis as Record<string, unknown>).__fw_usage_trackers__ as Map<string, unknown> | undefined;
683
+ cleanupTrackers?.delete(cleanupWorkerId);
636
684
  }
637
685
  }
638
686
 
@@ -7,7 +7,9 @@ import { promisify } from 'node:util';
7
7
  const execAsync = promisify(exec);
8
8
  import { runCommand } from '@synergenius/flow-weaver';
9
9
  import { BLOCKED_SHELL_PATTERNS } from './safety.js';
10
- import { OP_WRITE_FILE, OP_READ_FILE, OP_PATCH_FILE, OP_LIST_FILES, OP_RUN_SHELL, OP_TASK_CREATE, OP_RESPOND, OP_NO_OP, OP_NOOP, OP_DONE, OP_COMPLETE, OP_CREATE_WORKFLOW, OP_MODIFY_SOURCE, OP_IMPLEMENT_NODE, OP_REMEMBER, OP_RECALL, normalizeOperation } from './operations.js';
10
+ import { OP_WRITE_FILE, OP_READ_FILE, OP_PATCH_FILE, OP_LIST_FILES, OP_RUN_SHELL, OP_TASK_CREATE, OP_RESPOND, OP_NO_OP, OP_NOOP, OP_DONE, OP_COMPLETE, OP_CREATE_WORKFLOW, OP_MODIFY_SOURCE, OP_IMPLEMENT_NODE, OP_REMEMBER, OP_RECALL, OP_TSC_CHECK, OP_RUN_TESTS, normalizeOperation } from './operations.js';
11
+ import { isBlockedUrl } from './safety.js';
12
+ import { execFileSync } from 'node:child_process';
11
13
  import { TaskStore } from './task-store.js';
12
14
  import type { CreateTaskInput } from './task-types.js';
13
15
 
@@ -55,8 +57,17 @@ export function resetPlanFileCounter(): void {
55
57
  // ---------------------------------------------------------------------------
56
58
 
57
59
  function assertSafePath(filePath: string, projectDir: string): void {
58
- const resolved = path.resolve(projectDir, filePath);
60
+ // Normalize absolute paths that fall inside projectDir to relative.
61
+ // Models sometimes generate absolute paths from context they see in prompts.
59
62
  const resolvedBase = path.resolve(projectDir);
63
+ let normalizedPath = filePath;
64
+ if (path.isAbsolute(filePath)) {
65
+ const absPath = path.resolve(filePath);
66
+ if (absPath.startsWith(resolvedBase + path.sep) || absPath === resolvedBase) {
67
+ normalizedPath = path.relative(resolvedBase, absPath) || '.';
68
+ }
69
+ }
70
+ const resolved = path.resolve(projectDir, normalizedPath);
60
71
  if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
61
72
  throw new Error(
62
73
  `Path traversal blocked: "${filePath}" resolves outside project directory.`,
@@ -418,135 +429,25 @@ export async function executeStep(
418
429
  // Task management — create subtasks for recursive swarm execution
419
430
  // -----------------------------------------------------------------
420
431
  case OP_TASK_CREATE: {
421
- const title = (args.title as string) ?? '';
422
- if (!title) return { blocked: true, blockReason: 'task_create requires a "title" argument. Expected: { title: "...", description: "...", parentId: "@self", assignedProfile: "developer|reviewer|ops|orchestrator" }' };
423
-
424
- const store = new TaskStore(projectDir);
425
-
426
- // Validate assignedProfile: must be a valid slug (lowercase alphanumeric + hyphens)
427
- const rawProfile = args.assignedProfile as string | undefined;
428
- let assignedProfile: string | undefined;
429
- if (rawProfile !== undefined && rawProfile !== null) {
430
- if (typeof rawProfile !== 'string' || !rawProfile.trim()) {
431
- return { blocked: true, blockReason: 'task_create: assignedProfile must be a non-empty string.' };
432
- }
433
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(rawProfile)) {
434
- return { blocked: true, blockReason: `task_create: assignedProfile "${rawProfile}" is not a valid slug (use lowercase alphanumeric with hyphens).` };
435
- }
436
- assignedProfile = rawProfile;
437
- }
438
-
439
- // Validate complexity against enum, default to 'simple' if invalid
440
- const VALID_COMPLEXITIES = new Set(['trivial', 'simple', 'moderate', 'complex']);
441
- const rawComplexity = args.complexity as string | undefined;
442
- const complexity: 'trivial' | 'simple' | 'moderate' | 'complex' =
443
- rawComplexity && VALID_COMPLEXITIES.has(rawComplexity)
444
- ? (rawComplexity as 'trivial' | 'simple' | 'moderate' | 'complex')
445
- : 'simple';
446
-
447
- // Validate priority: coerce to number, default to 0 if NaN
448
- const rawPriority = Number(args.priority);
449
- const priority = Number.isFinite(rawPriority) ? rawPriority : 0;
450
-
451
- // Validate parentId — must reference an existing task
452
- const rawParentId = args.parentId as string | undefined;
453
- let parentId: string | undefined;
454
- if (rawParentId) {
455
- // Resolve through symbolicIdMap first (in case it's a title or symbolic ref)
456
- const resolved = symbolicIdMap?.[rawParentId] ?? rawParentId;
457
- const parentTask = await store.get(resolved);
458
- if (!parentTask) {
459
- return { blocked: true, blockReason: `task_create: parentId "${rawParentId}" does not match any existing task. Use the exact Task ID from the prompt.` };
460
- }
461
- parentId = resolved;
432
+ // Use shared handler for validation, @self resolution, dedup, acceptance parsing
433
+ const { handleTaskCreate } = await import('./task-create-handler.js');
434
+ const createResult = await handleTaskCreate(args as any, projectDir, symbolicIdMap);
435
+ if (createResult.blocked) {
436
+ return { blocked: true, blockReason: createResult.blockReason };
462
437
  }
463
-
464
- // Idempotent: if a task with the same title and parentId already exists, return it
465
- // instead of creating a duplicate. This prevents retry loops from producing duplicates.
466
- if (parentId) {
467
- const existing = (await store.list()).find(
468
- t => t.parentId === parentId && t.title.toLowerCase() === title.toLowerCase(),
469
- );
470
- if (existing) {
471
- if (symbolicIdMap) {
472
- const symbolicKey = (args.symbolicId as string) ?? (args.id as string);
473
- if (symbolicKey) symbolicIdMap[symbolicKey] = existing.id;
474
- symbolicIdMap[title] = existing.id;
475
- }
476
- return { output: `Task "${title}" already exists (${existing.id}), skipped duplicate.` };
477
- }
478
- }
479
-
480
- // Resolve symbolic IDs in dependsOn through the map
481
- const rawDeps = (args.dependsOn as string[]) ?? [];
482
- const resolvedDeps = symbolicIdMap
483
- ? rawDeps.map(dep => symbolicIdMap[dep] ?? dep)
484
- : rawDeps;
485
-
486
- // Parse acceptance criteria if provided
487
- const rawAcceptance = args.acceptance as Record<string, unknown> | undefined;
488
- const acceptance = rawAcceptance?.checks ? {
489
- checks: (rawAcceptance.checks as Array<{ name: string; command: string }>).map(c => ({
490
- name: String(c.name ?? ''),
491
- command: String(c.command ?? ''),
492
- })).filter(c => c.name && c.command),
493
- } : undefined;
494
-
495
- const input: CreateTaskInput = {
496
- title,
497
- description: (args.description as string) ?? title,
498
- complexity,
499
- priority,
500
- parentId,
501
- dependsOn: resolvedDeps,
502
- assignedProfile,
503
- acceptance,
504
- };
505
-
506
- // Support inline subtasks
507
- const subtasksArg = args.subtasks as Array<Record<string, unknown>> | undefined;
508
- if (subtasksArg?.length) {
509
- // Validate subtask titles are non-empty
510
- for (let i = 0; i < subtasksArg.length; i++) {
511
- const subTitle = (subtasksArg[i].title as string) ?? '';
512
- if (!subTitle.trim()) {
513
- return { blocked: true, blockReason: `task_create: subtask[${i}] has an empty title. All subtasks must have non-empty titles.` };
514
- }
515
- }
516
-
517
- input.subtasks = subtasksArg.map((s) => {
518
- const subComplexity = s.complexity as string | undefined;
519
- const subPriority = Number(s.priority);
520
- return {
521
- title: (s.title as string) ?? '',
522
- description: (s.description as string) ?? '',
523
- complexity: subComplexity && VALID_COMPLEXITIES.has(subComplexity)
524
- ? (subComplexity as 'trivial' | 'simple' | 'moderate' | 'complex')
525
- : 'simple',
526
- priority: Number.isFinite(subPriority) ? subPriority : 0,
527
- assignedProfile: (s.assignedProfile as string) ?? assignedProfile ?? 'developer',
528
- dependsOn: symbolicIdMap
529
- ? ((s.dependsOn as string[]) ?? []).map(dep => symbolicIdMap[dep] ?? dep)
530
- : (s.dependsOn as string[]) ?? [],
531
- acceptance: s.acceptance ? { checks: ((s.acceptance as Record<string, unknown>).checks as Array<{name:string,command:string}>).map(c => ({name:String(c.name),command:String(c.command)})).filter(c=>c.name&&c.command) } : undefined,
532
- };
533
- });
438
+ if (!createResult.taskId) {
439
+ // Dedup task already existed
440
+ return { output: createResult.output };
534
441
  }
535
442
 
536
- const task = await store.create(input);
537
-
538
- // Register symbolic ID → real ID mapping.
539
- // Also map the title so the AI can reference tasks by title in dependsOn.
540
- if (symbolicIdMap) {
541
- const symbolicKey = (args.symbolicId as string) ?? (args.id as string);
542
- if (symbolicKey) symbolicIdMap[symbolicKey] = task.id;
543
- // Title-based mapping — the AI often uses task titles as dependency references
544
- symbolicIdMap[title] = task.id;
545
- }
443
+ const store = new TaskStore(projectDir);
444
+ const title = String(args.title ?? '');
546
445
 
547
- const subtasks = task.isParent ? await store.getSubtasks(task.id) : [];
548
- const ids = [task.id, ...subtasks.map(s => s.id)];
549
- return { output: `Created task "${title}" (${task.id})${subtasks.length ? ` with ${subtasks.length} subtasks` : ''}. IDs: ${ids.join(', ')}` };
446
+ // Shared handler already created the task return result
447
+ const task = await store.get(createResult.taskId!);
448
+ const subtasks = task?.isParent ? await store.getSubtasks(createResult.taskId!) : [];
449
+ const ids = [createResult.taskId!, ...subtasks.map(s => s.id)];
450
+ return { output: `Created task "${title}" (${createResult.taskId})${subtasks.length ? ` with ${subtasks.length} subtasks` : ''}. IDs: ${ids.join(', ')}` };
550
451
  }
551
452
 
552
453
  // -----------------------------------------------------------------
@@ -591,6 +492,47 @@ export async function executeStep(
591
492
  return { output: response || undefined };
592
493
  }
593
494
 
495
+ // -----------------------------------------------------------------
496
+ // TypeScript check + test runner (same as weaver-tools.ts handlers)
497
+ // -----------------------------------------------------------------
498
+ case OP_TSC_CHECK: {
499
+ try {
500
+ const output = execFileSync('npx', ['tsc', '--noEmit', '--pretty'], {
501
+ cwd: projectDir, encoding: 'utf-8', timeout: 60_000, stdio: ['pipe', 'pipe', 'pipe'],
502
+ });
503
+ return { output: output.trim() || 'No TypeScript errors.' };
504
+ } catch (err: any) {
505
+ return { output: (err.stdout ?? err.message ?? '').slice(0, 5000) };
506
+ }
507
+ }
508
+
509
+ case OP_RUN_TESTS: {
510
+ const pattern = args.pattern ? String(args.pattern) : '';
511
+ try {
512
+ const testArgs = ['vitest', 'run', '--reporter', 'verbose'];
513
+ if (pattern) testArgs.push(pattern);
514
+ const output = execFileSync('npx', testArgs, {
515
+ cwd: projectDir, encoding: 'utf-8', timeout: 120_000, stdio: ['pipe', 'pipe', 'pipe'],
516
+ });
517
+ return { output: output.slice(0, 5000) };
518
+ } catch (err: any) {
519
+ return { output: (err.stdout ?? err.stderr ?? err.message ?? '').slice(0, 5000) };
520
+ }
521
+ }
522
+
523
+ case 'web_fetch': {
524
+ const url = String(args.url ?? '');
525
+ if (!url) return { output: 'Error: url is required', blocked: true, blockReason: 'Missing url argument' };
526
+ if (isBlockedUrl(url)) return { output: 'Blocked: cannot fetch internal/localhost URLs.', blocked: true, blockReason: 'Blocked URL' };
527
+ try {
528
+ const resp = await fetch(url, { method: (args.method as string) ?? 'GET', signal: AbortSignal.timeout(15_000) });
529
+ const text = await resp.text();
530
+ return { output: text.slice(0, 10_000) };
531
+ } catch (err: any) {
532
+ return { output: `Fetch error: ${err.message}` };
533
+ }
534
+ }
535
+
594
536
  default: {
595
537
  const result = await runCommand(operation, { ...args, cwd: projectDir });
596
538
  return {