@yeaft/webchat-agent 0.1.770 → 0.1.772

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.770",
3
+ "version": "0.1.772",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,216 @@
1
+ /**
2
+ * vp-status-broker.js — single source of truth for per-VP status,
3
+ * emitted to the frontend on every transition.
4
+ *
5
+ * Why: the browser used to reverse-infer VP status from assistant
6
+ * messages' `isStreaming` flag (web/stores/helpers/vp-timeline.js).
7
+ * That flag is a UI artifact, not a state machine — whenever the
8
+ * `result` event lands without flipping the flag (reconnect mid-turn,
9
+ * tool window where the flag is cosmetically dropped, persisted
10
+ * history that re-hydrates with the flag absent), the inferred
11
+ * status drifts and can stay stuck on "streaming" forever.
12
+ *
13
+ * This broker owns a tiny in-memory table keyed by `(groupId, vpId)`,
14
+ * and emits a `vp_status_changed` event each time the state really
15
+ * changes. `vp_status_snapshot` rebuilds the table on a fresh
16
+ * frontend (reconnect, refresh). The broker is in-memory only —
17
+ * agent restart starts everyone at `idle`, which is the right default
18
+ * because no turn is in flight on a fresh process either.
19
+ *
20
+ * State machine: see docs/notes/2026-05-15-vp-status-from-agent.md.
21
+ *
22
+ * Sink injection: the broker is constructed with a `send` callback so
23
+ * it doesn't depend on the WebSocket layer directly (and so tests can
24
+ * collect emitted events into an array without spinning up WS).
25
+ */
26
+
27
+ /**
28
+ * Valid status values. `error` is a terminal/sticky state that the
29
+ * caller is expected to transition out of (the broker doesn't
30
+ * auto-decay — that policy belongs to the caller / web bridge).
31
+ * @type {ReadonlySet<string>}
32
+ */
33
+ export const VALID_STATES = new Set([
34
+ 'idle',
35
+ 'typing',
36
+ 'thinking',
37
+ 'streaming',
38
+ 'tool',
39
+ 'error',
40
+ ]);
41
+
42
+ /**
43
+ * @typedef {Object} VpStatusEntry
44
+ * @property {string} state — one of VALID_STATES
45
+ * @property {number} since — ms timestamp the state was entered
46
+ * @property {string|null} turnId
47
+ */
48
+
49
+ /**
50
+ * Create a broker instance.
51
+ *
52
+ * @param {object} opts
53
+ * @param {(event: object) => void} opts.send — emits events to the
54
+ * wire (web bridge wraps `sendUnifyEvent`). Must be synchronous; the
55
+ * broker calls it inline so transitions are flushed in order.
56
+ * @param {() => number} [opts.now] — clock, defaults to
57
+ * `Date.now`. Injected so tests can pin timestamps.
58
+ */
59
+ export function createVpStatusBroker({ send, now = Date.now } = {}) {
60
+ if (typeof send !== 'function') {
61
+ throw new TypeError('createVpStatusBroker: `send` callback is required');
62
+ }
63
+
64
+ /**
65
+ * `${groupId}::${vpId}` → VpStatusEntry. Composite key because the
66
+ * same vpId can appear in multiple groups; we track them separately
67
+ * so the frontend's per-group timeline reads only its slice.
68
+ * @type {Map<string, VpStatusEntry & {groupId: string, vpId: string}>}
69
+ */
70
+ const table = new Map();
71
+
72
+ const keyOf = (groupId, vpId) => `${groupId || ''}::${vpId}`;
73
+
74
+ /**
75
+ * Apply a state transition. Idempotent: a no-op if the requested
76
+ * state is already current (no event emitted, no `since` rewrite).
77
+ * Validates the input state — unknown values throw, so a typo
78
+ * fails loudly in the agent log instead of silently breaking the
79
+ * UI's render.
80
+ *
81
+ * @param {object} args
82
+ * @param {string} args.groupId
83
+ * @param {string} args.vpId
84
+ * @param {string} args.state — must be in VALID_STATES
85
+ * @param {string|null} [args.turnId] — optional, for non-idle states
86
+ * @returns {boolean} — true if a change was emitted
87
+ */
88
+ function transition({ groupId, vpId, state, turnId = null }) {
89
+ if (!vpId) return false;
90
+ if (!VALID_STATES.has(state)) {
91
+ throw new RangeError(`vp-status-broker: invalid state '${state}'`);
92
+ }
93
+ const key = keyOf(groupId, vpId);
94
+ const prev = table.get(key);
95
+ // Dedup: same state AND same turnId means no real transition.
96
+ // Allowing turnId to differ even at the same state lets a fresh
97
+ // turn re-stamp `since` without spamming events.
98
+ if (prev && prev.state === state && prev.turnId === turnId) {
99
+ return false;
100
+ }
101
+ const since = now();
102
+ const entry = { state, since, turnId, groupId: groupId || null, vpId };
103
+ table.set(key, entry);
104
+ send({
105
+ type: 'vp_status_changed',
106
+ groupId: entry.groupId,
107
+ vpId,
108
+ state,
109
+ since,
110
+ turnId,
111
+ });
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Convenience: force `idle` regardless of current state. Used by
117
+ * the run-vp-turn `finally` block so a turn that ended via *any*
118
+ * path (normal, abort, error, watchdog escalation) always settles
119
+ * back to idle.
120
+ */
121
+ function settleIdle({ groupId, vpId }) {
122
+ return transition({ groupId, vpId, state: 'idle', turnId: null });
123
+ }
124
+
125
+ /**
126
+ * Build the snapshot payload. Optionally filtered by groupId.
127
+ *
128
+ * Filtering semantics:
129
+ * - `groupId === undefined` → return every row across every group.
130
+ * This is the all-groups broadcast used on `session_ready`.
131
+ * - `groupId === null` → same as undefined. Treated as "no
132
+ * scope" because the wire envelope serializes `undefined` as
133
+ * `null` over JSON, and we want both forms to behave the same.
134
+ * - `groupId === '<id>'` → only rows for that group.
135
+ *
136
+ * The store mirrors this semantic on the frontend (see
137
+ * `vp_status_snapshot` handler in chat.js): scoped snapshots
138
+ * replace just that group's slice, while null/undefined snapshots
139
+ * replace the whole table.
140
+ *
141
+ * @param {string} [groupId]
142
+ * @returns {Array<{vpId:string, state:string, since:number, turnId:string|null, groupId:string|null}>}
143
+ */
144
+ function snapshot(groupId) {
145
+ const out = [];
146
+ for (const entry of table.values()) {
147
+ if (groupId !== undefined && groupId !== null && entry.groupId !== groupId) continue;
148
+ out.push({
149
+ vpId: entry.vpId,
150
+ state: entry.state,
151
+ since: entry.since,
152
+ turnId: entry.turnId,
153
+ groupId: entry.groupId,
154
+ });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * Emit a `vp_status_snapshot` to the wire. Pulls from `snapshot()`
161
+ * so the wire shape stays consistent with the in-memory table.
162
+ *
163
+ * Wire envelope: `{ type, groupId, statuses }` where `groupId` is
164
+ * `null` when unscoped (frontend uses null to mean "replace the
165
+ * whole table"). See `snapshot()` JSDoc for the scoping contract.
166
+ */
167
+ function broadcastSnapshot({ groupId } = {}) {
168
+ send({
169
+ type: 'vp_status_snapshot',
170
+ groupId: groupId === undefined ? null : groupId,
171
+ statuses: snapshot(groupId),
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Drop an entry — e.g. when a VP is removed from a group. Without
177
+ * this, a deleted VP would stay in the snapshot forever and
178
+ * re-appear on the frontend at every reconnect.
179
+ */
180
+ function forget({ groupId, vpId }) {
181
+ table.delete(keyOf(groupId, vpId));
182
+ }
183
+
184
+ /**
185
+ * Wipe the in-memory table. Called from `resetUnifySession` on the
186
+ * agent side so a forced session reset doesn't leave the broker
187
+ * holding rows for VPs whose engines/inboxes have been cleared. The
188
+ * post-reset `broadcastSnapshot` then emits an empty table, and the
189
+ * frontend's mirror clears in lockstep.
190
+ *
191
+ * Distinct from `__testReset`: this is production code path, named
192
+ * accordingly. `__testReset` stays for tests that mutate broker
193
+ * state across describe-blocks.
194
+ */
195
+ function reset() {
196
+ table.clear();
197
+ }
198
+
199
+ /**
200
+ * Wipe everything (only used by tests; agent runtime keeps the
201
+ * broker alive for the whole process).
202
+ */
203
+ function __testReset() {
204
+ table.clear();
205
+ }
206
+
207
+ return {
208
+ transition,
209
+ settleIdle,
210
+ snapshot,
211
+ broadcastSnapshot,
212
+ forget,
213
+ reset,
214
+ __testReset,
215
+ };
216
+ }
@@ -50,10 +50,28 @@ import {
50
50
  } from './history-compact.js';
51
51
  import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
52
52
  import { parseSeqFromId } from './conversation/persist.js';
53
+ import { createVpStatusBroker } from './vp-status-broker.js';
53
54
 
54
55
  /** @type {import('./session.js').Session | null} */
55
56
  let session = null;
56
57
 
58
+ /**
59
+ * Tracks scoped-dream triggers that are currently inflight, keyed by
60
+ * groupId. Used by `handleUnifyDreamTrigger` to reject any overlapping
61
+ * scoped trigger rather than racing the sink-wrapping logic against
62
+ * itself.
63
+ *
64
+ * Cross-group overlap is rejected (not just same-group): under the
65
+ * existing dream scheduler a second concurrent trigger silently shares
66
+ * the first's inflight promise and dropped its own scope filter. So
67
+ * "B during A's run" doesn't actually produce a separate scoped pass
68
+ * for B — letting B install a second sink wrapper would only mis-stamp
69
+ * A's events with B's groupId. Rejecting B with an explicit error is
70
+ * the honest answer; the user can re-click after A settles.
71
+ * @type {Set<string>}
72
+ */
73
+ const inflightScopedDreamGroups = new Set();
74
+
57
75
  /**
58
76
  * Single in-flight AbortController. A new user message cancels the prior
59
77
  * round (if any). H2.f.2: replaces the per-thread Map.
@@ -78,6 +96,43 @@ let currentAbortCtrl = null;
78
96
  */
79
97
  const turnAbortCtrls = new Map();
80
98
 
99
+ /**
100
+ * Per-VP status broker — the agent-side authority for VP timeline
101
+ * status. Lazy-initialized on first use because `sendUnifyEvent` is
102
+ * declared below; trying to call it at module top-level would crash
103
+ * with a TDZ error during agent boot.
104
+ *
105
+ * Every transition (typing → thinking → streaming → tool → idle) is
106
+ * pushed through `vpStatusBroker.transition(...)`. The broker also
107
+ * owns the `vp_status_snapshot` payload for reconnect.
108
+ *
109
+ * @type {ReturnType<typeof createVpStatusBroker> | null}
110
+ */
111
+ let vpStatusBroker = null;
112
+ function getVpStatusBroker() {
113
+ if (!vpStatusBroker) {
114
+ vpStatusBroker = createVpStatusBroker({
115
+ send: (event) => {
116
+ // The broker emits both `vp_status_changed` and
117
+ // `vp_status_snapshot`. Both ride the standard sendUnifyEvent
118
+ // envelope so the frontend's existing unify_output dispatcher
119
+ // sees them. We stamp groupId/vpId on the envelope for
120
+ // events that target a specific VP so the server's per-client
121
+ // routing (groupId scoping, etc.) works the same way as
122
+ // typing events.
123
+ const env = {};
124
+ if (event && typeof event === 'object') {
125
+ if (event.groupId) env.groupId = event.groupId;
126
+ if (event.vpId) env.vpId = event.vpId;
127
+ if (event.turnId) env.turnId = event.turnId;
128
+ }
129
+ sendUnifyEvent(event, env);
130
+ },
131
+ });
132
+ }
133
+ return vpStatusBroker;
134
+ }
135
+
81
136
  /**
82
137
  * Per-VP inbox + driver + engine pool (group multi-VP delivery).
83
138
  *
@@ -573,6 +628,15 @@ function enqueueForVp(groupId, vpId, envelope) {
573
628
  }, { groupId, vpId, turnId });
574
629
  } catch { /* never crash WS pipeline */ }
575
630
 
631
+ // vp-status: queued, driver not running yet → 'typing'. Emitted
632
+ // alongside `vp_typing_start` so the timeline pane lights up on the
633
+ // same edge as the per-message typing dot.
634
+ try {
635
+ getVpStatusBroker().transition({ groupId, vpId, state: 'typing', turnId });
636
+ } catch (err) {
637
+ console.warn('[Unify] vp-status typing transition failed:', err?.message || err);
638
+ }
639
+
576
640
  ensureDriverRunning(groupId, vpId);
577
641
  }
578
642
 
@@ -864,6 +928,18 @@ export function handleUnifyVpDelete(msg) {
864
928
  const yeaftDir = ctx.CONFIG?.yeaftDir;
865
929
  const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
866
930
  deleteVp(vpId, memoryRoot ? { memoryRoot } : {});
931
+ // vp-status: a deleted VP must not haunt the snapshot. We don't
932
+ // know up front which groups the VP appeared in (the registry's
933
+ // delete already detached it from every group), so sweep every
934
+ // matching entry from the broker table.
935
+ try {
936
+ const broker = getVpStatusBroker();
937
+ for (const row of broker.snapshot()) {
938
+ if (row.vpId === vpId) broker.forget({ groupId: row.groupId, vpId });
939
+ }
940
+ } catch (err) {
941
+ console.warn('[Unify] vp-status forget on delete failed:', err?.message || err);
942
+ }
867
943
  sendVpCrudResult({ op: 'delete', requestId, ok: true, vpId });
868
944
  } catch (err) {
869
945
  sendVpCrudResult({
@@ -1126,9 +1202,23 @@ export function installUnifyRuntimeBridge(s) {
1126
1202
  if (!s) return;
1127
1203
 
1128
1204
  // Forward dream pipeline progress events to the web debug panel.
1205
+ //
1206
+ // Group-id stamping is NO LONGER done here. It used to be: this sink
1207
+ // read a module-level `activeScopedDreamGroupId` that
1208
+ // `handleUnifyDreamTrigger({groupId})` parked before awaiting the
1209
+ // scope-filtered pass. That created a race when two scoped triggers
1210
+ // overlapped (auto-tick during a manual click; or two manual clicks
1211
+ // for different groups): the second handler's `finally` could clear
1212
+ // the module slot while the first run was still emitting events,
1213
+ // dropping the stamp from the tail of the first pass. The new design:
1214
+ // `handleUnifyDreamTrigger` wraps THIS sink for the lifetime of the
1215
+ // trigger to inject `groupId` per-call (see that function below). The
1216
+ // base sink is intentionally a pure passthrough.
1129
1217
  s._dreamProgressSink = (evt) => {
1130
1218
  try {
1131
- sendUnifyEvent({ type: 'dream_progress', ...evt });
1219
+ const out = { type: 'dream_progress', ...evt };
1220
+ const tag = evt && evt.groupId ? { groupId: evt.groupId } : {};
1221
+ sendUnifyEvent(out, tag);
1132
1222
  } catch { /* never let event delivery throw */ }
1133
1223
  };
1134
1224
 
@@ -1165,6 +1255,29 @@ export function installUnifyRuntimeBridge(s) {
1165
1255
  };
1166
1256
  }
1167
1257
 
1258
+ /**
1259
+ * Mid-turn vp-status transitions (text_delta / tool_call / tool_end).
1260
+ * Tolerates `hctx` missing groupId/vpId — pre-707 1:1 chat paths don't
1261
+ * have either; they're tracked as the default broker key but the
1262
+ * frontend ignores rows it doesn't recognize.
1263
+ *
1264
+ * @param {object} hctx
1265
+ * @param {string} state
1266
+ */
1267
+ function maybeTransitionVpStatus(hctx, state) {
1268
+ if (!hctx || !hctx.vpId) return;
1269
+ try {
1270
+ getVpStatusBroker().transition({
1271
+ groupId: hctx.groupId || null,
1272
+ vpId: hctx.vpId,
1273
+ state,
1274
+ turnId: hctx.turnId || null,
1275
+ });
1276
+ } catch (err) {
1277
+ console.warn(`[Unify] vp-status ${state} transition failed:`, err?.message || err);
1278
+ }
1279
+ }
1280
+
1168
1281
  /**
1169
1282
  * Handle a single engine event unwrapped from an `engine_event` envelope.
1170
1283
  * H2.f.2: no longer stamps a threadId on outgoing claude_output frames.
@@ -1187,6 +1300,10 @@ function handleEngineEvent(event, hctx) {
1187
1300
  type: 'assistant',
1188
1301
  message: { content: [{ type: 'text', text: event.text }] },
1189
1302
  }, envelope);
1303
+ // vp-status: first text-delta of a (thinking|tool) phase flips
1304
+ // the row to 'streaming'. transition() is a no-op when already
1305
+ // streaming, so subsequent deltas are cheap.
1306
+ maybeTransitionVpStatus(hctx, 'streaming');
1190
1307
  break;
1191
1308
 
1192
1309
  case 'thinking_delta':
@@ -1220,6 +1337,7 @@ function handleEngineEvent(event, hctx) {
1220
1337
  }],
1221
1338
  },
1222
1339
  }, envelope);
1340
+ maybeTransitionVpStatus(hctx, 'tool');
1223
1341
  break;
1224
1342
 
1225
1343
  case 'tool_start':
@@ -1248,6 +1366,12 @@ function handleEngineEvent(event, hctx) {
1248
1366
  is_error: event.isError || false,
1249
1367
  }],
1250
1368
  }, envelope);
1369
+ // Tool finished. The engine may either (a) emit more text-deltas
1370
+ // before end_turn, or (b) go straight to end_turn. Settle the
1371
+ // row back to 'thinking' — if (a), the next text_delta will flip
1372
+ // it to 'streaming'; if (b), runVpTurn's finally will flip it to
1373
+ // 'idle'. Either way we never strand the row in 'tool'.
1374
+ maybeTransitionVpStatus(hctx, 'thinking');
1251
1375
  break;
1252
1376
 
1253
1377
  case 'turn_start':
@@ -1964,6 +2088,15 @@ async function ensureSessionLoaded() {
1964
2088
  tools: session.status.tools,
1965
2089
  });
1966
2090
  sendGroupSnapshotBroadcast();
2091
+ // vp-status: rebuild frontend status table from authoritative agent
2092
+ // memory. Sent unconditionally so reconnect/refresh paths get the same
2093
+ // bootstrap as first-load (the broker dedup logic makes a redundant
2094
+ // snapshot harmless).
2095
+ try {
2096
+ getVpStatusBroker().broadcastSnapshot();
2097
+ } catch (err) {
2098
+ console.warn('[Unify] vp-status snapshot broadcast failed:', err?.message || err);
2099
+ }
1967
2100
  }
1968
2101
 
1969
2102
  /**
@@ -2008,6 +2141,19 @@ async function runVpTurnWithEscalation(args) {
2008
2141
  { groupId, vpId, turnId },
2009
2142
  );
2010
2143
  } catch { /* never crash WS pipeline */ }
2144
+ // vp-status: when the watchdog escalates, `runVpTurn`'s inner
2145
+ // promise is still dangling (the adapter is ignoring `signal`)
2146
+ // and its outer `finally` won't run until the adapter eventually
2147
+ // returns — which may be never. Settle the broker here so the
2148
+ // row drops to idle in lockstep with the synthetic stop frame.
2149
+ // This is the exact failure mode the watchdog exists for; not
2150
+ // settling here would re-introduce the "stuck on streaming" bug
2151
+ // the whole PR is meant to fix.
2152
+ try {
2153
+ getVpStatusBroker().settleIdle({ groupId, vpId });
2154
+ } catch (err) {
2155
+ console.warn('[Unify] vp-status settleIdle (escalation) failed:', err?.message || err);
2156
+ }
2011
2157
  },
2012
2158
  });
2013
2159
  }
@@ -2099,6 +2245,12 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
2099
2245
 
2100
2246
  // Emit turn_start so frontend can create the message block.
2101
2247
  sendUnifyEvent({ type: 'vp_turn_start', vpId, turnId, groupId }, envelope);
2248
+ // vp-status: LLM call about to start, no text/tool yet → 'thinking'.
2249
+ try {
2250
+ getVpStatusBroker().transition({ groupId, vpId, state: 'thinking', turnId });
2251
+ } catch (err) {
2252
+ console.warn('[Unify] vp-status thinking transition failed:', err?.message || err);
2253
+ }
2102
2254
 
2103
2255
  try {
2104
2256
  const assistantTextParts = [];
@@ -2184,6 +2336,17 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
2184
2336
 
2185
2337
  console.error('[Unify] query error:', err);
2186
2338
 
2339
+ // vp-status: surface a transient `error` state so the row's status
2340
+ // label flips red for the brief window before the outer finally
2341
+ // settles it to idle. Without this, an LLM/tool failure would look
2342
+ // identical to a normal turn end in the timeline — the user has
2343
+ // no way to tell from the row that something went wrong.
2344
+ try {
2345
+ getVpStatusBroker().transition({ groupId, vpId, state: 'error', turnId });
2346
+ } catch (brokerErr) {
2347
+ console.warn('[Unify] vp-status error transition failed:', brokerErr?.message || brokerErr);
2348
+ }
2349
+
2187
2350
  if (isPermissionErrorMsg(err.message)) {
2188
2351
  if (!_permissionDiagnosticSent) {
2189
2352
  _permissionDiagnosticSent = true;
@@ -2212,6 +2375,16 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
2212
2375
  type: 'result',
2213
2376
  result_text: '',
2214
2377
  }, envelope);
2378
+ } finally {
2379
+ // vp-status: guaranteed-settle. Regardless of how the turn exited
2380
+ // (normal completion, AbortError early-return, caught exception),
2381
+ // the row must drop back to 'idle'. Wrapped in its own try so a
2382
+ // broker bug can't mask the original error.
2383
+ try {
2384
+ getVpStatusBroker().settleIdle({ groupId, vpId });
2385
+ } catch (err) {
2386
+ console.warn('[Unify] vp-status settleIdle failed:', err?.message || err);
2387
+ }
2215
2388
  }
2216
2389
  }
2217
2390
 
@@ -2518,6 +2691,46 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2518
2691
  return;
2519
2692
  }
2520
2693
 
2694
+ // Concurrent-trigger guard for scoped runs. Two scoped clicks (same
2695
+ // group or different) overlapping the same inflight pass used to set
2696
+ // the module-level groupId slot, race the sink wrapping, and let the
2697
+ // second `finally` restore the original sink while the first run was
2698
+ // still emitting events. We now refuse any second scoped trigger
2699
+ // while ANY scoped pass is inflight — the scheduler already
2700
+ // short-circuits the underlying run for same-group, and a different
2701
+ // group's filter would have been silently dropped anyway (see
2702
+ // dream-v2/schedule.js inflight reuse), so the user-facing semantics
2703
+ // are unchanged ("you already asked").
2704
+ if (groupId && inflightScopedDreamGroups.size > 0) {
2705
+ sendToServer({
2706
+ type: 'unify_dream_result',
2707
+ ...tag,
2708
+ success: false,
2709
+ error: 'A dream pass is already running.',
2710
+ });
2711
+ return;
2712
+ }
2713
+
2714
+ // Per-call sink wrapper. For scoped runs we install a closure that
2715
+ // injects this trigger's groupId onto top-level events the runner
2716
+ // emits without one (start/merge/done), then delegates to the
2717
+ // original passthrough sink. The wrapper lives only for the lifetime
2718
+ // of this trigger and is restored in `finally`; concurrent calls for
2719
+ // OTHER groupIds chain (last-installed wins) but each restoration
2720
+ // unwinds back to its predecessor.
2721
+ const originalSink = session?._dreamProgressSink;
2722
+ if (groupId && typeof originalSink === 'function') {
2723
+ inflightScopedDreamGroups.add(groupId);
2724
+ session._dreamProgressSink = (evt) => {
2725
+ try {
2726
+ const stamped = evt && evt.groupId
2727
+ ? evt
2728
+ : { ...evt, groupId };
2729
+ originalSink(stamped);
2730
+ } catch { /* never let event delivery throw */ }
2731
+ };
2732
+ }
2733
+
2521
2734
  try {
2522
2735
  sendToServer({
2523
2736
  type: 'unify_dream_status',
@@ -2536,6 +2749,7 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2536
2749
  const targets = Array.isArray(result?.targets) ? result.targets : [];
2537
2750
  const entriesCreated = targets.filter(t => t && t.status === 'done').length;
2538
2751
  const lastDreamAt = result?.startedAt || new Date().toISOString();
2752
+ const success = !result.error && !result.skipped;
2539
2753
 
2540
2754
  // Spread `result` FIRST so derived fields (success, entriesCreated,
2541
2755
  // lastDreamAt) authoritatively shadow anything the runner might grow
@@ -2543,11 +2757,19 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2543
2757
  // { groups, targets, startedAt, error?, skipped? }) but the failure
2544
2758
  // mode of the alternative ordering is silent — review feedback from
2545
2759
  // PR #743.
2760
+ //
2761
+ // This `unify_dream_result` envelope is the SOLE terminal signal for
2762
+ // a dream pass. The chat-store projects it into BOTH `unifyDreamLatest`
2763
+ // (final tally row) AND `unifyDreamEvents` (ring-buffer terminal
2764
+ // marker), so we no longer mirror a synthetic `phase:'result'`
2765
+ // dream_progress event — that mirror used to race the
2766
+ // `unifyDreamLatest` writer and flip the success row back to
2767
+ // 'running' (Critical reviewer finding pre-merge).
2546
2768
  sendToServer({
2547
2769
  type: 'unify_dream_result',
2548
2770
  ...tag,
2549
2771
  ...result,
2550
- success: !result.error && !result.skipped,
2772
+ success,
2551
2773
  entriesCreated,
2552
2774
  lastDreamAt,
2553
2775
  });
@@ -2558,6 +2780,12 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2558
2780
  success: false,
2559
2781
  error: err?.message || String(err),
2560
2782
  });
2783
+ } finally {
2784
+ // Restore the original sink and release the per-group inflight lock.
2785
+ if (groupId && typeof originalSink === 'function') {
2786
+ session._dreamProgressSink = originalSink;
2787
+ inflightScopedDreamGroups.delete(groupId);
2788
+ }
2561
2789
  }
2562
2790
  }
2563
2791
 
@@ -2693,6 +2921,14 @@ export async function handleUnifyLoadHistory(msg) {
2693
2921
  tools: session.status.tools,
2694
2922
  });
2695
2923
  sendGroupSnapshotBroadcast();
2924
+ // vp-status: replay the authoritative table on reconnect so a refreshed
2925
+ // frontend doesn't have to wait for the next transition to learn each
2926
+ // VP's current state.
2927
+ try {
2928
+ getVpStatusBroker().broadcastSnapshot();
2929
+ } catch (err) {
2930
+ console.warn('[Unify] vp-status snapshot broadcast (replay) failed:', err?.message || err);
2931
+ }
2696
2932
 
2697
2933
  // `msg.limit` is the replay-scrollback request from the frontend (UI
2698
2934
  // history pane, not engine context). Semantics changed (2026-05-01):
@@ -2865,6 +3101,16 @@ export async function resetUnifySession() {
2865
3101
  // History-dedup cache is keyed by per-session coordinator msg ids;
2866
3102
  // a fresh session resets the id space, so clear the cache too.
2867
3103
  _persistedUserMsgIds.clear();
3104
+ // vp-status: nuke the broker table too. Drivers above have just
3105
+ // been aborted, so any in-flight `settleIdle` from their outer
3106
+ // `finally` blocks is racing this reset. Clearing here makes the
3107
+ // post-reset `broadcastSnapshot` (further down) emit an empty
3108
+ // table, and the frontend mirror clears in lockstep.
3109
+ try {
3110
+ getVpStatusBroker().reset();
3111
+ } catch (err) {
3112
+ console.warn('[Unify] vp-status broker reset failed:', err?.message || err);
3113
+ }
2868
3114
 
2869
3115
  try {
2870
3116
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -2890,6 +3136,14 @@ export async function resetUnifySession() {
2890
3136
  mcpServers: session.status.mcpServers,
2891
3137
  tools: session.status.tools,
2892
3138
  });
3139
+ // vp-status: after a forced reset the broker table is still live in
3140
+ // memory; broadcast so the frontend can rebuild its mirror without
3141
+ // waiting for the first per-VP transition.
3142
+ try {
3143
+ getVpStatusBroker().broadcastSnapshot();
3144
+ } catch (err) {
3145
+ console.warn('[Unify] vp-status snapshot broadcast (reset) failed:', err?.message || err);
3146
+ }
2893
3147
  } catch (err) {
2894
3148
  console.error('[Unify] Failed to re-initialize session after reset:', err.message);
2895
3149
  }