@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 +4 -0
- package/dist/.source-hash +1 -1
- package/dist/activity-observer.d.ts +20 -8
- package/dist/activity-observer.js +77 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/pricing.d.ts +13 -0
- package/dist/pricing.js +16 -0
- package/dist/session-log-watcher.d.ts +7 -0
- package/dist/session-log-watcher.js +9 -0
- package/dist/stats-wire.d.ts +5 -3
- package/dist/stats-wire.js +3 -2
- package/dist/subagent-live.d.ts +34 -0
- package/dist/subagent-live.js +150 -0
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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,
|
|
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-
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
|
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)
|
|
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,
|
|
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 =
|
|
181
|
+
detail = labelOf(input.description);
|
|
140
182
|
break;
|
|
141
183
|
case "Skill":
|
|
142
|
-
detail =
|
|
184
|
+
detail = labelOf(input.skill);
|
|
143
185
|
break;
|
|
144
186
|
}
|
|
145
|
-
const text =
|
|
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-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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
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) {
|
package/dist/stats-wire.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
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
|
/**
|
package/dist/stats-wire.js
CHANGED
|
@@ -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-
|
|
126
|
-
|
|
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.
|
|
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",
|