@yeaft/webchat-agent 1.0.517 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.517",
3
+ "version": "1.0.519",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
- import { readFile, writeFile, readdir, stat, unlink, rename, mkdir, rm, copyFile, cp } from 'fs/promises';
1
+ import { readFile, realpath, writeFile, readdir, stat, unlink, rename, mkdir, rm, copyFile, cp } from 'fs/promises';
2
2
  import { existsSync } from 'fs';
3
- import { join, basename, dirname, extname } from 'path';
3
+ import { join, basename, dirname, extname, isAbsolute, relative, resolve } from 'path';
4
4
  import { platform } from 'os';
5
5
  import ctx from '../context.js';
6
6
  import { resolveAndValidatePath, BINARY_EXTENSIONS } from './utils.js';
@@ -9,6 +9,19 @@ import { sendWorkbenchResult } from './request-routing.js';
9
9
  export const MAX_WORKBENCH_PREVIEW_BYTES = 20 * 1024 * 1024;
10
10
  export const WORKBENCH_FILE_CHUNK_BYTES = 1024 * 1024;
11
11
 
12
+ async function validateResponseImagePath(filePath, workDir) {
13
+ const canonicalRoot = await realpath(resolve(workDir));
14
+ const canonicalFile = await realpath(resolveAndValidatePath(filePath, canonicalRoot));
15
+ const relativePath = relative(canonicalRoot, canonicalFile);
16
+ const outside = relativePath === '..'
17
+ || relativePath.startsWith(`..${platform() === 'win32' ? '\\' : '/'}`)
18
+ || isAbsolute(relativePath);
19
+ if (outside) throw new Error('Response image is outside the active workspace.');
20
+ const mimeType = BINARY_EXTENSIONS[extname(canonicalFile).toLowerCase()];
21
+ if (!mimeType?.startsWith('image/')) throw new Error('Response preview only supports image files.');
22
+ return canonicalFile;
23
+ }
24
+
12
25
  async function reportFileTransferDropped(msg, base) {
13
26
  const errorResult = {
14
27
  ...base,
@@ -64,7 +77,9 @@ export async function handleReadFile(msg) {
64
77
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
65
78
 
66
79
  try {
67
- const resolved = resolveAndValidatePath(filePath, workDir);
80
+ const resolved = msg.responseImagePreview
81
+ ? await validateResponseImagePath(filePath, workDir)
82
+ : resolveAndValidatePath(filePath, workDir);
68
83
  const ext = extname(resolved).toLowerCase();
69
84
  const mimeType = BINARY_EXTENSIONS[ext];
70
85
 
@@ -1,5 +1,5 @@
1
- import { readdir, stat } from 'node:fs/promises';
2
- import { basename, join, relative, resolve } from 'node:path';
1
+ import { readdir, realpath, stat } from 'node:fs/promises';
2
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { platform } from 'node:os';
4
4
  import ctx from '../context.js';
5
5
  import { resolveAndValidatePath } from './utils.js';
@@ -10,15 +10,28 @@ const MAX_SCANNED_ENTRIES = 5000;
10
10
  const MAX_DEPTH = 10;
11
11
  const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.next', '.nuxt', 'dist', 'build', '.cache', 'bin', 'obj']);
12
12
 
13
- async function isFile(path) {
14
- try { return (await stat(path)).isFile(); } catch { return false; }
15
- }
16
-
17
13
  function comparable(value) {
18
14
  const normalized = String(value || '').replaceAll('\\', '/');
19
15
  return platform() === 'win32' ? normalized.toLowerCase() : normalized;
20
16
  }
21
17
 
18
+ function isInsideWorkspace(root, candidate) {
19
+ const relativePath = relative(root, candidate);
20
+ return relativePath === '' || (relativePath !== '..'
21
+ && !relativePath.startsWith(`..${platform() === 'win32' ? '\\' : '/'}`)
22
+ && !isAbsolute(relativePath));
23
+ }
24
+
25
+ async function canonicalWorkspaceFile(candidate, canonicalRoot) {
26
+ try {
27
+ const canonicalPath = await realpath(candidate);
28
+ if (!isInsideWorkspace(canonicalRoot, canonicalPath) || !(await stat(canonicalPath)).isFile()) return null;
29
+ return canonicalPath;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
22
35
  async function findUniqueBasenames(workDir, requestedPaths, {
23
36
  maxScannedEntries = MAX_SCANNED_ENTRIES,
24
37
  maxDepth = MAX_DEPTH,
@@ -67,24 +80,30 @@ export async function resolveFileReferences(references, workDir, scanOptions) {
67
80
  const unique = [...new Set((Array.isArray(references) ? references : [])
68
81
  .map(value => typeof value === 'string' ? value.trim() : '')
69
82
  .filter(Boolean))].slice(0, MAX_REFERENCES);
70
- const exactMatches = await Promise.all(unique.map(async requestedPath => {
71
- const exactPath = resolveAndValidatePath(requestedPath, workDir);
72
- return await isFile(exactPath) ? exactPath : null;
73
- }));
74
- const unresolved = unique.filter((_path, index) => !exactMatches[index]);
75
- const basenameScan = await findUniqueBasenames(workDir, unresolved, scanOptions);
76
83
  const root = resolve(workDir);
84
+ const canonicalRoot = await realpath(root);
85
+ const exactCandidates = unique.map(requestedPath => resolveAndValidatePath(requestedPath, root));
86
+ const exactMatches = await Promise.all(exactCandidates.map(exactPath => (
87
+ canonicalWorkspaceFile(exactPath, canonicalRoot)
88
+ )));
89
+ const unresolved = unique.filter((_path, index) => (
90
+ !exactMatches[index] && isInsideWorkspace(root, exactCandidates[index])
91
+ ));
92
+ const basenameScan = await findUniqueBasenames(root, unresolved, scanOptions);
77
93
 
78
- return unique.flatMap((requestedPath, index) => {
94
+ const resolvedEntries = await Promise.all(unique.map(async (requestedPath, index) => {
79
95
  const matches = basenameScan.matches.get(comparable(basename(requestedPath))) || [];
80
- const fallbackPath = basenameScan.complete && matches.length === 1 ? matches[0] : null;
96
+ const fallbackPath = basenameScan.complete && matches.length === 1
97
+ ? await canonicalWorkspaceFile(matches[0], canonicalRoot)
98
+ : null;
81
99
  const matchedPath = exactMatches[index] || fallbackPath;
82
- if (!matchedPath) return [];
83
- return [{
100
+ if (!matchedPath) return null;
101
+ return {
84
102
  requestedPath,
85
- resolvedPath: relative(root, matchedPath).replaceAll('\\', '/') || basename(matchedPath),
86
- }];
87
- });
103
+ resolvedPath: relative(canonicalRoot, matchedPath).replaceAll('\\', '/') || basename(matchedPath),
104
+ };
105
+ }));
106
+ return resolvedEntries.filter(Boolean);
88
107
  }
89
108
 
90
109
  export async function handleResolveFileReferences(msg) {
@@ -413,6 +413,9 @@ function serializeMessage(msg) {
413
413
  if (msg.model) fm.push(`model: ${msg.model}`);
414
414
  if (msg.effort) fm.push(`effort: ${msg.effort}`);
415
415
  if (Number.isInteger(msg.llmCallCount) && msg.llmCallCount > 0) fm.push(`llmCallCount: ${msg.llmCallCount}`);
416
+ for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'totalMs']) {
417
+ if (Number.isFinite(msg[key]) && msg[key] >= 0) fm.push(`${key}: ${Math.round(msg[key])}`);
418
+ }
416
419
  if (msg.turnNumber != null) fm.push(`turnNumber: ${msg.turnNumber}`);
417
420
  if (msg.toolCallId) fm.push(`toolCallId: ${msg.toolCallId}`);
418
421
  if (msg.eventType) fm.push(`eventType: ${msg.eventType}`);
@@ -564,6 +567,10 @@ export function parseMessage(raw) {
564
567
  case 'model': msg.model = value; break;
565
568
  case 'effort': msg.effort = value; break;
566
569
  case 'llmCallCount': msg.llmCallCount = parseInt(value, 10); break;
570
+ case 'inputTokens': msg.inputTokens = parseInt(value, 10); break;
571
+ case 'outputTokens': msg.outputTokens = parseInt(value, 10); break;
572
+ case 'totalTokens': msg.totalTokens = parseInt(value, 10); break;
573
+ case 'totalMs': msg.totalMs = parseInt(value, 10); break;
567
574
  case 'turnNumber': msg.turnNumber = parseInt(value, 10); break;
568
575
  case 'toolCallId': msg.toolCallId = value; break;
569
576
  case 'eventType': msg.eventType = value; break;
@@ -332,10 +332,27 @@ function messagesPrefixLength(prevMessages, nextMessages) {
332
332
  return i;
333
333
  }
334
334
 
335
+ function normalizeRequestInputBreakdown(value) {
336
+ if (!value || typeof value !== 'object') return null;
337
+ const normalized = {};
338
+ for (const key of [
339
+ 'systemPromptTokens',
340
+ 'historyMessageTokens',
341
+ 'toolDefinitionTokens',
342
+ 'currentTurnTokens',
343
+ 'totalEstimatedTokens',
344
+ ]) {
345
+ const number = Number(value[key]);
346
+ normalized[key] = Number.isFinite(number) && number >= 0 ? Math.floor(number) : 0;
347
+ }
348
+ return normalized;
349
+ }
350
+
335
351
  function buildRequestSnapshot(info = {}) {
336
352
  return {
337
353
  systemPrompt: String(info.systemPrompt || ''),
338
354
  messages: Array.isArray(info.messages) ? cloneJsonValue(info.messages) : [],
355
+ requestInputBreakdown: normalizeRequestInputBreakdown(info.requestInputBreakdown),
339
356
  rawRequest: info.rawRequest ?? null,
340
357
  };
341
358
  }
@@ -346,6 +363,7 @@ function buildRequestDelta(previous, next) {
346
363
  base: true,
347
364
  systemPrompt: next.systemPrompt || '',
348
365
  messages: Array.isArray(next.messages) ? next.messages : [],
366
+ requestInputBreakdown: next.requestInputBreakdown || null,
349
367
  };
350
368
  const rawRequestDelta = buildRawRequestDelta(null, next.rawRequest);
351
369
  if (rawRequestDelta) delta.rawRequestDelta = rawRequestDelta;
@@ -355,6 +373,9 @@ function buildRequestDelta(previous, next) {
355
373
  if ((next.systemPrompt || '') !== (previous.systemPrompt || '')) {
356
374
  delta.systemPrompt = next.systemPrompt || '';
357
375
  }
376
+ if (!stableEqual(next.requestInputBreakdown || null, previous.requestInputBreakdown || null)) {
377
+ delta.requestInputBreakdown = next.requestInputBreakdown || null;
378
+ }
358
379
  const prevMessages = Array.isArray(previous.messages) ? previous.messages : [];
359
380
  const nextMessages = Array.isArray(next.messages) ? next.messages : [];
360
381
  const prefix = messagesPrefixLength(prevMessages, nextMessages);
@@ -371,16 +392,18 @@ function buildRequestDelta(previous, next) {
371
392
  }
372
393
 
373
394
  function applyRequestDelta(previous, delta = {}) {
374
- const base = previous || { systemPrompt: '', messages: [], rawRequest: null };
395
+ const base = previous || { systemPrompt: '', messages: [], requestInputBreakdown: null, rawRequest: null };
375
396
  const next = {
376
397
  systemPrompt: base.systemPrompt || '',
377
398
  messages: Array.isArray(base.messages) ? [...base.messages] : [],
399
+ requestInputBreakdown: normalizeRequestInputBreakdown(base.requestInputBreakdown),
378
400
  rawRequest: base.rawRequest ?? null,
379
401
  };
380
402
  if (delta.base) {
381
403
  const nextBase = {
382
404
  systemPrompt: delta.systemPrompt || '',
383
405
  messages: Array.isArray(delta.messages) ? delta.messages : [],
406
+ requestInputBreakdown: normalizeRequestInputBreakdown(delta.requestInputBreakdown),
384
407
  rawRequest: Object.prototype.hasOwnProperty.call(delta, 'rawRequest') ? delta.rawRequest : null,
385
408
  };
386
409
  if (Object.prototype.hasOwnProperty.call(delta, 'rawRequestDelta')) {
@@ -389,6 +412,9 @@ function applyRequestDelta(previous, delta = {}) {
389
412
  return nextBase;
390
413
  }
391
414
  if (typeof delta.systemPrompt === 'string') next.systemPrompt = delta.systemPrompt;
415
+ if (Object.prototype.hasOwnProperty.call(delta, 'requestInputBreakdown')) {
416
+ next.requestInputBreakdown = normalizeRequestInputBreakdown(delta.requestInputBreakdown);
417
+ }
392
418
  if (Array.isArray(delta.messages)) {
393
419
  next.messages = delta.messages;
394
420
  } else if (Array.isArray(delta.messagesAppend)) {
@@ -639,6 +665,7 @@ function expandTrace(trace) {
639
665
  // its actual source loop. Messages remain limited to the final loop.
640
666
  systemPrompt: '',
641
667
  messages: [],
668
+ requestInputBreakdown: snapshot.requestInputBreakdown || null,
642
669
  response: loop.response || '',
643
670
  toolCalls: Array.isArray(loop.toolCalls) ? loop.toolCalls : [],
644
671
  usage,
@@ -966,6 +993,7 @@ export class DebugTrace {
966
993
  trace.baseRequest = {
967
994
  systemPrompt: snapshot.systemPrompt,
968
995
  messages: Array.isArray(snapshot.messages) ? cloneJsonValue(snapshot.messages) : [],
996
+ requestInputBreakdown: snapshot.requestInputBreakdown,
969
997
  rawRequest: snapshot.rawRequest ?? null,
970
998
  };
971
999
  }
package/yeaft/engine.js CHANGED
@@ -33,7 +33,13 @@ import {
33
33
  DEFAULT_PROJECT_DOC_MAX_BYTES,
34
34
  } from './sessions/project-doc.js';
35
35
  import { archiveToolResults } from './archive/tool-results.js';
36
- import { trimSnapshotForBudget, estimateMessageTokens, buildHistoryBuckets, fitProviderRequestToContext } from './history-window.js';
36
+ import {
37
+ trimSnapshotForBudget,
38
+ estimateMessageTokens,
39
+ estimateMessagesTokens as estimateHistoryMessagesTokens,
40
+ buildHistoryBuckets,
41
+ fitProviderRequestToContext,
42
+ } from './history-window.js';
37
43
  import { recallConversationTurns } from './conversation/history-index.js';
38
44
  import { parseSeqFromId } from './conversation/persist.js';
39
45
  import { isVpForeign, readContent as readScopeContent } from './memory/store.js';
@@ -413,6 +419,41 @@ export function estimateMessagesTokens(system, messages) {
413
419
  return total;
414
420
  }
415
421
 
422
+ /**
423
+ * Estimate the input composition of the exact provider request copy. These are
424
+ * diagnostic estimates only; provider usage remains authoritative for billing.
425
+ * `historyMessageCount` is the final post-fit boundary between historical rows
426
+ * and current-turn rows.
427
+ *
428
+ * @param {{ systemPrompt?:string, messages?:Array<object>, historyMessageCount?:number, toolDefs?:Array<object> }} input
429
+ * @returns {{systemPromptTokens:number, historyMessageTokens:number, toolDefinitionTokens:number, currentTurnTokens:number, totalEstimatedTokens:number}}
430
+ */
431
+ export function estimateProviderInputBreakdown({
432
+ systemPrompt = '',
433
+ messages = [],
434
+ historyMessageCount = 0,
435
+ toolDefs = [],
436
+ } = {}) {
437
+ const source = Array.isArray(messages) ? messages : [];
438
+ const split = Math.max(0, Math.min(
439
+ source.length,
440
+ Number.isInteger(historyMessageCount) ? historyMessageCount : 0,
441
+ ));
442
+ const systemPromptTokens = approxTokens(typeof systemPrompt === 'string' ? systemPrompt : '');
443
+ const historyMessageTokens = estimateHistoryMessagesTokens(source.slice(0, split));
444
+ const currentTurnTokens = estimateHistoryMessagesTokens(source.slice(split));
445
+ const toolDefinitionTokens = Array.isArray(toolDefs) && toolDefs.length > 0
446
+ ? approxTokens(JSON.stringify(toolDefs))
447
+ : 0;
448
+ return {
449
+ systemPromptTokens,
450
+ historyMessageTokens,
451
+ toolDefinitionTokens,
452
+ currentTurnTokens,
453
+ totalEstimatedTokens: systemPromptTokens + historyMessageTokens + toolDefinitionTokens + currentTurnTokens,
454
+ };
455
+ }
456
+
416
457
  export const GROUP_CONTEXT_PRESSURE_RATIO = 0.8;
417
458
 
418
459
  export function shouldAllowGroupReflection({
@@ -2814,6 +2855,8 @@ export class Engine {
2814
2855
  reportedSkillError = skillResolutionError;
2815
2856
  systemPrompt = buildCurrentSystemPrompt();
2816
2857
  if (executionPolicy?.prompt) systemPrompt += `\n\n${executionPolicy.prompt}`;
2858
+ let requestDebugMessages = [];
2859
+ let requestInputBreakdown = null;
2817
2860
 
2818
2861
  try {
2819
2862
  // Resolve effort per provider request so a saved Session effort takes
@@ -2941,6 +2984,7 @@ export class Engine {
2941
2984
  language: requestConfig.language,
2942
2985
  });
2943
2986
  wireMessages = fittedRequest.messages;
2987
+ historyMessageCount = fittedRequest.meta.historyMessagesAfter;
2944
2988
  if (fittedRequest.meta.droppedHistoryMessages > 0
2945
2989
  || fittedRequest.meta.droppedCurrentMessages > 0
2946
2990
  || providerContextScale < 1) {
@@ -2996,6 +3040,17 @@ export class Engine {
2996
3040
  }
2997
3041
  }
2998
3042
 
3043
+ // Freeze diagnostics only after every provider-only trim/sweep has
3044
+ // completed, so debug describes the exact request copy rather than the
3045
+ // durable transcript or an earlier preflight candidate.
3046
+ requestDebugMessages = wireMessages.map(mapDebugMessage);
3047
+ requestInputBreakdown = estimateProviderInputBreakdown({
3048
+ systemPrompt,
3049
+ messages: wireMessages,
3050
+ historyMessageCount: Math.min(historyMessageCount, wireMessages.length),
3051
+ toolDefs,
3052
+ });
3053
+
2999
3054
  // Capture only the provider route before the visible boundary. The
3000
3055
  // returned async iterable must not make a request or write durable
3001
3056
  // state; both the retry continuation and Work Center EngineTurn remain
@@ -3281,7 +3336,8 @@ export class Engine {
3281
3336
  latencyMs,
3282
3337
  responseText,
3283
3338
  systemPrompt,
3284
- messages: conversationMessages.map(mapDebugMessage),
3339
+ messages: requestDebugMessages,
3340
+ requestInputBreakdown,
3285
3341
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
3286
3342
  usage: {
3287
3343
  inputTokens: totalUsage.inputTokens || 0,
@@ -3549,7 +3605,8 @@ export class Engine {
3549
3605
  // error path too — failure traces are the most valuable for
3550
3606
  // hydration.
3551
3607
  systemPrompt,
3552
- messages: conversationMessages.map(mapDebugMessage),
3608
+ messages: requestDebugMessages,
3609
+ requestInputBreakdown,
3553
3610
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
3554
3611
  usage: {
3555
3612
  inputTokens: totalUsage.inputTokens || 0,
@@ -3575,7 +3632,8 @@ export class Engine {
3575
3632
  loopNumber: turnNumber,
3576
3633
  model: currentModel,
3577
3634
  systemPrompt,
3578
- messages: conversationMessages.map(mapDebugMessage),
3635
+ messages: requestDebugMessages,
3636
+ requestInputBreakdown,
3579
3637
  response: responseText || `Error: ${err.message}`,
3580
3638
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
3581
3639
  usage: {
@@ -3654,7 +3712,8 @@ export class Engine {
3654
3712
  // in-memory — without these columns the user can never see
3655
3713
  // history from before the panel was opened.
3656
3714
  systemPrompt,
3657
- messages: conversationMessages.map(mapDebugMessage),
3715
+ messages: requestDebugMessages,
3716
+ requestInputBreakdown,
3658
3717
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
3659
3718
  usage: {
3660
3719
  inputTokens: totalUsage.inputTokens || 0,
@@ -3749,7 +3808,8 @@ export class Engine {
3749
3808
  loopNumber: turnNumber,
3750
3809
  model: currentModel,
3751
3810
  systemPrompt,
3752
- messages: conversationMessages.map(mapDebugMessage),
3811
+ messages: requestDebugMessages,
3812
+ requestInputBreakdown,
3753
3813
  response: responseText,
3754
3814
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
3755
3815
  usage: {
@@ -4926,13 +4986,19 @@ export class Engine {
4926
4986
  // Loop back to call adapter again with tool results
4927
4987
  }
4928
4988
 
4989
+ const totalMs = Date.now() - queryStartedAt;
4990
+ const totalTokens = cumulativeInputTokens + cumulativeOutputTokens;
4929
4991
  // Store final provider metadata on the response row itself. Debug events
4930
- // are transient; the response card must retain the last successful model,
4931
- // effort, and call count after a reload.
4992
+ // are transient; the response card must retain model, call count, token
4993
+ // usage, and duration after a reload.
4932
4994
  const llmCountMessage = lastPersistedAssistantTextMessage || lastPersistedAssistantMessage;
4933
4995
  if (llmCountMessage && typeof this.#conversationStore?.update === 'function') {
4934
4996
  const updated = this.#conversationStore.update(llmCountMessage, {
4935
4997
  llmCallCount: turnNumber,
4998
+ inputTokens: cumulativeInputTokens,
4999
+ outputTokens: cumulativeOutputTokens,
5000
+ totalTokens,
5001
+ totalMs,
4936
5002
  ...(lastSuccessfulModel ? { model: lastSuccessfulModel } : {}),
4937
5003
  ...(lastSuccessfulEffort ? { effort: lastSuccessfulEffort } : {}),
4938
5004
  });
@@ -4948,8 +5014,10 @@ export class Engine {
4948
5014
  type: 'turn_close',
4949
5015
  turnId: queryTurnId,
4950
5016
  threadId,
4951
- totalMs: Date.now() - queryStartedAt,
4952
- totalTokens: cumulativeInputTokens + cumulativeOutputTokens,
5017
+ totalMs,
5018
+ inputTokens: cumulativeInputTokens,
5019
+ outputTokens: cumulativeOutputTokens,
5020
+ totalTokens,
4953
5021
  loopCount: turnNumber,
4954
5022
  ...(lastSuccessfulModel ? { model: lastSuccessfulModel } : {}),
4955
5023
  ...(lastSuccessfulEffort ? { effort: lastSuccessfulEffort } : {}),
@@ -1408,6 +1408,9 @@ function projectPersistedToHistoryEntry(m, { includeReflections = false } = {})
1408
1408
  if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
1409
1409
  if (m.responseKind === 'progress' || m.responseKind === 'result') entry.responseKind = m.responseKind;
1410
1410
  if (Number.isInteger(m.llmCallCount) && m.llmCallCount > 0) entry.llmCallCount = m.llmCallCount;
1411
+ for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'totalMs']) {
1412
+ if (Number.isFinite(m[key]) && m[key] >= 0) entry[key] = m[key];
1413
+ }
1411
1414
  if (typeof m.model === 'string' && m.model) entry.model = m.model;
1412
1415
  if (typeof m.effort === 'string' && m.effort) entry.effort = m.effort;
1413
1416
  if (m.incomplete === true) entry.incomplete = true;
@@ -1551,6 +1554,10 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1551
1554
  ...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
1552
1555
  ...(m.responseKind === 'progress' || m.responseKind === 'result' ? { responseKind: m.responseKind } : {}),
1553
1556
  ...(Number.isInteger(m.llmCallCount) && m.llmCallCount > 0 ? { llmCallCount: m.llmCallCount } : {}),
1557
+ ...(Number.isFinite(m.inputTokens) && m.inputTokens >= 0 ? { inputTokens: m.inputTokens } : {}),
1558
+ ...(Number.isFinite(m.outputTokens) && m.outputTokens >= 0 ? { outputTokens: m.outputTokens } : {}),
1559
+ ...(Number.isFinite(m.totalTokens) && m.totalTokens >= 0 ? { totalTokens: m.totalTokens } : {}),
1560
+ ...(Number.isFinite(m.totalMs) && m.totalMs >= 0 ? { totalMs: m.totalMs } : {}),
1554
1561
  ...(typeof m.model === 'string' && m.model ? { model: m.model } : {}),
1555
1562
  ...(typeof m.effort === 'string' && m.effort ? { effort: m.effort } : {}),
1556
1563
  ...(m.incomplete === true ? { incomplete: true } : {}),
@@ -4568,6 +4575,8 @@ function handleEngineEvent(event, hctx) {
4568
4575
  turnId: event.turnId,
4569
4576
  threadId: event.threadId,
4570
4577
  totalMs: event.totalMs,
4578
+ inputTokens: event.inputTokens,
4579
+ outputTokens: event.outputTokens,
4571
4580
  totalTokens: event.totalTokens,
4572
4581
  loopCount: event.loopCount,
4573
4582
  ...(typeof event.model === 'string' && event.model ? { model: event.model } : {}),
@@ -5660,7 +5669,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5660
5669
  resetQueryTimer();
5661
5670
 
5662
5671
  // Emit turn_start so frontend can create the message block.
5663
- sendSessionEvent({ type: 'vp_turn_start', vpId, threadId, turnId, sessionId, title: thread?.title || '' }, envelope);
5672
+ sendSessionEvent({ type: 'vp_turn_start', vpId, threadId, turnId, sessionId, title: thread?.title || '', ts: turnStartAt }, envelope);
5664
5673
  // vp-status: LLM call about to start, no text/tool yet → 'thinking'.
5665
5674
  try {
5666
5675
  getVpStatusBroker().transition({ sessionId, vpId, threadId, title: thread?.title || '', state: 'thinking', turnId, messageCount: thread?.messageIds?.length || 0 });