@yeaft/webchat-agent 0.1.517 → 0.1.519
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.
- package/connection/message-router.js +7 -1
- package/crew/routing.js +141 -64
- package/package.json +1 -1
- package/unify/vp/vp-bridge.js +102 -0
- package/unify/web-bridge.js +10 -0
|
@@ -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 } from '../unify/web-bridge.js';
|
|
39
|
+
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe } from '../unify/web-bridge.js';
|
|
40
40
|
|
|
41
41
|
export async function handleMessage(msg) {
|
|
42
42
|
switch (msg.type) {
|
|
@@ -397,6 +397,12 @@ export async function handleMessage(msg) {
|
|
|
397
397
|
handleUnifyAbortAll();
|
|
398
398
|
break;
|
|
399
399
|
|
|
400
|
+
// task-334-ui-a: VP library subscribe — replies with one-shot
|
|
401
|
+
// vp_snapshot event. Live diff (vp_updated/vp_removed) deferred to 334h.
|
|
402
|
+
case 'unify_vp_subscribe':
|
|
403
|
+
handleUnifyVpSubscribe(msg);
|
|
404
|
+
break;
|
|
405
|
+
|
|
400
406
|
// Expert roles definition (for ExpertPanel detail view)
|
|
401
407
|
case 'get_expert_roles': {
|
|
402
408
|
const { getExpertRolesDefinition } = await import('../expert-roles.js');
|
package/crew/routing.js
CHANGED
|
@@ -242,7 +242,7 @@ export function parseRoutes(text) {
|
|
|
242
242
|
const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
|
|
243
243
|
const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
|
|
244
244
|
|
|
245
|
-
routes.push({ to: toRaw, summary, taskId: null, taskTitle: null });
|
|
245
|
+
routes.push({ to: toRaw, toList: [toRaw], summary, taskId: null, taskTitle: null });
|
|
246
246
|
// Shorthand is a single line — strip the whole line.
|
|
247
247
|
const lineEnd = maskedText.indexOf('\n', pos);
|
|
248
248
|
strippedRanges.push({
|
|
@@ -348,8 +348,21 @@ function _removeRanges(input, ranges) {
|
|
|
348
348
|
|
|
349
349
|
/**
|
|
350
350
|
* Parse fields from a ROUTE block body (the content between ---ROUTE--- and ---END_ROUTE---).
|
|
351
|
+
*
|
|
352
|
+
* task-335 — multi-target `to:` support. The `to:` line is split on
|
|
353
|
+
* common multi-target separators (`,`, `+`, `、`, `;`, `;`, ` and `) so
|
|
354
|
+
* `to: rev-1, rev-3` / `to: rev-1 + rev-3` / `to: rev-1、rev-3` all
|
|
355
|
+
* resolve to two distinct targets. Each target is normalised the same
|
|
356
|
+
* way single-target was normalised before (strip parenthetical notes,
|
|
357
|
+
* trailing punctuation, lowercase).
|
|
358
|
+
*
|
|
359
|
+
* Backward-compat shape: `route.to` is still a STRING (the first
|
|
360
|
+
* target) so the 20+ existing tests that assert `route.to === 'dev-1'`
|
|
361
|
+
* keep working. `route.toList` is the new authoritative list and is
|
|
362
|
+
* always at least one element. `executeRoute` consumes `toList`.
|
|
363
|
+
*
|
|
351
364
|
* @param {string} block — raw block content
|
|
352
|
-
* @returns {{ to: string, summary: string, taskId: string|null, taskTitle: string|null } | null}
|
|
365
|
+
* @returns {{ to: string, toList: string[], summary: string, taskId: string|null, taskTitle: string|null } | null}
|
|
353
366
|
*/
|
|
354
367
|
function _parseRouteBlock(block) {
|
|
355
368
|
// task-328 §3: tolerate Chinese full-width colon (`to:` / `task:` / `summary:`)
|
|
@@ -358,11 +371,29 @@ function _parseRouteBlock(block) {
|
|
|
358
371
|
const toMatch = block.match(/to\s*[::]\s*(.+)/i);
|
|
359
372
|
if (!toMatch) return null;
|
|
360
373
|
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
//
|
|
365
|
-
|
|
374
|
+
const toLine = toMatch[1].trim();
|
|
375
|
+
// task-335: split multi-target `to:` lines. Separators recognised:
|
|
376
|
+
// ASCII comma `,`, ASCII plus `+`, ASCII semicolon `;`, ` and `
|
|
377
|
+
// Chinese full-width comma `,`, ideographic comma `、`, full-width
|
|
378
|
+
// semicolon `;`, slash `/`. Whitespace around separators is fine.
|
|
379
|
+
const MULTI_TARGET_SEP = /\s*(?:,|\+|;|、|,|;|\/|\s+and\s+)\s*/i;
|
|
380
|
+
const rawTargets = toLine.split(MULTI_TARGET_SEP);
|
|
381
|
+
|
|
382
|
+
const cleaned = [];
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
for (const raw of rawTargets) {
|
|
385
|
+
const trimmed = raw.trim().toLowerCase();
|
|
386
|
+
if (!trimmed) continue;
|
|
387
|
+
// ★ Clean a single target value: take only the first word (strip
|
|
388
|
+
// parenthetical notes, trailing punctuation).
|
|
389
|
+
// e.g. "pm (决策者)" → "pm", "dev-1 // main dev" → "dev-1"
|
|
390
|
+
const oneWord = trimmed.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
|
|
391
|
+
if (!oneWord) continue;
|
|
392
|
+
if (seen.has(oneWord)) continue;
|
|
393
|
+
seen.add(oneWord);
|
|
394
|
+
cleaned.push(oneWord);
|
|
395
|
+
}
|
|
396
|
+
if (cleaned.length === 0) return null;
|
|
366
397
|
|
|
367
398
|
// ★ summary: match until next known field (task:/taskTitle:) or end of block.
|
|
368
399
|
// Field separator accepts ASCII `:` or Chinese `:`.
|
|
@@ -390,7 +421,8 @@ function _parseRouteBlock(block) {
|
|
|
390
421
|
}
|
|
391
422
|
|
|
392
423
|
return {
|
|
393
|
-
to:
|
|
424
|
+
to: cleaned[0],
|
|
425
|
+
toList: cleaned,
|
|
394
426
|
summary,
|
|
395
427
|
taskId: taskMatch ? taskMatch[1].trim() : null,
|
|
396
428
|
taskTitle: taskTitleMatch ? taskTitleMatch[1].trim() : null
|
|
@@ -471,11 +503,101 @@ export function resolveRoleName(to, session, fromRole) {
|
|
|
471
503
|
|
|
472
504
|
/**
|
|
473
505
|
* 执行路由
|
|
506
|
+
*
|
|
507
|
+
* task-335 — multi-target fan-out. `route.toList` (string[]) is the
|
|
508
|
+
* authoritative list of targets; `route.to` is kept as the first target
|
|
509
|
+
* for backward-compat with downstream consumers (kanban / sendCrewOutput
|
|
510
|
+
* still see a single primary `to`). Each target is dispatched as if it
|
|
511
|
+
* were its own ROUTE: self-route reject is per-target (one bad target
|
|
512
|
+
* does not kill the whole fan-out), unknown-target fallback is
|
|
513
|
+
* per-target, dispatch + kanban update + sendCrewOutput happen
|
|
514
|
+
* per-target.
|
|
515
|
+
*
|
|
516
|
+
* Side-effects done ONCE per call (not per target):
|
|
517
|
+
* - taskId fallback resolution
|
|
518
|
+
* - auto-resume from paused/stopped
|
|
519
|
+
* - state-stopped metric
|
|
520
|
+
*
|
|
474
521
|
* @param {Array<{mimeType, data}>} [turnImages] - auto-attached images from the turn (max 3)
|
|
475
522
|
*/
|
|
476
523
|
export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
477
|
-
let {
|
|
524
|
+
let { summary, taskId, taskTitle } = route;
|
|
525
|
+
|
|
526
|
+
// task-335: normalise to a list. Accept legacy callers that still pass
|
|
527
|
+
// `to: 'pm'` (string) without `toList`.
|
|
528
|
+
let toList = Array.isArray(route.toList) && route.toList.length > 0
|
|
529
|
+
? route.toList.slice()
|
|
530
|
+
: (typeof route.to === 'string' ? [route.to] : []);
|
|
531
|
+
if (toList.length === 0) {
|
|
532
|
+
console.warn(`[Crew] executeRoute called with no targets (fromRole=${fromRole})`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// task-330b §B item 1: state-stopped metric — message arrived while
|
|
537
|
+
// session was paused/stopped. Behaviour (auto-resume) is unchanged for
|
|
538
|
+
// backward compat; this is observer-only. Recorded ONCE per fan-out.
|
|
539
|
+
if (session.status === 'paused' || session.status === 'stopped') {
|
|
540
|
+
recordRoutingEvent(session, 'state-stopped', {
|
|
541
|
+
fromRole,
|
|
542
|
+
toRole: toList.join(','),
|
|
543
|
+
taskId: taskId || null,
|
|
544
|
+
note: `session.status=${session.status} at executeRoute entry`,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Auto-resume: paused/stopped → running (route execution means work should continue)
|
|
549
|
+
if (session.status === 'paused' || session.status === 'stopped') {
|
|
550
|
+
console.log(`[Crew] Auto-resuming session from ${session.status} to running (route from ${fromRole} to ${toList.join(',')})`);
|
|
551
|
+
session.status = 'running';
|
|
552
|
+
sendStatusUpdate(session);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── task-321: taskId fallback chain ─────────────────────────────
|
|
556
|
+
// When a ROUTE omits `task:` (shorthand, bare dispatch, human messages,
|
|
557
|
+
// PM forgetting the field), fall back to:
|
|
558
|
+
// (a) the sender's currentTask.taskId
|
|
559
|
+
// (b) the most recent non-system entry in session.messageHistory
|
|
560
|
+
// This keeps prev-* / designer / architect / shorthand messages from
|
|
561
|
+
// becoming taskId=null orphans that never appear on any feature card.
|
|
562
|
+
// Done ONCE per fan-out — all targets share the same inferred taskId.
|
|
563
|
+
if (!taskId) {
|
|
564
|
+
const fromRoleState = session.roleStates?.get(fromRole);
|
|
565
|
+
if (fromRoleState?.currentTask?.taskId) {
|
|
566
|
+
taskId = fromRoleState.currentTask.taskId;
|
|
567
|
+
taskTitle = taskTitle || fromRoleState.currentTask.taskTitle || null;
|
|
568
|
+
} else if (Array.isArray(session.messageHistory) && session.messageHistory.length > 0) {
|
|
569
|
+
for (let i = session.messageHistory.length - 1; i >= 0; i--) {
|
|
570
|
+
const h = session.messageHistory[i];
|
|
571
|
+
if (h && h.from !== 'system' && h.taskId) {
|
|
572
|
+
taskId = h.taskId;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Mirror the fallback back into the route object so downstream
|
|
578
|
+
// consumers (dispatchToRole / sendCrewOutput) see the inferred id.
|
|
579
|
+
if (taskId) {
|
|
580
|
+
route.taskId = taskId;
|
|
581
|
+
if (taskTitle) route.taskTitle = taskTitle;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
478
584
|
|
|
585
|
+
// ─── Fan-out over targets ─────────────────────────────────────────
|
|
586
|
+
for (const to of toList) {
|
|
587
|
+
await _dispatchOneTarget(session, fromRole, to, summary, taskId, taskTitle, toList.length, turnImages);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* task-335 helper — dispatch ONE resolved target. Carries the per-target
|
|
593
|
+
* self-route reject + unknown-target fallback + kanban + UI emit +
|
|
594
|
+
* actual dispatchToRole. Failures on one target do NOT abort the rest.
|
|
595
|
+
*
|
|
596
|
+
* @param {number} batchSize — how many targets in the parent fan-out
|
|
597
|
+
* (used for log clarity; not behavioural)
|
|
598
|
+
* @private
|
|
599
|
+
*/
|
|
600
|
+
async function _dispatchOneTarget(session, fromRole, to, summary, taskId, taskTitle, batchSize, turnImages) {
|
|
479
601
|
// ─── task-330a §A + task-330b §B: self-route hard-reject + metric ───
|
|
480
602
|
// 福勒 Final Spec §A — `route.to` 等同于发送方时直接拒绝,不消费 turn、
|
|
481
603
|
// 不写 kanban、不 dispatch、不 round++(round 已由 role-output 计数)。
|
|
@@ -486,21 +608,22 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
|
486
608
|
// alias self-route 漏记 metric 已记入 PM backlog 作 follow-up(330b 的
|
|
487
609
|
// raw 比较 `to === fromRole` 仅命中字面相同的情况;alias 形式由 330a
|
|
488
610
|
// 的 isSelf 兜底,但 330b 的 raw 检查保留为快速路径 + 兼容)。
|
|
611
|
+
//
|
|
612
|
+
// task-335: now per-target. A self-route to one target in a multi-
|
|
613
|
+
// target ROUTE only rejects THAT target; the other targets fan out
|
|
614
|
+
// normally.
|
|
489
615
|
if (to !== 'human') {
|
|
490
616
|
const resolvedSelfCheck = resolveRoleName(to, session, fromRole);
|
|
491
617
|
const isSelf = resolvedSelfCheck === fromRole
|
|
492
618
|
|| (typeof to === 'string' && to.toLowerCase() === String(fromRole).toLowerCase());
|
|
493
619
|
if (isSelf) {
|
|
494
|
-
console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'})`);
|
|
495
|
-
// 330b path — persistent metric counter (routing-metrics.json + ring).
|
|
496
|
-
// Always-safe; never throws (recordRoutingEvent degrades to console.warn).
|
|
620
|
+
console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'}, batch=${batchSize})`);
|
|
497
621
|
recordRoutingEvent(session, 'self-route', {
|
|
498
622
|
fromRole,
|
|
499
623
|
toRole: to,
|
|
500
624
|
taskId: taskId || null,
|
|
501
625
|
note: 'route.to === fromRole at executeRoute entry (rejected by §A)',
|
|
502
626
|
});
|
|
503
|
-
// 330a path — UI broadcast so the role sees rejection in transcript.
|
|
504
627
|
try {
|
|
505
628
|
sendCrewMessage({
|
|
506
629
|
type: 'routing-metrics',
|
|
@@ -530,60 +653,13 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
|
530
653
|
}
|
|
531
654
|
// Do NOT decrement session.round — role-output.js already incremented
|
|
532
655
|
// it for this whole turn batch; one rejected route doesn't undo the
|
|
533
|
-
// turn (other routes in the same batch may still be valid).
|
|
656
|
+
// turn (other routes/targets in the same batch may still be valid).
|
|
534
657
|
return;
|
|
535
658
|
}
|
|
536
659
|
}
|
|
537
660
|
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// backward compat; this is observer-only.
|
|
541
|
-
if (session.status === 'paused' || session.status === 'stopped') {
|
|
542
|
-
recordRoutingEvent(session, 'state-stopped', {
|
|
543
|
-
fromRole,
|
|
544
|
-
toRole: to,
|
|
545
|
-
taskId: taskId || null,
|
|
546
|
-
note: `session.status=${session.status} at executeRoute entry`,
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Auto-resume: paused/stopped → running (route execution means work should continue)
|
|
551
|
-
if (session.status === 'paused' || session.status === 'stopped') {
|
|
552
|
-
console.log(`[Crew] Auto-resuming session from ${session.status} to running (route from ${fromRole} to ${to})`);
|
|
553
|
-
session.status = 'running';
|
|
554
|
-
sendStatusUpdate(session);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// ─── task-321: taskId fallback chain ─────────────────────────────
|
|
558
|
-
// When a ROUTE omits `task:` (shorthand, bare dispatch, human messages,
|
|
559
|
-
// PM forgetting the field), fall back to:
|
|
560
|
-
// (a) the sender's currentTask.taskId
|
|
561
|
-
// (b) the most recent non-system entry in session.messageHistory
|
|
562
|
-
// This keeps prev-* / designer / architect / shorthand messages from
|
|
563
|
-
// becoming taskId=null orphans that never appear on any feature card.
|
|
564
|
-
if (!taskId) {
|
|
565
|
-
const fromRoleState = session.roleStates?.get(fromRole);
|
|
566
|
-
if (fromRoleState?.currentTask?.taskId) {
|
|
567
|
-
taskId = fromRoleState.currentTask.taskId;
|
|
568
|
-
taskTitle = taskTitle || fromRoleState.currentTask.taskTitle || null;
|
|
569
|
-
} else if (Array.isArray(session.messageHistory) && session.messageHistory.length > 0) {
|
|
570
|
-
for (let i = session.messageHistory.length - 1; i >= 0; i--) {
|
|
571
|
-
const h = session.messageHistory[i];
|
|
572
|
-
if (h && h.from !== 'system' && h.taskId) {
|
|
573
|
-
taskId = h.taskId;
|
|
574
|
-
break;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
// Mirror the fallback back into the route object so downstream
|
|
579
|
-
// consumers (dispatchToRole / sendCrewOutput) see the inferred id.
|
|
580
|
-
if (taskId) {
|
|
581
|
-
route.taskId = taskId;
|
|
582
|
-
if (taskTitle) route.taskTitle = taskTitle;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// Task 文件自动管理(fire-and-forget)
|
|
661
|
+
// Task 文件自动管理(fire-and-forget) — per-target so each target gets
|
|
662
|
+
// a kanban entry assigned to it.
|
|
587
663
|
if (taskId && summary) {
|
|
588
664
|
const fromRoleConfig = session.roles.get(fromRole);
|
|
589
665
|
// task-321: Auto-create feature file even when a non-PM role is the
|
|
@@ -622,7 +698,8 @@ export async function executeRoute(session, fromRole, route, turnImages = []) {
|
|
|
622
698
|
}).catch(e => console.warn(`[Crew] Failed to update kanban:`, e.message));
|
|
623
699
|
}
|
|
624
700
|
|
|
625
|
-
// 发送路由消息(UI 显示)
|
|
701
|
+
// 发送路由消息(UI 显示) — per-target so the transcript shows one
|
|
702
|
+
// route card per fan-out leg, mirroring how single-target ROUTEs render.
|
|
626
703
|
sendCrewOutput(session, fromRole, 'route', null, {
|
|
627
704
|
routeTo: to, routeSummary: summary,
|
|
628
705
|
taskId: taskId || undefined,
|
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vp-bridge.js — task-334-ui-a snapshot-only WS adapter.
|
|
3
|
+
*
|
|
4
|
+
* Per ruling .crew/context/task-334-ui-a-ruling.md §3 (D3 = mixed):
|
|
5
|
+
* • snapshot path lives here (this slice, in-scope)
|
|
6
|
+
* • live diff (vp_updated / vp_removed) deferred to 334h — see TODO below
|
|
7
|
+
*
|
|
8
|
+
* This module is the SOLE serialiser for the wire-format VP shape.
|
|
9
|
+
* Per ruling §1 (D1 = (b) web-bridge boundary rename), the entity layer
|
|
10
|
+
* (`vp-store.js` / `registry.js`) keeps `id` / `name`; here we map to
|
|
11
|
+
* `vpId` / `displayName` per the spec (§2.1) / architecture §R6.11.
|
|
12
|
+
*
|
|
13
|
+
* Per ruling §2 (D2):
|
|
14
|
+
* • subtitle → agent emits `vp.role` directly
|
|
15
|
+
* • personaHash → agent emits `vp.personaHash` (added by dev-1's
|
|
16
|
+
* 334a-followup patch). Until that lands, this serialiser falls back
|
|
17
|
+
* to undefined and the web layer simply omits the field.
|
|
18
|
+
* • color / avatar → web-derived, NOT emitted here
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { defaultRegistry } from './registry.js';
|
|
22
|
+
import { VpLoader } from './vp-loader.js';
|
|
23
|
+
|
|
24
|
+
/** Process-singleton VpLoader; lazily started on first subscribe. */
|
|
25
|
+
let _loaderStarted = false;
|
|
26
|
+
let _loader = null;
|
|
27
|
+
|
|
28
|
+
function ensureLoader() {
|
|
29
|
+
if (_loaderStarted) return _loader;
|
|
30
|
+
_loaderStarted = true;
|
|
31
|
+
try {
|
|
32
|
+
_loader = new VpLoader({ registry: defaultRegistry });
|
|
33
|
+
_loader.start();
|
|
34
|
+
} catch {
|
|
35
|
+
// Hot-reload optional; subscribe still returns whatever scan loaded.
|
|
36
|
+
_loader = null;
|
|
37
|
+
}
|
|
38
|
+
return _loader;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serialise a VP (entity layer shape) to the wire-format the web layer
|
|
43
|
+
* expects (spec §2.1). Pure; no IO.
|
|
44
|
+
*
|
|
45
|
+
* @param {{id:string,name:string,role:string,traits?:string[],modelHint?:string,personaHash?:string}} vp
|
|
46
|
+
* @returns {{vpId:string,displayName:string,subtitle:string,role:string,traits:string[],modelHint:?string,personaHash:?string}}
|
|
47
|
+
*/
|
|
48
|
+
export function serializeVpForWire(vp) {
|
|
49
|
+
return {
|
|
50
|
+
vpId: vp.id,
|
|
51
|
+
displayName: vp.name,
|
|
52
|
+
role: vp.role || '',
|
|
53
|
+
subtitle: vp.role || '',
|
|
54
|
+
traits: Array.isArray(vp.traits) ? vp.traits.slice() : [],
|
|
55
|
+
modelHint: vp.modelHint ?? null,
|
|
56
|
+
personaHash: vp.personaHash ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a vp_snapshot event payload from the registry.
|
|
62
|
+
* @param {import('./registry.js').Registry} [registry]
|
|
63
|
+
* @returns {{type:'vp_snapshot', vps:Array, emptyLibrary:boolean}}
|
|
64
|
+
*/
|
|
65
|
+
export function buildVpSnapshot(registry = defaultRegistry) {
|
|
66
|
+
const vps = registry.listVps().map(serializeVpForWire);
|
|
67
|
+
return {
|
|
68
|
+
type: 'vp_snapshot',
|
|
69
|
+
vps,
|
|
70
|
+
emptyLibrary: vps.length === 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Handle an `unify_vp_subscribe` request from the web client.
|
|
76
|
+
* Lazily starts the VpLoader on first call (debounced rescan watchers).
|
|
77
|
+
*
|
|
78
|
+
* @param {(event: object) => void} sendUnifyEvent — emit fn (web-bridge wires this)
|
|
79
|
+
* @param {import('./registry.js').Registry} [registry]
|
|
80
|
+
*/
|
|
81
|
+
export function handleVpSubscribe(sendUnifyEvent, registry = defaultRegistry) {
|
|
82
|
+
ensureLoader();
|
|
83
|
+
try {
|
|
84
|
+
sendUnifyEvent(buildVpSnapshot(registry));
|
|
85
|
+
} catch {
|
|
86
|
+
// Never crash the WS pipeline from snapshot serialisation.
|
|
87
|
+
}
|
|
88
|
+
// TODO(334h): vp_updated / vp_removed live broadcast.
|
|
89
|
+
// Wire VpLoader.onChange → emit per-vp `vp_updated` and `vp_removed`
|
|
90
|
+
// events using serializeVpForWire(). Out of scope for 334-ui-a.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Test seam: reset the lazy loader (for vitest).
|
|
95
|
+
*/
|
|
96
|
+
export function _resetVpBridgeForTest() {
|
|
97
|
+
if (_loader) {
|
|
98
|
+
try { _loader.stop(); } catch { /* ignore */ }
|
|
99
|
+
}
|
|
100
|
+
_loader = null;
|
|
101
|
+
_loaderStarted = false;
|
|
102
|
+
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -26,6 +26,7 @@ import { loadSession } from './session.js';
|
|
|
26
26
|
import { sendToServer } from '../connection/buffer.js';
|
|
27
27
|
import ctx from '../context.js';
|
|
28
28
|
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
29
|
+
import { handleVpSubscribe } from './vp/vp-bridge.js';
|
|
29
30
|
|
|
30
31
|
/** @type {import('./session.js').Session | null} */
|
|
31
32
|
let session = null;
|
|
@@ -103,6 +104,15 @@ function sendUnifyEvent(event) {
|
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* task-334-ui-a: respond to `unify_vp_subscribe` from the web client by
|
|
109
|
+
* pushing a one-shot `vp_snapshot` event. Live diff (vp_updated /
|
|
110
|
+
* vp_removed) is intentionally deferred to 334h per ruling §3.
|
|
111
|
+
*/
|
|
112
|
+
export function handleUnifyVpSubscribe(_msg) {
|
|
113
|
+
handleVpSubscribe(sendUnifyEvent);
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
/**
|
|
107
117
|
* task-318 rev-1 fix: install live-setter bridge between the session's
|
|
108
118
|
* runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
|