@50arms/session-telemetry 0.0.0-stage → 0.1.66

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 (45) hide show
  1. package/README.md +68 -2
  2. package/dist/.source-hash +1 -0
  3. package/dist/activity-observer.d.ts +186 -0
  4. package/dist/activity-observer.js +917 -0
  5. package/dist/api-tool-names.d.ts +12 -0
  6. package/dist/api-tool-names.js +19 -0
  7. package/dist/browser.d.ts +29 -0
  8. package/dist/browser.js +10 -0
  9. package/dist/catalog-prices.d.ts +27 -0
  10. package/dist/catalog-prices.js +31 -0
  11. package/dist/claim-tracker.d.ts +53 -0
  12. package/dist/claim-tracker.js +213 -0
  13. package/dist/dispatch-usage.d.ts +321 -0
  14. package/dist/dispatch-usage.js +657 -0
  15. package/dist/git-head.d.ts +1 -0
  16. package/dist/git-head.js +19 -0
  17. package/dist/index.d.ts +35 -0
  18. package/dist/index.js +49 -0
  19. package/dist/internal-logger.d.ts +16 -0
  20. package/dist/internal-logger.js +27 -0
  21. package/dist/ledger-timing.d.ts +26 -0
  22. package/dist/ledger-timing.js +31 -0
  23. package/dist/pricing.d.ts +93 -0
  24. package/dist/pricing.js +77 -0
  25. package/dist/real-work.d.ts +13 -0
  26. package/dist/real-work.js +57 -0
  27. package/dist/session-log-watcher.d.ts +616 -0
  28. package/dist/session-log-watcher.js +1285 -0
  29. package/dist/session-title-marks.d.ts +2 -0
  30. package/dist/session-title-marks.js +16 -0
  31. package/dist/stats-wire.d.ts +345 -0
  32. package/dist/stats-wire.js +636 -0
  33. package/dist/subagent-live.d.ts +36 -0
  34. package/dist/subagent-live.js +153 -0
  35. package/dist/tool-summary.d.ts +19 -0
  36. package/dist/tool-summary.js +58 -0
  37. package/dist/transcript-timing.d.ts +100 -0
  38. package/dist/transcript-timing.js +231 -0
  39. package/dist/types.d.ts +25 -0
  40. package/dist/types.js +1 -0
  41. package/dist/usage-ledger.d.ts +256 -0
  42. package/dist/usage-ledger.js +581 -0
  43. package/dist/write-queue.d.ts +9 -0
  44. package/dist/write-queue.js +16 -0
  45. package/package.json +43 -4
@@ -0,0 +1,636 @@
1
+ /**
2
+ * DX-2963 — the stats-write wire contract, shared by the worker (sender) and
3
+ * the dashboard (the one server writer, `usage-ledger.ts`). DB-free so a
4
+ * DB-less worker can import it.
5
+ *
6
+ * A stats write carries ONLY ledger entries (one per reply: the per-field
7
+ * maximum usage seen so far, the reply's tool-use ids) that are new or changed
8
+ * since the last acknowledged write — never totals. Every total, the cost,
9
+ * the turn count and the tool-call count are derived from the ledger on the
10
+ * server, and the response hands them back.
11
+ */
12
+ import { COMPACTION_TRIGGERS, } from "./usage-ledger.js";
13
+ import { LEDGER_TIMING_FIELDS } from "./ledger-timing.js";
14
+ import { ISSUE_ID_MAX_LENGTH, ISSUE_ID_PATTERN, MAX_HELD_CLAIMS, MAX_PENDING_TRANSITIONS, } from "./claim-tracker.js";
15
+ /**
16
+ * DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
17
+ * session read from the start) is sent as consecutive capped batches, so one
18
+ * request never carries an unbounded body or holds the dispatch lock for an
19
+ * unbounded time. 500 entries is well under a second of upsert work.
20
+ */
21
+ export const STATS_BATCH_CAP = 500;
22
+ /**
23
+ * DX-2954 — the running set's two kinds. `agent` is a sub-agent, produced from
24
+ * the TRANSCRIPT: one row per sub-agent transcript file the session-log watcher
25
+ * discovers (`packages/danx-session-telemetry/src/activity-observer.ts`; orchestrator decision, DX-2954
26
+ * comment 8004), and — for an operator session — from the plugin's
27
+ * `SubagentStart` / `SubagentStop` hooks. The transcript ends a row from the
28
+ * spawner's spawn result (foreground, DX-5576) or task notification
29
+ * (background, DX-4498); `SubagentStop` is the fallback finish. `bash` is a
30
+ * background script, produced only by the plugin's `PostToolUse` hook on a
31
+ * backgrounded Bash call (DX-3284; the transcript cannot see it). No hook event
32
+ * observes a background shell FINISHING (verified against the hooks reference —
33
+ * see the Step 0 note on DX-2954), so an open `bash` row is closed by the
34
+ * dispatch's own terminal write, never by inferring an end from transcript
35
+ * lines.
36
+ */
37
+ export const ACTIVITY_KINDS = ["agent", "bash"];
38
+ /**
39
+ * DX-5333 — which part of a sub-agent's conversation a stored message is: `brief` (the prompt its spawner gave it), `followup`
40
+ * (a later message sent to it) or `final` (a reply that ended its turn).
41
+ */
42
+ export const ACTIVITY_MESSAGE_KINDS = ["brief", "followup", "final"];
43
+ /** DX-5333 — the longest message text a collector sends (and the server stores); a longer one is cut by the collector. */
44
+ export const ACTIVITY_MESSAGE_MAX_CHARS = 100_000;
45
+ /** DX-5333 — cap on messages per stats write, so one write's lock hold stays bounded; the rest stay queued for the next. */
46
+ export const ACTIVITY_MESSAGE_BATCH_CAP = 100;
47
+ /** DX-5333 — cap on problem links per stats write. */
48
+ export const ACTIVITY_PROBLEM_BATCH_CAP = 100;
49
+ /**
50
+ * DX-4498 / DX-5576 — how an activity ended, when something explicitly said so: a foreground spawn's own
51
+ * `tool_result` (`completed`; `failed` if errored; `stopped` if interrupted or rejected), Claude Code's
52
+ * background-task notification (`completed`, `failed`, and `killed`/`stopped` as `stopped`), or the session ending
53
+ * while it still ran (`stopped`).
54
+ */
55
+ export const ACTIVITY_END_STATUSES = ["completed", "failed", "stopped"];
56
+ /** DX-4498 — the longest `currentActivity` a collector sends (and the server stores). */
57
+ export const CURRENT_ACTIVITY_MAX_CHARS = 120;
58
+ export function chunkEntries(entries, cap = STATS_BATCH_CAP) {
59
+ const out = [];
60
+ for (let i = 0; i < entries.length; i += cap)
61
+ out.push(entries.slice(i, i + cap));
62
+ return out;
63
+ }
64
+ export class StatsPayloadError extends Error {
65
+ }
66
+ function isNonNegInt(v) {
67
+ return typeof v === "number" && Number.isInteger(v) && v >= 0;
68
+ }
69
+ const USAGE_KEYS = ["input", "output", "cacheRead", "cacheWrite5m", "cacheWrite1h"];
70
+ /**
71
+ * DX-2954 — the ONLY keys a stats body may carry. An unknown key is a 400, so
72
+ * a worker that invents a field (or a renamed one that never reached a
73
+ * consumer) fails loud instead of being silently dropped — the pre-DX-2954
74
+ * parser rebuilt the object field by field and swallowed every extra key.
75
+ *
76
+ * `transcriptPath` is the ONE key allowed to carry a filesystem path.
77
+ */
78
+ const STATS_PAYLOAD_KEYS = new Set([
79
+ "entries",
80
+ "sourceCursors",
81
+ "activities",
82
+ "activityMessages",
83
+ "activityProblems",
84
+ "compactions",
85
+ "transcriptPath",
86
+ ]);
87
+ /**
88
+ * DX-2954 — cap on activity rows per stats write. The running set is bounded by
89
+ * how many sub-agents and background scripts one dispatch has ever started;
90
+ * this keeps a malformed or runaway sender from holding the dispatch lock while
91
+ * it upserts an unbounded list.
92
+ */
93
+ export const ACTIVITY_BATCH_CAP = 500;
94
+ /** DX-4922 — cap on compaction rows per stats write; a session compacts a handful of times, so a longer list is a defect. */
95
+ export const COMPACTION_BATCH_CAP = 100;
96
+ /** DX-2954 — cap on sources per stats write, for the same reason. */
97
+ export const CURSOR_BATCH_CAP = 500;
98
+ /**
99
+ * DX-2954 — collapse a queue of activity changes to ONE per
100
+ * `(kind, activityId)`, applying the SAME merge the server's upsert applies.
101
+ *
102
+ * Both sides need this rule, so it is stated once, here, beside the wire types:
103
+ * the server merges an incoming row against what it has stored, and a SENDER
104
+ * merges the changes it has queued since its last write against each other. The
105
+ * two are the same operation on the same monotone fields, so a client-side
106
+ * collapse is lossless — the server reaches the identical row either way.
107
+ *
108
+ * Why a sender needs it at all: a producer reports an activity's full current
109
+ * state every time anything about it moves, and a chatty sub-agent moves its
110
+ * `lastActivityAt` continuously. Queuing those raw would grow one entry per
111
+ * change between writes, blow {@link ACTIVITY_BATCH_CAP}, and take the WHOLE
112
+ * stats write down with it — not merely the activity part — for a payload that
113
+ * describes a handful of activities.
114
+ *
115
+ * A plain last-one-wins would be WRONG here, which is why this merges field by
116
+ * field: a still-running report queued after a finish must not un-finish the row unless its activity is later than the finish ({@link mergedFinish}).
117
+ */
118
+ export function collapseActivityChanges(changes) {
119
+ const byKey = new Map();
120
+ for (const c of changes) {
121
+ // NUL separator: neither a kind (a fixed enum) nor an activity id (a
122
+ // transcript basename or a tool-use id) can contain one, so no pair of
123
+ // distinct keys can collide into one string.
124
+ const key = `${c.kind}\u0000${c.activityId}`;
125
+ const prior = byKey.get(key);
126
+ if (prior === undefined) {
127
+ byKey.set(key, { ...c });
128
+ continue;
129
+ }
130
+ byKey.set(key, {
131
+ kind: c.kind,
132
+ activityId: c.activityId,
133
+ // COALESCE(incoming, stored) — a later report may FILL one in, never erase
134
+ // it (a Bash launch knows the description, a sub-agent stop knows the type;
135
+ // neither knows the other's).
136
+ agentType: c.agentType ?? prior.agentType,
137
+ description: c.description ?? prior.description,
138
+ // LEAST — a start never moves later.
139
+ startedAt: Math.min(prior.startedAt, c.startedAt),
140
+ // GREATEST — liveness never moves backwards.
141
+ lastActivityAt: Math.max(prior.lastActivityAt, c.lastActivityAt),
142
+ // DX-6815 — the LATEST finish stands while no activity is later than it (see {@link mergedFinish}); a still-running report
143
+ // that predates the finish never resurrects a stopped activity, and activity after it (a resumed sub-agent) reopens it.
144
+ ...mergedFinish(prior, c),
145
+ // DX-4498 — COALESCE(incoming, stored): a spawn fact, known or not, never changes once known.
146
+ parentActivityId: c.parentActivityId ?? prior.parentActivityId,
147
+ // DX-4498 — the report with the LATER liveness carries the current effort and tool call (a tie goes to the later
148
+ // report, which a producer queues after the one it supersedes); a report that knows neither keeps the stored ones.
149
+ ...latestOf(prior, c),
150
+ // DX-4508 — COALESCE(incoming, stored): the producer reports its best id and may improve it (a late sidecar's
151
+ // description outranks the brief); a null never erases one.
152
+ briefIssueId: c.briefIssueId ?? prior.briefIssueId,
153
+ // DX-5333 — COALESCE(stored, incoming): the start commit is a fact of the moment the sub-agent started, so the first
154
+ // one read stands; a field no report carries stays absent.
155
+ ...(prior.startSha !== undefined || c.startSha !== undefined ? { startSha: prior.startSha ?? c.startSha ?? null } : {}),
156
+ // DX-5898 - COALESCE(stored, incoming): the brief is read once, so the first word known stands.
157
+ ...(prior.briefProfile !== undefined || c.briefProfile !== undefined ? { briefProfile: prior.briefProfile ?? c.briefProfile ?? null } : {}),
158
+ });
159
+ }
160
+ return [...byKey.values()];
161
+ }
162
+ /**
163
+ * DX-6815 — a finish this close to (or before) an activity's latest activity still ends it. A finish is stamped from the line that reports it,
164
+ * which Claude Code writes after the sub-agent's last line: in production the gap is 0 to 90 ms, and the next gap seen is 11 seconds, a sub-agent
165
+ * resumed with `SendMessage`. The server's upsert and {@link collapseActivityChanges} both read it from here.
166
+ */
167
+ export const ACTIVITY_RESUME_GRACE_MS = 10_000;
168
+ /**
169
+ * DX-6815 — the finish and end status both sides apply when `incoming` meets `stored`. The latest finish known stands only while the latest activity
170
+ * is not later than it by more than {@link ACTIVITY_RESUME_GRACE_MS}: later activity means the sub-agent was resumed, so it is running again
171
+ * (`finishedAt` and `endStatus` null), and a finish older than the activity is one of an earlier life. While it stands, the latest explicit end status wins
172
+ * (a notification read after the session's own close names how it really ended). Every operand is a maximum, so writes may arrive in any order.
173
+ */
174
+ export function mergedFinish(stored, incoming) {
175
+ const finish = stored.finishedAt === null || incoming.finishedAt === null ? (stored.finishedAt ?? incoming.finishedAt) : Math.max(stored.finishedAt, incoming.finishedAt);
176
+ const activity = Math.max(stored.lastActivityAt, incoming.lastActivityAt);
177
+ if (finish === null || activity > finish + ACTIVITY_RESUME_GRACE_MS)
178
+ return { finishedAt: null, endStatus: null };
179
+ return { finishedAt: finish, endStatus: incoming.endStatus ?? stored.endStatus };
180
+ }
181
+ /**
182
+ * DX-4498 — the `effort` / `currentActivity` merge both sides apply: the report with the later `lastActivityAt` wins
183
+ * (ties go to `incoming`), and a null never erases a stored value.
184
+ */
185
+ function latestOf(stored, incoming) {
186
+ const [newer, older] = incoming.lastActivityAt >= stored.lastActivityAt ? [incoming, stored] : [stored, incoming];
187
+ return { effort: newer.effort ?? older.effort, currentActivity: newer.currentActivity ?? older.currentActivity };
188
+ }
189
+ const CURSOR_KEYS = new Set(["acked", "delivered", "claims", "pastEof"]);
190
+ const CLAIM_STATE_KEYS = new Set(["held", "pending"]);
191
+ const PENDING_KEYS = new Set(["op", "issueId"]);
192
+ /** DX-3962 - a card id as a claim or a ledger entry names it: the issue routes' own shape, within the column's width. */
193
+ function isIssueId(v) {
194
+ return typeof v === "string" && v.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(v);
195
+ }
196
+ /**
197
+ * DX-3962 - whether `raw` is a well-formed claim state. A STORED cursor from a server that predates attribution has
198
+ * none, and a collector must refuse such an answer instead of dereferencing it.
199
+ */
200
+ export function isClaimState(raw) {
201
+ try {
202
+ parseClaimState(raw, "claims");
203
+ return true;
204
+ }
205
+ catch (err) {
206
+ if (err instanceof StatsPayloadError)
207
+ return false;
208
+ throw err;
209
+ }
210
+ }
211
+ /** DX-3962 - strict parse of one source's claim state (a collector bug, never coerced). */
212
+ function parseClaimState(raw, where) {
213
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
214
+ throw new StatsPayloadError(where);
215
+ const c = raw;
216
+ for (const k of Object.keys(c)) {
217
+ if (!CLAIM_STATE_KEYS.has(k))
218
+ throw new StatsPayloadError(`${where}.${k} is not a claim-state field`);
219
+ }
220
+ if (!Array.isArray(c.held) || c.held.length > MAX_HELD_CLAIMS || !c.held.every(isIssueId)) {
221
+ throw new StatsPayloadError(`${where}.held must be at most ${MAX_HELD_CLAIMS} card ids`);
222
+ }
223
+ if (!c.pending || typeof c.pending !== "object" || Array.isArray(c.pending)) {
224
+ throw new StatsPayloadError(`${where}.pending must be an object`);
225
+ }
226
+ const pendingEntries = Object.entries(c.pending);
227
+ if (pendingEntries.length > MAX_PENDING_TRANSITIONS) {
228
+ throw new StatsPayloadError(`${where}.pending exceeds the cap of ${MAX_PENDING_TRANSITIONS}`);
229
+ }
230
+ const pending = {};
231
+ for (const [toolUseId, value] of pendingEntries) {
232
+ const w = `${where}.pending[${toolUseId}]`;
233
+ if (!toolUseId)
234
+ throw new StatsPayloadError(`${where}.pending key must be a non-empty tool-use id`);
235
+ if (!value || typeof value !== "object")
236
+ throw new StatsPayloadError(w);
237
+ const p = value;
238
+ for (const k of Object.keys(p)) {
239
+ if (!PENDING_KEYS.has(k))
240
+ throw new StatsPayloadError(`${w}.${k} is not a pending-transition field`);
241
+ }
242
+ if (p.op !== "claim" && p.op !== "release")
243
+ throw new StatsPayloadError(`${w}.op must be claim or release`);
244
+ if (!isIssueId(p.issueId))
245
+ throw new StatsPayloadError(`${w}.issueId`);
246
+ pending[toolUseId] = { op: p.op, issueId: p.issueId };
247
+ }
248
+ return { held: [...c.held], pending };
249
+ }
250
+ const PAST_EOF_KEYS = new Set(["storedOffset", "fileSize"]);
251
+ function parsePastEof(raw, where) {
252
+ if (!raw || typeof raw !== "object")
253
+ throw new StatsPayloadError(where);
254
+ const p = raw;
255
+ for (const k of Object.keys(p)) {
256
+ if (!PAST_EOF_KEYS.has(k))
257
+ throw new StatsPayloadError(`${where}.${k} is not a pastEof field`);
258
+ }
259
+ if (!isNonNegInt(p.storedOffset))
260
+ throw new StatsPayloadError(`${where}.storedOffset`);
261
+ if (!isNonNegInt(p.fileSize))
262
+ throw new StatsPayloadError(`${where}.fileSize`);
263
+ return { storedOffset: p.storedOffset, fileSize: p.fileSize };
264
+ }
265
+ function parseSourceCursors(raw) {
266
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
267
+ throw new StatsPayloadError("sourceCursors must be an object");
268
+ }
269
+ const entries = Object.entries(raw);
270
+ if (entries.length > CURSOR_BATCH_CAP) {
271
+ throw new StatsPayloadError(`sourceCursors exceeds the cap of ${CURSOR_BATCH_CAP}`);
272
+ }
273
+ const out = {};
274
+ for (const [source, value] of entries) {
275
+ if (!source)
276
+ throw new StatsPayloadError("sourceCursors key must be a non-empty source");
277
+ if (!value || typeof value !== "object") {
278
+ throw new StatsPayloadError(`sourceCursors[${source}] must be an object`);
279
+ }
280
+ const c = value;
281
+ for (const k of Object.keys(c)) {
282
+ if (!CURSOR_KEYS.has(k)) {
283
+ throw new StatsPayloadError(`sourceCursors[${source}].${k} is not a cursor field`);
284
+ }
285
+ }
286
+ if (!isNonNegInt(c.acked))
287
+ throw new StatsPayloadError(`sourceCursors[${source}].acked`);
288
+ if (!isNonNegInt(c.delivered))
289
+ throw new StatsPayloadError(`sourceCursors[${source}].delivered`);
290
+ // `delivered` is the highest line HANDED ON, `acked` the highest
291
+ // ACKNOWLEDGED — acknowledging a line necessarily means it was handed on
292
+ // first, so delivered < acked is structurally impossible and a sender bug.
293
+ if (c.delivered < c.acked) {
294
+ throw new StatsPayloadError(`sourceCursors[${source}]: delivered (${c.delivered}) is behind acked (${c.acked})`);
295
+ }
296
+ const report = {
297
+ acked: c.acked,
298
+ delivered: c.delivered,
299
+ claims: parseClaimState(c.claims, `sourceCursors[${source}].claims`),
300
+ };
301
+ if (c.pastEof !== undefined) {
302
+ report.pastEof = parsePastEof(c.pastEof, `sourceCursors[${source}].pastEof`);
303
+ }
304
+ out[source] = report;
305
+ }
306
+ return out;
307
+ }
308
+ const ACTIVITY_KEYS = new Set([
309
+ "kind",
310
+ "activityId",
311
+ "agentType",
312
+ "description",
313
+ "startedAt",
314
+ "lastActivityAt",
315
+ "finishedAt",
316
+ "parentActivityId",
317
+ "effort",
318
+ "currentActivity",
319
+ "endStatus",
320
+ "briefIssueId",
321
+ "startSha",
322
+ "briefProfile",
323
+ ]);
324
+ /** DX-4498 — a nullable string field of an activity: present, and either null or a non-empty string. */
325
+ function nullableText(a, key, where, maxChars) {
326
+ if (!(key in a))
327
+ throw new StatsPayloadError(`${where}.${key} is required (null when unknown)`);
328
+ const v = a[key];
329
+ if (v === null)
330
+ return null;
331
+ if (typeof v !== "string" || v === "")
332
+ throw new StatsPayloadError(`${where}.${key} must be a non-empty string or null`);
333
+ if (maxChars !== undefined && v.length > maxChars) {
334
+ throw new StatsPayloadError(`${where}.${key} exceeds ${maxChars} characters`);
335
+ }
336
+ return v;
337
+ }
338
+ function parseActivities(raw) {
339
+ if (!Array.isArray(raw))
340
+ throw new StatsPayloadError("activities must be an array");
341
+ if (raw.length > ACTIVITY_BATCH_CAP) {
342
+ throw new StatsPayloadError(`activities exceeds the cap of ${ACTIVITY_BATCH_CAP}`);
343
+ }
344
+ return raw.map((entry, i) => {
345
+ const where = `activities[${i}]`;
346
+ if (!entry || typeof entry !== "object")
347
+ throw new StatsPayloadError(where);
348
+ const a = entry;
349
+ for (const k of Object.keys(a)) {
350
+ if (!ACTIVITY_KEYS.has(k))
351
+ throw new StatsPayloadError(`${where}.${k} is not an activity field`);
352
+ }
353
+ if (typeof a.kind !== "string" || !ACTIVITY_KINDS.includes(a.kind)) {
354
+ throw new StatsPayloadError(`${where}.kind must be one of ${ACTIVITY_KINDS.join(", ")}`);
355
+ }
356
+ if (typeof a.activityId !== "string" || !a.activityId) {
357
+ throw new StatsPayloadError(`${where}.activityId`);
358
+ }
359
+ if (a.agentType !== null && typeof a.agentType !== "string") {
360
+ throw new StatsPayloadError(`${where}.agentType`);
361
+ }
362
+ if (a.description !== null && typeof a.description !== "string") {
363
+ throw new StatsPayloadError(`${where}.description`);
364
+ }
365
+ if (!isNonNegInt(a.startedAt))
366
+ throw new StatsPayloadError(`${where}.startedAt`);
367
+ if (!isNonNegInt(a.lastActivityAt))
368
+ throw new StatsPayloadError(`${where}.lastActivityAt`);
369
+ if (a.finishedAt !== null && !isNonNegInt(a.finishedAt)) {
370
+ throw new StatsPayloadError(`${where}.finishedAt`);
371
+ }
372
+ // DX-4498 — REQUIRED, `null` when unknown: an activity without them comes from a collector that predates them, which
373
+ // the `stats` surface floor refuses rather than this parser defaulting them.
374
+ const parentActivityId = nullableText(a, "parentActivityId", where);
375
+ const effort = nullableText(a, "effort", where);
376
+ const currentActivity = nullableText(a, "currentActivity", where, CURRENT_ACTIVITY_MAX_CHARS);
377
+ const endStatus = nullableText(a, "endStatus", where);
378
+ if (endStatus !== null && !ACTIVITY_END_STATUSES.includes(endStatus)) {
379
+ throw new StatsPayloadError(`${where}.endStatus must be one of ${ACTIVITY_END_STATUSES.join(", ")}, or null`);
380
+ }
381
+ const briefIssueId = nullableText(a, "briefIssueId", where);
382
+ if (briefIssueId !== null && !isIssueId(briefIssueId))
383
+ throw new StatsPayloadError(`${where}.briefIssueId`);
384
+ // DX-5333 - OPTIONAL: absent = a collector that predates it; present = null (unreadable) or a full git object id.
385
+ if (a.startSha !== undefined && a.startSha !== null && (typeof a.startSha !== "string" || !GIT_OBJECT_ID.test(a.startSha))) {
386
+ throw new StatsPayloadError(`${where}.startSha must be a git object id, or null`);
387
+ }
388
+ // DX-5898 - OPTIONAL like startSha: absent = an older collector; present = null or a profile-shaped word.
389
+ const briefProfile = a.briefProfile === undefined ? undefined : nullableText(a, "briefProfile", where, PROFILE_WORD_MAX_LENGTH);
390
+ return {
391
+ kind: a.kind,
392
+ activityId: a.activityId,
393
+ agentType: a.agentType,
394
+ description: a.description,
395
+ startedAt: a.startedAt,
396
+ lastActivityAt: a.lastActivityAt,
397
+ finishedAt: a.finishedAt,
398
+ parentActivityId,
399
+ effort,
400
+ currentActivity,
401
+ endStatus: endStatus,
402
+ briefIssueId,
403
+ ...(a.startSha !== undefined ? { startSha: a.startSha } : {}),
404
+ ...(briefProfile !== undefined ? { briefProfile } : {}),
405
+ };
406
+ });
407
+ }
408
+ /** DX-5898: the longest profile word stored. */
409
+ export const PROFILE_WORD_MAX_LENGTH = 64;
410
+ /** DX-5333 - a full git object id (SHA-1 or SHA-256), as `git rev-parse HEAD` prints it. */
411
+ export const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
412
+ const ACTIVITY_MESSAGE_KEYS = new Set(["activityId", "kind", "messageKey", "text", "at", "cwd", "gitBranch"]);
413
+ function parseActivityMessages(raw) {
414
+ if (!Array.isArray(raw))
415
+ throw new StatsPayloadError("activityMessages must be an array");
416
+ if (raw.length > ACTIVITY_MESSAGE_BATCH_CAP) {
417
+ throw new StatsPayloadError(`activityMessages exceeds the cap of ${ACTIVITY_MESSAGE_BATCH_CAP}`);
418
+ }
419
+ return raw.map((entry, i) => {
420
+ const where = `activityMessages[${i}]`;
421
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
422
+ throw new StatsPayloadError(where);
423
+ const m = entry;
424
+ for (const k of Object.keys(m)) {
425
+ if (!ACTIVITY_MESSAGE_KEYS.has(k))
426
+ throw new StatsPayloadError(`${where}.${k} is not an activity message field`);
427
+ }
428
+ if (typeof m.activityId !== "string" || !m.activityId)
429
+ throw new StatsPayloadError(`${where}.activityId`);
430
+ if (typeof m.kind !== "string" || !ACTIVITY_MESSAGE_KINDS.includes(m.kind)) {
431
+ throw new StatsPayloadError(`${where}.kind must be one of ${ACTIVITY_MESSAGE_KINDS.join(", ")}`);
432
+ }
433
+ if (typeof m.messageKey !== "string" || !m.messageKey)
434
+ throw new StatsPayloadError(`${where}.messageKey`);
435
+ if (typeof m.text !== "string" || !m.text)
436
+ throw new StatsPayloadError(`${where}.text`);
437
+ // A NUL cannot be stored (Postgres refuses it), and a write that fails is re-sent forever: refuse it here instead.
438
+ if (m.text.includes("\u0000") || m.messageKey.includes("\u0000"))
439
+ throw new StatsPayloadError(`${where} holds a NUL character`);
440
+ if (m.text.length > ACTIVITY_MESSAGE_MAX_CHARS) {
441
+ throw new StatsPayloadError(`${where}.text exceeds ${ACTIVITY_MESSAGE_MAX_CHARS} characters`);
442
+ }
443
+ if (!isNonNegInt(m.at))
444
+ throw new StatsPayloadError(`${where}.at`);
445
+ const cwd = nullableText(m, "cwd", where);
446
+ const gitBranch = nullableText(m, "gitBranch", where);
447
+ if (cwd?.includes("\u0000") || gitBranch?.includes("\u0000"))
448
+ throw new StatsPayloadError(`${where} holds a NUL character`);
449
+ return { activityId: m.activityId, kind: m.kind, messageKey: m.messageKey, text: m.text, at: m.at, cwd, gitBranch };
450
+ });
451
+ }
452
+ function parseActivityProblems(raw) {
453
+ if (!Array.isArray(raw))
454
+ throw new StatsPayloadError("activityProblems must be an array");
455
+ if (raw.length > ACTIVITY_PROBLEM_BATCH_CAP) {
456
+ throw new StatsPayloadError(`activityProblems exceeds the cap of ${ACTIVITY_PROBLEM_BATCH_CAP}`);
457
+ }
458
+ return raw.map((entry, i) => {
459
+ const where = `activityProblems[${i}]`;
460
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
461
+ throw new StatsPayloadError(where);
462
+ const p = entry;
463
+ for (const k of Object.keys(p)) {
464
+ if (k !== "activityId" && k !== "problemRefNum")
465
+ throw new StatsPayloadError(`${where}.${k} is not an activity problem field`);
466
+ }
467
+ if (typeof p.activityId !== "string" || !p.activityId)
468
+ throw new StatsPayloadError(`${where}.activityId`);
469
+ if (!isNonNegInt(p.problemRefNum) || p.problemRefNum === 0)
470
+ throw new StatsPayloadError(`${where}.problemRefNum`);
471
+ return { activityId: p.activityId, problemRefNum: p.problemRefNum };
472
+ });
473
+ }
474
+ const COMPACTION_KEYS = new Set(["id", "source", "trigger", "at", "contextBefore", "postTokens", "durationMs", "summaryChars"]);
475
+ function parseCompactions(raw) {
476
+ if (!Array.isArray(raw))
477
+ throw new StatsPayloadError("compactions must be an array");
478
+ if (raw.length > COMPACTION_BATCH_CAP) {
479
+ throw new StatsPayloadError(`compactions exceeds the cap of ${COMPACTION_BATCH_CAP}`);
480
+ }
481
+ const parsed = raw.map((entry, i) => {
482
+ const where = `compactions[${i}]`;
483
+ if (!entry || typeof entry !== "object")
484
+ throw new StatsPayloadError(where);
485
+ const c = entry;
486
+ for (const k of Object.keys(c)) {
487
+ if (!COMPACTION_KEYS.has(k))
488
+ throw new StatsPayloadError(`${where}.${k} is not a compaction field`);
489
+ }
490
+ if (typeof c.id !== "string" || !c.id)
491
+ throw new StatsPayloadError(`${where}.id`);
492
+ if (typeof c.source !== "string" || !c.source)
493
+ throw new StatsPayloadError(`${where}.source`);
494
+ if (!COMPACTION_TRIGGERS.includes(c.trigger)) {
495
+ throw new StatsPayloadError(`${where}.trigger must be one of ${COMPACTION_TRIGGERS.join(", ")}`);
496
+ }
497
+ if (!isNonNegInt(c.at))
498
+ throw new StatsPayloadError(`${where}.at`);
499
+ if (!isNonNegInt(c.contextBefore))
500
+ throw new StatsPayloadError(`${where}.contextBefore`);
501
+ // DX-4939 - REQUIRED, a count or `null` (the boundary line did not carry it): a compaction that omits the key comes from a
502
+ // collector that predates it, which the MCP version floor refuses rather than this parser tolerating.
503
+ if (c.postTokens !== null && !isNonNegInt(c.postTokens))
504
+ throw new StatsPayloadError(`${where}.postTokens`);
505
+ if (c.durationMs !== null && !isNonNegInt(c.durationMs))
506
+ throw new StatsPayloadError(`${where}.durationMs`);
507
+ if (c.summaryChars !== null && !isNonNegInt(c.summaryChars))
508
+ throw new StatsPayloadError(`${where}.summaryChars`);
509
+ return {
510
+ id: c.id,
511
+ source: c.source,
512
+ trigger: c.trigger,
513
+ at: c.at,
514
+ contextBefore: c.contextBefore,
515
+ postTokens: c.postTokens,
516
+ durationMs: c.durationMs,
517
+ summaryChars: c.summaryChars,
518
+ };
519
+ });
520
+ // One statement upserts the list, and Postgres refuses a statement that touches a row twice.
521
+ if (new Set(parsed.map((c) => c.id)).size !== parsed.length)
522
+ throw new StatsPayloadError("compactions repeats an id");
523
+ return parsed;
524
+ }
525
+ /**
526
+ * Strict parse of a stats body (fail loud — a malformed entry is a worker
527
+ * bug, never coerced). Refuses more than {@link STATS_BATCH_CAP} entries, and
528
+ * (DX-2954) any key outside {@link STATS_PAYLOAD_KEYS}.
529
+ */
530
+ export function parseStatsPayload(body) {
531
+ if (!body || typeof body !== "object")
532
+ throw new StatsPayloadError("body must be an object");
533
+ const b = body;
534
+ const unknown = Object.keys(b).filter((k) => !STATS_PAYLOAD_KEYS.has(k));
535
+ if (unknown.length > 0) {
536
+ throw new StatsPayloadError(`unknown field(s): ${unknown.join(", ")} — allowed: ${[...STATS_PAYLOAD_KEYS].join(", ")}`);
537
+ }
538
+ if (!Array.isArray(b.entries))
539
+ throw new StatsPayloadError("entries must be an array");
540
+ if (b.entries.length > STATS_BATCH_CAP) {
541
+ throw new StatsPayloadError(`entries exceeds the cap of ${STATS_BATCH_CAP}`);
542
+ }
543
+ if (b.transcriptPath !== undefined && (typeof b.transcriptPath !== "string" || !b.transcriptPath)) {
544
+ throw new StatsPayloadError("transcriptPath must be a non-empty string when present");
545
+ }
546
+ const entries = b.entries.map((raw, i) => {
547
+ const e = raw;
548
+ const where = `entries[${i}]`;
549
+ if (typeof e.messageId !== "string" || !e.messageId)
550
+ throw new StatsPayloadError(`${where}.messageId`);
551
+ if (typeof e.source !== "string" || !e.source)
552
+ throw new StatsPayloadError(`${where}.source`);
553
+ if (e.model !== null && typeof e.model !== "string")
554
+ throw new StatsPayloadError(`${where}.model`);
555
+ if (typeof e.speed !== "string" || !e.speed)
556
+ throw new StatsPayloadError(`${where}.speed`);
557
+ if (!isNonNegInt(e.repliedAt))
558
+ throw new StatsPayloadError(`${where}.repliedAt`);
559
+ const u = e.usage;
560
+ if (!u || typeof u !== "object")
561
+ throw new StatsPayloadError(`${where}.usage`);
562
+ for (const k of USAGE_KEYS) {
563
+ if (!isNonNegInt(u[k]))
564
+ throw new StatsPayloadError(`${where}.usage.${k}`);
565
+ }
566
+ // DX-3964 - REQUIRED: an entry without `timing` comes from a collector that predates it, which the MCP
567
+ // version floor refuses rather than this parser tolerating.
568
+ const t = e.timing;
569
+ if (!t || typeof t !== "object" || Array.isArray(t))
570
+ throw new StatsPayloadError(`${where}.timing`);
571
+ const timing = {};
572
+ for (const k of LEDGER_TIMING_FIELDS) {
573
+ if (!isNonNegInt(t[k]))
574
+ throw new StatsPayloadError(`${where}.timing.${k}`);
575
+ timing[k] = t[k];
576
+ }
577
+ if (!Array.isArray(e.toolUseIds) || e.toolUseIds.some((t) => typeof t !== "string")) {
578
+ throw new StatsPayloadError(`${where}.toolUseIds`);
579
+ }
580
+ // DX-3962 - REQUIRED, `null` or a card id: an entry that omits it comes from a collector that predates
581
+ // attribution, which the MCP version floor refuses rather than this parser tolerating.
582
+ if (e.issueId !== null && !isIssueId(e.issueId))
583
+ throw new StatsPayloadError(`${where}.issueId`);
584
+ // DX-4939 - REQUIRED: an entry without the real-work flag comes from a collector that predates it, which the MCP version
585
+ // floor refuses rather than this parser tolerating.
586
+ if (typeof e.realWork !== "boolean")
587
+ throw new StatsPayloadError(`${where}.realWork`);
588
+ return {
589
+ messageId: e.messageId,
590
+ source: e.source,
591
+ model: e.model,
592
+ speed: e.speed,
593
+ repliedAt: e.repliedAt,
594
+ usage: {
595
+ input: u.input,
596
+ output: u.output,
597
+ cacheRead: u.cacheRead,
598
+ cacheWrite5m: u.cacheWrite5m,
599
+ cacheWrite1h: u.cacheWrite1h,
600
+ },
601
+ timing,
602
+ toolUseIds: e.toolUseIds,
603
+ issueId: e.issueId,
604
+ realWork: e.realWork,
605
+ };
606
+ });
607
+ const payload = { entries };
608
+ if (b.sourceCursors !== undefined)
609
+ payload.sourceCursors = parseSourceCursors(b.sourceCursors);
610
+ if (b.activities !== undefined)
611
+ payload.activities = parseActivities(b.activities);
612
+ if (b.activityMessages !== undefined)
613
+ payload.activityMessages = parseActivityMessages(b.activityMessages);
614
+ if (b.activityProblems !== undefined)
615
+ payload.activityProblems = parseActivityProblems(b.activityProblems);
616
+ if (b.compactions !== undefined)
617
+ payload.compactions = parseCompactions(b.compactions);
618
+ if (b.transcriptPath !== undefined)
619
+ payload.transcriptPath = b.transcriptPath;
620
+ return payload;
621
+ }
622
+ /**
623
+ * DX-2963 — a terminal dispatch is never written: the server refuses a stats
624
+ * write for one. Defined here (DB-free) so both sinks throw the SAME error —
625
+ * the getPool-direct writer directly, the worker client on the route's 409.
626
+ */
627
+ export class DispatchUsageTerminalError extends Error {
628
+ dispatchId;
629
+ status;
630
+ constructor(dispatchId, status) {
631
+ super(`usage ledger: dispatch ${dispatchId} is terminal (${status}) — a terminal dispatch is never written`);
632
+ this.dispatchId = dispatchId;
633
+ this.status = status;
634
+ this.name = "DispatchUsageTerminalError";
635
+ }
636
+ }
@@ -0,0 +1,36 @@
1
+ import { type PriceLookup } from "./pricing.js";
2
+ import type { EntryMeta, SourceDiscoveredInfo } from "./session-log-watcher.js";
3
+ import type { ActivityEndStatus } from "./stats-wire.js";
4
+ /** One sub-agent as the live view reports it. Every time is epoch ms. The plugin pane renders exactly this shape. */
5
+ export interface SubagentLiveSnapshot {
6
+ /** `agent-<agentId>`: its transcript basename, the ledger source key and the dashboard row's activity id. */
7
+ id: string;
8
+ /** The sub-agent that spawned it, or null for one the session itself spawned. */
9
+ parentId: string | null;
10
+ description: string | null;
11
+ agentType: string | null;
12
+ /** Its newest reply's model, never `<synthetic>`; null before its first reply. */
13
+ model: string | null;
14
+ effort: string | null;
15
+ startedAt: number;
16
+ lastActivityAt: number;
17
+ finishedAt: number | null;
18
+ endStatus: ActivityEndStatus | null;
19
+ /** input + output + cacheRead + cacheWrite5m + cacheWrite1h, summed over its replies. */
20
+ tokensTotal: number;
21
+ costUsd: number;
22
+ toolCallCount: number;
23
+ currentActivity: string | null;
24
+ /** The card its spawning `Agent` call names (description first, then brief); shape-checked only. */
25
+ cardId: string | null;
26
+ }
27
+ export interface SubagentLiveView {
28
+ /** Wire to the watcher's `onSourceDiscovered`. */
29
+ onSourceDiscovered: (info: SourceDiscoveredInfo) => void;
30
+ /** Wire to the watcher's `onUsageLine` (the raw line feed, main transcript included: it carries task notifications). */
31
+ onLine: (raw: Record<string, unknown>, meta: EntryMeta) => void;
32
+ /** The snapshots that changed since the last call (each differs from the one last returned for it); clears the set. */
33
+ takeChanged: () => SubagentLiveSnapshot[];
34
+ }
35
+ /** `priceOf` is the caller's declared prices (the package holds none, DX-5611): the SAME lookup the server prices a credit with. */
36
+ export declare function createSubagentLiveView(priceOf: PriceLookup): SubagentLiveView;