@yeaft/webchat-agent 1.0.384 → 1.0.385

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.384"}
1
+ {"version":"1.0.385"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.384",
3
+ "version": "1.0.385",
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",
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { Engine } from './engine.js';
4
4
  import { createRouter } from './routing/router.js';
5
5
  import { createCoordinator } from './sessions/coordinator.js';
6
+ import { resolveMemberId } from './sessions/roster.js';
6
7
  import { sessionsRoot } from './sessions/session-crud.js';
7
8
  import { openSession, loadSessionMeta } from './sessions/session-store.js';
8
9
  import { loadSessionConfig, resolveSessionConfig } from './sessions/session-config.js';
@@ -61,6 +62,7 @@ export function createCliSessionRunner({
61
62
  workDir = process.cwd(),
62
63
  engineFactory = createCliVpEngine,
63
64
  personaFactory = buildVpPersona,
65
+ configureEngine = null,
64
66
  } = {}) {
65
67
  if (!loaded || !sessionId) return null;
66
68
  const sessionDir = join(sessionsRoot(loaded.yeaftDir), sessionId);
@@ -70,12 +72,21 @@ export function createCliSessionRunner({
70
72
  const engines = new Map();
71
73
  const tails = new Map();
72
74
  const pending = new Set();
75
+ // Root turns are accepted synchronously but execute on per-VP promise tails.
76
+ // Durable rows therefore cannot use append order as their causal boundary: a
77
+ // later root user row may be written before an earlier VP starts, while the
78
+ // earlier VP's assistant rows may be written after that later user row. Every
79
+ // new row carries one durable causalRootId; the legacy ids remain fallbacks
80
+ // for rows produced before that field existed.
81
+ const rootOrderByIdentity = new Map();
82
+ let nextRootOrder = 0;
73
83
  let closed = false;
74
84
 
75
85
  const engineFor = (vpId) => {
76
86
  let engine = engines.get(vpId);
77
87
  if (!engine) {
78
88
  engine = engineFactory(loaded, sessionId, vpId);
89
+ if (typeof configureEngine === 'function') configureEngine(engine, vpId);
79
90
  engines.set(vpId, engine);
80
91
  }
81
92
  return engine;
@@ -86,16 +97,56 @@ export function createCliSessionRunner({
86
97
  const runEnvelope = async (vpId, envelope, options) => {
87
98
  const meta = handle.getMeta();
88
99
  const engine = engineFor(vpId);
89
- const promptText = envelope?.msg?.text || '';
90
- const prompt = `@vp-${vpId} ${promptText}`;
91
- const messages = loaded.conversationStore.loadSessionHistoryForVp(sessionId, vpId);
100
+ const prompt = envelope?.msg?.text || '';
101
+ const persistedUserClientMessageId = typeof envelope?._persistedUserClientMessageId === 'string'
102
+ ? envelope._persistedUserClientMessageId
103
+ : null;
104
+ const causalRootId = typeof envelope?._cliCausalRootId === 'string' && envelope._cliCausalRootId
105
+ ? envelope._cliCausalRootId
106
+ : null;
107
+ const rootOrder = Number.isInteger(envelope?._cliRootOrder)
108
+ ? envelope._cliRootOrder
109
+ : null;
110
+ // Engine.query() appends `prompt` itself. Exclude this root's durable user
111
+ // row and every later root turn, regardless of where their assistant/tool
112
+ // rows landed in the globally sequenced transcript. This preserves rows
113
+ // completed by earlier accepted roots while preventing future prompts from
114
+ // entering an earlier provider request.
115
+ const messages = loaded.conversationStore
116
+ .loadSessionHistoryForVp(sessionId, vpId)
117
+ .filter((message) => {
118
+ if (persistedUserClientMessageId
119
+ && message?.role === 'user'
120
+ && message.clientMessageId === persistedUserClientMessageId) return false;
121
+ if (rootOrder === null) return true;
122
+ let messageRootOrder = null;
123
+ if (typeof message?.causalRootId === 'string' && message.causalRootId) {
124
+ // A durable causal root is authoritative. Do not reinterpret a row by
125
+ // its role-specific legacy ids when this field is present.
126
+ messageRootOrder = rootOrderByIdentity.get(message.causalRootId);
127
+ } else if (message?.role === 'user' && typeof message.clientMessageId === 'string') {
128
+ messageRootOrder = rootOrderByIdentity.get(message.clientMessageId);
129
+ } else if (typeof message?.turnId === 'string') {
130
+ messageRootOrder = rootOrderByIdentity.get(message.turnId);
131
+ }
132
+ return !Number.isInteger(messageRootOrder) || messageRootOrder < rootOrder;
133
+ });
92
134
  const todos = [];
93
135
  let resultText = '';
94
136
  let failed = null;
95
137
  const scopedCoordinator = {
96
138
  group: coordinator.group,
97
139
  ingest(input, opts) {
98
- return coordinator.ingest({ ...input, _cliTurnContext: envelope._cliTurnContext }, opts);
140
+ const report = coordinator.ingest({
141
+ ...input,
142
+ _cliRootOrder: envelope._cliRootOrder,
143
+ _cliCausalRootId: envelope._cliCausalRootId,
144
+ _cliTurnContext: envelope._cliTurnContext,
145
+ }, opts);
146
+ if (rootOrder !== null && typeof report?.message?.id === 'string') {
147
+ rootOrderByIdentity.set(report.message.id, rootOrder);
148
+ }
149
+ return report;
99
150
  },
100
151
  };
101
152
  const queryOptions = {
@@ -110,6 +161,7 @@ export function createCliSessionRunner({
110
161
  router: createRouter({ coordinator: scopedCoordinator }),
111
162
  inboundEnvelope: envelope,
112
163
  userAlreadyPersisted: true,
164
+ causalRootId,
113
165
  threadId: 'main',
114
166
  vpTurnId: envelope?.msg?.id || randomUUID(),
115
167
  collabToolPolicy: meta.roster.length > 1
@@ -120,7 +172,7 @@ export function createCliSessionRunner({
120
172
  todos.splice(0, todos.length, ...(Array.isArray(next) ? next : []));
121
173
  },
122
174
  askUser: options.askUser
123
- ? request => options.askUser(request, vpId, queryOptions.vpTurnId)
175
+ ? request => options.askUser(request, vpId, queryOptions.vpTurnId, queryOptions.threadId)
124
176
  : null,
125
177
  userEffort: options.modelEffort || null,
126
178
  };
@@ -151,14 +203,17 @@ export function createCliSessionRunner({
151
203
  if (closed) throw new Error('CLI Session runner is closed');
152
204
  const turnContext = envelope?._cliTurnContext;
153
205
  if (!turnContext) throw new Error('CLI Session envelope is missing its turn context');
206
+ if (turnContext.claimedVpIds.has(vpId)) {
207
+ return { ok: false, error: 'target_already_claimed' };
208
+ }
209
+ turnContext.claimedVpIds.add(vpId);
154
210
  const previous = tails.get(vpId) || Promise.resolve();
155
211
  const task = previous.catch(() => {}).then(() => runEnvelope(vpId, envelope, turnContext.options));
156
212
  tails.set(vpId, task);
157
213
  pending.add(task);
158
- turnContext.pending.add(task);
214
+ turnContext.tasks.push(task);
159
215
  task.finally(() => {
160
216
  pending.delete(task);
161
- turnContext.pending.delete(task);
162
217
  if (tails.get(vpId) === task) tails.delete(vpId);
163
218
  }).catch(() => {});
164
219
  return task;
@@ -181,26 +236,87 @@ export function createCliSessionRunner({
181
236
  get meta() { return handle.getMeta(); },
182
237
  async run(prompt, options = {}) {
183
238
  if (closed) throw new Error('CLI Session runner is closed');
184
- const turnContext = Object.freeze({ options: Object.freeze({ ...options }), pending: new Set() });
185
- const messageId = randomUUID();
186
- // The shared user row is the durability boundary. Every VP Engine skips
187
- // its own user append, preventing @all from duplicating the prompt N times.
188
- loaded.conversationStore.append({
189
- role: 'user',
190
- content: prompt,
191
- sessionId,
192
- threadId: 'main',
193
- clientMessageId: messageId,
194
- userAuthored: true,
239
+ let routingIntent = options.routingIntent && typeof options.routingIntent === 'object'
240
+ ? options.routingIntent
241
+ : null;
242
+ if (routingIntent) {
243
+ const meta = handle.getMeta();
244
+ const rawTargets = Array.isArray(routingIntent.targetVpIds)
245
+ ? routingIntent.targetVpIds
246
+ : [];
247
+ const targetVpIds = [];
248
+ for (const rawTarget of rawTargets) {
249
+ const target = typeof rawTarget === 'string' ? rawTarget.trim() : '';
250
+ const resolved = resolveMemberId(meta, target);
251
+ if (!resolved) throw new Error(`Unknown stream-json target VP ${target || String(rawTarget)}: not in roster`);
252
+ if (!targetVpIds.includes(resolved)) targetVpIds.push(resolved);
253
+ }
254
+ if (routingIntent.broadcast === true) {
255
+ for (const vpId of meta.roster) {
256
+ if (!targetVpIds.includes(vpId)) targetVpIds.push(vpId);
257
+ }
258
+ }
259
+ if (targetVpIds.length === 0) throw new Error('stream-json routing intent requires at least one target VP');
260
+ routingIntent = Object.freeze({
261
+ targetVpIds: Object.freeze(targetVpIds),
262
+ broadcast: routingIntent.broadcast === true,
263
+ explicit: routingIntent.explicit === true,
264
+ });
265
+ }
266
+ const turnContext = Object.freeze({
267
+ options: Object.freeze({ ...options }),
268
+ tasks: [],
269
+ claimedVpIds: new Set(),
195
270
  });
271
+ const messageId = randomUUID();
272
+ const rootOrder = nextRootOrder++;
273
+ rootOrderByIdentity.set(messageId, rootOrder);
274
+ // The shared user row is the durability boundary. Validate structured
275
+ // routing above before this append so malformed machine selectors never
276
+ // enter the transcript or reach a provider.
277
+ let persistedUserClientMessageId = null;
278
+ if (!options.internal) {
279
+ const persistedUser = loaded.conversationStore.append({
280
+ role: 'user',
281
+ content: prompt,
282
+ sessionId,
283
+ threadId: 'main',
284
+ clientMessageId: messageId,
285
+ causalRootId: messageId,
286
+ userAuthored: true,
287
+ });
288
+ persistedUserClientMessageId = persistedUser?.clientMessageId || messageId;
289
+ }
196
290
  const report = coordinator.ingest({
197
291
  id: messageId,
198
- from: 'user',
199
- role: 'user',
292
+ from: options.internal ? 'tool' : 'user',
293
+ role: options.internal ? 'assistant' : 'user',
200
294
  text: prompt,
295
+ ...(options.internal ? {
296
+ internal: true,
297
+ taskId: options.taskId || null,
298
+ meta: {
299
+ ...(options.meta || {}),
300
+ injectedBy: 'task_result',
301
+ ...(routingIntent?.targetVpIds?.[0]
302
+ ? { routeTargetVpId: routingIntent.targetVpIds[0] }
303
+ : {}),
304
+ },
305
+ } : {}),
306
+ ...(routingIntent ? { _routingIntent: routingIntent } : {}),
307
+ ...(persistedUserClientMessageId ? { _persistedUserClientMessageId: persistedUserClientMessageId } : {}),
308
+ _cliRootOrder: rootOrder,
309
+ _cliCausalRootId: messageId,
201
310
  _cliTurnContext: turnContext,
202
311
  });
203
- const results = await drain(turnContext.pending);
312
+ const results = [];
313
+ let cursor = 0;
314
+ while (cursor < turnContext.tasks.length) {
315
+ const batch = turnContext.tasks.slice(cursor);
316
+ cursor += batch.length;
317
+ results.push(...await Promise.all(batch));
318
+ await Promise.resolve();
319
+ }
204
320
  return { report, results };
205
321
  },
206
322
  abort(reason = 'user') {
package/yeaft/cli.js CHANGED
@@ -38,8 +38,17 @@ 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, runStreamSessionTurn } from './stdio-protocol.js';
41
+ import {
42
+ createJsonlWriter,
43
+ JsonlInput,
44
+ normalizeStreamRoutingIntent,
45
+ runStreamTurn,
46
+ runStreamSessionTurn,
47
+ } from './stdio-protocol.js';
42
48
  import { createCliSessionRunner } from './cli-session-runner.js';
49
+ import { emitStreamTaskEvent, taskResultReentryContext } from './tasks/result-delivery.js';
50
+ import { TASK_RESULT_DELIVERY, taskResultDeliveryFor } from './tasks/store.js';
51
+ import { buildStreamSubAgentFrame } from './sub-agent/public-event.js';
43
52
  import {
44
53
  cleanupManagedCliRuntimePaths,
45
54
  ensureManagedCliTools,
@@ -816,6 +825,34 @@ async function runREPL(config, args) {
816
825
 
817
826
  // ─── Structured stdio handler ──────────────────────────────────
818
827
 
828
+ function writeStreamProtocolError({ write, sessionId, error }) {
829
+ const turnId = randomUUID();
830
+ const normalized = error instanceof Error ? error : new Error(String(error || 'Unknown error'));
831
+ write({
832
+ type: 'error',
833
+ session_id: sessionId,
834
+ turn_id: turnId,
835
+ thread_id: 'main',
836
+ threadId: 'main',
837
+ error: { name: normalized.name || 'Error', message: normalized.message || String(normalized) },
838
+ retryable: false,
839
+ });
840
+ const result = {
841
+ type: 'result',
842
+ subtype: 'error',
843
+ session_id: sessionId,
844
+ turn_id: turnId,
845
+ thread_id: 'main',
846
+ threadId: 'main',
847
+ stop_reason: 'error',
848
+ is_error: true,
849
+ result: '',
850
+ error: normalized.message || String(normalized),
851
+ };
852
+ write(result);
853
+ return result;
854
+ }
855
+
819
856
  async function runStreamJson(config, args) {
820
857
  const sessionId = args.sessionId || `session_cli_${randomUUID()}`;
821
858
  const validation = validateSessionId(sessionId);
@@ -854,8 +891,148 @@ async function runStreamJson(config, args) {
854
891
  managedCliReady: args.managedCliReady,
855
892
  });
856
893
  const { engine, conversationStore, skillManager, toolRegistry } = loaded;
857
- const sessionRunner = createCliSessionRunner({ loaded, sessionId, workDir });
858
894
  const todoState = { value: [] };
895
+ const asyncTaskOwners = new Map();
896
+ const rescuedTaskIds = new Set();
897
+ const pendingTaskRescues = new Set();
898
+ const taskLifecycleWaiters = new Set();
899
+ let sessionRunner = null;
900
+ let taskEventIntakeOpen = true;
901
+ let hadError = false;
902
+ let singleEngineTail = Promise.resolve();
903
+
904
+ const loadStreamHistory = () => conversationStore.loadRecentBySession(sessionId, 20).map(message => ({
905
+ role: message.role,
906
+ content: message.content,
907
+ ...(message.toolCallId && { toolCallId: message.toolCallId }),
908
+ ...(message.toolCalls && { toolCalls: message.toolCalls }),
909
+ }));
910
+
911
+ // An ad-hoc stream-json conversation has exactly one Engine and no VP roster.
912
+ // Serialize both root turns and late task-result rescues on that same stable
913
+ // process owner so a completion cannot race another query or invent a VP id.
914
+ const runSingleEngineTurn = (turnOptions) => {
915
+ const turn = singleEngineTail.catch(() => {}).then(() => runStreamTurn({
916
+ engine,
917
+ ...turnOptions,
918
+ }));
919
+ singleEngineTail = turn;
920
+ return turn;
921
+ };
922
+
923
+ const wakeTaskLifecycleWaiters = () => {
924
+ if (taskLifecycleWaiters.size === 0) return;
925
+ const waiters = Array.from(taskLifecycleWaiters);
926
+ taskLifecycleWaiters.clear();
927
+ for (const resolveWaiter of waiters) resolveWaiter();
928
+ };
929
+
930
+ const rescueTaskResult = async (context) => {
931
+ const taskId = context?.task?.id;
932
+ if (!taskId || !context?.content || rescuedTaskIds.has(taskId)) return false;
933
+ if (sessionRunner && !context.vpId) return false;
934
+ rescuedTaskIds.add(taskId);
935
+ try {
936
+ const common = {
937
+ prompt: context.content,
938
+ sessionId,
939
+ workDir,
940
+ model: loaded.config.model,
941
+ modelEffort: loaded.config.modelEffort || null,
942
+ input,
943
+ write,
944
+ taskId,
945
+ };
946
+ const result = sessionRunner
947
+ ? await runStreamSessionTurn({
948
+ runner: sessionRunner,
949
+ ...common,
950
+ internal: true,
951
+ meta: {
952
+ injectedBy: 'task_result',
953
+ routeTargetVpId: context.vpId,
954
+ threadId: context.threadId || 'main',
955
+ },
956
+ routingIntent: { targetVpIds: [context.vpId], explicit: true },
957
+ })
958
+ : await runSingleEngineTurn({
959
+ ...common,
960
+ messages: loadStreamHistory(),
961
+ threadId: context.threadId || 'main',
962
+ userAlreadyPersisted: true,
963
+ });
964
+ hadError ||= result?.is_error === true;
965
+ return true;
966
+ } catch (error) {
967
+ hadError = true;
968
+ writeStreamProtocolError({ write, sessionId, error });
969
+ return false;
970
+ }
971
+ };
972
+
973
+ const queueTaskRescue = (context) => {
974
+ if (!taskEventIntakeOpen) return null;
975
+ let rescue;
976
+ rescue = rescueTaskResult(context).finally(() => {
977
+ pendingTaskRescues.delete(rescue);
978
+ wakeTaskLifecycleWaiters();
979
+ });
980
+ pendingTaskRescues.add(rescue);
981
+ wakeTaskLifecycleWaiters();
982
+ return rescue;
983
+ };
984
+
985
+ const configureStreamEngine = (targetEngine, vpId = null) => {
986
+ if (!targetEngine) return;
987
+ if (typeof targetEngine.setAsyncTaskCoordinator === 'function') {
988
+ const removeOwner = (taskId, ownerEngine) => {
989
+ const owner = asyncTaskOwners.get(taskId);
990
+ if (!owner || owner.engine !== ownerEngine) return null;
991
+ asyncTaskOwners.delete(taskId);
992
+ return owner;
993
+ };
994
+ targetEngine.setAsyncTaskCoordinator({
995
+ onRegister(taskId, ownerEngine) {
996
+ if (!taskId) return;
997
+ asyncTaskOwners.set(taskId, {
998
+ engine: ownerEngine,
999
+ sessionId,
1000
+ vpId: vpId || null,
1001
+ threadId: ownerEngine.currentThreadId || 'main',
1002
+ });
1003
+ },
1004
+ onUnregister: removeOwner,
1005
+ onConsumed: removeOwner,
1006
+ onDeferred(taskId, ownerEngine) {
1007
+ const owner = removeOwner(taskId, ownerEngine);
1008
+ const task = loaded.taskManager?.getTask?.(sessionId, taskId);
1009
+ if (!owner || !task || !['succeeded', 'failed', 'cancelled', 'orphaned'].includes(task.status)) return;
1010
+ const context = taskResultReentryContext({ event: 'completed', task }, { sessionId, owner });
1011
+ if (context) queueTaskRescue(context);
1012
+ },
1013
+ onUndelivered(taskId, delivery, ownerEngine) {
1014
+ const owner = removeOwner(taskId, ownerEngine);
1015
+ if (!owner || rescuedTaskIds.has(taskId)) return;
1016
+ queueTaskRescue({
1017
+ task: { id: taskId, kind: delivery?.taskKind, status: delivery?.taskStatus },
1018
+ sessionId: delivery?.sessionId || owner.sessionId,
1019
+ vpId: delivery?.vpId || owner.vpId,
1020
+ threadId: delivery?.threadId || owner.threadId,
1021
+ content: delivery?.content,
1022
+ });
1023
+ },
1024
+ });
1025
+ }
1026
+ if (typeof targetEngine.setSubAgentEventSink === 'function') {
1027
+ targetEngine.setSubAgentEventSink((agentId, event) => {
1028
+ const frame = buildStreamSubAgentFrame({ event, agentId, sessionId, vpId, threadId: targetEngine.currentThreadId });
1029
+ if (frame) write(frame);
1030
+ });
1031
+ }
1032
+ };
1033
+
1034
+ configureStreamEngine(engine, null);
1035
+ sessionRunner = createCliSessionRunner({ loaded, sessionId, workDir, configureEngine: configureStreamEngine });
859
1036
 
860
1037
  write({
861
1038
  type: 'system',
@@ -870,16 +1047,57 @@ async function runStreamJson(config, args) {
870
1047
  output_format: 'stream-json',
871
1048
  });
872
1049
 
873
- const runPrompt = async (prompt) => {
874
- const priorMessages = conversationStore.loadRecentBySession(sessionId, 20).map(message => ({
875
- role: message.role,
876
- content: message.content,
877
- ...(message.toolCallId && { toolCallId: message.toolCallId }),
878
- ...(message.toolCalls && { toolCalls: message.toolCalls }),
879
- }));
1050
+ if (loaded.taskManager && typeof loaded.taskManager.setEventSink === 'function') {
1051
+ loaded.taskManager.setEventSink((event) => {
1052
+ if (!taskEventIntakeOpen) return;
1053
+ const hadExactOwner = asyncTaskOwners.has(event?.task?.id);
1054
+ const delivery = emitStreamTaskEvent({ event, asyncTaskOwners, write, sessionId });
1055
+ if (delivery.projected && !delivery.delivered && !hadExactOwner && event?.event === 'completed') {
1056
+ const context = taskResultReentryContext(event, { sessionId });
1057
+ if (context) queueTaskRescue(context);
1058
+ }
1059
+ wakeTaskLifecycleWaiters();
1060
+ });
1061
+ }
1062
+
1063
+ const activeModelReentryTasks = () => {
1064
+ if (!loaded.taskManager || typeof loaded.taskManager.listActiveTasks !== 'function') return [];
1065
+ return loaded.taskManager.listActiveTasks(sessionId).filter(task => (
1066
+ taskResultDeliveryFor(task) === TASK_RESULT_DELIVERY.MODEL_REENTRY
1067
+ ));
1068
+ };
1069
+
1070
+ const drainTaskLifecycle = async () => {
1071
+ if (!loaded.taskManager || typeof loaded.taskManager.setEventSink !== 'function') {
1072
+ taskEventIntakeOpen = false;
1073
+ return;
1074
+ }
1075
+ // EOF is a lifecycle fence, not permission to abandon model-reentry work.
1076
+ // Keep the Session runner and task event sink alive until every owned
1077
+ // result-producing task is terminal and every resulting rescue turn has
1078
+ // settled. `status_only` tasks remain detached and never hold CLI exit.
1079
+ for (;;) {
1080
+ if (activeModelReentryTasks().length === 0 && pendingTaskRescues.size === 0) {
1081
+ // JavaScript runs this check + close synchronously. No TaskManager
1082
+ // completion can interleave between observing the empty sets and
1083
+ // detaching the sink, so no late rescue can target a closed runner.
1084
+ taskEventIntakeOpen = false;
1085
+ loaded.taskManager.setEventSink(null);
1086
+ wakeTaskLifecycleWaiters();
1087
+ return;
1088
+ }
1089
+ await new Promise(resolveWaiter => taskLifecycleWaiters.add(resolveWaiter));
1090
+ }
1091
+ };
1092
+
1093
+ const runPrompt = async (prompt, message = null) => {
1094
+ const routingIntent = normalizeStreamRoutingIntent(message);
1095
+ if (routingIntent && !sessionRunner) {
1096
+ throw new Error('stream-json VP selectors require an existing formal Session with a persisted roster');
1097
+ }
880
1098
  const turnOptions = {
881
1099
  prompt,
882
- messages: priorMessages,
1100
+ messages: loadStreamHistory(),
883
1101
  sessionId,
884
1102
  workDir,
885
1103
  model: loaded.config.model,
@@ -890,31 +1108,45 @@ async function runStreamJson(config, args) {
890
1108
  setCurrentTodos: todos => { todoState.value = Array.isArray(todos) ? todos.slice() : []; },
891
1109
  };
892
1110
  return sessionRunner
893
- ? runStreamSessionTurn({ runner: sessionRunner, ...turnOptions })
894
- : runStreamTurn({ engine, ...turnOptions });
1111
+ ? runStreamSessionTurn({ runner: sessionRunner, routingIntent, ...turnOptions })
1112
+ : runSingleEngineTurn(turnOptions);
895
1113
  };
896
1114
 
897
- let hadError = false;
898
1115
  const recordResult = (result) => {
899
1116
  hadError ||= result?.is_error === true;
900
1117
  return result;
901
1118
  };
902
1119
  try {
903
1120
  if (args.prompt) {
904
- recordResult(await runPrompt(args.prompt));
1121
+ try {
1122
+ recordResult(await runPrompt(args.prompt));
1123
+ } catch (error) {
1124
+ recordResult(writeStreamProtocolError({ write, sessionId, error }));
1125
+ }
905
1126
  } else if (input) {
906
1127
  for (;;) {
907
1128
  const item = await input.nextPrompt();
908
1129
  if (!item) break;
909
- recordResult(await runPrompt(item.prompt));
1130
+ try {
1131
+ recordResult(await runPrompt(item.prompt, item.message));
1132
+ } catch (error) {
1133
+ recordResult(writeStreamProtocolError({ write, sessionId, error }));
1134
+ }
910
1135
  }
911
1136
  } else {
912
1137
  let prompt = '';
913
1138
  for await (const chunk of process.stdin) prompt += chunk;
914
- if (prompt.trim()) recordResult(await runPrompt(prompt.trim()));
1139
+ if (prompt.trim()) {
1140
+ try {
1141
+ recordResult(await runPrompt(prompt.trim()));
1142
+ } catch (error) {
1143
+ recordResult(writeStreamProtocolError({ write, sessionId, error }));
1144
+ }
1145
+ }
915
1146
  }
916
1147
  } finally {
917
1148
  input?.close();
1149
+ await drainTaskLifecycle();
918
1150
  await sessionRunner?.close();
919
1151
  await loaded.shutdown();
920
1152
  console.log = originalConsole.log;
@@ -434,6 +434,7 @@ function serializeMessage(msg) {
434
434
  // Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
435
435
  fm.push(`threadId: ${msg.threadId || 'main'}`);
436
436
  if (msg.turnId) fm.push(`turnId: ${msg.turnId}`);
437
+ if (msg.causalRootId) fm.push(`causalRootId: ${msg.causalRootId}`);
437
438
  if (msg.executionOrigin === 'route_forward') fm.push('executionOrigin: route_forward');
438
439
  if (msg.imageAssetAnchor) fm.push('imageAssetAnchor: true');
439
440
  // task-313: when a thread is merged into another, the messages keep
@@ -581,6 +582,7 @@ export function parseMessage(raw) {
581
582
  case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
582
583
  case 'threadId': msg.threadId = value; break;
583
584
  case 'turnId': msg.turnId = value; break;
585
+ case 'causalRootId': msg.causalRootId = value; break;
584
586
  case 'executionOrigin':
585
587
  if (value === 'route_forward') msg.executionOrigin = value;
586
588
  break;