@yeaft/webchat-agent 0.1.706 → 0.1.709

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -326,7 +326,23 @@ export async function handleMessage(msg) {
326
326
  }
327
327
 
328
328
  case 'update_llm_config': {
329
+ // Capture the user's intent BEFORE updateLlmConfig — the return
330
+ // envelope ALWAYS populates `language` (falls back to 'en'), so
331
+ // gating on the *output* would broadcast on every provider /
332
+ // primaryModel / fastModel save, not just locale flips. Gate on
333
+ // the *input* `language` field instead.
334
+ const incomingLanguage = typeof msg.config?.language === 'string' && msg.config.language
335
+ ? msg.config.language
336
+ : null;
329
337
  const result = updateLlmConfig(msg.config || {}, ctx.CONFIG?.yeaftDir);
338
+ // task-708: live locale propagation. When the user flips the UI
339
+ // language dropdown, push the new value into every cached Engine
340
+ // (per-VP pool + 1:1 chat session.engine) so the very next turn
341
+ // renders the system prompt in the chosen language without the
342
+ // user reloading the session.
343
+ if (!result.error && incomingLanguage) {
344
+ broadcastLanguageChange(result.language);
345
+ }
330
346
  sendToServer({ type: 'llm_config_updated', ...result });
331
347
  break;
332
348
  }
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.709",
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
@@ -366,6 +366,22 @@ export class Engine {
366
366
  return !!this.#currentAbortCtrl && !this.#currentAbortCtrl.signal.aborted;
367
367
  }
368
368
 
369
+ /**
370
+ * Mutate the engine's effective language at runtime. The next call to
371
+ * #buildSystemPrompt reads this.#config.language live, so the very next
372
+ * turn renders in the new language without reconstructing the engine.
373
+ *
374
+ * Used by the live-locale broadcast path: when the user flips the UI
375
+ * language dropdown, web → server → message-router calls
376
+ * broadcastLanguageChange(lang) (web-bridge.js) which fans out to every
377
+ * Engine in the per-VP pool plus the 1:1-chat session engine.
378
+ *
379
+ * @param {string} lang — 'en' | 'zh'
380
+ */
381
+ setLanguage(lang) {
382
+ if (typeof lang !== 'string' || !lang) return;
383
+ this.#config.language = lang;
384
+ }
369
385
 
370
386
  /**
371
387
  * Unregister a tool.
@@ -682,6 +698,11 @@ export class Engine {
682
698
  inboundEnvelope: vpCtx?.inboundEnvelope,
683
699
  taskId: vpCtx?.taskId,
684
700
  taskMembers: vpCtx?.taskMembers,
701
+ // task-707: tool-callable end-turn signal. The engine threads this
702
+ // setter when constructing toolCtx so a tool (e.g. route_forward)
703
+ // can mark "after this batch, end the turn — do NOT call adapter
704
+ // again". Honored at the top of the tool-loop continuation.
705
+ requestEndTurn: vpCtx?.requestEndTurn,
685
706
  // Sub-agent plumbing — Agent tool needs these to spawn a child
686
707
  // Engine that inherits the parent's adapter / stores / toolset.
687
708
  parentEngineDeps: {
@@ -1180,6 +1201,13 @@ export class Engine {
1180
1201
  let currentModel = this.#config.model;
1181
1202
  let cumulativeInputTokens = 0;
1182
1203
  let cumulativeOutputTokens = 0;
1204
+ // task-707: tool-callable end-turn signal. Tools (currently only
1205
+ // `route_forward`) can set this via toolCtx.requestEndTurn(reason)
1206
+ // to break out of the tool-loop after the current batch finishes
1207
+ // — without invoking another adapter.stream(). Used to hand off
1208
+ // control to other VPs cleanly. Reset to null at the top of every
1209
+ // outer-loop iteration so the flag never carries across turns.
1210
+ let endTurnRequested = null;
1183
1211
 
1184
1212
  while (true) {
1185
1213
  turnNumber++;
@@ -1678,7 +1706,28 @@ export class Engine {
1678
1706
  }
1679
1707
 
1680
1708
  // Execute tool calls and feed results back
1681
- const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona, contextWindow: currentContextWindow });
1709
+ // task-707: requestEndTurn is a per-batch closure that lets a tool
1710
+ // signal "end this turn after the current batch — no adapter retry".
1711
+ // We re-create the closure each iteration because endTurnRequested
1712
+ // is a per-query local (reset implicitly at the top of #runQuery).
1713
+ const toolCtx = this.#buildToolContext(signal, {
1714
+ router,
1715
+ senderVpId,
1716
+ inboundEnvelope,
1717
+ taskId,
1718
+ taskMembers,
1719
+ vpPersona,
1720
+ contextWindow: currentContextWindow,
1721
+ requestEndTurn: (reason) => {
1722
+ // First call wins — preserve the kind/reason of the first tool
1723
+ // that asked to end the turn. Late callers (a second
1724
+ // route_forward in the same batch) keep dispatching but don't
1725
+ // overwrite the recorded reason.
1726
+ if (endTurnRequested == null) {
1727
+ endTurnRequested = reason || { kind: 'tool_handoff' };
1728
+ }
1729
+ },
1730
+ });
1682
1731
 
1683
1732
  // task-325a: track whether we aborted mid tool-loop so we can
1684
1733
  // break out of the outer while-loop cleanly once the current
@@ -1822,6 +1871,31 @@ export class Engine {
1822
1871
  conversationMessages.push({ role: 'user', content: reminder });
1823
1872
  }
1824
1873
 
1874
+ // task-707: tool-callable end-turn signal. If a tool in this batch
1875
+ // called toolCtx.requestEndTurn(reason), break out of the outer
1876
+ // while-loop now — DON'T call adapter.stream() again. The
1877
+ // assistant(tool_use)+tool(tool_result) pairs are already in
1878
+ // conversationMessages, so the next user-initiated turn sees a
1879
+ // clean wire shape. Used by `route_forward` to hand off control
1880
+ // to other VPs without continuing to generate.
1881
+ //
1882
+ // Order matters: this runs BEFORE T1 reflection (which would
1883
+ // collapse the arc into a summary that's only valuable across
1884
+ // multi-iteration tool loops) and BEFORE the abortedDuringTools
1885
+ // check (so a clean handoff doesn't get reported as 'aborted').
1886
+ if (endTurnRequested) {
1887
+ const handoffDetail = typeof endTurnRequested === 'object'
1888
+ ? endTurnRequested
1889
+ : { kind: 'tool_handoff', reason: String(endTurnRequested) };
1890
+ yield {
1891
+ type: 'turn_end',
1892
+ turnNumber,
1893
+ stopReason: 'tool_handoff',
1894
+ detail: handoffDetail,
1895
+ };
1896
+ break;
1897
+ }
1898
+
1825
1899
  // PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
1826
1900
  // query() lifetime, the moment queryToolCount crosses
1827
1901
  // 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,115 @@ 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
+
167
+ /**
168
+ * Live-locale broadcast: push a new language onto every Engine instance
169
+ * the agent currently holds.
170
+ *
171
+ * Called from `agent/connection/message-router.js` after `update_llm_config`
172
+ * persists `language` to ~/.yeaft/config.json. Without this, the per-VP
173
+ * engine pool (constructed once per VP and cached) keeps serving its
174
+ * old language until the session is reloaded — that's the bug fix from
175
+ * task-708 group-locale-sync. The 1:1-chat session.engine is also
176
+ * updated so Chat-mode prompts pick up the new language on the very
177
+ * next turn.
178
+ *
179
+ * No-op when `language` is falsy.
180
+ *
181
+ * @param {string} language — 'en' | 'zh'
182
+ */
183
+ export function broadcastLanguageChange(language) {
184
+ if (!language) return;
185
+ for (const eng of vpEngines.values()) {
186
+ try { eng.setLanguage?.(language); } catch { /* best-effort */ }
187
+ }
188
+ try { session?.engine?.setLanguage?.(language); } catch { /* best-effort */ }
189
+ }
190
+
73
191
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
74
192
  const QUERY_TIMEOUT_MS = 120_000;
75
193
 
@@ -122,6 +240,253 @@ function isPermissionErrorMsg(msg) {
122
240
  return lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied');
123
241
  }
124
242
 
243
+ // ============================================================
244
+ // task-707: per-VP inbox + driver helpers (group multi-VP fix)
245
+ // ============================================================
246
+
247
+ /**
248
+ * Get-or-create the per-VP Engine. Each VP owns its own Engine instance
249
+ * so private state (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
250
+ * `#abortReason`, `#adjustRanByGroup`, `#execLog`) doesn't collide when
251
+ * VP-A and VP-B run concurrent turns. All engines share the session's
252
+ * adapter / trace / config / stores so memory recall, conversation
253
+ * persistence, and tool registry remain consistent.
254
+ *
255
+ * @param {string} groupId
256
+ * @param {string} vpId
257
+ * @returns {import('./engine.js').Engine}
258
+ */
259
+ function getOrCreateVpEngine(groupId, vpId) {
260
+ const key = vpKey(groupId, vpId);
261
+ let eng = vpEngines.get(key);
262
+ if (eng) return eng;
263
+ if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
264
+ eng = new Engine({
265
+ adapter: session.adapter,
266
+ trace: session.trace,
267
+ config: session.config,
268
+ conversationStore: session.conversationStore,
269
+ memoryIndex: session.memoryIndex || null,
270
+ amsRegistry: session.amsRegistry,
271
+ toolRegistry: session.toolRegistry,
272
+ skillManager: session.skillManager,
273
+ mcpManager: session.mcpManager,
274
+ yeaftDir: session.yeaftDir,
275
+ });
276
+ vpEngines.set(key, eng);
277
+ return eng;
278
+ }
279
+
280
+ /**
281
+ * Get-or-create the persistent per-group coordinator + router.
282
+ *
283
+ * The coordinator MUST be reused across user turns AND across in-flight
284
+ * tool calls (route_forward) — its `deliver` callback is the only way
285
+ * envelopes reach `vpInboxes`. If we recreated it per `handleUnifyGroupChat`
286
+ * call (the pre-707 design), `route_forward` running mid-turn would
287
+ * deliver into a doomed `captured[]` while the new dispatch ran against
288
+ * a fresh coordinator. The persistent coordinator + module-level inboxes
289
+ * close that gap.
290
+ *
291
+ * Caller is responsible for passing in a freshly-opened groupHandle on
292
+ * first creation; subsequent calls reuse the cached coord.
293
+ *
294
+ * @param {string} groupId
295
+ * @param {object} groupHandle — only used on first creation
296
+ * @returns {{ coord: object, router: object, groupHandle: object }}
297
+ */
298
+ function getOrCreateGroupContext(groupId, groupHandle) {
299
+ let entry = groupContexts.get(groupId);
300
+ if (entry) return entry;
301
+ const coord = createCoordinator(groupHandle, {
302
+ deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
303
+ });
304
+ const router = createRouter({ coordinator: coord });
305
+ entry = { coord, router, groupHandle };
306
+ groupContexts.set(groupId, entry);
307
+ return entry;
308
+ }
309
+
310
+ /**
311
+ * Push an envelope onto a VP's inbox and ensure its driver is running.
312
+ *
313
+ * Side effect: emits `vp_typing_start` immediately (NOT after the driver
314
+ * picks up the envelope). This makes the UX match the user's expectation
315
+ * — the typing indicator turns on the instant the message is queued, so
316
+ * `route_forward` makes the target VP's typing dot light up before the
317
+ * engine even starts that turn.
318
+ *
319
+ * @param {string} groupId
320
+ * @param {string} vpId
321
+ * @param {object} envelope — coordinator envelope `{groupId, taskId, msg, trigger}`
322
+ */
323
+ function enqueueForVp(groupId, vpId, envelope) {
324
+ const key = vpKey(groupId, vpId);
325
+ let inbox = vpInboxes.get(key);
326
+ if (!inbox) {
327
+ inbox = [];
328
+ vpInboxes.set(key, inbox);
329
+ }
330
+ // Mint a turnId now so the typing event the UI sees is paired with
331
+ // the same id the driver uses when it later runs the turn.
332
+ const turnId = `${randomUUID().slice(0, 8)}:${vpId}`;
333
+ inbox.push({ envelope, turnId });
334
+
335
+ // Typing fires on enqueue. The matching `vp_typing_end` is emitted by
336
+ // the driver's runVpTurn `finally` block — every enqueue eventually
337
+ // results in exactly one runVpTurn execution, so the per-VP counter
338
+ // (`web/stores/helpers/vp-typing.js`) stays balanced.
339
+ try {
340
+ sendUnifyEvent({
341
+ type: 'vp_typing_start',
342
+ groupId,
343
+ vpId,
344
+ turnId,
345
+ ts: Date.now(),
346
+ }, { groupId, vpId, turnId });
347
+ } catch { /* never crash WS pipeline */ }
348
+
349
+ ensureDriverRunning(groupId, vpId);
350
+ }
351
+
352
+ /**
353
+ * Spin up a driver loop for a VP if one isn't already running. Idempotent.
354
+ * The driver pulls envelopes from the inbox one at a time and runs each
355
+ * through `runVpTurn`. When the inbox empties, the driver exits and is
356
+ * removed from `vpDrivers`. The next `enqueueForVp` will spawn a fresh
357
+ * driver.
358
+ *
359
+ * Mirrors `sub-agent/runner.js`'s `driveSubAgent` shape: shift → run →
360
+ * loop until empty. No internal sleep — all "wakeups" are driven by
361
+ * external `enqueueForVp` calls.
362
+ */
363
+ function ensureDriverRunning(groupId, vpId) {
364
+ const key = vpKey(groupId, vpId);
365
+ if (vpDrivers.has(key)) return;
366
+ const promise = (async () => {
367
+ while (true) {
368
+ const inbox = vpInboxes.get(key);
369
+ if (!inbox || inbox.length === 0) break;
370
+ const { envelope, turnId } = inbox.shift();
371
+ const vpAbort = new AbortController();
372
+ vpAborts.set(key, vpAbort);
373
+ // Mirror into turnAbortCtrls for the existing per-turn Stop button.
374
+ turnAbortCtrls.set(turnId, vpAbort);
375
+ // Snapshot history at the moment this turn starts. Later turns in
376
+ // the same driver loop see updated history (post-append from the
377
+ // previous turn).
378
+ const baseSnapshot = [...conversationMessages];
379
+ const trigger = envelope?.trigger || 'fallback';
380
+ // Synthesize the prompt. For coordinator-emitted envelopes the
381
+ // text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
382
+ // the legacy fan-out path so the model sees the same surface form
383
+ // it always has.
384
+ const text = envelope?.msg?.text || '';
385
+ const prompt = `@vp-${vpId} ${text}`;
386
+ try {
387
+ await runVpTurn({
388
+ prompt,
389
+ groupId,
390
+ vpId,
391
+ turnId,
392
+ envelope,
393
+ vpAbort,
394
+ baseSnapshot,
395
+ });
396
+ } catch (err) {
397
+ console.warn('[Unify] driveVp: runVpTurn failed', vpId, err?.message || err);
398
+ } finally {
399
+ turnAbortCtrls.delete(turnId);
400
+ // Only clear the entry if it's still ours. A fresh user message
401
+ // can install a new controller for this VP between our abort
402
+ // and our finally (selective-abort pre-pass aborts the OLD
403
+ // controller, then ingest enqueues a NEW envelope which mints
404
+ // a fresh controller). Without this guard we'd drop the new
405
+ // controller and a later abort-all wouldn't see it.
406
+ if (vpAborts.get(key) === vpAbort) vpAborts.delete(key);
407
+ try {
408
+ sendUnifyEvent({
409
+ type: 'vp_typing_end',
410
+ groupId,
411
+ vpId,
412
+ turnId,
413
+ ts: Date.now(),
414
+ }, { groupId, vpId, turnId });
415
+ } catch { /* never crash WS pipeline */ }
416
+ }
417
+ // Emit a group_message event so the persisted message shows up in
418
+ // the UI's group log AT THE POINT the turn actually ran (so a
419
+ // queued envelope doesn't "appear" before the prior turn finished
420
+ // its writes). Trigger reflects coord's classification. The emit
421
+ // happens regardless of whether the turn aborted — the envelope
422
+ // is real (coord persisted it before deliver), so the user log
423
+ // should show it even if the assistant reply was cut short.
424
+ try {
425
+ if (text && envelope?.msg) {
426
+ sendUnifyEvent({
427
+ type: 'group_message',
428
+ groupId,
429
+ vpId,
430
+ speakerVpId: vpId,
431
+ text,
432
+ mentions: Array.isArray(envelope?.msg?.mentions) ? envelope.msg.mentions : [],
433
+ trigger,
434
+ ts: Date.now(),
435
+ }, { groupId, vpId, turnId });
436
+ }
437
+ } catch { /* never crash WS pipeline */ }
438
+ }
439
+ vpDrivers.delete(key);
440
+ // Re-arm guard: the inbox could have been pushed into between the
441
+ // top-of-loop empty check and this delete (synchronous re-entry
442
+ // from a sendUnifyEvent listener, or a microtask scheduled while
443
+ // we were in `finally`). Without this, the new envelope would be
444
+ // stranded — `enqueueForVp` saw `vpDrivers.has(key)` return true
445
+ // because we hadn't deleted yet, then we delete and exit. Self-
446
+ // rearm if there is fresh work.
447
+ const tail = vpInboxes.get(key);
448
+ if (tail && tail.length > 0) ensureDriverRunning(groupId, vpId);
449
+ })();
450
+ vpDrivers.set(key, promise);
451
+ }
452
+
453
+ /**
454
+ * Test-only: drain all currently-queued VP work to completion. Tests use
455
+ * this as a barrier between "scenario triggered" and "now assert state".
456
+ * Production code never calls this — driver lifecycles are tied to
457
+ * inbox emptiness, not external signals.
458
+ */
459
+ export async function __testDrainVpDrivers() {
460
+ // Snapshot the in-flight driver promises and wait. New drivers
461
+ // spawned during the wait (by route_forward inside a turn) get
462
+ // picked up on the next iteration.
463
+ while (vpDrivers.size > 0) {
464
+ const promises = Array.from(vpDrivers.values());
465
+ await Promise.all(promises.map((p) => p.catch(() => {})));
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Test-only: reset all per-VP / per-group caches. Aborts in-flight
471
+ * drivers and drains them before clearing so the next test doesn't see
472
+ * a half-aborted controller writing to a now-cleared map.
473
+ */
474
+ export async function __testResetVpState() {
475
+ for (const ctrl of vpAborts.values()) {
476
+ try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* */ }
477
+ }
478
+ for (const inbox of vpInboxes.values()) {
479
+ if (Array.isArray(inbox)) inbox.length = 0;
480
+ }
481
+ await __testDrainVpDrivers();
482
+ vpInboxes.clear();
483
+ vpDrivers.clear();
484
+ vpEngines.clear();
485
+ vpAborts.clear();
486
+ groupContexts.clear();
487
+ }
488
+
489
+
125
490
  /**
126
491
  * Send a unify_output message carrying claude_output-format data.
127
492
  * Envelope fields: conversationId, groupId, vpId, turnId — the last two
@@ -318,6 +683,7 @@ export function handleUnifyRenameGroup(msg) {
318
683
  try {
319
684
  const yeaftDir = ctx.CONFIG?.yeaftDir;
320
685
  const group = renameGroup(yeaftDir, groupId, name);
686
+ invalidateGroupContext(groupId);
321
687
  sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
322
688
  sendGroupSnapshotBroadcast();
323
689
  } catch (err) {
@@ -357,6 +723,7 @@ export function handleUnifyUpdateGroup(msg) {
357
723
  if (hasAnnouncement) {
358
724
  group = updateGroupAnnouncement(yeaftDir, groupId, patch.announcement);
359
725
  }
726
+ invalidateGroupContext(groupId);
360
727
  sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
361
728
  sendGroupSnapshotBroadcast();
362
729
  } catch (err) {
@@ -370,6 +737,7 @@ export function handleUnifyArchiveGroup(msg) {
370
737
  try {
371
738
  const yeaftDir = ctx.CONFIG?.yeaftDir;
372
739
  const result = archiveGroup(yeaftDir, groupId);
740
+ invalidateGroupContext(groupId);
373
741
  sendGroupCrudResult({ op: 'archive', requestId, ok: true, groupId: result.groupId });
374
742
  sendGroupSnapshotBroadcast();
375
743
  } catch (err) {
@@ -396,6 +764,15 @@ export function handleUnifyDeleteGroup(msg) {
396
764
  } catch (cascadeErr) {
397
765
  console.warn(`[Yeaft] cascade delete for group ${groupId} failed: ${cascadeErr.message}`);
398
766
  }
767
+ // Drop the cached coord/router and abort/clear any in-flight VP
768
+ // turns for the deleted group. Engines for the deleted group are
769
+ // also dropped — unlike rename/announcement updates, the group is
770
+ // gone for good and there's nothing to preserve.
771
+ invalidateGroupContext(groupId);
772
+ const prefix = `${groupId}::`;
773
+ for (const k of Array.from(vpEngines.keys())) {
774
+ if (k.startsWith(prefix)) vpEngines.delete(k);
775
+ }
399
776
  sendGroupCrudResult({
400
777
  op: 'delete',
401
778
  requestId,
@@ -416,6 +793,7 @@ export function handleUnifyAddMember(msg) {
416
793
  try {
417
794
  const yeaftDir = ctx.CONFIG?.yeaftDir;
418
795
  const group = addMember(yeaftDir, groupId, vpId);
796
+ invalidateGroupContext(groupId);
419
797
  sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
420
798
  sendGroupRosterChanged(group);
421
799
  } catch (err) {
@@ -430,6 +808,10 @@ export function handleUnifyRemoveMember(msg) {
430
808
  try {
431
809
  const yeaftDir = ctx.CONFIG?.yeaftDir;
432
810
  const group = removeMember(yeaftDir, groupId, vpId);
811
+ invalidateGroupContext(groupId);
812
+ // Also drop the kicked VP's engine — the next time they're added
813
+ // back they should start with fresh per-VP state.
814
+ vpEngines.delete(vpKey(groupId, vpId));
433
815
  sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
434
816
  sendGroupRosterChanged(group);
435
817
  } catch (err) {
@@ -444,6 +826,7 @@ export function handleUnifySetDefaultVp(msg) {
444
826
  try {
445
827
  const yeaftDir = ctx.CONFIG?.yeaftDir;
446
828
  const group = setGroupDefaultVp(yeaftDir, groupId, vpId);
829
+ invalidateGroupContext(groupId);
447
830
  sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
448
831
  sendGroupRosterChanged(group);
449
832
  } catch (err) {
@@ -561,11 +944,41 @@ function handleEngineEvent(event, hctx) {
561
944
  break;
562
945
 
563
946
  case 'turn_start':
564
- case 'turn_end':
565
947
  case 'stop':
566
948
  // No UI action needed; outer loop sends the final result.
567
949
  break;
568
950
 
951
+ case 'turn_end':
952
+ // When a tool (currently only `route_forward`) signals
953
+ // requestEndTurn, the engine emits turn_end with
954
+ // stopReason='tool_handoff' and a structured `detail` payload.
955
+ // Surface that to the frontend as a `group_handoff` event so the
956
+ // originating VP's bubble can render "↪ 已转交给 @vp-b、@vp-c".
957
+ // Other turn_end variants are ignored (the outer loop handles
958
+ // result/end_turn semantics already).
959
+ //
960
+ // The `version` field is the wire schema version. Today there is
961
+ // only one shape. Future variants (e.g. a second hand-off tool)
962
+ // can bump it without breaking older frontends — they ignore
963
+ // unknown versions.
964
+ if (event.stopReason === 'tool_handoff' && event.detail && typeof event.detail === 'object') {
965
+ const detail = event.detail;
966
+ if (detail.kind === 'route_forward') {
967
+ sendUnifyEvent({
968
+ type: 'group_handoff',
969
+ version: 1,
970
+ kind: 'route_forward',
971
+ fromVpId: detail.fromVpId || hctx.vpId,
972
+ toVpIds: Array.isArray(detail.dispatched) ? detail.dispatched.slice() : [],
973
+ broadcast: Boolean(detail.broadcast),
974
+ text: typeof detail.text === 'string' ? detail.text : '',
975
+ reason: detail.reason || null,
976
+ ts: Date.now(),
977
+ }, envelope);
978
+ }
979
+ }
980
+ break;
981
+
569
982
  case 'usage':
570
983
  sendUnifyEvent({
571
984
  type: 'context_usage',
@@ -774,19 +1187,6 @@ export async function handleUnifyGroupChat(msg) {
774
1187
 
775
1188
  await ensureSessionLoaded();
776
1189
 
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
1190
  // Open the group; seed grp_default on the fly if absent. Track
791
1191
  // seedFailed separately so a seed crash surfaces a different message
792
1192
  // than a genuinely-missing group.
@@ -825,22 +1225,22 @@ export async function handleUnifyGroupChat(msg) {
825
1225
  }
826
1226
 
827
1227
  // Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
1228
+ let rosterMutated = false;
828
1229
  try {
829
1230
  const meta = groupHandle.getMeta();
830
1231
  const wantsAdd = mentions.filter(
831
1232
  (m) => m && m !== 'all' && !meta.roster.includes(m)
832
1233
  );
833
1234
  if (wantsAdd.length) {
834
- let mutated = false;
835
1235
  for (const vpId of wantsAdd) {
836
1236
  try {
837
1237
  const vp = readVp(vpId);
838
1238
  if (!vp) continue;
839
1239
  addMember(yeaftDir, groupId, vpId);
840
- mutated = true;
1240
+ rosterMutated = true;
841
1241
  } catch { /* skip strangers */ }
842
1242
  }
843
- if (mutated) {
1243
+ if (rosterMutated) {
844
1244
  try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
845
1245
  groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
846
1246
  sendGroupRosterChanged(groupHandle.getMeta());
@@ -853,17 +1253,71 @@ export async function handleUnifyGroupChat(msg) {
853
1253
  try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
854
1254
  groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
855
1255
  sendGroupRosterChanged(groupHandle.getMeta());
1256
+ rosterMutated = true;
856
1257
  } catch { /* best-effort */ }
857
1258
  }
858
1259
  } catch (err) {
859
1260
  console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
860
1261
  }
861
1262
 
862
- const captured = [];
863
- const coord = createCoordinator(groupHandle, {
864
- deliver: (vpId, envelope) => { captured.push({ vpId, envelope }); },
865
- });
1263
+ // task-707: per-group persistent coordinator/router. Created once per
1264
+ // groupId; reused across user messages AND across in-flight tool calls
1265
+ // (route_forward delivers via this same coord). If the roster mutated
1266
+ // we replace the cached coord so it points at the freshly-opened
1267
+ // groupHandle.
1268
+ if (rosterMutated) {
1269
+ groupContexts.delete(groupId);
1270
+ }
1271
+ const groupCtx = getOrCreateGroupContext(groupId, groupHandle);
1272
+ const coord = groupCtx.coord;
1273
+
1274
+ // Selective abort — replace the pre-707 blanket abort that killed
1275
+ // every in-flight turn on every new user message (Bug 2). We peek at
1276
+ // which VPs THIS message will retrigger via the coordinator's
1277
+ // `parseMentions` and only abort those VP's current turns. VPs busy
1278
+ // on unrelated work keep running.
1279
+ //
1280
+ // Intentional limitation: we do NOT walk the route_forward causedBy
1281
+ // chain. If VP-A is mid-turn, route_forwarded to VP-B, and the user
1282
+ // now mentions only @vp-a, we abort VP-A but VP-B keeps generating
1283
+ // even though VP-B's inbound was caused by the now-overridden VP-A
1284
+ // turn. The argument for letting VP-B run: VP-B may already have
1285
+ // useful work in flight (a partial reply); aborting it on a chain
1286
+ // override discards that work for no user-visible benefit. If a
1287
+ // future product decision wants to cancel transitively, walk
1288
+ // `vpInboxes[*].envelope.causedBy` against the selectively-aborted
1289
+ // VP set and fan the abort out.
1290
+ //
1291
+ // Note: this is a best-effort pre-pass. The real dispatch list comes
1292
+ // from `coord.ingest(...).dispatched` below, but at that point we
1293
+ // would already have called deliver() and started new typing events
1294
+ // for the same VPs we're about to abort — racy. Using the pre-pass
1295
+ // result keeps the abort window before any deliver() side effects.
1296
+ try {
1297
+ const meta = groupHandle.getMeta();
1298
+ const willTarget = mentions.includes('all')
1299
+ ? meta.roster.slice()
1300
+ : mentions.filter((m) => meta.roster.includes(m));
1301
+ for (const vpId of willTarget) {
1302
+ const k = vpKey(groupId, vpId);
1303
+ const ctrl = vpAborts.get(k);
1304
+ if (ctrl && !ctrl.signal.aborted) {
1305
+ try { ctrl.abort(); } catch { /* best-effort */ }
1306
+ }
1307
+ // Also drop any queued envelopes for VPs being replaced — the
1308
+ // user's new message supersedes them.
1309
+ const inbox = vpInboxes.get(k);
1310
+ if (inbox && inbox.length > 0) {
1311
+ inbox.length = 0;
1312
+ }
1313
+ }
1314
+ } catch (err) {
1315
+ console.warn('[Unify] unify_group_chat: selective abort pre-pass failed', err?.message || err);
1316
+ }
866
1317
 
1318
+ // Ingest user text. The coordinator persists, applies mention/fanout
1319
+ // rules, and calls deliver() (== enqueueForVp) for each chosen VP —
1320
+ // which both (a) emits vp_typing_start and (b) ensures a driver runs.
867
1321
  let report;
868
1322
  try {
869
1323
  report = coord.ingest({
@@ -883,7 +1337,8 @@ export async function handleUnifyGroupChat(msg) {
883
1337
  }
884
1338
 
885
1339
  const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
886
- if (dispatchedIds.length === 0 && !report?.fallback) {
1340
+ const fallbackId = typeof report?.fallback === 'string' ? report.fallback : null;
1341
+ if (dispatchedIds.length === 0 && !fallbackId) {
887
1342
  // Coordinator chose nobody and provided no fallback — should not happen
888
1343
  // with a healthy roster. Surface the failure explicitly rather than
889
1344
  // silently retrying as a single-VP turn (the legacy fallback masked
@@ -896,73 +1351,15 @@ export async function handleUnifyGroupChat(msg) {
896
1351
  return;
897
1352
  }
898
1353
 
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
- }));
1354
+ // Wait for the drivers initially scheduled by THIS user message to
1355
+ // drain. We pass the dispatched id list (plus any fallback) for
1356
+ // documentation; the wait function itself blocks on every driver in
1357
+ // the group, so route_forward fan-outs the user didn't mention still
1358
+ // get drained before we return.
1359
+ const primaryTargets = dispatchedIds.length > 0
1360
+ ? dispatchedIds.slice()
1361
+ : (fallbackId ? [fallbackId] : []);
1362
+ await waitForVpDrivers(groupId, primaryTargets);
966
1363
 
967
1364
  // Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
968
1365
  // history past 20 turns / 80K tokens. Runs in the background — does
@@ -973,10 +1370,52 @@ export async function handleUnifyGroupChat(msg) {
973
1370
  scheduleCompactAfterTurn(groupId);
974
1371
  }
975
1372
 
1373
+ /**
1374
+ * Wait for the drivers of `primaryTargets` AND any drivers spawned by
1375
+ * their downstream route_forward chains to all complete. We re-poll
1376
+ * because a route_forward inside VP-A's turn enqueues VP-B, which spawns
1377
+ * a new driver while we're waiting for VP-A. Loop terminates when every
1378
+ * group-local driver is idle.
1379
+ *
1380
+ * `primaryTargets` is informational — for filtering we wait on EVERY
1381
+ * driver in the group, since route_forward fan-out targets the user
1382
+ * never directly mentioned still need to drain before this user message
1383
+ * is considered handled.
1384
+ *
1385
+ * Bounded by the per-driver QUERY_TIMEOUT_MS that runVpTurn enforces,
1386
+ * so even a misbehaving model can't pin this forever.
1387
+ */
1388
+ async function waitForVpDrivers(groupId, _primaryTargets) {
1389
+ while (true) {
1390
+ // Snapshot the current set of drivers belonging to this group.
1391
+ const promises = [];
1392
+ const prefix = `${groupId}::`;
1393
+ for (const [key, p] of vpDrivers.entries()) {
1394
+ if (key.startsWith(prefix)) promises.push(p);
1395
+ }
1396
+ if (promises.length === 0) return;
1397
+ await Promise.all(promises.map((p) => p.catch(() => {})));
1398
+ // Re-check: if a route_forward in one of the awaited turns
1399
+ // enqueued more work for another VP, a new driver may have been
1400
+ // started. Loop again. Otherwise exit.
1401
+ }
1402
+ }
1403
+
976
1404
  /**
977
1405
  * Build the per-query VP context for the Engine.
1406
+ *
1407
+ * @param {object} args
1408
+ * @param {string} args.vpId
1409
+ * @param {object} args.groupCoordinator — the persistent coordinator for the
1410
+ * group; used here for `group.getMeta()` (defaultVpId, announcement) and
1411
+ * to bind the per-group router into toolCtx.
1412
+ * @param {string} [args.groupId]
1413
+ * @param {object} [args.envelope] — the inbound coordinator envelope that
1414
+ * triggered this turn. Threaded into toolCtx as `inboundEnvelope` so
1415
+ * `route_forward` can extend `causedBy` chains correctly. Optional only
1416
+ * for pre-707 callers that no longer exist in production.
978
1417
  */
979
- export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
1418
+ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope }) {
980
1419
  // Read the group meta once and reuse for both defaultVpId fallback and
981
1420
  // announcement injection. Each .getMeta() reload reads + parses the
982
1421
  // group.json file, so calling it twice per turn is wasteful — and
@@ -1040,6 +1479,14 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
1040
1479
  // Router build failure is non-fatal.
1041
1480
  }
1042
1481
  }
1482
+ // task-707: thread the inbound envelope into toolCtx so `route_forward`
1483
+ // can stamp `causedBy` chains and the loop guard can key per-sender
1484
+ // throttling against the originating envelope. Safe to omit on a
1485
+ // user-initiated turn — route_forward will fall back to a synthetic
1486
+ // envelope inside router.forward.
1487
+ if (envelope && typeof envelope === 'object') {
1488
+ out.inboundEnvelope = envelope;
1489
+ }
1043
1490
  return out;
1044
1491
  }
1045
1492
 
@@ -1105,15 +1552,23 @@ async function ensureSessionLoaded() {
1105
1552
  * coordinator-bound router, stream events to the frontend, and append the
1106
1553
  * result to the flat conversation history.
1107
1554
  *
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`.
1555
+ * Private — only the per-VP driver in `ensureDriverRunning` calls this.
1556
+ * Each VP-turn gets its own AbortController (`vpAbort`) so it can be
1557
+ * stopped individually. The shared `baseSnapshot` is the conversation
1558
+ * history at fan-out start — no VP sees another VP's in-flight output.
1559
+ * After the turn finishes (or is aborted), the VP's output is atomically
1560
+ * appended to `conversationMessages`.
1561
+ *
1562
+ * task-707: takes a coordinator `envelope` rather than the coordinator
1563
+ * itself; the persistent coord lives in `groupContexts[groupId]`. Uses
1564
+ * `getOrCreateVpEngine(groupId, vpId)` so each VP runs against its own
1565
+ * Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
1566
+ * `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
1567
+ * collide when VP-A and VP-B run concurrent turns.
1113
1568
  *
1114
- * @param {{ prompt: string, groupId: string, vpId: string, turnId: string, groupCoordinator: object, vpAbort: AbortController, baseSnapshot: Array }} args
1569
+ * @param {{ prompt: string, groupId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
1115
1570
  */
1116
- async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAbort, baseSnapshot }) {
1571
+ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
1117
1572
  if (!prompt?.trim()) return;
1118
1573
 
1119
1574
  const envelope = { groupId, vpId, turnId };
@@ -1143,7 +1598,19 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
1143
1598
  const toolCallsAccum = [];
1144
1599
  const toolResultsAccum = [];
1145
1600
 
1146
- const queryOpts = buildVpQueryOpts({ vpId, groupCoordinator, groupId });
1601
+ // task-707: per-VP engine + persistent group coord. The coord is
1602
+ // created in handleUnifyGroupChat via getOrCreateGroupContext and
1603
+ // cached on `groupContexts`; we pull it here so route_forward
1604
+ // (router built from this same coord) lands envelopes back on the
1605
+ // right inbox set.
1606
+ const groupCtx = groupContexts.get(groupId);
1607
+ const groupCoordinator = groupCtx?.coord || null;
1608
+ const queryOpts = buildVpQueryOpts({
1609
+ vpId,
1610
+ groupCoordinator,
1611
+ groupId,
1612
+ envelope: inboundEnvelope,
1613
+ });
1147
1614
  const handlerCtx = {
1148
1615
  assistantTextParts,
1149
1616
  toolCallsAccum,
@@ -1160,7 +1627,8 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
1160
1627
  const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
1161
1628
  messageTokenBudget: session?.config?.messageTokenBudget,
1162
1629
  });
1163
- for await (const event of session.engine.query({
1630
+ const vpEngine = getOrCreateVpEngine(groupId, vpId);
1631
+ for await (const event of vpEngine.query({
1164
1632
  prompt,
1165
1633
  messages: trimmedMessages,
1166
1634
  signal: vpAbort.signal,
@@ -1413,6 +1881,28 @@ async function runCompactNow(groupId) {
1413
1881
  }
1414
1882
  }
1415
1883
 
1884
+ /**
1885
+ * Abort every in-flight VP turn and clear all queued envelopes across
1886
+ * every group. Shared by `handleUnifyAbortThread` and
1887
+ * `handleUnifyAbortAll` — both have the same "stop everything" intent
1888
+ * after the H2.f.2 collapse to single-conversation. Pushes
1889
+ * `vp:<key>` strings into the supplied `aborted` array for the
1890
+ * unify_aborted event.
1891
+ *
1892
+ * @param {string[]} aborted — output array, mutated in place
1893
+ */
1894
+ function abortAllVpRuntime(aborted) {
1895
+ for (const [key, ctrl] of vpAborts) {
1896
+ try {
1897
+ if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(`vp:${key}`); }
1898
+ } catch { /* best-effort */ }
1899
+ }
1900
+ vpAborts.clear();
1901
+ for (const inbox of vpInboxes.values()) {
1902
+ if (Array.isArray(inbox)) inbox.length = 0;
1903
+ }
1904
+ }
1905
+
1416
1906
  /**
1417
1907
  * H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
1418
1908
  * `threadId` parameter; the new version aborts the single in-flight
@@ -1433,6 +1923,7 @@ export function handleUnifyAbortThread(_msg = {}) {
1433
1923
  try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
1434
1924
  }
1435
1925
  turnAbortCtrls.clear();
1926
+ abortAllVpRuntime(aborted);
1436
1927
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
1437
1928
  return { aborted, all: false };
1438
1929
  }
@@ -1452,6 +1943,7 @@ export function handleUnifyAbortAll() {
1452
1943
  try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
1453
1944
  }
1454
1945
  turnAbortCtrls.clear();
1946
+ abortAllVpRuntime(aborted);
1455
1947
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
1456
1948
  return { aborted, all: true };
1457
1949
  }
@@ -1803,6 +2295,22 @@ export async function resetUnifySession() {
1803
2295
  }
1804
2296
  unifyConversationId = null;
1805
2297
  conversationMessages = [];
2298
+ // Re-arm the permission warning. The user might have fixed the
2299
+ // ~/.yeaft/ permissions in the interim and is now restarting the
2300
+ // session — they should see the diagnostic again if it still fails.
2301
+ _permissionDiagnosticSent = false;
2302
+ // Drop all per-VP / per-group transient state when the session is
2303
+ // replaced. Drivers may still be running with a stale engine
2304
+ // reference; abort them so they exit cleanly. The new session gets
2305
+ // fresh inboxes / engines / coords on first dispatch.
2306
+ for (const [, ctrl] of vpAborts) {
2307
+ try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
2308
+ }
2309
+ vpAborts.clear();
2310
+ vpInboxes.clear();
2311
+ vpDrivers.clear();
2312
+ vpEngines.clear();
2313
+ groupContexts.clear();
1806
2314
 
1807
2315
  try {
1808
2316
  const yeaftDir = ctx.CONFIG?.yeaftDir;