@yeaft/webchat-agent 0.1.706 → 0.1.707

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.706",
3
+ "version": "0.1.707",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -682,6 +682,11 @@ export class Engine {
682
682
  inboundEnvelope: vpCtx?.inboundEnvelope,
683
683
  taskId: vpCtx?.taskId,
684
684
  taskMembers: vpCtx?.taskMembers,
685
+ // task-707: tool-callable end-turn signal. The engine threads this
686
+ // setter when constructing toolCtx so a tool (e.g. route_forward)
687
+ // can mark "after this batch, end the turn — do NOT call adapter
688
+ // again". Honored at the top of the tool-loop continuation.
689
+ requestEndTurn: vpCtx?.requestEndTurn,
685
690
  // Sub-agent plumbing — Agent tool needs these to spawn a child
686
691
  // Engine that inherits the parent's adapter / stores / toolset.
687
692
  parentEngineDeps: {
@@ -1180,6 +1185,13 @@ export class Engine {
1180
1185
  let currentModel = this.#config.model;
1181
1186
  let cumulativeInputTokens = 0;
1182
1187
  let cumulativeOutputTokens = 0;
1188
+ // task-707: tool-callable end-turn signal. Tools (currently only
1189
+ // `route_forward`) can set this via toolCtx.requestEndTurn(reason)
1190
+ // to break out of the tool-loop after the current batch finishes
1191
+ // — without invoking another adapter.stream(). Used to hand off
1192
+ // control to other VPs cleanly. Reset to null at the top of every
1193
+ // outer-loop iteration so the flag never carries across turns.
1194
+ let endTurnRequested = null;
1183
1195
 
1184
1196
  while (true) {
1185
1197
  turnNumber++;
@@ -1678,7 +1690,28 @@ export class Engine {
1678
1690
  }
1679
1691
 
1680
1692
  // Execute tool calls and feed results back
1681
- const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona, contextWindow: currentContextWindow });
1693
+ // task-707: requestEndTurn is a per-batch closure that lets a tool
1694
+ // signal "end this turn after the current batch — no adapter retry".
1695
+ // We re-create the closure each iteration because endTurnRequested
1696
+ // is a per-query local (reset implicitly at the top of #runQuery).
1697
+ const toolCtx = this.#buildToolContext(signal, {
1698
+ router,
1699
+ senderVpId,
1700
+ inboundEnvelope,
1701
+ taskId,
1702
+ taskMembers,
1703
+ vpPersona,
1704
+ contextWindow: currentContextWindow,
1705
+ requestEndTurn: (reason) => {
1706
+ // First call wins — preserve the kind/reason of the first tool
1707
+ // that asked to end the turn. Late callers (a second
1708
+ // route_forward in the same batch) keep dispatching but don't
1709
+ // overwrite the recorded reason.
1710
+ if (endTurnRequested == null) {
1711
+ endTurnRequested = reason || { kind: 'tool_handoff' };
1712
+ }
1713
+ },
1714
+ });
1682
1715
 
1683
1716
  // task-325a: track whether we aborted mid tool-loop so we can
1684
1717
  // break out of the outer while-loop cleanly once the current
@@ -1822,6 +1855,31 @@ export class Engine {
1822
1855
  conversationMessages.push({ role: 'user', content: reminder });
1823
1856
  }
1824
1857
 
1858
+ // task-707: tool-callable end-turn signal. If a tool in this batch
1859
+ // called toolCtx.requestEndTurn(reason), break out of the outer
1860
+ // while-loop now — DON'T call adapter.stream() again. The
1861
+ // assistant(tool_use)+tool(tool_result) pairs are already in
1862
+ // conversationMessages, so the next user-initiated turn sees a
1863
+ // clean wire shape. Used by `route_forward` to hand off control
1864
+ // to other VPs without continuing to generate.
1865
+ //
1866
+ // Order matters: this runs BEFORE T1 reflection (which would
1867
+ // collapse the arc into a summary that's only valuable across
1868
+ // multi-iteration tool loops) and BEFORE the abortedDuringTools
1869
+ // check (so a clean handoff doesn't get reported as 'aborted').
1870
+ if (endTurnRequested) {
1871
+ const handoffDetail = typeof endTurnRequested === 'object'
1872
+ ? endTurnRequested
1873
+ : { kind: 'tool_handoff', reason: String(endTurnRequested) };
1874
+ yield {
1875
+ type: 'turn_end',
1876
+ turnNumber,
1877
+ stopReason: 'tool_handoff',
1878
+ detail: handoffDetail,
1879
+ };
1880
+ break;
1881
+ }
1882
+
1825
1883
  // PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
1826
1884
  // query() lifetime, the moment queryToolCount crosses
1827
1885
  // TOOL_BATCH_SIZE (13). Generates a markdown reflection over the
@@ -106,6 +106,24 @@ Returns JSON: { ok, dispatched?, error?, detail? }.`,
106
106
  detail: result.detail || null,
107
107
  });
108
108
  }
109
+ // task-707: hand off control. Successful forward means the originating
110
+ // turn should NOT continue generating — the target VPs are now in
111
+ // charge. Signal the engine to break the tool-loop after this batch.
112
+ // The structured payload feeds web-bridge's `group_handoff` UX event
113
+ // so the frontend can render "↪ 已转交给 @vp-x、@vp-y" without
114
+ // re-parsing a string.
115
+ if (typeof ctx.requestEndTurn === 'function') {
116
+ try {
117
+ ctx.requestEndTurn({
118
+ kind: 'route_forward',
119
+ fromVpId: senderVpId,
120
+ dispatched: result.dispatched.slice(),
121
+ broadcast: Boolean(result.report?.broadcast),
122
+ text,
123
+ reason: reason || null,
124
+ });
125
+ } catch { /* never block the tool path on a UX hint */ }
126
+ }
109
127
  return JSON.stringify({
110
128
  ok: true,
111
129
  dispatched: result.dispatched,
@@ -27,6 +27,23 @@
27
27
  * @property {number} [contextWindow] — current model's context window in
28
28
  * tokens (used by ToolRegistry.execute to cap a single tool result at a
29
29
  * fraction of the window so one runaway grep can't blow the wire).
30
+ * @property {(reason?: string|object) => void} [requestEndTurn]
31
+ * — tool-callable signal that the current engine turn should end after
32
+ * this batch of tool calls completes (no follow-up adapter.stream call).
33
+ * Used by `route_forward` to hand off control to other VPs without
34
+ * continuing to generate. The engine wires this when it builds toolCtx
35
+ * and yields a `turn_end` event with `stopReason: 'tool_handoff'` and
36
+ * the supplied reason as `detail`. `reason` may be a structured object
37
+ * `{kind, ...}` so downstream observers (web-bridge) can render UI hints
38
+ * (e.g. "↪ 已转交给 @vp-b") without re-parsing strings.
39
+ * @property {string} [senderVpId] — id of the VP whose turn is currently
40
+ * running. Used by `route_forward` to stamp the forwarded message and
41
+ * by the loop guard to key per-sender throttling.
42
+ * @property {object} [inboundEnvelope] — the envelope that triggered this
43
+ * turn (groupId / msgId / causedBy chain). Threaded into route_forward
44
+ * so causedBy chains extend correctly.
45
+ * @property {object} [router] — per-group router (createRouter() output)
46
+ * for VP-to-VP forwarding. Set by the bridge when running inside a group.
30
47
  */
31
48
 
32
49
  /**
@@ -21,6 +21,7 @@
21
21
  import { join } from 'node:path';
22
22
  import { existsSync } from 'node:fs';
23
23
  import { randomUUID } from 'node:crypto';
24
+ import { Engine } from './engine.js';
24
25
  import { loadSession } from './session.js';
25
26
  import { sendToServer } from '../connection/buffer.js';
26
27
  import ctx from '../context.js';
@@ -57,6 +58,14 @@ let session = null;
57
58
  /**
58
59
  * Single in-flight AbortController. A new user message cancels the prior
59
60
  * round (if any). H2.f.2: replaces the per-thread Map.
61
+ *
62
+ * Note: post-707, group fan-out turns no longer flow through this slot —
63
+ * they each get their own controller in `vpAborts` keyed by
64
+ * `${groupId}::${vpId}`. The `currentAbortCtrl` here is only mutated by
65
+ * 1:1 chat paths, the test seeder, and the session-reset cleanup. Don't
66
+ * reach for it from new group-flow code; selective abort, abort-all,
67
+ * and abort-turn already operate against `vpAborts` correctly.
68
+ *
60
69
  * @type {AbortController | null}
61
70
  */
62
71
  let currentAbortCtrl = null;
@@ -70,6 +79,91 @@ let currentAbortCtrl = null;
70
79
  */
71
80
  const turnAbortCtrls = new Map();
72
81
 
82
+ /**
83
+ * Per-VP inbox + driver + engine pool (group multi-VP delivery).
84
+ *
85
+ * Replaces the pre-707 one-shot `captured[]` array. The coordinator's
86
+ * `deliver(vpId, envelope)` callback now pushes into `vpInboxes`, and a
87
+ * per-VP driver (long-lived async function) drains the inbox one
88
+ * envelope at a time — exactly the shape used by sub-agent runner's
89
+ * `pendingPrompts` + `driveSubAgent`. With this in place:
90
+ * 1. `route_forward` pushes via the same `deliver` → enqueueForVp
91
+ * path the user dispatch uses, so VP-to-VP hand-offs actually run
92
+ * the target VP's driver instead of being dropped.
93
+ * 2. Each VP gets its own Engine (via `vpEngines`) so private state
94
+ * (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
95
+ * `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not collide
96
+ * across concurrent VP turns. Engines are keyed by
97
+ * `${groupId}::${vpId}` rather than vpId alone because Engine
98
+ * cannot serve two concurrent queries safely — even if AMS state
99
+ * partitions correctly by groupKey, the non-group-keyed private
100
+ * state would collide if the same VP ran turns in two groups in
101
+ * parallel.
102
+ */
103
+ /** @type {Map<string, Array<{envelope: object, opts: object}>>} */
104
+ const vpInboxes = new Map();
105
+ /** @type {Map<string, Promise<void>>} */
106
+ const vpDrivers = new Map();
107
+ /** @type {Map<string, import('./engine.js').Engine>} */
108
+ const vpEngines = new Map();
109
+ /** @type {Map<string, AbortController>} */
110
+ const vpAborts = new Map();
111
+ /**
112
+ * Per-group cached coordinator + router. Created on first
113
+ * `handleUnifyGroupChat` for a given groupId; reused across user messages
114
+ * AND across `route_forward` deliveries inside running VP turns (the
115
+ * router is wired into engine ctx; if we recreated coord per turn the
116
+ * route_forward path would deliver into a freshly-created `captured[]`
117
+ * that nobody consumes — exactly the pre-707 bug).
118
+ *
119
+ * Purge sites:
120
+ * - `invalidateGroupContext(groupId)` — called from every group CRUD
121
+ * handler that mutates roster / meta / lifecycle state on disk
122
+ * (rename, update announcement, archive, delete, add/remove member,
123
+ * set default VP).
124
+ * - `handleUnifyGroupChat` — invalidates inline when its own
125
+ * auto-add / default-VP-heal pass mutated the roster.
126
+ * - `resetUnifySession` and `__testResetVpState` clear the whole map.
127
+ *
128
+ * @type {Map<string, { coord: ReturnType<typeof createCoordinator>,
129
+ * router: ReturnType<typeof createRouter>,
130
+ * groupHandle: object }>}
131
+ */
132
+ const groupContexts = new Map();
133
+
134
+ function vpKey(groupId, vpId) {
135
+ return `${groupId}::${vpId}`;
136
+ }
137
+
138
+ /**
139
+ * Drop the cached coordinator + router for a group AND abort/clear any
140
+ * in-flight VP turns belonging to it. Call this from every CRUD handler
141
+ * that mutates the group's roster, meta, or lifecycle state on disk —
142
+ * the cached coord holds a closed `groupHandle`, so without invalidation
143
+ * later route_forward / ingest calls would read zombie meta (stale
144
+ * roster, pre-rename announcement, kicked members still routable).
145
+ *
146
+ * Idempotent — safe to call when no entry exists.
147
+ */
148
+ function invalidateGroupContext(groupId) {
149
+ if (!groupId) return;
150
+ groupContexts.delete(groupId);
151
+ const prefix = `${groupId}::`;
152
+ for (const [k, ctrl] of vpAborts) {
153
+ if (!k.startsWith(prefix)) continue;
154
+ try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
155
+ vpAborts.delete(k);
156
+ }
157
+ for (const [k, inbox] of vpInboxes) {
158
+ if (!k.startsWith(prefix)) continue;
159
+ if (Array.isArray(inbox)) inbox.length = 0;
160
+ }
161
+ // Engines are NOT torn down here on purpose. They hold subordinate
162
+ // state (AMS adjustments) that should survive a meta change and a
163
+ // closed groupHandle — they don't reach the on-disk group meta
164
+ // directly. They *are* dropped on `resetUnifySession`.
165
+ }
166
+
73
167
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
74
168
  const QUERY_TIMEOUT_MS = 120_000;
75
169
 
@@ -122,6 +216,253 @@ function isPermissionErrorMsg(msg) {
122
216
  return lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied');
123
217
  }
124
218
 
219
+ // ============================================================
220
+ // task-707: per-VP inbox + driver helpers (group multi-VP fix)
221
+ // ============================================================
222
+
223
+ /**
224
+ * Get-or-create the per-VP Engine. Each VP owns its own Engine instance
225
+ * so private state (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
226
+ * `#abortReason`, `#adjustRanByGroup`, `#execLog`) doesn't collide when
227
+ * VP-A and VP-B run concurrent turns. All engines share the session's
228
+ * adapter / trace / config / stores so memory recall, conversation
229
+ * persistence, and tool registry remain consistent.
230
+ *
231
+ * @param {string} groupId
232
+ * @param {string} vpId
233
+ * @returns {import('./engine.js').Engine}
234
+ */
235
+ function getOrCreateVpEngine(groupId, vpId) {
236
+ const key = vpKey(groupId, vpId);
237
+ let eng = vpEngines.get(key);
238
+ if (eng) return eng;
239
+ if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
240
+ eng = new Engine({
241
+ adapter: session.adapter,
242
+ trace: session.trace,
243
+ config: session.config,
244
+ conversationStore: session.conversationStore,
245
+ memoryIndex: session.memoryIndex || null,
246
+ amsRegistry: session.amsRegistry,
247
+ toolRegistry: session.toolRegistry,
248
+ skillManager: session.skillManager,
249
+ mcpManager: session.mcpManager,
250
+ yeaftDir: session.yeaftDir,
251
+ });
252
+ vpEngines.set(key, eng);
253
+ return eng;
254
+ }
255
+
256
+ /**
257
+ * Get-or-create the persistent per-group coordinator + router.
258
+ *
259
+ * The coordinator MUST be reused across user turns AND across in-flight
260
+ * tool calls (route_forward) — its `deliver` callback is the only way
261
+ * envelopes reach `vpInboxes`. If we recreated it per `handleUnifyGroupChat`
262
+ * call (the pre-707 design), `route_forward` running mid-turn would
263
+ * deliver into a doomed `captured[]` while the new dispatch ran against
264
+ * a fresh coordinator. The persistent coordinator + module-level inboxes
265
+ * close that gap.
266
+ *
267
+ * Caller is responsible for passing in a freshly-opened groupHandle on
268
+ * first creation; subsequent calls reuse the cached coord.
269
+ *
270
+ * @param {string} groupId
271
+ * @param {object} groupHandle — only used on first creation
272
+ * @returns {{ coord: object, router: object, groupHandle: object }}
273
+ */
274
+ function getOrCreateGroupContext(groupId, groupHandle) {
275
+ let entry = groupContexts.get(groupId);
276
+ if (entry) return entry;
277
+ const coord = createCoordinator(groupHandle, {
278
+ deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
279
+ });
280
+ const router = createRouter({ coordinator: coord });
281
+ entry = { coord, router, groupHandle };
282
+ groupContexts.set(groupId, entry);
283
+ return entry;
284
+ }
285
+
286
+ /**
287
+ * Push an envelope onto a VP's inbox and ensure its driver is running.
288
+ *
289
+ * Side effect: emits `vp_typing_start` immediately (NOT after the driver
290
+ * picks up the envelope). This makes the UX match the user's expectation
291
+ * — the typing indicator turns on the instant the message is queued, so
292
+ * `route_forward` makes the target VP's typing dot light up before the
293
+ * engine even starts that turn.
294
+ *
295
+ * @param {string} groupId
296
+ * @param {string} vpId
297
+ * @param {object} envelope — coordinator envelope `{groupId, taskId, msg, trigger}`
298
+ */
299
+ function enqueueForVp(groupId, vpId, envelope) {
300
+ const key = vpKey(groupId, vpId);
301
+ let inbox = vpInboxes.get(key);
302
+ if (!inbox) {
303
+ inbox = [];
304
+ vpInboxes.set(key, inbox);
305
+ }
306
+ // Mint a turnId now so the typing event the UI sees is paired with
307
+ // the same id the driver uses when it later runs the turn.
308
+ const turnId = `${randomUUID().slice(0, 8)}:${vpId}`;
309
+ inbox.push({ envelope, turnId });
310
+
311
+ // Typing fires on enqueue. The matching `vp_typing_end` is emitted by
312
+ // the driver's runVpTurn `finally` block — every enqueue eventually
313
+ // results in exactly one runVpTurn execution, so the per-VP counter
314
+ // (`web/stores/helpers/vp-typing.js`) stays balanced.
315
+ try {
316
+ sendUnifyEvent({
317
+ type: 'vp_typing_start',
318
+ groupId,
319
+ vpId,
320
+ turnId,
321
+ ts: Date.now(),
322
+ }, { groupId, vpId, turnId });
323
+ } catch { /* never crash WS pipeline */ }
324
+
325
+ ensureDriverRunning(groupId, vpId);
326
+ }
327
+
328
+ /**
329
+ * Spin up a driver loop for a VP if one isn't already running. Idempotent.
330
+ * The driver pulls envelopes from the inbox one at a time and runs each
331
+ * through `runVpTurn`. When the inbox empties, the driver exits and is
332
+ * removed from `vpDrivers`. The next `enqueueForVp` will spawn a fresh
333
+ * driver.
334
+ *
335
+ * Mirrors `sub-agent/runner.js`'s `driveSubAgent` shape: shift → run →
336
+ * loop until empty. No internal sleep — all "wakeups" are driven by
337
+ * external `enqueueForVp` calls.
338
+ */
339
+ function ensureDriverRunning(groupId, vpId) {
340
+ const key = vpKey(groupId, vpId);
341
+ if (vpDrivers.has(key)) return;
342
+ const promise = (async () => {
343
+ while (true) {
344
+ const inbox = vpInboxes.get(key);
345
+ if (!inbox || inbox.length === 0) break;
346
+ const { envelope, turnId } = inbox.shift();
347
+ const vpAbort = new AbortController();
348
+ vpAborts.set(key, vpAbort);
349
+ // Mirror into turnAbortCtrls for the existing per-turn Stop button.
350
+ turnAbortCtrls.set(turnId, vpAbort);
351
+ // Snapshot history at the moment this turn starts. Later turns in
352
+ // the same driver loop see updated history (post-append from the
353
+ // previous turn).
354
+ const baseSnapshot = [...conversationMessages];
355
+ const trigger = envelope?.trigger || 'fallback';
356
+ // Synthesize the prompt. For coordinator-emitted envelopes the
357
+ // text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
358
+ // the legacy fan-out path so the model sees the same surface form
359
+ // it always has.
360
+ const text = envelope?.msg?.text || '';
361
+ const prompt = `@vp-${vpId} ${text}`;
362
+ try {
363
+ await runVpTurn({
364
+ prompt,
365
+ groupId,
366
+ vpId,
367
+ turnId,
368
+ envelope,
369
+ vpAbort,
370
+ baseSnapshot,
371
+ });
372
+ } catch (err) {
373
+ console.warn('[Unify] driveVp: runVpTurn failed', vpId, err?.message || err);
374
+ } finally {
375
+ turnAbortCtrls.delete(turnId);
376
+ // Only clear the entry if it's still ours. A fresh user message
377
+ // can install a new controller for this VP between our abort
378
+ // and our finally (selective-abort pre-pass aborts the OLD
379
+ // controller, then ingest enqueues a NEW envelope which mints
380
+ // a fresh controller). Without this guard we'd drop the new
381
+ // controller and a later abort-all wouldn't see it.
382
+ if (vpAborts.get(key) === vpAbort) vpAborts.delete(key);
383
+ try {
384
+ sendUnifyEvent({
385
+ type: 'vp_typing_end',
386
+ groupId,
387
+ vpId,
388
+ turnId,
389
+ ts: Date.now(),
390
+ }, { groupId, vpId, turnId });
391
+ } catch { /* never crash WS pipeline */ }
392
+ }
393
+ // Emit a group_message event so the persisted message shows up in
394
+ // the UI's group log AT THE POINT the turn actually ran (so a
395
+ // queued envelope doesn't "appear" before the prior turn finished
396
+ // its writes). Trigger reflects coord's classification. The emit
397
+ // happens regardless of whether the turn aborted — the envelope
398
+ // is real (coord persisted it before deliver), so the user log
399
+ // should show it even if the assistant reply was cut short.
400
+ try {
401
+ if (text && envelope?.msg) {
402
+ sendUnifyEvent({
403
+ type: 'group_message',
404
+ groupId,
405
+ vpId,
406
+ speakerVpId: vpId,
407
+ text,
408
+ mentions: Array.isArray(envelope?.msg?.mentions) ? envelope.msg.mentions : [],
409
+ trigger,
410
+ ts: Date.now(),
411
+ }, { groupId, vpId, turnId });
412
+ }
413
+ } catch { /* never crash WS pipeline */ }
414
+ }
415
+ vpDrivers.delete(key);
416
+ // Re-arm guard: the inbox could have been pushed into between the
417
+ // top-of-loop empty check and this delete (synchronous re-entry
418
+ // from a sendUnifyEvent listener, or a microtask scheduled while
419
+ // we were in `finally`). Without this, the new envelope would be
420
+ // stranded — `enqueueForVp` saw `vpDrivers.has(key)` return true
421
+ // because we hadn't deleted yet, then we delete and exit. Self-
422
+ // rearm if there is fresh work.
423
+ const tail = vpInboxes.get(key);
424
+ if (tail && tail.length > 0) ensureDriverRunning(groupId, vpId);
425
+ })();
426
+ vpDrivers.set(key, promise);
427
+ }
428
+
429
+ /**
430
+ * Test-only: drain all currently-queued VP work to completion. Tests use
431
+ * this as a barrier between "scenario triggered" and "now assert state".
432
+ * Production code never calls this — driver lifecycles are tied to
433
+ * inbox emptiness, not external signals.
434
+ */
435
+ export async function __testDrainVpDrivers() {
436
+ // Snapshot the in-flight driver promises and wait. New drivers
437
+ // spawned during the wait (by route_forward inside a turn) get
438
+ // picked up on the next iteration.
439
+ while (vpDrivers.size > 0) {
440
+ const promises = Array.from(vpDrivers.values());
441
+ await Promise.all(promises.map((p) => p.catch(() => {})));
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Test-only: reset all per-VP / per-group caches. Aborts in-flight
447
+ * drivers and drains them before clearing so the next test doesn't see
448
+ * a half-aborted controller writing to a now-cleared map.
449
+ */
450
+ export async function __testResetVpState() {
451
+ for (const ctrl of vpAborts.values()) {
452
+ try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* */ }
453
+ }
454
+ for (const inbox of vpInboxes.values()) {
455
+ if (Array.isArray(inbox)) inbox.length = 0;
456
+ }
457
+ await __testDrainVpDrivers();
458
+ vpInboxes.clear();
459
+ vpDrivers.clear();
460
+ vpEngines.clear();
461
+ vpAborts.clear();
462
+ groupContexts.clear();
463
+ }
464
+
465
+
125
466
  /**
126
467
  * Send a unify_output message carrying claude_output-format data.
127
468
  * Envelope fields: conversationId, groupId, vpId, turnId — the last two
@@ -318,6 +659,7 @@ export function handleUnifyRenameGroup(msg) {
318
659
  try {
319
660
  const yeaftDir = ctx.CONFIG?.yeaftDir;
320
661
  const group = renameGroup(yeaftDir, groupId, name);
662
+ invalidateGroupContext(groupId);
321
663
  sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
322
664
  sendGroupSnapshotBroadcast();
323
665
  } catch (err) {
@@ -357,6 +699,7 @@ export function handleUnifyUpdateGroup(msg) {
357
699
  if (hasAnnouncement) {
358
700
  group = updateGroupAnnouncement(yeaftDir, groupId, patch.announcement);
359
701
  }
702
+ invalidateGroupContext(groupId);
360
703
  sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
361
704
  sendGroupSnapshotBroadcast();
362
705
  } catch (err) {
@@ -370,6 +713,7 @@ export function handleUnifyArchiveGroup(msg) {
370
713
  try {
371
714
  const yeaftDir = ctx.CONFIG?.yeaftDir;
372
715
  const result = archiveGroup(yeaftDir, groupId);
716
+ invalidateGroupContext(groupId);
373
717
  sendGroupCrudResult({ op: 'archive', requestId, ok: true, groupId: result.groupId });
374
718
  sendGroupSnapshotBroadcast();
375
719
  } catch (err) {
@@ -396,6 +740,15 @@ export function handleUnifyDeleteGroup(msg) {
396
740
  } catch (cascadeErr) {
397
741
  console.warn(`[Yeaft] cascade delete for group ${groupId} failed: ${cascadeErr.message}`);
398
742
  }
743
+ // Drop the cached coord/router and abort/clear any in-flight VP
744
+ // turns for the deleted group. Engines for the deleted group are
745
+ // also dropped — unlike rename/announcement updates, the group is
746
+ // gone for good and there's nothing to preserve.
747
+ invalidateGroupContext(groupId);
748
+ const prefix = `${groupId}::`;
749
+ for (const k of Array.from(vpEngines.keys())) {
750
+ if (k.startsWith(prefix)) vpEngines.delete(k);
751
+ }
399
752
  sendGroupCrudResult({
400
753
  op: 'delete',
401
754
  requestId,
@@ -416,6 +769,7 @@ export function handleUnifyAddMember(msg) {
416
769
  try {
417
770
  const yeaftDir = ctx.CONFIG?.yeaftDir;
418
771
  const group = addMember(yeaftDir, groupId, vpId);
772
+ invalidateGroupContext(groupId);
419
773
  sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
420
774
  sendGroupRosterChanged(group);
421
775
  } catch (err) {
@@ -430,6 +784,10 @@ export function handleUnifyRemoveMember(msg) {
430
784
  try {
431
785
  const yeaftDir = ctx.CONFIG?.yeaftDir;
432
786
  const group = removeMember(yeaftDir, groupId, vpId);
787
+ invalidateGroupContext(groupId);
788
+ // Also drop the kicked VP's engine — the next time they're added
789
+ // back they should start with fresh per-VP state.
790
+ vpEngines.delete(vpKey(groupId, vpId));
433
791
  sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
434
792
  sendGroupRosterChanged(group);
435
793
  } catch (err) {
@@ -444,6 +802,7 @@ export function handleUnifySetDefaultVp(msg) {
444
802
  try {
445
803
  const yeaftDir = ctx.CONFIG?.yeaftDir;
446
804
  const group = setGroupDefaultVp(yeaftDir, groupId, vpId);
805
+ invalidateGroupContext(groupId);
447
806
  sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
448
807
  sendGroupRosterChanged(group);
449
808
  } catch (err) {
@@ -561,11 +920,41 @@ function handleEngineEvent(event, hctx) {
561
920
  break;
562
921
 
563
922
  case 'turn_start':
564
- case 'turn_end':
565
923
  case 'stop':
566
924
  // No UI action needed; outer loop sends the final result.
567
925
  break;
568
926
 
927
+ case 'turn_end':
928
+ // When a tool (currently only `route_forward`) signals
929
+ // requestEndTurn, the engine emits turn_end with
930
+ // stopReason='tool_handoff' and a structured `detail` payload.
931
+ // Surface that to the frontend as a `group_handoff` event so the
932
+ // originating VP's bubble can render "↪ 已转交给 @vp-b、@vp-c".
933
+ // Other turn_end variants are ignored (the outer loop handles
934
+ // result/end_turn semantics already).
935
+ //
936
+ // The `version` field is the wire schema version. Today there is
937
+ // only one shape. Future variants (e.g. a second hand-off tool)
938
+ // can bump it without breaking older frontends — they ignore
939
+ // unknown versions.
940
+ if (event.stopReason === 'tool_handoff' && event.detail && typeof event.detail === 'object') {
941
+ const detail = event.detail;
942
+ if (detail.kind === 'route_forward') {
943
+ sendUnifyEvent({
944
+ type: 'group_handoff',
945
+ version: 1,
946
+ kind: 'route_forward',
947
+ fromVpId: detail.fromVpId || hctx.vpId,
948
+ toVpIds: Array.isArray(detail.dispatched) ? detail.dispatched.slice() : [],
949
+ broadcast: Boolean(detail.broadcast),
950
+ text: typeof detail.text === 'string' ? detail.text : '',
951
+ reason: detail.reason || null,
952
+ ts: Date.now(),
953
+ }, envelope);
954
+ }
955
+ }
956
+ break;
957
+
569
958
  case 'usage':
570
959
  sendUnifyEvent({
571
960
  type: 'context_usage',
@@ -774,19 +1163,6 @@ export async function handleUnifyGroupChat(msg) {
774
1163
 
775
1164
  await ensureSessionLoaded();
776
1165
 
777
- // Cancel any prior in-flight dispatch BEFORE we fan out.
778
- if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
779
- try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
780
- }
781
- // Also abort any lingering per-VP controllers from the prior dispatch.
782
- for (const ctrl of turnAbortCtrls.values()) {
783
- try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
784
- }
785
- turnAbortCtrls.clear();
786
-
787
- const dispatchAbortCtrl = new AbortController();
788
- currentAbortCtrl = dispatchAbortCtrl;
789
-
790
1166
  // Open the group; seed grp_default on the fly if absent. Track
791
1167
  // seedFailed separately so a seed crash surfaces a different message
792
1168
  // than a genuinely-missing group.
@@ -825,22 +1201,22 @@ export async function handleUnifyGroupChat(msg) {
825
1201
  }
826
1202
 
827
1203
  // Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
1204
+ let rosterMutated = false;
828
1205
  try {
829
1206
  const meta = groupHandle.getMeta();
830
1207
  const wantsAdd = mentions.filter(
831
1208
  (m) => m && m !== 'all' && !meta.roster.includes(m)
832
1209
  );
833
1210
  if (wantsAdd.length) {
834
- let mutated = false;
835
1211
  for (const vpId of wantsAdd) {
836
1212
  try {
837
1213
  const vp = readVp(vpId);
838
1214
  if (!vp) continue;
839
1215
  addMember(yeaftDir, groupId, vpId);
840
- mutated = true;
1216
+ rosterMutated = true;
841
1217
  } catch { /* skip strangers */ }
842
1218
  }
843
- if (mutated) {
1219
+ if (rosterMutated) {
844
1220
  try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
845
1221
  groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
846
1222
  sendGroupRosterChanged(groupHandle.getMeta());
@@ -853,17 +1229,71 @@ export async function handleUnifyGroupChat(msg) {
853
1229
  try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
854
1230
  groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
855
1231
  sendGroupRosterChanged(groupHandle.getMeta());
1232
+ rosterMutated = true;
856
1233
  } catch { /* best-effort */ }
857
1234
  }
858
1235
  } catch (err) {
859
1236
  console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
860
1237
  }
861
1238
 
862
- const captured = [];
863
- const coord = createCoordinator(groupHandle, {
864
- deliver: (vpId, envelope) => { captured.push({ vpId, envelope }); },
865
- });
1239
+ // task-707: per-group persistent coordinator/router. Created once per
1240
+ // groupId; reused across user messages AND across in-flight tool calls
1241
+ // (route_forward delivers via this same coord). If the roster mutated
1242
+ // we replace the cached coord so it points at the freshly-opened
1243
+ // groupHandle.
1244
+ if (rosterMutated) {
1245
+ groupContexts.delete(groupId);
1246
+ }
1247
+ const groupCtx = getOrCreateGroupContext(groupId, groupHandle);
1248
+ const coord = groupCtx.coord;
1249
+
1250
+ // Selective abort — replace the pre-707 blanket abort that killed
1251
+ // every in-flight turn on every new user message (Bug 2). We peek at
1252
+ // which VPs THIS message will retrigger via the coordinator's
1253
+ // `parseMentions` and only abort those VP's current turns. VPs busy
1254
+ // on unrelated work keep running.
1255
+ //
1256
+ // Intentional limitation: we do NOT walk the route_forward causedBy
1257
+ // chain. If VP-A is mid-turn, route_forwarded to VP-B, and the user
1258
+ // now mentions only @vp-a, we abort VP-A but VP-B keeps generating
1259
+ // even though VP-B's inbound was caused by the now-overridden VP-A
1260
+ // turn. The argument for letting VP-B run: VP-B may already have
1261
+ // useful work in flight (a partial reply); aborting it on a chain
1262
+ // override discards that work for no user-visible benefit. If a
1263
+ // future product decision wants to cancel transitively, walk
1264
+ // `vpInboxes[*].envelope.causedBy` against the selectively-aborted
1265
+ // VP set and fan the abort out.
1266
+ //
1267
+ // Note: this is a best-effort pre-pass. The real dispatch list comes
1268
+ // from `coord.ingest(...).dispatched` below, but at that point we
1269
+ // would already have called deliver() and started new typing events
1270
+ // for the same VPs we're about to abort — racy. Using the pre-pass
1271
+ // result keeps the abort window before any deliver() side effects.
1272
+ try {
1273
+ const meta = groupHandle.getMeta();
1274
+ const willTarget = mentions.includes('all')
1275
+ ? meta.roster.slice()
1276
+ : mentions.filter((m) => meta.roster.includes(m));
1277
+ for (const vpId of willTarget) {
1278
+ const k = vpKey(groupId, vpId);
1279
+ const ctrl = vpAborts.get(k);
1280
+ if (ctrl && !ctrl.signal.aborted) {
1281
+ try { ctrl.abort(); } catch { /* best-effort */ }
1282
+ }
1283
+ // Also drop any queued envelopes for VPs being replaced — the
1284
+ // user's new message supersedes them.
1285
+ const inbox = vpInboxes.get(k);
1286
+ if (inbox && inbox.length > 0) {
1287
+ inbox.length = 0;
1288
+ }
1289
+ }
1290
+ } catch (err) {
1291
+ console.warn('[Unify] unify_group_chat: selective abort pre-pass failed', err?.message || err);
1292
+ }
866
1293
 
1294
+ // Ingest user text. The coordinator persists, applies mention/fanout
1295
+ // rules, and calls deliver() (== enqueueForVp) for each chosen VP —
1296
+ // which both (a) emits vp_typing_start and (b) ensures a driver runs.
867
1297
  let report;
868
1298
  try {
869
1299
  report = coord.ingest({
@@ -883,7 +1313,8 @@ export async function handleUnifyGroupChat(msg) {
883
1313
  }
884
1314
 
885
1315
  const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
886
- if (dispatchedIds.length === 0 && !report?.fallback) {
1316
+ const fallbackId = typeof report?.fallback === 'string' ? report.fallback : null;
1317
+ if (dispatchedIds.length === 0 && !fallbackId) {
887
1318
  // Coordinator chose nobody and provided no fallback — should not happen
888
1319
  // with a healthy roster. Surface the failure explicitly rather than
889
1320
  // silently retrying as a single-VP turn (the legacy fallback masked
@@ -896,73 +1327,15 @@ export async function handleUnifyGroupChat(msg) {
896
1327
  return;
897
1328
  }
898
1329
 
899
- // Mint a dispatch ID for this fan-out each VP gets a unique turnId.
900
- const dispatchId = randomUUID().slice(0, 8);
901
-
902
- // Snapshot conversation history BEFORE fan-out starts. Each VP reads
903
- // from this consistent point; no VP sees another VP's in-flight output.
904
- const baseSnapshot = [...conversationMessages];
905
-
906
- for (const { vpId, envelope } of captured) {
907
- const turnId = `${dispatchId}:${vpId}`;
908
- try {
909
- sendUnifyEvent({
910
- type: 'group_message',
911
- groupId,
912
- vpId,
913
- speakerVpId: vpId,
914
- text,
915
- mentions,
916
- trigger: envelope?.trigger || 'fallback',
917
- ts: Date.now(),
918
- }, { groupId, vpId, turnId });
919
- } catch { /* never crash WS pipeline */ }
920
-
921
- try {
922
- sendUnifyEvent({
923
- type: 'vp_typing_start',
924
- groupId,
925
- vpId,
926
- turnId,
927
- ts: Date.now(),
928
- }, { groupId, vpId, turnId });
929
- } catch { /* never crash WS pipeline */ }
930
- }
931
-
932
- // Per-VP parallel fan-out. Each VP gets its own AbortController
933
- // (stoppable individually via per-VP Stop button) and reads from
934
- // the shared baseSnapshot. On completion (or abort), results are
935
- // atomically appended to conversationMessages.
936
- await Promise.all(captured.map(async ({ vpId }) => {
937
- const turnId = `${dispatchId}:${vpId}`;
938
- const vpAbort = new AbortController();
939
- turnAbortCtrls.set(turnId, vpAbort);
940
-
941
- try {
942
- await runVpTurn({
943
- prompt: `@vp-${vpId} ${text}`,
944
- groupId,
945
- vpId,
946
- turnId,
947
- groupCoordinator: coord,
948
- vpAbort,
949
- baseSnapshot,
950
- });
951
- } catch (err) {
952
- console.warn('[Unify] unify_group_chat: per-vp dispatch failed', vpId, err?.message || err);
953
- } finally {
954
- turnAbortCtrls.delete(turnId);
955
- try {
956
- sendUnifyEvent({
957
- type: 'vp_typing_end',
958
- groupId,
959
- vpId,
960
- turnId,
961
- ts: Date.now(),
962
- }, { groupId, vpId, turnId });
963
- } catch { /* never crash WS pipeline */ }
964
- }
965
- }));
1330
+ // Wait for the drivers initially scheduled by THIS user message to
1331
+ // drain. We pass the dispatched id list (plus any fallback) for
1332
+ // documentation; the wait function itself blocks on every driver in
1333
+ // the group, so route_forward fan-outs the user didn't mention still
1334
+ // get drained before we return.
1335
+ const primaryTargets = dispatchedIds.length > 0
1336
+ ? dispatchedIds.slice()
1337
+ : (fallbackId ? [fallbackId] : []);
1338
+ await waitForVpDrivers(groupId, primaryTargets);
966
1339
 
967
1340
  // Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
968
1341
  // history past 20 turns / 80K tokens. Runs in the background — does
@@ -973,10 +1346,52 @@ export async function handleUnifyGroupChat(msg) {
973
1346
  scheduleCompactAfterTurn(groupId);
974
1347
  }
975
1348
 
1349
+ /**
1350
+ * Wait for the drivers of `primaryTargets` AND any drivers spawned by
1351
+ * their downstream route_forward chains to all complete. We re-poll
1352
+ * because a route_forward inside VP-A's turn enqueues VP-B, which spawns
1353
+ * a new driver while we're waiting for VP-A. Loop terminates when every
1354
+ * group-local driver is idle.
1355
+ *
1356
+ * `primaryTargets` is informational — for filtering we wait on EVERY
1357
+ * driver in the group, since route_forward fan-out targets the user
1358
+ * never directly mentioned still need to drain before this user message
1359
+ * is considered handled.
1360
+ *
1361
+ * Bounded by the per-driver QUERY_TIMEOUT_MS that runVpTurn enforces,
1362
+ * so even a misbehaving model can't pin this forever.
1363
+ */
1364
+ async function waitForVpDrivers(groupId, _primaryTargets) {
1365
+ while (true) {
1366
+ // Snapshot the current set of drivers belonging to this group.
1367
+ const promises = [];
1368
+ const prefix = `${groupId}::`;
1369
+ for (const [key, p] of vpDrivers.entries()) {
1370
+ if (key.startsWith(prefix)) promises.push(p);
1371
+ }
1372
+ if (promises.length === 0) return;
1373
+ await Promise.all(promises.map((p) => p.catch(() => {})));
1374
+ // Re-check: if a route_forward in one of the awaited turns
1375
+ // enqueued more work for another VP, a new driver may have been
1376
+ // started. Loop again. Otherwise exit.
1377
+ }
1378
+ }
1379
+
976
1380
  /**
977
1381
  * Build the per-query VP context for the Engine.
1382
+ *
1383
+ * @param {object} args
1384
+ * @param {string} args.vpId
1385
+ * @param {object} args.groupCoordinator — the persistent coordinator for the
1386
+ * group; used here for `group.getMeta()` (defaultVpId, announcement) and
1387
+ * to bind the per-group router into toolCtx.
1388
+ * @param {string} [args.groupId]
1389
+ * @param {object} [args.envelope] — the inbound coordinator envelope that
1390
+ * triggered this turn. Threaded into toolCtx as `inboundEnvelope` so
1391
+ * `route_forward` can extend `causedBy` chains correctly. Optional only
1392
+ * for pre-707 callers that no longer exist in production.
978
1393
  */
979
- export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
1394
+ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope }) {
980
1395
  // Read the group meta once and reuse for both defaultVpId fallback and
981
1396
  // announcement injection. Each .getMeta() reload reads + parses the
982
1397
  // group.json file, so calling it twice per turn is wasteful — and
@@ -1040,6 +1455,14 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
1040
1455
  // Router build failure is non-fatal.
1041
1456
  }
1042
1457
  }
1458
+ // task-707: thread the inbound envelope into toolCtx so `route_forward`
1459
+ // can stamp `causedBy` chains and the loop guard can key per-sender
1460
+ // throttling against the originating envelope. Safe to omit on a
1461
+ // user-initiated turn — route_forward will fall back to a synthetic
1462
+ // envelope inside router.forward.
1463
+ if (envelope && typeof envelope === 'object') {
1464
+ out.inboundEnvelope = envelope;
1465
+ }
1043
1466
  return out;
1044
1467
  }
1045
1468
 
@@ -1105,15 +1528,23 @@ async function ensureSessionLoaded() {
1105
1528
  * coordinator-bound router, stream events to the frontend, and append the
1106
1529
  * result to the flat conversation history.
1107
1530
  *
1108
- * Private — only `handleUnifyGroupChat` calls this. Each VP-turn gets its
1109
- * own AbortController (`vpAbort`) so it can be stopped individually. The
1110
- * shared `baseSnapshot` is the conversation history at fan-out start — no
1111
- * VP sees another VP's in-flight output. After the turn finishes (or is
1112
- * aborted), the VP's output is atomically appended to `conversationMessages`.
1531
+ * Private — only the per-VP driver in `ensureDriverRunning` calls this.
1532
+ * Each VP-turn gets its own AbortController (`vpAbort`) so it can be
1533
+ * stopped individually. The shared `baseSnapshot` is the conversation
1534
+ * history at fan-out start — no VP sees another VP's in-flight output.
1535
+ * After the turn finishes (or is aborted), the VP's output is atomically
1536
+ * appended to `conversationMessages`.
1113
1537
  *
1114
- * @param {{ prompt: string, groupId: string, vpId: string, turnId: string, groupCoordinator: object, vpAbort: AbortController, baseSnapshot: Array }} args
1538
+ * task-707: takes a coordinator `envelope` rather than the coordinator
1539
+ * itself; the persistent coord lives in `groupContexts[groupId]`. Uses
1540
+ * `getOrCreateVpEngine(groupId, vpId)` so each VP runs against its own
1541
+ * Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
1542
+ * `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
1543
+ * collide when VP-A and VP-B run concurrent turns.
1544
+ *
1545
+ * @param {{ prompt: string, groupId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
1115
1546
  */
1116
- async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAbort, baseSnapshot }) {
1547
+ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
1117
1548
  if (!prompt?.trim()) return;
1118
1549
 
1119
1550
  const envelope = { groupId, vpId, turnId };
@@ -1143,7 +1574,19 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
1143
1574
  const toolCallsAccum = [];
1144
1575
  const toolResultsAccum = [];
1145
1576
 
1146
- const queryOpts = buildVpQueryOpts({ vpId, groupCoordinator, groupId });
1577
+ // task-707: per-VP engine + persistent group coord. The coord is
1578
+ // created in handleUnifyGroupChat via getOrCreateGroupContext and
1579
+ // cached on `groupContexts`; we pull it here so route_forward
1580
+ // (router built from this same coord) lands envelopes back on the
1581
+ // right inbox set.
1582
+ const groupCtx = groupContexts.get(groupId);
1583
+ const groupCoordinator = groupCtx?.coord || null;
1584
+ const queryOpts = buildVpQueryOpts({
1585
+ vpId,
1586
+ groupCoordinator,
1587
+ groupId,
1588
+ envelope: inboundEnvelope,
1589
+ });
1147
1590
  const handlerCtx = {
1148
1591
  assistantTextParts,
1149
1592
  toolCallsAccum,
@@ -1160,7 +1603,8 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
1160
1603
  const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
1161
1604
  messageTokenBudget: session?.config?.messageTokenBudget,
1162
1605
  });
1163
- for await (const event of session.engine.query({
1606
+ const vpEngine = getOrCreateVpEngine(groupId, vpId);
1607
+ for await (const event of vpEngine.query({
1164
1608
  prompt,
1165
1609
  messages: trimmedMessages,
1166
1610
  signal: vpAbort.signal,
@@ -1413,6 +1857,28 @@ async function runCompactNow(groupId) {
1413
1857
  }
1414
1858
  }
1415
1859
 
1860
+ /**
1861
+ * Abort every in-flight VP turn and clear all queued envelopes across
1862
+ * every group. Shared by `handleUnifyAbortThread` and
1863
+ * `handleUnifyAbortAll` — both have the same "stop everything" intent
1864
+ * after the H2.f.2 collapse to single-conversation. Pushes
1865
+ * `vp:<key>` strings into the supplied `aborted` array for the
1866
+ * unify_aborted event.
1867
+ *
1868
+ * @param {string[]} aborted — output array, mutated in place
1869
+ */
1870
+ function abortAllVpRuntime(aborted) {
1871
+ for (const [key, ctrl] of vpAborts) {
1872
+ try {
1873
+ if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(`vp:${key}`); }
1874
+ } catch { /* best-effort */ }
1875
+ }
1876
+ vpAborts.clear();
1877
+ for (const inbox of vpInboxes.values()) {
1878
+ if (Array.isArray(inbox)) inbox.length = 0;
1879
+ }
1880
+ }
1881
+
1416
1882
  /**
1417
1883
  * H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
1418
1884
  * `threadId` parameter; the new version aborts the single in-flight
@@ -1433,6 +1899,7 @@ export function handleUnifyAbortThread(_msg = {}) {
1433
1899
  try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
1434
1900
  }
1435
1901
  turnAbortCtrls.clear();
1902
+ abortAllVpRuntime(aborted);
1436
1903
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
1437
1904
  return { aborted, all: false };
1438
1905
  }
@@ -1452,6 +1919,7 @@ export function handleUnifyAbortAll() {
1452
1919
  try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
1453
1920
  }
1454
1921
  turnAbortCtrls.clear();
1922
+ abortAllVpRuntime(aborted);
1455
1923
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
1456
1924
  return { aborted, all: true };
1457
1925
  }
@@ -1803,6 +2271,22 @@ export async function resetUnifySession() {
1803
2271
  }
1804
2272
  unifyConversationId = null;
1805
2273
  conversationMessages = [];
2274
+ // Re-arm the permission warning. The user might have fixed the
2275
+ // ~/.yeaft/ permissions in the interim and is now restarting the
2276
+ // session — they should see the diagnostic again if it still fails.
2277
+ _permissionDiagnosticSent = false;
2278
+ // Drop all per-VP / per-group transient state when the session is
2279
+ // replaced. Drivers may still be running with a stale engine
2280
+ // reference; abort them so they exit cleanly. The new session gets
2281
+ // fresh inboxes / engines / coords on first dispatch.
2282
+ for (const [, ctrl] of vpAborts) {
2283
+ try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
2284
+ }
2285
+ vpAborts.clear();
2286
+ vpInboxes.clear();
2287
+ vpDrivers.clear();
2288
+ vpEngines.clear();
2289
+ groupContexts.clear();
1806
2290
 
1807
2291
  try {
1808
2292
  const yeaftDir = ctx.CONFIG?.yeaftDir;