@thehammer/danx-session-telemetry 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,9 +39,9 @@ own header comments for the per-file reasoning.
39
39
 
40
40
  | Consumer | Mechanism | Why |
41
41
  |---|---|---|
42
- | **danxbot worker** (root repo, `tsx src/index.ts`) | npm **workspace** link (`"workspaces": ["packages/*"]` in root `package.json`, `"@thehammer/danx-session-telemetry"` in root `dependencies`) | The worker and this package always ship from the SAME repo commit — a workspace link means the worker runs whatever source the current commit carries, with zero drift risk and no dependency on registry propagation timing. The Docker image builds `dist/` for it in a dedicated builder stage (mirroring `dashboard-builder`/`frontend-builder` in `Dockerfile`) since the final image's `npm ci --omit=dev` never installs this package's own `devDependencies` (`typescript`). |
42
+ | **danxbot worker** (root repo, `tsx src/index.ts`) | npm **workspace** link (`"workspaces": ["packages/*"]` in root `package.json`, `"@thehammer/danx-session-telemetry"` in root `dependencies`) | The worker and this package always ship from the SAME repo commit — a workspace link means the worker runs whatever source the current commit carries, with zero drift risk and no dependency on registry propagation timing. The Docker image builds `dist/` for it in a dedicated builder stage (alongside `frontend-builder` in `Dockerfile`) since the final image's `npm ci --omit=dev` never installs this package's own `devDependencies` (`typescript`). |
43
43
  | **`danx-dashboard-mcp` desktop bridge** (separate npm-published package, DX-2954) | Ordinary npm dependency, **pinned to the exact published version** the worker's commit ships | The bridge is a genuinely separate installable artifact (`npx -y @thehammer/danx-dashboard-mcp`), so it cannot workspace-link into danxbot's own checkout — it needs the real published bytes. Pinning exact (not `^`/`~`) is what makes "worker and MCP run identical collector code for any given release" true: MCP's own version bump is the only thing that ever changes what collector code it runs. |
44
- | **Dashboard server / dashboard SPA** (`src/dashboard/*`, `dashboard/*`) | Same npm dependency shape as the worker (dashboard's `src/` is root-repo code, so it resolves through the SAME root workspace link as the worker; `dashboard/` is its own separate npm package — DX-2963, the first real consumer there, adds an ordinary `dashboard/package.json` dependency, resolved by Vite/Vitest/`vue-tsc` through plain node_modules resolution, no alias needed) | No `@backend`-style alias is needed for an npm dependency the way one is needed for `dashboard/`'s reach into root `src/` — that alias exists because `dashboard/` is a SEPARATE package with no other way to reach root source; a real npm dependency resolves through ordinary node_modules lookup in every one of Vite's two configs, Vitest, `vue-tsc`, and the Dockerfile builder stages (`dashboard-deps-builder` runs `cd dashboard && npm ci` from `dashboard/`'s own manifests, so a `dashboard/package.json` dependency is resolvable with zero Dockerfile change). `dashboard/` is FROZEN (`.claude/rules/ui-deprecation.md`) — this card adds no dependency there since nothing consumes it yet; DX-2963 adds the one line when it wires the real counter into `DispatchDetail.vue`/`DispatchList.vue`. |
44
+ | **Dashboard server** (`src/dashboard/*`) | Same npm dependency shape as the worker (the dashboard server's `src/` is root-repo code, so it resolves through the SAME root workspace link as the worker) | An npm dependency resolves through ordinary node_modules lookup, so no `@backend`-style alias is needed. |
45
45
 
46
46
  ## Release order (DX-2962 AC3)
47
47
 
package/dist/.source-hash CHANGED
@@ -1 +1 @@
1
- 89cd1ca4f2b51d45e885401f25cd2013ae9a293b3f3788aa820d45ea63e2a004
1
+ 3ffba7fcfab27ba33e21879f9c097dc9eaafaf4a688857098edbd721e2f81300
@@ -9,30 +9,41 @@
9
9
  *
10
10
  * ## Why the transcript, and where the hooks come in
11
11
  *
12
- * Claude Code's `SubagentStart` / `SubagentStop` hooks carry strictly more (an
13
- * `agent_type` before the sub-agent has written anything, and — the part this
14
- * module cannot supply — a FINISH). They ship from the danxbot plugin in the
12
+ * Claude Code's `SubagentStart` / `SubagentStop` hooks carry an `agent_type`
13
+ * before the sub-agent has written anything and a FINISH for every sub-agent,
14
+ * foreground or background. They ship from the danxbot plugin in the
15
15
  * claude-plugins repo (DX-3284, `danxbot/scripts/activity-report.mjs`), run the
16
16
  * `activity` subcommand of `@thehammer/danx-dashboard-mcp`, and reach the server
17
17
  * through `POST /api/plan-sessions/me/activity` — which writes the SAME
18
18
  * `dispatch_activities` rows through the same one server writer.
19
19
  *
20
20
  * The transcript stays the producer because it needs no hook to be installed: a
21
- * session with no plugin, and every WORKER dispatch, still gets a start row for
22
- * each sub-agent from here. The hooks are an ENRICHMENT layered on the same rows:
21
+ * session with no plugin, and every WORKER dispatch, still gets a row for each
22
+ * sub-agent from here. The hooks are an ENRICHMENT layered on the same rows:
23
23
  *
24
24
  * | Signal | Sub-agent start | Sub-agent finish | Background script |
25
25
  * |---|---|---|---|
26
- * | Transcript (here) | file discovered | dispatch terminal (`finishOpenActivities`) | not visible |
26
+ * | Transcript (here) | file discovered | a background sub-agent's task notification (DX-4498); else the dispatch's terminal write (`finishOpenActivities`) | not visible |
27
27
  * | Hooks (DX-3284, operator sessions) | `SubagentStart` | `SubagentStop` | `PostToolUse` start; end at dispatch terminal (no documented completion event) |
28
28
  *
29
- * **Where "running" closes.** An operator session's sub-agent closes at its
30
- * `SubagentStop` hook. A WORKER dispatch has no hook reporting yet
31
- * (`/me/activity` refuses a worker's session id as `session_is_worker`), so its
32
- * sub-agents stay in the running set until the dispatch itself ends, and the
33
- * silent-too-long warning fires `RUNNING_ACTIVITY_SILENT_AFTER_MS` after a
34
- * sub-agent that has in fact finished. A worker-side `SubagentStop` report is the
35
- * fix, and is not built.
29
+ * ## What it reports about each sub-agent (DX-4498)
30
+ *
31
+ * All of it read from what Claude Code itself writes, with no cooperation from
32
+ * the sub-agent:
33
+ *
34
+ * | Field | Read from |
35
+ * |---|---|
36
+ * | `agentType`, `description`, `parentActivityId` | the sub-agent's `meta.json` sidecar (via `SourceDiscoveredInfo`) |
37
+ * | `lastActivityAt` | the timestamp of its latest `assistant` / `user` line |
38
+ * | `effort` | its latest `assistant` line's top-level `effort` |
39
+ * | `currentActivity` | its latest `tool_use` block, through {@link describeToolCall} |
40
+ * | `briefIssueId` | the card id its brief (the root `user` line) opens with |
41
+ * | `finishedAt`, `endStatus` | the task notification Claude Code writes into the SPAWNER's transcript |
42
+ *
43
+ * Model, tokens, cost, tool-call count and the claimed card are NOT here: the
44
+ * usage ledger already records them per reply, keyed by the same source, and
45
+ * the read derives them from it (two places recording one number is how they
46
+ * drift).
36
47
  *
37
48
  * ## Why the two producers cannot double-count
38
49
  *
@@ -47,20 +58,23 @@
47
58
  * which the subcommand uses to refuse a key that disagrees. That basename is also
48
59
  * the key the ledger credits the sub-agent's tokens under, which is how
49
60
  * per-sub-agent tokens join to the running set without this table carrying a
50
- * token column.
61
+ * token column. A task notification names the sub-agent by its bare id
62
+ * (`<task-id>`), which is mapped onto the same `agent-<id>` key.
51
63
  *
52
- * ## What this module deliberately does NOT do
64
+ * ## A finish is reported only when Claude Code says so
53
65
  *
54
- * It never reports a FINISH. A transcript gives no "this sub-agent is done"
55
- * signal — a finished sub-agent's file simply stops growing, which is
56
- * indistinguishable from a slow one. Guessing a finish from an idle gap would
57
- * mark a long-thinking sub-agent as complete. So open rows are closed exactly
58
- * once, at the dispatch's own terminal transition (`finishOpenActivities`),
59
- * unless the `SubagentStop` hook (DX-3284) closes a row first.
66
+ * A sub-agent's OWN transcript gives no "this sub-agent is done" signal — a
67
+ * finished sub-agent's file simply stops growing, which is indistinguishable
68
+ * from a slow one. Guessing a finish from an idle gap would mark a
69
+ * long-thinking sub-agent as complete, so this module never does. DX-4498: the
70
+ * one finish it reports is EXPLICIT — the `<task-notification>` Claude Code
71
+ * delivers to the spawner when a BACKGROUND task ends, carrying its
72
+ * `<status>` (`completed`, `failed`, `killed`, `stopped`). A foreground
73
+ * sub-agent gets no notification, so its row closes at its `SubagentStop` hook
74
+ * (operator sessions) or at the dispatch's own terminal transition.
60
75
  */
61
- import type { ActivityChange } from "./stats-wire.js";
76
+ import { type ActivityChange, type ActivityEndStatus } from "./stats-wire.js";
62
77
  import type { EntryMeta, SourceDiscoveredInfo } from "./session-log-watcher.js";
63
- import type { AgentLogEntry } from "./types.js";
64
78
  /** What the observer needs from the dispatch's usage accumulator. */
65
79
  export interface ActivityObserverDeps {
66
80
  /**
@@ -84,7 +98,30 @@ export interface ActivityObserverDeps {
84
98
  export interface ActivityObserver {
85
99
  /** Wire to the watcher's `onSourceDiscovered`. */
86
100
  onSourceDiscovered: (info: SourceDiscoveredInfo) => void;
87
- /** Wire to the watcher's `onEntry`. */
88
- onEntry: (entry: AgentLogEntry, meta: EntryMeta) => void;
101
+ /** DX-4498 — wire to the watcher's `onUsageLine` (the raw line feed: the brief and task notifications are not entries). */
102
+ onLine: (raw: Record<string, unknown>, meta: EntryMeta) => void;
89
103
  }
104
+ /**
105
+ * DX-4498 — a task notification's `(task id, end status)`, or null for a line that is not one, or one whose status is
106
+ * not an end (`running`). Only the two delivery shapes Claude Code itself writes are read: a `user` line whose
107
+ * `origin.kind` is `task-notification`, and a `queued_command` attachment with that `commandMode`. A sub-agent's
108
+ * reply that merely QUOTES a notification carries neither marker, so it can never end anything.
109
+ */
110
+ export declare function parseTaskNotification(raw: Record<string, unknown>): {
111
+ taskId: string;
112
+ endStatus: ActivityEndStatus;
113
+ } | null;
114
+ /**
115
+ * DX-4498 — a sub-agent's current activity, from one `tool_use` block: the tool's name, plus a label only where the
116
+ * agent wrote one AS a label (a shell call's `description`, an `Agent` call's `description`, a `Skill` call's skill
117
+ * name). Never a command, a file path or a prompt: the stats wire carries ids, numbers and labels, never free-form
118
+ * transcript content.
119
+ */
120
+ export declare function describeToolCall(name: string, input: Record<string, unknown>): string;
121
+ /**
122
+ * DX-4498 — the card id a brief opens with: the first token of its first non-empty line, when that token is shaped
123
+ * like a card id (`DX-4498`, also `DX-4498:`). Worker briefs open with the card they are for; any other brief names
124
+ * none. The server keeps it only when it names a real card of the team, so a plan ref (`PLAN-2`) never reaches a reader.
125
+ */
126
+ export declare function briefIssueId(brief: string): string | null;
90
127
  export declare function createActivityObserver(deps: ActivityObserverDeps): ActivityObserver;
@@ -1,10 +1,216 @@
1
+ /**
2
+ * DX-2954 — the TRANSCRIPT-side producer of a dispatch's running set.
3
+ *
4
+ * `dispatch_activities` answers "what is this dispatch running RIGHT NOW" — one
5
+ * row per sub-agent or background script, with `finished_at` NULL while it is
6
+ * still going. This module produces the sub-agent half of that set from the one
7
+ * signal danxbot owns end-to-end: the session-log watcher discovering a
8
+ * sub-agent's transcript file and then tailing it.
9
+ *
10
+ * ## Why the transcript, and where the hooks come in
11
+ *
12
+ * Claude Code's `SubagentStart` / `SubagentStop` hooks carry an `agent_type`
13
+ * before the sub-agent has written anything and a FINISH for every sub-agent,
14
+ * foreground or background. They ship from the danxbot plugin in the
15
+ * claude-plugins repo (DX-3284, `danxbot/scripts/activity-report.mjs`), run the
16
+ * `activity` subcommand of `@thehammer/danx-dashboard-mcp`, and reach the server
17
+ * through `POST /api/plan-sessions/me/activity` — which writes the SAME
18
+ * `dispatch_activities` rows through the same one server writer.
19
+ *
20
+ * The transcript stays the producer because it needs no hook to be installed: a
21
+ * session with no plugin, and every WORKER dispatch, still gets a row for each
22
+ * sub-agent from here. The hooks are an ENRICHMENT layered on the same rows:
23
+ *
24
+ * | Signal | Sub-agent start | Sub-agent finish | Background script |
25
+ * |---|---|---|---|
26
+ * | Transcript (here) | file discovered | a background sub-agent's task notification (DX-4498); else the dispatch's terminal write (`finishOpenActivities`) | not visible |
27
+ * | Hooks (DX-3284, operator sessions) | `SubagentStart` | `SubagentStop` | `PostToolUse` start; end at dispatch terminal (no documented completion event) |
28
+ *
29
+ * ## What it reports about each sub-agent (DX-4498)
30
+ *
31
+ * All of it read from what Claude Code itself writes, with no cooperation from
32
+ * the sub-agent:
33
+ *
34
+ * | Field | Read from |
35
+ * |---|---|
36
+ * | `agentType`, `description`, `parentActivityId` | the sub-agent's `meta.json` sidecar (via `SourceDiscoveredInfo`) |
37
+ * | `lastActivityAt` | the timestamp of its latest `assistant` / `user` line |
38
+ * | `effort` | its latest `assistant` line's top-level `effort` |
39
+ * | `currentActivity` | its latest `tool_use` block, through {@link describeToolCall} |
40
+ * | `briefIssueId` | the card id its brief (the root `user` line) opens with |
41
+ * | `finishedAt`, `endStatus` | the task notification Claude Code writes into the SPAWNER's transcript |
42
+ *
43
+ * Model, tokens, cost, tool-call count and the claimed card are NOT here: the
44
+ * usage ledger already records them per reply, keyed by the same source, and
45
+ * the read derives them from it (two places recording one number is how they
46
+ * drift).
47
+ *
48
+ * ## Why the two producers cannot double-count
49
+ *
50
+ * Every row is an upsert keyed `(dispatch_id, kind, activity_id)`, and BOTH
51
+ * producers derive `activity_id` from the same thing: the sub-agent's transcript
52
+ * file basename, `agent-<agent_id>`. The watcher has it directly; a hook has the
53
+ * `agent_id` and builds the same basename from it (`subagentActivityId` in
54
+ * danx-dashboard-mcp's `activity.ts`, which also strips an `agent-` prefix the hooks docs show
55
+ * on some `agent_id`s so both forms reach this key). A hook's `transcript_path` is NOT the
56
+ * sub-agent's file — on these events it is the MAIN session's transcript, and only
57
+ * `SubagentStop` also carries the sub-agent's own as `agent_transcript_path`,
58
+ * which the subcommand uses to refuse a key that disagrees. That basename is also
59
+ * the key the ledger credits the sub-agent's tokens under, which is how
60
+ * per-sub-agent tokens join to the running set without this table carrying a
61
+ * token column. A task notification names the sub-agent by its bare id
62
+ * (`<task-id>`), which is mapped onto the same `agent-<id>` key.
63
+ *
64
+ * ## A finish is reported only when Claude Code says so
65
+ *
66
+ * A sub-agent's OWN transcript gives no "this sub-agent is done" signal — a
67
+ * finished sub-agent's file simply stops growing, which is indistinguishable
68
+ * from a slow one. Guessing a finish from an idle gap would mark a
69
+ * long-thinking sub-agent as complete, so this module never does. DX-4498: the
70
+ * one finish it reports is EXPLICIT — the `<task-notification>` Claude Code
71
+ * delivers to the spawner when a BACKGROUND task ends, carrying its
72
+ * `<status>` (`completed`, `failed`, `killed`, `stopped`). A foreground
73
+ * sub-agent gets no notification, so its row closes at its `SubagentStop` hook
74
+ * (operator sessions) or at the dispatch's own terminal transition.
75
+ */
76
+ import { CURRENT_ACTIVITY_MAX_CHARS, } from "./stats-wire.js";
77
+ import { ISSUE_ID_MAX_LENGTH, ISSUE_ID_PATTERN } from "./claim-tracker.js";
78
+ /** The prefix a sub-agent's source key (its transcript basename) carries before its bare agent id. */
79
+ const SUBAGENT_SOURCE_PREFIX = "agent-";
80
+ /**
81
+ * DX-4498 — how many task notifications for a source not yet discovered are held. A notification normally names a
82
+ * sub-agent whose file was discovered long before it ended; one that arrives first waits here. Background SHELL tasks
83
+ * notify the same way and are never discovered, so the map is bounded: the oldest entry is dropped beyond this.
84
+ */
85
+ const MAX_PENDING_ENDS = 500;
86
+ /** DX-4498 — Claude Code's notification statuses, as the end status the running set stores. */
87
+ const NOTIFICATION_END_STATUS = {
88
+ completed: "completed",
89
+ failed: "failed",
90
+ killed: "stopped",
91
+ stopped: "stopped",
92
+ };
93
+ const NOTIFICATION = /^\s*<task-notification>\s*<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/;
94
+ /**
95
+ * DX-4498 — a task notification's `(task id, end status)`, or null for a line that is not one, or one whose status is
96
+ * not an end (`running`). Only the two delivery shapes Claude Code itself writes are read: a `user` line whose
97
+ * `origin.kind` is `task-notification`, and a `queued_command` attachment with that `commandMode`. A sub-agent's
98
+ * reply that merely QUOTES a notification carries neither marker, so it can never end anything.
99
+ */
100
+ export function parseTaskNotification(raw) {
101
+ let text;
102
+ if (raw.type === "user") {
103
+ const origin = raw.origin;
104
+ if (origin?.kind !== "task-notification")
105
+ return null;
106
+ text = raw.message?.content;
107
+ }
108
+ else if (raw.type === "attachment") {
109
+ const attachment = raw.attachment;
110
+ if (attachment?.type !== "queued_command" || attachment.commandMode !== "task-notification")
111
+ return null;
112
+ text = attachment.prompt;
113
+ }
114
+ else {
115
+ return null;
116
+ }
117
+ if (typeof text !== "string")
118
+ return null;
119
+ const match = NOTIFICATION.exec(text);
120
+ if (match === null)
121
+ return null;
122
+ const endStatus = NOTIFICATION_END_STATUS[match[2].trim()];
123
+ return endStatus === undefined ? null : { taskId: match[1].trim(), endStatus };
124
+ }
125
+ /**
126
+ * DX-4498 — a sub-agent's current activity, from one `tool_use` block: the tool's name, plus a label only where the
127
+ * agent wrote one AS a label (a shell call's `description`, an `Agent` call's `description`, a `Skill` call's skill
128
+ * name). Never a command, a file path or a prompt: the stats wire carries ids, numbers and labels, never free-form
129
+ * transcript content.
130
+ */
131
+ export function describeToolCall(name, input) {
132
+ const label = (v) => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
133
+ let detail = null;
134
+ switch (name) {
135
+ case "Bash":
136
+ case "PowerShell":
137
+ case "Agent":
138
+ case "Task":
139
+ detail = label(input.description);
140
+ break;
141
+ case "Skill":
142
+ detail = label(input.skill);
143
+ break;
144
+ }
145
+ const text = (detail === null ? name : `${name}: ${detail}`).replace(/\s+/g, " ");
146
+ return text.length > CURRENT_ACTIVITY_MAX_CHARS ? `${text.slice(0, CURRENT_ACTIVITY_MAX_CHARS - 3)}...` : text;
147
+ }
148
+ /**
149
+ * DX-4498 — the card id a brief opens with: the first token of its first non-empty line, when that token is shaped
150
+ * like a card id (`DX-4498`, also `DX-4498:`). Worker briefs open with the card they are for; any other brief names
151
+ * none. The server keeps it only when it names a real card of the team, so a plan ref (`PLAN-2`) never reaches a reader.
152
+ */
153
+ export function briefIssueId(brief) {
154
+ const firstLine = brief.split("\n").find((line) => line.trim() !== "");
155
+ if (firstLine === undefined)
156
+ return null;
157
+ const token = firstLine.trim().split(/\s+/)[0].replace(/[.,:;)]+$/, "");
158
+ return token.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(token) ? token : null;
159
+ }
160
+ /** The text of a `user` line's content: the string itself, or its first text block. */
161
+ function userText(raw) {
162
+ const content = raw.message?.content;
163
+ if (typeof content === "string")
164
+ return content;
165
+ if (!Array.isArray(content))
166
+ return null;
167
+ const block = content.find((b) => b?.type === "text");
168
+ return typeof block?.text === "string" ? block.text : null;
169
+ }
170
+ /** The line's own timestamp in epoch ms, or null when it carries none that parses. */
171
+ function lineTime(raw) {
172
+ if (typeof raw.timestamp !== "string")
173
+ return null;
174
+ const ms = Date.parse(raw.timestamp);
175
+ return Number.isNaN(ms) ? null : ms;
176
+ }
1
177
  export function createActivityObserver(deps) {
2
178
  // No clock of its own, deliberately. Every stamp this module reports comes from
3
179
  // the event that caused it — the watcher's `discoveredAt` for a start, the
4
- // entry's own `timestamp` for an advance — so there is no wall-clock reading
5
- // anywhere that a test would need to freeze or that a replay could skew.
180
+ // line's own `timestamp` for an advance or an end — so there is no wall-clock
181
+ // reading anywhere that a test would need to freeze or that a replay could skew.
6
182
  /** Every sub-agent this observer has reported, by its ledger source key. */
7
183
  const started = new Map();
184
+ /**
185
+ * DX-4498 — per source, the newest line timestamp read so far. The effort and tool call come from the NEWEST line,
186
+ * which `lastActivityAt` cannot tell: it starts at the watcher's discovery time, after every line a reattached
187
+ * collector re-reads.
188
+ */
189
+ const newestLineAt = new Map();
190
+ /** DX-4498 — ends notified for a source not yet discovered (see {@link MAX_PENDING_ENDS}). */
191
+ const pendingEnds = new Map();
192
+ const report = (change) => deps.recordActivities([{ ...change }]);
193
+ const applyEnd = (change, end) => {
194
+ // The FIRST finish stands (the server's own merge); the LATEST status wins, so a resumed sub-agent's second
195
+ // notification names how it really ended.
196
+ change.finishedAt ??= end.finishedAt;
197
+ change.endStatus = end.endStatus;
198
+ };
199
+ const onNotification = (taskId, endStatus, at) => {
200
+ const source = `${SUBAGENT_SOURCE_PREFIX}${taskId}`;
201
+ const change = started.get(source);
202
+ if (change === undefined) {
203
+ pendingEnds.delete(source);
204
+ pendingEnds.set(source, { finishedAt: at, endStatus });
205
+ if (pendingEnds.size > MAX_PENDING_ENDS)
206
+ pendingEnds.delete(pendingEnds.keys().next().value);
207
+ return;
208
+ }
209
+ if (change.finishedAt !== null && change.endStatus === endStatus)
210
+ return;
211
+ applyEnd(change, { finishedAt: at, endStatus });
212
+ report(change);
213
+ };
8
214
  return {
9
215
  onSourceDiscovered(info) {
10
216
  // Not this dispatch's sub-agent (see `ignorePreexisting`). Never added to
@@ -14,42 +220,76 @@ export function createActivityObserver(deps) {
14
220
  return;
15
221
  const existing = started.get(info.source);
16
222
  if (existing) {
17
- // A second discovery of a source already reported can only ADD an
18
- // `agentType` the first pass could not read yet (the sub-agent had not
19
- // written a line naming one). Never re-stamp `startedAt`: the server
20
- // takes LEAST(startedAt) anyway, but reporting a later start for a
21
- // sub-agent that has been running since before this watcher attached
22
- // would be wrong in the payload itself.
223
+ // A second discovery of a source already reported can only ADD what the
224
+ // first pass could not read yet (its `meta.json` sidecar was not written).
225
+ // Never re-stamp `startedAt`: the server takes LEAST(startedAt) anyway, but
226
+ // reporting a later start for a sub-agent that has been running since
227
+ // before this watcher attached would be wrong in the payload itself.
228
+ let filled = false;
23
229
  if (existing.agentType === null && info.agentType !== null) {
24
230
  existing.agentType = info.agentType;
25
- deps.recordActivities([{ ...existing }]);
231
+ filled = true;
26
232
  }
233
+ if (existing.description === null && info.description !== null) {
234
+ existing.description = info.description;
235
+ filled = true;
236
+ }
237
+ if (existing.parentActivityId === null && info.parentSource !== null) {
238
+ existing.parentActivityId = info.parentSource;
239
+ filled = true;
240
+ }
241
+ if (filled)
242
+ report(existing);
27
243
  return;
28
244
  }
29
245
  const change = {
30
246
  kind: "agent",
31
247
  activityId: info.source,
32
248
  agentType: info.agentType,
33
- // A sub-agent has no Bash `description`; the field exists for the
34
- // background-script kind and stays null here rather than being filled
35
- // with a stand-in a reader would have to distinguish from a real one.
36
- description: null,
249
+ // DX-4498 — the label its spawning `Agent` call gave it, from the sidecar.
250
+ description: info.description,
37
251
  startedAt: info.discoveredAt,
38
252
  lastActivityAt: info.discoveredAt,
39
- // Never a finish from the transcript — see the module docblock.
40
253
  finishedAt: null,
254
+ parentActivityId: info.parentSource,
255
+ effort: null,
256
+ currentActivity: null,
257
+ endStatus: null,
258
+ briefIssueId: null,
41
259
  };
260
+ const pending = pendingEnds.get(info.source);
261
+ if (pending !== undefined) {
262
+ pendingEnds.delete(info.source);
263
+ applyEnd(change, pending);
264
+ }
42
265
  started.set(info.source, change);
43
- deps.recordActivities([{ ...change }]);
266
+ report(change);
44
267
  },
45
- onEntry(entry, meta) {
268
+ onLine(raw, meta) {
269
+ const at = lineTime(raw);
270
+ // A line with no timestamp moves nothing: every stamp here is the line's own, never a wall clock.
271
+ if (at === null)
272
+ return;
273
+ // DX-4498 — a notification ends the sub-agent it names, wherever it is read: the main transcript for one the
274
+ // session spawned, the spawning sub-agent's own transcript for a nested one.
275
+ const notification = parseTaskNotification(raw);
276
+ if (notification !== null) {
277
+ onNotification(notification.taskId, notification.endStatus, at);
278
+ return;
279
+ }
280
+ const change = started.get(meta.source);
281
+ if (!change)
282
+ return;
283
+ if (raw.type !== "assistant" && raw.type !== "user")
284
+ return;
285
+ let changed = false;
46
286
  // DX-2954 — a REPLAY counts as activity here, unlike the stall detector's
47
287
  // liveness signal. The two ask different questions: the stall detector asks
48
288
  // "is the agent alive NOW" (a re-read proves nothing), while this stamp
49
289
  // answers "when did this sub-agent last actually say something", which a
50
290
  // re-read line genuinely reports.
51
291
  //
52
- // Which is exactly why the stamp is the ENTRY's OWN timestamp and never a
292
+ // Which is exactly why the stamp is the LINE's OWN timestamp and never a
53
293
  // wall clock. Stamping `Date.now()` would make a reattach reset every
54
294
  // pre-existing sub-agent's last activity to the moment the collector
55
295
  // restarted: the whole replay window would report as fresh, and a sub-agent
@@ -59,15 +299,50 @@ export function createActivityObserver(deps) {
59
299
  // wall-clock-for-entry-time confusion in the boot mark). Even with no
60
300
  // replay involved, a wall clock records when the 5s poll NOTICED the line,
61
301
  // not when the agent wrote it.
62
- const change = started.get(meta.source);
63
- if (!change)
64
- return;
302
+ //
65
303
  // GREATEST, mirroring the server: a line read out of order, or an older
66
- // line inside a replay window, never drags liveness backwards.
67
- if (entry.timestamp <= change.lastActivityAt)
68
- return;
69
- change.lastActivityAt = entry.timestamp;
70
- deps.recordActivities([{ ...change }]);
304
+ // line inside a replay window, never drags liveness backwards — nor the
305
+ // effort and tool call that ride it.
306
+ const newest = at >= (newestLineAt.get(meta.source) ?? 0);
307
+ if (newest)
308
+ newestLineAt.set(meta.source, at);
309
+ if (at > change.lastActivityAt) {
310
+ change.lastActivityAt = at;
311
+ changed = true;
312
+ }
313
+ if (raw.type === "user") {
314
+ // 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.
316
+ if (change.briefIssueId === null && raw.parentUuid === null) {
317
+ const text = userText(raw);
318
+ const id = text === null ? null : briefIssueId(text);
319
+ if (id !== null) {
320
+ change.briefIssueId = id;
321
+ changed = true;
322
+ }
323
+ }
324
+ }
325
+ else if (newest) {
326
+ if (typeof raw.effort === "string" && raw.effort !== "" && raw.effort !== change.effort) {
327
+ change.effort = raw.effort;
328
+ changed = true;
329
+ }
330
+ const content = raw.message?.content;
331
+ const toolUses = Array.isArray(content)
332
+ ? content.filter((b) => b?.type === "tool_use" && typeof b.name === "string")
333
+ : [];
334
+ const last = toolUses.at(-1);
335
+ if (last !== undefined) {
336
+ const input = last.input !== null && typeof last.input === "object" ? last.input : {};
337
+ const activity = describeToolCall(last.name, input);
338
+ if (activity !== change.currentActivity) {
339
+ change.currentActivity = activity;
340
+ changed = true;
341
+ }
342
+ }
343
+ }
344
+ if (changed)
345
+ report(change);
71
346
  },
72
347
  };
73
348
  }
package/dist/browser.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * DX-2963 adds the first real runtime exports here: the one usage counter
18
18
  * (`usage-ledger.ts`) and the one pricing function (`pricing.ts`), both pure
19
19
  * arithmetic with no I/O — `usage-snapshot.ts` in danxbot root (bundled by the
20
- * frozen Vue SPA) imports through THIS subpath, never the main entry.
20
+ * React frontend) imports through THIS subpath, never the main entry.
21
21
  */
22
22
  export type { AgentLogEntry, AgentLineage } from "./types.js";
23
23
  export { truncStr } from "./tool-summary.js";
@@ -12,7 +12,7 @@
12
12
  * Claude Code writes:
13
13
  * - Parent session: ~/.claude/projects/<cwd-path>/<session-uuid>.jsonl
14
14
  * - Sub-agent sessions: ~/.claude/projects/<cwd-path>/<session-uuid>/subagents/agent-<hash>.jsonl
15
- * + agent-<hash>.meta.json sidecar with {agentType, description}
15
+ * + agent-<hash>.meta.json sidecar with {agentType, description, parentAgentId (nested only), ...}
16
16
  *
17
17
  * The watcher tails the parent file and all sub-agent files in parallel, parses
18
18
  * new lines, converts them to AgentLogEntry, and emits to registered consumers.
@@ -38,7 +38,8 @@ export declare const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
38
38
  export type EntryConsumer = (entry: AgentLogEntry, meta: EntryMeta) => void;
39
39
  /**
40
40
  * DX-3964 - a consumer of the usage feed: the PARSED RAW line (assistant, user, attachment or system - nothing
41
- * else) with the same {@link EntryMeta} an `onEntry` consumer gets. Collector-only: see
41
+ * else) with the same {@link EntryMeta} an `onEntry` consumer gets. Collector-only (the usage counter and the
42
+ * activity observer): see
42
43
  * {@link SessionLogWatcher.onUsageLine}.
43
44
  */
44
45
  export type UsageLineConsumer = (raw: Record<string, unknown>, meta: EntryMeta) => void;
@@ -149,11 +150,16 @@ export interface SourceDiscoveredInfo {
149
150
  /** The sub-agent's ledger source key (its transcript file's basename). */
150
151
  source: string;
151
152
  /**
152
- * The sub-agent's declared type, read from its own transcript. `null` when it
153
- * has not written a line naming one yet — a hook-side report of the same
154
- * sub-agent fills it in later without clobbering a value already stored.
153
+ * The sub-agent's declared type, read from its `meta.json` sidecar. `null` when
154
+ * Claude Code has not written the sidecar yet — it is reported again once read,
155
+ * and a hook-side report of the same sub-agent fills it in without clobbering a
156
+ * value already stored.
155
157
  */
156
158
  agentType: string | null;
159
+ /** DX-4498 — the label the spawning `Agent` call gave it (`meta.json` `description`); null until the sidecar is read. */
160
+ description: string | null;
161
+ /** DX-4498 — the source key of the sub-agent that spawned it (`agent-<parentAgentId>`); null for one the session spawned. */
162
+ parentSource: string | null;
157
163
  /** Epoch ms this watcher first saw the file. */
158
164
  discoveredAt: number;
159
165
  /**
@@ -395,7 +401,9 @@ export declare class SessionLogWatcher {
395
401
  /**
396
402
  * DX-3964 - register a usage-feed consumer: it receives the parsed raw line of every assistant, user,
397
403
  * attachment and system line, in file order, with the same `EntryMeta` as `onEntry`. The ONLY subscribers are
398
- * the two `DispatchUsage` feed sites (the worker and the operator bridge): `convertJsonlEntry` and every
404
+ * the collector's two consumers at each of its two sites (the worker and the operator bridge): `DispatchUsage`
405
+ * and, DX-4498, the activity observer, which reads the sub-agent's brief and Claude Code's task notifications,
406
+ * neither of which is an entry. `convertJsonlEntry` and every
399
407
  * `onEntry` subscriber keep seeing exactly what they saw before, because human prompts, attachments (tens of
400
408
  * thousands of hook records) and system lines are not theirs.
401
409
  *
@@ -512,12 +520,13 @@ export declare class SessionLogWatcher {
512
520
  private emitSourceDiscovered;
513
521
  private refreshLineage;
514
522
  /**
515
- * Read agentType from the `<subagentId>.meta.json` sidecar. Returns
523
+ * Read the `<subagentId>.meta.json` sidecar (DX-4498: its type, label and spawning sub-agent). Returns
516
524
  * undefined only for ENOENT (file not written yet). Malformed JSON or
517
525
  * permission errors are logged at WARN and return undefined — the watcher
518
526
  * must not crash on bad sidecar files, but the failure is visible in logs.
527
+ * A key the sidecar lacks, or carries as anything but a non-empty string, is `null`.
519
528
  */
520
- private readAgentType;
529
+ private readSubagentMeta;
521
530
  /** Process raw text from a file tail, handling partial lines across poll ticks. */
522
531
  private processText;
523
532
  /**
@@ -12,7 +12,7 @@
12
12
  * Claude Code writes:
13
13
  * - Parent session: ~/.claude/projects/<cwd-path>/<session-uuid>.jsonl
14
14
  * - Sub-agent sessions: ~/.claude/projects/<cwd-path>/<session-uuid>/subagents/agent-<hash>.jsonl
15
- * + agent-<hash>.meta.json sidecar with {agentType, description}
15
+ * + agent-<hash>.meta.json sidecar with {agentType, description, parentAgentId (nested only), ...}
16
16
  *
17
17
  * The watcher tails the parent file and all sub-agent files in parallel, parses
18
18
  * new lines, converts them to AgentLogEntry, and emits to registered consumers.
@@ -622,7 +622,9 @@ export class SessionLogWatcher {
622
622
  /**
623
623
  * DX-3964 - register a usage-feed consumer: it receives the parsed raw line of every assistant, user,
624
624
  * attachment and system line, in file order, with the same `EntryMeta` as `onEntry`. The ONLY subscribers are
625
- * the two `DispatchUsage` feed sites (the worker and the operator bridge): `convertJsonlEntry` and every
625
+ * the collector's two consumers at each of its two sites (the worker and the operator bridge): `DispatchUsage`
626
+ * and, DX-4498, the activity observer, which reads the sub-agent's brief and Claude Code's task notifications,
627
+ * neither of which is an entry. `convertJsonlEntry` and every
626
628
  * `onEntry` subscriber keep seeing exactly what they saw before, because human prompts, attachments (tens of
627
629
  * thousands of hook records) and system lines are not theirs.
628
630
  *
@@ -923,7 +925,7 @@ export class SessionLogWatcher {
923
925
  // `dispatch_activities.activity_id` holds — so a cursor, a per-source
924
926
  // token total and a running-set row all join on one key.
925
927
  const subagentId = basename(file, ".jsonl");
926
- const agentType = await this.readAgentType(subagentId);
928
+ const meta = await this.readSubagentMeta(subagentId);
927
929
  const start = await this.resolveStart(subagentId, fullPath, firstPass);
928
930
  if (start === null) {
929
931
  allResolved = false;
@@ -942,10 +944,11 @@ export class SessionLogWatcher {
942
944
  replayUntil,
943
945
  discoveredAt,
944
946
  preexisting: firstPass,
947
+ meta,
945
948
  lineage: {
946
949
  subagent_id: subagentId,
947
950
  parent_session_id: this.parentSessionId,
948
- agent_type: agentType,
951
+ agent_type: meta?.agentType ?? undefined,
949
952
  },
950
953
  });
951
954
  // DX-2954 — fired AFTER the tail is registered, so a consumer that throws
@@ -955,7 +958,7 @@ export class SessionLogWatcher {
955
958
  // reports is an idempotent upsert keyed on this same id, and re-reporting
956
959
  // is how a reattached collector re-learns the sub-agents that were already
957
960
  // running before it existed.
958
- this.emitSourceDiscovered(subagentId, agentType, discoveredAt, firstPass);
961
+ this.emitSourceDiscovered(subagentId, meta, discoveredAt, firstPass);
959
962
  }
960
963
  if (allResolved)
961
964
  this.subagentDiscoveryDone = true;
@@ -994,15 +997,17 @@ export class SessionLogWatcher {
994
997
  *
995
998
  * Reporting the running set must never cost the monitoring it rides on.
996
999
  */
997
- emitSourceDiscovered(source, agentType, discoveredAt, preexisting) {
1000
+ emitSourceDiscovered(source, meta, discoveredAt, preexisting) {
998
1001
  try {
999
1002
  this.onSourceDiscovered?.({
1000
1003
  source,
1001
1004
  // ONE absent-shape on the wire, never two: this class's own reader
1002
1005
  // answers `undefined` for "no meta file yet", while the stats payload and
1003
- // the `dispatch_activities.agent_type` column both spell absent as
1004
- // `null`. Normalizing here keeps every consumer from accepting both.
1005
- agentType: agentType ?? null,
1006
+ // the `dispatch_activities` columns all spell absent as `null`.
1007
+ // Normalizing here keeps every consumer from accepting both.
1008
+ agentType: meta?.agentType ?? null,
1009
+ description: meta?.description ?? null,
1010
+ parentSource: meta?.parentSource ?? null,
1006
1011
  discoveredAt,
1007
1012
  preexisting,
1008
1013
  });
@@ -1022,14 +1027,16 @@ export class SessionLogWatcher {
1022
1027
  // yet" at discovery is the COMMON case, not an edge. Without a report here
1023
1028
  // the running-set row's `agent_type` would stay NULL for the whole dispatch
1024
1029
  // even though the watcher learned the real value one poll later.
1025
- const typeWasUnknown = agent_type === undefined;
1026
- if (agent_type === undefined) {
1027
- agent_type = await this.readAgentType(state.lineage.subagent_id);
1030
+ // DX-4498 — the whole sidecar (type, label, parent) is learned in one read, so it is re-read only until it exists.
1031
+ const metaWasUnknown = state.meta === undefined;
1032
+ if (metaWasUnknown) {
1033
+ state.meta = await this.readSubagentMeta(state.lineage.subagent_id);
1034
+ agent_type = state.meta?.agentType ?? undefined;
1028
1035
  }
1029
- if (typeWasUnknown && agent_type !== undefined && state.source !== MAIN_SOURCE) {
1036
+ if (metaWasUnknown && state.meta !== undefined && state.source !== MAIN_SOURCE) {
1030
1037
  // The ORIGINAL discovery stamp, never a fresh `Date.now()`: this is the
1031
1038
  // same activity, learning its type late, not a new one starting now.
1032
- this.emitSourceDiscovered(state.source, agent_type, state.discoveredAt, state.preexisting);
1039
+ this.emitSourceDiscovered(state.source, state.meta, state.discoveredAt, state.preexisting);
1033
1040
  }
1034
1041
  if (parent_session_id === null && this.parentSessionId !== null) {
1035
1042
  parent_session_id = this.parentSessionId;
@@ -1041,19 +1048,26 @@ export class SessionLogWatcher {
1041
1048
  };
1042
1049
  }
1043
1050
  /**
1044
- * Read agentType from the `<subagentId>.meta.json` sidecar. Returns
1051
+ * Read the `<subagentId>.meta.json` sidecar (DX-4498: its type, label and spawning sub-agent). Returns
1045
1052
  * undefined only for ENOENT (file not written yet). Malformed JSON or
1046
1053
  * permission errors are logged at WARN and return undefined — the watcher
1047
1054
  * must not crash on bad sidecar files, but the failure is visible in logs.
1055
+ * A key the sidecar lacks, or carries as anything but a non-empty string, is `null`.
1048
1056
  */
1049
- async readAgentType(subagentId) {
1057
+ async readSubagentMeta(subagentId) {
1050
1058
  if (!this.subagentsDir)
1051
1059
  return undefined;
1052
1060
  const metaPath = join(this.subagentsDir, `${subagentId}.meta.json`);
1061
+ const text = (v) => (typeof v === "string" && v !== "" ? v : null);
1053
1062
  try {
1054
- const text = await readFile(metaPath, "utf-8");
1055
- const meta = JSON.parse(text);
1056
- return typeof meta.agentType === "string" ? meta.agentType : undefined;
1063
+ const raw = JSON.parse(await readFile(metaPath, "utf-8"));
1064
+ const parentAgentId = text(raw.parentAgentId);
1065
+ return {
1066
+ agentType: text(raw.agentType),
1067
+ description: text(raw.description),
1068
+ // The parent's own transcript is `agent-<parentAgentId>.jsonl`, so this is its source key.
1069
+ parentSource: parentAgentId === null ? null : `agent-${parentAgentId}`,
1070
+ };
1057
1071
  }
1058
1072
  catch (err) {
1059
1073
  const code = err.code;
@@ -104,7 +104,33 @@ export interface ActivityChange {
104
104
  lastActivityAt: number;
105
105
  /** Epoch ms when it finished, or null while it is still running. */
106
106
  finishedAt: number | null;
107
+ /**
108
+ * DX-4498 — the sub-agent that spawned this one (`agent-<parentAgentId>` from its `meta.json`), or null for a
109
+ * sub-agent the session itself spawned and for a background script.
110
+ */
111
+ parentActivityId: string | null;
112
+ /** DX-4498 — the reasoning effort its latest reply ran at (the assistant line's own `effort`); null until one is read. */
113
+ effort: string | null;
114
+ /**
115
+ * DX-4498 — what it is doing now: its latest tool call as `<Tool>` or `<Tool>: <label>`, where the label is only ever
116
+ * text the agent wrote AS a label (see {@link describeToolCall}); null until it calls a tool.
117
+ */
118
+ currentActivity: string | null;
119
+ /** DX-4498 — how it ended, when an explicit end event named it; null while running and for an end that named none. */
120
+ endStatus: ActivityEndStatus | null;
121
+ /** DX-4498 — the card id its brief opens with (`DX-4498
122
+
123
+ ...`), or null when the brief names none. */
124
+ briefIssueId: string | null;
107
125
  }
126
+ /**
127
+ * DX-4498 — how an activity ended, when something explicitly said so: Claude Code's background-task notification
128
+ * (`completed`, `failed`, and `killed`/`stopped` as `stopped`), or the session ending while it still ran (`stopped`).
129
+ */
130
+ export declare const ACTIVITY_END_STATUSES: readonly ["completed", "failed", "stopped"];
131
+ export type ActivityEndStatus = (typeof ACTIVITY_END_STATUSES)[number];
132
+ /** DX-4498 — the longest `currentActivity` a collector sends (and the server stores). */
133
+ export declare const CURRENT_ACTIVITY_MAX_CHARS = 120;
108
134
  export interface StatsPayload {
109
135
  entries: LedgerEntry[];
110
136
  /**
@@ -21,6 +21,13 @@ export const STATS_BATCH_CAP = 500;
21
21
  * lines.
22
22
  */
23
23
  export const ACTIVITY_KINDS = ["agent", "bash"];
24
+ /**
25
+ * DX-4498 — how an activity ended, when something explicitly said so: Claude Code's background-task notification
26
+ * (`completed`, `failed`, and `killed`/`stopped` as `stopped`), or the session ending while it still ran (`stopped`).
27
+ */
28
+ export const ACTIVITY_END_STATUSES = ["completed", "failed", "stopped"];
29
+ /** DX-4498 — the longest `currentActivity` a collector sends (and the server stores). */
30
+ export const CURRENT_ACTIVITY_MAX_CHARS = 120;
24
31
  export function chunkEntries(entries, cap = STATS_BATCH_CAP) {
25
32
  const out = [];
26
33
  for (let i = 0; i < entries.length; i += cap)
@@ -107,10 +114,28 @@ export function collapseActivityChanges(changes) {
107
114
  // COALESCE(stored, incoming) — the FIRST finish wins; a later
108
115
  // still-running report must never resurrect a stopped activity.
109
116
  finishedAt: prior.finishedAt ?? c.finishedAt,
117
+ // DX-4498 — COALESCE(incoming, stored): a spawn fact, known or not, never changes once known.
118
+ parentActivityId: c.parentActivityId ?? prior.parentActivityId,
119
+ // DX-4498 — the report with the LATER liveness carries the current effort and tool call (a tie goes to the later
120
+ // report, which a producer queues after the one it supersedes); a report that knows neither keeps the stored ones.
121
+ ...latestOf(prior, c),
122
+ // DX-4498 — COALESCE(incoming, stored): the latest explicit end status wins (a notification read after the session's
123
+ // own close names how it really ended).
124
+ endStatus: c.endStatus ?? prior.endStatus,
125
+ // DX-4498 — COALESCE(stored, incoming): the brief is the transcript's first line, read once.
126
+ briefIssueId: prior.briefIssueId ?? c.briefIssueId,
110
127
  });
111
128
  }
112
129
  return [...byKey.values()];
113
130
  }
131
+ /**
132
+ * DX-4498 — the `effort` / `currentActivity` merge both sides apply: the report with the later `lastActivityAt` wins
133
+ * (ties go to `incoming`), and a null never erases a stored value.
134
+ */
135
+ function latestOf(stored, incoming) {
136
+ const [newer, older] = incoming.lastActivityAt >= stored.lastActivityAt ? [incoming, stored] : [stored, incoming];
137
+ return { effort: newer.effort ?? older.effort, currentActivity: newer.currentActivity ?? older.currentActivity };
138
+ }
114
139
  const CURSOR_KEYS = new Set(["acked", "delivered", "claims", "pastEof"]);
115
140
  const CLAIM_STATE_KEYS = new Set(["held", "pending"]);
116
141
  const PENDING_KEYS = new Set(["op", "issueId"]);
@@ -238,7 +263,26 @@ const ACTIVITY_KEYS = new Set([
238
263
  "startedAt",
239
264
  "lastActivityAt",
240
265
  "finishedAt",
266
+ "parentActivityId",
267
+ "effort",
268
+ "currentActivity",
269
+ "endStatus",
270
+ "briefIssueId",
241
271
  ]);
272
+ /** DX-4498 — a nullable string field of an activity: present, and either null or a non-empty string. */
273
+ function nullableText(a, key, where, maxChars) {
274
+ if (!(key in a))
275
+ throw new StatsPayloadError(`${where}.${key} is required (null when unknown)`);
276
+ const v = a[key];
277
+ if (v === null)
278
+ return null;
279
+ if (typeof v !== "string" || v === "")
280
+ throw new StatsPayloadError(`${where}.${key} must be a non-empty string or null`);
281
+ if (maxChars !== undefined && v.length > maxChars) {
282
+ throw new StatsPayloadError(`${where}.${key} exceeds ${maxChars} characters`);
283
+ }
284
+ return v;
285
+ }
242
286
  function parseActivities(raw) {
243
287
  if (!Array.isArray(raw))
244
288
  throw new StatsPayloadError("activities must be an array");
@@ -273,6 +317,18 @@ function parseActivities(raw) {
273
317
  if (a.finishedAt !== null && !isNonNegInt(a.finishedAt)) {
274
318
  throw new StatsPayloadError(`${where}.finishedAt`);
275
319
  }
320
+ // DX-4498 — REQUIRED, `null` when unknown: an activity without them comes from a collector that predates them, which
321
+ // the `stats` surface floor refuses rather than this parser defaulting them.
322
+ const parentActivityId = nullableText(a, "parentActivityId", where);
323
+ const effort = nullableText(a, "effort", where);
324
+ const currentActivity = nullableText(a, "currentActivity", where, CURRENT_ACTIVITY_MAX_CHARS);
325
+ const endStatus = nullableText(a, "endStatus", where);
326
+ if (endStatus !== null && !ACTIVITY_END_STATUSES.includes(endStatus)) {
327
+ throw new StatsPayloadError(`${where}.endStatus must be one of ${ACTIVITY_END_STATUSES.join(", ")}, or null`);
328
+ }
329
+ const briefIssueId = nullableText(a, "briefIssueId", where);
330
+ if (briefIssueId !== null && !isIssueId(briefIssueId))
331
+ throw new StatsPayloadError(`${where}.briefIssueId`);
276
332
  return {
277
333
  kind: a.kind,
278
334
  activityId: a.activityId,
@@ -281,6 +337,11 @@ function parseActivities(raw) {
281
337
  startedAt: a.startedAt,
282
338
  lastActivityAt: a.lastActivityAt,
283
339
  finishedAt: a.finishedAt,
340
+ parentActivityId,
341
+ effort,
342
+ currentActivity,
343
+ endStatus: endStatus,
344
+ briefIssueId,
284
345
  };
285
346
  });
286
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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",