@yeaft/webchat-agent 0.1.534 → 0.1.536

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
39
- import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove } from '../unify/web-bridge.js';
39
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -436,6 +436,32 @@ export async function handleMessage(msg) {
436
436
  handleUnifyUserMemoryRemove(msg);
437
437
  break;
438
438
 
439
+ // task-334m: Group CRUD + D1 seed wiring (§Δ10 334m + R6 §Δ31.2).
440
+ // All handlers reply via `group_crud_result`; mutating ops additionally
441
+ // emit `group_roster_changed` (add/remove/default) or
442
+ // `group_list_updated` (create/rename/archive) for listener sync.
443
+ case 'unify_list_groups':
444
+ handleUnifyListGroups(msg);
445
+ break;
446
+ case 'unify_create_group':
447
+ handleUnifyCreateGroup(msg);
448
+ break;
449
+ case 'unify_rename_group':
450
+ handleUnifyRenameGroup(msg);
451
+ break;
452
+ case 'unify_archive_group':
453
+ handleUnifyArchiveGroup(msg);
454
+ break;
455
+ case 'unify_add_member':
456
+ handleUnifyAddMember(msg);
457
+ break;
458
+ case 'unify_remove_member':
459
+ handleUnifyRemoveMember(msg);
460
+ break;
461
+ case 'unify_set_default_vp':
462
+ handleUnifySetDefaultVp(msg);
463
+ break;
464
+
439
465
  // Expert roles definition (for ExpertPanel detail view)
440
466
  case 'get_expert_roles': {
441
467
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.534",
3
+ "version": "0.1.536",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,250 @@
1
+ /**
2
+ * group-crud.js — High-level Group CRUD API (task-334m).
3
+ *
4
+ * Wraps the primitives from group-store.js + roster.js into the 5 operations
5
+ * wired to WS events (§Δ10 334m + R6 §Δ31.2):
6
+ * createGroupFromSpec — wizard "create new group" (empty or user-picked roster)
7
+ * renameGroup — update meta.name; preserves roster / defaultVpId
8
+ * archiveGroup — rename dir to `.archived-<ts>-<id>` (soft delete)
9
+ * addMember — roster.addVp + save; sets defaultVpId if first
10
+ * removeMember — roster.removeVp + save; clears/rotates defaultVpId
11
+ *
12
+ * Plus the D1 bootstrap helper:
13
+ * ensureDefaultGroupIfEmpty(yeaftDir, {libDir}) — if NO group exists on
14
+ * disk, seed `grp_default` with roster = every VP in the library, and
15
+ * defaultVpId = alphabetically first vpId. No-op when ≥1 group present.
16
+ *
17
+ * Hard constraints (PM):
18
+ * (a) We don't touch 334o storage primitives (storage/index.js) — we call
19
+ * group-store.openGroup / saveMeta which already go through openLog.
20
+ * (b) We don't touch VP entity (vp-store.js / vp-loader.js) — only read
21
+ * via scanVpLibrary to know which VPs exist at seed time.
22
+ * (c) When `addMember` is called with an empty roster and no defaultVpId
23
+ * resolvable, callers surface `no_default_vp` via `createGroupFromSpec`;
24
+ * on `removeMember` we permit the empty state (UI nudges the user).
25
+ *
26
+ * Error shape — every throw is a `GroupCrudError` with a stable `.code`:
27
+ * 'not_found' — group id has no dir / meta
28
+ * 'duplicate' — createGroup collided with an existing id
29
+ * 'invalid_name' — display name empty after trim
30
+ * 'no_default_vp' — seed with empty VP library OR roster empties to []
31
+ * and the caller asked for a defaultVpId. (D1 spec)
32
+ * 'reserved'/'invalid_vp_id'/... — bubbled from ids.js validators
33
+ */
34
+
35
+ import { existsSync, renameSync } from 'fs';
36
+ import { randomBytes } from 'crypto';
37
+ import { join } from 'path';
38
+ import {
39
+ openGroup, createGroup, listGroups, loadGroupMeta,
40
+ } from './group-store.js';
41
+ import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './roster.js';
42
+ import { seedDefaultGroup, DEFAULT_GROUP_ID } from './seed-default.js';
43
+ import { nextGroupId, validateVpId, isReservedVpId } from './ids.js';
44
+ import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
45
+
46
+ export class GroupCrudError extends Error {
47
+ constructor(code, groupId, message) {
48
+ super(message || `${code}: ${groupId}`);
49
+ this.name = 'GroupCrudError';
50
+ this.code = code;
51
+ this.groupId = groupId;
52
+ }
53
+ }
54
+
55
+ function groupsRoot(yeaftDir) {
56
+ return join(yeaftDir, 'groups');
57
+ }
58
+
59
+ /** Build a safe group id from a display name (slug + ulid-lite suffix). */
60
+ export function makeGroupId(name) {
61
+ const slug = String(name || 'group')
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, '-')
64
+ .replace(/^-+|-+$/g, '')
65
+ .slice(0, 24) || 'group';
66
+ return nextGroupId(slug);
67
+ }
68
+
69
+ /**
70
+ * (B) D1 seed — called at boot (or when multi-VP is first enabled). Idempotent:
71
+ * returns `{seeded:false}` if any group already exists on disk (including
72
+ * `grp_default`). When empty, seeds with roster = full VP library, sorted
73
+ * alphabetically; defaultVpId = roster[0].
74
+ *
75
+ * When the VP library is also empty, we still seed an empty-roster group so
76
+ * the UI has somewhere to land — but defaultVpId is null and downstream
77
+ * message send will return `no_default_vp` until the user adds a VP.
78
+ */
79
+ export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
80
+ const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
81
+ const existing = listGroups(groupsRoot(yeaftDir));
82
+ if (existing.length > 0) {
83
+ return { seeded: false, groupId: existing[0].id };
84
+ }
85
+
86
+ // Sort VP ids alphabetically (stable for tests / deterministic UI).
87
+ // NB: vp-store returns records with `.id` (not `.vpId`) — keep this in sync.
88
+ const vps = scanVpLibrary({ dir: libDir })
89
+ .map(v => v && v.id)
90
+ .filter(v => typeof v === 'string' && v.length > 0);
91
+ vps.sort((a, b) => a.localeCompare(b));
92
+
93
+ const defaultVpId = vps[0] || null;
94
+ const { group, created } = seedDefaultGroup(yeaftDir, {
95
+ name: options.name || 'Default',
96
+ roster: vps,
97
+ defaultVpId,
98
+ });
99
+ return {
100
+ seeded: created,
101
+ groupId: group.id,
102
+ defaultVpId,
103
+ rosterSize: vps.length,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * (A.1) Create group from a wizard spec. `spec.roster` is authoritative —
109
+ * we do NOT auto-expand to the full VP library here. That's D1's job only.
110
+ *
111
+ * @param {string} yeaftDir
112
+ * @param {{name:string, roster?:string[], defaultVpId?:string|null}} spec
113
+ * @returns {{id:string, name:string, roster:string[], defaultVpId:string|null}}
114
+ */
115
+ export function createGroupFromSpec(yeaftDir, spec) {
116
+ const name = String(spec && spec.name || '').trim();
117
+ if (!name) throw new GroupCrudError('invalid_name', null, 'group name required');
118
+
119
+ const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
120
+ // Validate every member up-front so we fail before touching fs.
121
+ for (const vpId of roster) {
122
+ if (isReservedVpId(vpId)) {
123
+ throw new GroupCrudError('reserved', null, `reserved vpId: ${vpId}`);
124
+ }
125
+ const v = validateVpId(vpId);
126
+ if (!v.ok) throw new GroupCrudError(v.reason, null, `invalid vpId: ${vpId}`);
127
+ }
128
+
129
+ // defaultVpId resolution: explicit > roster[0] > null. Null is allowed at
130
+ // create time (empty roster) — the wizard modal warns the user downstream
131
+ // (task-334m spec: `no_default_vp` surfaced on first send, not on create).
132
+ let defaultVpId = spec.defaultVpId || null;
133
+ if (defaultVpId && !roster.includes(defaultVpId)) {
134
+ throw new GroupCrudError('default_not_in_roster', null, `${defaultVpId} not in roster`);
135
+ }
136
+ if (!defaultVpId) defaultVpId = roster[0] || null;
137
+
138
+ const id = makeGroupId(name);
139
+ const root = groupsRoot(yeaftDir);
140
+ if (existsSync(join(root, id))) {
141
+ // Extremely unlikely (ulid suffix), but surface deterministically.
142
+ throw new GroupCrudError('duplicate', id);
143
+ }
144
+
145
+ const handle = createGroup(root, { id, name, roster, defaultVpId });
146
+ const meta = handle.getMeta();
147
+ handle.close();
148
+ return meta;
149
+ }
150
+
151
+ /**
152
+ * (A.2) Rename — updates meta.name; preserves everything else.
153
+ */
154
+ export function renameGroup(yeaftDir, groupId, newName) {
155
+ const name = String(newName || '').trim();
156
+ if (!name) throw new GroupCrudError('invalid_name', groupId);
157
+ const handle = requireGroup(yeaftDir, groupId);
158
+ const meta = handle.getMeta();
159
+ handle.saveMeta({ ...meta, name });
160
+ const next = handle.getMeta();
161
+ handle.close();
162
+ return next;
163
+ }
164
+
165
+ /**
166
+ * (A.3) Archive — renames the dir to `.archived-<ts>-<id>`. Directory
167
+ * prefix `.` keeps `listGroups` from picking it up (readdirSync filter in
168
+ * the caller). Reversible: user can rename back manually for recovery.
169
+ *
170
+ * We do NOT support hard-delete here — that's an upstream UI flow with its
171
+ * own second-confirm modal (acceptance #4 in task-334-slice-specs.md 334m).
172
+ */
173
+ export function archiveGroup(yeaftDir, groupId) {
174
+ const root = groupsRoot(yeaftDir);
175
+ const srcDir = join(root, groupId);
176
+ if (!existsSync(srcDir) || !loadGroupMeta(srcDir)) {
177
+ throw new GroupCrudError('not_found', groupId);
178
+ }
179
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
180
+ // Append 4 hex chars to disambiguate same-millisecond archives (nit #5).
181
+ const suffix = randomBytes(2).toString('hex');
182
+ const dstDir = join(root, `.archived-${ts}-${suffix}-${groupId}`);
183
+ renameSync(srcDir, dstDir);
184
+ return { groupId, archivedAs: dstDir };
185
+ }
186
+
187
+ /**
188
+ * (A.4) Add a VP to the group roster. Idempotent — no-op if already present.
189
+ * Returns the new meta.
190
+ */
191
+ export function addMember(yeaftDir, groupId, vpId) {
192
+ const handle = requireGroup(yeaftDir, groupId);
193
+ try {
194
+ const meta = handle.getMeta();
195
+ const next = rosterAdd(meta, vpId);
196
+ handle.saveMeta(next);
197
+ return handle.getMeta();
198
+ } finally {
199
+ handle.close();
200
+ }
201
+ }
202
+
203
+ /**
204
+ * (A.5) Remove a VP from the group roster. If the removed id was default,
205
+ * roster.removeVp rotates to the next member (or null).
206
+ */
207
+ export function removeMember(yeaftDir, groupId, vpId) {
208
+ const handle = requireGroup(yeaftDir, groupId);
209
+ try {
210
+ const meta = handle.getMeta();
211
+ if (!meta.roster.includes(vpId)) {
212
+ // Treat as idempotent no-op — UI wants the post-state.
213
+ return meta;
214
+ }
215
+ const next = rosterRemove(meta, vpId);
216
+ handle.saveMeta(next);
217
+ return handle.getMeta();
218
+ } finally {
219
+ handle.close();
220
+ }
221
+ }
222
+
223
+ /** Expose default-VP setter for UI "set as default" affordance. */
224
+ export function setGroupDefaultVp(yeaftDir, groupId, vpId) {
225
+ const handle = requireGroup(yeaftDir, groupId);
226
+ try {
227
+ const meta = handle.getMeta();
228
+ const next = setDefaultVp(meta, vpId);
229
+ handle.saveMeta(next);
230
+ return handle.getMeta();
231
+ } finally {
232
+ handle.close();
233
+ }
234
+ }
235
+
236
+ function requireGroup(yeaftDir, groupId) {
237
+ const root = groupsRoot(yeaftDir);
238
+ const dir = join(root, groupId);
239
+ if (!existsSync(dir) || !loadGroupMeta(dir)) {
240
+ throw new GroupCrudError('not_found', groupId);
241
+ }
242
+ return openGroup(root, groupId);
243
+ }
244
+
245
+ /** Convenience: snapshot all non-archived groups for WS broadcast. */
246
+ export function snapshotGroups(yeaftDir) {
247
+ return listGroups(groupsRoot(yeaftDir));
248
+ }
249
+
250
+ export { DEFAULT_GROUP_ID };
@@ -37,6 +37,18 @@ export {
37
37
  seedDefaultGroup,
38
38
  DEFAULT_GROUP_ID,
39
39
  } from './seed-default.js';
40
+ export {
41
+ GroupCrudError,
42
+ makeGroupId,
43
+ ensureDefaultGroupIfEmpty,
44
+ createGroupFromSpec,
45
+ renameGroup,
46
+ archiveGroup,
47
+ addMember,
48
+ removeMember,
49
+ setGroupDefaultVp,
50
+ snapshotGroups,
51
+ } from './group-crud.js';
40
52
  export {
41
53
  nextMsgId,
42
54
  nextGroupId,
package/unify/session.js CHANGED
@@ -31,6 +31,7 @@ import { getThreadStore } from './threads/store.js';
31
31
  import { createIntentClassifier } from './router/intent-classifier.js';
32
32
  import { initInputQueueStore } from './input-queue/store.js';
33
33
  import { createDispatcher } from './pipeline/dispatcher.js';
34
+ import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
34
35
  import { join } from 'path';
35
36
  import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
36
37
 
@@ -164,6 +165,20 @@ export async function loadSession(options = {}) {
164
165
  idleArchiveDays: config.unify?.autoArchiveIdleDays ?? 0,
165
166
  });
166
167
 
168
+ // ─── 5c. D1 first-boot seed (task-334m) ─────────────────
169
+ // When no groups exist on disk AND we're not in read-only mode,
170
+ // seed a default group with roster = all VPs in the library,
171
+ // defaultVpId = alphabetically first. Idempotent — no-op when
172
+ // any group already exists. Never throws; failure logs a warning
173
+ // so session load always succeeds.
174
+ if (!config._readOnly) {
175
+ try {
176
+ ensureDefaultGroupIfEmpty(yeaftDir);
177
+ } catch (err) {
178
+ console.warn(`[Yeaft] ensureDefaultGroupIfEmpty failed: ${err?.message || err}`);
179
+ }
180
+ }
181
+
167
182
  // ─── 6. Load skills ────────────────────────────────────
168
183
  let skillManager;
169
184
  if (skipSkills) {
@@ -33,6 +33,16 @@ import {
33
33
  handleUnifyUserMemoryWrite as _handleUnifyUserMemoryWrite,
34
34
  handleUnifyUserMemoryRemove as _handleUnifyUserMemoryRemove,
35
35
  } from './user-memory.js';
36
+ import {
37
+ GroupCrudError,
38
+ createGroupFromSpec,
39
+ renameGroup,
40
+ archiveGroup,
41
+ addMember,
42
+ removeMember,
43
+ setGroupDefaultVp,
44
+ snapshotGroups,
45
+ } from './groups/group-crud.js';
36
46
 
37
47
  /** @type {import('./session.js').Session | null} */
38
48
  let session = null;
@@ -253,6 +263,151 @@ export function handleUnifyVpRead(msg) {
253
263
  sendVpCrudResult({ op: 'read', requestId, ok: true, vpId, vp });
254
264
  }
255
265
 
266
+ /**
267
+ * task-334m: Group CRUD wired to WS events (§Δ10 334m + R6 §Δ31.2).
268
+ *
269
+ * Message shapes (wire):
270
+ * unify_list_groups { requestId? }
271
+ * unify_create_group { payload: {name, roster?, defaultVpId?}, requestId? }
272
+ * unify_rename_group { groupId, name, requestId? }
273
+ * unify_archive_group { groupId, requestId? }
274
+ * unify_add_member { groupId, vpId, requestId? }
275
+ * unify_remove_member { groupId, vpId, requestId? }
276
+ * unify_set_default_vp { groupId, vpId, requestId? }
277
+ *
278
+ * Replies (sendUnifyEvent):
279
+ * { type: 'group_crud_result', op, requestId, ok, group?, groups?, error?: {code, groupId?, message?} }
280
+ *
281
+ * Post-change broadcast (when meta mutates):
282
+ * { type: 'group_roster_changed', groupId, roster, defaultVpId, name }
283
+ */
284
+ function sendGroupCrudResult(payload) {
285
+ sendUnifyEvent({ type: 'group_crud_result', ...payload });
286
+ }
287
+
288
+ function sendGroupSnapshotBroadcast() {
289
+ try {
290
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
291
+ if (!yeaftDir) return;
292
+ const groups = snapshotGroups(yeaftDir);
293
+ sendUnifyEvent({ type: 'group_list_updated', groups });
294
+ } catch (err) {
295
+ console.warn('[Unify] sendGroupSnapshotBroadcast failed:', err?.message || err);
296
+ }
297
+ }
298
+
299
+ function sendGroupRosterChanged(group) {
300
+ if (!group) return;
301
+ sendUnifyEvent({
302
+ type: 'group_roster_changed',
303
+ groupId: group.id,
304
+ name: group.name,
305
+ roster: group.roster,
306
+ defaultVpId: group.defaultVpId,
307
+ });
308
+ }
309
+
310
+ function groupErrorPayload(err) {
311
+ return {
312
+ code: err instanceof GroupCrudError ? err.code : 'unknown',
313
+ groupId: err && err.groupId,
314
+ message: err && err.message,
315
+ };
316
+ }
317
+
318
+ export function handleUnifyListGroups(msg) {
319
+ const requestId = msg && msg.requestId;
320
+ try {
321
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
322
+ const groups = snapshotGroups(yeaftDir);
323
+ sendGroupCrudResult({ op: 'list', requestId, ok: true, groups });
324
+ } catch (err) {
325
+ sendGroupCrudResult({ op: 'list', requestId, ok: false, error: groupErrorPayload(err) });
326
+ }
327
+ }
328
+
329
+ export function handleUnifyCreateGroup(msg) {
330
+ const requestId = msg && msg.requestId;
331
+ const payload = (msg && msg.payload) || {};
332
+ try {
333
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
334
+ const group = createGroupFromSpec(yeaftDir, payload);
335
+ sendGroupCrudResult({ op: 'create', requestId, ok: true, group });
336
+ sendGroupSnapshotBroadcast();
337
+ } catch (err) {
338
+ sendGroupCrudResult({ op: 'create', requestId, ok: false, error: groupErrorPayload(err) });
339
+ }
340
+ }
341
+
342
+ export function handleUnifyRenameGroup(msg) {
343
+ const requestId = msg && msg.requestId;
344
+ const groupId = msg && msg.groupId;
345
+ const name = msg && msg.name;
346
+ try {
347
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
348
+ const group = renameGroup(yeaftDir, groupId, name);
349
+ sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
350
+ sendGroupSnapshotBroadcast();
351
+ } catch (err) {
352
+ sendGroupCrudResult({ op: 'rename', requestId, ok: false, error: groupErrorPayload(err) });
353
+ }
354
+ }
355
+
356
+ export function handleUnifyArchiveGroup(msg) {
357
+ const requestId = msg && msg.requestId;
358
+ const groupId = msg && msg.groupId;
359
+ try {
360
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
361
+ const result = archiveGroup(yeaftDir, groupId);
362
+ sendGroupCrudResult({ op: 'archive', requestId, ok: true, groupId: result.groupId });
363
+ sendGroupSnapshotBroadcast();
364
+ } catch (err) {
365
+ sendGroupCrudResult({ op: 'archive', requestId, ok: false, error: groupErrorPayload(err) });
366
+ }
367
+ }
368
+
369
+ export function handleUnifyAddMember(msg) {
370
+ const requestId = msg && msg.requestId;
371
+ const groupId = msg && msg.groupId;
372
+ const vpId = msg && msg.vpId;
373
+ try {
374
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
375
+ const group = addMember(yeaftDir, groupId, vpId);
376
+ sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
377
+ sendGroupRosterChanged(group);
378
+ } catch (err) {
379
+ sendGroupCrudResult({ op: 'add_member', requestId, ok: false, error: groupErrorPayload(err) });
380
+ }
381
+ }
382
+
383
+ export function handleUnifyRemoveMember(msg) {
384
+ const requestId = msg && msg.requestId;
385
+ const groupId = msg && msg.groupId;
386
+ const vpId = msg && msg.vpId;
387
+ try {
388
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
389
+ const group = removeMember(yeaftDir, groupId, vpId);
390
+ sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
391
+ sendGroupRosterChanged(group);
392
+ } catch (err) {
393
+ sendGroupCrudResult({ op: 'remove_member', requestId, ok: false, error: groupErrorPayload(err) });
394
+ }
395
+ }
396
+
397
+ export function handleUnifySetDefaultVp(msg) {
398
+ const requestId = msg && msg.requestId;
399
+ const groupId = msg && msg.groupId;
400
+ const vpId = msg && msg.vpId;
401
+ try {
402
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
403
+ const group = setGroupDefaultVp(yeaftDir, groupId, vpId);
404
+ sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
405
+ sendGroupRosterChanged(group);
406
+ } catch (err) {
407
+ sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: false, error: groupErrorPayload(err) });
408
+ }
409
+ }
410
+
256
411
  /**
257
412
  * task-318 rev-1 fix: install live-setter bridge between the session's
258
413
  * runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
@@ -845,6 +1000,10 @@ export async function handleUnifyChat(msg) {
845
1000
  // serverTime) so a freshly-connected client can restore inflight
846
1001
  // status without waiting for the next engine event.
847
1002
  sendThreadListSnapshot();
1003
+ // task-334m: push initial groups snapshot so the Sidebar Groups
1004
+ // section renders the full list immediately (including the D1
1005
+ // default group seeded during session bootstrap).
1006
+ sendGroupSnapshotBroadcast();
848
1007
  }
849
1008
 
850
1009
  // ─── Per-call AbortController (task-320) ──
@@ -1318,6 +1477,8 @@ export async function handleUnifyLoadHistory(msg) {
1318
1477
  // mutation-delta stream; `thread_list_snapshot` is the single
1319
1478
  // authoritative "everything right now" payload.
1320
1479
  sendThreadListSnapshot();
1480
+ // task-334m: replay groups snapshot so Sidebar Groups rebuilds on refresh.
1481
+ sendGroupSnapshotBroadcast();
1321
1482
 
1322
1483
  const limit = msg.limit || 50;
1323
1484
  const messages = session.conversationStore.loadRecent(limit);