@aiwayds/dsh-tui-pi 0.8.2 → 0.9.0

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.
Files changed (60) hide show
  1. package/README.md +44 -3
  2. package/README.zh.md +36 -3
  3. package/assets/fonts/LICENSE-Hack.txt +45 -0
  4. package/assets/fonts/LICENSE-NerdFonts.txt +126 -0
  5. package/assets/fonts/LICENSE-NotoSansSymbols.txt +93 -0
  6. package/assets/fonts/LICENSE-NotoSansSymbols2.txt +93 -0
  7. package/assets/fonts/dsh-tui-pi-nerd.ttf +0 -0
  8. package/assets/fonts-gen.mjs +219 -0
  9. package/assets/whale-gen.mjs +169 -0
  10. package/assets/whale-source.png +0 -0
  11. package/lib/agents.js +1 -1
  12. package/lib/agents.js.map +1 -1
  13. package/lib/canvas-terminal.d.ts +17 -11
  14. package/lib/canvas-terminal.js +62 -24
  15. package/lib/canvas-terminal.js.map +1 -1
  16. package/lib/dsh-events.d.ts +32 -3
  17. package/lib/dsh-events.js +14 -0
  18. package/lib/dsh-events.js.map +1 -1
  19. package/lib/font-detect.d.ts +66 -0
  20. package/lib/font-detect.js +124 -0
  21. package/lib/font-detect.js.map +1 -0
  22. package/lib/footer.d.ts +3 -2
  23. package/lib/footer.js +16 -8
  24. package/lib/footer.js.map +1 -1
  25. package/lib/icons.d.ts +53 -0
  26. package/lib/icons.js +78 -0
  27. package/lib/icons.js.map +1 -0
  28. package/lib/index.js +44 -13
  29. package/lib/index.js.map +1 -1
  30. package/lib/live-widgets.d.ts +16 -5
  31. package/lib/live-widgets.js +29 -8
  32. package/lib/live-widgets.js.map +1 -1
  33. package/lib/messages.js +2 -1
  34. package/lib/messages.js.map +1 -1
  35. package/lib/session.d.ts +99 -16
  36. package/lib/session.js +322 -64
  37. package/lib/session.js.map +1 -1
  38. package/lib/sessions.d.ts +15 -0
  39. package/lib/sessions.js +18 -3
  40. package/lib/sessions.js.map +1 -1
  41. package/lib/settings.js +2 -2
  42. package/lib/skills.d.ts +19 -3
  43. package/lib/skills.js +43 -10
  44. package/lib/skills.js.map +1 -1
  45. package/lib/subagent-policy.d.ts +11 -11
  46. package/lib/subagent-policy.js +17 -15
  47. package/lib/subagent-policy.js.map +1 -1
  48. package/lib/subagent-viewer.d.ts +20 -5
  49. package/lib/subagent-viewer.js +29 -15
  50. package/lib/subagent-viewer.js.map +1 -1
  51. package/lib/theme-settings.d.ts +25 -10
  52. package/lib/theme-settings.js +40 -14
  53. package/lib/theme-settings.js.map +1 -1
  54. package/lib/tokens.d.ts +38 -0
  55. package/lib/tokens.js +68 -0
  56. package/lib/tokens.js.map +1 -0
  57. package/lib/tui.js +15 -6
  58. package/lib/tui.js.map +1 -1
  59. package/package.json +2 -1
  60. package/scripts/install-font.mjs +329 -0
package/lib/session.js CHANGED
@@ -14,8 +14,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
14
14
  import { settingsNamespace, SettingsConflictError } from '@deepseek-ai/dsh-settings';
15
15
  import { SessionId } from '@deepseek-ai/dsh-session';
16
16
  import { readAppendSystem } from "./append-system.js";
17
- import { isAgentEnd, isAgentStart, isLlmRetry, isSubagentDescriptor } from "./dsh-events.js";
17
+ import { isAgentEnd, isAgentStart, isDcpCompactionNotice, isLlmRetry, isSubagentDescriptor } from "./dsh-events.js";
18
18
  import { installSpawnToolFence } from "./subagent-policy.js";
19
+ import { estimateContentTokens, estimateTextTokens } from "./tokens.js";
19
20
  /**
20
21
  * Register the APPEND_SYSTEM.md section on ONE agent's scoped context, so the
21
22
  * user's orchestrator identity applies to the TUI's main agent ONLY. A section
@@ -45,18 +46,45 @@ function installAppendSystem(agentCtx) {
45
46
  */
46
47
  const CHILD_LOG_CAP = 2000;
47
48
  /**
48
- * Cadence of the per-child turn-count reconcile. The TUI's discovery and fold
49
- * assume child `session/event` streams bubble here, but in some deployments
50
- * a subagent's own events never reach this plugin — the child shows up only
51
- * through the parent's `tool-workflow/agent-start`, so "rounds" stays 0 and
52
- * the `maxRounds` policy never fires. The reconcile re-derives each child's
53
- * turn count from its authoritative session log (`ctx.sessions`) as a
54
- * fallback; it only ever moves the count UP past what streamed events already
55
- * recorded, so the two sources never double count.
49
+ * Cadence of the per-child round-count reconcile. The TUI's discovery and
50
+ * fold assume child `session/event` streams bubble here, but in some
51
+ * deployments a subagent's own events never reach this plugin — the child
52
+ * shows up only through the parent's `tool-workflow/agent-start`, so
53
+ * "rounds" stays 0 and the `maxRounds` policy never fires. The reconcile
54
+ * re-derives each child's round count from its authoritative session log
55
+ * (`ctx.sessions`) as a fallback; it only ever moves the count UP past what
56
+ * streamed events already recorded, so the two sources never double count.
57
+ * Rounds are counted on `assistant/message` events (one per LLM round-trip) —
58
+ * a one-shot child never advances `turn/end` while it works.
56
59
  */
57
- const TURN_RECONCILE_MS = 600;
60
+ const ROUND_RECONCILE_MS = 600;
58
61
  /** Tail marker for `llm/retry` events (no text content — the row shows it is re-working). */
59
62
  const RETRY_MARKER = '↻ retry';
63
+ /**
64
+ * Estimated tokens of a `tool/result` event's text payload (the tool output
65
+ * that enters the next request). Defensive: reads through an `unknown` shape
66
+ * and never throws. The result body lives in `data.message.content[*].content`
67
+ * as `{ type: 'text', text }` blocks (plus any inline `isError` marker).
68
+ */
69
+ function estimateToolResultTokens(data) {
70
+ const content = data.message?.content;
71
+ if (!Array.isArray(content))
72
+ return 0;
73
+ const parts = [];
74
+ for (const block of content) {
75
+ if (block === null || typeof block !== 'object')
76
+ continue;
77
+ const inner = block.content;
78
+ if (!Array.isArray(inner))
79
+ continue;
80
+ for (const item of inner) {
81
+ const b = item;
82
+ if (b.type === 'text' && typeof b.text === 'string')
83
+ parts.push(b.text);
84
+ }
85
+ }
86
+ return estimateTextTokens(parts.join('\n'));
87
+ }
60
88
  /**
61
89
  * Bound of a child's live streaming buffer (text + reasoning deltas): enough
62
90
  * to extract the last content line of any real stream, bounded so a runaway
@@ -149,7 +177,23 @@ export class DshSessionBridge {
149
177
  cacheWriteTokens: 0,
150
178
  msgCount: 0,
151
179
  toolCallCount: 0,
180
+ contextTokens: 0,
152
181
  };
182
+ /**
183
+ * Usage snapshot of the LATEST assistant/message — the exact billed context
184
+ * of the last request (input = uncached, cache read/write billed on top;
185
+ * output = that request's own output). The footer's context segment prices
186
+ * this plus `pendingTokens` (see BridgeStats.contextTokens). `undefined`
187
+ * until the first assistant/message arrives.
188
+ */
189
+ lastUsage;
190
+ /**
191
+ * CJK-estimated tokens of every message appended AFTER the latest
192
+ * assistant/message (user prompts, tool results, streamed text deltas) —
193
+ * they enter the next request. Reset to 0 by the next assistant/message,
194
+ * whose usage snapshot then covers them exactly.
195
+ */
196
+ pendingTokens = 0;
153
197
  /** Live subagent rows keyed by child session id. */
154
198
  agentViews = new Map();
155
199
  /** Child session ids whose own events we fold into the agent views. */
@@ -168,14 +212,49 @@ export class DshSessionBridge {
168
212
  * line and deletes the entry.
169
213
  */
170
214
  childStreams = new Map();
171
- /** Per-child completed turn count — the "rounds" the policy caps. */
172
- turnCounts = new Map();
173
215
  /**
174
- * Per-child session-log length already reconciled (see reconcileChildTurns).
175
- * Incremental high-water, so the reconcile scans only newly-appended events.
216
+ * Per-child assistant-message count — the "rounds" the policy caps. One
217
+ * increment per `assistant/message` (an LLM round-trip), counted on the
218
+ * child's OWN events. Settled children keep counting (a continuable child
219
+ * resumes later), so the count stays authoritative for the viewer and the
220
+ * wrap-up policy.
221
+ */
222
+ roundCounts = new Map();
223
+ /**
224
+ * The EVENT path's own absolute per-child assistant-message count — its
225
+ * ledger of streamed `assistant/message` events it has actually received.
226
+ * The reconcile derives its own count from the session log; the displayed
227
+ * "rounds" is `max(streamed, reconciled)` (see foldTracked). Keeping the
228
+ * two ledgers separate is what makes a message the reconcile already
229
+ * counted immune to a second count when its streamed event arrives late.
230
+ */
231
+ streamedRoundCounts = new Map();
232
+ /**
233
+ * Per-child usage snapshot of the latest assistant/message (see
234
+ * `lastUsage` for the main session) — the exact billed context of the
235
+ * child's last request, the `contextTokens` baseline.
236
+ */
237
+ childUsage = new Map();
238
+ /**
239
+ * Per-child CJK-estimated tokens of messages appended after the latest
240
+ * assistant/message (see `pendingTokens` for the main session) — the
241
+ * `contextTokens` increment that makes the compact `X/Y` follow live.
242
+ */
243
+ childPending = new Map();
244
+ /**
245
+ * Per-child dsh-dcp compaction count — one per `user/message` notice whose
246
+ * source is `{ kind: 'plugin', plugin: 'dsh-dcp', form: 'notice' }`. The
247
+ * viewer's picker rows read this via `getChildCompactionCount` so the user
248
+ * sees at a glance whether DCP actually compacted inside a child.
249
+ */
250
+ childCompactionCounts = new Map();
251
+ /**
252
+ * Per-child session-log length already reconciled (see
253
+ * reconcileChildRounds). Incremental high-water, so the reconcile scans
254
+ * only newly-appended events.
176
255
  */
177
256
  reconciledLen = new Map();
178
- /** Per-child turn count derived from the session log by the reconcile. */
257
+ /** Per-child round count derived from the session log by the reconcile. */
179
258
  reconciledCount = new Map();
180
259
  /**
181
260
  * Session ids that can own tracked children (this session first, then every
@@ -203,20 +282,40 @@ export class DshSessionBridge {
203
282
  const sessionKey = String(session.id);
204
283
  // Discover subagent children by session header — the deployment may
205
284
  // never emit tool-workflow events (the firehose is not scope-filtered,
206
- // so child sessions arrive here too). Any session whose parent is a
207
- // tracked session is a child; the descriptor event later refines the
208
- // label/provider.
285
+ // so child sessions arrive here too). A session whose parent is a
286
+ // tracked session AND is marked as delegated is a child; the descriptor
287
+ // event later refines the label/provider.
288
+ //
289
+ // Guard: a child is `origin: 'subagent'` OR a delegation budget
290
+ // `delegationDepth > 0`. Both markers are written together by dsh's
291
+ // childSessionMeta (spawn AND in-process fork children alike), so in
292
+ // practice the origin alone carries the decision; the budget clause
293
+ // only future-proofs the gate against a child that carries the budget
294
+ // without the origin. The budget test MUST be a VALUE test, not a
295
+ // field-presence test: the jsonl persistence backend materialises
296
+ // `delegationDepth: 0` on every restored header (write `?? 0`, read
297
+ // unconditionally), so a presence test would pull user-facing
298
+ // `Session.fork` conversations and other non-child restored sessions
299
+ // onto the live board / Ctrl+G.
209
300
  const header = session.header;
210
- if (header?.origin === 'subagent' && header.parentSession !== undefined
301
+ if (header?.parentSession !== undefined
302
+ && (header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0)
211
303
  && this.trackedSessions.has(String(header.parentSession))
212
304
  && !this.agentViews.has(sessionKey)) {
213
305
  this.agentViews.set(sessionKey, {
214
306
  childId: sessionKey,
215
307
  parentSession: String(header.parentSession),
216
- label: `subagent ${sessionKey.slice(0, 8)}`,
308
+ // Current dsh children always carry the origin, so in practice the
309
+ // label is `subagent <id8>`; the `fork` label only fires for the
310
+ // defensive budget-without-origin shape above.
311
+ label: header.origin === undefined
312
+ ? `fork ${sessionKey.slice(0, 8)}`
313
+ : `subagent ${sessionKey.slice(0, 8)}`,
217
314
  startedAt: event.time,
218
315
  tokens: 0,
316
+ rounds: 0,
219
317
  retries: 0,
318
+ contextTokens: 0,
220
319
  });
221
320
  this.childSessions.add(sessionKey);
222
321
  this.trackedSessions.add(sessionKey);
@@ -243,33 +342,36 @@ export class DshSessionBridge {
243
342
  this.running = status === 'running';
244
343
  this.callbacks.onStatus(status);
245
344
  }));
246
- // Turn-count reconcile fallback: keep "rounds" and the maxRounds policy live
247
- // even when a child's own events never bubble to this plugin (see
248
- // TURN_RECONCILE_MS). Runs regardless of whether the viewer is open — the
345
+ // Round-count reconcile fallback: keep "rounds" and the maxRounds policy
346
+ // live even when a child's own events never bubble to this plugin (see
347
+ // ROUND_RECONCILE_MS). Runs regardless of whether the viewer is open — the
249
348
  // cap must still fire while the child works in the background.
250
349
  const reconcile = setInterval(() => {
251
350
  try {
252
- this.reconcileChildTurns();
351
+ this.reconcileChildRounds();
253
352
  }
254
353
  catch {
255
354
  // A throwing reconcile must never take the process down.
256
355
  }
257
- }, TURN_RECONCILE_MS);
356
+ }, ROUND_RECONCILE_MS);
258
357
  reconcile.unref?.();
259
358
  this.disposers.push(() => clearInterval(reconcile));
260
359
  }
261
360
  /**
262
- * Re-derive each tracked child's turn count from its own session log
361
+ * Re-derive each tracked child's round count from its own session log
263
362
  * (`ctx.sessions`), the authoritative append-only facts. The firehose
264
363
  * assumption behind discovery/fold is unreliable in some deployments — a
265
364
  * child is listed from the parent's `tool-workflow/agent-start` but its own
266
- * `turn/end` never arrives, which leaves both the rounds display and the
267
- * `maxRounds` policy dead at 0. This corrects the count UP past whatever the
268
- * streamed events already recorded (never down, never re-adding what events
269
- * counted), and fires `onTurnCount` so the cap still guards a child whose
270
- * events were never delivered. Scans only newly-appended events per child.
365
+ * `assistant/message` events never arrive, which leaves both the rounds
366
+ * display and the `maxRounds` policy dead at 0. This corrects the count UP
367
+ * past whatever the streamed events already recorded (never down, never
368
+ * re-adding what events counted), and fires `onRoundCount` so the cap still
369
+ * guards a child whose events were never delivered. Rounds are counted on
370
+ * `assistant/message` (one per LLM round-trip — `turn/end` is structurally
371
+ * inert for a one-shot child that never leaves its single turn). Scans only
372
+ * newly-appended events per child.
271
373
  */
272
- reconcileChildTurns() {
374
+ reconcileChildRounds() {
273
375
  const sessions = this.ctx.sessions;
274
376
  if (sessions === undefined)
275
377
  return;
@@ -290,16 +392,16 @@ export class DshSessionBridge {
290
392
  continue;
291
393
  let added = 0;
292
394
  for (let i = from; i < len; i++) {
293
- if (events[i].type === 'turn/end')
395
+ if (events[i].type === 'assistant/message')
294
396
  added += 1;
295
397
  }
296
398
  const absolute = (this.reconciledCount.get(childId) ?? 0) + added;
297
399
  this.reconciledLen.set(childId, len);
298
400
  this.reconciledCount.set(childId, absolute);
299
- const current = this.turnCounts.get(childId) ?? 0;
401
+ const current = this.roundCounts.get(childId) ?? 0;
300
402
  if (absolute > current) {
301
- this.turnCounts.set(childId, absolute);
302
- this.callbacks.onTurnCount?.(childId, absolute);
403
+ this.roundCounts.set(childId, absolute);
404
+ this.callbacks.onRoundCount?.(childId, absolute);
303
405
  }
304
406
  }
305
407
  }
@@ -323,9 +425,13 @@ export class DshSessionBridge {
323
425
  getChildLog(childId) {
324
426
  return this.childLogs.get(childId) ?? [];
325
427
  }
326
- /** Completed turn count of one child ("rounds"). */
327
- getTurnCount(childId) {
328
- return this.turnCounts.get(childId) ?? 0;
428
+ /** Assistant-message count of one child ("rounds" — the policy cap unit). */
429
+ getRoundCount(childId) {
430
+ return this.roundCounts.get(childId) ?? 0;
431
+ }
432
+ /** dsh-dcp compaction count of one child (viewer picker rows). */
433
+ getChildCompactionCount(childId) {
434
+ return this.childCompactionCounts.get(childId) ?? 0;
329
435
  }
330
436
  /** Whether one child settled (a continuable child may resume later). */
331
437
  isChildSettled(childId) {
@@ -399,7 +505,11 @@ export class DshSessionBridge {
399
505
  this.childSessions.clear();
400
506
  this.childLogs.clear();
401
507
  this.childStreams.clear();
402
- this.turnCounts.clear();
508
+ this.roundCounts.clear();
509
+ this.streamedRoundCounts.clear();
510
+ this.childUsage.clear();
511
+ this.childPending.clear();
512
+ this.childCompactionCounts.clear();
403
513
  this.reconciledLen.clear();
404
514
  this.reconciledCount.clear();
405
515
  this.trackedSessions.clear();
@@ -428,11 +538,18 @@ export class DshSessionBridge {
428
538
  this.stats.msgCount = 0;
429
539
  this.stats.toolCallCount = 0;
430
540
  this.stats.cacheHitRate = undefined;
541
+ this.stats.contextTokens = 0;
542
+ this.lastUsage = undefined;
543
+ this.pendingTokens = 0;
431
544
  this.agentViews.clear();
432
545
  this.childSessions.clear();
433
546
  this.childLogs.clear();
434
547
  this.childStreams.clear();
435
- this.turnCounts.clear();
548
+ this.roundCounts.clear();
549
+ this.streamedRoundCounts.clear();
550
+ this.childUsage.clear();
551
+ this.childPending.clear();
552
+ this.childCompactionCounts.clear();
436
553
  this.reconciledLen.clear();
437
554
  this.reconciledCount.clear();
438
555
  this.trackedSessions.clear();
@@ -514,6 +631,9 @@ export class DshSessionBridge {
514
631
  this.stats.msgCount = 0;
515
632
  this.stats.toolCallCount = 0;
516
633
  this.stats.cacheHitRate = undefined;
634
+ this.stats.contextTokens = 0;
635
+ this.lastUsage = undefined;
636
+ this.pendingTokens = 0;
517
637
  const sessionId = this.sessionId;
518
638
  for (const event of events) {
519
639
  if (event.type === 'assistant/chunk')
@@ -533,6 +653,9 @@ export class DshSessionBridge {
533
653
  switch (event.type) {
534
654
  case 'user/message':
535
655
  this.stats.msgCount += 1;
656
+ // The prompt enters the NEXT request — priced into the occupancy now.
657
+ this.pendingTokens += estimateContentTokens(event.data.content);
658
+ this.stats.contextTokens = this.contextTokens();
536
659
  break;
537
660
  case 'assistant/message': {
538
661
  this.stats.msgCount += 1;
@@ -541,22 +664,83 @@ export class DshSessionBridge {
541
664
  this.stats.toolCallCount += 1;
542
665
  }
543
666
  const usage = event.data.usage;
544
- if (usage === undefined)
545
- break;
546
- this.stats.inputTokens += usage.inputTokens;
547
- this.stats.outputTokens += usage.outputTokens;
548
- this.stats.cacheReadTokens += usage.cacheReadTokens ?? 0;
549
- this.stats.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
550
- const billedInput = this.stats.inputTokens + this.stats.cacheReadTokens + this.stats.cacheWriteTokens;
551
- this.stats.cacheHitRate = billedInput > 0
552
- ? (this.stats.cacheReadTokens / billedInput) * 100
553
- : undefined;
667
+ if (usage !== undefined) {
668
+ // The assembled message finalizes the request: its usage snapshot is
669
+ // now the exact billed context (input + cache + output) and the
670
+ // pending estimate restarts from what follows it. A usage-less
671
+ // message (adapter reported none) leaves the baseline untouched —
672
+ // pending already holds the streamed chunk estimate accumulated for
673
+ // this message, a reasonable stand-in until the next billed message.
674
+ this.lastUsage = usage;
675
+ this.pendingTokens = 0;
676
+ this.stats.inputTokens += usage.inputTokens;
677
+ this.stats.outputTokens += usage.outputTokens;
678
+ this.stats.cacheReadTokens += usage.cacheReadTokens ?? 0;
679
+ this.stats.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
680
+ const billedInput = this.stats.inputTokens + this.stats.cacheReadTokens + this.stats.cacheWriteTokens;
681
+ this.stats.cacheHitRate = billedInput > 0
682
+ ? (this.stats.cacheReadTokens / billedInput) * 100
683
+ : undefined;
684
+ }
685
+ this.stats.contextTokens = this.contextTokens();
686
+ break;
687
+ }
688
+ case 'assistant/chunk': {
689
+ // Streamed output of the in-flight request — the next request's
690
+ // context grows with it, so the occupancy follows the stream live.
691
+ // Both text and reasoning deltas are priced: usage.outputTokens
692
+ // includes reasoning tokens at snapshot time, so the live estimate
693
+ // must count reasoning too to match that accounting (the next billed
694
+ // assistant/message corrects the final value anyway).
695
+ const chunk = event.data.chunk;
696
+ if ((chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') && chunk.text !== '') {
697
+ this.pendingTokens += estimateTextTokens(chunk.text ?? '');
698
+ this.stats.contextTokens = this.contextTokens();
699
+ }
554
700
  break;
555
701
  }
702
+ case 'tool/result':
703
+ // Tool results enter the next request — priced into the occupancy.
704
+ this.pendingTokens += estimateToolResultTokens(event.data);
705
+ this.stats.contextTokens = this.contextTokens();
706
+ break;
556
707
  default:
557
708
  break;
558
709
  }
559
710
  }
711
+ /**
712
+ * The current context-occupancy estimate: the latest assistant/message's
713
+ * billed input (uncached + cache read + cache write) and output, plus the
714
+ * CJK estimate of every message appended after it. Before the first
715
+ * assistant/message the pending estimate alone stands in for the whole
716
+ * current context.
717
+ */
718
+ contextTokens() {
719
+ if (this.lastUsage === undefined)
720
+ return this.pendingTokens;
721
+ return this.lastUsage.inputTokens
722
+ + (this.lastUsage.cacheReadTokens ?? 0)
723
+ + (this.lastUsage.cacheWriteTokens ?? 0)
724
+ + this.lastUsage.outputTokens
725
+ + this.pendingTokens;
726
+ }
727
+ /**
728
+ * The current context-occupancy estimate of one child (the `X` in the
729
+ * compact line's `X/Y`): the child's latest assistant/message billed input
730
+ * + output plus the CJK estimate of messages appended after it. Before the
731
+ * child's first assistant/message the pending estimate alone stands in.
732
+ */
733
+ childContextTokens(childId) {
734
+ const usage = this.childUsage.get(childId);
735
+ const pending = this.childPending.get(childId) ?? 0;
736
+ if (usage === undefined)
737
+ return pending;
738
+ return usage.inputTokens
739
+ + (usage.cacheReadTokens ?? 0)
740
+ + (usage.cacheWriteTokens ?? 0)
741
+ + usage.outputTokens
742
+ + pending;
743
+ }
560
744
  /**
561
745
  * Fold one event of a tracked session into the agent views (workflow events
562
746
  * of the parent log + the child's own log). O(1) per event — Maps/Sets only,
@@ -573,7 +757,9 @@ export class DshSessionBridge {
573
757
  label: event.data.label,
574
758
  startedAt: event.time,
575
759
  tokens: 0,
760
+ rounds: 0,
576
761
  retries: 0,
762
+ contextTokens: 0,
577
763
  });
578
764
  this.runSeqToChild.set(`${event.data.runId}:${event.data.seq}`, event.data.childId);
579
765
  this.childSessions.add(event.data.childId);
@@ -594,13 +780,6 @@ export class DshSessionBridge {
594
780
  }
595
781
  }
596
782
  if (this.childSessions.has(sessionId)) {
597
- // Round counting is view-independent: every completed turn of the child
598
- // is one round, even once the view settled (continuable children resume).
599
- if (event.type === 'turn/end') {
600
- const count = (this.turnCounts.get(sessionId) ?? 0) + 1;
601
- this.turnCounts.set(sessionId, count);
602
- this.callbacks.onTurnCount?.(sessionId, count);
603
- }
604
783
  const view = this.agentViews.get(sessionId);
605
784
  if (view !== undefined) {
606
785
  if (isSubagentDescriptor(event)) {
@@ -617,22 +796,60 @@ export class DshSessionBridge {
617
796
  }
618
797
  }
619
798
  else if (event.type === 'assistant/message') {
799
+ // Round counting is view-independent: every assistant message of the
800
+ // child is one round (one LLM round-trip), counted even once the
801
+ // view settled — continuable children resume. `turn/end` is NOT the
802
+ // round unit: a one-shot child never leaves its single turn, so its
803
+ // rounds would stay frozen at 0 while it works.
804
+ // The event path keeps its OWN absolute streamed count and merges
805
+ // it with the reconcile's log-derived count by max() — the two
806
+ // ledgers never double count. A message the reconcile already
807
+ // counted (its `assistant/message` reached the session log first)
808
+ // must not be counted AGAIN when that same event arrives late here:
809
+ // the reconcile only moves the count up, so a naive `current + 1`
810
+ // would inflate it permanently. Caveat: "absolute" holds only while
811
+ // the firehose delivers each event at most once — there is no seq
812
+ // de-dup, so a redelivered `assistant/message` inflates the
813
+ // streamed ledger permanently (the previous `current + 1` counter
814
+ // had the same exposure).
815
+ const streamed = (this.streamedRoundCounts.get(sessionId) ?? 0) + 1;
816
+ this.streamedRoundCounts.set(sessionId, streamed);
817
+ const count = Math.max(streamed, this.roundCounts.get(sessionId) ?? 0);
818
+ if (count !== (this.roundCounts.get(sessionId) ?? 0)) {
819
+ this.roundCounts.set(sessionId, count);
820
+ this.callbacks.onRoundCount?.(sessionId, count);
821
+ }
620
822
  const usage = event.data.usage;
621
823
  const delta = usage === undefined
622
824
  ? 0
623
825
  : usage.inputTokens + usage.outputTokens
624
826
  + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
827
+ // The assembled message finalizes the child's request: its usage is
828
+ // now the exact billed context baseline and the pending estimate
829
+ // restarts from what follows it. A usage-less message leaves both
830
+ // untouched — pending still holds the streamed chunk estimate
831
+ // accumulated for this message, a reasonable approximation until the
832
+ // next billed message replaces it.
833
+ if (usage !== undefined) {
834
+ this.childUsage.set(sessionId, usage);
835
+ this.childPending.set(sessionId, 0);
836
+ }
625
837
  const lastLine = lastTextLine(event.data);
626
838
  const tokensChanged = delta > 0;
627
839
  const lineChanged = lastLine !== undefined && lastLine !== view.lastLine;
628
- if (tokensChanged || lineChanged) {
629
- // One set: neither delta nor lastLine may clobber the other. The
630
- // assembled message is authoritative for the content tail — its
631
- // streaming buffer is spent.
840
+ const roundsChanged = count !== view.rounds;
841
+ const contextTokens = this.childContextTokens(sessionId);
842
+ const contextChanged = contextTokens !== view.contextTokens;
843
+ if (tokensChanged || lineChanged || roundsChanged || contextChanged) {
844
+ // One set: neither delta nor lastLine nor rounds nor contextTokens
845
+ // may clobber the others. The assembled message is authoritative
846
+ // for the content tail — its streaming buffer is spent.
632
847
  this.agentViews.set(sessionId, {
633
848
  ...view,
634
849
  ...(tokensChanged ? { tokens: view.tokens + delta } : {}),
635
850
  ...(lineChanged ? { lastLine } : {}),
851
+ ...(roundsChanged ? { rounds: count } : {}),
852
+ ...(contextChanged ? { contextTokens } : {}),
636
853
  });
637
854
  changed = true;
638
855
  }
@@ -651,13 +868,54 @@ export class DshSessionBridge {
651
868
  const buffer = bumpChildStream(this.childStreams.get(sessionId) ?? '', delta);
652
869
  this.childStreams.set(sessionId, buffer);
653
870
  const lastLine = lastBufferLine(buffer);
654
- if (lastLine !== undefined && lastLine !== view.lastLine) {
655
- this.agentViews.set(sessionId, { ...view, lastLine });
871
+ // Streamed output (text AND reasoning) grows the child's current
872
+ // context too — usage.outputTokens includes reasoning tokens at
873
+ // snapshot, so the live estimate prices both; the compact X/Y
874
+ // follows live.
875
+ const wasPending = this.childPending.get(sessionId) ?? 0;
876
+ const lineChanged = lastLine !== undefined && lastLine !== view.lastLine;
877
+ // The outer guard already narrowed to text/reasoning deltas, so
878
+ // this branch is unconditional.
879
+ this.childPending.set(sessionId, wasPending + estimateTextTokens(delta));
880
+ const contextTokens = this.childContextTokens(sessionId);
881
+ const contextChanged = contextTokens !== view.contextTokens;
882
+ if (lineChanged || contextChanged) {
883
+ this.agentViews.set(sessionId, {
884
+ ...view,
885
+ ...(lineChanged ? { lastLine } : {}),
886
+ ...(contextChanged ? { contextTokens } : {}),
887
+ });
656
888
  changed = true;
657
889
  }
658
890
  }
659
891
  }
660
892
  }
893
+ else if (event.type === 'user/message') {
894
+ // The child's own prompts/injects enter its next request — the
895
+ // occupancy follows (the content tail is never touched here). A
896
+ // dsh-dcp compaction notice (plugin `notice` form) also tallies the
897
+ // per-child compaction count the viewer's picker rows read.
898
+ if (isDcpCompactionNotice(event)) {
899
+ this.childCompactionCounts.set(sessionId, (this.childCompactionCounts.get(sessionId) ?? 0) + 1);
900
+ }
901
+ this.childPending.set(sessionId, (this.childPending.get(sessionId) ?? 0)
902
+ + estimateContentTokens(event.data.content));
903
+ const contextTokens = this.childContextTokens(sessionId);
904
+ if (contextTokens !== view.contextTokens) {
905
+ this.agentViews.set(sessionId, { ...view, contextTokens });
906
+ changed = true;
907
+ }
908
+ }
909
+ else if (event.type === 'tool/result') {
910
+ // Tool results enter the child's next request — the occupancy
911
+ // follows (the content tail shows the child's own output only).
912
+ this.childPending.set(sessionId, (this.childPending.get(sessionId) ?? 0) + estimateToolResultTokens(event.data));
913
+ const contextTokens = this.childContextTokens(sessionId);
914
+ if (contextTokens !== view.contextTokens) {
915
+ this.agentViews.set(sessionId, { ...view, contextTokens });
916
+ changed = true;
917
+ }
918
+ }
661
919
  else if (isLlmRetry(event)) {
662
920
  // No text content — surface a fixed marker so the row shows the
663
921
  // child is (re)working. Merged into one set so the retry counters