@yeaft/webchat-agent 0.1.796 → 0.1.797

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, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, broadcastLanguageChange } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, handleUnifyFetchDebugHistory, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -502,6 +502,14 @@ export async function handleMessage(msg) {
502
502
  await handleUnifyFetchToolStats(msg);
503
503
  break;
504
504
 
505
+ // fix-vp-multi-thread (bug 4): hydrate the Unify debug panel from
506
+ // the persistent SQLite trace. Without this, the panel only shows
507
+ // turns that happened after the panel was opened — every previous
508
+ // turn is invisible.
509
+ case 'unify_fetch_debug_history':
510
+ await handleUnifyFetchDebugHistory(msg);
511
+ break;
512
+
505
513
  // Expert roles definition (for ExpertPanel detail view)
506
514
  case 'get_expert_roles': {
507
515
  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.796",
3
+ "version": "0.1.797",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,7 +28,18 @@ const SCHEMA = `
28
28
  latency_ms INTEGER,
29
29
  response_text TEXT,
30
30
  started_at INTEGER NOT NULL,
31
- ended_at INTEGER
31
+ ended_at INTEGER,
32
+ group_id TEXT,
33
+ vp_id TEXT,
34
+ thread_id TEXT,
35
+ system_prompt TEXT,
36
+ messages_json TEXT,
37
+ tool_calls_json TEXT,
38
+ usage_json TEXT,
39
+ ttfb_ms INTEGER,
40
+ raw_request TEXT,
41
+ raw_response TEXT,
42
+ user_prompt TEXT
32
43
  );
33
44
 
34
45
  CREATE TABLE IF NOT EXISTS trace_tools (
@@ -61,9 +72,47 @@ const SCHEMA = `
61
72
  CREATE INDEX IF NOT EXISTS idx_events_type ON trace_events(event_type);
62
73
  `;
63
74
 
75
+ /**
76
+ * Indexes that reference columns added by the v0.1.x fix-vp-multi-thread
77
+ * migration. Must be executed AFTER `migrateAddColumn` for those columns
78
+ * — running them inside the main SCHEMA block would fail on an old DB
79
+ * whose `trace_turns` table predates `group_id` / `vp_id` / `thread_id`
80
+ * (`CREATE TABLE IF NOT EXISTS` is a no-op when the table already
81
+ * exists, so the columns are never added by SCHEMA alone).
82
+ */
83
+ const POST_MIGRATION_INDEXES = `
84
+ CREATE INDEX IF NOT EXISTS idx_turns_group_id ON trace_turns(group_id);
85
+ CREATE INDEX IF NOT EXISTS idx_turns_vp_id ON trace_turns(vp_id);
86
+ CREATE INDEX IF NOT EXISTS idx_turns_thread_id ON trace_turns(thread_id);
87
+ `;
88
+
89
+ /**
90
+ * Idempotent column-adds for pre-existing trace databases. `ALTER TABLE …
91
+ * ADD COLUMN` throws if the column already exists, so we wrap each call.
92
+ * Mirrors the columns introduced above so older DBs upgrade in place.
93
+ */
94
+ function migrateAddColumn(db, table, column, type) {
95
+ try {
96
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
97
+ } catch (err) {
98
+ if (!String(err?.message || err).match(/duplicate column name/i)) {
99
+ throw err;
100
+ }
101
+ }
102
+ }
103
+
64
104
  /** Max tool_output size stored (10KB). Longer outputs are truncated. */
65
105
  const MAX_TOOL_OUTPUT = 10240;
66
106
 
107
+ /**
108
+ * Max per-loop payload (system prompt, messages JSON, raw request /
109
+ * response, response text) stored per row. Larger than MAX_TOOL_OUTPUT
110
+ * because real-world LLM exchanges (system prompt + 30K-token message
111
+ * trail + raw response) routinely cross 10KB. 256KB lets us replay the
112
+ * panel verbatim for the most recent traces without bloating the DB.
113
+ */
114
+ const MAX_LOOP_PAYLOAD = 256 * 1024;
115
+
67
116
  /**
68
117
  * Truncate a string to a max length, appending "... [truncated]" if needed.
69
118
  * @param {string|null|undefined} str
@@ -98,29 +147,53 @@ export class DebugTrace {
98
147
  this.#db.exec('PRAGMA journal_mode = WAL');
99
148
  this.#db.exec('PRAGMA foreign_keys = ON');
100
149
  this.#db.exec(SCHEMA);
150
+ // Forward-compat: a DB created by an older version of the bridge
151
+ // will be missing the group/vp/thread + per-loop snapshot columns.
152
+ // Add them on open; no-op for fresh DBs (column already exists
153
+ // from SCHEMA above).
154
+ migrateAddColumn(this.#db, 'trace_turns', 'group_id', 'TEXT');
155
+ migrateAddColumn(this.#db, 'trace_turns', 'vp_id', 'TEXT');
156
+ migrateAddColumn(this.#db, 'trace_turns', 'thread_id', 'TEXT');
157
+ migrateAddColumn(this.#db, 'trace_turns', 'system_prompt', 'TEXT');
158
+ migrateAddColumn(this.#db, 'trace_turns', 'messages_json', 'TEXT');
159
+ migrateAddColumn(this.#db, 'trace_turns', 'tool_calls_json', 'TEXT');
160
+ migrateAddColumn(this.#db, 'trace_turns', 'usage_json', 'TEXT');
161
+ migrateAddColumn(this.#db, 'trace_turns', 'ttfb_ms', 'INTEGER');
162
+ migrateAddColumn(this.#db, 'trace_turns', 'raw_request', 'TEXT');
163
+ migrateAddColumn(this.#db, 'trace_turns', 'raw_response', 'TEXT');
164
+ // C2 fix: explicit `user_prompt` column. Deriving the prompt from
165
+ // `messages_json` is unsafe because every loop after turn 1 in a
166
+ // multi-loop tool-call cycle persists the *cumulative* conversation
167
+ // snapshot — `messages.find(role==='user')` would return turn 1's
168
+ // text for every subsequent turn, mislabeling every Turn header.
169
+ migrateAddColumn(this.#db, 'trace_turns', 'user_prompt', 'TEXT');
170
+ // Indexes on the just-added columns. Must run AFTER the ALTER TABLEs
171
+ // — running them inside SCHEMA's CREATE INDEX IF NOT EXISTS block
172
+ // would fail with "no such column: group_id" on a pre-bugfix DB.
173
+ this.#db.exec(POST_MIGRATION_INDEXES);
101
174
  }
102
175
 
103
176
  // ─── Write API ───────────────────────────────────────────────
104
177
 
105
178
  /**
106
179
  * Start a new turn.
107
- * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number }} opts
180
+ * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number, groupId?: string, vpId?: string, threadId?: string, userPrompt?: string }} opts
108
181
  * @returns {string} — turnId
109
182
  */
110
- startTurn({ traceId, messageId = null, mode = null, turnNumber = null }) {
183
+ startTurn({ traceId, messageId = null, mode = null, turnNumber = null, groupId = null, vpId = null, threadId = null, userPrompt = null }) {
111
184
  const id = randomUUID();
112
185
  const now = Date.now();
113
186
  this.#prepare('insertTurn', `
114
- INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at)
115
- VALUES (?, ?, ?, ?, ?, ?)
116
- `).run(id, traceId, messageId, mode, turnNumber, now);
187
+ INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at, group_id, vp_id, thread_id, user_prompt)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
189
+ `).run(id, traceId, messageId, mode, turnNumber, now, groupId, vpId, threadId, truncate(userPrompt, MAX_LOOP_PAYLOAD));
117
190
  return id;
118
191
  }
119
192
 
120
193
  /**
121
194
  * End a turn with model response info.
122
195
  * @param {string} turnId
123
- * @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string }} info
196
+ * @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string, systemPrompt?: string, messages?: unknown, toolCalls?: unknown, usage?: unknown, ttfbMs?: number, rawRequest?: unknown, rawResponse?: unknown }} info
124
197
  */
125
198
  endTurn(turnId, {
126
199
  model = null,
@@ -131,19 +204,68 @@ export class DebugTrace {
131
204
  stopReason = null,
132
205
  latencyMs = null,
133
206
  responseText = null,
207
+ systemPrompt = null,
208
+ messages = null,
209
+ toolCalls = null,
210
+ usage = null,
211
+ ttfbMs = null,
212
+ rawRequest = null,
213
+ rawResponse = null,
134
214
  } = {}) {
135
215
  const now = Date.now();
216
+ // JSON-stringify the structured fields so they round-trip through
217
+ // SQLite as TEXT. JSON serialisation might fail (cyclic structure /
218
+ // BigInt) — guard with try/catch and persist null on failure so a
219
+ // single bad message can never tank the whole turn record.
220
+ //
221
+ // I2 fix: if the serialised JSON exceeds MAX_LOOP_PAYLOAD, the naïve
222
+ // `truncate(s, MAX)` would append `... [truncated]` mid-string and
223
+ // make the row's JSON unparseable. The reader's `parseJsonSafe` would
224
+ // then silently return null and the panel would render the loop with
225
+ // empty messages / toolCalls / usage. Persist a structured sentinel
226
+ // instead so the panel can render a "[truncated, N bytes]" notice.
227
+ const safeStringify = (v) => {
228
+ if (v == null) return null;
229
+ try {
230
+ const s = JSON.stringify(v);
231
+ if (s.length <= MAX_LOOP_PAYLOAD) return s;
232
+ return JSON.stringify({
233
+ __truncated: true,
234
+ originalBytes: s.length,
235
+ maxBytes: MAX_LOOP_PAYLOAD,
236
+ });
237
+ } catch { return null; }
238
+ };
239
+ // For raw request/response, accept either a pre-stringified blob
240
+ // (treat as opaque text — truncation here is fine because
241
+ // parseJsonSafe is not used on raw_*) or a structured object (route
242
+ // through safeStringify which preserves JSON validity).
243
+ const stringifyRaw = (v) => {
244
+ if (v == null) return null;
245
+ if (typeof v === 'string') return truncate(v, MAX_LOOP_PAYLOAD);
246
+ return safeStringify(v);
247
+ };
136
248
  this.#prepare('endTurn', `
137
249
  UPDATE trace_turns SET
138
250
  model = ?, input_tokens = ?, output_tokens = ?,
139
251
  cache_read_tokens = ?, cache_write_tokens = ?,
140
- stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?
252
+ stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?,
253
+ system_prompt = ?, messages_json = ?, tool_calls_json = ?,
254
+ usage_json = ?, ttfb_ms = ?, raw_request = ?, raw_response = ?
141
255
  WHERE id = ?
142
256
  `).run(
143
257
  model, inputTokens, outputTokens,
144
258
  cacheReadTokens, cacheWriteTokens,
145
- stopReason, latencyMs, truncate(responseText, MAX_TOOL_OUTPUT),
146
- now, turnId,
259
+ stopReason, latencyMs, truncate(responseText, MAX_LOOP_PAYLOAD),
260
+ now,
261
+ truncate(systemPrompt, MAX_LOOP_PAYLOAD),
262
+ safeStringify(messages),
263
+ safeStringify(toolCalls),
264
+ safeStringify(usage),
265
+ ttfbMs,
266
+ stringifyRaw(rawRequest),
267
+ stringifyRaw(rawResponse),
268
+ turnId,
147
269
  );
148
270
  }
149
271
 
@@ -236,6 +358,125 @@ export class DebugTrace {
236
358
  `).all(limit);
237
359
  }
238
360
 
361
+ /**
362
+ * Fetch the recent debug history for the UnifyDebugPanel. Returns one
363
+ * record per LLM loop (ordered oldest → newest) with the structured
364
+ * fields the panel expects. JSON columns are parsed; truncated /
365
+ * malformed payloads degrade to null instead of failing the call.
366
+ *
367
+ * @param {{ limit?: number, groupId?: string|null, threadId?: string|null }} [opts]
368
+ * @returns {{ loops: object[], turns: object[] }}
369
+ */
370
+ fetchRecentDebugHistory({ limit = 100, groupId = null, threadId = null } = {}) {
371
+ const lim = Math.max(1, Math.min(500, Number(limit) || 100));
372
+ const where = [];
373
+ const args = [];
374
+ if (groupId) { where.push('group_id = ?'); args.push(groupId); }
375
+ if (threadId) { where.push('thread_id = ?'); args.push(threadId); }
376
+ const sql = `
377
+ SELECT * FROM trace_turns
378
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
379
+ ORDER BY started_at DESC
380
+ LIMIT ?
381
+ `;
382
+ args.push(lim);
383
+ const rows = this.#db.prepare(sql).all(...args);
384
+ const turnIds = rows.map(r => r.id);
385
+ const tools = turnIds.length > 0
386
+ ? this.#db.prepare(
387
+ `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
388
+ ).all(...turnIds)
389
+ : [];
390
+ const parseJsonSafe = (s) => {
391
+ if (s == null) return null;
392
+ try { return JSON.parse(s); }
393
+ catch { return null; }
394
+ };
395
+ // Group rows by (turnId, threadId, groupId, vpId) → frontend Turn
396
+ // record. Each row is also surfaced as a Loop.
397
+ const turnsById = new Map();
398
+ const loops = rows.map((r) => {
399
+ const parsedMessages = parseJsonSafe(r.messages_json) || [];
400
+ const parsedUsage = parseJsonSafe(r.usage_json);
401
+ const loop = {
402
+ turnId: r.trace_id, // panel groups loops by trace_id-as-turnId
403
+ loopNumber: r.turn_number || 0,
404
+ model: r.model || null,
405
+ systemPrompt: r.system_prompt || '',
406
+ messages: parsedMessages,
407
+ response: r.response_text || '',
408
+ toolCalls: parseJsonSafe(r.tool_calls_json) || [],
409
+ usage: parsedUsage || {
410
+ inputTokens: r.input_tokens || 0,
411
+ outputTokens: r.output_tokens || 0,
412
+ totalTokens: (r.input_tokens || 0) + (r.output_tokens || 0),
413
+ },
414
+ latencyMs: r.latency_ms || 0,
415
+ ttfbMs: r.ttfb_ms || null,
416
+ stopReason: r.stop_reason || null,
417
+ rawRequest: r.raw_request || null,
418
+ rawResponse: r.raw_response || null,
419
+ groupId: r.group_id || null,
420
+ vpId: r.vp_id || null,
421
+ threadId: r.thread_id || null,
422
+ };
423
+ if (!turnsById.has(r.trace_id)) {
424
+ turnsById.set(r.trace_id, {
425
+ turnId: r.trace_id,
426
+ // C2 fix: read the explicit `user_prompt` column persisted at
427
+ // startTurn time. Deriving from messages_json is unsafe — each
428
+ // tool-loop iteration overwrites messages_json with the
429
+ // cumulative conversation snapshot, so `messages[0].content`
430
+ // would be turn-1's prompt for every subsequent turn header.
431
+ userPrompt: r.user_prompt || '',
432
+ groupId: r.group_id || null,
433
+ vpId: r.vp_id || null,
434
+ threadId: r.thread_id || null,
435
+ openedAt: r.started_at || 0,
436
+ closedAt: r.ended_at || null,
437
+ totalMs: 0,
438
+ totalTokens: 0,
439
+ loopCount: 0,
440
+ memoryLoaded: null,
441
+ memoryAdjust: null,
442
+ tools: [],
443
+ });
444
+ }
445
+ const t = turnsById.get(r.trace_id);
446
+ t.loopCount += 1;
447
+ // Aggregate per-loop latency / tokens so the Turn header shows the
448
+ // same totals the live `turn_close` event would have stamped.
449
+ t.totalMs += r.latency_ms || 0;
450
+ const usageTokens = parsedUsage && Number.isFinite(parsedUsage.totalTokens)
451
+ ? parsedUsage.totalTokens
452
+ : (r.input_tokens || 0) + (r.output_tokens || 0);
453
+ t.totalTokens += usageTokens;
454
+ if (r.ended_at && (!t.closedAt || r.ended_at > t.closedAt)) t.closedAt = r.ended_at;
455
+ return loop;
456
+ });
457
+ // Attach tools to their parent Turn so the panel can render per-tool
458
+ // timing without scanning the loop bodies.
459
+ for (const tool of tools) {
460
+ // Find which loop row this tool belongs to → that row's trace_id
461
+ // identifies the Turn.
462
+ const owner = rows.find(r => r.id === tool.turn_id);
463
+ if (!owner) continue;
464
+ const t = turnsById.get(owner.trace_id);
465
+ if (!t) continue;
466
+ t.tools.push({
467
+ loopNumber: owner.turn_number || 0,
468
+ callId: tool.id,
469
+ name: tool.tool_name,
470
+ durationMs: tool.duration_ms || 0,
471
+ isError: !!tool.is_error,
472
+ });
473
+ }
474
+ // Reverse to oldest-first so the panel's existing append-driven UI
475
+ // renders in chronological order on hydration.
476
+ loops.reverse();
477
+ return { loops, turns: Array.from(turnsById.values()) };
478
+ }
479
+
239
480
  /**
240
481
  * Query tool calls with optional filters.
241
482
  * @param {{ name?: string, since?: number }} [filters={}]
@@ -383,6 +624,7 @@ export class NullTrace {
383
624
  cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
384
625
  purge() {}
385
626
  close() {}
627
+ fetchRecentDebugHistory() { return { loops: [], turns: [] }; }
386
628
  }
387
629
 
388
630
  /**
package/unify/engine.js CHANGED
@@ -1468,6 +1468,18 @@ export class Engine {
1468
1468
  const turnId = this.#trace.startTurn({
1469
1469
  traceId: this.#traceId,
1470
1470
  turnNumber,
1471
+ // fix-vp-multi-thread (bug 4): stamp routing context so the
1472
+ // debug-trace SQL row carries enough info to be filtered by
1473
+ // group / thread / VP later when the panel hydrates from disk.
1474
+ groupId: groupId || null,
1475
+ vpId: queryVpId || null,
1476
+ threadId: threadId || null,
1477
+ // Persist the user prompt EXPLICITLY rather than reconstruct it
1478
+ // post-hoc from `messages_json` — every tool-loop iteration
1479
+ // writes the *cumulative* messages array, so deriving the prompt
1480
+ // from `messages.find(role==='user')` would always return turn
1481
+ // 1's prompt and mislabel every subsequent Turn header.
1482
+ userPrompt: userQuestionPreview,
1471
1483
  });
1472
1484
 
1473
1485
  const startTime = Date.now();
@@ -1677,6 +1689,20 @@ export class Engine {
1677
1689
  stopReason: 'error',
1678
1690
  latencyMs,
1679
1691
  responseText,
1692
+ // fix-vp-multi-thread (bug 4): persist the snapshot on the
1693
+ // error path too — failure traces are the most valuable for
1694
+ // hydration.
1695
+ systemPrompt,
1696
+ messages: conversationMessages.map(mapDebugMessage),
1697
+ toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
1698
+ usage: {
1699
+ inputTokens: totalUsage.inputTokens || 0,
1700
+ outputTokens: totalUsage.outputTokens || 0,
1701
+ totalTokens: (totalUsage.inputTokens || 0) + (totalUsage.outputTokens || 0),
1702
+ },
1703
+ ttfbMs,
1704
+ rawRequest,
1705
+ rawResponse,
1680
1706
  });
1681
1707
 
1682
1708
  // Emit `loop` event for error path too (was `debug_turn`).
@@ -1759,6 +1785,21 @@ export class Engine {
1759
1785
  stopReason,
1760
1786
  latencyMs,
1761
1787
  responseText,
1788
+ // fix-vp-multi-thread (bug 4): persist the full per-loop
1789
+ // snapshot. The frontend debug panel only renders what it has
1790
+ // in-memory — without these columns the user can never see
1791
+ // history from before the panel was opened.
1792
+ systemPrompt,
1793
+ messages: conversationMessages.map(mapDebugMessage),
1794
+ toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
1795
+ usage: {
1796
+ inputTokens: totalUsage.inputTokens || 0,
1797
+ outputTokens: totalUsage.outputTokens || 0,
1798
+ totalTokens: (totalUsage.inputTokens || 0) + (totalUsage.outputTokens || 0),
1799
+ },
1800
+ ttfbMs,
1801
+ rawRequest,
1802
+ rawResponse,
1762
1803
  });
1763
1804
 
1764
1805
  // Emit `loop` event for the debug panel.
@@ -837,7 +837,7 @@ async function routeEnvelopeToVpThread(groupId, vpId, envelope) {
837
837
 
838
838
  if (related) {
839
839
  const content = promptParts || prompt;
840
- thread.pendingQueries.push({ content, preview: prompt });
840
+ thread.pendingQueries.push({ content, preview: prompt, originalText: text, originalParts: Array.isArray(envelope?._promptParts) ? envelope._promptParts : null });
841
841
  persistInboundMessageOnceByMsgId({
842
842
  msgId: envelope?.msg?.id,
843
843
  text,
@@ -979,6 +979,72 @@ function ensureDriverRunning(groupId, vpId, threadId = 'main') {
979
979
  }, { groupId, vpId, threadId: thread.threadId, turnId });
980
980
  }
981
981
  } catch { /* never crash WS pipeline */ }
982
+
983
+ // fix-vp-multi-thread (bug 1 + 3): rescue any orphaned related-
984
+ // appends. If a user (or a VP via route_forward) added queries
985
+ // to this thread's `pendingQueries` AFTER the engine had already
986
+ // decided to end_turn (so the inner drain at engine.js:1850 no
987
+ // longer fires), those queries would be silently lost. Convert
988
+ // each leftover into a synthetic inbox envelope so the driver
989
+ // re-enters and runs a fresh turn on the same thread.
990
+ if (thread && Array.isArray(thread.pendingQueries) && thread.pendingQueries.length > 0) {
991
+ const leftovers = thread.pendingQueries.splice(0);
992
+ for (const leftover of leftovers) {
993
+ // `originalText` / `originalParts` capture the inbound payload
994
+ // BEFORE `buildVpPromptPayload` prepended `@vp-<id> ` and added
995
+ // any suffix. Replaying through `buildVpPromptPayload` (via the
996
+ // driver) re-applies the prefix, so we must NOT pass the
997
+ // already-decorated `preview` here or the prompt would carry
998
+ // a double `@vp-<id> @vp-<id> ...` mention.
999
+ const replayText = typeof leftover?.originalText === 'string'
1000
+ ? leftover.originalText
1001
+ : '';
1002
+ const replayParts = Array.isArray(leftover?.originalParts) && leftover.originalParts.length > 0
1003
+ ? leftover.originalParts
1004
+ : null;
1005
+ if (!replayText && !replayParts) continue;
1006
+ const followUpId = `followup_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
1007
+ const followUpEnvelope = {
1008
+ groupId,
1009
+ taskId: envelope?.taskId || null,
1010
+ trigger: 'pending_rescue',
1011
+ msg: {
1012
+ id: followUpId,
1013
+ from: 'user',
1014
+ text: replayText,
1015
+ meta: { rescuedFrom: 'pendingQueries', threadId: thread.threadId },
1016
+ },
1017
+ ...(replayParts ? { _promptParts: replayParts } : {}),
1018
+ };
1019
+ const followUpTurnId = `${randomUUID().slice(0, 8)}:${vpId}`;
1020
+ inbox.push({ envelope: followUpEnvelope, turnId: followUpTurnId, thread });
1021
+ try {
1022
+ thread.status = 'typing';
1023
+ thread.updatedAt = Date.now();
1024
+ getVpStatusBroker().transition({
1025
+ groupId,
1026
+ vpId,
1027
+ threadId: thread.threadId,
1028
+ title: thread.title || '',
1029
+ state: 'typing',
1030
+ turnId: followUpTurnId,
1031
+ messageCount: thread.messageIds.length,
1032
+ });
1033
+ } catch (err) {
1034
+ console.warn('[Unify] vp-status typing transition (rescue) failed:', err?.message || err);
1035
+ }
1036
+ try {
1037
+ sendUnifyEvent({
1038
+ type: 'vp_typing_start',
1039
+ groupId,
1040
+ vpId,
1041
+ threadId: thread.threadId,
1042
+ turnId: followUpTurnId,
1043
+ ts: Date.now(),
1044
+ }, { groupId, vpId, threadId: thread.threadId, turnId: followUpTurnId });
1045
+ } catch { /* never crash WS pipeline */ }
1046
+ }
1047
+ }
982
1048
  }
983
1049
  vpDrivers.delete(key);
984
1050
  const tail = vpInboxes.get(key);
@@ -2518,6 +2584,20 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, threadId =
2518
2584
  } catch (err) {
2519
2585
  console.warn('[Unify] vp-status settleIdle failed:', err?.message || err);
2520
2586
  }
2587
+ // fix-vp-multi-thread (bug 2): the bridge tracks per-thread status
2588
+ // on `thread.status` separately from the broker. Multiple sites
2589
+ // (`maybeTransitionVpStatus`, `routeEnvelopeToVpThread`'s typing
2590
+ // transition) write to it but no site cleared it on turn end, so
2591
+ // every finished thread was stuck reporting `thinking|streaming|tool`
2592
+ // forever. `getRunningThreads` filters on this field, so the
2593
+ // classifier next time the user spoke would treat the zombie as
2594
+ // a live thread and route the new query as "related" — orphaning
2595
+ // the message in `pendingQueries` because no engine was running
2596
+ // to drain it. Always settle to 'idle' here.
2597
+ if (thread) {
2598
+ thread.status = 'idle';
2599
+ thread.updatedAt = Date.now();
2600
+ }
2521
2601
  }
2522
2602
  }
2523
2603
 
@@ -3059,6 +3139,55 @@ export async function handleUnifyFetchToolStats(_msg = {}) {
3059
3139
  });
3060
3140
  }
3061
3141
 
3142
+ /**
3143
+ * Hydrate the UnifyDebugPanel from the persistent SQLite trace. The
3144
+ * panel state (`unifyDebugLoops` / `unifyDebugTurnsById`) is otherwise
3145
+ * built ONLY from in-flight `loop` / `turn_open` events on the wire,
3146
+ * so a panel opened after a turn has finished sees nothing for that
3147
+ * turn. This handler ships back a frontend-shaped snapshot the store
3148
+ * splices into place.
3149
+ *
3150
+ * Inputs (all optional):
3151
+ * - `limit` — max number of loops to return (1..500, default 100)
3152
+ * - `groupId` — narrow by group
3153
+ * - `threadId` — narrow by thread
3154
+ *
3155
+ * Sends:
3156
+ * { type: 'unify_debug_history', loops: [...], turns: [...] }
3157
+ *
3158
+ * Best-effort: if the session / trace isn't ready, sends an empty
3159
+ * snapshot so the panel renders a placeholder instead of spinning.
3160
+ */
3161
+ export async function handleUnifyFetchDebugHistory(msg = {}) {
3162
+ const limit = Number.isFinite(msg?.limit) ? Number(msg.limit) : 100;
3163
+ const groupId = typeof msg?.groupId === 'string' && msg.groupId ? msg.groupId : null;
3164
+ const threadId = typeof msg?.threadId === 'string' && msg.threadId ? msg.threadId : null;
3165
+ let loops = [];
3166
+ let turns = [];
3167
+ try {
3168
+ if (session?.trace && typeof session.trace.fetchRecentDebugHistory === 'function') {
3169
+ const out = session.trace.fetchRecentDebugHistory({ limit, groupId, threadId });
3170
+ loops = Array.isArray(out?.loops) ? out.loops : [];
3171
+ turns = Array.isArray(out?.turns) ? out.turns : [];
3172
+ }
3173
+ } catch (err) {
3174
+ sendToServer({
3175
+ type: 'unify_debug_history',
3176
+ loops: [],
3177
+ turns: [],
3178
+ error: err && err.message ? err.message : String(err),
3179
+ });
3180
+ return;
3181
+ }
3182
+ sendToServer({
3183
+ type: 'unify_debug_history',
3184
+ loops,
3185
+ turns,
3186
+ groupId,
3187
+ threadId,
3188
+ });
3189
+ }
3190
+
3062
3191
  /** Deprecated mode switch — Unify is single-mode. */
3063
3192
  export function handleUnifyModeSwitch(_msg) {
3064
3193
  console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');