@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 +2 -2
- package/dist/.source-hash +1 -1
- package/dist/activity-observer.d.ts +62 -25
- package/dist/activity-observer.js +300 -25
- package/dist/browser.d.ts +1 -1
- package/dist/session-log-watcher.d.ts +17 -8
- package/dist/session-log-watcher.js +33 -19
- package/dist/stats-wire.d.ts +26 -0
- package/dist/stats-wire.js +61 -0
- package/package.json +1 -1
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* sub-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
* ##
|
|
64
|
+
* ## A finish is reported only when Claude Code says so
|
|
53
65
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
88
|
-
|
|
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
|
-
//
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
34
|
-
|
|
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
|
-
|
|
266
|
+
report(change);
|
|
44
267
|
},
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
153
|
-
* has not written
|
|
154
|
-
* sub-agent fills it in
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
1004
|
-
//
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
1055
|
-
const
|
|
1056
|
-
return
|
|
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;
|
package/dist/stats-wire.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/stats-wire.js
CHANGED
|
@@ -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.
|
|
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",
|