@yeaft/webchat-agent 0.1.770 → 0.1.771

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.770",
3
+ "version": "0.1.771",
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,216 @@
1
+ /**
2
+ * vp-status-broker.js — single source of truth for per-VP status,
3
+ * emitted to the frontend on every transition.
4
+ *
5
+ * Why: the browser used to reverse-infer VP status from assistant
6
+ * messages' `isStreaming` flag (web/stores/helpers/vp-timeline.js).
7
+ * That flag is a UI artifact, not a state machine — whenever the
8
+ * `result` event lands without flipping the flag (reconnect mid-turn,
9
+ * tool window where the flag is cosmetically dropped, persisted
10
+ * history that re-hydrates with the flag absent), the inferred
11
+ * status drifts and can stay stuck on "streaming" forever.
12
+ *
13
+ * This broker owns a tiny in-memory table keyed by `(groupId, vpId)`,
14
+ * and emits a `vp_status_changed` event each time the state really
15
+ * changes. `vp_status_snapshot` rebuilds the table on a fresh
16
+ * frontend (reconnect, refresh). The broker is in-memory only —
17
+ * agent restart starts everyone at `idle`, which is the right default
18
+ * because no turn is in flight on a fresh process either.
19
+ *
20
+ * State machine: see docs/notes/2026-05-15-vp-status-from-agent.md.
21
+ *
22
+ * Sink injection: the broker is constructed with a `send` callback so
23
+ * it doesn't depend on the WebSocket layer directly (and so tests can
24
+ * collect emitted events into an array without spinning up WS).
25
+ */
26
+
27
+ /**
28
+ * Valid status values. `error` is a terminal/sticky state that the
29
+ * caller is expected to transition out of (the broker doesn't
30
+ * auto-decay — that policy belongs to the caller / web bridge).
31
+ * @type {ReadonlySet<string>}
32
+ */
33
+ export const VALID_STATES = new Set([
34
+ 'idle',
35
+ 'typing',
36
+ 'thinking',
37
+ 'streaming',
38
+ 'tool',
39
+ 'error',
40
+ ]);
41
+
42
+ /**
43
+ * @typedef {Object} VpStatusEntry
44
+ * @property {string} state — one of VALID_STATES
45
+ * @property {number} since — ms timestamp the state was entered
46
+ * @property {string|null} turnId
47
+ */
48
+
49
+ /**
50
+ * Create a broker instance.
51
+ *
52
+ * @param {object} opts
53
+ * @param {(event: object) => void} opts.send — emits events to the
54
+ * wire (web bridge wraps `sendUnifyEvent`). Must be synchronous; the
55
+ * broker calls it inline so transitions are flushed in order.
56
+ * @param {() => number} [opts.now] — clock, defaults to
57
+ * `Date.now`. Injected so tests can pin timestamps.
58
+ */
59
+ export function createVpStatusBroker({ send, now = Date.now } = {}) {
60
+ if (typeof send !== 'function') {
61
+ throw new TypeError('createVpStatusBroker: `send` callback is required');
62
+ }
63
+
64
+ /**
65
+ * `${groupId}::${vpId}` → VpStatusEntry. Composite key because the
66
+ * same vpId can appear in multiple groups; we track them separately
67
+ * so the frontend's per-group timeline reads only its slice.
68
+ * @type {Map<string, VpStatusEntry & {groupId: string, vpId: string}>}
69
+ */
70
+ const table = new Map();
71
+
72
+ const keyOf = (groupId, vpId) => `${groupId || ''}::${vpId}`;
73
+
74
+ /**
75
+ * Apply a state transition. Idempotent: a no-op if the requested
76
+ * state is already current (no event emitted, no `since` rewrite).
77
+ * Validates the input state — unknown values throw, so a typo
78
+ * fails loudly in the agent log instead of silently breaking the
79
+ * UI's render.
80
+ *
81
+ * @param {object} args
82
+ * @param {string} args.groupId
83
+ * @param {string} args.vpId
84
+ * @param {string} args.state — must be in VALID_STATES
85
+ * @param {string|null} [args.turnId] — optional, for non-idle states
86
+ * @returns {boolean} — true if a change was emitted
87
+ */
88
+ function transition({ groupId, vpId, state, turnId = null }) {
89
+ if (!vpId) return false;
90
+ if (!VALID_STATES.has(state)) {
91
+ throw new RangeError(`vp-status-broker: invalid state '${state}'`);
92
+ }
93
+ const key = keyOf(groupId, vpId);
94
+ const prev = table.get(key);
95
+ // Dedup: same state AND same turnId means no real transition.
96
+ // Allowing turnId to differ even at the same state lets a fresh
97
+ // turn re-stamp `since` without spamming events.
98
+ if (prev && prev.state === state && prev.turnId === turnId) {
99
+ return false;
100
+ }
101
+ const since = now();
102
+ const entry = { state, since, turnId, groupId: groupId || null, vpId };
103
+ table.set(key, entry);
104
+ send({
105
+ type: 'vp_status_changed',
106
+ groupId: entry.groupId,
107
+ vpId,
108
+ state,
109
+ since,
110
+ turnId,
111
+ });
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Convenience: force `idle` regardless of current state. Used by
117
+ * the run-vp-turn `finally` block so a turn that ended via *any*
118
+ * path (normal, abort, error, watchdog escalation) always settles
119
+ * back to idle.
120
+ */
121
+ function settleIdle({ groupId, vpId }) {
122
+ return transition({ groupId, vpId, state: 'idle', turnId: null });
123
+ }
124
+
125
+ /**
126
+ * Build the snapshot payload. Optionally filtered by groupId.
127
+ *
128
+ * Filtering semantics:
129
+ * - `groupId === undefined` → return every row across every group.
130
+ * This is the all-groups broadcast used on `session_ready`.
131
+ * - `groupId === null` → same as undefined. Treated as "no
132
+ * scope" because the wire envelope serializes `undefined` as
133
+ * `null` over JSON, and we want both forms to behave the same.
134
+ * - `groupId === '<id>'` → only rows for that group.
135
+ *
136
+ * The store mirrors this semantic on the frontend (see
137
+ * `vp_status_snapshot` handler in chat.js): scoped snapshots
138
+ * replace just that group's slice, while null/undefined snapshots
139
+ * replace the whole table.
140
+ *
141
+ * @param {string} [groupId]
142
+ * @returns {Array<{vpId:string, state:string, since:number, turnId:string|null, groupId:string|null}>}
143
+ */
144
+ function snapshot(groupId) {
145
+ const out = [];
146
+ for (const entry of table.values()) {
147
+ if (groupId !== undefined && groupId !== null && entry.groupId !== groupId) continue;
148
+ out.push({
149
+ vpId: entry.vpId,
150
+ state: entry.state,
151
+ since: entry.since,
152
+ turnId: entry.turnId,
153
+ groupId: entry.groupId,
154
+ });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * Emit a `vp_status_snapshot` to the wire. Pulls from `snapshot()`
161
+ * so the wire shape stays consistent with the in-memory table.
162
+ *
163
+ * Wire envelope: `{ type, groupId, statuses }` where `groupId` is
164
+ * `null` when unscoped (frontend uses null to mean "replace the
165
+ * whole table"). See `snapshot()` JSDoc for the scoping contract.
166
+ */
167
+ function broadcastSnapshot({ groupId } = {}) {
168
+ send({
169
+ type: 'vp_status_snapshot',
170
+ groupId: groupId === undefined ? null : groupId,
171
+ statuses: snapshot(groupId),
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Drop an entry — e.g. when a VP is removed from a group. Without
177
+ * this, a deleted VP would stay in the snapshot forever and
178
+ * re-appear on the frontend at every reconnect.
179
+ */
180
+ function forget({ groupId, vpId }) {
181
+ table.delete(keyOf(groupId, vpId));
182
+ }
183
+
184
+ /**
185
+ * Wipe the in-memory table. Called from `resetUnifySession` on the
186
+ * agent side so a forced session reset doesn't leave the broker
187
+ * holding rows for VPs whose engines/inboxes have been cleared. The
188
+ * post-reset `broadcastSnapshot` then emits an empty table, and the
189
+ * frontend's mirror clears in lockstep.
190
+ *
191
+ * Distinct from `__testReset`: this is production code path, named
192
+ * accordingly. `__testReset` stays for tests that mutate broker
193
+ * state across describe-blocks.
194
+ */
195
+ function reset() {
196
+ table.clear();
197
+ }
198
+
199
+ /**
200
+ * Wipe everything (only used by tests; agent runtime keeps the
201
+ * broker alive for the whole process).
202
+ */
203
+ function __testReset() {
204
+ table.clear();
205
+ }
206
+
207
+ return {
208
+ transition,
209
+ settleIdle,
210
+ snapshot,
211
+ broadcastSnapshot,
212
+ forget,
213
+ reset,
214
+ __testReset,
215
+ };
216
+ }
@@ -50,6 +50,7 @@ import {
50
50
  } from './history-compact.js';
51
51
  import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
52
52
  import { parseSeqFromId } from './conversation/persist.js';
53
+ import { createVpStatusBroker } from './vp-status-broker.js';
53
54
 
54
55
  /** @type {import('./session.js').Session | null} */
55
56
  let session = null;
@@ -78,6 +79,43 @@ let currentAbortCtrl = null;
78
79
  */
79
80
  const turnAbortCtrls = new Map();
80
81
 
82
+ /**
83
+ * Per-VP status broker — the agent-side authority for VP timeline
84
+ * status. Lazy-initialized on first use because `sendUnifyEvent` is
85
+ * declared below; trying to call it at module top-level would crash
86
+ * with a TDZ error during agent boot.
87
+ *
88
+ * Every transition (typing → thinking → streaming → tool → idle) is
89
+ * pushed through `vpStatusBroker.transition(...)`. The broker also
90
+ * owns the `vp_status_snapshot` payload for reconnect.
91
+ *
92
+ * @type {ReturnType<typeof createVpStatusBroker> | null}
93
+ */
94
+ let vpStatusBroker = null;
95
+ function getVpStatusBroker() {
96
+ if (!vpStatusBroker) {
97
+ vpStatusBroker = createVpStatusBroker({
98
+ send: (event) => {
99
+ // The broker emits both `vp_status_changed` and
100
+ // `vp_status_snapshot`. Both ride the standard sendUnifyEvent
101
+ // envelope so the frontend's existing unify_output dispatcher
102
+ // sees them. We stamp groupId/vpId on the envelope for
103
+ // events that target a specific VP so the server's per-client
104
+ // routing (groupId scoping, etc.) works the same way as
105
+ // typing events.
106
+ const env = {};
107
+ if (event && typeof event === 'object') {
108
+ if (event.groupId) env.groupId = event.groupId;
109
+ if (event.vpId) env.vpId = event.vpId;
110
+ if (event.turnId) env.turnId = event.turnId;
111
+ }
112
+ sendUnifyEvent(event, env);
113
+ },
114
+ });
115
+ }
116
+ return vpStatusBroker;
117
+ }
118
+
81
119
  /**
82
120
  * Per-VP inbox + driver + engine pool (group multi-VP delivery).
83
121
  *
@@ -573,6 +611,15 @@ function enqueueForVp(groupId, vpId, envelope) {
573
611
  }, { groupId, vpId, turnId });
574
612
  } catch { /* never crash WS pipeline */ }
575
613
 
614
+ // vp-status: queued, driver not running yet → 'typing'. Emitted
615
+ // alongside `vp_typing_start` so the timeline pane lights up on the
616
+ // same edge as the per-message typing dot.
617
+ try {
618
+ getVpStatusBroker().transition({ groupId, vpId, state: 'typing', turnId });
619
+ } catch (err) {
620
+ console.warn('[Unify] vp-status typing transition failed:', err?.message || err);
621
+ }
622
+
576
623
  ensureDriverRunning(groupId, vpId);
577
624
  }
578
625
 
@@ -864,6 +911,18 @@ export function handleUnifyVpDelete(msg) {
864
911
  const yeaftDir = ctx.CONFIG?.yeaftDir;
865
912
  const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
866
913
  deleteVp(vpId, memoryRoot ? { memoryRoot } : {});
914
+ // vp-status: a deleted VP must not haunt the snapshot. We don't
915
+ // know up front which groups the VP appeared in (the registry's
916
+ // delete already detached it from every group), so sweep every
917
+ // matching entry from the broker table.
918
+ try {
919
+ const broker = getVpStatusBroker();
920
+ for (const row of broker.snapshot()) {
921
+ if (row.vpId === vpId) broker.forget({ groupId: row.groupId, vpId });
922
+ }
923
+ } catch (err) {
924
+ console.warn('[Unify] vp-status forget on delete failed:', err?.message || err);
925
+ }
867
926
  sendVpCrudResult({ op: 'delete', requestId, ok: true, vpId });
868
927
  } catch (err) {
869
928
  sendVpCrudResult({
@@ -1165,6 +1224,29 @@ export function installUnifyRuntimeBridge(s) {
1165
1224
  };
1166
1225
  }
1167
1226
 
1227
+ /**
1228
+ * Mid-turn vp-status transitions (text_delta / tool_call / tool_end).
1229
+ * Tolerates `hctx` missing groupId/vpId — pre-707 1:1 chat paths don't
1230
+ * have either; they're tracked as the default broker key but the
1231
+ * frontend ignores rows it doesn't recognize.
1232
+ *
1233
+ * @param {object} hctx
1234
+ * @param {string} state
1235
+ */
1236
+ function maybeTransitionVpStatus(hctx, state) {
1237
+ if (!hctx || !hctx.vpId) return;
1238
+ try {
1239
+ getVpStatusBroker().transition({
1240
+ groupId: hctx.groupId || null,
1241
+ vpId: hctx.vpId,
1242
+ state,
1243
+ turnId: hctx.turnId || null,
1244
+ });
1245
+ } catch (err) {
1246
+ console.warn(`[Unify] vp-status ${state} transition failed:`, err?.message || err);
1247
+ }
1248
+ }
1249
+
1168
1250
  /**
1169
1251
  * Handle a single engine event unwrapped from an `engine_event` envelope.
1170
1252
  * H2.f.2: no longer stamps a threadId on outgoing claude_output frames.
@@ -1187,6 +1269,10 @@ function handleEngineEvent(event, hctx) {
1187
1269
  type: 'assistant',
1188
1270
  message: { content: [{ type: 'text', text: event.text }] },
1189
1271
  }, envelope);
1272
+ // vp-status: first text-delta of a (thinking|tool) phase flips
1273
+ // the row to 'streaming'. transition() is a no-op when already
1274
+ // streaming, so subsequent deltas are cheap.
1275
+ maybeTransitionVpStatus(hctx, 'streaming');
1190
1276
  break;
1191
1277
 
1192
1278
  case 'thinking_delta':
@@ -1220,6 +1306,7 @@ function handleEngineEvent(event, hctx) {
1220
1306
  }],
1221
1307
  },
1222
1308
  }, envelope);
1309
+ maybeTransitionVpStatus(hctx, 'tool');
1223
1310
  break;
1224
1311
 
1225
1312
  case 'tool_start':
@@ -1248,6 +1335,12 @@ function handleEngineEvent(event, hctx) {
1248
1335
  is_error: event.isError || false,
1249
1336
  }],
1250
1337
  }, envelope);
1338
+ // Tool finished. The engine may either (a) emit more text-deltas
1339
+ // before end_turn, or (b) go straight to end_turn. Settle the
1340
+ // row back to 'thinking' — if (a), the next text_delta will flip
1341
+ // it to 'streaming'; if (b), runVpTurn's finally will flip it to
1342
+ // 'idle'. Either way we never strand the row in 'tool'.
1343
+ maybeTransitionVpStatus(hctx, 'thinking');
1251
1344
  break;
1252
1345
 
1253
1346
  case 'turn_start':
@@ -1964,6 +2057,15 @@ async function ensureSessionLoaded() {
1964
2057
  tools: session.status.tools,
1965
2058
  });
1966
2059
  sendGroupSnapshotBroadcast();
2060
+ // vp-status: rebuild frontend status table from authoritative agent
2061
+ // memory. Sent unconditionally so reconnect/refresh paths get the same
2062
+ // bootstrap as first-load (the broker dedup logic makes a redundant
2063
+ // snapshot harmless).
2064
+ try {
2065
+ getVpStatusBroker().broadcastSnapshot();
2066
+ } catch (err) {
2067
+ console.warn('[Unify] vp-status snapshot broadcast failed:', err?.message || err);
2068
+ }
1967
2069
  }
1968
2070
 
1969
2071
  /**
@@ -2008,6 +2110,19 @@ async function runVpTurnWithEscalation(args) {
2008
2110
  { groupId, vpId, turnId },
2009
2111
  );
2010
2112
  } catch { /* never crash WS pipeline */ }
2113
+ // vp-status: when the watchdog escalates, `runVpTurn`'s inner
2114
+ // promise is still dangling (the adapter is ignoring `signal`)
2115
+ // and its outer `finally` won't run until the adapter eventually
2116
+ // returns — which may be never. Settle the broker here so the
2117
+ // row drops to idle in lockstep with the synthetic stop frame.
2118
+ // This is the exact failure mode the watchdog exists for; not
2119
+ // settling here would re-introduce the "stuck on streaming" bug
2120
+ // the whole PR is meant to fix.
2121
+ try {
2122
+ getVpStatusBroker().settleIdle({ groupId, vpId });
2123
+ } catch (err) {
2124
+ console.warn('[Unify] vp-status settleIdle (escalation) failed:', err?.message || err);
2125
+ }
2011
2126
  },
2012
2127
  });
2013
2128
  }
@@ -2099,6 +2214,12 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
2099
2214
 
2100
2215
  // Emit turn_start so frontend can create the message block.
2101
2216
  sendUnifyEvent({ type: 'vp_turn_start', vpId, turnId, groupId }, envelope);
2217
+ // vp-status: LLM call about to start, no text/tool yet → 'thinking'.
2218
+ try {
2219
+ getVpStatusBroker().transition({ groupId, vpId, state: 'thinking', turnId });
2220
+ } catch (err) {
2221
+ console.warn('[Unify] vp-status thinking transition failed:', err?.message || err);
2222
+ }
2102
2223
 
2103
2224
  try {
2104
2225
  const assistantTextParts = [];
@@ -2184,6 +2305,17 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
2184
2305
 
2185
2306
  console.error('[Unify] query error:', err);
2186
2307
 
2308
+ // vp-status: surface a transient `error` state so the row's status
2309
+ // label flips red for the brief window before the outer finally
2310
+ // settles it to idle. Without this, an LLM/tool failure would look
2311
+ // identical to a normal turn end in the timeline — the user has
2312
+ // no way to tell from the row that something went wrong.
2313
+ try {
2314
+ getVpStatusBroker().transition({ groupId, vpId, state: 'error', turnId });
2315
+ } catch (brokerErr) {
2316
+ console.warn('[Unify] vp-status error transition failed:', brokerErr?.message || brokerErr);
2317
+ }
2318
+
2187
2319
  if (isPermissionErrorMsg(err.message)) {
2188
2320
  if (!_permissionDiagnosticSent) {
2189
2321
  _permissionDiagnosticSent = true;
@@ -2212,6 +2344,16 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
2212
2344
  type: 'result',
2213
2345
  result_text: '',
2214
2346
  }, envelope);
2347
+ } finally {
2348
+ // vp-status: guaranteed-settle. Regardless of how the turn exited
2349
+ // (normal completion, AbortError early-return, caught exception),
2350
+ // the row must drop back to 'idle'. Wrapped in its own try so a
2351
+ // broker bug can't mask the original error.
2352
+ try {
2353
+ getVpStatusBroker().settleIdle({ groupId, vpId });
2354
+ } catch (err) {
2355
+ console.warn('[Unify] vp-status settleIdle failed:', err?.message || err);
2356
+ }
2215
2357
  }
2216
2358
  }
2217
2359
 
@@ -2693,6 +2835,14 @@ export async function handleUnifyLoadHistory(msg) {
2693
2835
  tools: session.status.tools,
2694
2836
  });
2695
2837
  sendGroupSnapshotBroadcast();
2838
+ // vp-status: replay the authoritative table on reconnect so a refreshed
2839
+ // frontend doesn't have to wait for the next transition to learn each
2840
+ // VP's current state.
2841
+ try {
2842
+ getVpStatusBroker().broadcastSnapshot();
2843
+ } catch (err) {
2844
+ console.warn('[Unify] vp-status snapshot broadcast (replay) failed:', err?.message || err);
2845
+ }
2696
2846
 
2697
2847
  // `msg.limit` is the replay-scrollback request from the frontend (UI
2698
2848
  // history pane, not engine context). Semantics changed (2026-05-01):
@@ -2865,6 +3015,16 @@ export async function resetUnifySession() {
2865
3015
  // History-dedup cache is keyed by per-session coordinator msg ids;
2866
3016
  // a fresh session resets the id space, so clear the cache too.
2867
3017
  _persistedUserMsgIds.clear();
3018
+ // vp-status: nuke the broker table too. Drivers above have just
3019
+ // been aborted, so any in-flight `settleIdle` from their outer
3020
+ // `finally` blocks is racing this reset. Clearing here makes the
3021
+ // post-reset `broadcastSnapshot` (further down) emit an empty
3022
+ // table, and the frontend mirror clears in lockstep.
3023
+ try {
3024
+ getVpStatusBroker().reset();
3025
+ } catch (err) {
3026
+ console.warn('[Unify] vp-status broker reset failed:', err?.message || err);
3027
+ }
2868
3028
 
2869
3029
  try {
2870
3030
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -2890,6 +3050,14 @@ export async function resetUnifySession() {
2890
3050
  mcpServers: session.status.mcpServers,
2891
3051
  tools: session.status.tools,
2892
3052
  });
3053
+ // vp-status: after a forced reset the broker table is still live in
3054
+ // memory; broadcast so the frontend can rebuild its mirror without
3055
+ // waiting for the first per-VP transition.
3056
+ try {
3057
+ getVpStatusBroker().broadcastSnapshot();
3058
+ } catch (err) {
3059
+ console.warn('[Unify] vp-status snapshot broadcast (reset) failed:', err?.message || err);
3060
+ }
2893
3061
  } catch (err) {
2894
3062
  console.error('[Unify] Failed to re-initialize session after reset:', err.message);
2895
3063
  }