@thehammer/danx-session-telemetry 0.1.27 → 0.1.31

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/README.md CHANGED
@@ -17,6 +17,10 @@ inside the MCP package.
17
17
  shape. Canonical home now; danxbot root's `src/types.ts` re-exports both
18
18
  names verbatim.
19
19
  - `tool-summary.ts` — human-readable summary builders the collector uses.
20
+ - `subagent-live.ts` — `createSubagentLiveView` (DX-4508): per-sub-agent snapshots
21
+ (tokens, cost, tool calls, model, current activity, card) computed by the same
22
+ activity observer, ledger counter and pricing rule the dashboard's numbers come
23
+ from; streamed by `danx-dashboard-mcp subagents-live`.
20
24
  - `internal-logger.ts` — this package's own tiny logging shim (see its
21
25
  header for why it does not reuse root's `src/logger.ts`).
22
26
  - `index.ts` — the main entry (Node-only; imports `node:fs`/`node:path`/`node:os`).
package/dist/.source-hash CHANGED
@@ -1 +1 @@
1
- 46e2f294fe40d0bc967337a16d25bee3eeee3a6d9dae5af7ee213a4d519175f3
1
+ ade91474df465c805cff25ba35ef8265728fbf4a6649e43ea3401b5812ed943c
@@ -37,7 +37,7 @@
37
37
  * | `lastActivityAt` | the timestamp of its latest `assistant` / `user` line |
38
38
  * | `effort` | its latest `assistant` line's top-level `effort` |
39
39
  * | `currentActivity` | its latest `tool_use` block, through {@link describeToolCall} |
40
- * | `briefIssueId` | the card id its brief (the root `user` line) opens with |
40
+ * | `briefIssueId` | the card its spawning `Agent` call names: the first card id in its `description` (redacted), else the first anywhere in its brief (the root `user` line) — see {@link firstIssueId} (DX-4508) |
41
41
  * | `finishedAt`, `endStatus` | the task notification Claude Code writes into the SPAWNER's transcript |
42
42
  *
43
43
  * Model, tokens, cost, tool-call count and the claimed card are NOT here: the
@@ -129,9 +129,11 @@ export declare function labelOf(value: unknown): string | null;
129
129
  */
130
130
  export declare function describeToolCall(name: string, input: Record<string, unknown>): string;
131
131
  /**
132
- * DX-4498 — the card id a brief opens with: the first token of its first non-empty line, when that token is shaped
133
- * like a card id (`DX-4498`, also `DX-4498:`). Worker briefs open with the card they are for; any other brief names
134
- * none. The server keeps it only when it names a real card of the team, so a plan ref (`PLAN-2`) never reaches a reader.
132
+ * DX-4508 — the first card-id-shaped token in a text (`DX-4498`, also `DX-4498:`, `(DX-4498)`), or null when it has
133
+ * none. A sub-agent's card is the one its spawning `Agent` call names: its `description` ("Review DX-4498 code gates")
134
+ * first, else its brief ("Quality gate for card DX-4421 ..."), where the first-word rule DX-4498 shipped missed most
135
+ * real briefs. Only the SHAPE is checked: the server keeps the id only when it is a real card of the team, so a plan ref
136
+ * (`PLAN-2`) or rule ref (`R-45`) never reaches a reader.
135
137
  */
136
- export declare function briefIssueId(brief: string): string | null;
138
+ export declare function firstIssueId(text: string): string | null;
137
139
  export declare function createActivityObserver(deps: ActivityObserverDeps): ActivityObserver;
@@ -37,7 +37,7 @@
37
37
  * | `lastActivityAt` | the timestamp of its latest `assistant` / `user` line |
38
38
  * | `effort` | its latest `assistant` line's top-level `effort` |
39
39
  * | `currentActivity` | its latest `tool_use` block, through {@link describeToolCall} |
40
- * | `briefIssueId` | the card id its brief (the root `user` line) opens with |
40
+ * | `briefIssueId` | the card its spawning `Agent` call names: the first card id in its `description` (redacted), else the first anywhere in its brief (the root `user` line) — see {@link firstIssueId} (DX-4508) |
41
41
  * | `finishedAt`, `endStatus` | the task notification Claude Code writes into the SPAWNER's transcript |
42
42
  *
43
43
  * Model, tokens, cost, tool-call count and the claimed card are NOT here: the
@@ -188,16 +188,19 @@ export function describeToolCall(name, input) {
188
188
  return text.length > CURRENT_ACTIVITY_MAX_CHARS ? `${text.slice(0, CURRENT_ACTIVITY_MAX_CHARS - 3)}...` : text;
189
189
  }
190
190
  /**
191
- * DX-4498 — the card id a brief opens with: the first token of its first non-empty line, when that token is shaped
192
- * like a card id (`DX-4498`, also `DX-4498:`). Worker briefs open with the card they are for; any other brief names
193
- * none. The server keeps it only when it names a real card of the team, so a plan ref (`PLAN-2`) never reaches a reader.
191
+ * DX-4508 — the first card-id-shaped token in a text (`DX-4498`, also `DX-4498:`, `(DX-4498)`), or null when it has
192
+ * none. A sub-agent's card is the one its spawning `Agent` call names: its `description` ("Review DX-4498 code gates")
193
+ * first, else its brief ("Quality gate for card DX-4421 ..."), where the first-word rule DX-4498 shipped missed most
194
+ * real briefs. Only the SHAPE is checked: the server keeps the id only when it is a real card of the team, so a plan ref
195
+ * (`PLAN-2`) or rule ref (`R-45`) never reaches a reader.
194
196
  */
195
- export function briefIssueId(brief) {
196
- const firstLine = brief.split("\n").find((line) => line.trim() !== "");
197
- if (firstLine === undefined)
198
- return null;
199
- const token = firstLine.trim().split(/\s+/)[0].replace(/[.,:;)]+$/, "");
200
- return token.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(token) ? token : null;
197
+ export function firstIssueId(text) {
198
+ for (const token of text.split(/\s+/)) {
199
+ const bare = token.replace(WRAPPING_PUNCTUATION, "");
200
+ if (bare.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(bare))
201
+ return bare;
202
+ }
203
+ return null;
201
204
  }
202
205
  /** The text of a `user` line's content: the string itself, or its first text block. */
203
206
  function userText(raw) {
@@ -276,6 +279,10 @@ export function createActivityObserver(deps) {
276
279
  if (existing.description === null && description !== null) {
277
280
  existing.description = description;
278
281
  filled = true;
282
+ // DX-4508 — the description outranks the brief: a late sidecar naming a card replaces a brief-derived id.
283
+ const named = firstIssueId(description);
284
+ if (named !== null && named !== existing.briefIssueId)
285
+ existing.briefIssueId = named;
279
286
  }
280
287
  if (existing.parentActivityId === null && info.parentSource !== null) {
281
288
  existing.parentActivityId = info.parentSource;
@@ -285,12 +292,13 @@ export function createActivityObserver(deps) {
285
292
  report(existing);
286
293
  return;
287
294
  }
295
+ // DX-4498 — the label its spawning `Agent` call gave it, from the sidecar, redacted like every label.
296
+ const description = labelOf(info.description);
288
297
  const change = {
289
298
  kind: "agent",
290
299
  activityId: info.source,
291
300
  agentType: info.agentType,
292
- // DX-4498 — the label its spawning `Agent` call gave it, from the sidecar, redacted like every label.
293
- description: labelOf(info.description),
301
+ description,
294
302
  startedAt: info.discoveredAt,
295
303
  lastActivityAt: info.discoveredAt,
296
304
  finishedAt: null,
@@ -298,7 +306,9 @@ export function createActivityObserver(deps) {
298
306
  effort: null,
299
307
  currentActivity: null,
300
308
  endStatus: null,
301
- briefIssueId: null,
309
+ // DX-4508 — the card the description names (the REDACTED label: the one way a label leaves a transcript); the
310
+ // brief fills it in only while this is still null.
311
+ briefIssueId: description === null ? null : firstIssueId(description),
302
312
  };
303
313
  const pending = pendingEnds.get(info.source);
304
314
  if (pending !== undefined) {
@@ -355,10 +365,10 @@ export function createActivityObserver(deps) {
355
365
  }
356
366
  if (raw.type === "user") {
357
367
  // DX-4498 — the brief is the chain's root line (`parentUuid: null`), so a collector that resumes mid-file
358
- // never mistakes a later prompt for it.
368
+ // never mistakes a later prompt for it. DX-4508 — read only while no card is known: a description's id wins.
359
369
  if (change.briefIssueId === null && raw.parentUuid === null) {
360
370
  const text = userText(raw);
361
- const id = text === null ? null : briefIssueId(text);
371
+ const id = text === null ? null : firstIssueId(text);
362
372
  if (id !== null) {
363
373
  change.briefIssueId = id;
364
374
  changed = true;
@@ -1,5 +1,9 @@
1
- /** The one tool a sub-agent reaches the dashboard API through (the MCP server key is `danx-dashboard`). */
2
- export declare const DASHBOARD_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
1
+ /** The `danxbot_api` tool of a dispatched worker's `danx-dashboard` server. */
2
+ export declare const DISPATCH_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
3
+ /** The `danxbot_api` tool of the danxbot plugin's server, which an operator session's sub-agent calls. */
4
+ export declare const PLUGIN_API_TOOL_NAME = "mcp__plugin_danxbot_danx-dashboard__danxbot_api";
5
+ /** Every tool name a sub-agent reaches the dashboard API through. */
6
+ export declare const DASHBOARD_API_TOOL_NAMES: ReadonlySet<string>;
3
7
  /** A card id as the issue routes name it, e.g. `DX-3962`. Also the wire's validation for every card id a claim carries. */
4
8
  export declare const ISSUE_ID_PATTERN: RegExp;
5
9
  /** Longest card id a claim may carry (`dispatches.issue_id` is `VARCHAR(32)`). */
@@ -46,8 +46,14 @@
46
46
  * Pure bookkeeping — no `node:` imports, so it rides the browser-safe subpath too.
47
47
  */
48
48
  import { MAIN_SOURCE } from "./usage-ledger.js";
49
- /** The one tool a sub-agent reaches the dashboard API through (the MCP server key is `danx-dashboard`). */
50
- export const DASHBOARD_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
49
+ // DX-4578: both names are live, neither a fallback. A dispatched worker's clean-room config names the server `danx-dashboard`; an operator
50
+ // session's sub-agent reaches it through the danxbot plugin's server (`plugin:danxbot:danx-dashboard`, tools `mcp__plugin_danxbot_danx-dashboard__*`).
51
+ /** The `danxbot_api` tool of a dispatched worker's `danx-dashboard` server. */
52
+ export const DISPATCH_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
53
+ /** The `danxbot_api` tool of the danxbot plugin's server, which an operator session's sub-agent calls. */
54
+ export const PLUGIN_API_TOOL_NAME = "mcp__plugin_danxbot_danx-dashboard__danxbot_api";
55
+ /** Every tool name a sub-agent reaches the dashboard API through. */
56
+ export const DASHBOARD_API_TOOL_NAMES = new Set([DISPATCH_API_TOOL_NAME, PLUGIN_API_TOOL_NAME]);
51
57
  /** A card id as the issue routes name it, e.g. `DX-3962`. Also the wire's validation for every card id a claim carries. */
52
58
  export const ISSUE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9]*-[0-9]+$/;
53
59
  /** Longest card id a claim may carry (`dispatches.issue_id` is `VARCHAR(32)`). */
@@ -102,7 +108,7 @@ function resultSucceeded(block) {
102
108
  }
103
109
  /** The transition a `tool_use` block asks for, or `null` when it is not a claim or release of a card. */
104
110
  function transitionOf(block) {
105
- if (block.type !== "tool_use" || block.name !== DASHBOARD_API_TOOL_NAME)
111
+ if (block.type !== "tool_use" || typeof block.name !== "string" || !DASHBOARD_API_TOOL_NAMES.has(block.name))
106
112
  return null;
107
113
  const input = block.input;
108
114
  if (input === null || typeof input !== "object")
package/dist/index.d.ts CHANGED
@@ -26,4 +26,5 @@ export * from "./stats-wire.js";
26
26
  export * from "./claim-tracker.js";
27
27
  export * from "./dispatch-usage.js";
28
28
  export * from "./activity-observer.js";
29
+ export * from "./subagent-live.js";
29
30
  export * from "./write-queue.js";
package/dist/index.js CHANGED
@@ -37,4 +37,6 @@ export * from "./stats-wire.js";
37
37
  export * from "./claim-tracker.js";
38
38
  export * from "./dispatch-usage.js";
39
39
  export * from "./activity-observer.js";
40
+ // DX-4508 — the local live sub-agent view `danx-dashboard-mcp subagents-live` streams to the plugin pane.
41
+ export * from "./subagent-live.js";
40
42
  export * from "./write-queue.js";
package/dist/pricing.d.ts CHANGED
@@ -102,3 +102,16 @@ export type PriceResult = {
102
102
  export declare function priceUsage(model: string, speed: string, tokens: PricedTokens): PriceResult;
103
103
  /** True when `model` has a standard-speed price (used by tests + guards). */
104
104
  export declare function isPricedModel(model: string): boolean;
105
+ /**
106
+ * DX-4508 — one ledger credit's cost, exactly as the server's usage-ledger writer stores it (moved here from
107
+ * `src/dashboard/usage-ledger.ts` so the live sub-agent view, `subagent-live.ts`, prices a reply with the SAME rule
108
+ * instead of a copy): a `<synthetic>` reply costs nothing and is never unpriced; a reply with no model is unpriced only
109
+ * when it carries tokens; any other model goes through {@link priceUsage}, an unpriced one costing 0 and naming itself.
110
+ */
111
+ export declare function priceLedgerCredit(model: string | null, speed: string, tokens: PricedTokens): {
112
+ costUsd: number;
113
+ unpriced: {
114
+ model: string;
115
+ speed: string;
116
+ } | null;
117
+ };
package/dist/pricing.js CHANGED
@@ -170,3 +170,19 @@ export function isPricedModel(model) {
170
170
  // `Object.hasOwn` can't be fooled by it. See `priceUsage`'s own comment.
171
171
  return Object.hasOwn(PRICING, model);
172
172
  }
173
+ /**
174
+ * DX-4508 — one ledger credit's cost, exactly as the server's usage-ledger writer stores it (moved here from
175
+ * `src/dashboard/usage-ledger.ts` so the live sub-agent view, `subagent-live.ts`, prices a reply with the SAME rule
176
+ * instead of a copy): a `<synthetic>` reply costs nothing and is never unpriced; a reply with no model is unpriced only
177
+ * when it carries tokens; any other model goes through {@link priceUsage}, an unpriced one costing 0 and naming itself.
178
+ */
179
+ export function priceLedgerCredit(model, speed, tokens) {
180
+ if (model === SYNTHETIC_MODEL)
181
+ return { costUsd: 0, unpriced: null };
182
+ if (model === null) {
183
+ const zero = tokens.input === 0 && tokens.output === 0 && tokens.cacheRead === 0 && tokens.cacheWrite5m === 0 && tokens.cacheWrite1h === 0;
184
+ return zero ? { costUsd: 0, unpriced: null } : { costUsd: 0, unpriced: { model: "(none)", speed } };
185
+ }
186
+ const r = priceUsage(model, speed, tokens);
187
+ return r.kind === "priced" ? { costUsd: r.costUsd, unpriced: null } : { costUsd: 0, unpriced: { model: r.model, speed: r.speed } };
188
+ }
@@ -127,6 +127,12 @@ export interface SessionLogWatcherOptions {
127
127
  * within it.
128
128
  */
129
129
  onSourceDiscovered?: (info: SourceDiscoveredInfo) => void;
130
+ /**
131
+ * DX-4508 — one poll (the main transcript, sub-agent discovery, then every sub-agent file) has finished handing its
132
+ * lines to subscribers. The live sub-agent view (`danx-dashboard-mcp subagents-live`) prints one line per poll from
133
+ * it, so a reader sees every change one poll made together. A throw is contained like every consumer's.
134
+ */
135
+ onPollComplete?: () => void;
130
136
  }
131
137
  /** DX-2954 — one source's stored cursor pair, as a collector starts from it. */
132
138
  export interface SourceCursorStart {
@@ -344,6 +350,7 @@ export declare class SessionLogWatcher {
344
350
  private readonly startExistingFilesAtEof;
345
351
  private readonly onCursorPastEof;
346
352
  private readonly onSourceDiscovered;
353
+ private readonly onPollComplete;
347
354
  private subagentDiscoveryDone;
348
355
  private running;
349
356
  private polling;
@@ -495,6 +495,7 @@ export class SessionLogWatcher {
495
495
  startExistingFilesAtEof;
496
496
  onCursorPastEof;
497
497
  onSourceDiscovered;
498
+ onPollComplete;
498
499
  subagentDiscoveryDone = false;
499
500
  running = false;
500
501
  polling = false;
@@ -514,6 +515,7 @@ export class SessionLogWatcher {
514
515
  this.startExistingFilesAtEof = options.startExistingFilesAtEof ?? false;
515
516
  this.onCursorPastEof = options.onCursorPastEof;
516
517
  this.onSourceDiscovered = options.onSourceDiscovered;
518
+ this.onPollComplete = options.onPollComplete;
517
519
  }
518
520
  /**
519
521
  * DX-2954 — resolve one file's starting offset and replay boundary, per the
@@ -795,6 +797,13 @@ export class SessionLogWatcher {
795
797
  finally {
796
798
  this.polling = false;
797
799
  }
800
+ // DX-4508 — only a poll that ran to its end: a stop mid-poll returns above and reports nothing.
801
+ try {
802
+ this.onPollComplete?.();
803
+ }
804
+ catch (err) {
805
+ log.error("onPollComplete consumer error:", err);
806
+ }
798
807
  }
799
808
  /** Read any new bytes from `state.filePath` and emit resulting entries. */
800
809
  async pollFile(state) {
@@ -118,9 +118,11 @@ export interface ActivityChange {
118
118
  currentActivity: string | null;
119
119
  /** DX-4498 — how it ended, when an explicit end event named it; null while running and for an end that named none. */
120
120
  endStatus: ActivityEndStatus | null;
121
- /** DX-4498 — the card id its brief opens with (`DX-4498
122
-
123
- ...`), or null when the brief names none. */
121
+ /**
122
+ * DX-4508 — the card its spawning `Agent` call names: the first card id in its (redacted) `description`, else the
123
+ * first anywhere in its brief; null when neither names one. The name stays `briefIssueId` (and the column
124
+ * `brief_issue_id`): renaming a required wire field would make every bridge already published fail the strict parser.
125
+ */
124
126
  briefIssueId: string | null;
125
127
  }
126
128
  /**
@@ -122,8 +122,9 @@ export function collapseActivityChanges(changes) {
122
122
  // DX-4498 — COALESCE(incoming, stored): the latest explicit end status wins (a notification read after the session's
123
123
  // own close names how it really ended).
124
124
  endStatus: c.endStatus ?? prior.endStatus,
125
- // DX-4498 — COALESCE(stored, incoming): the brief is the transcript's first line, read once.
126
- briefIssueId: prior.briefIssueId ?? c.briefIssueId,
125
+ // DX-4508 — COALESCE(incoming, stored): the producer reports its best id and may improve it (a late sidecar's
126
+ // description outranks the brief); a null never erases one.
127
+ briefIssueId: c.briefIssueId ?? prior.briefIssueId,
127
128
  });
128
129
  }
129
130
  return [...byKey.values()];
@@ -0,0 +1,34 @@
1
+ import type { EntryMeta, SourceDiscoveredInfo } from "./session-log-watcher.js";
2
+ import type { ActivityEndStatus } from "./stats-wire.js";
3
+ /** One sub-agent as the live view reports it. Every time is epoch ms. The plugin pane renders exactly this shape. */
4
+ export interface SubagentLiveSnapshot {
5
+ /** `agent-<agentId>`: its transcript basename, the ledger source key and the dashboard row's activity id. */
6
+ id: string;
7
+ /** The sub-agent that spawned it, or null for one the session itself spawned. */
8
+ parentId: string | null;
9
+ description: string | null;
10
+ agentType: string | null;
11
+ /** Its newest reply's model, never `<synthetic>`; null before its first reply. */
12
+ model: string | null;
13
+ effort: string | null;
14
+ startedAt: number;
15
+ lastActivityAt: number;
16
+ finishedAt: number | null;
17
+ endStatus: ActivityEndStatus | null;
18
+ /** input + output + cacheRead + cacheWrite5m + cacheWrite1h, summed over its replies. */
19
+ tokensTotal: number;
20
+ costUsd: number;
21
+ toolCallCount: number;
22
+ currentActivity: string | null;
23
+ /** The card its spawning `Agent` call names (description first, then brief); shape-checked only. */
24
+ cardId: string | null;
25
+ }
26
+ export interface SubagentLiveView {
27
+ /** Wire to the watcher's `onSourceDiscovered`. */
28
+ onSourceDiscovered: (info: SourceDiscoveredInfo) => void;
29
+ /** Wire to the watcher's `onUsageLine` (the raw line feed, main transcript included: it carries task notifications). */
30
+ onLine: (raw: Record<string, unknown>, meta: EntryMeta) => void;
31
+ /** The snapshots that changed since the last call (each differs from the one last returned for it); clears the set. */
32
+ takeChanged: () => SubagentLiveSnapshot[];
33
+ }
34
+ export declare function createSubagentLiveView(): SubagentLiveView;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * DX-4508 — a LOCAL live view of a session's sub-agents, for the plugin's plan pane.
3
+ *
4
+ * The pane shows one card per sub-agent: what it is, what it is doing, and what it has spent. Its dashboard path
5
+ * (`GET /api/plan-sessions/:id/subagents`) lags about 80s behind the transcript (the bridge's 5s poll, its 15s
6
+ * heartbeat flush, the pane's 60s read), so the same facts are also computed here, on the operator's own machine, from
7
+ * the transcript files themselves, and streamed by `danx-dashboard-mcp subagents-live` within a poll of being written.
8
+ *
9
+ * ## One place computes each number
10
+ *
11
+ * Nothing here counts or prices on its own. It consumes the SAME two watcher feeds the bridge wires
12
+ * (`onSourceDiscovered`, `onUsageLine`) into the SAME producers:
13
+ *
14
+ * | Snapshot field | Computed by | As the dashboard read derives it from |
15
+ * |---|---|---|
16
+ * | `description`, `agentType`, `parentId`, `effort`, `currentActivity`, `finishedAt`, `endStatus`, `cardId` | {@link createActivityObserver} (`cardId` is its `briefIssueId`) | the `dispatch_activities` row the same observer feeds |
17
+ * | `tokensTotal` | {@link UsageLedgerCounter} (per-reply per-field maximum) via {@link ledgerLineFromRaw} | `usage_by_source[<id>]`, all five fields summed |
18
+ * | `costUsd` | {@link priceLedgerCredit} over each reply | `usage_by_source[<id>].costUsd` (the ledger writer prices each credit with it) |
19
+ * | `toolCallCount` | the counter's distinct `tool_use` ids per reply | `SUM(cardinality(tool_use_ids))` |
20
+ * | `model` | the counter's newest reply whose model is not {@link SYNTHETIC_MODEL} | the newest non-synthetic `dispatch_message_usage.model` |
21
+ *
22
+ * Cost is priced per reply over the reply's whole usage, where the server prices each CREDIT (the excess a resend
23
+ * adds); pricing is linear, so the two agree whenever a reply keeps one model and speed, which Claude Code always writes.
24
+ *
25
+ * ## Where the live view differs from the observer's own stamps
26
+ *
27
+ * `startedAt` and `lastActivityAt` come from the sub-agent's own transcript lines (its first and newest `assistant` /
28
+ * `user` line). The observer stamps a start at the moment its watcher DISCOVERED the file, which is right for the bridge
29
+ * (its first discovery is the real start, and the server keeps the earliest), but this view reads every file from byte
30
+ * 0 each time it starts, so a discovery stamp would show every finished sub-agent as having started, and been active,
31
+ * just now. The discovery stamp is used only for a sub-agent that has written no line yet.
32
+ *
33
+ * ## Memory
34
+ *
35
+ * Per sub-agent: the observer's one small state record, and the counter's one small entry per reply. No transcript
36
+ * text is held; the watcher reads each file's new bytes and drops them once parsed.
37
+ */
38
+ import { createActivityObserver } from "./activity-observer.js";
39
+ import { SYNTHETIC_MODEL, priceLedgerCredit } from "./pricing.js";
40
+ import { MAIN_SOURCE, UsageLedgerCounter, ledgerLineFromRaw, totalTokensOfUsage } from "./usage-ledger.js";
41
+ /** The line's own timestamp in epoch ms, or null when it carries none that parses. */
42
+ function lineTime(raw) {
43
+ if (typeof raw.timestamp !== "string")
44
+ return null;
45
+ const ms = Date.parse(raw.timestamp);
46
+ return Number.isNaN(ms) ? null : ms;
47
+ }
48
+ export function createSubagentLiveView() {
49
+ const sources = new Map();
50
+ const dirty = new Set();
51
+ const observer = createActivityObserver({
52
+ // The observer reports a source's FULL current state on every change.
53
+ recordActivities: (changes) => {
54
+ for (const change of changes) {
55
+ const state = sources.get(change.activityId);
56
+ if (state === undefined) {
57
+ sources.set(change.activityId, {
58
+ activity: change,
59
+ counter: new UsageLedgerCounter(),
60
+ firstLineAt: null,
61
+ newestLineAt: null,
62
+ lastSent: null,
63
+ });
64
+ }
65
+ else {
66
+ state.activity = change;
67
+ }
68
+ dirty.add(change.activityId);
69
+ }
70
+ },
71
+ });
72
+ const snapshot = (state) => {
73
+ let costUsd = 0;
74
+ let toolCallCount = 0;
75
+ let model = null;
76
+ let modelAt = -Infinity;
77
+ for (const entry of state.counter.all()) {
78
+ costUsd += priceLedgerCredit(entry.model, entry.speed, entry.usage).costUsd;
79
+ toolCallCount += entry.toolUseIds.length;
80
+ if (entry.model !== null && entry.model !== SYNTHETIC_MODEL && entry.repliedAt >= modelAt) {
81
+ model = entry.model;
82
+ modelAt = entry.repliedAt;
83
+ }
84
+ }
85
+ const a = state.activity;
86
+ const startedAt = state.firstLineAt ?? a.startedAt;
87
+ return {
88
+ id: a.activityId,
89
+ parentId: a.parentActivityId,
90
+ description: a.description,
91
+ agentType: a.agentType,
92
+ model,
93
+ effort: a.effort,
94
+ startedAt,
95
+ lastActivityAt: state.newestLineAt ?? startedAt,
96
+ finishedAt: a.finishedAt,
97
+ endStatus: a.endStatus,
98
+ tokensTotal: totalTokensOfUsage(state.counter.totals()),
99
+ costUsd,
100
+ toolCallCount,
101
+ currentActivity: a.currentActivity,
102
+ cardId: a.briefIssueId,
103
+ };
104
+ };
105
+ return {
106
+ onSourceDiscovered: observer.onSourceDiscovered,
107
+ onLine(raw, meta) {
108
+ // The observer first: it reads the main transcript's task notifications, and a line the ledger refuses (no
109
+ // timestamp) must not withhold them.
110
+ observer.onLine(raw, meta);
111
+ if (meta.source === MAIN_SOURCE)
112
+ return;
113
+ const state = sources.get(meta.source);
114
+ if (state === undefined)
115
+ return;
116
+ if (raw.type === "assistant" || raw.type === "user") {
117
+ const at = lineTime(raw);
118
+ if (at !== null) {
119
+ if (state.firstLineAt === null || at < state.firstLineAt) {
120
+ state.firstLineAt = at;
121
+ dirty.add(meta.source);
122
+ }
123
+ if (state.newestLineAt === null || at > state.newestLineAt) {
124
+ state.newestLineAt = at;
125
+ dirty.add(meta.source);
126
+ }
127
+ }
128
+ }
129
+ const line = ledgerLineFromRaw(raw, meta.source, meta.byteOffset);
130
+ if (line !== null && state.counter.observeLine(line))
131
+ dirty.add(meta.source);
132
+ },
133
+ takeChanged() {
134
+ const out = [];
135
+ for (const source of dirty) {
136
+ const state = sources.get(source);
137
+ if (state === undefined)
138
+ continue;
139
+ const snap = snapshot(state);
140
+ const json = JSON.stringify(snap);
141
+ if (json === state.lastSent)
142
+ continue;
143
+ state.lastSent = json;
144
+ out.push(snap);
145
+ }
146
+ dirty.clear();
147
+ return out;
148
+ },
149
+ };
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.27",
3
+ "version": "0.1.31",
4
4
  "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp desktop bridge, so both run identical collector code.",
5
5
  "license": "MIT",
6
6
  "type": "module",