@yeaft/webchat-agent 0.1.649 → 0.1.652

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,26 @@
1
1
  /**
2
2
  * web-bridge.js — Bridge between web UI and Yeaft Unify Engine.
3
3
  *
4
+ * H2.f.2: collapsed to a single-conversation bridge. The pre-H2 multi-thread
5
+ * routing model is gone — there is one engine, one conversation, one
6
+ * AbortController, one flat message history. The wire protocol drops
7
+ * `threadId` from outgoing events; frontend reads them as a single stream.
8
+ *
4
9
  * Translates Engine events into claude_output-format messages so the
5
10
  * frontend can fully reuse the standard Chat rendering pipeline
6
11
  * (MessageList, AssistantTurn, ToolLine, AskCard, waiting cat, etc.).
7
12
  *
8
- * Architecture:
9
- * 1. On first use, loadSession() initialises Engine with skills + MCP enabled.
10
- * 2. A virtual conversationId ('unify-<ts>') is assigned per session.
11
- * 3. Engine.query() yields events → translated into unify_output messages
12
- * that carry { conversationId, data } in claude_output format.
13
- * 4. The frontend's handleUnifyOutput dispatches them through handleClaudeOutput.
14
- *
15
13
  * task-330c lint guard:
16
14
  * ⚠️ DO NOT introduce greedy `text.replace(/---ROUTE---[\s\S]*$/g, '')`
17
15
  * style strips on incoming/outgoing message bodies. Crew ROUTE
18
16
  * stripping is owned EXCLUSIVELY by `agent/crew/routing.js`
19
17
  * `parseRoutes()` which returns `{routes, displayBody}` with exact
20
- * ranges removed. Re-stripping here would (a) double-eat content
21
- * that has already been parser-cleaned, (b) reintroduce the bug
22
- * task-328 fixed (greedy tail-strip ate trailing prose).
18
+ * ranges removed.
23
19
  */
24
20
 
25
21
  import { loadSession } from './session.js';
26
22
  import { sendToServer } from '../connection/buffer.js';
27
23
  import ctx from '../context.js';
28
- import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
29
24
  import { handleVpSubscribe } from './vp/vp-bridge.js';
30
25
  import { createVp, updateVp, deleteVp, readVp, VpCrudError } from './vp/vp-crud.js';
31
26
  import { scanVpLibrary } from './vp/vp-store.js';
@@ -52,16 +47,11 @@ import {
52
47
  let session = null;
53
48
 
54
49
  /**
55
- * task-320: per-thread in-flight AbortController registry.
56
- *
57
- * A new message only cancels the prior round on the SAME thread; a message
58
- * routed to a different thread runs concurrently without aliasing. Keyed by
59
- * the resolved `targetThreadId` from the dispatcher's `routing_decision`
60
- * event (we don't know the thread until the router has classified).
61
- *
62
- * @type {Map<string, AbortController>}
50
+ * Single in-flight AbortController. A new user message cancels the prior
51
+ * round (if any). H2.f.2: replaces the per-thread Map.
52
+ * @type {AbortController | null}
63
53
  */
64
- const abortByThread = new Map();
54
+ let currentAbortCtrl = null;
65
55
 
66
56
  /** Query timeout in ms — abort if LLM doesn't respond within this window */
67
57
  const QUERY_TIMEOUT_MS = 120_000;
@@ -74,41 +64,24 @@ let unifyConversationId = null;
74
64
  let _vpUnsubscribe = null;
75
65
 
76
66
  /**
77
- * task-320: per-thread accumulated conversation messages for context
78
- * continuity. Previously a single flat array which cross-contaminated
79
- * history across threads. Keyed by threadId. Cleared on session reset or
80
- * by a `consolidate` event for that thread only.
81
- *
82
- * @type {Map<string, Array<{role: 'user'|'assistant', content: string|Array}>>}
67
+ * Flat conversation history for engine context continuity. H2.f.2: replaces
68
+ * the per-thread Map. Cleared on session reset or by a `consolidate` event.
69
+ * @type {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>}
83
70
  */
84
- const messagesByThread = new Map();
85
-
86
- function getThreadMessages(threadId) {
87
- if (!threadId) return [];
88
- let arr = messagesByThread.get(threadId);
89
- if (!arr) { arr = []; messagesByThread.set(threadId, arr); }
90
- return arr;
91
- }
71
+ let conversationMessages = [];
92
72
 
93
73
  /**
94
- * Restore per-thread message history from persisted conversation store.
95
- *
96
- * task-fix: accept `role:'tool'` messages AND preserve `toolCalls` /
97
- * `toolCallId` fields. Without this, chat-completions serialization
98
- * emits `tool_calls` without paired `role:'tool'` results, causing
99
- * "No tool output found for function call" 400s after the first tool
100
- * use across any restart / session-ready / model-switch event.
74
+ * Restore conversation history from persisted store. Accepts `role:'tool'`
75
+ * messages and preserves `toolCalls`/`toolCallId` so the next chat-completions
76
+ * serialization includes paired tool messages (avoids "No tool output found
77
+ * for function call" 400s).
101
78
  *
102
79
  * @param {Array<object>} recent — output of conversationStore.loadRecent()
103
80
  */
104
- function restoreThreadHistoryFromRecent(recent) {
105
- messagesByThread.clear();
81
+ function restoreHistoryFromRecent(recent) {
82
+ conversationMessages = [];
106
83
  for (const m of recent) {
107
- // Keep user, assistant, AND tool messages. Tool messages are required
108
- // for the chat-completions `tool_call_id` pairing.
109
84
  if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
110
- const tid = m.threadId || MAIN_THREAD_ID;
111
- const bucket = getThreadMessages(tid);
112
85
  const entry = { role: m.role, content: m.content };
113
86
  if (m.toolCallId) entry.toolCallId = m.toolCallId;
114
87
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
@@ -119,18 +92,13 @@ function restoreThreadHistoryFromRecent(recent) {
119
92
  }));
120
93
  }
121
94
  if (m.isError) entry.isError = true;
122
- bucket.push(entry);
95
+ conversationMessages.push(entry);
123
96
  }
124
97
  }
125
98
 
126
99
  /** Whether we've already sent a permission warning to the UI */
127
100
  let _permissionDiagnosticSent = false;
128
101
 
129
- /**
130
- * Check if an error message is a permission error.
131
- * @param {string} msg
132
- * @returns {boolean}
133
- */
134
102
  function isPermissionErrorMsg(msg) {
135
103
  if (!msg) return false;
136
104
  const lower = msg.toLowerCase();
@@ -139,14 +107,9 @@ function isPermissionErrorMsg(msg) {
139
107
 
140
108
  /**
141
109
  * Send a unify_output message carrying claude_output-format data.
142
- * The server forwards this as-is to the web client.
143
- * The frontend's handleUnifyOutput will dispatch via handleClaudeOutput.
144
- *
145
110
  * Optional `groupId` tags every emitted assistant/tool/user mirror with
146
111
  * the originating group so the frontend can stamp arriving messages with
147
- * the SEND-context group instead of the user's CURRENT filter (which can
148
- * change while the reply is in flight). Without this, switching groups
149
- * mid-reply lands the assistant turn in the wrong group.
112
+ * the SEND-context group.
150
113
  */
151
114
  function sendUnifyOutput(data, groupId) {
152
115
  sendToServer({
@@ -157,10 +120,7 @@ function sendUnifyOutput(data, groupId) {
157
120
  });
158
121
  }
159
122
 
160
- /**
161
- * Send a unify_output event (non-claude_output metadata).
162
- * Optional `groupId` — see sendUnifyOutput for rationale.
163
- */
123
+ /** Send a unify_output event (non-claude_output metadata). */
164
124
  function sendUnifyEvent(event, groupId) {
165
125
  sendToServer({
166
126
  type: 'unify_output',
@@ -170,15 +130,7 @@ function sendUnifyEvent(event, groupId) {
170
130
  });
171
131
  }
172
132
 
173
- /**
174
- * task-334-ui-a + task-334h: respond to `unify_vp_subscribe` from the web
175
- * client by pushing a one-shot `vp_snapshot` event AND registering this
176
- * socket as a live-diff subscriber. VpLoader's debounced rescan fans out
177
- * `vp_updated` / `vp_removed` events to every active subscriber.
178
- */
179
133
  export function handleUnifyVpSubscribe(_msg) {
180
- // Unsub any prior subscription before re-subscribing to prevent duplicate
181
- // handler registration on reconnect / re-subscribe.
182
134
  if (_vpUnsubscribe) {
183
135
  try { _vpUnsubscribe(); } catch { /* ignore */ }
184
136
  _vpUnsubscribe = null;
@@ -187,23 +139,7 @@ export function handleUnifyVpSubscribe(_msg) {
187
139
  }
188
140
 
189
141
  /**
190
- * task-334-ui-g: VP CRUD from the web client.
191
- *
192
- * Thin dispatcher over agent/unify/vp/vp-crud.js. We never throw on the WS
193
- * path — each op reports via `unify_output` with a structured payload so
194
- * the UI can surface errors as i18n strings keyed by `error.code`. VpLoader
195
- * picks up the on-disk change on its next debounced rescan (default 500ms)
196
- * and fans out `vp_updated` / `vp_removed` events to every subscriber, so
197
- * we do not need to emit an extra snapshot here.
198
- *
199
- * Message shapes (wire):
200
- * unify_vp_create { payload: {vpId, displayName, role, traits, modelHint, persona}, requestId? }
201
- * unify_vp_update { payload: {...}, requestId? }
202
- * unify_vp_delete { vpId, requestId? }
203
- * unify_vp_read { vpId, requestId? }
204
- *
205
- * Replies (all sent through sendUnifyEvent):
206
- * { type: 'vp_crud_result', op, requestId, ok, vpId?, vp?, error?: {code, vpId?} }
142
+ * VP CRUD from the web client. See historic doc for full message shapes.
207
143
  */
208
144
  function sendVpCrudResult(payload) {
209
145
  sendUnifyEvent({ type: 'vp_crud_result', ...payload });
@@ -269,53 +205,18 @@ export function handleUnifyVpDelete(msg) {
269
205
  }
270
206
  }
271
207
 
272
- /**
273
- * task-334h (R6 §Δ28 / §Δ31.6): task-scoped direct message echo.
274
- *
275
- * Replaces the withdrawn R3 `unify_task_private_chat`. The agent acts as a
276
- * relay: validate → stamp msgId + ts → broadcast `task_message`. Real
277
- * persistence + task ACL lands in 334l.
278
- *
279
- * @param {any} msg
280
- */
281
208
  export function handleUnifyFeatureMessage(msg) {
282
209
  _handleUnifyFeatureMessage(msg, sendUnifyEvent);
283
210
  }
284
211
 
285
- /**
286
- * task-334h (R6 §Δ29): user-memory write skeleton. Replies with a
287
- * `user_memory_updated` ack carrying `pending: true`; 334l replaces the
288
- * stub with real ingestion + entryId.
289
- *
290
- * @param {any} msg
291
- */
292
212
  export function handleUnifyUserMemoryWrite(msg) {
293
213
  _handleUnifyUserMemoryWrite(msg, sendUnifyEvent);
294
214
  }
295
215
 
296
- /**
297
- * task-334h (R6 §Δ29): user-memory remove skeleton. Replies with
298
- * `user_memory_removed` ack; 334l replaces the stub.
299
- *
300
- * @param {any} msg
301
- */
302
216
  export function handleUnifyUserMemoryRemove(msg) {
303
217
  _handleUnifyUserMemoryRemove(msg, sendUnifyEvent);
304
218
  }
305
219
 
306
- /**
307
- * task-fix: list MemoryStore entries as a scope-tree for the
308
- * UserMemoryPage "folder view". Entries come from MemoryStore.listEntries()
309
- * and are grouped client-side by their `scope` (a `/`-separated path like
310
- * `work/claude-web-chat/auth`).
311
- *
312
- * Request shape: { type: 'unify_memory_scope_list', requestId? }
313
- * Reply shape: { type: 'memory_scope_snapshot',
314
- * entries: Array<{name,scope,kind,tags,importance,
315
- * frequency,created_at,updated_at,
316
- * content}>,
317
- * requestId? }
318
- */
319
220
  export function handleUnifyMemoryScopeList(msg) {
320
221
  const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
321
222
  try {
@@ -355,22 +256,7 @@ export function handleUnifyVpRead(msg) {
355
256
  }
356
257
 
357
258
  /**
358
- * task-334m: Group CRUD wired to WS events (§Δ10 334m + R6 §Δ31.2).
359
- *
360
- * Message shapes (wire):
361
- * unify_list_groups { requestId? }
362
- * unify_create_group { payload: {name, roster?, defaultVpId?}, requestId? }
363
- * unify_rename_group { groupId, name, requestId? }
364
- * unify_archive_group { groupId, requestId? }
365
- * unify_add_member { groupId, vpId, requestId? }
366
- * unify_remove_member { groupId, vpId, requestId? }
367
- * unify_set_default_vp { groupId, vpId, requestId? }
368
- *
369
- * Replies (sendUnifyEvent):
370
- * { type: 'group_crud_result', op, requestId, ok, group?, groups?, error?: {code, groupId?, message?} }
371
- *
372
- * Post-change broadcast (when meta mutates):
373
- * { type: 'group_roster_changed', groupId, roster, defaultVpId, name }
259
+ * Group CRUD wired to WS events.
374
260
  */
375
261
  function sendGroupCrudResult(payload) {
376
262
  sendUnifyEvent({ type: 'group_crud_result', ...payload });
@@ -457,10 +343,6 @@ export function handleUnifyArchiveGroup(msg) {
457
343
  }
458
344
  }
459
345
 
460
- /**
461
- * Bug 8: physical delete — removes the group dir and any legacy
462
- * `.archived-*-<groupId>` siblings. Replies with op:'delete'.
463
- */
464
346
  export function handleUnifyDeleteGroup(msg) {
465
347
  const requestId = msg && msg.requestId;
466
348
  const groupId = msg && msg.groupId;
@@ -517,26 +399,16 @@ export function handleUnifySetDefaultVp(msg) {
517
399
  }
518
400
 
519
401
  /**
520
- * task-318 rev-1 fix: install live-setter bridge between the session's
521
- * runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
522
- * which message-router's `update_unify_settings` branch reads. Previously
523
- * that object was null and the setters were dead code. Now every
524
- * `update_unify_settings` mutation pushes the new caps into the live
525
- * session within the same tick — no reload required.
526
- *
527
- * Exported so tests can drive the same wiring with a mock session.
402
+ * Install the dream pipeline progress sink and runtime settings bridge.
403
+ * H2.f.2: dropped the threadStore-related setters (autoArchiveIdleDays,
404
+ * maxConcurrentThreads). Only the dream sink remains.
528
405
  *
529
406
  * @param {import('./session.js').Session} s
530
407
  */
531
408
  export function installUnifyRuntimeBridge(s) {
532
409
  if (!s) return;
533
- const initialMax = s.engineRegistry?.maxConcurrent ?? null;
534
- const initialIdle = s.threadStore?.idleArchiveDays ?? 0;
535
410
 
536
- // DESIGN-v2 §19.4: forward dream pipeline progress events to the web
537
- // client so the debug panel can render live state. Events flow through
538
- // the same `unify_output` channel; no new WebSocket message type is
539
- // introduced.
411
+ // Forward dream pipeline progress events to the web debug panel.
540
412
  s._dreamProgressSink = (evt) => {
541
413
  try {
542
414
  sendUnifyEvent({ type: 'dream_progress', ...evt });
@@ -544,278 +416,24 @@ export function installUnifyRuntimeBridge(s) {
544
416
  };
545
417
 
546
418
  ctx.unifyRuntimeSettings = {
547
- get maxConcurrentThreads() { return s.engineRegistry?.maxConcurrent ?? initialMax; },
548
- set maxConcurrentThreads(v) {
549
- if (typeof s.engineRegistry?.setMaxConcurrent === 'function') {
550
- s.engineRegistry.setMaxConcurrent(v);
551
- }
552
- },
553
- get autoArchiveIdleDays() { return s.threadStore?.idleArchiveDays ?? initialIdle; },
554
- set autoArchiveIdleDays(v) {
555
- if (typeof s.threadStore?.setIdleArchiveDays === 'function') {
556
- s.threadStore.setIdleArchiveDays(v);
557
- }
558
- // task-317: re-sweep right after the cap changes so a user who
559
- // lowers the threshold sees stale threads disappear immediately
560
- // rather than having to wait for the hourly tick.
561
- runAutoArchiveSweep(s);
562
- },
419
+ // No multi-thread settings to surface anymore. Stub for back-compat
420
+ // with message-router's update_unify_settings branch — assignments are
421
+ // accepted but ignored.
422
+ get maxConcurrentThreads() { return null; },
423
+ set maxConcurrentThreads(_v) { /* deprecated, ignored */ },
424
+ get autoArchiveIdleDays() { return 0; },
425
+ set autoArchiveIdleDays(_v) { /* deprecated, ignored */ },
563
426
  };
564
427
  }
565
428
 
566
- /**
567
- * task-317: idle thread auto-archive.
568
- *
569
- * A single sweep = ask the ThreadStore to archive every non-main,
570
- * non-archived thread whose last activity predates the configured idle
571
- * window. When any thread is archived we push a fresh `thread_list_updated`
572
- * so the sidebar reflects reality within the same tick.
573
- *
574
- * Safe on stores with `idleArchiveDays === 0` (returns no-op) and on
575
- * sessions missing a threadStore handle (defensive; should never happen
576
- * once `installUnifyRuntimeBridge` has run).
577
- *
578
- * @param {import('./session.js').Session|null} s
579
- * @returns {string[]} archived thread ids (empty when nothing changed)
580
- */
581
- export function runAutoArchiveSweep(s) {
582
- try {
583
- const store = s?.threadStore ?? (typeof getThreadStore === 'function' ? getThreadStore() : null);
584
- if (!store || typeof store.runArchivePass !== 'function') return [];
585
- const { archived } = store.runArchivePass();
586
- if (archived && archived.length > 0) {
587
- sendThreadListUpdate();
588
- }
589
- return archived || [];
590
- } catch (err) {
591
- console.warn('[Unify] runAutoArchiveSweep failed:', err?.message || err);
592
- return [];
593
- }
594
- }
595
-
596
- /**
597
- * task-317: schedule the hourly auto-archive tick bound to the given
598
- * session. Returns the `Timeout` handle so tests can assert / clear it.
599
- * Re-calling replaces any prior timer (idempotent per-session).
600
- *
601
- * The timer is `unref()`'d so a pending tick never keeps the Node loop
602
- * alive during shutdown; an explicit `clearAutoArchiveSchedule()` is
603
- * provided for tests.
604
- */
605
- let autoArchiveTimer = null;
606
- const AUTO_ARCHIVE_TICK_MS = 60 * 60 * 1000; // 1h
607
-
608
- export function scheduleAutoArchive(s, { intervalMs = AUTO_ARCHIVE_TICK_MS } = {}) {
609
- if (autoArchiveTimer) {
610
- clearInterval(autoArchiveTimer);
611
- autoArchiveTimer = null;
612
- }
613
- if (!s) return null;
614
- autoArchiveTimer = setInterval(() => {
615
- runAutoArchiveSweep(s);
616
- }, intervalMs);
617
- if (autoArchiveTimer && typeof autoArchiveTimer.unref === 'function') {
618
- autoArchiveTimer.unref();
619
- }
620
- return autoArchiveTimer;
621
- }
622
-
623
- export function clearAutoArchiveSchedule() {
624
- if (autoArchiveTimer) {
625
- clearInterval(autoArchiveTimer);
626
- autoArchiveTimer = null;
627
- }
628
- }
629
-
630
- /**
631
- * task-301 Part 2: push the full thread list snapshot to the web client.
632
- * Called after any ThreadStore-mutating tool completes and at turn_end so
633
- * the sidebar V2 always shows a fresh picture. Cheap — ThreadStore keeps
634
- * cached counters so list() is O(n) over a small n.
635
- */
636
- function sendThreadListUpdate() {
637
- try {
638
- const store = getThreadStore();
639
- const threads = store.list().map(t => ({
640
- id: t.id,
641
- name: t.name,
642
- goal: t.goal || '',
643
- parentThreadId: t.parentThreadId || null,
644
- status: t.status,
645
- archived: !!t.archived,
646
- messageCount: t.messageCount || 0,
647
- lastMessageAt: t.lastMessageAt || null,
648
- lastActivityAt: t.lastActivityAt || t.lastMessageAt || t.updatedAt || null,
649
- unread: t.unread || 0,
650
- preview: t.preview || '',
651
- createdAt: t.createdAt,
652
- updatedAt: t.updatedAt,
653
- // task-315: attached featureId (if any) so the UI can aggregate all
654
- // messages belonging to a feature across multiple threads. null when
655
- // the thread has no attached feature.
656
- featureId: (typeof store.attachedFeature === 'function')
657
- ? (store.attachedFeature(t.id) || null)
658
- : null,
659
- // `running` — the thread whose id equals the store's currentId is
660
- // considered the active/running track. The UI uses this for the
661
- // green halo in the Active group.
662
- running: t.id === store.currentId,
663
- }));
664
- sendUnifyEvent({ type: 'thread_list_updated', threads, currentThreadId: store.currentId });
665
- } catch (err) {
666
- // Best-effort; sidebar update must never block the main query path.
667
- console.warn('[Unify] sendThreadListUpdate failed:', err?.message || err);
668
- }
669
- }
670
-
671
- /** Tool names that mutate ThreadStore. After any of these we push an update. */
672
- const THREAD_MUTATING_TOOLS = new Set([
673
- 'SpawnThread',
674
- 'SwitchThread',
675
- 'ArchiveThread',
676
- 'AttachThreadToFeature',
677
- ]);
678
-
679
- /**
680
- * task-325b — Working Status event stream.
681
- *
682
- * Surfaces Engine lifecycle events (emitted by 325a) as a single
683
- * `thread_status` event for the frontend Working Status panel, plus
684
- * `thread_list_snapshot` for cold-start / reconnect.
685
- *
686
- * Contract (aligned with designer spec):
687
- * thread_status → { type: 'thread_status', threadId, state,
688
- * startedAt?, completedAt?, toolName?, reason? }
689
- * state ∈ 'running' | 'idle' | 'aborted' | 'error'
690
- * thread_list_snapshot → { type: 'thread_list_snapshot', threads[],
691
- * currentThreadId, serverTime }
692
- *
693
- * Red lines (per PM): do NOT mutate engine state; this layer is a pure
694
- * observer + translator. Event names match designer doc verbatim.
695
- */
696
-
697
- /** Map Engine event name → Working Status state string. */
698
- function engineEventToState(engineEventType) {
699
- switch (engineEventType) {
700
- case 'thread_started': return 'running';
701
- case 'thread_completed': return 'idle';
702
- case 'thread_aborted': return 'aborted';
703
- case 'thread_error': return 'error';
704
- default: return null;
705
- }
706
- }
707
-
708
- /**
709
- * Build and broadcast a `thread_status` payload translated from a raw
710
- * engine lifecycle event. The engine event shape (325a) is:
711
- * { type, threadId, startedAt?, completedAt?, toolName?, reason? }
712
- * Unknown fields pass through untouched so future engine additions
713
- * (e.g. `attempt`) flow to the UI without another bridge change.
714
- *
715
- * @param {object} ev — engine event
716
- * @returns {boolean} true if a thread_status was emitted
717
- */
718
- function emitThreadStatusFromEngineEvent(ev) {
719
- if (!ev || typeof ev !== 'object') return false;
720
- const state = engineEventToState(ev.type);
721
- if (!state) return false;
722
- const payload = { type: 'thread_status', threadId: ev.threadId, state };
723
- if (ev.startedAt != null) payload.startedAt = ev.startedAt;
724
- if (ev.completedAt != null) payload.completedAt = ev.completedAt;
725
- if (ev.toolName) payload.toolName = ev.toolName;
726
- if (ev.reason) payload.reason = ev.reason;
727
- if (ev.error?.message) payload.error = ev.error.message;
728
- sendUnifyEvent(payload);
729
- return true;
730
- }
731
-
732
- /**
733
- * task-325b: full-snapshot push distinct from `thread_list_updated`.
734
- * Emits `thread_list_snapshot` — a complete state dump the client uses
735
- * on page load / WebSocket reconnect to rebuild the Working Status panel
736
- * without missing any in-flight thread.
737
- *
738
- * Snapshot includes per-thread `state` (idle / running / aborted) resolved
739
- * from the engine registry's live inflight set. Threads the registry has
740
- * no entry for default to 'idle'.
741
- */
742
- function sendThreadListSnapshot() {
743
- try {
744
- const store = getThreadStore();
745
- const registry = session?.engineRegistry || null;
746
- const inflight = new Set(
747
- typeof registry?.inflightThreadIds === 'function'
748
- ? registry.inflightThreadIds()
749
- : [],
750
- );
751
- const threads = store.list().map(t => ({
752
- id: t.id,
753
- name: t.name,
754
- goal: t.goal || '',
755
- parentThreadId: t.parentThreadId || null,
756
- status: t.status,
757
- archived: !!t.archived,
758
- messageCount: t.messageCount || 0,
759
- lastMessageAt: t.lastMessageAt || null,
760
- lastActivityAt: t.lastActivityAt || t.lastMessageAt || t.updatedAt || null,
761
- unread: t.unread || 0,
762
- preview: t.preview || '',
763
- createdAt: t.createdAt,
764
- updatedAt: t.updatedAt,
765
- featureId: (typeof store.attachedFeature === 'function')
766
- ? (store.attachedFeature(t.id) || null)
767
- : null,
768
- running: t.id === store.currentId,
769
- state: inflight.has(t.id) ? 'running' : 'idle',
770
- }));
771
- sendUnifyEvent({
772
- type: 'thread_list_snapshot',
773
- threads,
774
- currentThreadId: store.currentId,
775
- serverTime: Date.now(),
776
- });
777
- } catch (err) {
778
- console.warn('[Unify] sendThreadListSnapshot failed:', err?.message || err);
779
- }
780
- }
781
-
782
- /**
783
- * task-310: parse a leading `@thread-<id>` or `@thread-<name>` marker on
784
- * the user's input and return it as a dispatcher override. The marker
785
- * itself is STRIPPED from the prompt before it reaches the engine —
786
- * users don't want to see `@thread-foo` echoed back into their
787
- * conversation.
788
- *
789
- * Thread IDs are `main` or `thr-<8 hex>`, so the match captures the id
790
- * name AFTER the literal `@thread-`. The returned `override.threadId`
791
- * is the fully-qualified thread id (e.g. `thread-main`, `thread-thr-abcd1234`).
792
- *
793
- * Returns { prompt, override? } where override = { threadId } if matched.
794
- */
795
- export function parseThreadPrefix(text) {
796
- if (!text || typeof text !== 'string') return { prompt: text || '', override: null };
797
- // Capture the id portion after the literal `@thread-` prefix.
798
- const m = text.match(/^\s*@thread-([A-Za-z0-9_-]+)\b\s*/);
799
- if (!m) return { prompt: text, override: null };
800
- const rest = text.slice(m[0].length);
801
- // The captured id may already include a `thr-` sub-prefix (for non-main
802
- // threads). For the canonical `main` thread, the override is the bare
803
- // string `main`; for `thr-xxxxxxxx` threads, pass through verbatim.
804
- const threadId = m[1];
805
- return { prompt: rest || text, override: { threadId } };
806
- }
807
-
808
429
  /**
809
430
  * Translate a pipeline event (from Dispatcher) into web-bridge outputs.
810
431
  * Pipeline events are distinct from engine events — they carry queue /
811
- * routing state for the UI. Engine events are unwrapped and forwarded
812
- * through the existing sendUnifyOutput / sendUnifyEvent path.
813
- *
814
- * Returns whether the pipeline is complete (terminal error / no more).
432
+ * routing state for the UI. Engine events are unwrapped and forwarded.
815
433
  */
816
- function forwardPipelineEvent(ev, ctx) {
434
+ function forwardPipelineEvent(ev, pctx) {
817
435
  if (!ev || typeof ev !== 'object') return false;
818
- const gid = ctx && ctx.groupId;
436
+ const gid = pctx && pctx.groupId;
819
437
  switch (ev.type) {
820
438
  case 'input_queue_updated':
821
439
  sendUnifyEvent({
@@ -828,28 +446,21 @@ function forwardPipelineEvent(ev, ctx) {
828
446
  }, gid);
829
447
  return false;
830
448
  case 'routing_decision':
449
+ // H2.f.2: still forwarded for wire compat, but frontend treats it as
450
+ // a no-op marker; targetThreadId is always 'main'.
831
451
  sendUnifyEvent({
832
452
  type: 'routing_decision',
833
453
  entryId: ev.entryId,
834
454
  action: ev.action,
835
- targetThreadId: ev.targetThreadId,
836
455
  source: ev.source,
837
456
  reason: ev.reason,
838
457
  }, gid);
839
458
  return false;
840
- case 'thread_list_updated':
841
- // Dispatcher built it already; just forward.
842
- sendUnifyEvent({
843
- type: 'thread_list_updated',
844
- threads: ev.threads,
845
- currentThreadId: ev.currentThreadId,
846
- }, gid);
847
- return false;
848
459
  case 'engine_event':
849
- ctx.onEngineEvent(ev.event, ev.threadId);
460
+ pctx.onEngineEvent(ev.event);
850
461
  return false;
851
462
  case 'error':
852
- ctx.onError(ev.error);
463
+ pctx.onError(ev.error);
853
464
  return true;
854
465
  default:
855
466
  return false;
@@ -857,52 +468,32 @@ function forwardPipelineEvent(ev, ctx) {
857
468
  }
858
469
 
859
470
  /**
860
- * Handle a single engine event unwrapped from an `engine_event` pipeline
861
- * envelope. Contains the event-type switch previously inlined in the
862
- * streaming loop. `threadId` is propagated onto tool_use / tool_result
863
- * blocks so the UI can render per-thread bubbles.
471
+ * Handle a single engine event unwrapped from an `engine_event` envelope.
472
+ * H2.f.2: no longer stamps a threadId on outgoing claude_output frames.
864
473
  *
865
474
  * @param {object} event — engine event (text_delta / tool_call / …)
866
- * @param {string} threadId owning thread id (from envelope)
867
- * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function}} hctx
475
+ * @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function, groupId?:string}} hctx
868
476
  */
869
- function handleEngineEvent(event, threadId, hctx) {
477
+ function handleEngineEvent(event, hctx) {
870
478
  hctx.resetQueryTimer();
871
479
  const gid = hctx && hctx.groupId;
872
480
 
873
- // task-325b: translate Engine lifecycle events into a single
874
- // `thread_status` event for the frontend Working Status panel. These
875
- // events are observer-only — they never mutate bridge state. The raw
876
- // engine event is NOT forwarded further; the switch below handles
877
- // anything the UI still needs.
878
- if (event && (
879
- event.type === 'thread_started' ||
880
- event.type === 'thread_completed' ||
881
- event.type === 'thread_aborted' ||
882
- event.type === 'thread_error'
883
- )) {
884
- // Engine events carry their own threadId; fall back to envelope id.
885
- emitThreadStatusFromEngineEvent({ ...event, threadId: event.threadId || threadId });
886
- return;
887
- }
888
-
889
481
  switch (event.type) {
890
482
  case 'text_delta':
891
483
  hctx.assistantTextParts.push(event.text);
892
484
  sendUnifyOutput({
893
485
  type: 'assistant',
894
486
  message: { content: [{ type: 'text', text: event.text }] },
895
- threadId,
896
487
  }, gid);
897
488
  break;
898
489
 
899
490
  case 'thinking_delta':
900
- sendUnifyEvent({ type: 'thinking_delta', text: event.text, threadId }, gid);
491
+ sendUnifyEvent({ type: 'thinking_delta', text: event.text }, gid);
901
492
  break;
902
493
 
903
494
  case 'tool_call':
904
- // Capture tool_call for the assistant message's toolCalls array so the
905
- // next turn's history correctly pairs `tool_calls` with `role:'tool'`
495
+ // Capture tool_call for the assistant message's toolCalls array so
496
+ // the next turn's history pairs `tool_calls` with `role:'tool'`
906
497
  // results (fixes "No tool output found for function call" 400s).
907
498
  if (hctx.toolCallsAccum) {
908
499
  hctx.toolCallsAccum.push({
@@ -915,7 +506,6 @@ function handleEngineEvent(event, threadId, hctx) {
915
506
  sendUnifyOutput({
916
507
  type: 'assistant',
917
508
  message: { content: [] },
918
- threadId,
919
509
  }, gid);
920
510
  sendUnifyOutput({
921
511
  type: 'assistant',
@@ -927,7 +517,6 @@ function handleEngineEvent(event, threadId, hctx) {
927
517
  input: event.input,
928
518
  }],
929
519
  },
930
- threadId: event.threadId || threadId,
931
520
  }, gid);
932
521
  break;
933
522
 
@@ -936,14 +525,10 @@ function handleEngineEvent(event, threadId, hctx) {
936
525
  type: 'tool_start',
937
526
  id: event.id,
938
527
  name: event.name,
939
- threadId: event.threadId || threadId,
940
528
  }, gid);
941
529
  break;
942
530
 
943
531
  case 'tool_end':
944
- // Capture tool result for the next-turn history so the paired
945
- // `role:'tool'` message is included when we hand `messages` back to
946
- // engine.query() (chat-completions requires tool_call_id pairing).
947
532
  if (hctx.toolResultsAccum) {
948
533
  hctx.toolResultsAccum.push({
949
534
  role: 'tool',
@@ -960,11 +545,7 @@ function handleEngineEvent(event, threadId, hctx) {
960
545
  content: event.output || '',
961
546
  is_error: event.isError || false,
962
547
  }],
963
- threadId: event.threadId || threadId,
964
548
  }, gid);
965
- if (THREAD_MUTATING_TOOLS.has(event.name)) {
966
- sendThreadListUpdate();
967
- }
968
549
  break;
969
550
 
970
551
  case 'turn_start':
@@ -978,7 +559,6 @@ function handleEngineEvent(event, threadId, hctx) {
978
559
  type: 'context_usage',
979
560
  inputTokens: event.inputTokens,
980
561
  outputTokens: event.outputTokens,
981
- threadId,
982
562
  }, gid);
983
563
  break;
984
564
 
@@ -987,23 +567,16 @@ function handleEngineEvent(event, threadId, hctx) {
987
567
  type: 'recall',
988
568
  entryCount: event.entryCount,
989
569
  cached: event.cached,
990
- threadId,
991
570
  }, gid);
992
571
  break;
993
572
 
994
573
  case 'consolidate':
995
- // Engine compressed the context — clear our accumulated history for
996
- // THIS thread only (task-320: per-thread history map).
997
- if (threadId) {
998
- messagesByThread.set(threadId, []);
999
- } else {
1000
- messagesByThread.clear();
1001
- }
574
+ // Engine compressed the context — clear our accumulated history.
575
+ conversationMessages = [];
1002
576
  sendUnifyEvent({
1003
577
  type: 'consolidate',
1004
578
  archivedCount: event.archivedCount,
1005
579
  extractedCount: event.extractedCount,
1006
- threadId,
1007
580
  }, gid);
1008
581
  break;
1009
582
 
@@ -1013,15 +586,10 @@ function handleEngineEvent(event, threadId, hctx) {
1013
586
  from: event.from,
1014
587
  to: event.to,
1015
588
  reason: event.reason,
1016
- threadId,
1017
589
  }, gid);
1018
590
  break;
1019
591
 
1020
592
  case 'reflection':
1021
- // PR-L: V7 tool-history reflection event. Two phases per occurrence:
1022
- // status: 'pending' — generation kicked off
1023
- // status: 'ready' — markdown content + durationMs
1024
- // status: 'error' — generation failed (history left unchanged)
1025
593
  sendUnifyEvent({
1026
594
  type: 'reflection',
1027
595
  trigger: event.trigger,
@@ -1031,7 +599,6 @@ function handleEngineEvent(event, threadId, hctx) {
1031
599
  content: event.content,
1032
600
  durationMs: event.durationMs,
1033
601
  error: event.error,
1034
- threadId,
1035
602
  }, gid);
1036
603
  break;
1037
604
 
@@ -1048,18 +615,13 @@ function handleEngineEvent(event, threadId, hctx) {
1048
615
  latencyMs: event.latencyMs,
1049
616
  ttfbMs: event.ttfbMs,
1050
617
  stopReason: event.stopReason,
1051
- // task-344: forward raw request / response (redacted) to web debug panel.
1052
618
  rawRequest: event.rawRequest,
1053
619
  rawResponse: event.rawResponse,
1054
- threadId,
1055
620
  }, gid);
1056
621
  break;
1057
622
 
1058
623
  case 'error': {
1059
624
  const errMsg = event.error?.message || 'Unknown error';
1060
- // Filter permission errors: show friendly one-time diagnostic
1061
- // instead of raw error. Subsequent permission errors are suppressed
1062
- // — the user already saw the actionable message once.
1063
625
  if (isPermissionErrorMsg(errMsg)) {
1064
626
  if (!_permissionDiagnosticSent) {
1065
627
  _permissionDiagnosticSent = true;
@@ -1071,17 +633,14 @@ function handleEngineEvent(event, threadId, hctx) {
1071
633
  text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
1072
634
  }],
1073
635
  },
1074
- threadId,
1075
636
  }, gid);
1076
637
  }
1077
- // Don't show subsequent permission errors.
1078
638
  } else {
1079
639
  sendUnifyOutput({
1080
640
  type: 'assistant',
1081
641
  message: {
1082
642
  content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
1083
643
  },
1084
- threadId,
1085
644
  }, gid);
1086
645
  }
1087
646
  break;
@@ -1094,21 +653,8 @@ function handleEngineEvent(event, threadId, hctx) {
1094
653
  }
1095
654
 
1096
655
  /**
1097
- * task-338-F4: Handle a unify_group_chat message from the web UI.
1098
- *
1099
- * Routes user text through the group coordinator's dispatch contract:
1100
- * 1. @-mentions → each mentioned vpId (intersected with group roster)
1101
- * 2. no mention → group.defaultVpId
1102
- * 3. no default VP → fallback to legacy single-agent handleUnifyChat
1103
- *
1104
- * Emits a `group_message` event tagged with `vpId` per dispatched target so
1105
- * frontend can render VP-scoped feedback. Does NOT itself run the engine —
1106
- * for each resolved target, it delegates into handleUnifyChat (which owns
1107
- * the Dispatcher + AbortController + timeout plumbing).
1108
- *
1109
- * Message shape: { type:'unify_group_chat', groupId, text, mentions? }
1110
- *
1111
- * @param {{groupId:string, text:string, mentions?:string[], agentId?:string, userId?:string, username?:string}} msg
656
+ * Handle a unify_group_chat message from the web UI. Routes user text
657
+ * through the group coordinator's dispatch contract.
1112
658
  */
1113
659
  export async function handleUnifyGroupChat(msg) {
1114
660
  if (!msg || typeof msg !== 'object') return;
@@ -1116,17 +662,11 @@ export async function handleUnifyGroupChat(msg) {
1116
662
  if (!text?.trim()) return;
1117
663
  const mentions = Array.isArray(msg.mentions) ? msg.mentions : [];
1118
664
 
1119
- // Fallback path #1 (PM red-line): no groupId on payload → skip group
1120
- // resolution entirely and hand the text to the legacy single-agent
1121
- // dispatcher. Ensures backward-compat with old clients that never learned
1122
- // about groups.
1123
665
  if (!groupId) {
1124
666
  await handleUnifyChat({ ...msg, prompt: text });
1125
667
  return;
1126
668
  }
1127
669
 
1128
- // Open the group handle (meta + jsonl log). Unresolvable groups take the
1129
- // legacy fallback — never silently drop the send.
1130
670
  let groupHandle = null;
1131
671
  try {
1132
672
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -1149,12 +689,7 @@ export async function handleUnifyGroupChat(msg) {
1149
689
  return;
1150
690
  }
1151
691
 
1152
- // Bug 2: When the user @-mentions a VP that exists in the library but is
1153
- // not yet in the group's roster, auto-add it. This is the natural "invite"
1154
- // gesture in group chat — failing here would punt to the legacy fallback
1155
- // and surface the misleading "only Yeaft is in this conversation" error
1156
- // even though the VP exists. We also ensure the group has a defaultVpId
1157
- // when its roster is non-empty, so unaddressed messages route correctly.
692
+ // Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
1158
693
  try {
1159
694
  const meta = groupHandle.getMeta();
1160
695
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -1172,7 +707,6 @@ export async function handleUnifyGroupChat(msg) {
1172
707
  } catch { /* skip strangers */ }
1173
708
  }
1174
709
  if (mutated) {
1175
- // Re-open with fresh meta so the coordinator sees the new roster.
1176
710
  try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
1177
711
  const { openGroup } = await import('./groups/group-store.js');
1178
712
  const { join } = await import('node:path');
@@ -1180,7 +714,6 @@ export async function handleUnifyGroupChat(msg) {
1180
714
  sendGroupRosterChanged(groupHandle.getMeta());
1181
715
  }
1182
716
  }
1183
- // Heal missing defaultVpId — pick roster[0] when one exists.
1184
717
  const meta2 = groupHandle.getMeta();
1185
718
  if (!meta2.defaultVpId && meta2.roster.length && yeaftDir) {
1186
719
  try {
@@ -1196,17 +729,6 @@ export async function handleUnifyGroupChat(msg) {
1196
729
  console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
1197
730
  }
1198
731
 
1199
- // Adapter layer (PM red-line: do NOT modify coordinator to fit this
1200
- // consumer). We drive `createCoordinator()` with a capturing `deliver`
1201
- // callback, collect its dispatched/fallback report, then translate each
1202
- // target into (a) a per-VP `group_message` event for the UI and (b) a
1203
- // per-VP prompt dispatched through the legacy `handleUnifyChat` path.
1204
- //
1205
- // Source of truth for mentions is the PAYLOAD (ChatInput parsed them
1206
- // once). We pass them through the coordinator's `input.meta` so the
1207
- // appended log carries the authoritative set — the coordinator will also
1208
- // run its own parse over `text` for routing, which matches the payload by
1209
- // construction (ChatInput's `parseMentions` is the same regex).
1210
732
  const { createCoordinator } = await import('./groups/coordinator.js');
1211
733
  const captured = [];
1212
734
  const coord = createCoordinator(groupHandle, {
@@ -1227,22 +749,12 @@ export async function handleUnifyGroupChat(msg) {
1227
749
  return;
1228
750
  }
1229
751
 
1230
- // Fallback path #2: coordinator resolved no targets and no default VP.
1231
- // Hand off to the legacy single-agent path so the text still runs.
1232
752
  const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
1233
753
  if (dispatchedIds.length === 0 && !report?.fallback) {
1234
754
  await handleUnifyChat({ ...msg, prompt: text });
1235
755
  return;
1236
756
  }
1237
757
 
1238
- // Per target: emit `group_message` tagged with `speakerVpId` (the VP
1239
- // being addressed — F3's GroupSelector binding consumes this) + dispatch
1240
- // through the Engine via handleUnifyChat with an `@vp-<id>` prompt prefix.
1241
- //
1242
- // task-fix: bracket each per-VP dispatch with `vp_typing_start` /
1243
- // `vp_typing_end` events so the frontend can render a per-speaker typing
1244
- // dot next to that VP's avatar (matching IM apps). Avoids the old
1245
- // "one global running cat for N concurrent speakers" ambiguity.
1246
758
  for (const { vpId, envelope } of captured) {
1247
759
  try {
1248
760
  sendUnifyEvent({
@@ -1273,9 +785,6 @@ export async function handleUnifyGroupChat(msg) {
1273
785
  groupId,
1274
786
  vpId,
1275
787
  speakerVpId: vpId,
1276
- // Bug 4: hand the coordinator down so handleUnifyChat can build a
1277
- // Router for the RouteForward tool. Field is namespaced with `_`
1278
- // to mark it as an internal hop, never sent over WS.
1279
788
  _groupCoordinator: coord,
1280
789
  });
1281
790
  } catch (err) {
@@ -1295,27 +804,8 @@ export async function handleUnifyGroupChat(msg) {
1295
804
 
1296
805
  /**
1297
806
  * Build the per-query VP context for the Engine.
1298
- *
1299
- * - Loads the addressed VP's persona via readVp() so the system prompt
1300
- * speaks in that VP's voice (Bug 3 fix).
1301
- * - When a GroupCoordinator handle is supplied, wraps it in a Router so
1302
- * the RouteForward tool actually forwards instead of returning
1303
- * `router_unavailable` (Bug 4 fix).
1304
- *
1305
- * Returns `undefined` when we have nothing to inject — keeps the legacy
1306
- * single-agent path identical to before.
1307
807
  */
1308
808
  export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
1309
- // PR-G fix (Option A): when no vpId is supplied, resolve a default so the
1310
- // engine still receives a vpPersona and the system prompt speaks as the
1311
- // VP — not as legacy Yeaft. Resolution order:
1312
- // 1. caller-supplied vpId (group/coordinator dispatch)
1313
- // 2. open group's defaultVpId (`groupCoordinator.group.getMeta()`)
1314
- // 3. session config `defaultVpId` (~/.yeaft/config.json)
1315
- // 4. first VP in the local library (scanVpLibrary)
1316
- // Cold-start (empty library) returns undefined — engine then falls back
1317
- // to the legacy Yeaft identity, which is the intentional baseline only
1318
- // when no VP is available at all.
1319
809
  let resolvedVpId = vpId;
1320
810
  if (!resolvedVpId) {
1321
811
  try {
@@ -1362,8 +852,7 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
1362
852
  try {
1363
853
  out.router = createRouter({ coordinator: groupCoordinator });
1364
854
  } catch {
1365
- // Router build failure is non-fatal — RouteForward will report
1366
- // router_unavailable and the VP can pivot.
855
+ // Router build failure is non-fatal.
1367
856
  }
1368
857
  }
1369
858
  return out;
@@ -1372,51 +861,33 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
1372
861
  /**
1373
862
  * Handle a unify_chat message from the web UI.
1374
863
  *
864
+ * H2.f.2: a new message cancels the prior in-flight controller (single
865
+ * conversation, not per-thread). The history accumulator is a flat array.
866
+ *
1375
867
  * @param {{ prompt: string, mode?: string, userId?: string, username?: string }} msg
1376
- * NOTE: `mode` is deprecated (task-297) — Unify now runs in a single unified mode.
1377
- * If present, a warning is logged and the field is ignored.
1378
868
  */
1379
869
  export async function handleUnifyChat(msg) {
1380
870
  const { prompt, mode } = msg;
1381
871
  if (!prompt?.trim()) return;
1382
- // Bug 3 / Bug 4 — when the upstream group dispatcher addresses a specific
1383
- // VP, it stamps `vpId` (and optionally a coordinator handle in
1384
- // `_groupCoordinator`). Hoist them now so they survive the lazy-init
1385
- // branch and reach the dispatcher.submit() queryOpts.
1386
872
  const vpId = typeof msg.vpId === 'string' && msg.vpId.trim() ? msg.vpId.trim() : null;
1387
873
  const groupCoordinator = msg._groupCoordinator || null;
1388
- // Bug 1: every event we emit during this query must carry the originating
1389
- // groupId so the frontend stamps arriving messages with the SEND-context
1390
- // group, not the user's CURRENT filter (which can change mid-reply).
1391
874
  const groupId = typeof msg.groupId === 'string' && msg.groupId.trim() ? msg.groupId.trim() : null;
1392
875
 
1393
- // Deprecation warning — task-297 removed chat/work mode distinction
1394
876
  if (mode !== undefined && mode !== null) {
1395
877
  console.warn('[Unify] unify_chat.mode is deprecated and ignored — Unify now runs in a single unified mode.');
1396
878
  }
1397
879
 
1398
880
  try {
1399
- // ─── Lazy-init session (reuse across queries — Engine manages history) ──
1400
881
  if (!session) {
1401
882
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1402
883
  session = await loadSession({
1403
884
  ...(yeaftDir && { dir: yeaftDir }),
1404
- // Enable all features — no lazy shortcuts
1405
885
  skipMCP: false,
1406
886
  skipSkills: false,
1407
887
  });
1408
888
 
1409
- // task-318 rev-1 fix: expose live setters on ctx so message-router's
1410
- // update_unify_settings branch can push the new caps into the
1411
- // registry + thread store without a session reload. Previously this
1412
- // object was null and setMaxConcurrent/setIdleArchiveDays were dead
1413
- // code — the config file was updated on disk but the running
1414
- // session continued with the old caps until next restart.
1415
889
  installUnifyRuntimeBridge(session);
1416
890
 
1417
- // PR-M1: install a sub-agent event sink so events emitted by sub-
1418
- // agent Engines surface to the web client. Frontend filters by the
1419
- // `agentId` field and renders them inside the sub-agent card.
1420
891
  try {
1421
892
  if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {
1422
893
  session.engine.setSubAgentEventSink((agentId, evt) => {
@@ -1429,14 +900,7 @@ export async function handleUnifyChat(msg) {
1429
900
  console.warn('[Unify] setSubAgentEventSink wiring failed:', err?.message || err);
1430
901
  }
1431
902
 
1432
- // task-317: run one idle-archive sweep at bootstrap, then schedule
1433
- // the hourly tick bound to this session.
1434
- runAutoArchiveSweep(session);
1435
- scheduleAutoArchive(session);
1436
-
1437
- // Bug 8: clean up any legacy `.archived-*` group directories left
1438
- // behind by the previous soft-archive flow. This is a one-shot
1439
- // boot-time sweep — physical deletes after this point are immediate.
903
+ // Bug 8: clean up legacy `.archived-*` group dirs at boot.
1440
904
  try {
1441
905
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1442
906
  if (yeaftDir) {
@@ -1449,16 +913,10 @@ export async function handleUnifyChat(msg) {
1449
913
  console.warn('[Unify] purgeArchivedGroups failed:', err?.message || err);
1450
914
  }
1451
915
 
1452
- // Create a stable conversationId for the Unify session
1453
916
  unifyConversationId = `unify-${Date.now()}`;
1454
917
 
1455
- // Restore per-thread history from persisted conversation store.
1456
- // task-320: bucket by threadId so each thread keeps its own context.
1457
- // task-fix: use restoreThreadHistoryFromRecent() so tool messages
1458
- // and toolCalls/toolCallId survive the restore.
1459
- restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
918
+ restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
1460
919
 
1461
- // Notify UI: session is ready with model info + conversationId
1462
920
  sendUnifyEvent({
1463
921
  type: 'session_ready',
1464
922
  conversationId: unifyConversationId,
@@ -1468,35 +926,20 @@ export async function handleUnifyChat(msg) {
1468
926
  mcpServers: session.status.mcpServers,
1469
927
  tools: session.status.tools,
1470
928
  });
1471
- // task-301 Part 2: initial thread snapshot so sidebar V2 renders
1472
- // the real 'main' thread (and any restored threads) right away.
1473
- sendThreadListUpdate();
1474
- // task-325b: full Working Status snapshot (superset with state +
1475
- // serverTime) so a freshly-connected client can restore inflight
1476
- // status without waiting for the next engine event.
1477
- sendThreadListSnapshot();
1478
- // task-334m: push initial groups snapshot so the Sidebar Groups
1479
- // section renders the full list immediately (including the D1
1480
- // default group seeded during session bootstrap).
1481
929
  sendGroupSnapshotBroadcast();
1482
930
  }
1483
931
 
1484
- // wave-6b: notify dream scheduler of user activity
1485
932
  if (session?.dreamScheduler) {
1486
933
  session.dreamScheduler.noteUserMessage();
1487
934
  }
1488
935
 
1489
- // ─── Per-call AbortController (task-320) ──
1490
- // Each call owns its own controller. Only once the router resolves the
1491
- // target thread do we register it into `abortByThread` and abort any
1492
- // prior controller on THAT same thread. Messages routed to different
1493
- // threads never alias each other's signals.
936
+ // Cancel any prior in-flight round before starting this one.
937
+ if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
938
+ try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
939
+ }
1494
940
  const abortCtrl = new AbortController();
1495
- /** @type {string | null} — set on routing_decision */
1496
- let resolvedThreadId = null;
941
+ currentAbortCtrl = abortCtrl;
1497
942
 
1498
- // ─── Timeout guard: abort query if LLM hangs beyond threshold ──
1499
- // Resets on every event — fires only after prolonged silence.
1500
943
  let queryTimer = null;
1501
944
  const resetQueryTimer = () => {
1502
945
  if (queryTimer) clearTimeout(queryTimer);
@@ -1510,128 +953,79 @@ export async function handleUnifyChat(msg) {
1510
953
  resetQueryTimer();
1511
954
 
1512
955
  try {
1513
- // ─── Collect assistant response for conversation history ──
1514
- let assistantTextParts = [];
1515
- // task-fix: preserve toolCalls + tool_result pairings across turns so
1516
- // the next engine.query({messages}) handoff stays valid for OpenAI
1517
- // chat-completions (avoids "No tool output found for function call").
1518
- const toolCallsAccum = [];
1519
- const toolResultsAccum = [];
1520
-
1521
- // task-310: route via Dispatcher pipeline (queue → router → registry →
1522
- // EngineInstance). The input is enqueued first so the UI observes the
1523
- // `input_queue_updated` snapshot before the router runs. An explicit
1524
- // `@thread-xxx` prefix on the message or an `override` field on the
1525
- // `unify_chat` payload becomes a dispatcher override — skipping the LLM.
1526
- const { prompt: cleanedPrompt, override: prefixOverride } = parseThreadPrefix(prompt);
1527
- const override = msg.override && typeof msg.override === 'object' && msg.override.threadId
1528
- ? msg.override
1529
- : prefixOverride;
1530
-
1531
- const { entry } = session.dispatcher.submit(cleanedPrompt, {
1532
- messageId: msg.messageId,
1533
- override: override || undefined,
1534
- queryOpts: buildVpQueryOpts({ vpId, groupCoordinator, groupId }),
1535
- });
1536
- sendUnifyEvent({
1537
- type: 'input_queue_updated',
1538
- total: 1,
1539
- pending: 1,
1540
- routing: 0,
1541
- dispatched: 0,
1542
- head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
1543
- }, groupId);
956
+ const assistantTextParts = [];
957
+ const toolCallsAccum = [];
958
+ const toolResultsAccum = [];
1544
959
 
1545
- const pipelineCtx = {
1546
- groupId,
1547
- onEngineEvent: (event, threadId) => handleEngineEvent(event, threadId, {
1548
- assistantTextParts,
1549
- toolCallsAccum,
1550
- toolResultsAccum,
1551
- resetQueryTimer,
1552
- groupId,
1553
- }),
1554
- onError: (err) => { throw err; },
1555
- };
960
+ const { entry } = session.dispatcher.submit(prompt, {
961
+ messageId: msg.messageId,
962
+ queryOpts: buildVpQueryOpts({ vpId, groupCoordinator, groupId }),
963
+ });
964
+ sendUnifyEvent({
965
+ type: 'input_queue_updated',
966
+ total: 1,
967
+ pending: 1,
968
+ routing: 0,
969
+ dispatched: 0,
970
+ head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
971
+ }, groupId);
1556
972
 
1557
- for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
1558
- resetQueryTimer();
1559
- // task-320: on routing_decision, bind this abort controller to the
1560
- // resolved target thread and abort any prior in-flight controller
1561
- // owned by that thread. Different threads don't alias.
1562
- if (pev && pev.type === 'routing_decision' && pev.targetThreadId && !resolvedThreadId) {
1563
- resolvedThreadId = pev.targetThreadId;
1564
- const prior = abortByThread.get(resolvedThreadId);
1565
- if (prior && prior !== abortCtrl) {
1566
- prior.abort();
1567
- }
1568
- abortByThread.set(resolvedThreadId, abortCtrl);
1569
- }
1570
- forwardPipelineEvent(pev, pipelineCtx);
1571
- }
973
+ const pipelineCtx = {
974
+ groupId,
975
+ onEngineEvent: (event) => handleEngineEvent(event, {
976
+ assistantTextParts,
977
+ toolCallsAccum,
978
+ toolResultsAccum,
979
+ resetQueryTimer,
980
+ groupId,
981
+ }),
982
+ onError: (err) => { throw err; },
983
+ };
1572
984
 
1573
- // ─── Query complete accumulate messages for context continuity ──
1574
- // task-320: per-thread history (no cross-thread contamination).
1575
- // task-fix: when the turn made tool calls, the assistant message MUST
1576
- // carry `toolCalls` AND each paired `role:'tool'` result must be
1577
- // appended — otherwise the next turn's chat-completions serializer
1578
- // emits `tool_calls` without matching `tool` messages → proxy 400
1579
- // "No tool output found for function call call_xxx".
1580
- const historyThread = resolvedThreadId || MAIN_THREAD_ID;
1581
- const threadMessages = getThreadMessages(historyThread);
1582
- threadMessages.push({ role: 'user', content: cleanedPrompt });
1583
-
1584
- const fullText = assistantTextParts.join('');
1585
- if (fullText || toolCallsAccum.length > 0) {
1586
- const assistantMsg = { role: 'assistant', content: fullText };
1587
- if (toolCallsAccum.length > 0) {
1588
- assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
1589
- id: tc.id,
1590
- name: tc.name,
1591
- input: tc.input,
1592
- }));
985
+ for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
986
+ resetQueryTimer();
987
+ forwardPipelineEvent(pev, pipelineCtx);
1593
988
  }
1594
- threadMessages.push(assistantMsg);
1595
989
 
1596
- // Append paired tool results, in order. Chat-completions requires
1597
- // one `role:'tool'` message per `tool_call_id` right after the
1598
- // assistant message that emitted them.
1599
- for (const tr of toolResultsAccum) {
1600
- threadMessages.push({
1601
- role: 'tool',
1602
- toolCallId: tr.toolCallId,
1603
- content: tr.content,
1604
- isError: tr.isError,
1605
- });
990
+ // Accumulate messages for context continuity.
991
+ conversationMessages.push({ role: 'user', content: prompt });
992
+
993
+ const fullText = assistantTextParts.join('');
994
+ if (fullText || toolCallsAccum.length > 0) {
995
+ const assistantMsg = { role: 'assistant', content: fullText };
996
+ if (toolCallsAccum.length > 0) {
997
+ assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
998
+ id: tc.id,
999
+ name: tc.name,
1000
+ input: tc.input,
1001
+ }));
1002
+ }
1003
+ conversationMessages.push(assistantMsg);
1004
+
1005
+ for (const tr of toolResultsAccum) {
1006
+ conversationMessages.push({
1007
+ role: 'tool',
1008
+ toolCallId: tr.toolCallId,
1009
+ content: tr.content,
1010
+ isError: tr.isError,
1011
+ });
1012
+ }
1606
1013
  }
1607
- }
1608
-
1609
- // ─── Signal turn end to UI ──
1610
- // Finish any streaming text
1611
- sendUnifyOutput({
1612
- type: 'assistant',
1613
- message: { content: [] },
1614
- }, groupId);
1615
- // Send result to clear processing state
1616
- sendUnifyOutput({
1617
- type: 'result',
1618
- result_text: '',
1619
- }, groupId);
1620
1014
 
1015
+ sendUnifyOutput({
1016
+ type: 'assistant',
1017
+ message: { content: [] },
1018
+ }, groupId);
1019
+ sendUnifyOutput({
1020
+ type: 'result',
1021
+ result_text: '',
1022
+ }, groupId);
1621
1023
  } finally {
1622
- // Always clear the timeout guard
1623
1024
  if (queryTimer) clearTimeout(queryTimer);
1624
1025
  }
1625
-
1626
1026
  } catch (err) {
1627
- // task-320: classify both DOM AbortError and LLMAbortError as
1628
- // "aborted" — LLMAbortError is thrown by the LLM adapters when the
1629
- // signal trips and must NOT render as a session error bubble.
1630
1027
  const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
1631
1028
  if (isAbort) {
1632
- // Silent abort — the new in-flight round (on the same thread) will
1633
- // produce its own output. Still send `result` so the frontend's
1634
- // processing spinner for this exact send clears.
1635
1029
  sendUnifyOutput({
1636
1030
  type: 'result',
1637
1031
  result_text: '',
@@ -1641,7 +1035,6 @@ export async function handleUnifyChat(msg) {
1641
1035
 
1642
1036
  console.error('[Unify] query error:', err.message);
1643
1037
 
1644
- // Filter permission errors at the session level too
1645
1038
  if (isPermissionErrorMsg(err.message)) {
1646
1039
  if (!_permissionDiagnosticSent) {
1647
1040
  _permissionDiagnosticSent = true;
@@ -1666,111 +1059,75 @@ export async function handleUnifyChat(msg) {
1666
1059
  },
1667
1060
  }, groupId);
1668
1061
  }
1669
- // Still send result to clear processing state
1670
1062
  sendUnifyOutput({
1671
1063
  type: 'result',
1672
1064
  result_text: '',
1673
1065
  }, groupId);
1674
1066
  } finally {
1675
- // task-320: only clear the per-thread slot if THIS controller is still
1676
- // the registered one. If a newer message already overwrote it, leaving
1677
- // the newer controller in the map is the correct state.
1678
- if (resolvedThreadId && abortByThread.get(resolvedThreadId) === abortCtrl) {
1679
- abortByThread.delete(resolvedThreadId);
1067
+ if (currentAbortCtrl && currentAbortCtrl.signal.aborted) {
1068
+ // Aborted controllers stay where they are; a new query will replace.
1680
1069
  }
1681
1070
  }
1682
1071
  }
1683
1072
 
1684
1073
  /**
1685
- * task-325c: user-initiated abort of an in-flight Unify query on ONE thread.
1074
+ * H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
1075
+ * `threadId` parameter; the new version aborts the single in-flight
1076
+ * controller. The `threadId` field on `msg` is accepted but ignored for
1077
+ * back-compat with older clients.
1686
1078
  *
1687
- * Cancels the AbortController registered for `msg.threadId` (if any). Silent
1688
- * no-op when the thread has no in-flight round — users clicking Stop on an
1689
- * already-idle thread should not trigger an error bubble. Emits an
1690
- * `unify_aborted` event for UI acknowledgement and a fresh
1691
- * `thread_list_updated` so inflight pills clear immediately.
1692
- *
1693
- * Red line (PM): the `thread_list_updated` event name is preserved; no
1694
- * new per-thread abort signal leaks into `Engine.abort()`'s signature.
1695
- *
1696
- * @param {{ threadId?: string }} msg
1079
+ * @param {{ threadId?: string }} _msg
1697
1080
  * @returns {{ aborted: string[], all: boolean }}
1698
1081
  */
1699
- export function handleUnifyAbortThread(msg = {}) {
1082
+ export function handleUnifyAbortThread(_msg = {}) {
1700
1083
  const aborted = [];
1701
- const threadId = msg && msg.threadId;
1702
- if (threadId) {
1703
- const ctrl = abortByThread.get(threadId);
1704
- if (ctrl) {
1705
- try { ctrl.abort(); } catch { /* best-effort */ }
1706
- abortByThread.delete(threadId);
1707
- aborted.push(threadId);
1708
- }
1084
+ if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
1085
+ try { currentAbortCtrl.abort(); aborted.push('main'); } catch { /* best-effort */ }
1709
1086
  }
1087
+ currentAbortCtrl = null;
1710
1088
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
1711
- sendThreadListUpdate();
1712
1089
  return { aborted, all: false };
1713
1090
  }
1714
1091
 
1715
1092
  /**
1716
- * task-325c: user-initiated abort of ALL in-flight Unify queries.
1717
- *
1718
- * Iterates every registered controller, aborts it, then clears the map.
1719
- * Always emits `unify_aborted` with `all:true` (even when nothing was
1720
- * running) so the UI can confirm the click landed.
1721
- *
1093
+ * H2.f.2: abort all (single conversation same as abort one).
1722
1094
  * @returns {{ aborted: string[], all: boolean }}
1723
1095
  */
1724
1096
  export function handleUnifyAbortAll() {
1725
1097
  const aborted = [];
1726
- for (const [threadId, ctrl] of abortByThread.entries()) {
1727
- try { ctrl.abort(); } catch { /* best-effort */ }
1728
- aborted.push(threadId);
1098
+ if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
1099
+ try { currentAbortCtrl.abort(); aborted.push('main'); } catch { /* best-effort */ }
1729
1100
  }
1730
- abortByThread.clear();
1101
+ currentAbortCtrl = null;
1731
1102
  sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
1732
- sendThreadListUpdate();
1733
1103
  return { aborted, all: true };
1734
1104
  }
1735
1105
 
1736
1106
  /**
1737
- * Unified dispatcher bound onto `session.abort({ threadId?, all? })`.
1738
- * Routes to {@link handleUnifyAbortThread} or {@link handleUnifyAbortAll}
1739
- * per input. Kept exported so message-router and tests can call it too.
1740
- *
1107
+ * Unified abort entry: routes by payload shape.
1741
1108
  * @param {{ threadId?: string, all?: boolean }} [opts]
1742
1109
  */
1743
1110
  export function abortUnifySession(opts = {}) {
1744
1111
  if (opts && opts.all) return handleUnifyAbortAll();
1745
1112
  if (opts && opts.threadId) return handleUnifyAbortThread({ threadId: opts.threadId });
1746
- // No payload — conservative default: abort nothing, just emit ack so
1747
- // callers see the no-op round-trip. Matches PM "don't accidentally
1748
- // nuke everything on a bare click".
1113
+ // No payload — conservative no-op ack.
1749
1114
  sendUnifyEvent({ type: 'unify_aborted', aborted: [], all: false });
1750
1115
  return { aborted: [], all: false };
1751
1116
  }
1752
1117
 
1753
- /**
1754
- * Test-only: seed / inspect the abort registry without spinning up a
1755
- * full session. Never use from production code — the prod registry is
1756
- * managed by handleUnifyChat's per-query controller lifecycle.
1757
- * @private
1758
- */
1759
- export function __testSeedAbortController(threadId, ctrl) {
1760
- abortByThread.set(threadId, ctrl);
1118
+ /** Test-only: seed the in-flight controller. */
1119
+ export function __testSeedAbortController(_threadId, ctrl) {
1120
+ // _threadId is ignored H2.f.2 has a single controller.
1121
+ currentAbortCtrl = ctrl;
1761
1122
  }
1762
1123
 
1763
- /** Test-only: returns the set of thread ids currently registered. */
1124
+ /** Test-only: returns ['main'] when a controller is registered, else []. */
1764
1125
  export function __testGetRegisteredThreadIds() {
1765
- return [...abortByThread.keys()];
1126
+ return currentAbortCtrl && !currentAbortCtrl.signal.aborted ? ['main'] : [];
1766
1127
  }
1767
1128
 
1768
1129
  /**
1769
- * wave-6b: Handle manual dream trigger from VP detail page.
1770
- * Payload: { vpId?: string } (optional, defaults to 'default').
1771
- * Emits unify_dream_result with the dream outcome.
1772
- *
1773
- * @param {{ vpId?: string }} msg
1130
+ * Manual dream trigger from VP detail page.
1774
1131
  */
1775
1132
  export async function handleUnifyDreamTrigger(msg = {}) {
1776
1133
  if (!session?.dreamScheduler) {
@@ -1809,39 +1166,12 @@ export async function handleUnifyDreamTrigger(msg = {}) {
1809
1166
  }
1810
1167
  }
1811
1168
 
1812
- /**
1813
- * Handle mode switch from the web UI.
1814
- * DEPRECATED (task-297): Unify no longer has chat/work mode distinction.
1815
- * Retained as a no-op with warning for backward compatibility.
1816
- * @param {{ mode?: string }} _msg
1817
- */
1169
+ /** Deprecated mode switch — Unify is single-mode. */
1818
1170
  export function handleUnifyModeSwitch(_msg) {
1819
1171
  console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
1820
1172
  }
1821
1173
 
1822
- /**
1823
- * R6 G2 — VP/Feature memory browser query.
1824
- *
1825
- * Reads from session.memoryShardStore (R6 shard-based memory) and replies
1826
- * with a time-sorted list of entries scoped to the requested vpId / featureId.
1827
- * The web UI's MemoryCard / MemoryTraceModal consume the reply.
1828
- *
1829
- * Request shape:
1830
- * { type: 'unify_memory_query',
1831
- * vpId?: string, featureId?: string,
1832
- * limit?: number, requestId?: string }
1833
- *
1834
- * Reply shape:
1835
- * { type: 'unify_memory_query_result',
1836
- * scope: { vpId, featureId },
1837
- * entries: Array<thinEntry>, // shape from shard-store mapRecordToThinEntry
1838
- * requestId? }
1839
- *
1840
- * Per D2 the query is scoped — the LLM owns memory recall via the
1841
- * memory_query tool; this surface is purely UI browsing (read-only).
1842
- *
1843
- * @param {{ vpId?: string, featureId?: string, limit?: number, requestId?: string }} msg
1844
- */
1174
+ /** Read-only memory query for the UI memory browser. */
1845
1175
  export function handleUnifyMemoryQuery(msg = {}) {
1846
1176
  const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1847
1177
  const vpId = typeof msg.vpId === 'string' ? msg.vpId : null;
@@ -1866,7 +1196,6 @@ export function handleUnifyMemoryQuery(msg = {}) {
1866
1196
  if (featureId) filter.feature = featureId;
1867
1197
  const res = session.memoryShardStore.query(filter);
1868
1198
  const list = Array.isArray(res?.results) ? res.results : [];
1869
- // Time-sorted desc on updatedAt / createdAt.
1870
1199
  list.sort((a, b) => {
1871
1200
  const ax = (a && (a.updatedAt || a.createdAt)) || 0;
1872
1201
  const bx = (b && (b.updatedAt || b.createdAt)) || 0;
@@ -1880,23 +1209,7 @@ export function handleUnifyMemoryQuery(msg = {}) {
1880
1209
  }
1881
1210
  }
1882
1211
 
1883
- /**
1884
- * R6 G2 — Open the source message behind a memory entry (memory_trace).
1885
- *
1886
- * Resolves entry → sourceRef.{conversationId, messageId} (or threadId/range)
1887
- * via MemoryShardStore.get(entryId), then echoes the reference + the entry
1888
- * for the MemoryTraceModal to render. The trace itself is a read-only
1889
- * surface — the UI follows the conversationId/messageId to MessageList.
1890
- *
1891
- * Request shape:
1892
- * { type: 'unify_memory_trace', entryId: string, requestId?: string }
1893
- *
1894
- * Reply shape:
1895
- * { type: 'unify_memory_trace_result',
1896
- * entryId, entry: object|null, sourceRef: object|null, requestId? }
1897
- *
1898
- * @param {{ entryId?: string, requestId?: string }} msg
1899
- */
1212
+ /** Open the source message behind a memory entry. */
1900
1213
  export function handleUnifyMemoryTrace(msg = {}) {
1901
1214
  const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1902
1215
  const entryId = typeof msg.entryId === 'string' ? msg.entryId : null;
@@ -1922,21 +1235,7 @@ export function handleUnifyMemoryTrace(msg = {}) {
1922
1235
  }
1923
1236
  }
1924
1237
 
1925
- /**
1926
- * R6 G1a — Fetch a feature's summary history (revision chain).
1927
- *
1928
- * Streams the group log filtered by `featureId` and `meta.kind === 'summary'`,
1929
- * separates `current` (≤10 most recent non-superseded) from `archived` rows
1930
- * per §Δ31.5. Default `includeArchived: false` keeps the wire payload small;
1931
- * the UI's "Show archived" button re-issues with the flag set.
1932
- *
1933
- * Request shape:
1934
- * { type: 'unify_fetch_summary_history', featureId, includeArchived?: bool }
1935
- *
1936
- * Reply shape:
1937
- * { type: 'unify_summary_history', featureId, revisions: [...],
1938
- * archived: [...]|null, error?: string, requestId? }
1939
- */
1238
+ /** Fetch a feature's summary history (revision chain). */
1940
1239
  export async function handleUnifyFetchSummaryHistory(msg = {}) {
1941
1240
  const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1942
1241
  const featureId = typeof msg.featureId === 'string' ? msg.featureId : null;
@@ -1994,7 +1293,6 @@ export async function handleUnifyFetchSummaryHistory(msg = {}) {
1994
1293
  if (supersededIds.has(s.id)) archived.push(s);
1995
1294
  else current.push(s);
1996
1295
  }
1997
- // §Δ31.5: keep only 10 in current; oldest extras spill to archived.
1998
1296
  const overflow = current.slice(10);
1999
1297
  const trimmedCurrent = current.slice(0, 10);
2000
1298
  if (overflow.length) archived.push(...overflow);
@@ -2012,17 +1310,7 @@ export async function handleUnifyFetchSummaryHistory(msg = {}) {
2012
1310
  }
2013
1311
  }
2014
1312
 
2015
- /**
2016
- * R6 G1a — Feature affiliation CRUD (relate / unrelate / kick_vp / abort_vp).
2017
- *
2018
- * Single envelope so the UI doesn't fan out four separate WS message types
2019
- * for housekeeping verbs. Replies with `unify_feature_crud_result`.
2020
- *
2021
- * - relate { featureId, relatedFeatureId } — bidirectional Δ27 link
2022
- * - unrelate { featureId, relatedFeatureId } — drop both directions
2023
- * - kick_vp { featureId, vpId } — featureStore.removeMember
2024
- * - abort_vp { featureId, vpId } — abort that VP's in-flight engine inside the feature
2025
- */
1313
+ /** Feature affiliation CRUD (relate / unrelate / kick_vp / abort_vp). */
2026
1314
  export async function handleUnifyFeatureCrud(msg = {}) {
2027
1315
  const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
2028
1316
  const op = typeof msg.op === 'string' ? msg.op : null;
@@ -2074,14 +1362,10 @@ export async function handleUnifyFeatureCrud(msg = {}) {
2074
1362
 
2075
1363
  if (op === 'abort_vp') {
2076
1364
  if (!vpId) { reply({ ok: false, error: 'missing_vp_id' }); return; }
2077
- // Reuse per-thread abort registry keyed by (featureId,vpId) tuple if
2078
- // the engine instance is registered there. For v1 we surface success
2079
- // and let the engine settle; full per-VP cancellation is owned by
2080
- // the engine registry in 334o follow-up.
2081
- const reg = session?.engineRegistry;
2082
- if (reg && typeof reg.abortVpInFeature === 'function') {
2083
- reg.abortVpInFeature(featureId, vpId);
2084
- }
1365
+ // H2.f.2: per-VP abort no longer routed through engineRegistry the
1366
+ // single engine handles its own abort via currentAbortCtrl. Reply
1367
+ // ok:true so the UI surface still works; deeper per-VP cancel is a
1368
+ // separate task.
2085
1369
  reply({ ok: true });
2086
1370
  return;
2087
1371
  }
@@ -2093,141 +1377,36 @@ export async function handleUnifyFeatureCrud(msg = {}) {
2093
1377
  }
2094
1378
 
2095
1379
  /**
2096
- * task-313: merge a source thread into a target thread.
2097
- * Reassigns messages, archives source with `mergedInto`, terminates source
2098
- * engine instance, broadcasts `thread_merged` + `thread_list_updated`.
2099
- *
2100
- * @param {{ sourceId: string, targetId: string }} msg
1380
+ * H2.f.2 stub: thread merge no longer exists. Kept for back-compat with
1381
+ * older message-router cases emits a failed-ack.
2101
1382
  */
2102
1383
  export function handleUnifyMergeThread(msg) {
2103
- if (!session) {
2104
- console.warn('[Unify] unify_merge_thread received before session init — ignored');
2105
- return;
2106
- }
2107
1384
  const { sourceId, targetId } = msg || {};
2108
- if (!sourceId || !targetId) {
2109
- sendUnifyEvent({ type: 'thread_merge_failed', sourceId, targetId, error: 'sourceId and targetId required' });
2110
- return;
2111
- }
2112
-
2113
- let reassigned = 0;
2114
- try {
2115
- // 1. Reassign messages (ConversationStore) — preserves sourceThreadId pill.
2116
- if (session.conversationStore && typeof session.conversationStore.reassignThread === 'function') {
2117
- reassigned = session.conversationStore.reassignThread(sourceId, targetId);
2118
- }
2119
- // 2. Mutate ThreadStore (mergedInto + archived + counter rollup).
2120
- const store = session.threadStore || getThreadStore();
2121
- store.mergeThread(sourceId, targetId);
2122
- // 3. Terminate + forget the source engine instance — releases its slot.
2123
- if (session.engineRegistry) {
2124
- session.engineRegistry.delete(sourceId);
2125
- // If the registry was tracking source as current, move to target.
2126
- if (typeof session.engineRegistry.setCurrent === 'function'
2127
- && session.engineRegistry.currentThreadId === sourceId) {
2128
- session.engineRegistry.setCurrent(targetId);
2129
- }
2130
- }
2131
- // 4. Flush ThreadStore so the merge is durable before the UI refreshes.
2132
- if (typeof store.flush === 'function') store.flush();
2133
- } catch (err) {
2134
- sendUnifyEvent({
2135
- type: 'thread_merge_failed',
2136
- sourceId,
2137
- targetId,
2138
- error: err?.message || String(err),
2139
- });
2140
- return;
2141
- }
2142
-
2143
- // 5. Broadcast the merge + refreshed thread list.
2144
1385
  sendUnifyEvent({
2145
- type: 'thread_merged',
1386
+ type: 'thread_merge_failed',
2146
1387
  sourceId,
2147
1388
  targetId,
2148
- reassignedMessages: reassigned,
1389
+ error: 'thread merge is no longer supported (H2 single-conversation)',
2149
1390
  });
2150
- sendThreadListUpdate();
2151
1391
  }
2152
1392
 
2153
1393
  /**
2154
- * task-314: fork a new thread from an existing one at a specific message.
2155
- * Copies every message up to (and including) `atMessageId` from the source
2156
- * thread onto a fresh thread, stamps `forkedFrom` on the new thread record,
2157
- * and broadcasts `thread_forked` + refreshed thread list. The source is not
2158
- * modified.
2159
- *
2160
- * @param {{ sourceThreadId: string, atMessageId: string, name?: string }} msg
1394
+ * H2.f.2 stub: thread fork no longer exists.
2161
1395
  */
2162
1396
  export function handleUnifyForkThread(msg) {
2163
- if (!session) {
2164
- console.warn('[Unify] unify_fork_thread received before session init — ignored');
2165
- return;
2166
- }
2167
- const { sourceThreadId, atMessageId, name } = msg || {};
2168
- if (!sourceThreadId || !atMessageId) {
2169
- sendUnifyEvent({
2170
- type: 'thread_fork_failed',
2171
- sourceThreadId,
2172
- atMessageId,
2173
- error: 'sourceThreadId and atMessageId required',
2174
- });
2175
- return;
2176
- }
2177
-
2178
- let copied = 0;
2179
- let newThread;
2180
- try {
2181
- // 1. Create the fork record on ThreadStore (sets forkedFrom pointer).
2182
- const store = session.threadStore || getThreadStore();
2183
- newThread = store.forkThread(sourceThreadId, atMessageId, { name });
2184
- // 2. Copy messages up to the cursor (inclusive) into the new thread.
2185
- if (session.conversationStore && typeof session.conversationStore.copyThreadUpTo === 'function') {
2186
- copied = session.conversationStore.copyThreadUpTo(
2187
- sourceThreadId,
2188
- newThread.id,
2189
- atMessageId,
2190
- );
2191
- }
2192
- // 3. Roll cached counters on the new thread so the sidebar shows the
2193
- // copied messages without needing a rebuild pass.
2194
- if (copied > 0) {
2195
- newThread.messageCount = copied;
2196
- newThread.lastMessageAt = Date.now();
2197
- newThread.lastActivityAt = newThread.lastMessageAt;
2198
- }
2199
- // 4. Flush so the new thread is durable before the UI refreshes.
2200
- if (typeof store.flush === 'function') store.flush();
2201
- } catch (err) {
2202
- sendUnifyEvent({
2203
- type: 'thread_fork_failed',
2204
- sourceThreadId,
2205
- atMessageId,
2206
- error: err?.message || String(err),
2207
- });
2208
- return;
2209
- }
2210
-
2211
- // 5. Broadcast the fork + refreshed thread list.
1397
+ const { sourceThreadId, atMessageId } = msg || {};
2212
1398
  sendUnifyEvent({
2213
- type: 'thread_forked',
1399
+ type: 'thread_fork_failed',
2214
1400
  sourceThreadId,
2215
- targetThreadId: newThread.id,
2216
- forkedAtMessageId: atMessageId,
2217
- copiedMessages: copied,
1401
+ atMessageId,
1402
+ error: 'thread fork is no longer supported (H2 single-conversation)',
2218
1403
  });
2219
- sendThreadListUpdate();
2220
1404
  }
2221
1405
 
2222
- /**
2223
- * Handle model switch from the web UI.
2224
- * Updates Engine's config so the next query uses the new model.
2225
- * @param {{ model: string }} msg
2226
- */
1406
+ /** Handle model switch from the web UI. */
2227
1407
  export function handleUnifyModelSwitch(msg) {
2228
1408
  if (!session || !msg.model) return;
2229
1409
 
2230
- // Validate: model must be in availableModels list
2231
1410
  const available = session.config.availableModels || [];
2232
1411
  const found = available.some(m => m.id === msg.model);
2233
1412
  if (!found) {
@@ -2235,10 +1414,8 @@ export function handleUnifyModelSwitch(msg) {
2235
1414
  return;
2236
1415
  }
2237
1416
 
2238
- // Update Engine's model for subsequent queries
2239
1417
  session.config.model = msg.model;
2240
1418
 
2241
- // Confirm switch to frontend
2242
1419
  sendUnifyEvent({
2243
1420
  type: 'model_switched',
2244
1421
  model: msg.model,
@@ -2246,14 +1423,10 @@ export function handleUnifyModelSwitch(msg) {
2246
1423
  }
2247
1424
 
2248
1425
  /**
2249
- * Handle history load request from the web UI.
2250
- * Loads recent messages from ConversationStore and sends them through
2251
- * the standard claude_output rendering pipeline (sendUnifyOutput).
2252
- *
2253
- * @param {{ limit?: number }} msg
1426
+ * Handle history load request. Loads recent messages from ConversationStore
1427
+ * and replays them through the standard claude_output pipeline.
2254
1428
  */
2255
1429
  export async function handleUnifyLoadHistory(msg) {
2256
- // Lazy-init session if needed (same logic as handleUnifyChat)
2257
1430
  if (!session) {
2258
1431
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2259
1432
  session = await loadSession({
@@ -2261,33 +1434,14 @@ export async function handleUnifyLoadHistory(msg) {
2261
1434
  skipMCP: false,
2262
1435
  skipSkills: false,
2263
1436
  });
2264
- // task-318 rev-1 fix: wire live setters; see handleUnifyChat.
2265
1437
  installUnifyRuntimeBridge(session);
2266
- // task-317: sweep + schedule auto-archive on history-load path too.
2267
- runAutoArchiveSweep(session);
2268
- scheduleAutoArchive(session);
2269
1438
 
2270
1439
  unifyConversationId = `unify-${Date.now()}`;
2271
1440
 
2272
- // Restore per-thread history from persisted conversation store.
2273
- // task-320: bucket by threadId so each thread keeps its own context.
2274
- // task-fix: use restoreThreadHistoryFromRecent() so tool messages
2275
- // and toolCalls/toolCallId survive the restore.
2276
- restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1441
+ restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
2277
1442
  }
2278
1443
 
2279
- // task-322: replay `session_ready` + `thread_list_updated` UNCONDITIONALLY
2280
- // on every load-history call. The module-level `session` is a process-wide
2281
- // singleton — on page refresh the agent reuses it, so the lazy-init block
2282
- // above is skipped. The frontend's `enterUnify()` resets
2283
- // `unifyModel=null`, `unifyThreads=[]`, `unifySessionReady=false` every
2284
- // time, so without this replay the UI is left with the model selector
2285
- // stuck on the placeholder, sidebar empty, and the local→agent
2286
- // conversationId migration never triggers (leaving the main pane blank).
2287
- //
2288
- // Frontend is idempotent: the `session_ready` handler either migrates
2289
- // local→agent convId (first time) or just updates model/status fields
2290
- // (repeat) — receiving it twice is a no-op on state invariants.
1444
+ // Always replay session_ready so refresh / reconnect rebuilds UI state.
2291
1445
  sendUnifyEvent({
2292
1446
  type: 'session_ready',
2293
1447
  conversationId: unifyConversationId,
@@ -2297,30 +1451,14 @@ export async function handleUnifyLoadHistory(msg) {
2297
1451
  mcpServers: session.status.mcpServers,
2298
1452
  tools: session.status.tools,
2299
1453
  });
2300
- sendThreadListUpdate();
2301
- // task-325b: after a page refresh / reconnect the frontend needs the
2302
- // full Working Status snapshot to rebuild the panel (which thread is
2303
- // running, idle, aborted). `thread_list_updated` is intentionally a
2304
- // mutation-delta stream; `thread_list_snapshot` is the single
2305
- // authoritative "everything right now" payload.
2306
- sendThreadListSnapshot();
2307
- // task-334m: replay groups snapshot so Sidebar Groups rebuilds on refresh.
2308
1454
  sendGroupSnapshotBroadcast();
2309
1455
 
2310
- // Honor explicit limit:0 — frontend uses it on Unify re-entry to refresh
2311
- // metadata (model/status/group snapshot via the unconditional replay
2312
- // above) without re-streaming the message history.
2313
1456
  const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
2314
1457
  const messages = limit > 0 ? session.conversationStore.loadRecent(limit) : [];
2315
1458
  const compactSummary = session.conversationStore.readCompactSummary();
2316
1459
 
2317
- // Send each message through standard claude_output rendering pipeline
2318
1460
  for (const m of messages) {
2319
1461
  if (m.role === 'user') {
2320
- // Bug 6: forward groupId per message so the frontend re-stamps
2321
- // replayed messages into their originating group instead of the
2322
- // user's current filter (which would otherwise hide them when
2323
- // switching groups).
2324
1462
  sendUnifyOutput({ type: 'user', message: { content: m.content } }, m.groupId || null);
2325
1463
  } else if (m.role === 'assistant') {
2326
1464
  sendUnifyOutput({
@@ -2331,7 +1469,6 @@ export async function handleUnifyLoadHistory(msg) {
2331
1469
  }
2332
1470
  }
2333
1471
 
2334
- // Signal history loading complete
2335
1472
  sendUnifyEvent({
2336
1473
  type: 'history_loaded',
2337
1474
  count: messages.length,
@@ -2342,19 +1479,14 @@ export async function handleUnifyLoadHistory(msg) {
2342
1479
  }
2343
1480
 
2344
1481
  /**
2345
- * Reset Unify session (for clear messages or config change).
2346
- * After shutdown, immediately re-initializes the session and sends
2347
- * session_ready so the frontend picks up updated models/config.
1482
+ * Reset Unify session. Aborts the in-flight controller, tears down the
1483
+ * session, then re-initialises so the frontend gets fresh config.
2348
1484
  */
2349
1485
  export async function resetUnifySession() {
2350
- // task-320: abort ALL in-flight controllers (every thread) before
2351
- // tearing down the session. Leaves no dangling round still writing
2352
- // to stdout after shutdown.
2353
- for (const ctrl of abortByThread.values()) {
2354
- try { ctrl.abort(); } catch { /* ignore */ }
1486
+ if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
1487
+ try { currentAbortCtrl.abort(); } catch { /* ignore */ }
2355
1488
  }
2356
- abortByThread.clear();
2357
- // Clean up VP subscriber to prevent stale sends after reset.
1489
+ currentAbortCtrl = null;
2358
1490
  if (_vpUnsubscribe) {
2359
1491
  try { _vpUnsubscribe(); } catch { /* ignore */ }
2360
1492
  _vpUnsubscribe = null;
@@ -2364,9 +1496,8 @@ export async function resetUnifySession() {
2364
1496
  session = null;
2365
1497
  }
2366
1498
  unifyConversationId = null;
2367
- messagesByThread.clear();
1499
+ conversationMessages = [];
2368
1500
 
2369
- // Re-initialize session immediately so frontend gets updated config
2370
1501
  try {
2371
1502
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2372
1503
  session = await loadSession({
@@ -2374,20 +1505,11 @@ export async function resetUnifySession() {
2374
1505
  skipMCP: false,
2375
1506
  skipSkills: false,
2376
1507
  });
2377
- // task-318 rev-1 fix: wire live setters; see handleUnifyChat.
2378
1508
  installUnifyRuntimeBridge(session);
2379
- // task-317: sweep + re-schedule auto-archive on reset too (the old
2380
- // interval was bound to the previous session; reschedule against the
2381
- // fresh one so timer references don't dangle).
2382
- runAutoArchiveSweep(session);
2383
- scheduleAutoArchive(session);
2384
1509
 
2385
1510
  unifyConversationId = `unify-${Date.now()}`;
2386
1511
 
2387
- // Restore per-thread history for LLM context (task-320).
2388
- // task-fix: use restoreThreadHistoryFromRecent() so tool messages
2389
- // and toolCalls/toolCallId survive the restore.
2390
- restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
1512
+ restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
2391
1513
 
2392
1514
  sendUnifyEvent({
2393
1515
  type: 'session_ready',
@@ -2398,11 +1520,6 @@ export async function resetUnifySession() {
2398
1520
  mcpServers: session.status.mcpServers,
2399
1521
  tools: session.status.tools,
2400
1522
  });
2401
- // task-301 Part 2: re-push thread snapshot after session reset.
2402
- sendThreadListUpdate();
2403
- // task-325b: also push the full Working Status snapshot so the UI
2404
- // doesn't retain stale "running" badges from the prior session.
2405
- sendThreadListSnapshot();
2406
1523
  } catch (err) {
2407
1524
  console.error('[Unify] Failed to re-initialize session after reset:', err.message);
2408
1525
  }