@yeaft/webchat-agent 0.1.671 → 0.1.672

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 { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -378,12 +378,7 @@ export async function handleMessage(msg) {
378
378
  break;
379
379
  }
380
380
 
381
- // Unify — independent chat via Engine
382
- case 'unify_chat':
383
- await handleUnifyChat(msg);
384
- break;
385
-
386
- // task-338-F4: Unify group-chat dispatch via GroupCoordinator.
381
+ // Unify — single conversation backed by the default group.
387
382
  case 'unify_group_chat':
388
383
  await handleUnifyGroupChat(msg);
389
384
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.671",
3
+ "version": "0.1.672",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * The previous RoleInstance map (per (vpId, groupId) pair, with LRU
9
9
  * eviction) was removed in GC.2 — production fans out per-VP via
10
- * `handleUnifyChat` directly and never instantiated RoleInstance.
10
+ * `handleUnifyGroupChat` -> `runVpTurn` directly and never instantiated
11
+ * RoleInstance.
11
12
  */
12
13
 
13
14
  export class Registry {
@@ -18,6 +18,8 @@
18
18
  * ranges removed.
19
19
  */
20
20
 
21
+ import { join } from 'node:path';
22
+ import { existsSync } from 'node:fs';
21
23
  import { loadSession } from './session.js';
22
24
  import { sendToServer } from '../connection/buffer.js';
23
25
  import ctx from '../context.js';
@@ -38,6 +40,9 @@ import {
38
40
  setGroupDefaultVp,
39
41
  snapshotGroups,
40
42
  } from './groups/group-crud.js';
43
+ import { openGroup, loadGroupMeta } from './groups/group-store.js';
44
+ import { createCoordinator } from './groups/coordinator.js';
45
+ import { seedDefaultGroup } from './groups/seed-default.js';
41
46
 
42
47
  /** @type {import('./session.js').Session | null} */
43
48
  let session = null;
@@ -578,50 +583,101 @@ function handleEngineEvent(event, hctx) {
578
583
  }
579
584
 
580
585
  /**
581
- * Handle a unify_group_chat message from the web UI. Routes user text
582
- * through the group coordinator's dispatch contract.
586
+ * Handle a unify_group_chat message from the web UI the SOLE Unify
587
+ * conversation entry point.
588
+ *
589
+ * Contract (post-consolidation, was previously split between handleUnifyChat
590
+ * and handleUnifyGroupChat):
591
+ * - Frontend ALWAYS sends `unify_group_chat`. There is no `unify_chat`.
592
+ * - `groupId` defaults to `'grp_default'` if missing — Unify is a single
593
+ * conversation backed by the default group; the user is never "outside"
594
+ * a group.
595
+ * - If the group dir doesn't exist and the resolved id is `'grp_default'`,
596
+ * it is seeded on the fly. Any other unknown groupId surfaces an error.
597
+ * - Coordinator is MANDATORY (this is what guarantees ctx.router is wired
598
+ * so the `route_forward` tool can never trip `router_unavailable`).
599
+ * - No legacy "no-group" fallback paths — they were the source of the
600
+ * router_unavailable bug fixed in v0.1.671.
583
601
  */
584
602
  export async function handleUnifyGroupChat(msg) {
585
603
  if (!msg || typeof msg !== 'object') return;
586
- const { groupId, text } = msg;
604
+ const { text } = msg;
587
605
  if (!text?.trim()) return;
588
606
  const mentions = Array.isArray(msg.mentions) ? msg.mentions : [];
589
-
590
- if (!groupId) {
591
- await handleUnifyChat({ ...msg, prompt: text });
607
+ const groupId = (typeof msg.groupId === 'string' && msg.groupId.trim())
608
+ ? msg.groupId.trim()
609
+ : 'grp_default';
610
+
611
+ // yeaftDir is a hard prerequisite for both session boot and group seeding;
612
+ // validate BEFORE booting so a misconfigured agent doesn't leave a zombie
613
+ // session lying around.
614
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
615
+ if (!yeaftDir) {
616
+ sendUnifyOutput({
617
+ type: 'assistant',
618
+ message: { content: [{ type: 'text', text: '⚠️ Unify session error: no yeaft directory configured.' }] },
619
+ }, groupId);
620
+ sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
592
621
  return;
593
622
  }
594
623
 
624
+ await ensureSessionLoaded();
625
+
626
+ // Cancel any prior in-flight dispatch BEFORE we fan out. One dispatch =
627
+ // one cancellation domain. Each per-VP runVpTurn shares this signal, so
628
+ // siblings within the same dispatch never abort each other (the bug
629
+ // before this fix: each runVpTurn replaced currentAbortCtrl, causing
630
+ // VP-B's start to silently kill VP-A's in-flight LLM call).
631
+ if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
632
+ try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
633
+ }
634
+ const dispatchAbortCtrl = new AbortController();
635
+ currentAbortCtrl = dispatchAbortCtrl;
636
+
637
+ // Open the group; seed grp_default on the fly if absent. Track
638
+ // seedFailed separately so a seed crash surfaces a different message
639
+ // than a genuinely-missing group.
595
640
  let groupHandle = null;
641
+ let seedFailed = false;
596
642
  try {
597
- const yeaftDir = ctx.CONFIG?.yeaftDir;
598
- if (yeaftDir) {
599
- const { openGroup, loadGroupMeta } = await import('./groups/group-store.js');
600
- const { join } = await import('node:path');
601
- const { existsSync } = await import('node:fs');
602
- const root = join(yeaftDir, 'groups');
603
- const dir = join(root, groupId);
604
- if (existsSync(dir) && loadGroupMeta(dir)) {
605
- groupHandle = openGroup(root, groupId);
643
+ const root = join(yeaftDir, 'groups');
644
+ const dir = join(root, groupId);
645
+ if (existsSync(dir) && loadGroupMeta(dir)) {
646
+ groupHandle = openGroup(root, groupId);
647
+ } else if (groupId === 'grp_default') {
648
+ try {
649
+ const seeded = seedDefaultGroup(yeaftDir, {});
650
+ groupHandle = seeded.group;
651
+ } catch (seedErr) {
652
+ seedFailed = true;
653
+ console.warn('[Unify] unify_group_chat: seedDefaultGroup failed', seedErr?.message || seedErr);
606
654
  }
655
+ } else {
656
+ console.warn('[Unify] unify_group_chat: groupId %s not found', groupId);
607
657
  }
608
658
  } catch (err) {
609
659
  console.warn('[Unify] unify_group_chat: group open failed', err?.message || err);
610
660
  }
611
661
 
612
662
  if (!groupHandle) {
613
- await handleUnifyChat({ ...msg, prompt: text });
663
+ const errText = seedFailed
664
+ ? `⚠️ Failed to seed default group ${groupId} — check ~/.yeaft/ permissions.`
665
+ : `⚠️ Group ${groupId} not found.`;
666
+ sendUnifyOutput({
667
+ type: 'assistant',
668
+ message: { content: [{ type: 'text', text: errText }] },
669
+ }, groupId);
670
+ sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
614
671
  return;
615
672
  }
616
673
 
617
674
  // Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
618
675
  try {
619
676
  const meta = groupHandle.getMeta();
620
- const yeaftDir = ctx.CONFIG?.yeaftDir;
621
677
  const wantsAdd = mentions.filter(
622
678
  (m) => m && m !== 'all' && !meta.roster.includes(m)
623
679
  );
624
- if (wantsAdd.length && yeaftDir) {
680
+ if (wantsAdd.length) {
625
681
  let mutated = false;
626
682
  for (const vpId of wantsAdd) {
627
683
  try {
@@ -633,19 +689,15 @@ export async function handleUnifyGroupChat(msg) {
633
689
  }
634
690
  if (mutated) {
635
691
  try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
636
- const { openGroup } = await import('./groups/group-store.js');
637
- const { join } = await import('node:path');
638
692
  groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
639
693
  sendGroupRosterChanged(groupHandle.getMeta());
640
694
  }
641
695
  }
642
696
  const meta2 = groupHandle.getMeta();
643
- if (!meta2.defaultVpId && meta2.roster.length && yeaftDir) {
697
+ if (!meta2.defaultVpId && meta2.roster.length) {
644
698
  try {
645
699
  setGroupDefaultVp(yeaftDir, groupId, meta2.roster[0]);
646
700
  try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
647
- const { openGroup } = await import('./groups/group-store.js');
648
- const { join } = await import('node:path');
649
701
  groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
650
702
  sendGroupRosterChanged(groupHandle.getMeta());
651
703
  } catch { /* best-effort */ }
@@ -654,7 +706,6 @@ export async function handleUnifyGroupChat(msg) {
654
706
  console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
655
707
  }
656
708
 
657
- const { createCoordinator } = await import('./groups/coordinator.js');
658
709
  const captured = [];
659
710
  const coord = createCoordinator(groupHandle, {
660
711
  deliver: (vpId, envelope) => { captured.push({ vpId, envelope }); },
@@ -670,13 +721,25 @@ export async function handleUnifyGroupChat(msg) {
670
721
  });
671
722
  } catch (err) {
672
723
  console.warn('[Unify] unify_group_chat: coord.ingest failed', err?.message || err);
673
- await handleUnifyChat({ ...msg, prompt: text });
724
+ sendUnifyOutput({
725
+ type: 'assistant',
726
+ message: { content: [{ type: 'text', text: `⚠️ Group dispatch error: ${err?.message || err}` }] },
727
+ }, groupId);
728
+ sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
674
729
  return;
675
730
  }
676
731
 
677
732
  const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
678
733
  if (dispatchedIds.length === 0 && !report?.fallback) {
679
- await handleUnifyChat({ ...msg, prompt: text });
734
+ // Coordinator chose nobody and provided no fallback — should not happen
735
+ // with a healthy roster. Surface the failure explicitly rather than
736
+ // silently retrying as a single-VP turn (the legacy fallback masked
737
+ // group-roster bugs).
738
+ sendUnifyOutput({
739
+ type: 'assistant',
740
+ message: { content: [{ type: 'text', text: '⚠️ No VP available to respond — check the group roster.' }] },
741
+ }, groupId);
742
+ sendUnifyOutput({ type: 'result', result_text: '' }, groupId);
680
743
  return;
681
744
  }
682
745
 
@@ -706,7 +769,9 @@ export async function handleUnifyGroupChat(msg) {
706
769
 
707
770
  // GC.1 Commit C: VP-level parallelism. Each selected VP runs its
708
771
  // turn concurrently via Promise.all. Intra-VP loops (LLM → tool →
709
- // LLM) stay serial inside each handleUnifyChat call.
772
+ // LLM) stay serial inside each runVpTurn call. All siblings share
773
+ // the dispatch-level AbortSignal so a single user-initiated abort
774
+ // (or a timeout in any one VP) cancels the whole fan-out.
710
775
  //
711
776
  // Side-effect: VP-B's transcript no longer contains VP-A's reply
712
777
  // (they're concurrent). Cross-VP visibility moves to the explicit
@@ -714,13 +779,12 @@ export async function handleUnifyGroupChat(msg) {
714
779
  // truth for full-fidelity replay.
715
780
  await Promise.all(captured.map(async ({ vpId }) => {
716
781
  try {
717
- await handleUnifyChat({
718
- ...msg,
782
+ await runVpTurn({
719
783
  prompt: `@vp-${vpId} ${text}`,
720
784
  groupId,
721
785
  vpId,
722
- speakerVpId: vpId,
723
- _groupCoordinator: coord,
786
+ groupCoordinator: coord,
787
+ abortCtrl: dispatchAbortCtrl,
724
788
  });
725
789
  } catch (err) {
726
790
  console.warn('[Unify] unify_group_chat: per-vp dispatch failed', vpId, err?.message || err);
@@ -794,94 +858,96 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
794
858
  }
795
859
 
796
860
  /**
797
- * Handle a unify_chat message from the web UI.
798
- *
799
- * H2.f.2: a new message cancels the prior in-flight controller (single
800
- * conversation, not per-thread). The history accumulator is a flat array.
801
- *
802
- * @param {{ prompt: string, mode?: string, userId?: string, username?: string }} msg
861
+ * Lazy session boot. Idempotent: subsequent calls are no-ops once `session`
862
+ * is set. Emits `session_ready` on first init so the frontend can finalize
863
+ * its handshake.
803
864
  */
804
- export async function handleUnifyChat(msg) {
805
- const { prompt, mode } = msg;
806
- if (!prompt?.trim()) return;
807
- const vpId = typeof msg.vpId === 'string' && msg.vpId.trim() ? msg.vpId.trim() : null;
808
- let groupCoordinator = msg._groupCoordinator || null;
809
- let groupId = typeof msg.groupId === 'string' && msg.groupId.trim() ? msg.groupId.trim() : null;
865
+ async function ensureSessionLoaded() {
866
+ if (session) return;
867
+
868
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
869
+ session = await loadSession({
870
+ ...(yeaftDir && { dir: yeaftDir }),
871
+ skipMCP: false,
872
+ skipSkills: false,
873
+ });
810
874
 
811
- if (mode !== undefined && mode !== null) {
812
- console.warn('[Unify] unify_chat.mode is deprecated and ignored — Unify now runs in a single unified mode.');
813
- }
875
+ installUnifyRuntimeBridge(session);
814
876
 
815
877
  try {
816
- if (!session) {
817
- const yeaftDir = ctx.CONFIG?.yeaftDir;
818
- session = await loadSession({
819
- ...(yeaftDir && { dir: yeaftDir }),
820
- skipMCP: false,
821
- skipSkills: false,
878
+ if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {
879
+ session.engine.setSubAgentEventSink((agentId, evt) => {
880
+ try {
881
+ sendUnifyEvent({ type: 'sub_agent_event', agentId, payload: evt });
882
+ } catch { /* ignore */ }
822
883
  });
884
+ }
885
+ } catch (err) {
886
+ console.warn('[Unify] setSubAgentEventSink wiring failed:', err?.message || err);
887
+ }
823
888
 
824
- installUnifyRuntimeBridge(session);
825
-
826
- try {
827
- if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {
828
- session.engine.setSubAgentEventSink((agentId, evt) => {
829
- try {
830
- sendUnifyEvent({ type: 'sub_agent_event', agentId, payload: evt });
831
- } catch { /* ignore */ }
832
- });
833
- }
834
- } catch (err) {
835
- console.warn('[Unify] setSubAgentEventSink wiring failed:', err?.message || err);
889
+ // Bug 8: clean up legacy `.archived-*` group dirs at boot.
890
+ try {
891
+ if (yeaftDir) {
892
+ const removed = purgeArchivedGroups(yeaftDir);
893
+ if (removed && removed.length > 0) {
894
+ console.log(`[Unify] purged ${removed.length} legacy .archived group dir(s)`);
836
895
  }
896
+ }
897
+ } catch (err) {
898
+ console.warn('[Unify] purgeArchivedGroups failed:', err?.message || err);
899
+ }
837
900
 
838
- // Bug 8: clean up legacy `.archived-*` group dirs at boot.
839
- try {
840
- const yeaftDir = ctx.CONFIG?.yeaftDir;
841
- if (yeaftDir) {
842
- const removed = purgeArchivedGroups(yeaftDir);
843
- if (removed && removed.length > 0) {
844
- console.log(`[Unify] purged ${removed.length} legacy .archived group dir(s)`);
845
- }
846
- }
847
- } catch (err) {
848
- console.warn('[Unify] purgeArchivedGroups failed:', err?.message || err);
849
- }
901
+ unifyConversationId = `unify-${Date.now()}`;
850
902
 
851
- unifyConversationId = `unify-${Date.now()}`;
903
+ restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
852
904
 
853
- restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
905
+ sendUnifyEvent({
906
+ type: 'session_ready',
907
+ conversationId: unifyConversationId,
908
+ model: session.config.model,
909
+ availableModels: session.config.availableModels || [],
910
+ skills: session.status.skills,
911
+ mcpServers: session.status.mcpServers,
912
+ tools: session.status.tools,
913
+ });
914
+ sendGroupSnapshotBroadcast();
915
+ }
854
916
 
855
- sendUnifyEvent({
856
- type: 'session_ready',
857
- conversationId: unifyConversationId,
858
- model: session.config.model,
859
- availableModels: session.config.availableModels || [],
860
- skills: session.status.skills,
861
- mcpServers: session.status.mcpServers,
862
- tools: session.status.tools,
863
- });
864
- sendGroupSnapshotBroadcast();
865
- }
917
+ /**
918
+ * Run a single VP's turn: call engine.query() with the supplied prompt and
919
+ * coordinator-bound router, stream events to the frontend, and append the
920
+ * result to the flat conversation history.
921
+ *
922
+ * Private — only `handleUnifyGroupChat` calls this. Coordinator and groupId
923
+ * are mandatory; callers MUST resolve a default group before invoking. The
924
+ * caller also owns the AbortController; we receive the whole controller
925
+ * (not just its signal) so the per-VP query-timeout can abort exactly the
926
+ * dispatch it belongs to — and only if that controller is still the
927
+ * module-active one. This avoids a stale-timer-from-an-aborted-dispatch
928
+ * killing the next dispatch.
929
+ *
930
+ * @param {{ prompt: string, groupId: string, vpId: string|null, groupCoordinator: object, abortCtrl: AbortController }} args
931
+ */
932
+ async function runVpTurn({ prompt, groupId, vpId, groupCoordinator, abortCtrl }) {
933
+ if (!prompt?.trim()) return;
866
934
 
935
+ try {
867
936
  if (session?.dreamScheduler) {
868
937
  session.dreamScheduler.noteUserMessage();
869
938
  }
870
939
 
871
- // Cancel any prior in-flight round before starting this one.
872
- if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
873
- try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
874
- }
875
- const abortCtrl = new AbortController();
876
- currentAbortCtrl = abortCtrl;
877
-
878
940
  let queryTimer = null;
879
941
  const resetQueryTimer = () => {
880
942
  if (queryTimer) clearTimeout(queryTimer);
881
943
  queryTimer = setTimeout(() => {
882
- if (!abortCtrl.signal.aborted) {
944
+ // Abort the captured dispatch controller — but only if it is
945
+ // still the module-active one. If a later dispatch already
946
+ // replaced it, our timer is stale and must NOT abort whatever
947
+ // the new dispatch installed (that was the original race).
948
+ if (abortCtrl === currentAbortCtrl && !abortCtrl.signal.aborted) {
883
949
  console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
884
- abortCtrl.abort();
950
+ try { abortCtrl.abort(); } catch { /* best-effort */ }
885
951
  }
886
952
  }, QUERY_TIMEOUT_MS);
887
953
  };
@@ -892,48 +958,6 @@ export async function handleUnifyChat(msg) {
892
958
  const toolCallsAccum = [];
893
959
  const toolResultsAccum = [];
894
960
 
895
- // Backend safety net: if no coordinator was supplied (legacy
896
- // `unify_chat` path, or test harness) but we have a yeaft dir, build
897
- // an ephemeral coordinator over `groupId || grp_default`. This keeps
898
- // ctx.router always wired so `route_forward` never bombs out with
899
- // `router_unavailable`. The coordinator is local to this turn — no
900
- // fan-out, no extra dispatch — it only exists so the per-VP Engine
901
- // query can hand it to createRouter().
902
- if (!groupCoordinator) {
903
- try {
904
- const yeaftDir = ctx.CONFIG?.yeaftDir;
905
- if (yeaftDir) {
906
- const resolvedGroupId = groupId || 'grp_default';
907
- const { openGroup, loadGroupMeta } = await import('./groups/group-store.js');
908
- const { join } = await import('node:path');
909
- const { existsSync } = await import('node:fs');
910
- const root = join(yeaftDir, 'groups');
911
- const dir = join(root, resolvedGroupId);
912
- let groupHandle = null;
913
- if (existsSync(dir) && loadGroupMeta(dir)) {
914
- groupHandle = openGroup(root, resolvedGroupId);
915
- } else if (resolvedGroupId === 'grp_default') {
916
- const { seedDefaultGroup } = await import('./groups/seed-default.js');
917
- const seeded = seedDefaultGroup(yeaftDir, {});
918
- groupHandle = seeded.group;
919
- } else {
920
- console.warn('[Unify] handleUnifyChat: groupId %s not found; no router will be wired for this turn', resolvedGroupId);
921
- }
922
- if (groupHandle) {
923
- const { createCoordinator } = await import('./groups/coordinator.js');
924
- groupCoordinator = createCoordinator(groupHandle, {
925
- deliver: () => { /* no-op: 1:1 path, no fan-out */ },
926
- });
927
- if (!groupId) groupId = resolvedGroupId;
928
- }
929
- }
930
- } catch (err) {
931
- console.warn('[Unify] handleUnifyChat: ephemeral coordinator build failed', err?.message || err);
932
- // Non-fatal — buildVpQueryOpts will return without router and
933
- // route_forward will surface `router_unavailable` for that turn.
934
- }
935
- }
936
-
937
961
  // H2.f.5: dispatcher + InputQueue retired. Call engine.query() directly,
938
962
  // passing the flat conversation history as `messages` for context continuity.
939
963
  const queryOpts = buildVpQueryOpts({ vpId, groupCoordinator, groupId });
@@ -1000,7 +1024,7 @@ export async function handleUnifyChat(msg) {
1000
1024
  return;
1001
1025
  }
1002
1026
 
1003
- console.error('[Unify] query error:', err.message);
1027
+ console.error('[Unify] query error:', err);
1004
1028
 
1005
1029
  if (isPermissionErrorMsg(err.message)) {
1006
1030
  if (!_permissionDiagnosticSent) {
@@ -1030,10 +1054,6 @@ export async function handleUnifyChat(msg) {
1030
1054
  type: 'result',
1031
1055
  result_text: '',
1032
1056
  }, groupId);
1033
- } finally {
1034
- if (currentAbortCtrl && currentAbortCtrl.signal.aborted) {
1035
- // Aborted controllers stay where they are; a new query will replace.
1036
- }
1037
1057
  }
1038
1058
  }
1039
1059
 
@@ -1164,9 +1184,6 @@ export async function handleUnifyFetchSummaryHistory(msg = {}) {
1164
1184
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1165
1185
  if (!yeaftDir) { reply({ revisions: [], archived: null, error: 'no_yeaft_dir' }); return; }
1166
1186
 
1167
- const { openGroup, loadGroupMeta } = await import('./groups/group-store.js');
1168
- const { join } = await import('node:path');
1169
- const { existsSync } = await import('node:fs');
1170
1187
  const root = join(yeaftDir, 'groups');
1171
1188
  const dir = join(root, groupId);
1172
1189
  if (!existsSync(dir) || !loadGroupMeta(dir)) {