@yeaft/webchat-agent 0.1.874 → 0.1.876

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.
Files changed (46) hide show
  1. package/connection/message-router.js +20 -13
  2. package/package.json +1 -1
  3. package/yeaft/attachments.js +2 -2
  4. package/yeaft/cli.js +13 -13
  5. package/yeaft/compact/compactor.js +20 -20
  6. package/yeaft/conversation/persist.js +95 -95
  7. package/yeaft/debug-trace.js +12 -12
  8. package/yeaft/dream-v2/apply.js +15 -15
  9. package/yeaft/dream-v2/merge.js +12 -12
  10. package/yeaft/dream-v2/prompts/{extract-group.md → extract-session.md} +12 -12
  11. package/yeaft/dream-v2/prompts/index.js +3 -3
  12. package/yeaft/dream-v2/prompts/triage-pass1.md +1 -1
  13. package/yeaft/dream-v2/runner.js +37 -37
  14. package/yeaft/dream-v2/segment.js +3 -3
  15. package/yeaft/dream-v2/session-wiring.js +22 -22
  16. package/yeaft/dream-v2/state.js +7 -7
  17. package/yeaft/dream-v2/triage.js +22 -22
  18. package/yeaft/engine.js +65 -65
  19. package/yeaft/memory/ams-registry.js +22 -22
  20. package/yeaft/memory/seed-backfill.js +9 -9
  21. package/yeaft/memory/store-v2.js +27 -27
  22. package/yeaft/prompts.js +9 -9
  23. package/yeaft/routing/loop-guard.js +14 -14
  24. package/yeaft/routing/router.js +5 -5
  25. package/yeaft/session.js +5 -5
  26. package/yeaft/sessions/coordinator.js +96 -20
  27. package/yeaft/{groups → sessions}/ids.js +3 -3
  28. package/yeaft/{groups → sessions}/index.js +28 -28
  29. package/yeaft/sessions/pre-flow.js +178 -42
  30. package/yeaft/{groups → sessions}/seed-default.js +19 -19
  31. package/yeaft/{groups/group-config.js → sessions/session-config.js} +29 -29
  32. package/yeaft/{groups/group-crud.js → sessions/session-crud.js} +113 -113
  33. package/yeaft/sessions/session-store.js +85 -154
  34. package/yeaft/stop-hooks.js +4 -4
  35. package/yeaft/tools/todo-write.js +1 -1
  36. package/yeaft/tools/types.js +2 -2
  37. package/yeaft/vp/registry.js +1 -1
  38. package/yeaft/vp/vp-crud.js +1 -1
  39. package/yeaft/vp-status-broker.js +28 -28
  40. package/yeaft/web-bridge.js +411 -398
  41. package/yeaft/groups/coordinator.js +0 -221
  42. package/yeaft/groups/group-store.js +0 -212
  43. package/yeaft/groups/pre-flow.js +0 -329
  44. /package/yeaft/{groups → sessions}/feature-flag.js +0 -0
  45. /package/yeaft/{groups → sessions}/project-doc.js +0 -0
  46. /package/yeaft/{groups → sessions}/roster.js +0 -0
@@ -1,19 +1,20 @@
1
1
  /**
2
- * session-store.js — Per-session persistent store (collapses group + chat).
2
+ * group-store.js — Per-group persistent store for task-334b.
3
3
  *
4
- * Layout:
5
- * ~/.yeaft/sessions/<sessionId>/
6
- * meta.json # { sessionId, vpIds[], displayName, workDir, createdAt, lastTurnAt, archivedAt? }
7
- * messages/ # JSONL size-rotation log (storage/openLog)
4
+ * Layout (see architecture §2):
5
+ * ~/.yeaft/sessions/<group-id>/
6
+ * group.json # { id, name, roster: [vpId...], defaultVpId, createdAt }
7
+ * messages/ # JSONL size-rotation log (334o openLog)
8
8
  * 000001.jsonl
9
9
  * index.json
10
+ * tasks/ # populated by 334n — reserved here
11
+ * vps/ # populated by 334c RoleInstance runtime — reserved here
10
12
  *
11
- * A session has N≥1 VPs. N=1 is the old "chat"; N>1 is the old "group".
12
- * The coordinator fan-out is identical for both N=1 just resolves to
13
- * one VP turn per ingest.
13
+ * This module owns only the group.json + messages/ log. Roster mutation
14
+ * logic lives in roster.js so coordinator and group-store both compose it.
14
15
  *
15
- * Hard constraint: no @-mention parsing, no dispatch, no engine awareness.
16
- * Pure persistence.
16
+ * Hard constraint: the store does not parse @-mentions, does not dispatch,
17
+ * and has no knowledge of VP/RoleInstance. It is pure persistence over 334o.
17
18
  */
18
19
 
19
20
  import {
@@ -21,43 +22,34 @@ import {
21
22
  mkdirSync,
22
23
  readFileSync,
23
24
  readdirSync,
24
- renameSync,
25
- rmSync,
26
25
  statSync,
27
26
  } from 'fs';
28
27
  import { join } from 'path';
29
28
  import { writeAtomic, openLog } from '../storage/index.js';
30
- import {
31
- nextMsgId,
32
- isReservedVpId,
33
- ReservedVpIdError,
34
- validateVpId,
35
- InvalidVpIdError,
36
- } from '../groups/ids.js';
29
+ import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from './ids.js';
37
30
 
38
- const META_FILE = 'meta.json';
31
+ const GROUP_FILE = 'group.json';
39
32
  const MESSAGES_DIR = 'messages';
40
- const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
41
33
 
42
34
  /**
43
- * Open (or partially create) the directory for a session. Returns a handle
44
- * even when meta.json is absent — call createSession() to materialise it.
35
+ * Load (or create) the directory for a single group.
45
36
  *
46
- * @param {string} sessionsRoot
37
+ * @param {string} sessionsRoot e.g. `${yeaftDir}/groups`
47
38
  * @param {string} sessionId
48
- * @returns {SessionHandle}
39
+ * @returns {GroupHandle}
49
40
  */
50
41
  export function openSession(sessionsRoot, sessionId) {
51
42
  if (!sessionId || typeof sessionId !== 'string') {
52
43
  throw new Error('openSession: sessionId required (string)');
53
44
  }
54
- if (!SESSION_ID_RE.test(sessionId)) {
55
- throw new Error(`openSession: invalid sessionId "${sessionId}"`);
56
- }
57
45
  const dir = join(sessionsRoot, sessionId);
58
46
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
59
47
 
60
48
  let meta = loadSessionMeta(dir);
49
+ if (!meta) {
50
+ // Fresh group — caller must call initGroup() next; we return a handle
51
+ // with meta=null so createSession() can write the initial file.
52
+ }
61
53
 
62
54
  const messagesDir = join(dir, MESSAGES_DIR);
63
55
  if (!existsSync(messagesDir)) mkdirSync(messagesDir, { recursive: true });
@@ -66,24 +58,38 @@ export function openSession(sessionsRoot, sessionId) {
66
58
  return {
67
59
  dir,
68
60
  id: sessionId,
61
+ /** Return current meta (reads fresh from memory after last save). */
69
62
  getMeta() { return meta ? structuredClone(meta) : null; },
63
+ /** Overwrite group.json atomically. */
70
64
  saveMeta(next) {
71
65
  validateMeta(next);
72
66
  meta = next;
73
- writeAtomic(join(dir, META_FILE), JSON.stringify(meta, null, 2));
67
+ writeAtomic(join(dir, GROUP_FILE), JSON.stringify(meta, null, 2));
74
68
  },
69
+ /**
70
+ * Append a message to the group log. Assigns an id if absent.
71
+ * Returns the stored record (with id + ts).
72
+ *
73
+ * Structural invariant: NO field on `record` may start with `_`.
74
+ * The `_` prefix is reserved for ephemeral per-turn payloads (image
75
+ * base64, prompt suffixes) that must reach the driver but must
76
+ * never hit the persisted jsonl-log. If this throws, the caller
77
+ * forgot to partition ephemeral fields off — fix the caller.
78
+ */
75
79
  appendMessage(record) {
76
80
  if (!record || typeof record !== 'object') {
77
81
  throw new Error('appendMessage: record required');
78
82
  }
79
- const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
80
- if (leaked.length > 0) {
81
- throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
83
+ {
84
+ const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
85
+ if (leaked.length > 0) {
86
+ throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
87
+ }
82
88
  }
83
89
  const stored = {
84
90
  id: record.id || nextMsgId(),
85
91
  ts: record.ts || new Date().toISOString(),
86
- from: record.from,
92
+ from: record.from, // vpId | 'user'
87
93
  role: record.role || (record.from === 'user' ? 'user' : 'assistant'),
88
94
  text: record.text ?? '',
89
95
  taskId: record.taskId || null,
@@ -93,71 +99,77 @@ export function openSession(sessionsRoot, sessionId) {
93
99
  log.append(stored);
94
100
  return stored;
95
101
  },
96
- *streamMessages() { yield* log.streamAll(); },
97
- *readMessageRange(firstId, lastId) { yield* log.readRange(firstId, lastId); },
102
+ /** Iterate all messages oldest→newest. */
103
+ *streamMessages() {
104
+ yield* log.streamAll();
105
+ },
106
+ /** Iterate a message id range inclusive. */
107
+ *readMessageRange(firstId, lastId) {
108
+ yield* log.readRange(firstId, lastId);
109
+ },
110
+ /** Flush + close underlying log (on shutdown). */
98
111
  close() { log.close(); },
99
112
  };
100
113
  }
101
114
 
102
115
  /**
103
- * Create a new session on disk. Fails if meta.json already exists.
104
- * @param {string} sessionsRoot
105
- * @param {{
106
- * id: string,
107
- * vpIds: string[],
108
- * displayName?: string,
109
- * workDir?: string,
110
- * createdAt?: string,
111
- * }} spec
112
- * @returns {SessionHandle}
116
+ * Create a fresh group on disk. Fails if group.json already exists.
117
+ * @returns {GroupHandle}
113
118
  */
114
119
  export function createSession(sessionsRoot, spec) {
115
120
  if (!spec || !spec.id) throw new Error('createSession: spec.id required');
116
- if (!Array.isArray(spec.vpIds) || spec.vpIds.length === 0) {
117
- throw new Error('createSession: spec.vpIds required (non-empty array)');
121
+ const h = openSession(sessionsRoot, spec.id);
122
+ if (h.getMeta()) {
123
+ throw new Error(`group ${spec.id} already exists`);
118
124
  }
119
- for (const v of spec.vpIds) {
125
+ const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
126
+ for (const v of roster) {
120
127
  if (isReservedVpId(v)) throw new ReservedVpIdError(v);
121
128
  const verdict = validateVpId(v);
122
129
  if (!verdict.ok) throw new InvalidVpIdError(v, verdict.reason);
123
130
  }
124
- const h = openSession(sessionsRoot, spec.id);
125
- if (h.getMeta()) throw new Error(`session ${spec.id} already exists`);
131
+ if (spec.defaultVpId) {
132
+ if (isReservedVpId(spec.defaultVpId)) throw new ReservedVpIdError(spec.defaultVpId);
133
+ const dverdict = validateVpId(spec.defaultVpId);
134
+ if (!dverdict.ok) throw new InvalidVpIdError(spec.defaultVpId, dverdict.reason);
135
+ }
126
136
  const meta = {
127
137
  id: spec.id,
128
- displayName: typeof spec.displayName === 'string' && spec.displayName.trim()
129
- ? spec.displayName.trim() : spec.id,
130
- vpIds: Array.from(new Set(spec.vpIds)),
138
+ name: spec.name || spec.id,
139
+ roster,
140
+ defaultVpId: spec.defaultVpId || null,
141
+ announcement: typeof spec.announcement === 'string' ? spec.announcement : '',
131
142
  workDir: typeof spec.workDir === 'string' ? spec.workDir.trim() : '',
132
143
  createdAt: spec.createdAt || new Date().toISOString(),
133
- lastTurnAt: null,
134
144
  };
135
145
  h.saveMeta(meta);
136
146
  return h;
137
147
  }
138
148
 
139
- /** Non-destructive load — returns null if meta.json is missing/corrupt. */
149
+ /** Non-destructive load — returns null if group.json is missing/corrupt. */
140
150
  export function loadSessionMeta(dir) {
141
- const path = join(dir, META_FILE);
151
+ const path = join(dir, GROUP_FILE);
142
152
  if (!existsSync(path)) return null;
143
153
  try {
144
154
  const raw = readFileSync(path, 'utf8');
145
155
  const parsed = JSON.parse(raw);
146
156
  validateMeta(parsed);
147
- if (typeof parsed.displayName !== 'string') parsed.displayName = parsed.id;
157
+ // Legacy groups created before optional fields were added are
158
+ // forward-compat: missing fields read back as safe empty strings.
159
+ if (typeof parsed.announcement !== 'string') parsed.announcement = '';
148
160
  if (typeof parsed.workDir !== 'string') parsed.workDir = '';
149
- if (parsed.lastTurnAt === undefined) parsed.lastTurnAt = null;
150
161
  return parsed;
151
162
  } catch {
152
163
  return null;
153
164
  }
154
165
  }
155
166
 
156
- /** List every session directory under `sessionsRoot`. */
167
+ /** List every group directory under `sessionsRoot`. */
157
168
  export function listSessions(sessionsRoot) {
158
169
  if (!existsSync(sessionsRoot)) return [];
159
170
  const out = [];
160
171
  for (const name of readdirSync(sessionsRoot)) {
172
+ // Skip dotfiles and legacy soft-archive dirs (`.archived-*`).
161
173
  if (name.startsWith('.')) continue;
162
174
  const p = join(sessionsRoot, name);
163
175
  try {
@@ -169,113 +181,32 @@ export function listSessions(sessionsRoot) {
169
181
  return out;
170
182
  }
171
183
 
172
- /** Update a session's displayName. */
173
- export function renameSession(sessionsRoot, sessionId, displayName) {
174
- const h = openSession(sessionsRoot, sessionId);
175
- const meta = h.getMeta();
176
- if (!meta) throw new Error(`renameSession: session ${sessionId} not found`);
177
- const next = { ...meta, displayName: String(displayName || '').trim() || meta.id };
178
- h.saveMeta(next);
179
- h.close();
180
- return next;
181
- }
182
-
183
- /**
184
- * Patch a session's meta: add/remove VPs or change displayName/workDir.
185
- * @param {string} sessionsRoot
186
- * @param {string} sessionId
187
- * @param {{addVpIds?: string[], removeVpIds?: string[], displayName?: string, workDir?: string}} patch
188
- */
189
- export function updateSession(sessionsRoot, sessionId, patch = {}) {
190
- const h = openSession(sessionsRoot, sessionId);
191
- const meta = h.getMeta();
192
- if (!meta) { h.close(); throw new Error(`updateSession: session ${sessionId} not found`); }
193
- let vpIds = Array.from(meta.vpIds || []);
194
- if (Array.isArray(patch.addVpIds)) {
195
- for (const v of patch.addVpIds) {
196
- if (isReservedVpId(v)) throw new ReservedVpIdError(v);
197
- const verdict = validateVpId(v);
198
- if (!verdict.ok) throw new InvalidVpIdError(v, verdict.reason);
199
- if (!vpIds.includes(v)) vpIds.push(v);
200
- }
201
- }
202
- if (Array.isArray(patch.removeVpIds)) {
203
- const drop = new Set(patch.removeVpIds);
204
- vpIds = vpIds.filter((v) => !drop.has(v));
205
- }
206
- if (vpIds.length === 0) {
207
- h.close();
208
- throw new Error('updateSession: refusing to leave session with zero VPs');
209
- }
210
- const next = { ...meta, vpIds };
211
- if (typeof patch.displayName === 'string' && patch.displayName.trim()) {
212
- next.displayName = patch.displayName.trim();
213
- }
214
- if (typeof patch.workDir === 'string') {
215
- next.workDir = patch.workDir.trim();
216
- }
217
- h.saveMeta(next);
218
- h.close();
219
- return next;
220
- }
221
-
222
- /** Stamp lastTurnAt. */
223
- export function touchSession(sessionsRoot, sessionId, when = new Date().toISOString()) {
224
- const h = openSession(sessionsRoot, sessionId);
225
- const meta = h.getMeta();
226
- if (!meta) { h.close(); return null; }
227
- const next = { ...meta, lastTurnAt: when };
228
- h.saveMeta(next);
229
- h.close();
230
- return next;
231
- }
232
-
233
- /** Soft-archive: rename dir to `.archived-<sessionId>-<ts>`. */
234
- export function archiveSession(sessionsRoot, sessionId) {
235
- const src = join(sessionsRoot, sessionId);
236
- if (!existsSync(src)) return false;
237
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
238
- const dst = join(sessionsRoot, `.archived-${sessionId}-${ts}`);
239
- renameSync(src, dst);
240
- return true;
241
- }
242
-
243
- /** Permanently delete a session directory. */
244
- export function deleteSession(sessionsRoot, sessionId) {
245
- const src = join(sessionsRoot, sessionId);
246
- if (!existsSync(src)) return false;
247
- rmSync(src, { recursive: true, force: true });
248
- return true;
249
- }
250
-
251
184
  function validateMeta(meta) {
252
- if (!meta || typeof meta !== 'object') throw new Error('session meta.json must be object');
253
- if (!meta.id || typeof meta.id !== 'string') throw new Error('session.id required');
254
- if (!Array.isArray(meta.vpIds) || meta.vpIds.length === 0) {
255
- throw new Error('session.vpIds required (non-empty array)');
185
+ if (!meta || typeof meta !== 'object') throw new Error('group.json must be object');
186
+ if (!meta.id || typeof meta.id !== 'string') throw new Error('group.id required');
187
+ if (!Array.isArray(meta.roster)) throw new Error('group.roster must be array');
188
+ for (const v of meta.roster) {
189
+ if (typeof v !== 'string') throw new Error('group.roster must be string[]');
256
190
  }
257
- for (const v of meta.vpIds) {
258
- if (typeof v !== 'string') throw new Error('session.vpIds must be string[]');
191
+ if (meta.defaultVpId != null && typeof meta.defaultVpId !== 'string') {
192
+ throw new Error('group.defaultVpId must be string|null');
259
193
  }
260
- if (meta.displayName != null && typeof meta.displayName !== 'string') {
261
- throw new Error('session.displayName must be string');
194
+ if (meta.announcement != null && typeof meta.announcement !== 'string') {
195
+ throw new Error('group.announcement must be string');
262
196
  }
263
197
  if (meta.workDir != null && typeof meta.workDir !== 'string') {
264
- throw new Error('session.workDir must be string');
265
- }
266
- if (meta.lastTurnAt != null && typeof meta.lastTurnAt !== 'string') {
267
- throw new Error('session.lastTurnAt must be string|null');
198
+ throw new Error('group.workDir must be string');
268
199
  }
269
200
  }
270
201
 
271
202
  /**
272
- * @typedef {Object} SessionHandle
203
+ * @typedef {Object} GroupHandle
273
204
  * @property {string} dir
274
205
  * @property {string} id
275
206
  * @property {() => any} getMeta
276
- * @property {(next:any) => void} saveMeta
277
- * @property {(record:any) => any} appendMessage
207
+ * @property {(next:any)=>void} saveMeta
208
+ * @property {(record:any)=>any} appendMessage
278
209
  * @property {() => Generator<any>} streamMessages
279
- * @property {(first:string,last:string) => Generator<any>} readMessageRange
210
+ * @property {(first:string,last:string)=>Generator<any>} readMessageRange
280
211
  * @property {() => void} close
281
212
  */
@@ -61,9 +61,9 @@ export async function runStopHooks(context) {
61
61
  turnStartIdx,
62
62
  taskId,
63
63
  trace,
64
- // Bug 6: groupId/threadId stamped on every persisted message so
64
+ // Bug 6: sessionId/threadId stamped on every persisted message so
65
65
  // history replay can route messages back into the originating group.
66
- groupId,
66
+ sessionId,
67
67
  threadId,
68
68
  // Multi-VP fan-out (history-dedup): when several engines run the
69
69
  // same user prompt in parallel, the orchestrator persists the user
@@ -161,8 +161,8 @@ export async function runStopHooks(context) {
161
161
  record.toolCalls = msg.toolCalls;
162
162
  }
163
163
  if (msg.isError) record.isError = true;
164
- // Bug 6: stamp groupId / threadId so replay can re-route by group.
165
- if (groupId) record.groupId = groupId;
164
+ // Bug 6: stamp sessionId / threadId so replay can re-route by group.
165
+ if (sessionId) record.sessionId = sessionId;
166
166
  if (threadId) record.threadId = threadId;
167
167
  conversationStore.append(record);
168
168
  result.messagesPersisted++;
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Per-thread isolation: each running VP thread keeps its own current todo
13
13
  * list. The web-bridge injects `ctx.getCurrentTodos()` /
14
- * `ctx.setCurrentTodos()` pointing at a per-(groupId,vpId,threadId) slot so
14
+ * `ctx.setCurrentTodos()` pointing at a per-(sessionId,vpId,threadId) slot so
15
15
  * two concurrent threads for the same VP cannot overwrite each other's
16
16
  * progress.
17
17
  *
@@ -21,7 +21,7 @@
21
21
  * @property {object} [config] — engine config
22
22
  * @property {string} [currentVpId] — R6: VP id of the caller (set in multi-VP groups)
23
23
  * @property {string} [currentGroupId] — R6: group id of the caller's RoleInstance
24
- * @property {(groupId: string) => string[]|null} [getGroupRoster]
24
+ * @property {(sessionId: string) => string[]|null} [getGroupRoster]
25
25
  * — R6: resolve a group's roster (used by TaskCreate / route_forward to
26
26
  * validate `members` ⊆ roster without importing group-store directly).
27
27
  * @property {number} [contextWindow] — current model's context window in
@@ -40,7 +40,7 @@
40
40
  * running. Used by `route_forward` to stamp the forwarded message and
41
41
  * by the loop guard to key per-sender throttling.
42
42
  * @property {object} [inboundEnvelope] — the envelope that triggered this
43
- * turn (groupId / msgId / causedBy chain). Threaded into route_forward
43
+ * turn (sessionId / msgId / causedBy chain). Threaded into route_forward
44
44
  * so causedBy chains extend correctly.
45
45
  * @property {object} [router] — per-group router (createRouter() output)
46
46
  * for VP-to-VP forwarding. Set by the bridge when running inside a group.
@@ -5,7 +5,7 @@
5
5
  * startup + filesystem rescans; vp-bridge.js reads it to serve the
6
6
  * `vp_snapshot` and live-diff WS events.
7
7
  *
8
- * The previous RoleInstance map (per (vpId, groupId) pair, with LRU
8
+ * The previous RoleInstance map (per (vpId, sessionId) pair, with LRU
9
9
  * eviction) was removed in GC.2 — production fans out per-VP via
10
10
  * `handleYeaftGroupChat` -> `runVpTurn` directly and never instantiated
11
11
  * RoleInstance.
@@ -21,7 +21,7 @@
21
21
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
22
22
  import { join } from 'path';
23
23
  import { homedir } from 'os';
24
- import { validateVpId } from '../groups/ids.js';
24
+ import { validateVpId } from '../sessions/ids.js';
25
25
  import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
26
26
  import { VP_STUB_MARKER } from '../memory/seed-backfill.js';
27
27
  import { STOCK_VP_IDS } from './stock-ids.js';
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * vp-status-broker.js — thread-aware source of truth for VP runtime status.
3
3
  *
4
- * Internally stores rows by (groupId, vpId, threadId). The wire still emits
5
- * one aggregate row per (groupId, vpId), with `threads[]` and
4
+ * Internally stores rows by (sessionId, vpId, threadId). The wire still emits
5
+ * one aggregate row per (sessionId, vpId), with `threads[]` and
6
6
  * `runningThreadCount`, so old UI code that only reads `state` keeps working
7
7
  * while new UI can show concurrent threads.
8
8
  */
@@ -29,13 +29,13 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
29
29
  /** @type {Map<string, any>} */
30
30
  const threads = new Map();
31
31
 
32
- const vpKeyOf = (groupId, vpId) => `${groupId || ''}::${vpId}`;
33
- const threadKeyOf = (groupId, vpId, threadId) => `${groupId || ''}::${vpId}::${threadId || 'main'}`;
32
+ const vpKeyOf = (sessionId, vpId) => `${sessionId || ''}::${vpId}`;
33
+ const threadKeyOf = (sessionId, vpId, threadId) => `${sessionId || ''}::${vpId}::${threadId || 'main'}`;
34
34
 
35
- function aggregateFor(groupId, vpId) {
35
+ function aggregateFor(sessionId, vpId) {
36
36
  const rows = [];
37
37
  for (const row of threads.values()) {
38
- if ((row.groupId || null) === (groupId || null) && row.vpId === vpId) rows.push(row);
38
+ if ((row.sessionId || null) === (sessionId || null) && row.vpId === vpId) rows.push(row);
39
39
  }
40
40
  rows.sort((a, b) => (b.updatedAt || b.since || 0) - (a.updatedAt || a.since || 0));
41
41
 
@@ -55,7 +55,7 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
55
55
  const runningThreadCount = rows.filter(r => RUNNING_STATES.has(r.state)).length;
56
56
  const latest = rows[0] || null;
57
57
  return {
58
- groupId: groupId || null,
58
+ sessionId: sessionId || null,
59
59
  vpId,
60
60
  state,
61
61
  since: latest ? latest.since : now(),
@@ -76,10 +76,10 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
76
76
  };
77
77
  }
78
78
 
79
- function prune(groupId, vpId) {
79
+ function prune(sessionId, vpId) {
80
80
  const rows = [];
81
81
  for (const [key, row] of threads.entries()) {
82
- if ((row.groupId || null) === (groupId || null) && row.vpId === vpId) rows.push([key, row]);
82
+ if ((row.sessionId || null) === (sessionId || null) && row.vpId === vpId) rows.push([key, row]);
83
83
  }
84
84
  const cutoff = now() - COMPLETED_TTL_MS;
85
85
  for (const [key, row] of rows) {
@@ -94,18 +94,18 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
94
94
  }
95
95
  }
96
96
 
97
- function emitAggregate(groupId, vpId) {
98
- prune(groupId, vpId);
99
- send({ type: 'vp_status_changed', ...aggregateFor(groupId, vpId) });
97
+ function emitAggregate(sessionId, vpId) {
98
+ prune(sessionId, vpId);
99
+ send({ type: 'vp_status_changed', ...aggregateFor(sessionId, vpId) });
100
100
  }
101
101
 
102
- function transition({ groupId, vpId, state, turnId = null, threadId = 'main', title = '', messageCount } = {}) {
102
+ function transition({ sessionId, vpId, state, turnId = null, threadId = 'main', title = '', messageCount } = {}) {
103
103
  if (!vpId) return false;
104
104
  if (!VALID_STATES.has(state)) {
105
105
  throw new RangeError(`vp-status-broker: invalid state '${state}'`);
106
106
  }
107
107
  const tid = threadId || 'main';
108
- const key = threadKeyOf(groupId, vpId, tid);
108
+ const key = threadKeyOf(sessionId, vpId, tid);
109
109
  const prev = threads.get(key);
110
110
  if (prev && prev.state === state && prev.turnId === turnId && (!title || prev.title === title)) {
111
111
  return false;
@@ -113,7 +113,7 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
113
113
  const ts = now();
114
114
  const row = {
115
115
  ...(prev || {}),
116
- groupId: groupId || null,
116
+ sessionId: sessionId || null,
117
117
  vpId,
118
118
  threadId: tid,
119
119
  state,
@@ -125,41 +125,41 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
125
125
  messageCount: Number.isFinite(messageCount) ? messageCount : (prev?.messageCount || 0),
126
126
  };
127
127
  threads.set(key, row);
128
- emitAggregate(groupId, vpId);
128
+ emitAggregate(sessionId, vpId);
129
129
  return true;
130
130
  }
131
131
 
132
- function settleIdle({ groupId, vpId, threadId = 'main', title = '', messageCount } = {}) {
133
- return transition({ groupId, vpId, threadId, state: 'idle', turnId: null, title, messageCount });
132
+ function settleIdle({ sessionId, vpId, threadId = 'main', title = '', messageCount } = {}) {
133
+ return transition({ sessionId, vpId, threadId, state: 'idle', turnId: null, title, messageCount });
134
134
  }
135
135
 
136
- function snapshot(groupId) {
136
+ function snapshot(sessionId) {
137
137
  const seen = new Set();
138
138
  const out = [];
139
139
  for (const row of threads.values()) {
140
- if (groupId !== undefined && groupId !== null && row.groupId !== groupId) continue;
141
- const key = vpKeyOf(row.groupId, row.vpId);
140
+ if (sessionId !== undefined && sessionId !== null && row.sessionId !== sessionId) continue;
141
+ const key = vpKeyOf(row.sessionId, row.vpId);
142
142
  if (seen.has(key)) continue;
143
143
  seen.add(key);
144
- out.push(aggregateFor(row.groupId, row.vpId));
144
+ out.push(aggregateFor(row.sessionId, row.vpId));
145
145
  }
146
146
  return out;
147
147
  }
148
148
 
149
- function broadcastSnapshot({ groupId } = {}) {
149
+ function broadcastSnapshot({ sessionId } = {}) {
150
150
  send({
151
151
  type: 'vp_status_snapshot',
152
- groupId: groupId === undefined ? null : groupId,
153
- statuses: snapshot(groupId),
152
+ sessionId: sessionId === undefined ? null : sessionId,
153
+ statuses: snapshot(sessionId),
154
154
  });
155
155
  }
156
156
 
157
- function forget({ groupId, vpId, threadId } = {}) {
157
+ function forget({ sessionId, vpId, threadId } = {}) {
158
158
  if (threadId) {
159
- threads.delete(threadKeyOf(groupId, vpId, threadId));
159
+ threads.delete(threadKeyOf(sessionId, vpId, threadId));
160
160
  } else {
161
161
  for (const key of Array.from(threads.keys())) {
162
- if (key.startsWith(`${groupId || ''}::${vpId}::`)) threads.delete(key);
162
+ if (key.startsWith(`${sessionId || ''}::${vpId}::`)) threads.delete(key);
163
163
  }
164
164
  }
165
165
  }