@yeaft/webchat-agent 0.1.763 → 0.1.767

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.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * todo-write.js — TodoWrite tool: per-VP multi-step task tracking.
3
+ *
4
+ * Mirrors Claude Code's `TodoWrite` tool 1:1 in shape (name + `todos[]`
5
+ * with `content` / `status` / `activeForm`) so the existing frontend
6
+ * rendering pipeline (`MessageList.js:691` → `AssistantTurn.js:53-63`
7
+ * → `ToolLine.js:150`) renders a checkmark-style list automatically
8
+ * without any new UI code. The frontend reads the *input* of the
9
+ * `tool_use` event — not the result — so this tool's persistence story
10
+ * is "stamp into the LLM event stream and cache on ctx for replay."
11
+ *
12
+ * Per-VP isolation: each VP keeps its own current todo list. The
13
+ * web-bridge injects `ctx.getCurrentTodos()` / `ctx.setCurrentTodos()`
14
+ * pointing at a per-(groupId,vpId) slot so two VPs in the same group
15
+ * can independently track their own multi-step tasks without
16
+ * stepping on each other.
17
+ *
18
+ * Reference: plan §2 (2026-05-13 — Feature system retired, TodoWrite
19
+ * added as the actual progress-tracking surface for the LLM).
20
+ */
21
+
22
+ import { defineTool } from './types.js';
23
+
24
+ const VALID_STATUS = new Set(['pending', 'in_progress', 'completed']);
25
+
26
+ export default defineTool({
27
+ name: 'TodoWrite',
28
+ description: `Track multi-step task progress with a checklist that the user can see ticked off in real time.
29
+
30
+ WHEN TO USE:
31
+ - The task has 3+ meaningful steps, or
32
+ - The user gave you a list of things to do (numbered/comma-separated), or
33
+ - You're about to start a non-trivial, multi-file change.
34
+
35
+ HOW TO USE:
36
+ - First call: enumerate all the todos with status "pending", set exactly one to "in_progress".
37
+ - Each subsequent call: rewrite the FULL list — mark the just-finished item "completed", mark the next item "in_progress".
38
+ - AT MOST one item may be "in_progress" at any time.
39
+ - \`content\` is the imperative form ("Run tests"); \`activeForm\` is the present-continuous shown during execution ("Running tests").
40
+
41
+ WHEN NOT TO USE:
42
+ - Single trivial change, single command run, pure conversation/question.`,
43
+ parameters: {
44
+ type: 'object',
45
+ properties: {
46
+ todos: {
47
+ type: 'array',
48
+ description: 'The full current todo list. Always send the entire list, not a diff.',
49
+ items: {
50
+ type: 'object',
51
+ properties: {
52
+ content: {
53
+ type: 'string',
54
+ description: 'Imperative description of the step (e.g. "Run tests").',
55
+ },
56
+ status: {
57
+ type: 'string',
58
+ enum: ['pending', 'in_progress', 'completed'],
59
+ description: 'Current state. At most one item may be "in_progress".',
60
+ },
61
+ activeForm: {
62
+ type: 'string',
63
+ description: 'Present-continuous form shown while executing (e.g. "Running tests").',
64
+ },
65
+ },
66
+ required: ['content', 'status', 'activeForm'],
67
+ },
68
+ },
69
+ },
70
+ required: ['todos'],
71
+ },
72
+ isConcurrencySafe: () => false,
73
+ isReadOnly: () => true,
74
+ async execute(input, ctx) {
75
+ const todos = input && Array.isArray(input.todos) ? input.todos : null;
76
+ if (!todos || todos.length === 0) {
77
+ return JSON.stringify({ error: 'todos must be a non-empty array' });
78
+ }
79
+
80
+ let inProgressCount = 0;
81
+ for (let i = 0; i < todos.length; i++) {
82
+ const t = todos[i];
83
+ if (!t || typeof t !== 'object') {
84
+ return JSON.stringify({ error: `todos[${i}] must be an object` });
85
+ }
86
+ if (typeof t.content !== 'string' || !t.content.trim()) {
87
+ return JSON.stringify({ error: `todos[${i}].content must be a non-empty string` });
88
+ }
89
+ if (typeof t.activeForm !== 'string' || !t.activeForm.trim()) {
90
+ return JSON.stringify({ error: `todos[${i}].activeForm must be a non-empty string` });
91
+ }
92
+ if (!VALID_STATUS.has(t.status)) {
93
+ return JSON.stringify({
94
+ error: `todos[${i}].status must be one of: pending, in_progress, completed`,
95
+ });
96
+ }
97
+ if (t.status === 'in_progress') inProgressCount += 1;
98
+ }
99
+
100
+ if (inProgressCount > 1) {
101
+ return JSON.stringify({
102
+ error: `at most one todo may be in_progress at a time (found ${inProgressCount})`,
103
+ });
104
+ }
105
+
106
+ // Cache the current todo list onto the per-VP slot if the web-bridge
107
+ // provided one. Best-effort: this is the cache the frontend may pull
108
+ // on reconnect / VP-switch. Tools should not crash if the slot is
109
+ // missing — sub-agent ctx or test ctx may lack it.
110
+ if (ctx && typeof ctx.setCurrentTodos === 'function') {
111
+ try { ctx.setCurrentTodos(todos.slice()); } catch { /* swallow */ }
112
+ }
113
+
114
+ const counts = { pending: 0, in_progress: 0, completed: 0 };
115
+ for (const t of todos) counts[t.status] += 1;
116
+
117
+ return JSON.stringify({
118
+ success: true,
119
+ count: todos.length,
120
+ pending: counts.pending,
121
+ in_progress: counts.in_progress,
122
+ completed: counts.completed,
123
+ });
124
+ },
125
+ });
@@ -297,5 +297,6 @@ export function readVp(vpId, options = {}) {
297
297
  traits: Array.isArray(meta.traits) ? meta.traits.map(String) : [],
298
298
  modelHint,
299
299
  persona: body,
300
+ planInstruction: typeof meta.planInstruction === 'string' ? String(meta.planInstruction) : '',
300
301
  };
301
302
  }
@@ -140,7 +140,8 @@ export class VpLoader {
140
140
  prev.mtimeMs !== next.mtimeMs ||
141
141
  prev.persona !== next.persona ||
142
142
  prev.name !== next.name ||
143
- prev.role !== next.role
143
+ prev.role !== next.role ||
144
+ prev.planInstruction !== next.planInstruction
144
145
  ) {
145
146
  // In-place update preserves VP identity → RoleInstance.runtimeState
146
147
  // is untouched; persona swap propagates on next read of vp.persona.
@@ -37,6 +37,9 @@ import { createHash } from 'crypto';
37
37
  * @property {'fast'|'primary'|undefined} modelHint
38
38
  * @property {string} persona — markdown body (persona / system prompt seed)
39
39
  * @property {string} personaHash — sha256(persona).slice(0,8); changes when persona body changes
40
+ * @property {string} planInstruction — optional per-VP planning style (used by `StartPlan`
41
+ * tool); '' means "fall back to the default template".
42
+ * Frontmatter scalar key `planInstruction`.
40
43
  * @property {string} dir — absolute path to VP dir
41
44
  * @property {string} memoryDir — absolute path to VP memory dir
42
45
  * @property {number} mtimeMs — role.md mtime (for hot-reload)
@@ -163,6 +166,11 @@ export function loadVpFromDir(dir) {
163
166
  modelHint,
164
167
  persona: body,
165
168
  personaHash: personaHashValue,
169
+ // Optional per-VP planning style for the `StartPlan` tool. Stored as a
170
+ // raw scalar string in role.md frontmatter (`planInstruction: "..."`).
171
+ // Empty / missing → the tool falls back to the default template. Kept
172
+ // verbatim — the tool itself is responsible for any framing.
173
+ planInstruction: typeof meta.planInstruction === 'string' ? String(meta.planInstruction) : '',
166
174
  dir,
167
175
  memoryDir,
168
176
  mtimeMs: st.mtimeMs,
@@ -107,6 +107,24 @@ const vpDrivers = new Map();
107
107
  const vpEngines = new Map();
108
108
  /** @type {Map<string, AbortController>} */
109
109
  const vpAborts = new Map();
110
+ /**
111
+ * Per-(groupId, vpId) current TodoWrite list. Each VP in a group keeps
112
+ * its own todo state so two VPs in the same group can independently
113
+ * track multi-step tasks without overwriting each other. Threaded into
114
+ * the engine's tool ctx via buildVpQueryOpts → getCurrentTodos /
115
+ * setCurrentTodos closures. Best-effort in-memory cache only — todos
116
+ * are also stamped into the LLM event stream (the frontend reads from
117
+ * the tool_use input, not from this map), so a server restart simply
118
+ * loses the "what was the most recent list?" peek without breaking the
119
+ * UI replay.
120
+ *
121
+ * Key: `${groupId}::${vpId}` (matches vpEngines/vpAborts convention).
122
+ * Value: `Array<{content, status, activeForm}>` — the last full list
123
+ * the VP wrote with TodoWrite.
124
+ *
125
+ * @type {Map<string, Array<{content: string, status: string, activeForm: string}>>}
126
+ */
127
+ const vpCurrentTodos = new Map();
110
128
  /**
111
129
  * Per-group cached coordinator + router. Created on first
112
130
  * `handleUnifyGroupChat` for a given groupId; reused across user messages
@@ -157,6 +175,11 @@ function invalidateGroupContext(groupId) {
157
175
  if (!k.startsWith(prefix)) continue;
158
176
  if (Array.isArray(inbox)) inbox.length = 0;
159
177
  }
178
+ // Reap per-(group,vp) TodoWrite snapshots for this group so a
179
+ // deleted/renamed group doesn't pin a stale checklist forever.
180
+ for (const k of vpCurrentTodos.keys()) {
181
+ if (k.startsWith(prefix)) vpCurrentTodos.delete(k);
182
+ }
160
183
  // Engines are NOT torn down here on purpose. They hold subordinate
161
184
  // state (AMS adjustments) that should survive a meta change and a
162
185
  // closed groupHandle — they don't reach the on-disk group meta
@@ -739,6 +762,7 @@ export async function __testResetVpState() {
739
762
  vpEngines.clear();
740
763
  vpAborts.clear();
741
764
  groupContexts.clear();
765
+ vpCurrentTodos.clear();
742
766
  // Per-group compact in-flight + pending state lives on the session's
743
767
  // Compactor. Clear it so a follow-on test doesn't see ghost in-flight
744
768
  // promises from a prior run.
@@ -753,27 +777,25 @@ export async function __testResetVpState() {
753
777
  * Envelope fields: conversationId, groupId, vpId, turnId — the last two
754
778
  * let the frontend route incremental deltas to the correct per-VP message block.
755
779
  */
756
- function sendUnifyOutput(data, { groupId, vpId, turnId, featureId } = {}) {
780
+ function sendUnifyOutput(data, { groupId, vpId, turnId } = {}) {
757
781
  sendToServer({
758
782
  type: 'unify_output',
759
783
  conversationId: unifyConversationId,
760
784
  ...(groupId ? { groupId } : {}),
761
785
  ...(vpId ? { vpId } : {}),
762
786
  ...(turnId ? { turnId } : {}),
763
- ...(featureId ? { featureId } : {}),
764
787
  data,
765
788
  });
766
789
  }
767
790
 
768
791
  /** Send a unify_output event (non-claude_output metadata). */
769
- function sendUnifyEvent(event, { groupId, vpId, turnId, featureId } = {}) {
792
+ function sendUnifyEvent(event, { groupId, vpId, turnId } = {}) {
770
793
  sendToServer({
771
794
  type: 'unify_output',
772
795
  conversationId: unifyConversationId,
773
796
  ...(groupId ? { groupId } : {}),
774
797
  ...(vpId ? { vpId } : {}),
775
798
  ...(turnId ? { turnId } : {}),
776
- ...(featureId ? { featureId } : {}),
777
799
  event,
778
800
  });
779
801
  }
@@ -1152,18 +1174,10 @@ export function installUnifyRuntimeBridge(s) {
1152
1174
  */
1153
1175
  function handleEngineEvent(event, hctx) {
1154
1176
  hctx.resetQueryTimer();
1155
- // Sub-agent events may carry their own `featureId` (stamped by the
1156
- // sub-agent runner from the parent's inbound feature scope). Plain
1157
- // VP-turn events have no featureId — auto-feature creation was
1158
- // removed when Track-A / FeatureArc was deleted (2026-05-08).
1159
- const eventFeatureId = typeof event === 'object' && event && typeof event.featureId === 'string'
1160
- ? event.featureId
1161
- : null;
1162
1177
  const envelope = {
1163
1178
  groupId: hctx.groupId,
1164
1179
  vpId: hctx.vpId,
1165
1180
  turnId: hctx.turnId,
1166
- ...(eventFeatureId ? { featureId: eventFeatureId } : {}),
1167
1181
  };
1168
1182
 
1169
1183
  switch (event.type) {
@@ -1854,6 +1868,10 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope })
1854
1868
  role: vp.role || '',
1855
1869
  roleZh: vp.roleZh || '',
1856
1870
  persona: vp.persona || '',
1871
+ // Optional per-VP planning style for the `StartPlan` tool. Empty
1872
+ // string means "fall back to the default template" — the tool
1873
+ // handles the lookup so callers stay ignorant of the default.
1874
+ planInstruction: typeof vp.planInstruction === 'string' ? vp.planInstruction : '',
1857
1875
  };
1858
1876
  }
1859
1877
  } catch { /* persona load is best-effort */ }
@@ -1872,6 +1890,20 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope })
1872
1890
  if (envelope && typeof envelope === 'object') {
1873
1891
  out.inboundEnvelope = envelope;
1874
1892
  }
1893
+ // TodoWrite per-VP isolation. Bind closures that read/write a slot
1894
+ // keyed by `${groupId}::${vpId}` so two VPs in the same group don't
1895
+ // overwrite each other's lists, and the TodoWrite tool can stay
1896
+ // ignorant of routing details (it just calls ctx.setCurrentTodos).
1897
+ const todosKey = `${out.groupId || ''}::${resolvedVpId}`;
1898
+ out.getCurrentTodos = () => {
1899
+ const cached = vpCurrentTodos.get(todosKey);
1900
+ return Array.isArray(cached) ? cached.slice() : null;
1901
+ };
1902
+ out.setCurrentTodos = (todos) => {
1903
+ if (Array.isArray(todos)) {
1904
+ vpCurrentTodos.set(todosKey, todos.slice());
1905
+ }
1906
+ };
1875
1907
  return out;
1876
1908
  }
1877
1909
 
@@ -2529,149 +2561,64 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2529
2561
  }
2530
2562
  }
2531
2563
 
2532
- /** Deprecated mode switch — Unify is single-mode. */
2533
- export function handleUnifyModeSwitch(_msg) {
2534
- console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
2535
- }
2536
-
2537
- /** Fetch a feature's summary history (revision chain). */
2538
- export async function handleUnifyFetchSummaryHistory(msg = {}) {
2539
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
2540
- const featureId = typeof msg.featureId === 'string' ? msg.featureId : null;
2541
- const includeArchived = !!msg.includeArchived;
2542
-
2543
- const reply = (extra = {}) => sendUnifyEvent({
2544
- type: 'unify_summary_history',
2545
- featureId,
2546
- ...extra,
2547
- ...(requestId ? { requestId } : {}),
2548
- });
2549
-
2550
- if (!featureId) { reply({ revisions: [], archived: null, error: 'missing_feature_id' }); return; }
2551
-
2564
+ /**
2565
+ * 2026-05-13: serve the Unify debug drawer's "Tool Stats" panel.
2566
+ *
2567
+ * Replies with `{type: 'unify_tool_stats', snapshot, registered,
2568
+ * unused}`. `snapshot` is the `ToolUsageStats.snapshot()` keyed by
2569
+ * tool name (callCount, errorCount, p50Ms, p95Ms, avgMs, lastCalledAt,
2570
+ * lastError, errorRate). `registered` is the static list of built-in
2571
+ * tool names so the frontend can render the "(defined but never
2572
+ * called)" subview without spinning up its own registry mirror.
2573
+ *
2574
+ * Best-effort: if the session hasn't booted yet or toolStats is
2575
+ * missing, we still reply with an empty snapshot so the UI can render
2576
+ * a placeholder rather than spin forever.
2577
+ */
2578
+ export async function handleUnifyFetchToolStats(_msg = {}) {
2579
+ let snapshot = {};
2580
+ let registered = [];
2581
+ let unused = [];
2552
2582
  try {
2553
- const { getFeatureStore } = await import('./tools/feature-tools.js');
2554
- const featureStore = getFeatureStore();
2555
- const feature = featureStore?.get(featureId);
2556
- if (!feature) { reply({ revisions: [], archived: null, error: 'feature_not_found' }); return; }
2557
- const groupId = feature.groupId;
2558
- if (!groupId) { reply({ revisions: [], archived: null, error: 'feature_has_no_group' }); return; }
2559
-
2560
- const yeaftDir = ctx.CONFIG?.yeaftDir;
2561
- if (!yeaftDir) { reply({ revisions: [], archived: null, error: 'no_yeaft_dir' }); return; }
2562
-
2563
- const root = join(yeaftDir, 'groups');
2564
- const dir = join(root, groupId);
2565
- if (!existsSync(dir) || !loadGroupMeta(dir)) {
2566
- reply({ revisions: [], archived: null, error: 'group_not_found' });
2567
- return;
2568
- }
2569
- const groupHandle = openGroup(root, groupId);
2570
- const summaries = [];
2571
- for (const m of groupHandle.streamMessages()) {
2572
- if (!m || m.featureId !== featureId) continue;
2573
- const meta = m.meta || {};
2574
- if (meta.kind === 'summary' || meta.type === 'summary') summaries.push(m);
2583
+ if (session?.toolStats && typeof session.toolStats.snapshot === 'function') {
2584
+ snapshot = session.toolStats.snapshot();
2575
2585
  }
2576
- summaries.sort((a, b) => {
2577
- const at = Date.parse(a.ts || '') || 0;
2578
- const bt = Date.parse(b.ts || '') || 0;
2579
- return bt - at;
2580
- });
2581
- const supersededIds = new Set();
2582
- for (const s of summaries) {
2583
- const arr = s.meta?.supersedes;
2584
- if (Array.isArray(arr)) for (const id of arr) supersededIds.add(id);
2586
+ // Pull the static built-in tool list. MCP/skill tools aren't in
2587
+ // here that's fine: the "unused" view is meant to flag stale
2588
+ // built-in tools, not user-installed ones.
2589
+ const { allTools } = await import('./tools/index.js');
2590
+ if (Array.isArray(allTools)) {
2591
+ registered = allTools
2592
+ .filter(t => t && typeof t.name === 'string' && t.name)
2593
+ .map(t => t.name);
2585
2594
  }
2586
- const current = [];
2587
- const archived = [];
2588
- for (const s of summaries) {
2589
- if (supersededIds.has(s.id)) archived.push(s);
2590
- else current.push(s);
2595
+ if (session?.toolStats && typeof session.toolStats.getRegisteredButUncalled === 'function') {
2596
+ unused = session.toolStats.getRegisteredButUncalled(registered);
2591
2597
  }
2592
- const overflow = current.slice(10);
2593
- const trimmedCurrent = current.slice(0, 10);
2594
- if (overflow.length) archived.push(...overflow);
2595
- archived.sort((a, b) => {
2596
- const at = Date.parse(a.ts || '') || 0;
2597
- const bt = Date.parse(b.ts || '') || 0;
2598
- return bt - at;
2599
- });
2600
- reply({
2601
- revisions: trimmedCurrent,
2602
- archived: includeArchived ? archived : null,
2603
- });
2604
2598
  } catch (err) {
2605
- reply({ revisions: [], archived: null, error: String(err?.message || err) });
2599
+ sendToServer({
2600
+ type: 'unify_tool_stats',
2601
+ snapshot: {},
2602
+ registered: [],
2603
+ unused: [],
2604
+ error: err && err.message ? err.message : String(err),
2605
+ });
2606
+ return;
2606
2607
  }
2607
- }
2608
-
2609
- /** Feature affiliation CRUD (relate / unrelate / kick_vp / abort_vp). */
2610
- export async function handleUnifyFeatureCrud(msg = {}) {
2611
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
2612
- const op = typeof msg.op === 'string' ? msg.op : null;
2613
- const featureId = typeof msg.featureId === 'string' ? msg.featureId : null;
2614
- const vpId = typeof msg.vpId === 'string' ? msg.vpId : null;
2615
- const relatedFeatureId = typeof msg.relatedFeatureId === 'string' ? msg.relatedFeatureId : null;
2616
-
2617
- const reply = (extra = {}) => sendUnifyEvent({
2618
- type: 'unify_feature_crud_result',
2619
- op,
2620
- featureId,
2621
- ...(vpId ? { vpId } : {}),
2622
- ...extra,
2623
- ...(requestId ? { requestId } : {}),
2608
+ sendToServer({
2609
+ type: 'unify_tool_stats',
2610
+ snapshot,
2611
+ registered,
2612
+ unused,
2624
2613
  });
2614
+ }
2625
2615
 
2626
- if (!op) { reply({ ok: false, error: 'missing_op' }); return; }
2627
- if (!featureId) { reply({ ok: false, error: 'missing_feature_id' }); return; }
2628
-
2629
- try {
2630
- const { getFeatureStore } = await import('./tools/feature-tools.js');
2631
- const featureStore = getFeatureStore();
2632
- const feature = featureStore?.get(featureId);
2633
- if (!feature) { reply({ ok: false, error: 'feature_not_found' }); return; }
2634
-
2635
- if (op === 'relate' || op === 'unrelate') {
2636
- if (!relatedFeatureId) { reply({ ok: false, error: 'missing_related_feature_id' }); return; }
2637
- const other = featureStore.get(relatedFeatureId);
2638
- if (!other) { reply({ ok: false, error: 'related_feature_not_found' }); return; }
2639
- const apply = (t, otherId, add) => {
2640
- const cur = Array.isArray(t.relatedFeatureIds) ? t.relatedFeatureIds.slice() : [];
2641
- const idx = cur.indexOf(otherId);
2642
- if (add && idx === -1) cur.push(otherId);
2643
- if (!add && idx !== -1) cur.splice(idx, 1);
2644
- featureStore.update(t.id, { relatedFeatureIds: cur });
2645
- };
2646
- apply(feature, relatedFeatureId, op === 'relate');
2647
- apply(other, featureId, op === 'relate');
2648
- reply({ ok: true, relatedFeatureId });
2649
- return;
2650
- }
2651
-
2652
- if (op === 'kick_vp') {
2653
- if (!vpId) { reply({ ok: false, error: 'missing_vp_id' }); return; }
2654
- featureStore.removeMember(featureId, vpId);
2655
- reply({ ok: true });
2656
- return;
2657
- }
2658
-
2659
- if (op === 'abort_vp') {
2660
- if (!vpId) { reply({ ok: false, error: 'missing_vp_id' }); return; }
2661
- // H2.f.2: per-VP abort no longer routed through engineRegistry — the
2662
- // single engine handles its own abort via currentAbortCtrl. Reply
2663
- // ok:true so the UI surface still works; deeper per-VP cancel is a
2664
- // separate task.
2665
- reply({ ok: true });
2666
- return;
2667
- }
2668
-
2669
- reply({ ok: false, error: 'unknown_op' });
2670
- } catch (err) {
2671
- reply({ ok: false, error: String(err?.message || err) });
2672
- }
2616
+ /** Deprecated mode switch Unify is single-mode. */
2617
+ export function handleUnifyModeSwitch(_msg) {
2618
+ console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
2673
2619
  }
2674
2620
 
2621
+
2675
2622
  /** Handle model switch from the web UI. */
2676
2623
  export function handleUnifyModelSwitch(msg) {
2677
2624
  if (!session || !msg.model) return;
@@ -2914,6 +2861,7 @@ export async function resetUnifySession() {
2914
2861
  vpDrivers.clear();
2915
2862
  vpEngines.clear();
2916
2863
  groupContexts.clear();
2864
+ vpCurrentTodos.clear();
2917
2865
  // History-dedup cache is keyed by per-session coordinator msg ids;
2918
2866
  // a fresh session resets the id space, so clear the cache too.
2919
2867
  _persistedUserMsgIds.clear();