@thehammer/danx-session-telemetry 0.1.26 → 0.1.29

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
- 3ffba7fcfab27ba33e21879f9c097dc9eaafaf4a688857098edbd721e2f81300
1
+ 2f856d47ffedab1236ae8ab337dfdd4c37c3d578a819f50ffee9f8603865c721
@@ -33,11 +33,11 @@
33
33
  *
34
34
  * | Field | Read from |
35
35
  * |---|---|
36
- * | `agentType`, `description`, `parentActivityId` | the sub-agent's `meta.json` sidecar (via `SourceDiscoveredInfo`) |
36
+ * | `agentType`, `description`, `parentActivityId` | the sub-agent's `meta.json` sidecar (via `SourceDiscoveredInfo`); the description through {@link redactLabel} |
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
@@ -111,17 +111,29 @@ export declare function parseTaskNotification(raw: Record<string, unknown>): {
111
111
  taskId: string;
112
112
  endStatus: ActivityEndStatus;
113
113
  } | null;
114
+ /**
115
+ * DX-4498 — an agent-written label as the stats wire may carry it: on one line, with every path-shaped token (any token
116
+ * holding a `/` or `\`) read as `[path]`, and every secret-shaped token (an `ENV_NAME=value` value, or an opaque run of
117
+ * 20+ letters and digits such as a key, token or full SHA) read as `[redacted]`. A label is free text the agent wrote
118
+ * for a person to read (real shell descriptions name absolute paths), and it reaches every reader of the team, so this
119
+ * is the one way any label leaves a transcript. Empty for a blank label.
120
+ */
121
+ export declare function redactLabel(label: string): string;
122
+ /** DX-4498 — a transcript value read as a label: {@link redactLabel}'s text, or null for a non-string or blank one. */
123
+ export declare function labelOf(value: unknown): string | null;
114
124
  /**
115
125
  * DX-4498 — a sub-agent's current activity, from one `tool_use` block: the tool's name, plus a label only where the
116
126
  * agent wrote one AS a label (a shell call's `description`, an `Agent` call's `description`, a `Skill` call's skill
117
- * name). Never a command, a file path or a prompt: the stats wire carries ids, numbers and labels, never free-form
118
- * transcript content.
127
+ * name), through {@link labelOf}. Never a command, a file path, a secret or a prompt: the stats wire carries ids,
128
+ * numbers and redacted labels, never free-form transcript content.
119
129
  */
120
130
  export declare function describeToolCall(name: string, input: Record<string, unknown>): string;
121
131
  /**
122
- * DX-4498 — the card id a brief opens with: the first token of its first non-empty line, when that token is shaped
123
- * like a card id (`DX-4498`, also `DX-4498:`). Worker briefs open with the card they are for; any other brief names
124
- * 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.
125
137
  */
126
- export declare function briefIssueId(brief: string): string | null;
138
+ export declare function firstIssueId(text: string): string | null;
127
139
  export declare function createActivityObserver(deps: ActivityObserverDeps): ActivityObserver;
@@ -33,11 +33,11 @@
33
33
  *
34
34
  * | Field | Read from |
35
35
  * |---|---|
36
- * | `agentType`, `description`, `parentActivityId` | the sub-agent's `meta.json` sidecar (via `SourceDiscoveredInfo`) |
36
+ * | `agentType`, `description`, `parentActivityId` | the sub-agent's `meta.json` sidecar (via `SourceDiscoveredInfo`); the description through {@link redactLabel} |
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
@@ -122,40 +122,85 @@ export function parseTaskNotification(raw) {
122
122
  const endStatus = NOTIFICATION_END_STATUS[match[2].trim()];
123
123
  return endStatus === undefined ? null : { taskId: match[1].trim(), endStatus };
124
124
  }
125
+ /** DX-4498 — what {@link redactLabel} puts in place of a path-shaped token. */
126
+ const REDACTED_PATH = "[path]";
127
+ /** DX-4498 — what {@link redactLabel} puts in place of a secret-shaped token, or an assignment's value. */
128
+ const REDACTED_SECRET = "[redacted]";
129
+ /** An `ENV_NAME=value` token: its value may be a credential. */
130
+ const ASSIGNMENT_TOKEN = /^([A-Za-z_][A-Za-z0-9_]*)=.+$/;
131
+ /** The characters a key, token, hash or id is written in. */
132
+ const OPAQUE_TOKEN = /^[A-Za-z0-9_+.-]+$/;
133
+ /** The shortest opaque run of letters and digits treated as a key, token, hash or id (a card id or short SHA is far shorter). */
134
+ const OPAQUE_TOKEN_MIN_CHARS = 20;
135
+ /** Punctuation a token may be wrapped in, which never stops it from being read as opaque. */
136
+ const WRAPPING_PUNCTUATION = /^[("'`[]+|[)"'`\],.:;!?]+$/g;
137
+ function redactToken(token) {
138
+ if (/[\\/]/.test(token))
139
+ return REDACTED_PATH;
140
+ const assignment = ASSIGNMENT_TOKEN.exec(token);
141
+ if (assignment !== null)
142
+ return `${assignment[1]}=${REDACTED_SECRET}`;
143
+ const bare = token.replace(WRAPPING_PUNCTUATION, "");
144
+ const opaque = bare.length >= OPAQUE_TOKEN_MIN_CHARS && OPAQUE_TOKEN.test(bare) && /[0-9]/.test(bare) && /[A-Za-z]/.test(bare);
145
+ return opaque ? REDACTED_SECRET : token;
146
+ }
147
+ /**
148
+ * DX-4498 — an agent-written label as the stats wire may carry it: on one line, with every path-shaped token (any token
149
+ * holding a `/` or `\`) read as `[path]`, and every secret-shaped token (an `ENV_NAME=value` value, or an opaque run of
150
+ * 20+ letters and digits such as a key, token or full SHA) read as `[redacted]`. A label is free text the agent wrote
151
+ * for a person to read (real shell descriptions name absolute paths), and it reaches every reader of the team, so this
152
+ * is the one way any label leaves a transcript. Empty for a blank label.
153
+ */
154
+ export function redactLabel(label) {
155
+ return label
156
+ .split(/\s+/)
157
+ .filter((token) => token !== "")
158
+ .map(redactToken)
159
+ .join(" ");
160
+ }
161
+ /** DX-4498 — a transcript value read as a label: {@link redactLabel}'s text, or null for a non-string or blank one. */
162
+ export function labelOf(value) {
163
+ if (typeof value !== "string")
164
+ return null;
165
+ const label = redactLabel(value);
166
+ return label === "" ? null : label;
167
+ }
125
168
  /**
126
169
  * DX-4498 — a sub-agent's current activity, from one `tool_use` block: the tool's name, plus a label only where the
127
170
  * agent wrote one AS a label (a shell call's `description`, an `Agent` call's `description`, a `Skill` call's skill
128
- * name). Never a command, a file path or a prompt: the stats wire carries ids, numbers and labels, never free-form
129
- * transcript content.
171
+ * name), through {@link labelOf}. Never a command, a file path, a secret or a prompt: the stats wire carries ids,
172
+ * numbers and redacted labels, never free-form transcript content.
130
173
  */
131
174
  export function describeToolCall(name, input) {
132
- const label = (v) => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
133
175
  let detail = null;
134
176
  switch (name) {
135
177
  case "Bash":
136
178
  case "PowerShell":
137
179
  case "Agent":
138
180
  case "Task":
139
- detail = label(input.description);
181
+ detail = labelOf(input.description);
140
182
  break;
141
183
  case "Skill":
142
- detail = label(input.skill);
184
+ detail = labelOf(input.skill);
143
185
  break;
144
186
  }
145
- const text = (detail === null ? name : `${name}: ${detail}`).replace(/\s+/g, " ");
187
+ const text = detail === null ? name : `${name}: ${detail}`;
146
188
  return text.length > CURRENT_ACTIVITY_MAX_CHARS ? `${text.slice(0, CURRENT_ACTIVITY_MAX_CHARS - 3)}...` : text;
147
189
  }
148
190
  /**
149
- * DX-4498 — the card id a brief opens with: the first token of its first non-empty line, when that token is shaped
150
- * like a card id (`DX-4498`, also `DX-4498:`). Worker briefs open with the card they are for; any other brief names
151
- * 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.
152
196
  */
153
- export function briefIssueId(brief) {
154
- const firstLine = brief.split("\n").find((line) => line.trim() !== "");
155
- if (firstLine === undefined)
156
- return null;
157
- const token = firstLine.trim().split(/\s+/)[0].replace(/[.,:;)]+$/, "");
158
- 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;
159
204
  }
160
205
  /** The text of a `user` line's content: the string itself, or its first text block. */
161
206
  function userText(raw) {
@@ -230,9 +275,14 @@ export function createActivityObserver(deps) {
230
275
  existing.agentType = info.agentType;
231
276
  filled = true;
232
277
  }
233
- if (existing.description === null && info.description !== null) {
234
- existing.description = info.description;
278
+ const description = labelOf(info.description);
279
+ if (existing.description === null && description !== null) {
280
+ existing.description = description;
235
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;
236
286
  }
237
287
  if (existing.parentActivityId === null && info.parentSource !== null) {
238
288
  existing.parentActivityId = info.parentSource;
@@ -242,12 +292,13 @@ export function createActivityObserver(deps) {
242
292
  report(existing);
243
293
  return;
244
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);
245
297
  const change = {
246
298
  kind: "agent",
247
299
  activityId: info.source,
248
300
  agentType: info.agentType,
249
- // DX-4498 — the label its spawning `Agent` call gave it, from the sidecar.
250
- description: info.description,
301
+ description,
251
302
  startedAt: info.discoveredAt,
252
303
  lastActivityAt: info.discoveredAt,
253
304
  finishedAt: null,
@@ -255,7 +306,9 @@ export function createActivityObserver(deps) {
255
306
  effort: null,
256
307
  currentActivity: null,
257
308
  endStatus: null,
258
- 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),
259
312
  };
260
313
  const pending = pendingEnds.get(info.source);
261
314
  if (pending !== undefined) {
@@ -312,10 +365,10 @@ export function createActivityObserver(deps) {
312
365
  }
313
366
  if (raw.type === "user") {
314
367
  // DX-4498 — the brief is the chain's root line (`parentUuid: null`), so a collector that resumes mid-file
315
- // 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.
316
369
  if (change.briefIssueId === null && raw.parentUuid === null) {
317
370
  const text = userText(raw);
318
- const id = text === null ? null : briefIssueId(text);
371
+ const id = text === null ? null : firstIssueId(text);
319
372
  if (id !== null) {
320
373
  change.briefIssueId = id;
321
374
  changed = true;
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.26",
3
+ "version": "0.1.29",
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",