@thehammer/danx-session-telemetry 0.1.19 → 0.1.21

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.
@@ -0,0 +1,47 @@
1
+ /** The one tool a sub-agent reaches the dashboard API through (the MCP server key is `danx-dashboard`). */
2
+ export declare const DASHBOARD_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
3
+ /** A card id as the issue routes name it, e.g. `DX-3962`. Also the wire's validation for every card id a claim carries. */
4
+ export declare const ISSUE_ID_PATTERN: RegExp;
5
+ /** Longest card id a claim may carry (`dispatches.issue_id` is `VARCHAR(32)`). */
6
+ export declare const ISSUE_ID_MAX_LENGTH = 32;
7
+ /**
8
+ * Bounds that keep one source's persisted state, and the stats write that carries
9
+ * it, bounded. A real sub-agent holds one or two cards, so neither is reachable;
10
+ * the OLDEST entry is the one dropped, because the newest claim is what the next
11
+ * reply is credited to.
12
+ */
13
+ export declare const MAX_HELD_CLAIMS = 50;
14
+ export declare const MAX_PENDING_TRANSITIONS = 50;
15
+ /** One transition whose `tool_use` has been read and whose `tool_result` has not. */
16
+ export interface PendingTransition {
17
+ op: "claim" | "release";
18
+ issueId: string;
19
+ }
20
+ /** One source's claim state at one transcript position. */
21
+ export interface ClaimState {
22
+ /** Claimed cards, oldest first. */
23
+ held: string[];
24
+ /** In-flight transitions keyed by their `tool_use_id`. */
25
+ pending: Record<string, PendingTransition>;
26
+ }
27
+ export declare function emptyClaimState(): ClaimState;
28
+ /**
29
+ * The per-process claim tracker: one state per sub-agent source. Fed every
30
+ * transcript line by `DispatchUsage` (operator-bridge mode only — the worker never
31
+ * constructs one), seeded from the stored cursor at attach, and snapshotted per
32
+ * source for the stats write.
33
+ */
34
+ export declare class ClaimTracker {
35
+ private readonly states;
36
+ /** Replace a source's state — at attach (the stored cursor's claims) and on a past-EOF reset (empty). */
37
+ seed(source: string, state: ClaimState): void;
38
+ /** A copy of the source's state as of the last line fed; empty for a source that holds nothing. */
39
+ snapshot(source: string): ClaimState;
40
+ /** The card a reply whose first line is read NOW is credited to; `null` when the source holds none. */
41
+ issueFor(source: string): string | null;
42
+ /** An assistant line's content blocks: remember every claim / release the line asks for until its result arrives. */
43
+ observeAssistantBlocks(source: string, blocks: readonly unknown[]): void;
44
+ /** A user line's content blocks: settle every remembered transition whose result is here. */
45
+ observeResultBlocks(source: string, blocks: readonly unknown[]): void;
46
+ private stateOf;
47
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * DX-3962 — which card a sub-agent is working, read from the sub-agent's OWN
3
+ * transcript.
4
+ *
5
+ * An operator session builds most cards through sub-agents that claim the card
6
+ * with a manual `pickup`, which creates no dispatch row. Every such claim is in
7
+ * the sub-agent's transcript: a `danxbot_api` `tool_use`
8
+ * `POST /api/issues/<ID>/transition {action: "pickup"}` and, later, the
9
+ * `tool_result` carrying the server's answer. This tracker turns those into the
10
+ * card id every one of the sub-agent's replies is credited to.
11
+ *
12
+ * ## The rule (DX-3962 plan-architecture finding 5)
13
+ *
14
+ * Per source, an ORDERED set of claimed cards.
15
+ * - a successful `pickup` appends the card, or moves it to the end when it is
16
+ * already held;
17
+ * - a successful `complete` / `cancel` / `block` / `rollback_pickup` of a held
18
+ * card BY THE SAME SOURCE removes it. (`review` is not a transition action —
19
+ * Review is a derived status, `issues/derive/status.ts` — so the card's list
20
+ * of releases names only the four that exist.)
21
+ * - a reply's card is the LAST element of the set at the reply's first line, by
22
+ * transcript order, never wall clock. An empty set is `null`.
23
+ *
24
+ * Success is read from the RESULT, never the request: a claim or release takes
25
+ * effect only on the `tool_result` for its own `tool_use_id` whose body is
26
+ * `{"ok": true, ...}`. A refused pickup (409) never attributes and a refused
27
+ * release never releases.
28
+ *
29
+ * `main` never attributes (epic decision): its replies roll up to the plan or the
30
+ * board. Quality-gate verdicts, comments and edits are not transitions, so they
31
+ * never set a claim — a reviewer touching several cards stays unattributed.
32
+ * Documented unattributed cases: a nested sub-agent with no pickup of its own, and
33
+ * the reply that issued the pickup call itself (its first line precedes the
34
+ * successful result).
35
+ *
36
+ * ## The state belongs to a transcript position
37
+ *
38
+ * The state is the held set PLUS the transitions whose `tool_use` has been read
39
+ * and whose `tool_result` has not (`pending`). Both are persisted beside the
40
+ * source's read cursor (`dispatch_source_cursors.claims`): a bridge that restarts
41
+ * resumes at its stored `acked` offset, and a pickup that sits before it is never
42
+ * re-read, so without the state every later reply would go unattributed. The
43
+ * pending half closes the narrow window where the restart lands between a pickup's
44
+ * `tool_use` and its `tool_result`.
45
+ *
46
+ * Pure bookkeeping — no `node:` imports, so it rides the browser-safe subpath too.
47
+ */
48
+ import { MAIN_SOURCE } from "./usage-ledger.js";
49
+ /** The one tool a sub-agent reaches the dashboard API through (the MCP server key is `danx-dashboard`). */
50
+ export const DASHBOARD_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
51
+ /** A card id as the issue routes name it, e.g. `DX-3962`. Also the wire's validation for every card id a claim carries. */
52
+ export const ISSUE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9]*-[0-9]+$/;
53
+ /** Longest card id a claim may carry (`dispatches.issue_id` is `VARCHAR(32)`). */
54
+ export const ISSUE_ID_MAX_LENGTH = 32;
55
+ /** The transition that claims a card. */
56
+ const CLAIM_ACTION = "pickup";
57
+ /** The transitions that end a sub-agent's hold on a card. */
58
+ const RELEASE_ACTIONS = new Set(["complete", "cancel", "block", "rollback_pickup"]);
59
+ const TRANSITION_PATH = /^\/api\/issues\/([A-Za-z][A-Za-z0-9]*-[0-9]+)\/transition$/;
60
+ /**
61
+ * Bounds that keep one source's persisted state, and the stats write that carries
62
+ * it, bounded. A real sub-agent holds one or two cards, so neither is reachable;
63
+ * the OLDEST entry is the one dropped, because the newest claim is what the next
64
+ * reply is credited to.
65
+ */
66
+ export const MAX_HELD_CLAIMS = 50;
67
+ export const MAX_PENDING_TRANSITIONS = 50;
68
+ export function emptyClaimState() {
69
+ return { held: [], pending: {} };
70
+ }
71
+ function cloneState(s) {
72
+ return {
73
+ held: [...s.held],
74
+ pending: Object.fromEntries(Object.entries(s.pending).map(([id, p]) => [id, { ...p }])),
75
+ };
76
+ }
77
+ /** The server's answer to a `danxbot_api` call is the JSON text `{"ok": true|false, "status": ...}`; only a leading `ok:true` is a success. */
78
+ const OK_TRUE = /^\s*\{\s*"ok"\s*:\s*true\b/;
79
+ function resultText(content) {
80
+ if (typeof content === "string")
81
+ return content;
82
+ if (!Array.isArray(content))
83
+ return "";
84
+ return content
85
+ .map((part) => {
86
+ const p = part;
87
+ return p !== null && typeof p === "object" && p.type === "text" && typeof p.text === "string" ? p.text : "";
88
+ })
89
+ .join("");
90
+ }
91
+ /**
92
+ * Whether a `tool_result` block reports a successful `danxbot_api` call. Anything
93
+ * that is not a leading `{"ok": true` is NOT a success: an errored tool call, a
94
+ * 409's `{"ok": false, ...}`, a refusal, text the tool never produces. A truncated
95
+ * body still starts with its `ok`, so a large answer reads correctly.
96
+ */
97
+ function resultSucceeded(block) {
98
+ if (block.is_error === true)
99
+ return false;
100
+ return OK_TRUE.test(resultText(block.content));
101
+ }
102
+ /** The transition a `tool_use` block asks for, or `null` when it is not a claim or release of a card. */
103
+ function transitionOf(block) {
104
+ if (block.type !== "tool_use" || block.name !== DASHBOARD_API_TOOL_NAME)
105
+ return null;
106
+ const input = block.input;
107
+ if (input === null || typeof input !== "object")
108
+ return null;
109
+ if (typeof input.method !== "string" || input.method.toUpperCase() !== "POST")
110
+ return null;
111
+ if (typeof input.path !== "string")
112
+ return null;
113
+ const match = TRANSITION_PATH.exec(input.path);
114
+ if (match === null)
115
+ return null;
116
+ const body = input.body;
117
+ if (body === null || typeof body !== "object" || typeof body.action !== "string")
118
+ return null;
119
+ if (match[1].length > ISSUE_ID_MAX_LENGTH)
120
+ return null;
121
+ if (body.action === CLAIM_ACTION)
122
+ return { op: "claim", issueId: match[1] };
123
+ if (RELEASE_ACTIONS.has(body.action))
124
+ return { op: "release", issueId: match[1] };
125
+ return null;
126
+ }
127
+ /**
128
+ * The per-process claim tracker: one state per sub-agent source. Fed every
129
+ * transcript line by `DispatchUsage` (operator-bridge mode only — the worker never
130
+ * constructs one), seeded from the stored cursor at attach, and snapshotted per
131
+ * source for the stats write.
132
+ */
133
+ export class ClaimTracker {
134
+ states = new Map();
135
+ /** Replace a source's state — at attach (the stored cursor's claims) and on a past-EOF reset (empty). */
136
+ seed(source, state) {
137
+ if (source === MAIN_SOURCE)
138
+ return;
139
+ this.states.set(source, cloneState(state));
140
+ }
141
+ /** A copy of the source's state as of the last line fed; empty for a source that holds nothing. */
142
+ snapshot(source) {
143
+ const state = this.states.get(source);
144
+ return state === undefined ? emptyClaimState() : cloneState(state);
145
+ }
146
+ /** The card a reply whose first line is read NOW is credited to; `null` when the source holds none. */
147
+ issueFor(source) {
148
+ const held = this.states.get(source)?.held;
149
+ return held === undefined || held.length === 0 ? null : held[held.length - 1];
150
+ }
151
+ /** An assistant line's content blocks: remember every claim / release the line asks for until its result arrives. */
152
+ observeAssistantBlocks(source, blocks) {
153
+ if (source === MAIN_SOURCE)
154
+ return;
155
+ for (const raw of blocks) {
156
+ const block = raw;
157
+ if (block === null || typeof block !== "object" || typeof block.id !== "string")
158
+ continue;
159
+ const transition = transitionOf(block);
160
+ if (transition === null)
161
+ continue;
162
+ const state = this.stateOf(source);
163
+ delete state.pending[block.id];
164
+ state.pending[block.id] = transition;
165
+ const ids = Object.keys(state.pending);
166
+ for (const stale of ids.slice(0, Math.max(0, ids.length - MAX_PENDING_TRANSITIONS)))
167
+ delete state.pending[stale];
168
+ }
169
+ }
170
+ /** A user line's content blocks: settle every remembered transition whose result is here. */
171
+ observeResultBlocks(source, blocks) {
172
+ if (source === MAIN_SOURCE)
173
+ return;
174
+ const state = this.states.get(source);
175
+ if (state === undefined)
176
+ return;
177
+ for (const raw of blocks) {
178
+ const block = raw;
179
+ if (block === null || typeof block !== "object" || block.type !== "tool_result")
180
+ continue;
181
+ if (typeof block.tool_use_id !== "string")
182
+ continue;
183
+ const pending = state.pending[block.tool_use_id];
184
+ if (pending === undefined)
185
+ continue;
186
+ delete state.pending[block.tool_use_id];
187
+ if (!resultSucceeded(block))
188
+ continue;
189
+ state.held = state.held.filter((id) => id !== pending.issueId);
190
+ if (pending.op === "claim") {
191
+ state.held.push(pending.issueId);
192
+ if (state.held.length > MAX_HELD_CLAIMS)
193
+ state.held = state.held.slice(state.held.length - MAX_HELD_CLAIMS);
194
+ }
195
+ }
196
+ }
197
+ stateOf(source) {
198
+ let state = this.states.get(source);
199
+ if (state === undefined) {
200
+ state = emptyClaimState();
201
+ this.states.set(source, state);
202
+ }
203
+ return state;
204
+ }
205
+ }
@@ -26,14 +26,39 @@ export interface StatsSink {
26
26
  }
27
27
  export declare function zeroUsageTotals(): UsageTotals;
28
28
  export type FlushOutcome = "ok" | "failed" | "refused";
29
+ /**
30
+ * DX-3962 - which transcript owner a collector is built for. It decides whether
31
+ * replies are attributed to cards, and it has no default: every construction names
32
+ * its path.
33
+ *
34
+ * - `"worker"` - a dispatched worker. NO claim tracker, and every entry's
35
+ * `issueId` is `null` whatever the transcript contains (a worker picks its card
36
+ * up server-side, its transcript has no pickup, and its dispatch row already
37
+ * carries the card). The server refuses a non-null `issueId` from a non-operator
38
+ * dispatch by refusing the WHOLE batch, which would stall the worker's stats,
39
+ * token ceiling and spend alert on every resend - so a worker is structurally
40
+ * unable to send one.
41
+ * - `"operator-bridge"` - the desktop bridge of an operator session
42
+ * (`danx-dashboard-mcp`'s `session-stats.ts`), the only path that attributes.
43
+ */
44
+ export type DispatchUsageMode = "worker" | "operator-bridge";
29
45
  export declare class DispatchUsage {
30
46
  readonly counter: UsageLedgerCounter;
47
+ /** DX-3962 - `null` in `"worker"` mode: the worker can never create a claim, so it can never send an `issueId`. */
48
+ private readonly claims;
49
+ /**
50
+ * DX-3962 - per source, the claim state as of its `acked` offset: what the next
51
+ * write reports with `acked`, promoted together with it. The state the tracker
52
+ * holds NOW belongs to `delivered`, which runs ahead.
53
+ */
54
+ private readonly ackedClaims;
31
55
  private serverTotals;
32
56
  private readonly acked;
33
57
  private healthy;
34
58
  private refused;
35
59
  private started;
36
60
  private readonly queue;
61
+ constructor(mode: DispatchUsageMode);
37
62
  /** How to read the watcher's live per-source cursor state. */
38
63
  private readCursorState;
39
64
  /**
@@ -179,6 +204,7 @@ export declare class DispatchUsage {
179
204
  * estimate does not count it twice (see {@link DispatchUsage.unacknowledged}).
180
205
  */
181
206
  observeEntry(entry: AgentLogEntry, replay?: boolean): void;
207
+ private observeReply;
182
208
  /**
183
209
  * Ship every changed entry in capped batches, one after another. The first
184
210
  * call always writes (even with nothing to send) so the process takes the
@@ -16,7 +16,8 @@
16
16
  * DB-free: runs in the DB-less worker. The sink decides the transport
17
17
  * (HTTP on a registered worker, getPool-direct on the dashboard).
18
18
  */
19
- import { UsageLedgerCounter, ledgerLineFromLogEntry, excessUsage, syntheticMessageKey, } from "./usage-ledger.js";
19
+ import { MAIN_SOURCE, UsageLedgerCounter, ledgerLineFromLogEntry, excessUsage, syntheticMessageKey, } from "./usage-ledger.js";
20
+ import { ClaimTracker, emptyClaimState } from "./claim-tracker.js";
20
21
  import { ACTIVITY_BATCH_CAP, DispatchUsageTerminalError, chunkEntries, collapseActivityChanges, } from "./stats-wire.js";
21
22
  import { createUsageWriteQueue } from "./write-queue.js";
22
23
  import { createLogger } from "./internal-logger.js";
@@ -33,26 +34,25 @@ export function zeroUsageTotals() {
33
34
  toolCallCount: 0,
34
35
  };
35
36
  }
36
- /*
37
- * DX-2954 — `isPreCardUsageRow` was DELETED here, along with
38
- * `src/agent/pre-card-lineage.ts` and `markPreCardLineage` below.
39
- *
40
- * It answered "did this row count usage before the ledger existed?", which
41
- * mattered only because a dispatch had nowhere to record HOW FAR it had read:
42
- * the choice was between re-reading a file from 0 (double-counting) and skipping
43
- * to its end (losing everything since the last write). `src/agent/start-cursors.ts`
44
- * dissolves the question — a row WITH stored cursors resumes from them whatever
45
- * its lineage, and a row WITHOUT any is exactly the case the start order's rule
46
- * 4 covers. Nothing writes `usage_pre_card_lineage` any more.
47
- */
48
37
  export class DispatchUsage {
49
38
  counter = new UsageLedgerCounter();
39
+ /** DX-3962 - `null` in `"worker"` mode: the worker can never create a claim, so it can never send an `issueId`. */
40
+ claims;
41
+ /**
42
+ * DX-3962 - per source, the claim state as of its `acked` offset: what the next
43
+ * write reports with `acked`, promoted together with it. The state the tracker
44
+ * holds NOW belongs to `delivered`, which runs ahead.
45
+ */
46
+ ackedClaims = new Map();
50
47
  serverTotals = zeroUsageTotals();
51
48
  acked = new Map();
52
49
  healthy = true;
53
50
  refused = false;
54
51
  started = false;
55
52
  queue = createUsageWriteQueue();
53
+ constructor(mode) {
54
+ this.claims = mode === "operator-bridge" ? new ClaimTracker() : null;
55
+ }
56
56
  // ── DX-2954: the cursor half ──────────────────────────────────────────────
57
57
  /** How to read the watcher's live per-source cursor state. */
58
58
  readCursorState = null;
@@ -109,6 +109,10 @@ export class DispatchUsage {
109
109
  this.startCursors = startCursors;
110
110
  for (const [source, cursor] of Object.entries(startCursors)) {
111
111
  this.ackedCursor.set(source, cursor.acked);
112
+ // DX-3962 - the watcher reads each source from its stored `acked`, so the tracker starts from the claims
113
+ // stored with that offset: a pickup before it is never read again.
114
+ this.ackedClaims.set(source, cursor.claims);
115
+ this.claims?.seed(source, cursor.claims);
112
116
  }
113
117
  }
114
118
  /**
@@ -125,6 +129,10 @@ export class DispatchUsage {
125
129
  // that can never be satisfied. The bytes we believed acknowledged do not
126
130
  // exist any more, so claiming them is simply false.
127
131
  this.ackedCursor.set(info.source, 0);
132
+ // DX-3962 - the claims recorded against the old file name a transcript that is gone; the source re-earns them
133
+ // from the lines it re-reads from 0 (the server clears the stored claims in the same statement as the cursor).
134
+ this.ackedClaims.set(info.source, emptyClaimState());
135
+ this.claims?.seed(info.source, emptyClaimState());
128
136
  }
129
137
  /**
130
138
  * DX-2954 — queue running-set changes for the next write.
@@ -210,8 +218,9 @@ export class DispatchUsage {
210
218
  buildCursorReports() {
211
219
  const reports = {};
212
220
  const deliveredAtSend = new Map();
221
+ const claimsAtSend = new Map();
213
222
  if (!this.readCursorState) {
214
- return { reports, deliveredAtSend, reportedResets: [], carriesStartCursors: false };
223
+ return { reports, deliveredAtSend, claimsAtSend, reportedResets: [], carriesStartCursors: false };
215
224
  }
216
225
  const resetBySource = new Map(this.pastEof.map((p) => [p.source, p]));
217
226
  const reportedResets = [];
@@ -221,7 +230,7 @@ export class DispatchUsage {
221
230
  for (const [source, start] of Object.entries(this.startCursors)) {
222
231
  if (source in live)
223
232
  continue; // the live state supersedes it once opened
224
- reports[source] = { acked: start.acked, delivered: start.delivered };
233
+ reports[source] = { acked: start.acked, delivered: start.delivered, claims: start.claims };
225
234
  carriesStartCursors = true;
226
235
  }
227
236
  }
@@ -230,7 +239,13 @@ export class DispatchUsage {
230
239
  // under rule 4 must never claim byte 0 as acknowledged, or the next
231
240
  // restart would read the whole file and re-sum the history rule 4 skipped.
232
241
  const acked = Math.max(this.ackedCursor.get(source) ?? 0, state.startOffset);
233
- const report = { acked, delivered: state.delivered };
242
+ // The claims reported with `acked` are the state AS OF `acked`, never the tracker's current state (which
243
+ // belongs to `delivered`): a restart resumes at `acked`, so that is the position the claims must describe.
244
+ const report = {
245
+ acked,
246
+ delivered: state.delivered,
247
+ claims: this.ackedClaims.get(source) ?? emptyClaimState(),
248
+ };
234
249
  const reset = resetBySource.get(source);
235
250
  if (reset) {
236
251
  report.pastEof = { storedOffset: reset.storedOffset, fileSize: reset.fileSize };
@@ -238,12 +253,13 @@ export class DispatchUsage {
238
253
  }
239
254
  reports[source] = report;
240
255
  deliveredAtSend.set(source, state.delivered);
256
+ claimsAtSend.set(source, this.claims?.snapshot(source) ?? emptyClaimState());
241
257
  }
242
258
  // Only the resets that actually reached a report are consumed. A reset for a
243
259
  // source that has left `cursorState()` (its tail dropped on ENOENT) would
244
260
  // otherwise be taken and forgotten, leaving the stored `acked` at a value the
245
261
  // file can never satisfy.
246
- return { reports, deliveredAtSend, reportedResets, carriesStartCursors };
262
+ return { reports, deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors };
247
263
  }
248
264
  /**
249
265
  * DX-2954 — whether the cursor half has anything new to say. An IDLE dispatch
@@ -272,9 +288,21 @@ export class DispatchUsage {
272
288
  * estimate does not count it twice (see {@link DispatchUsage.unacknowledged}).
273
289
  */
274
290
  observeEntry(entry, replay = false) {
291
+ const content = Array.isArray(entry.data.content) ? entry.data.content : [];
292
+ const source = typeof entry.data.subagent_id === "string" ? entry.data.subagent_id : MAIN_SOURCE;
293
+ // DX-3962 - a `tool_result` settles a pickup / release the source asked for earlier.
294
+ if (entry.type === "user")
295
+ this.claims?.observeResultBlocks(source, content);
275
296
  const line = ledgerLineFromLogEntry(entry);
276
- if (!line)
277
- return;
297
+ if (line)
298
+ this.observeReply(line, replay);
299
+ // AFTER the reply is counted: a reply is credited to the card held at its FIRST line, so the pickup this line
300
+ // itself asks for takes effect only on its result, and never on the reply that issued it.
301
+ if (entry.type === "assistant")
302
+ this.claims?.observeAssistantBlocks(source, content);
303
+ }
304
+ observeReply(line, replay) {
305
+ line.issueId = this.claims?.issueFor(line.source) ?? null;
278
306
  // The counter's OWN key, not `line.messageId`: a usage line with no
279
307
  // `message.id` is keyed by `syntheticMessageKey(source, byteOffset)`
280
308
  // (`usage-ledger.ts`), and recording a different key here would leave that
@@ -304,7 +332,7 @@ export class DispatchUsage {
304
332
  this.flushBegan = true;
305
333
  const changed = this.counter.takeChanged();
306
334
  const batches = chunkEntries(changed);
307
- const { reports, deliveredAtSend, reportedResets, carriesStartCursors } = this.buildCursorReports();
335
+ const { reports, deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors } = this.buildCursorReports();
308
336
  // TAKE the queued side data, exactly as `counter.takeChanged()` does, so a
309
337
  // hook that fires during the await lands in the NEXT write rather than
310
338
  // being silently dropped by a post-write reset of the live array.
@@ -370,6 +398,8 @@ export class DispatchUsage {
370
398
  if (refused.has(source) || resetMidFlight.has(source))
371
399
  continue;
372
400
  this.ackedCursor.set(source, delivered);
401
+ // DX-3962 - the claims advance with the offset they describe, never ahead of it.
402
+ this.ackedClaims.set(source, claimsAtSend.get(source));
373
403
  }
374
404
  if (payload.transcriptPath !== undefined)
375
405
  this.transcriptPathSent = true;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSum
21
21
  export * from "./usage-ledger.js";
22
22
  export * from "./pricing.js";
23
23
  export * from "./stats-wire.js";
24
+ export * from "./claim-tracker.js";
24
25
  export * from "./dispatch-usage.js";
25
26
  export * from "./activity-observer.js";
26
27
  export * from "./write-queue.js";
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ export * from "./pricing.js";
32
32
  // running-set changes, the transcript-side running-set producer, and the FIFO
33
33
  // that orders a dispatch's writes.
34
34
  export * from "./stats-wire.js";
35
+ export * from "./claim-tracker.js";
35
36
  export * from "./dispatch-usage.js";
36
37
  export * from "./activity-observer.js";
37
38
  export * from "./write-queue.js";
package/dist/pricing.d.ts CHANGED
@@ -31,6 +31,17 @@
31
31
  * Haiku 3.5) are not in Claude Code's own model-tag surface and are left out
32
32
  * on the same basis the table already excludes them.
33
33
  *
34
+ * DX-3960 — re-read the whole "Model pricing" table and the "Fast mode pricing"
35
+ * section again, 2026-10-01, https://platform.claude.com/docs/en/about-claude/pricing
36
+ * (cross-checked against the claude-api skill's model table). Found:
37
+ * `claude-sonnet-5-5` ($2 in / $10 out, $2.50 5m write, $4 1h write, $0.20 hit)
38
+ * was missing, so every Sonnet 5.5 bucket in production priced at $0; added.
39
+ * Every other row matched; fast mode is still Opus 5.5 / Opus 5 / Opus 4.8 only.
40
+ * A model id with no row is never silently $0: `priceUsage` returns
41
+ * `unpriced`, the ledger writer records a visible `unpriced_model` dispatch
42
+ * event for it, and `src/agent/priced-models.test.ts` (danxbot root) fails the
43
+ * suite when a model danxbot can select has no row.
44
+ *
34
45
  * DX-2963 — cache writes are TWO prices, never one: a 5-minute write is
35
46
  * 1.25x base input and a 1-hour write is 2x base input. Claude Code reports
36
47
  * each lifetime separately (`usage.cache_creation.ephemeral_5m_input_tokens` /
package/dist/pricing.js CHANGED
@@ -31,6 +31,17 @@
31
31
  * Haiku 3.5) are not in Claude Code's own model-tag surface and are left out
32
32
  * on the same basis the table already excludes them.
33
33
  *
34
+ * DX-3960 — re-read the whole "Model pricing" table and the "Fast mode pricing"
35
+ * section again, 2026-10-01, https://platform.claude.com/docs/en/about-claude/pricing
36
+ * (cross-checked against the claude-api skill's model table). Found:
37
+ * `claude-sonnet-5-5` ($2 in / $10 out, $2.50 5m write, $4 1h write, $0.20 hit)
38
+ * was missing, so every Sonnet 5.5 bucket in production priced at $0; added.
39
+ * Every other row matched; fast mode is still Opus 5.5 / Opus 5 / Opus 4.8 only.
40
+ * A model id with no row is never silently $0: `priceUsage` returns
41
+ * `unpriced`, the ledger writer records a visible `unpriced_model` dispatch
42
+ * event for it, and `src/agent/priced-models.test.ts` (danxbot root) fails the
43
+ * suite when a model danxbot can select has no row.
44
+ *
34
45
  * DX-2963 — cache writes are TWO prices, never one: a 5-minute write is
35
46
  * 1.25x base input and a 1-hour write is 2x base input. Claude Code reports
36
47
  * each lifetime separately (`usage.cache_creation.ephemeral_5m_input_tokens` /
@@ -76,6 +87,10 @@ const PRICING = {
76
87
  "claude-opus-4-5": price(5, 25, 0.5),
77
88
  "claude-opus-4-20250514": price(15, 75, 1.5),
78
89
  "claude-sonnet-5": price(2, 10, 0.2),
90
+ // DX-3960 — Claude Sonnet 5.5, page row ($2 in / $2.50 5m / $4 1h / $0.20 hit /
91
+ // $10 out). Its absence priced every sonnet-5-5 bucket at $0. No fast row: the
92
+ // page's "Fast mode pricing" section lists Opus 5.5 / Opus 5 / Opus 4.8 only.
93
+ "claude-sonnet-5-5": price(2, 10, 0.2),
79
94
  "claude-sonnet-4-6": price(3, 15, 0.3),
80
95
  "claude-sonnet-4-5-20250929": price(3, 15, 0.3),
81
96
  "claude-sonnet-4-20250514": price(3, 15, 0.3),
@@ -10,6 +10,7 @@
10
10
  * server, and the response hands them back.
11
11
  */
12
12
  import type { LedgerEntry, LedgerUsage } from "./usage-ledger.js";
13
+ import { type ClaimState } from "./claim-tracker.js";
13
14
  /**
14
15
  * DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
15
16
  * session read from the start) is sent as consecutive capped batches, so one
@@ -27,6 +28,11 @@ export declare const STATS_BATCH_CAP = 500;
27
28
  *
28
29
  * Lines between them are re-read on a restart and handed on as REPLAYS.
29
30
  *
31
+ * `claims` (DX-3962) is the source's claim state AS OF `acked` - the card(s) the
32
+ * sub-agent holds, so a collector that resumes at `acked` attributes the next
33
+ * reply to the right card without re-reading the pickup that sits before it.
34
+ * A worker's is always empty: it has no claim tracker.
35
+ *
30
36
  * "None stored" is the ABSENCE of a source's key, never `{acked: 0}` — 0 is a
31
37
  * legitimate stored cursor meaning "start of file", and CP1 forbids a reader
32
38
  * defaulting a missing persisted value.
@@ -34,6 +40,7 @@ export declare const STATS_BATCH_CAP = 500;
34
40
  export interface SourceCursor {
35
41
  acked: number;
36
42
  delivered: number;
43
+ claims: ClaimState;
37
44
  }
38
45
  /** DX-2954 — per-source cursors. An absent key means none stored (never 0). */
39
46
  export type SourceCursors = Record<string, SourceCursor>;
@@ -1,3 +1,4 @@
1
+ import { ISSUE_ID_MAX_LENGTH, ISSUE_ID_PATTERN, MAX_HELD_CLAIMS, MAX_PENDING_TRANSITIONS, } from "./claim-tracker.js";
1
2
  /**
2
3
  * DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
3
4
  * session read from the start) is sent as consecutive capped batches, so one
@@ -109,7 +110,52 @@ export function collapseActivityChanges(changes) {
109
110
  }
110
111
  return [...byKey.values()];
111
112
  }
112
- const CURSOR_KEYS = new Set(["acked", "delivered", "pastEof"]);
113
+ const CURSOR_KEYS = new Set(["acked", "delivered", "claims", "pastEof"]);
114
+ const CLAIM_STATE_KEYS = new Set(["held", "pending"]);
115
+ const PENDING_KEYS = new Set(["op", "issueId"]);
116
+ /** DX-3962 - a card id as a claim or a ledger entry names it: the issue routes' own shape, within the column's width. */
117
+ function isIssueId(v) {
118
+ return typeof v === "string" && v.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(v);
119
+ }
120
+ /** DX-3962 - strict parse of one source's claim state (a collector bug, never coerced). */
121
+ function parseClaimState(raw, where) {
122
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
123
+ throw new StatsPayloadError(where);
124
+ const c = raw;
125
+ for (const k of Object.keys(c)) {
126
+ if (!CLAIM_STATE_KEYS.has(k))
127
+ throw new StatsPayloadError(`${where}.${k} is not a claim-state field`);
128
+ }
129
+ if (!Array.isArray(c.held) || c.held.length > MAX_HELD_CLAIMS || !c.held.every(isIssueId)) {
130
+ throw new StatsPayloadError(`${where}.held must be at most ${MAX_HELD_CLAIMS} card ids`);
131
+ }
132
+ if (!c.pending || typeof c.pending !== "object" || Array.isArray(c.pending)) {
133
+ throw new StatsPayloadError(`${where}.pending must be an object`);
134
+ }
135
+ const pendingEntries = Object.entries(c.pending);
136
+ if (pendingEntries.length > MAX_PENDING_TRANSITIONS) {
137
+ throw new StatsPayloadError(`${where}.pending exceeds the cap of ${MAX_PENDING_TRANSITIONS}`);
138
+ }
139
+ const pending = {};
140
+ for (const [toolUseId, value] of pendingEntries) {
141
+ const w = `${where}.pending[${toolUseId}]`;
142
+ if (!toolUseId)
143
+ throw new StatsPayloadError(`${where}.pending key must be a non-empty tool-use id`);
144
+ if (!value || typeof value !== "object")
145
+ throw new StatsPayloadError(w);
146
+ const p = value;
147
+ for (const k of Object.keys(p)) {
148
+ if (!PENDING_KEYS.has(k))
149
+ throw new StatsPayloadError(`${w}.${k} is not a pending-transition field`);
150
+ }
151
+ if (p.op !== "claim" && p.op !== "release")
152
+ throw new StatsPayloadError(`${w}.op must be claim or release`);
153
+ if (!isIssueId(p.issueId))
154
+ throw new StatsPayloadError(`${w}.issueId`);
155
+ pending[toolUseId] = { op: p.op, issueId: p.issueId };
156
+ }
157
+ return { held: [...c.held], pending };
158
+ }
113
159
  const PAST_EOF_KEYS = new Set(["storedOffset", "fileSize"]);
114
160
  function parsePastEof(raw, where) {
115
161
  if (!raw || typeof raw !== "object")
@@ -156,7 +202,11 @@ function parseSourceCursors(raw) {
156
202
  if (c.delivered < c.acked) {
157
203
  throw new StatsPayloadError(`sourceCursors[${source}]: delivered (${c.delivered}) is behind acked (${c.acked})`);
158
204
  }
159
- const report = { acked: c.acked, delivered: c.delivered };
205
+ const report = {
206
+ acked: c.acked,
207
+ delivered: c.delivered,
208
+ claims: parseClaimState(c.claims, `sourceCursors[${source}].claims`),
209
+ };
160
210
  if (c.pastEof !== undefined) {
161
211
  report.pastEof = parsePastEof(c.pastEof, `sourceCursors[${source}].pastEof`);
162
212
  }
@@ -265,6 +315,10 @@ export function parseStatsPayload(body) {
265
315
  if (!Array.isArray(e.toolUseIds) || e.toolUseIds.some((t) => typeof t !== "string")) {
266
316
  throw new StatsPayloadError(`${where}.toolUseIds`);
267
317
  }
318
+ // DX-3962 - REQUIRED, `null` or a card id: an entry that omits it comes from a collector that predates
319
+ // attribution, which the MCP version floor refuses rather than this parser tolerating.
320
+ if (e.issueId !== null && !isIssueId(e.issueId))
321
+ throw new StatsPayloadError(`${where}.issueId`);
268
322
  return {
269
323
  messageId: e.messageId,
270
324
  source: e.source,
@@ -279,6 +333,7 @@ export function parseStatsPayload(body) {
279
333
  cacheWrite1h: u.cacheWrite1h,
280
334
  },
281
335
  toolUseIds: e.toolUseIds,
336
+ issueId: e.issueId,
282
337
  };
283
338
  });
284
339
  const payload = { entries };
@@ -39,6 +39,13 @@ export interface LedgerEntry {
39
39
  /** The reply's own transcript timestamp (its FIRST line's), epoch ms. */
40
40
  repliedAt: number;
41
41
  toolUseIds: string[];
42
+ /**
43
+ * DX-3962 - the card this reply is credited to, or `null`. Fixed by the
44
+ * reply's FIRST line and never changed by a later one. Only an operator-bridge
45
+ * collector (`DispatchUsage`, `"operator-bridge"` mode) ever sets it; every
46
+ * other producer of an entry carries `null`.
47
+ */
48
+ issueId: string | null;
42
49
  }
43
50
  /** The fields of one assistant line the counter needs. */
44
51
  export interface LedgerLine {
@@ -53,6 +60,8 @@ export interface LedgerLine {
53
60
  timestamp: number;
54
61
  /** `tool_use` block ids on this line. */
55
62
  toolUseIds: readonly string[];
63
+ /** DX-3962 - the card the line's reply is credited to when this is its first line; see {@link LedgerEntry.issueId}. */
64
+ issueId: string | null;
56
65
  }
57
66
  export declare const MAIN_SOURCE = "main";
58
67
  /**
@@ -140,6 +140,7 @@ export class UsageLedgerCounter {
140
140
  usage,
141
141
  repliedAt: line.timestamp,
142
142
  toolUseIds: [...new Set(line.toolUseIds)],
143
+ issueId: line.issueId,
143
144
  });
144
145
  this.changed.add(key);
145
146
  return true;
@@ -247,6 +248,7 @@ export function ledgerLineFromRaw(raw, source, byteOffset) {
247
248
  toolUseIds: content
248
249
  .filter((b) => b.type === "tool_use" && typeof b.id === "string")
249
250
  .map((b) => b.id),
251
+ issueId: null,
250
252
  };
251
253
  }
252
254
  /**
@@ -318,5 +320,6 @@ export function ledgerLineFromLogEntry(entry) {
318
320
  usage: hasUsage ? d.usage : undefined,
319
321
  timestamp: entry.timestamp,
320
322
  toolUseIds,
323
+ issueId: null,
321
324
  };
322
325
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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",