@yeaft/webchat-agent 0.1.902 → 0.1.904

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.
@@ -55,6 +55,8 @@ import {
55
55
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
56
56
  import { parseSeqFromId } from './conversation/persist.js';
57
57
  import { sliceLastNTurns } from './turn-utils.js';
58
+ import { pairSanitize } from './pair-sanitize.js';
59
+ import { filterSnapshotForVp } from './snapshot-filter.js';
58
60
  import { createVpStatusBroker } from './vp-status-broker.js';
59
61
  import { classifyThread as defaultClassifyThread, fallbackTitle } from './vp/thread-classifier.js';
60
62
 
@@ -221,7 +223,7 @@ const vpCurrentTodos = new Map();
221
223
  * router: ReturnType<typeof createRouter>,
222
224
  * sessionHandle: object }>}
223
225
  */
224
- const groupContexts = new Map();
226
+ const sessionContexts = new Map();
225
227
 
226
228
  function vpKey(sessionId, vpId) {
227
229
  return `${sessionId}::${vpId}`;
@@ -356,7 +358,7 @@ async function waitForRoutePromises(msgId) {
356
358
  */
357
359
  function invalidateGroupContext(sessionId) {
358
360
  if (!sessionId) return;
359
- groupContexts.delete(sessionId);
361
+ sessionContexts.delete(sessionId);
360
362
  const prefix = `${sessionId}::`;
361
363
  for (const [k, ctrl] of vpAborts) {
362
364
  if (!k.startsWith(prefix)) continue;
@@ -458,7 +460,7 @@ let _vpUnsubscribe = null;
458
460
 
459
461
  /**
460
462
  * Per-group conversation history lives on the GroupContext entry
461
- * (`groupContexts.get(sessionId).history`). The pre-refactor module-level
463
+ * (`sessionContexts.get(sessionId).history`). The pre-refactor module-level
462
464
  * `conversationMessages` was a single array shared across every group —
463
465
  * a user prompt in group-A would leak into group-B's next-turn snapshot
464
466
  * because the bridge appended every turn to the same array regardless
@@ -466,7 +468,7 @@ let _vpUnsubscribe = null;
466
468
  * the in-memory tape was unified.
467
469
  *
468
470
  * Post-refactor: each GroupContext owns its own `history`, lazily
469
- * hydrated from `conversationStore.loadRecentByGroup(sessionId)` on first
471
+ * hydrated from `conversationStore.loadRecentBySession(sessionId)` on first
470
472
  * access. Group-A and group-B are isolated.
471
473
  *
472
474
  * @typedef {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>} GroupHistory
@@ -482,7 +484,7 @@ let _vpUnsubscribe = null;
482
484
  * from disk (or explicitly assigned). The flag is required because an
483
485
  * empty array is legitimate post-consolidate / post-clear state and
484
486
  * MUST NOT trigger a re-hydrate. Without the flag, a partial entry
485
- * would short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
487
+ * would short-circuit `getOrCreateSessionHistory` on truthy `[]` and skip
486
488
  * the disk load.
487
489
  */
488
490
 
@@ -602,26 +604,26 @@ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null)
602
604
 
603
605
  let rows = [];
604
606
  try {
605
- if (typeof store.loadVisibleByGroup === 'function') {
606
- const page = store.loadVisibleByGroup(sessionId, beforeSeq, limit);
607
+ if (typeof store.loadVisibleBySession === 'function') {
608
+ const page = store.loadVisibleBySession(sessionId, beforeSeq, limit);
607
609
  return {
608
610
  messages: (page.messages || []).map(projectPersistedToVisibleHistoryEntry).filter(Boolean),
609
611
  oldestSeq: (typeof page.oldestSeq === 'number') ? page.oldestSeq : null,
610
612
  hasMore: !!page.hasMore,
611
613
  };
612
- } else if (typeof store.loadOlderByGroup === 'function') {
614
+ } else if (typeof store.loadOlderBySession === 'function') {
613
615
  // Compatibility fallback for older test doubles: use an unbounded raw
614
616
  // prefix, then project/slice visible rows below.
615
- rows = store.loadOlderByGroup(sessionId, beforeSeq, Infinity).messages || [];
617
+ rows = store.loadOlderBySession(sessionId, beforeSeq, Infinity).messages || [];
616
618
  } else if (Number.isFinite(beforeSeq)) {
617
- const all = typeof store.loadAllByGroup === 'function'
618
- ? store.loadAllByGroup(sessionId)
619
- : store.loadRecentByGroup(sessionId, Infinity);
619
+ const all = typeof store.loadAllBySession === 'function'
620
+ ? store.loadAllBySession(sessionId)
621
+ : store.loadRecentBySession(sessionId, Infinity);
620
622
  rows = all.filter(m => parseSeqFromId(m?.id) < beforeSeq);
621
- } else if (typeof store.loadAllByGroup === 'function') {
622
- rows = store.loadAllByGroup(sessionId);
623
+ } else if (typeof store.loadAllBySession === 'function') {
624
+ rows = store.loadAllBySession(sessionId);
623
625
  } else {
624
- rows = store.loadRecentByGroup(sessionId, Infinity);
626
+ rows = store.loadRecentBySession(sessionId, Infinity);
625
627
  }
626
628
  } catch (err) {
627
629
  console.error('[Yeaft] visible history page load failed:', err?.message || err);
@@ -658,7 +660,7 @@ function hydrateGroupHistory(sessionId) {
658
660
  if (!session?.conversationStore || !sessionId) return [];
659
661
  let recent;
660
662
  try {
661
- recent = session.conversationStore.loadRecentByGroup(sessionId);
663
+ recent = session.conversationStore.loadRecentBySession(sessionId);
662
664
  } catch (err) {
663
665
  console.warn('[Yeaft] hydrateGroupHistory failed (sessionId=%s):', sessionId, err?.message || err);
664
666
  return [];
@@ -674,7 +676,7 @@ function hydrateGroupHistory(sessionId) {
674
676
  /**
675
677
  * Get-or-create the per-group history array. Used everywhere the bridge
676
678
  * needs to read/append/snapshot a group's conversation tape. Lazily
677
- * inserts an entry into `groupContexts` on first access — no
679
+ * inserts an entry into `sessionContexts` on first access — no
678
680
  * `sessionHandle` required (history is independent of coord/router
679
681
  * lifecycle, so a sub-agent / route_forward path that hasn't yet
680
682
  * opened the group can still read history).
@@ -687,9 +689,9 @@ function hydrateGroupHistory(sessionId) {
687
689
  * @param {string} sessionId
688
690
  * @returns {GroupHistory}
689
691
  */
690
- function getOrCreateGroupHistory(sessionId) {
692
+ function getOrCreateSessionHistory(sessionId) {
691
693
  if (!sessionId) return [];
692
- let entry = groupContexts.get(sessionId);
694
+ let entry = sessionContexts.get(sessionId);
693
695
  // Use `historyHydrated` rather than truthiness on `history` itself —
694
696
  // an empty array (post-consolidate, post-clear, or a partial entry
695
697
  // seeded by an early `getOrCreateSessionContext` call before data was
@@ -699,7 +701,7 @@ function getOrCreateGroupHistory(sessionId) {
699
701
  if (entry && entry.historyHydrated) return entry.history;
700
702
  if (!entry) {
701
703
  entry = makeGroupContextStub();
702
- groupContexts.set(sessionId, entry);
704
+ sessionContexts.set(sessionId, entry);
703
705
  }
704
706
  entry.history = hydrateGroupHistory(sessionId);
705
707
  entry.historyHydrated = true;
@@ -720,10 +722,10 @@ function getOrCreateGroupHistory(sessionId) {
720
722
  */
721
723
  function setGroupHistory(sessionId, next) {
722
724
  if (!sessionId) return;
723
- let entry = groupContexts.get(sessionId);
725
+ let entry = sessionContexts.get(sessionId);
724
726
  if (!entry) {
725
727
  entry = makeGroupContextStub();
726
- groupContexts.set(sessionId, entry);
728
+ sessionContexts.set(sessionId, entry);
727
729
  }
728
730
  entry.history = next;
729
731
  entry.historyHydrated = true;
@@ -737,7 +739,7 @@ function setGroupHistory(sessionId, next) {
737
739
  * @param {string} sessionId
738
740
  */
739
741
  export function __testGroupHistory(sessionId) {
740
- return getOrCreateGroupHistory(sessionId);
742
+ return getOrCreateSessionHistory(sessionId);
741
743
  }
742
744
 
743
745
  /**
@@ -758,12 +760,12 @@ export function __testSetSession(sessionLike) {
758
760
  /**
759
761
  * Test-only: peek at the GroupContext entry for a group (or undefined
760
762
  * if never seeded). Lets tests assert the `historyHydrated` flag without
761
- * exporting the entire `groupContexts` Map.
763
+ * exporting the entire `sessionContexts` Map.
762
764
  *
763
765
  * @param {string} sessionId
764
766
  */
765
767
  export function __testGroupContextEntry(sessionId) {
766
- return groupContexts.get(sessionId);
768
+ return sessionContexts.get(sessionId);
767
769
  }
768
770
 
769
771
  /**
@@ -887,9 +889,9 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
887
889
  * @returns {{ coord: object, router: object, sessionHandle: object }}
888
890
  */
889
891
  function getOrCreateSessionContext(sessionId, sessionHandle) {
890
- let entry = groupContexts.get(sessionId);
892
+ let entry = sessionContexts.get(sessionId);
891
893
  if (entry && entry.coord && entry.router) return entry;
892
- // Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
894
+ // Either no entry, or a partial entry seeded by `getOrCreateSessionHistory`
893
895
  // (no coord/router yet). Build the coord/router and merge into the
894
896
  // existing record so the per-group history reference and hydration
895
897
  // flag are preserved.
@@ -899,13 +901,13 @@ function getOrCreateSessionContext(sessionId, sessionHandle) {
899
901
  const router = createRouter({ coordinator: coord });
900
902
  if (!entry) {
901
903
  entry = makeGroupContextStub();
902
- groupContexts.set(sessionId, entry);
904
+ sessionContexts.set(sessionId, entry);
903
905
  }
904
906
  entry.coord = coord;
905
907
  entry.router = router;
906
908
  entry.sessionHandle = sessionHandle;
907
909
  // Defend against a future caller that builds a coord/router without
908
- // having gone through `getOrCreateGroupHistory` first: a partial entry
910
+ // having gone through `getOrCreateSessionHistory` first: a partial entry
909
911
  // could exist with `historyHydrated:false`, so do the load now.
910
912
  if (!entry.historyHydrated) {
911
913
  entry.history = hydrateGroupHistory(sessionId);
@@ -1048,8 +1050,13 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1048
1050
  vpAborts.set(key, vpAbort);
1049
1051
  turnAbortCtrls.set(turnId, vpAbort);
1050
1052
  turnAbortMeta.set(turnId, { sessionId, vpId, threadId: thread.threadId, key });
1051
- const baseSnapshot = getOrCreateGroupHistory(sessionId)
1052
- .filter((m) => !m.threadId || m.threadId === 'main' || m.threadId === thread.threadId);
1053
+ const baseSnapshot = pairSanitize(
1054
+ filterSnapshotForVp(
1055
+ getOrCreateSessionHistory(sessionId)
1056
+ .filter((m) => !m.threadId || m.threadId === 'main' || m.threadId === thread.threadId),
1057
+ vpId,
1058
+ ),
1059
+ );
1053
1060
  const trigger = envelope?.trigger || 'fallback';
1054
1061
  const { text, prompt, promptParts } = buildVpPromptPayload(vpId, envelope);
1055
1062
 
@@ -1104,7 +1111,7 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1104
1111
  try {
1105
1112
  if (text && envelope?.msg) {
1106
1113
  sendYeaftEvent({
1107
- type: 'group_message',
1114
+ type: 'session_message',
1108
1115
  sessionId,
1109
1116
  vpId,
1110
1117
  threadId: thread.threadId,
@@ -1226,7 +1233,7 @@ export async function __testResetVpState() {
1226
1233
  vpDrivers.clear();
1227
1234
  vpEngines.clear();
1228
1235
  vpAborts.clear();
1229
- groupContexts.clear();
1236
+ sessionContexts.clear();
1230
1237
  vpCurrentTodos.clear();
1231
1238
  threadClassifier = defaultClassifyThread;
1232
1239
  // Per-group compact in-flight + pending state lives on the session's
@@ -1413,49 +1420,53 @@ export function handleYeaftVpRead(msg) {
1413
1420
  }
1414
1421
 
1415
1422
  /**
1416
- * Group CRUD wired to WS events.
1423
+ * Session CRUD wired to WS events.
1417
1424
  */
1418
- function sendGroupCrudResult(payload) {
1419
- // Wire-compat: emit BOTH legacy `group_crud_result` (old web bundles)
1420
- // and the new `session_crud_result` alias. Payload includes both
1421
- // `groupId` and `sessionId` for the same reason — see below.
1422
- const out = { ...payload };
1423
- if (out.sessionId != null && out.groupId == null) out.groupId = out.sessionId;
1424
- if (out.groupId != null && out.sessionId == null) out.sessionId = out.groupId;
1425
- sendYeaftEvent({ type: 'group_crud_result', ...out });
1426
- sendYeaftEvent({ type: 'session_crud_result', ...out });
1427
- }
1428
-
1429
- function sendGroupSnapshotBroadcast() {
1425
+ function sendSessionCrudResult(payload) {
1426
+ sendYeaftEvent({ type: 'session_crud_result', ...payload });
1427
+ }
1428
+
1429
+ function sendSessionSnapshotBroadcast() {
1430
1430
  try {
1431
1431
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1432
1432
  if (!yeaftDir) return;
1433
1433
  const sessions = snapshotSessions(yeaftDir);
1434
- // Wire-compat: emit both legacy `group_list_updated` (groups field)
1435
- // and new `session_list_updated` (sessions field). Old web bundles
1436
- // listen on the former.
1437
- sendYeaftEvent({ type: 'group_list_updated', groups: sessions });
1438
1434
  sendYeaftEvent({ type: 'session_list_updated', sessions });
1439
1435
  } catch (err) {
1440
- console.warn('[Yeaft] sendGroupSnapshotBroadcast failed:', err?.message || err);
1436
+ console.warn('[Yeaft] sendSessionSnapshotBroadcast failed:', err?.message || err);
1441
1437
  }
1442
1438
  }
1443
1439
 
1444
- function sendGroupRosterChanged(session) {
1440
+ /**
1441
+ * Eager-broadcast this agent's session snapshot to the server (which
1442
+ * relays it to all owner clients with `agentId` stamped). Called on
1443
+ * `registered` so the unified sidebar can render this agent's sessions
1444
+ * the moment the agent connects — without waiting for the user to
1445
+ * first enter Yeaft view and trigger `ensureSessionLoaded`. Cheap:
1446
+ * pure FS scan of `~/.yeaft/sessions/`, no engine boot.
1447
+ *
1448
+ * fix-yeaft-session-per-agent: previously, Agent B's sessions were
1449
+ * invisible in the unified sidebar until the user clicked into B's
1450
+ * Yeaft view, because `sendSessionSnapshotBroadcast` only fired from
1451
+ * `ensureSessionLoaded`. That made the cross-agent list look broken
1452
+ * ("I see A but not B even though B is online") and was a major
1453
+ * contributor to the "session list disappears on switch" symptom.
1454
+ */
1455
+ export { sendSessionSnapshotBroadcast as broadcastYeaftSessionSnapshotEager };
1456
+
1457
+ function sendSessionRosterChanged(session) {
1445
1458
  if (!session) return;
1446
1459
  const payload = {
1447
1460
  sessionId: session.id,
1448
- groupId: session.id, // wire-compat for old web bundles
1449
1461
  name: session.name,
1450
1462
  roster: session.roster,
1451
1463
  defaultVpId: session.defaultVpId,
1452
1464
  workDir: session.workDir || '',
1453
1465
  };
1454
- sendYeaftEvent({ type: 'group_roster_changed', ...payload });
1455
1466
  sendYeaftEvent({ type: 'session_roster_changed', ...payload });
1456
1467
  }
1457
1468
 
1458
- function groupErrorPayload(err) {
1469
+ function sessionErrorPayload(err) {
1459
1470
  let code = 'unknown';
1460
1471
  if (err instanceof SessionCrudError) code = err.code;
1461
1472
  else if (err instanceof SessionConfigError) code = err.code;
@@ -1471,9 +1482,9 @@ export function handleYeaftListSessions(msg) {
1471
1482
  try {
1472
1483
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1473
1484
  const groups = snapshotSessions(yeaftDir);
1474
- sendGroupCrudResult({ op: 'list', requestId, ok: true, groups });
1485
+ sendSessionCrudResult({ op: 'list', requestId, ok: true, sessions: groups });
1475
1486
  } catch (err) {
1476
- sendGroupCrudResult({ op: 'list', requestId, ok: false, error: groupErrorPayload(err) });
1487
+ sendSessionCrudResult({ op: 'list', requestId, ok: false, error: sessionErrorPayload(err) });
1477
1488
  }
1478
1489
  }
1479
1490
 
@@ -1483,10 +1494,10 @@ export function handleYeaftCreateSession(msg) {
1483
1494
  try {
1484
1495
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1485
1496
  const group = createSessionFromSpec(yeaftDir, payload);
1486
- sendGroupCrudResult({ op: 'create', requestId, ok: true, group });
1487
- sendGroupSnapshotBroadcast();
1497
+ sendSessionCrudResult({ op: 'create', requestId, ok: true, session: group });
1498
+ sendSessionSnapshotBroadcast();
1488
1499
  } catch (err) {
1489
- sendGroupCrudResult({ op: 'create', requestId, ok: false, error: groupErrorPayload(err) });
1500
+ sendSessionCrudResult({ op: 'create', requestId, ok: false, error: sessionErrorPayload(err) });
1490
1501
  }
1491
1502
  }
1492
1503
 
@@ -1498,10 +1509,10 @@ export function handleYeaftRenameSession(msg) {
1498
1509
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1499
1510
  const group = renameSession(yeaftDir, sessionId, name);
1500
1511
  invalidateGroupContext(sessionId);
1501
- sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
1502
- sendGroupSnapshotBroadcast();
1512
+ sendSessionCrudResult({ op: 'rename', requestId, ok: true, session: group });
1513
+ sendSessionSnapshotBroadcast();
1503
1514
  } catch (err) {
1504
- sendGroupCrudResult({ op: 'rename', requestId, ok: false, error: groupErrorPayload(err) });
1515
+ sendSessionCrudResult({ op: 'rename', requestId, ok: false, error: sessionErrorPayload(err) });
1505
1516
  }
1506
1517
  }
1507
1518
 
@@ -1538,10 +1549,10 @@ export function handleYeaftUpdateSession(msg) {
1538
1549
  group = updateSessionAnnouncement(yeaftDir, sessionId, patch.announcement);
1539
1550
  }
1540
1551
  invalidateGroupContext(sessionId);
1541
- sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
1542
- sendGroupSnapshotBroadcast();
1552
+ sendSessionCrudResult({ op: 'update', requestId, ok: true, session: group });
1553
+ sendSessionSnapshotBroadcast();
1543
1554
  } catch (err) {
1544
- sendGroupCrudResult({ op: 'update', requestId, ok: false, error: groupErrorPayload(err) });
1555
+ sendSessionCrudResult({ op: 'update', requestId, ok: false, error: sessionErrorPayload(err) });
1545
1556
  }
1546
1557
  }
1547
1558
 
@@ -1568,10 +1579,10 @@ export function handleYeaftUpdateSessionConfig(msg) {
1568
1579
  if (k.startsWith(prefix)) vpEngines.delete(k);
1569
1580
  }
1570
1581
  invalidateGroupContext(sessionId);
1571
- sendGroupCrudResult({ op: 'update_config', requestId, ok: true, sessionId, config: savedConfig });
1572
- sendGroupSnapshotBroadcast();
1582
+ sendSessionCrudResult({ op: 'update_config', requestId, ok: true, sessionId, config: savedConfig });
1583
+ sendSessionSnapshotBroadcast();
1573
1584
  } catch (err) {
1574
- sendGroupCrudResult({ op: 'update_config', requestId, ok: false, error: groupErrorPayload(err) });
1585
+ sendSessionCrudResult({ op: 'update_config', requestId, ok: false, error: sessionErrorPayload(err) });
1575
1586
  }
1576
1587
  }
1577
1588
 
@@ -1582,10 +1593,10 @@ export function handleYeaftArchiveSession(msg) {
1582
1593
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1583
1594
  const result = archiveSession(yeaftDir, sessionId);
1584
1595
  invalidateGroupContext(sessionId);
1585
- sendGroupCrudResult({ op: 'archive', requestId, ok: true, sessionId: result.sessionId });
1586
- sendGroupSnapshotBroadcast();
1596
+ sendSessionCrudResult({ op: 'archive', requestId, ok: true, sessionId: result.sessionId });
1597
+ sendSessionSnapshotBroadcast();
1587
1598
  } catch (err) {
1588
- sendGroupCrudResult({ op: 'archive', requestId, ok: false, error: groupErrorPayload(err) });
1599
+ sendSessionCrudResult({ op: 'archive', requestId, ok: false, error: sessionErrorPayload(err) });
1589
1600
  }
1590
1601
  }
1591
1602
 
@@ -1616,16 +1627,16 @@ export function handleYeaftDeleteSession(msg) {
1616
1627
  for (const k of Array.from(vpEngines.keys())) {
1617
1628
  if (k.startsWith(prefix)) vpEngines.delete(k);
1618
1629
  }
1619
- sendGroupCrudResult({
1630
+ sendSessionCrudResult({
1620
1631
  op: 'delete',
1621
1632
  requestId,
1622
1633
  ok: true,
1623
1634
  sessionId: result.sessionId,
1624
1635
  messagesRemoved,
1625
1636
  });
1626
- sendGroupSnapshotBroadcast();
1637
+ sendSessionSnapshotBroadcast();
1627
1638
  } catch (err) {
1628
- sendGroupCrudResult({ op: 'delete', requestId, ok: false, error: groupErrorPayload(err) });
1639
+ sendSessionCrudResult({ op: 'delete', requestId, ok: false, error: sessionErrorPayload(err) });
1629
1640
  }
1630
1641
  }
1631
1642
 
@@ -1637,10 +1648,10 @@ export function handleYeaftSessionAddMember(msg) {
1637
1648
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1638
1649
  const group = addMember(yeaftDir, sessionId, vpId);
1639
1650
  invalidateGroupContext(sessionId);
1640
- sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
1641
- sendGroupRosterChanged(group);
1651
+ sendSessionCrudResult({ op: 'add_member', requestId, ok: true, session: group });
1652
+ sendSessionRosterChanged(group);
1642
1653
  } catch (err) {
1643
- sendGroupCrudResult({ op: 'add_member', requestId, ok: false, error: groupErrorPayload(err) });
1654
+ sendSessionCrudResult({ op: 'add_member', requestId, ok: false, error: sessionErrorPayload(err) });
1644
1655
  }
1645
1656
  }
1646
1657
 
@@ -1658,10 +1669,10 @@ export function handleYeaftSessionRemoveMember(msg) {
1658
1669
  for (const key of Array.from(vpEngines.keys())) {
1659
1670
  if (key.startsWith(removedPrefix)) vpEngines.delete(key);
1660
1671
  }
1661
- sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
1662
- sendGroupRosterChanged(group);
1672
+ sendSessionCrudResult({ op: 'remove_member', requestId, ok: true, session: group });
1673
+ sendSessionRosterChanged(group);
1663
1674
  } catch (err) {
1664
- sendGroupCrudResult({ op: 'remove_member', requestId, ok: false, error: groupErrorPayload(err) });
1675
+ sendSessionCrudResult({ op: 'remove_member', requestId, ok: false, error: sessionErrorPayload(err) });
1665
1676
  }
1666
1677
  }
1667
1678
 
@@ -1673,10 +1684,10 @@ export function handleYeaftSessionSetDefaultVp(msg) {
1673
1684
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1674
1685
  const group = setSessionDefaultVp(yeaftDir, sessionId, vpId);
1675
1686
  invalidateGroupContext(sessionId);
1676
- sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
1677
- sendGroupRosterChanged(group);
1687
+ sendSessionCrudResult({ op: 'set_default_vp', requestId, ok: true, session: group });
1688
+ sendSessionRosterChanged(group);
1678
1689
  } catch (err) {
1679
- sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: false, error: groupErrorPayload(err) });
1690
+ sendSessionCrudResult({ op: 'set_default_vp', requestId, ok: false, error: sessionErrorPayload(err) });
1680
1691
  }
1681
1692
  }
1682
1693
 
@@ -2153,20 +2164,21 @@ function handleEngineEvent(event, hctx) {
2153
2164
  }
2154
2165
 
2155
2166
  /**
2156
- * Handle a yeaft_group_chat message from the web UI — the SOLE Yeaft
2167
+ * Handle a yeaft_session_chat message from the web UI — the SOLE Yeaft
2157
2168
  * conversation entry point.
2158
2169
  *
2159
2170
  * Contract (post-consolidation, was previously split between handleYeaftChat
2160
2171
  * and handleYeaftSessionSend):
2161
- * - Frontend ALWAYS sends `yeaft_group_chat`. There is no `yeaft_chat`.
2172
+ * - Frontend ALWAYS sends `yeaft_session_chat`. There is no `yeaft_chat`.
2162
2173
  * - `sessionId` defaults to `'grp_default'` if missing — Yeaft is a single
2163
- * conversation backed by the default group; the user is never "outside"
2164
- * a group.
2165
- * - If the group dir doesn't exist and the resolved id is `'grp_default'`,
2166
- * it is seeded on the fly. Any other unknown sessionId surfaces an error.
2174
+ * conversation backed by the default session; the user is never "outside"
2175
+ * a session.
2176
+ * - Sessions are created up-front via `handleYeaftCreateSession`; this
2177
+ * handler does NOT seed missing sessions on the fly. An unknown
2178
+ * sessionId surfaces a clear "session not found" error to the UI.
2167
2179
  * - Coordinator is MANDATORY (this is what guarantees ctx.router is wired
2168
2180
  * so the `route_forward` tool can never trip `router_unavailable`).
2169
- * - No legacy "no-group" fallback paths — they were the source of the
2181
+ * - No legacy "no-session" fallback paths — they were the source of the
2170
2182
  * router_unavailable bug fixed in v0.1.671.
2171
2183
  */
2172
2184
  export async function handleYeaftSessionSend(msg) {
@@ -2211,12 +2223,11 @@ export async function handleYeaftSessionSend(msg) {
2211
2223
 
2212
2224
  await ensureSessionLoaded();
2213
2225
 
2214
- // Open the group; seed grp_default on the fly if absent. Track
2215
- // seedFailed separately so a seed crash surfaces a different message
2216
- // than a genuinely-missing group.
2226
+ // Open the session. The default `grp_default` no longer self-seeds
2227
+ // here session creation happens up-front via `handleYeaftCreateSession`,
2228
+ // so a missing dir surfaces a clear error rather than masking it.
2217
2229
  let sessionHandle = null;
2218
2230
  let sessionRoot = null;
2219
- let seedFailed = false;
2220
2231
  try {
2221
2232
  const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
2222
2233
  sessionRoot = sessionsRoot(groupYeaftDir);
@@ -2230,19 +2241,16 @@ export async function handleYeaftSessionSend(msg) {
2230
2241
  // default-group row across agents. Now we surface the not-found
2231
2242
  // case so the web can show an "agent offline / session missing"
2232
2243
  // hint instead of silently creating a different session.
2233
- console.warn('[Yeaft] yeaft_group_chat: sessionId %s not found', sessionId);
2244
+ console.warn('[Yeaft] yeaft_session_chat: sessionId %s not found', sessionId);
2234
2245
  }
2235
2246
  } catch (err) {
2236
- console.warn('[Yeaft] yeaft_group_chat: group open failed', err?.message || err);
2247
+ console.warn('[Yeaft] yeaft_session_chat: session open failed', err?.message || err);
2237
2248
  }
2238
2249
 
2239
2250
  if (!sessionHandle) {
2240
- const errText = seedFailed
2241
- ? `⚠️ Failed to seed default group ${sessionId} — check group .yeaft permissions.`
2242
- : `⚠️ Group ${sessionId} not found.`;
2243
2251
  sendYeaftOutput({
2244
2252
  type: 'assistant',
2245
- message: { content: [{ type: 'text', text: errText }] },
2253
+ message: { content: [{ type: 'text', text: `⚠️ Session ${sessionId} not found.` }] },
2246
2254
  }, { sessionId });
2247
2255
  sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
2248
2256
  return;
@@ -2267,7 +2275,7 @@ export async function handleYeaftSessionSend(msg) {
2267
2275
  if (rosterMutated) {
2268
2276
  try { sessionHandle.close && sessionHandle.close(); } catch { /* best-effort */ }
2269
2277
  sessionHandle = openSession(sessionRoot, sessionId);
2270
- sendGroupRosterChanged(sessionHandle.getMeta());
2278
+ sendSessionRosterChanged(sessionHandle.getMeta());
2271
2279
  }
2272
2280
  }
2273
2281
  const meta2 = sessionHandle.getMeta();
@@ -2276,12 +2284,12 @@ export async function handleYeaftSessionSend(msg) {
2276
2284
  setSessionDefaultVp(yeaftDir, sessionId, meta2.roster[0]);
2277
2285
  try { sessionHandle.close && sessionHandle.close(); } catch { /* best-effort */ }
2278
2286
  sessionHandle = openSession(sessionRoot, sessionId);
2279
- sendGroupRosterChanged(sessionHandle.getMeta());
2287
+ sendSessionRosterChanged(sessionHandle.getMeta());
2280
2288
  rosterMutated = true;
2281
2289
  } catch { /* best-effort */ }
2282
2290
  }
2283
2291
  } catch (err) {
2284
- console.warn('[Yeaft] yeaft_group_chat: auto-roster heal failed', err?.message || err);
2292
+ console.warn('[Yeaft] yeaft_session_chat: auto-roster heal failed', err?.message || err);
2285
2293
  }
2286
2294
 
2287
2295
  // task-707: per-group persistent coordinator/router. Created once per
@@ -2290,7 +2298,7 @@ export async function handleYeaftSessionSend(msg) {
2290
2298
  // we replace the cached coord so it points at the freshly-opened
2291
2299
  // sessionHandle.
2292
2300
  if (rosterMutated) {
2293
- groupContexts.delete(sessionId);
2301
+ sessionContexts.delete(sessionId);
2294
2302
  }
2295
2303
  const sessionCtx = getOrCreateSessionContext(sessionId, sessionHandle);
2296
2304
  const coord = sessionCtx.coord;
@@ -2314,7 +2322,7 @@ export async function handleYeaftSessionSend(msg) {
2314
2322
  try {
2315
2323
  attachmentBundle = persistYeaftAttachments(inboundFiles, { subdir: sessionId });
2316
2324
  } catch (err) {
2317
- console.warn('[Yeaft] yeaft_group_chat: attachment persist failed', err?.message || err);
2325
+ console.warn('[Yeaft] yeaft_session_chat: attachment persist failed', err?.message || err);
2318
2326
  }
2319
2327
  }
2320
2328
  // Surface partial / total upload failures to the user. We don't abort
@@ -2353,10 +2361,10 @@ export async function handleYeaftSessionSend(msg) {
2353
2361
  _promptSuffix: attachmentBundle.promptSuffix,
2354
2362
  });
2355
2363
  } catch (err) {
2356
- console.warn('[Yeaft] yeaft_group_chat: coord.ingest failed', err?.message || err);
2364
+ console.warn('[Yeaft] yeaft_session_chat: coord.ingest failed', err?.message || err);
2357
2365
  sendYeaftOutput({
2358
2366
  type: 'assistant',
2359
- message: { content: [{ type: 'text', text: `⚠️ Group dispatch error: ${err?.message || err}` }] },
2367
+ message: { content: [{ type: 'text', text: `⚠️ Session dispatch error: ${err?.message || err}` }] },
2360
2368
  }, { sessionId });
2361
2369
  sendYeaftOutput({ type: 'result', result_text: '' }, { sessionId });
2362
2370
  return;
@@ -2557,7 +2565,7 @@ async function ensureSessionLoaded() {
2557
2565
 
2558
2566
  yeaftConversationId = `yeaft-${Date.now()}`;
2559
2567
 
2560
- // Per-group history is hydrated lazily on first `getOrCreateGroupHistory`
2568
+ // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
2561
2569
  // — there's no global "all conversations" tape any more.
2562
2570
 
2563
2571
  sendYeaftEvent({
@@ -2570,7 +2578,7 @@ async function ensureSessionLoaded() {
2570
2578
  tools: session.status.tools,
2571
2579
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
2572
2580
  });
2573
- sendGroupSnapshotBroadcast();
2581
+ sendSessionSnapshotBroadcast();
2574
2582
  // vp-status: rebuild frontend status table from authoritative agent
2575
2583
  // memory. Sent unconditionally so reconnect/refresh paths get the same
2576
2584
  // bootstrap as first-load (the broker dedup logic makes a redundant
@@ -2696,7 +2704,7 @@ async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
2696
2704
  * appended to `conversationMessages`.
2697
2705
  *
2698
2706
  * task-707: takes a coordinator `envelope` rather than the coordinator
2699
- * itself; the persistent coord lives in `groupContexts[sessionId]`. Uses
2707
+ * itself; the persistent coord lives in `sessionContexts[sessionId]`. Uses
2700
2708
  * `getOrCreateVpEngine(sessionId, vpId)` so each VP runs against its own
2701
2709
  * Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
2702
2710
  * `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
@@ -2777,10 +2785,10 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2777
2785
 
2778
2786
  // task-707: per-VP engine + persistent group coord. The coord is
2779
2787
  // created in handleYeaftSessionSend via getOrCreateSessionContext and
2780
- // cached on `groupContexts`; we pull it here so route_forward
2788
+ // cached on `sessionContexts`; we pull it here so route_forward
2781
2789
  // (router built from this same coord) lands envelopes back on the
2782
2790
  // right inbox set.
2783
- const sessionCtx = groupContexts.get(sessionId);
2791
+ const sessionCtx = sessionContexts.get(sessionId);
2784
2792
  const sessionCoordinator = sessionCtx?.coord || null;
2785
2793
  const queryOpts = buildVpQueryOpts({
2786
2794
  vpId,
@@ -2839,7 +2847,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2839
2847
  }
2840
2848
 
2841
2849
  // Turn completed — atomically append this VP's output to shared history.
2842
- appendTurnToGroupHistory(sessionId, threadId, [prompt, ...appendedUserPrompts], assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
2850
+ appendTurnToSessionHistory(sessionId, threadId, vpId, [prompt, ...appendedUserPrompts], assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
2843
2851
 
2844
2852
  sendYeaftOutput({
2845
2853
  type: 'assistant',
@@ -2957,23 +2965,31 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2957
2965
  * exactly once, with the same threadId as the running thread. The
2958
2966
  * engine's own `conversationMessages` (with T1/T2 collapse applied)
2959
2967
  * is persisted to disk via stop-hooks, so the next turn's history is
2960
- * read from disk via `loadRecentByGroup` on next session boot. Within
2968
+ * read from disk via `loadRecentBySession` on next session boot. Within
2961
2969
  * a session, this in-memory tape carries the un-collapsed form — which
2962
2970
  * is fine because each VP turn's `engine.query` re-collapses on the fly.
2963
2971
  */
2964
- function appendTurnToGroupHistory(sessionId, threadId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum) {
2972
+ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum) {
2965
2973
  if (!sessionId) return;
2966
- const history = getOrCreateGroupHistory(sessionId);
2974
+ const history = getOrCreateSessionHistory(sessionId);
2967
2975
  const promptList = Array.isArray(prompts) ? prompts : [prompts];
2968
2976
  for (const prompt of promptList) {
2969
2977
  if (typeof prompt === 'string' && prompt.trim()) {
2978
+ // user rows intentionally carry NO speakerVpId — every VP in the
2979
+ // session should see the prompt in their history.
2970
2980
  history.push({ role: 'user', content: prompt, threadId: threadId || 'main' });
2971
2981
  }
2972
2982
  }
2973
2983
 
2974
2984
  const fullText = assistantTextParts.join('');
2975
2985
  if (fullText || toolCallsAccum.length > 0) {
2986
+ // Stamp speakerVpId on assistant + tool rows so the in-memory
2987
+ // baseSnapshot filter (filterSnapshotForVp) can mirror the disk
2988
+ // replay's per-VP isolation rules. Without this stamp, the next
2989
+ // VP turn would inherit the previous VP's tool_use ids without
2990
+ // matching tool_result rows → Anthropic API 422.
2976
2991
  const assistantMsg = { role: 'assistant', content: fullText, threadId: threadId || 'main' };
2992
+ if (vpId) assistantMsg.speakerVpId = vpId;
2977
2993
  if (toolCallsAccum.length > 0) {
2978
2994
  assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
2979
2995
  id: tc.id,
@@ -2985,7 +3001,9 @@ function appendTurnToGroupHistory(sessionId, threadId, prompts, assistantTextPar
2985
3001
  // requires us to echo them back on the next request or the API
2986
3002
  // returns "content[].thinking in the thinking mode must be passed
2987
3003
  // back to the API". The signature is server-private — it stays in
2988
- // this in-memory history and in agent-side persistence only.
3004
+ // this in-memory history and in agent-side persistence only. The
3005
+ // signature is also VP-private; filterSnapshotForVp drops it from
3006
+ // OTHER VPs' rows before each turn's payload is built.
2989
3007
  if (Array.isArray(thinkingBlocksAccum) && thinkingBlocksAccum.length > 0) {
2990
3008
  assistantMsg.thinkingBlocks = thinkingBlocksAccum.map(tb => (
2991
3009
  tb.redacted
@@ -2996,13 +3014,15 @@ function appendTurnToGroupHistory(sessionId, threadId, prompts, assistantTextPar
2996
3014
  history.push(assistantMsg);
2997
3015
 
2998
3016
  for (const tr of toolResultsAccum) {
2999
- history.push({
3017
+ const toolMsg = {
3000
3018
  role: 'tool',
3001
3019
  toolCallId: tr.toolCallId,
3002
3020
  content: tr.content,
3003
3021
  isError: tr.isError,
3004
3022
  threadId: threadId || 'main',
3005
- });
3023
+ };
3024
+ if (vpId) toolMsg.speakerVpId = vpId;
3025
+ history.push(toolMsg);
3006
3026
  }
3007
3027
  }
3008
3028
  }
@@ -3286,6 +3306,16 @@ export function __testGetRegisteredThreadIds() {
3286
3306
  */
3287
3307
  export const __testRaceWithEscalation = raceWithEscalation;
3288
3308
 
3309
+ /**
3310
+ * Test-only: invoke `appendTurnToSessionHistory` directly. Lets the VP
3311
+ * stamp contract (`speakerVpId` on assistant + tool rows, none on user
3312
+ * rows) be pinned with a table-driven test instead of booting a full
3313
+ * session. See `test/agent/yeaft/web-bridge-append-turn-vp-stamp.test.js`.
3314
+ */
3315
+ export function __testAppendTurnToSessionHistory(...args) {
3316
+ return appendTurnToSessionHistory(...args);
3317
+ }
3318
+
3289
3319
  /**
3290
3320
  * Manual dream trigger.
3291
3321
  *
@@ -3611,11 +3641,11 @@ export function handleYeaftModelSwitch(msg) {
3611
3641
  export async function handleYeaftLoadHistory(msg) {
3612
3642
  const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
3613
3643
  // `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
3614
- // `loadRecentByGroup` use turn-based slicing so the cut never lands
3644
+ // `loadRecentBySession` use turn-based slicing so the cut never lands
3615
3645
  // mid-tool-arc. Pass `undefined` to use the persistence-layer default
3616
3646
  // (DEFAULT_RECENT_TURNS = 20 turns).
3617
3647
  const pickRecent = (store, lim) =>
3618
- sessionId ? store.loadRecentByGroup(sessionId, lim) : store.loadRecent(lim);
3648
+ sessionId ? store.loadRecentBySession(sessionId, lim) : store.loadRecent(lim);
3619
3649
 
3620
3650
  if (!session) {
3621
3651
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -3629,7 +3659,7 @@ export async function handleYeaftLoadHistory(msg) {
3629
3659
 
3630
3660
  yeaftConversationId = `yeaft-${Date.now()}`;
3631
3661
 
3632
- // Per-group history hydrates lazily via getOrCreateGroupHistory.
3662
+ // Per-group history hydrates lazily via getOrCreateSessionHistory.
3633
3663
  // When the load-history call carries a sessionId, force-refresh THAT
3634
3664
  // group's tape so the next user message sees on-disk state. When
3635
3665
  // it doesn't (legacy callers), do nothing — the per-group lazy
@@ -3653,7 +3683,7 @@ export async function handleYeaftLoadHistory(msg) {
3653
3683
  tools: session.status.tools,
3654
3684
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
3655
3685
  });
3656
- sendGroupSnapshotBroadcast();
3686
+ sendSessionSnapshotBroadcast();
3657
3687
  // vp-status: replay the authoritative table on reconnect so a refreshed
3658
3688
  // frontend doesn't have to wait for the next transition to learn each
3659
3689
  // VP's current state.
@@ -3778,8 +3808,8 @@ export async function handleYeaftLoadHistory(msg) {
3778
3808
  // replay, only scoped per-(group, vp) summaries count; legacy compact.md is
3779
3809
  // reserved for non-group / pre-scoped 1:1 callers.
3780
3810
  let hasCompactSummaryFlag = !!compactSummary;
3781
- if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForGroup === 'function') {
3782
- hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(sessionId);
3811
+ if (sessionId && typeof session.conversationStore.hasAnyCompactSummaryForSession === 'function') {
3812
+ hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForSession(sessionId);
3783
3813
  }
3784
3814
 
3785
3815
  // Latest seq cursor in the recent-mode reply lets the frontend stamp its
@@ -3839,7 +3869,7 @@ export async function handleYeaftLoadMoreHistory(msg) {
3839
3869
  try {
3840
3870
  result = loadVisibleGroupHistoryPage(session.conversationStore, sessionId, turns, beforeSeq);
3841
3871
  } catch (err) {
3842
- console.error('[Yeaft] loadOlderByGroup failed:', err.message);
3872
+ console.error('[Yeaft] loadOlderBySession failed:', err.message);
3843
3873
  result = { messages: [], oldestSeq: null, hasMore: false };
3844
3874
  }
3845
3875
 
@@ -3885,7 +3915,7 @@ export async function resetYeaftSession() {
3885
3915
  session = null;
3886
3916
  }
3887
3917
  yeaftConversationId = null;
3888
- // Per-group histories live on groupContexts entries — clearing the
3918
+ // Per-group histories live on sessionContexts entries — clearing the
3889
3919
  // map (a few lines below) drops every group's history with it. No
3890
3920
  // separate global tape to clear.
3891
3921
  // Re-arm the permission warning. The user might have fixed the
@@ -3905,7 +3935,7 @@ export async function resetYeaftSession() {
3905
3935
  vpInboxes.clear();
3906
3936
  vpDrivers.clear();
3907
3937
  vpEngines.clear();
3908
- groupContexts.clear();
3938
+ sessionContexts.clear();
3909
3939
  vpCurrentTodos.clear();
3910
3940
  threadClassifier = defaultClassifyThread;
3911
3941
  // History-dedup cache is keyed by per-session coordinator msg ids;
@@ -3934,7 +3964,7 @@ export async function resetYeaftSession() {
3934
3964
 
3935
3965
  yeaftConversationId = `yeaft-${Date.now()}`;
3936
3966
 
3937
- // Per-group history hydrates lazily via getOrCreateGroupHistory on
3967
+ // Per-group history hydrates lazily via getOrCreateSessionHistory on
3938
3968
  // first read. Nothing to seed here.
3939
3969
 
3940
3970
  sendYeaftEvent({