@yeaft/webchat-agent 1.0.289 → 1.0.290

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.
@@ -1 +1 @@
1
- {"version":"1.0.289"}
1
+ {"version":"1.0.290"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.289",
3
+ "version": "1.0.290",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,213 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { Engine } from './engine.js';
4
+ import { createRouter } from './routing/router.js';
5
+ import { createCoordinator } from './sessions/coordinator.js';
6
+ import { sessionsRoot } from './sessions/session-crud.js';
7
+ import { openSession, loadSessionMeta } from './sessions/session-store.js';
8
+ import { loadSessionConfig, resolveSessionConfig } from './sessions/session-config.js';
9
+ import { readVp } from './vp/vp-crud.js';
10
+ import { COLLAB_TOOL_POLICY } from './tools/registry.js';
11
+
12
+ function buildVpPersona(vpId, loaded) {
13
+ const vp = readVp(vpId, { libDir: join(loaded.yeaftDir, 'virtual-persons') });
14
+ if (!vp) return null;
15
+ return {
16
+ vpId,
17
+ displayName: vp.displayName || vpId,
18
+ displayNameZh: vp.displayNameZh || '',
19
+ role: vp.role || '',
20
+ roleZh: vp.roleZh || '',
21
+ persona: vp.persona || '',
22
+ planInstruction: vp.planInstruction || '',
23
+ };
24
+ }
25
+
26
+ function createEngine(loaded, sessionId, vpId) {
27
+ const effectiveConfig = resolveSessionConfig(
28
+ loaded.config,
29
+ loadSessionConfig(loaded.yeaftDir, sessionId),
30
+ );
31
+ return new Engine({
32
+ adapter: loaded.adapter,
33
+ trace: loaded.trace,
34
+ config: effectiveConfig,
35
+ conversationStore: loaded.conversationStore,
36
+ memoryIndex: loaded.memoryIndex || null,
37
+ amsRegistry: loaded.amsRegistry || null,
38
+ toolRegistry: loaded.toolRegistry,
39
+ skillManager: loaded.skillManager,
40
+ mcpManager: loaded.mcpManager,
41
+ yeaftDir: loaded.yeaftDir,
42
+ toolStats: loaded.toolStats || null,
43
+ taskManager: loaded.taskManager || null,
44
+ sessionId,
45
+ vpId,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Open a transport-neutral multi-VP runtime for an existing Yeaft Session.
51
+ * Different VPs run concurrently; each VP owns one serial promise tail and
52
+ * one Engine, matching the WebSocket runtime's state-isolation rule.
53
+ *
54
+ * Returns null when session metadata does not exist so legacy one-engine CLI
55
+ * invocations can keep their existing behavior.
56
+ */
57
+ export function createCliSessionRunner({
58
+ loaded,
59
+ sessionId,
60
+ workDir = process.cwd(),
61
+ engineFactory = createEngine,
62
+ personaFactory = buildVpPersona,
63
+ } = {}) {
64
+ if (!loaded || !sessionId) return null;
65
+ const sessionDir = join(sessionsRoot(loaded.yeaftDir), sessionId);
66
+ if (!loadSessionMeta(sessionDir)) return null;
67
+
68
+ const handle = openSession(sessionsRoot(loaded.yeaftDir), sessionId);
69
+ const engines = new Map();
70
+ const tails = new Map();
71
+ const pending = new Set();
72
+ let closed = false;
73
+
74
+ const engineFor = (vpId) => {
75
+ let engine = engines.get(vpId);
76
+ if (!engine) {
77
+ engine = engineFactory(loaded, sessionId, vpId);
78
+ engines.set(vpId, engine);
79
+ }
80
+ return engine;
81
+ };
82
+
83
+ let coordinator;
84
+
85
+ const runEnvelope = async (vpId, envelope, options) => {
86
+ const meta = handle.getMeta();
87
+ const engine = engineFor(vpId);
88
+ const promptText = envelope?.msg?.text || '';
89
+ const prompt = `@vp-${vpId} ${promptText}`;
90
+ const messages = loaded.conversationStore.loadSessionHistoryForVp(sessionId, vpId);
91
+ const todos = [];
92
+ let resultText = '';
93
+ let failed = null;
94
+ const scopedCoordinator = {
95
+ group: coordinator.group,
96
+ ingest(input, opts) {
97
+ return coordinator.ingest({ ...input, _cliTurnContext: envelope._cliTurnContext }, opts);
98
+ },
99
+ };
100
+ const queryOptions = {
101
+ prompt,
102
+ messages,
103
+ sessionId,
104
+ workDir: meta.workDir || workDir,
105
+ senderVpId: vpId,
106
+ sessionMembers: meta.roster.slice(),
107
+ sessionAnnouncement: meta.announcement || '',
108
+ vpPersona: personaFactory(vpId, loaded),
109
+ router: createRouter({ coordinator: scopedCoordinator }),
110
+ inboundEnvelope: envelope,
111
+ userAlreadyPersisted: true,
112
+ threadId: 'main',
113
+ vpTurnId: envelope?.msg?.id || randomUUID(),
114
+ collabToolPolicy: meta.roster.length > 1
115
+ ? COLLAB_TOOL_POLICY.MULTI_VP
116
+ : COLLAB_TOOL_POLICY.SINGLE_VP,
117
+ getCurrentTodos: () => todos.slice(),
118
+ setCurrentTodos: (next) => {
119
+ todos.splice(0, todos.length, ...(Array.isArray(next) ? next : []));
120
+ },
121
+ askUser: options.askUser
122
+ ? request => options.askUser(request, vpId, queryOptions.vpTurnId)
123
+ : null,
124
+ userEffort: options.modelEffort || null,
125
+ };
126
+
127
+ try {
128
+ for await (const event of engine.query(queryOptions)) {
129
+ if (event.type === 'text_delta') resultText += event.text || '';
130
+ await options.onEvent?.({ vpId, event, sessionId, turnId: queryOptions.vpTurnId });
131
+ }
132
+ } catch (error) {
133
+ failed = error;
134
+ await options.onEvent?.({
135
+ vpId,
136
+ sessionId,
137
+ turnId: queryOptions.vpTurnId,
138
+ event: { type: 'error', error, retryable: false },
139
+ });
140
+ }
141
+ return { vpId, result: resultText, error: failed };
142
+ };
143
+
144
+ const enqueue = (vpId, envelope) => {
145
+ if (closed) throw new Error('CLI Session runner is closed');
146
+ const turnContext = envelope?._cliTurnContext;
147
+ if (!turnContext) throw new Error('CLI Session envelope is missing its turn context');
148
+ const previous = tails.get(vpId) || Promise.resolve();
149
+ const task = previous.catch(() => {}).then(() => runEnvelope(vpId, envelope, turnContext.options));
150
+ tails.set(vpId, task);
151
+ pending.add(task);
152
+ turnContext.pending.add(task);
153
+ task.finally(() => {
154
+ pending.delete(task);
155
+ turnContext.pending.delete(task);
156
+ if (tails.get(vpId) === task) tails.delete(vpId);
157
+ }).catch(() => {});
158
+ return task;
159
+ };
160
+
161
+ coordinator = createCoordinator(handle, { deliver: enqueue });
162
+
163
+ async function drain(tasks = pending) {
164
+ const results = [];
165
+ while (tasks.size > 0) {
166
+ const batch = Array.from(tasks);
167
+ results.push(...await Promise.all(batch));
168
+ await Promise.resolve();
169
+ }
170
+ return results;
171
+ }
172
+
173
+ return {
174
+ sessionId,
175
+ get meta() { return handle.getMeta(); },
176
+ async run(prompt, options = {}) {
177
+ if (closed) throw new Error('CLI Session runner is closed');
178
+ const turnContext = Object.freeze({ options: Object.freeze({ ...options }), pending: new Set() });
179
+ const messageId = randomUUID();
180
+ // The shared user row is the durability boundary. Every VP Engine skips
181
+ // its own user append, preventing @all from duplicating the prompt N times.
182
+ loaded.conversationStore.append({
183
+ role: 'user',
184
+ content: prompt,
185
+ sessionId,
186
+ threadId: 'main',
187
+ clientMessageId: messageId,
188
+ userAuthored: true,
189
+ });
190
+ const report = coordinator.ingest({
191
+ id: messageId,
192
+ from: 'user',
193
+ role: 'user',
194
+ text: prompt,
195
+ _cliTurnContext: turnContext,
196
+ });
197
+ const results = await drain(turnContext.pending);
198
+ return { report, results };
199
+ },
200
+ abort(reason = 'user') {
201
+ let count = 0;
202
+ for (const engine of engines.values()) {
203
+ if (engine.abort?.(reason)) count += 1;
204
+ }
205
+ return count;
206
+ },
207
+ async close() {
208
+ closed = true;
209
+ await drain();
210
+ handle.close();
211
+ },
212
+ };
213
+ }
package/yeaft/cli.js CHANGED
@@ -38,7 +38,8 @@ import { ConversationStore } from './conversation/persist.js';
38
38
  import { snapshotSessions } from './sessions/session-crud.js';
39
39
  import { loadSessionConfig, resolveSessionConfig } from './sessions/session-config.js';
40
40
  import { validateSessionId } from './sessions/ids.js';
41
- import { createJsonlWriter, JsonlInput, runStreamTurn } from './stdio-protocol.js';
41
+ import { createJsonlWriter, JsonlInput, runStreamTurn, runStreamSessionTurn } from './stdio-protocol.js';
42
+ import { createCliSessionRunner } from './cli-session-runner.js';
42
43
 
43
44
  // ─── Argument parsing ──────────────────────────────────────────
44
45
 
@@ -333,16 +334,28 @@ function handleDeleteGroup(config, sessionId) {
333
334
  // ─── REPL ──────────────────────────────────────────────────────
334
335
 
335
336
  async function runREPL(config, args) {
336
- // Use loadSession() to wire all subsystems
337
+ const workDir = resolve(args.workDir || process.cwd());
338
+ const persisted = args.sessionId ? loadSessionConfig(config.dir, args.sessionId) : {};
339
+ const effectiveConfig = resolveSessionConfig(config, persisted);
337
340
  const session = await loadSession({
338
- model: args.model || config.model,
339
- language: args.language || config.language,
340
- debug: args.debug || config.debug,
341
+ dir: config.dir,
342
+ workDir,
343
+ model: args.model || effectiveConfig.model,
344
+ language: args.language || effectiveConfig.language,
345
+ debug: args.debug || effectiveConfig.debug,
341
346
  skipMCP: args.skipMCP,
342
347
  skipSkills: args.skipSkills,
348
+ configOverrides: effectiveConfig,
343
349
  });
344
350
 
345
351
  const { engine, conversationStore, trace, skillManager, mcpManager, toolRegistry } = session;
352
+ const sessionRunner = args.sessionId
353
+ ? createCliSessionRunner({ loaded: session, sessionId: args.sessionId, workDir })
354
+ : null;
355
+ if (args.sessionId && !sessionRunner) {
356
+ await session.shutdown();
357
+ throw new Error(`Session not found: ${args.sessionId}`);
358
+ }
346
359
 
347
360
  // Load persisted conversation as initial messages. `loadRecent` is now
348
361
  // turn-based (one user round-trip = one turn; multi-VP fan-out collapses
@@ -655,9 +668,28 @@ async function runREPL(config, args) {
655
668
  return;
656
669
  }
657
670
 
658
- // Regular input → engine.query
671
+ // Regular input → Session fan-out or legacy single Engine.
659
672
  try {
660
673
  let responseText = '';
674
+ if (sessionRunner) {
675
+ const outcome = await sessionRunner.run(input, {
676
+ modelEffort: args.modelEffort || session.config.modelEffort || null,
677
+ onEvent: ({ vpId, event }) => {
678
+ if (event.type === 'text_delta') {
679
+ responseText += event.text || '';
680
+ process.stdout.write(`\n[${vpId}] ${event.text || ''}`);
681
+ } else if (event.type === 'tool_start') {
682
+ process.stderr.write(`\n[${vpId}] ${event.name}...\n`);
683
+ } else if (event.type === 'error') {
684
+ process.stderr.write(`\n[${vpId}] Error: ${event.error?.message || event.error}\n`);
685
+ }
686
+ },
687
+ });
688
+ console.log();
689
+ if (outcome.results.some(result => result.error)) process.exitCode = 1;
690
+ rl.prompt();
691
+ return;
692
+ }
661
693
 
662
694
  for await (const event of engine.query({
663
695
  prompt: input,
@@ -721,6 +753,7 @@ async function runREPL(config, args) {
721
753
  });
722
754
 
723
755
  rl.on('close', async () => {
756
+ await sessionRunner?.close();
724
757
  await session.shutdown();
725
758
  console.log('\nBye!');
726
759
  process.exit(0);
@@ -766,6 +799,7 @@ async function runStreamJson(config, args) {
766
799
  },
767
800
  });
768
801
  const { engine, conversationStore, skillManager, toolRegistry } = loaded;
802
+ const sessionRunner = createCliSessionRunner({ loaded, sessionId, workDir });
769
803
  const todoState = { value: [] };
770
804
 
771
805
  write({
@@ -788,8 +822,7 @@ async function runStreamJson(config, args) {
788
822
  ...(message.toolCallId && { toolCallId: message.toolCallId }),
789
823
  ...(message.toolCalls && { toolCalls: message.toolCalls }),
790
824
  }));
791
- return runStreamTurn({
792
- engine,
825
+ const turnOptions = {
793
826
  prompt,
794
827
  messages: priorMessages,
795
828
  sessionId,
@@ -800,7 +833,10 @@ async function runStreamJson(config, args) {
800
833
  write,
801
834
  getCurrentTodos: () => todoState.value.slice(),
802
835
  setCurrentTodos: todos => { todoState.value = Array.isArray(todos) ? todos.slice() : []; },
803
- });
836
+ };
837
+ return sessionRunner
838
+ ? runStreamSessionTurn({ runner: sessionRunner, ...turnOptions })
839
+ : runStreamTurn({ engine, ...turnOptions });
804
840
  };
805
841
 
806
842
  let lastResult = null;
@@ -820,6 +856,7 @@ async function runStreamJson(config, args) {
820
856
  }
821
857
  } finally {
822
858
  input?.close();
859
+ await sessionRunner?.close();
823
860
  await loaded.shutdown();
824
861
  console.log = originalConsole.log;
825
862
  console.info = originalConsole.info;
@@ -836,18 +873,42 @@ async function runOnce(config, args) {
836
873
  return;
837
874
  }
838
875
 
839
- // Use loadSession() to wire all subsystems
876
+ const workDir = resolve(args.workDir || process.cwd());
877
+ const persisted = args.sessionId ? loadSessionConfig(config.dir, args.sessionId) : {};
878
+ const effectiveConfig = resolveSessionConfig(config, persisted);
840
879
  const session = await loadSession({
841
- model: args.model || config.model,
842
- language: args.language || config.language,
843
- debug: args.debug || config.debug,
880
+ dir: config.dir,
881
+ workDir,
882
+ model: args.model || effectiveConfig.model,
883
+ language: args.language || effectiveConfig.language,
884
+ debug: args.debug || effectiveConfig.debug,
844
885
  skipMCP: args.skipMCP,
845
886
  skipSkills: args.skipSkills,
887
+ configOverrides: effectiveConfig,
846
888
  });
847
889
 
848
890
  const { engine, conversationStore } = session;
891
+ const sessionRunner = args.sessionId
892
+ ? createCliSessionRunner({ loaded: session, sessionId: args.sessionId, workDir })
893
+ : null;
849
894
 
850
895
  try {
896
+ if (args.sessionId && !sessionRunner) {
897
+ throw new Error(`Session not found: ${args.sessionId}`);
898
+ }
899
+ if (sessionRunner) {
900
+ const outcome = await sessionRunner.run(args.prompt, {
901
+ modelEffort: args.modelEffort || session.config.modelEffort || null,
902
+ onEvent: ({ vpId, event }) => {
903
+ if (event.type === 'text_delta') process.stdout.write(`\n[${vpId}] ${event.text || ''}`);
904
+ else if (event.type === 'tool_start') process.stderr.write(`\n[${vpId}] ${event.name}...\n`);
905
+ else if (event.type === 'error') process.stderr.write(`\n[${vpId}] Error: ${event.error?.message || event.error}\n`);
906
+ },
907
+ });
908
+ console.log();
909
+ if (outcome.results.some(result => result.error)) process.exitCode = 1;
910
+ return;
911
+ }
851
912
  // Load recent conversation as context
852
913
  const priorMessages = conversationStore.loadRecent(20).map(m => ({
853
914
  role: m.role,
@@ -903,6 +964,7 @@ async function runOnce(config, args) {
903
964
  // Final newline after streaming text
904
965
  console.log();
905
966
  } finally {
967
+ await sessionRunner?.close();
906
968
  await session.shutdown();
907
969
  }
908
970
  }
@@ -297,3 +297,167 @@ export async function runStreamTurn({
297
297
  write(result);
298
298
  return result;
299
299
  }
300
+
301
+ /**
302
+ * Run one prompt through the multi-VP CLI Session runner. Engine events keep
303
+ * the stream-json shape used by runStreamTurn and add `vp_id`, allowing
304
+ * concurrent VP streams to be demultiplexed without imposing output order.
305
+ */
306
+ export async function runStreamSessionTurn({
307
+ runner,
308
+ prompt,
309
+ sessionId,
310
+ workDir = process.cwd(),
311
+ model = null,
312
+ modelEffort = null,
313
+ input = null,
314
+ write,
315
+ }) {
316
+ const clientTurnId = randomUUID();
317
+ const states = new Map();
318
+ let failed = null;
319
+
320
+ const stateFor = (vpId) => {
321
+ let state = states.get(vpId);
322
+ if (!state) {
323
+ state = {
324
+ turnId: clientTurnId,
325
+ resultText: '',
326
+ stopReason: 'end_turn',
327
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheInputDeltaTokens: 0 },
328
+ };
329
+ states.set(vpId, state);
330
+ }
331
+ return state;
332
+ };
333
+
334
+ const askUser = async ({ question, options }, vpId = null, turnId = clientTurnId) => {
335
+ const requestId = randomUUID();
336
+ write({
337
+ type: 'ask_user', subtype: 'request', request_id: requestId,
338
+ session_id: sessionId, turn_id: turnId, vp_id: vpId,
339
+ question, options: Array.isArray(options) ? options : [],
340
+ });
341
+ if (!input) throw new Error('AskUser requires --input-format stream-json');
342
+ const answers = await input.waitForAnswer(requestId);
343
+ write({
344
+ type: 'ask_user', subtype: 'response', request_id: requestId,
345
+ session_id: sessionId, turn_id: turnId, vp_id: vpId, answers,
346
+ });
347
+ return answers;
348
+ };
349
+
350
+ const onEvent = async ({ vpId, event, turnId: fallbackTurnId }) => {
351
+ const state = stateFor(vpId);
352
+ if (event.type === 'turn_open' && event.turnId) state.turnId = event.turnId;
353
+ const turnId = state.turnId || fallbackTurnId || clientTurnId;
354
+ const base = { session_id: sessionId, turn_id: turnId, vp_id: vpId };
355
+ switch (event.type) {
356
+ case 'turn_open':
357
+ write({ ...event, type: 'turn', subtype: 'start', ...base, model, model_effort: modelEffort, cwd: workDir });
358
+ break;
359
+ case 'text_delta':
360
+ state.resultText += event.text || '';
361
+ write({ type: 'assistant', subtype: 'text_delta', ...base, delta: { type: 'text_delta', text: event.text || '' } });
362
+ break;
363
+ case 'thinking_delta':
364
+ write({ type: 'assistant', subtype: 'thinking_delta', ...base, delta: { type: 'thinking_delta', thinking: event.text || '' } });
365
+ break;
366
+ case 'skill_loaded':
367
+ write({ type: 'skill', subtype: 'loaded', ...base, skill: event.skill });
368
+ break;
369
+ case 'skill_error':
370
+ write({ type: 'skill', subtype: 'error', ...base, skill_name: event.skillName, error: event.message });
371
+ break;
372
+ case 'tool_call':
373
+ write({ type: 'assistant', subtype: 'tool_use', ...base, content: [{ type: 'tool_use', id: event.id, name: event.name, input: event.input }] });
374
+ if (event.name === 'TodoWrite') {
375
+ write({ type: 'todo', subtype: 'update', ...base, tool_use_id: event.id, todos: event.input?.todos || [] });
376
+ }
377
+ break;
378
+ case 'tool_start':
379
+ write({ type: 'tool', subtype: 'start', ...base, tool_use_id: event.id, name: event.name, input: event.input });
380
+ break;
381
+ case 'tool_end':
382
+ write({ type: 'tool', subtype: 'result', ...base, tool_use_id: event.id, name: event.name, content: event.output, is_error: !!event.isError });
383
+ break;
384
+ case 'usage': {
385
+ const cacheDelta = event.cacheTokensAreIncludedInInput ? 0 : (event.cacheReadTokens || 0) + (event.cacheWriteTokens || 0);
386
+ state.usage.inputTokens += event.inputTokens || 0;
387
+ state.usage.outputTokens += event.outputTokens || 0;
388
+ state.usage.cacheReadTokens += event.cacheReadTokens || 0;
389
+ state.usage.cacheWriteTokens += event.cacheWriteTokens || 0;
390
+ state.usage.cacheInputDeltaTokens += cacheDelta;
391
+ write({ type: 'usage', ...base, usage: snakeUsage(event) });
392
+ break;
393
+ }
394
+ case 'stop':
395
+ case 'turn_end':
396
+ if (event.stopReason && TERMINAL_STOP_REASONS.has(event.stopReason)) state.stopReason = event.stopReason;
397
+ break;
398
+ case 'turn_close':
399
+ write({ type: 'turn', subtype: 'stop', ...base, duration_ms: event.totalMs, loop_count: event.loopCount, total_tokens: event.totalTokens });
400
+ break;
401
+ case 'error':
402
+ failed ||= event.error instanceof Error ? event.error : new Error(event.error?.message || String(event.error || 'Unknown error'));
403
+ write({ type: 'error', ...base, error: { name: failed.name, message: failed.message }, retryable: !!event.retryable });
404
+ break;
405
+ case 'fallback':
406
+ case 'llm_retry':
407
+ case 'memory_used':
408
+ case 'recall':
409
+ case 'consolidate':
410
+ case 'reflection':
411
+ write({ ...event, ...base });
412
+ break;
413
+ }
414
+ };
415
+
416
+ let outcome;
417
+ try {
418
+ outcome = await runner.run(prompt, {
419
+ modelEffort,
420
+ onEvent,
421
+ askUser,
422
+ });
423
+ } catch (error) {
424
+ failed = error;
425
+ write({ type: 'error', session_id: sessionId, turn_id: clientTurnId, error: { name: error.name || 'Error', message: error.message || String(error) } });
426
+ outcome = { report: { dispatched: [] }, results: [] };
427
+ }
428
+
429
+ const perVp = Array.from(states, ([vpId, state]) => ({
430
+ vp_id: vpId,
431
+ turn_id: state.turnId,
432
+ stop_reason: state.stopReason,
433
+ result: state.resultText,
434
+ usage: {
435
+ input_tokens: state.usage.inputTokens,
436
+ output_tokens: state.usage.outputTokens,
437
+ cache_read_input_tokens: state.usage.cacheReadTokens,
438
+ cache_creation_input_tokens: state.usage.cacheWriteTokens,
439
+ total_input_tokens: state.usage.inputTokens + state.usage.cacheInputDeltaTokens,
440
+ total_tokens: state.usage.inputTokens + state.usage.cacheInputDeltaTokens + state.usage.outputTokens,
441
+ },
442
+ }));
443
+ const stopReasons = perVp.map(item => item.stop_reason);
444
+ const aggregateStopReason = failed || stopReasons.includes('error')
445
+ ? 'error'
446
+ : stopReasons.length > 0 && stopReasons.every(reason => reason === 'aborted')
447
+ ? 'aborted'
448
+ : 'end_turn';
449
+ const isError = !!failed || aggregateStopReason === 'error';
450
+ const result = {
451
+ type: 'result', subtype: isError ? 'error' : 'success',
452
+ session_id: sessionId, turn_id: clientTurnId,
453
+ model, model_effort: modelEffort,
454
+ stop_reason: aggregateStopReason,
455
+ is_error: isError,
456
+ result: perVp.map(item => item.result).filter(Boolean).join('\n'),
457
+ dispatched_vp_ids: outcome?.report?.dispatched || [],
458
+ vp_results: perVp,
459
+ ...(failed ? { error: failed.message || String(failed) } : {}),
460
+ };
461
+ write(result);
462
+ return result;
463
+ }