@thehammer/danx-session-telemetry 0.1.12 → 0.1.14
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 +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/pricing.d.ts +2 -2
- package/dist/pricing.js +2 -2
- package/dist/session-log-watcher.d.ts +35 -216
- package/dist/session-log-watcher.js +118 -330
- package/dist/usage-ledger.d.ts +2 -2
- package/dist/usage-ledger.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ own header comments for the per-file reasoning.
|
|
|
41
41
|
|---|---|---|
|
|
42
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`). |
|
|
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 `dashboard-builder`
|
|
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`. |
|
|
45
45
|
|
|
46
46
|
## Release order (DX-2962 AC3)
|
|
47
47
|
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* usage display.
|
|
16
16
|
*/
|
|
17
17
|
export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, deriveSessionDir, sessionUuidExistsInCleanRoom, findNewestJsonlFile, findSessionFileByDispatchId, convertJsonlEntry, } from "./session-log-watcher.js";
|
|
18
|
-
export type { EntryConsumer, SessionLogWatcherOptions,
|
|
18
|
+
export type { EntryConsumer, SessionLogWatcherOptions, } from "./session-log-watcher.js";
|
|
19
19
|
export type { AgentLogEntry, AgentLineage } from "./types.js";
|
|
20
20
|
export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
|
|
21
21
|
export * from "./usage-ledger.js";
|
package/dist/pricing.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-2963 — the ONE pricing function, shared by every consumer that turns
|
|
3
3
|
* token counts into dollars: the danxbot server's usage-ledger writer (which
|
|
4
|
-
* prices each credit once, when it is credited), the router's Haiku calls
|
|
5
|
-
*
|
|
4
|
+
* prices each credit once, when it is credited), and the router's Haiku calls.
|
|
5
|
+
* Moved here from danxbot root `src/agent/pricing.ts`
|
|
6
6
|
* so the counter and the price list live side by side, and exported through
|
|
7
7
|
* the browser-safe `./browser` subpath (pure arithmetic, no `node:` imports).
|
|
8
8
|
*
|
package/dist/pricing.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-2963 — the ONE pricing function, shared by every consumer that turns
|
|
3
3
|
* token counts into dollars: the danxbot server's usage-ledger writer (which
|
|
4
|
-
* prices each credit once, when it is credited), the router's Haiku calls
|
|
5
|
-
*
|
|
4
|
+
* prices each credit once, when it is credited), and the router's Haiku calls.
|
|
5
|
+
* Moved here from danxbot root `src/agent/pricing.ts`
|
|
6
6
|
* so the counter and the price list live side by side, and exported through
|
|
7
7
|
* the browser-safe `./browser` subpath (pure arithmetic, no `node:` imports).
|
|
8
8
|
*
|
|
@@ -28,14 +28,7 @@ import type { AgentLogEntry } from "./types.js";
|
|
|
28
28
|
* wrong file (e.g., an active human session).
|
|
29
29
|
*/
|
|
30
30
|
export declare const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
31
|
-
|
|
32
|
-
* A consumer of transcript lines.
|
|
33
|
-
*
|
|
34
|
-
* DX-2954 added the second argument. A consumer that ignores it behaves exactly
|
|
35
|
-
* as before; a consumer with a SIDE EFFECT must read `meta.replay` and skip a
|
|
36
|
-
* re-read line (see {@link EntryMeta.replay}).
|
|
37
|
-
*/
|
|
38
|
-
export type EntryConsumer = (entry: AgentLogEntry, meta: EntryMeta) => void;
|
|
31
|
+
export type EntryConsumer = (entry: AgentLogEntry) => void;
|
|
39
32
|
export interface SessionLogWatcherOptions {
|
|
40
33
|
/** Working directory the agent was launched in. Used to derive the session directory. */
|
|
41
34
|
cwd: string;
|
|
@@ -53,118 +46,32 @@ export interface SessionLogWatcherOptions {
|
|
|
53
46
|
*/
|
|
54
47
|
dispatchId?: string;
|
|
55
48
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* That lost every line between the last write and the restart; a cursor does
|
|
70
|
-
* not.
|
|
49
|
+
* Skip every byte on disk at attach time and tail strictly forward from
|
|
50
|
+
* EOF. Set whenever the bytes already on disk belong to a PRIOR dispatch's
|
|
51
|
+
* accounting, so replaying them would double-count that dispatch's usage /
|
|
52
|
+
* tool calls against this one:
|
|
53
|
+
* - the Phase 2c (DX-209) reattach pass — a prior worker incarnation
|
|
54
|
+
* already accumulated the JSONL into the row's counters; and
|
|
55
|
+
* - a `claude --resume <id>` resume (DX-2583) — claude appends this
|
|
56
|
+
* dispatch's turns to the SAME session JSONL the prior dispatch(es)
|
|
57
|
+
* already filled, so tailing from byte 0 re-sums the whole session's
|
|
58
|
+
* history onto a fresh row (`attach-monitoring-stack.ts` sets this via
|
|
59
|
+
* `seedFromEof` when `resumeSessionId` is present).
|
|
60
|
+
* Default `false` — a genuinely fresh spawn writes a brand-new file whose
|
|
61
|
+
* EOF is 0, so it ingests its own full file from the start.
|
|
71
62
|
*/
|
|
72
|
-
|
|
63
|
+
fromEof?: boolean;
|
|
73
64
|
/**
|
|
74
|
-
* DX-
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* LATER is genuinely new and still starts at 0.
|
|
83
|
-
*
|
|
84
|
-
* It must never be set alongside cursors for some sources: rule 5 says a
|
|
85
|
-
* sub-agent file that appeared after the last stats write, on a dispatch that
|
|
86
|
-
* HAS cursors, starts at 0 so none of its lines are lost.
|
|
65
|
+
* DX-2963 — the end-of-file rule for PRE-CARD lineage. When set, every
|
|
66
|
+
* sub-agent file that ALREADY EXISTS on the first sub-agent discovery pass
|
|
67
|
+
* is opened at its end (like the main file under `fromEof`); a sub-agent
|
|
68
|
+
* file that appears later is read from byte 0. Set by the worker when its
|
|
69
|
+
* own dispatch row, or its predecessor's, is pre-card (stored totals with
|
|
70
|
+
* no ledger rows to dedupe against), so replies those totals already count
|
|
71
|
+
* are never credited again. DX-2954 replaces this with its cursor start
|
|
72
|
+
* order and deletes it.
|
|
87
73
|
*/
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* DX-2954 — a stored cursor was PAST the end of its file (the file was
|
|
91
|
-
* replaced or truncated), so that source restarted at 0.
|
|
92
|
-
*
|
|
93
|
-
* Without this the watcher would silently tail nothing for the rest of the
|
|
94
|
-
* dispatch: its `size <= byteOffset` early return is indistinguishable from
|
|
95
|
-
* "no new bytes". The caller records a visible `transcript_cursor_past_eof`
|
|
96
|
-
* event with both numbers and marks the reset in its next stats write, which
|
|
97
|
-
* is the ONE sanctioned backwards move of a stored `acked`.
|
|
98
|
-
*/
|
|
99
|
-
onCursorPastEof?: (info: CursorPastEofInfo) => void;
|
|
100
|
-
/**
|
|
101
|
-
* DX-2954 — a sub-agent transcript just came under tail for the first time,
|
|
102
|
-
* i.e. a sub-agent of this dispatch is RUNNING.
|
|
103
|
-
*
|
|
104
|
-
* This is the transcript-side producer of the dispatch's running set. It
|
|
105
|
-
* reports the sub-agent's ledger source key, which is the same value its
|
|
106
|
-
* `dispatch_activities.activity_id` and its per-source token total join on —
|
|
107
|
-
* so a hook-side producer reporting the same sub-agent by its
|
|
108
|
-
* `transcript_path` resolves to this same id and upserts the SAME row rather
|
|
109
|
-
* than a duplicate.
|
|
110
|
-
*
|
|
111
|
-
* Fired once per source, from the discovery pass, for sub-agent files only —
|
|
112
|
-
* never for the main transcript, which is the dispatch itself, not an activity
|
|
113
|
-
* within it.
|
|
114
|
-
*/
|
|
115
|
-
onSourceDiscovered?: (info: SourceDiscoveredInfo) => void;
|
|
116
|
-
}
|
|
117
|
-
/** DX-2954 — one source's stored cursor pair, as a collector starts from it. */
|
|
118
|
-
export interface SourceCursorStart {
|
|
119
|
-
/** The resume point: read on from here. */
|
|
120
|
-
acked: number;
|
|
121
|
-
/**
|
|
122
|
-
* The highest byte already handed to subscribers. Lines between `acked` and
|
|
123
|
-
* this are re-read and handed on marked as REPLAYS, so side-effecting
|
|
124
|
-
* subscribers skip them while the ledger and the log shipper still see them.
|
|
125
|
-
*/
|
|
126
|
-
delivered: number;
|
|
127
|
-
}
|
|
128
|
-
/** DX-2954 — reported when a stored cursor was past its file's end. */
|
|
129
|
-
export interface CursorPastEofInfo {
|
|
130
|
-
source: string;
|
|
131
|
-
storedOffset: number;
|
|
132
|
-
fileSize: number;
|
|
133
|
-
}
|
|
134
|
-
/** DX-2954 — reported when a sub-agent's transcript comes under tail. */
|
|
135
|
-
export interface SourceDiscoveredInfo {
|
|
136
|
-
/** The sub-agent's ledger source key (its transcript file's basename). */
|
|
137
|
-
source: string;
|
|
138
|
-
/**
|
|
139
|
-
* The sub-agent's declared type, read from its own transcript. `null` when it
|
|
140
|
-
* has not written a line naming one yet — a hook-side report of the same
|
|
141
|
-
* sub-agent fills it in later without clobbering a value already stored.
|
|
142
|
-
*/
|
|
143
|
-
agentType: string | null;
|
|
144
|
-
/** Epoch ms this watcher first saw the file. */
|
|
145
|
-
discoveredAt: number;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* DX-2954 — what a consumer learns about the line it is being handed, beyond
|
|
149
|
-
* the entry itself.
|
|
150
|
-
*/
|
|
151
|
-
export interface EntryMeta {
|
|
152
|
-
/** {@link MAIN_SOURCE} for the main transcript, else the sub-agent id. */
|
|
153
|
-
source: string;
|
|
154
|
-
/** Byte offset of the line's first byte in its file. */
|
|
155
|
-
byteOffset: number;
|
|
156
|
-
/**
|
|
157
|
-
* This line was already handed to a PREVIOUS incarnation's subscribers (it
|
|
158
|
-
* falls below the source's stored `delivered` cursor) and is being handed
|
|
159
|
-
* again after a restart.
|
|
160
|
-
*
|
|
161
|
-
* Every subscriber with a SIDE EFFECT must skip a replay — re-posting Slack
|
|
162
|
-
* progress, re-forwarding to gpt-manager, or re-running the API-error
|
|
163
|
-
* detector on a predecessor's last lines are all real, visible damage. The
|
|
164
|
-
* ledger and the log shipper DO process replays: both are idempotent, and
|
|
165
|
-
* skipping them is what would lose data.
|
|
166
|
-
*/
|
|
167
|
-
replay: boolean;
|
|
74
|
+
existingSubagentFilesFromEof?: boolean;
|
|
168
75
|
}
|
|
169
76
|
/**
|
|
170
77
|
* Encode a working directory to the subdir name Claude Code writes JSONL
|
|
@@ -279,29 +186,6 @@ export declare function convertJsonlEntry(raw: Record<string, unknown>, lastTime
|
|
|
279
186
|
entry: AgentLogEntry;
|
|
280
187
|
timestamp: number;
|
|
281
188
|
} | null;
|
|
282
|
-
/**
|
|
283
|
-
* DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
|
|
284
|
-
* {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
|
|
285
|
-
* the right `startCursors` / `startExistingFilesAtEof`.
|
|
286
|
-
*
|
|
287
|
-
* | # | Condition | Start |
|
|
288
|
-
* |---|---|---|
|
|
289
|
-
* | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
|
|
290
|
-
* | 2 | a worker dispatch continuing a session: its predecessor's cursor for this source (the caller resolves it INTO `startCursors`, so it arrives as rule 1) | the predecessor's final `acked`, replaying to its final `delivered` |
|
|
291
|
-
* | 3 | the FIRST dispatch of its Claude session — a new worker session, or any operator dispatch (always the only one for its session id, including one opened by a mid-session `plan_connect` and a forked session's new id) | 0 |
|
|
292
|
-
* | 4 | a WORKER dispatch continuing a session with NO cursor for ANY source (a row from before this card; a `--resume` whose predecessor predates it) — `startExistingFilesAtEof` | every ALREADY-EXISTING file at its current end |
|
|
293
|
-
* | 5 | otherwise — in particular a sub-agent file that appeared AFTER the last stats write, on a dispatch that has cursors for other sources | 0 |
|
|
294
|
-
*
|
|
295
|
-
* Rules 1 and 2 arrive identically because "where does this source resume" has
|
|
296
|
-
* ONE answer; rule 4 is the only branch that skips content, and it is narrow by
|
|
297
|
-
* construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
|
|
298
|
-
* late sub-agent file's lines).
|
|
299
|
-
*
|
|
300
|
-
* Rules 3 and 5 both read from 0, but for different reasons, and both are
|
|
301
|
-
* correct: a fresh session's full usage SHOULD be counted, and a forked
|
|
302
|
-
* session's copied history is read and credited nothing (the ledger's excess
|
|
303
|
-
* rule already covers it).
|
|
304
|
-
*/
|
|
305
189
|
export declare class SessionLogWatcher {
|
|
306
190
|
private consumers;
|
|
307
191
|
private pollTimer;
|
|
@@ -313,62 +197,13 @@ export declare class SessionLogWatcher {
|
|
|
313
197
|
private sessionId;
|
|
314
198
|
private dispatchId;
|
|
315
199
|
private pollIntervalMs;
|
|
316
|
-
private
|
|
317
|
-
private
|
|
318
|
-
private readonly onCursorPastEof;
|
|
319
|
-
private readonly onSourceDiscovered;
|
|
200
|
+
private fromEof;
|
|
201
|
+
private existingSubagentFilesFromEof;
|
|
320
202
|
private subagentDiscoveryDone;
|
|
321
203
|
private running;
|
|
322
204
|
private polling;
|
|
323
205
|
private initSynthesized;
|
|
324
206
|
constructor(options: SessionLogWatcherOptions);
|
|
325
|
-
/**
|
|
326
|
-
* DX-2954 — resolve one file's starting offset and replay boundary, per the
|
|
327
|
-
* start order on {@link SessionLogWatcher}.
|
|
328
|
-
*
|
|
329
|
-
* `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
|
|
330
|
-
* attached, on a continuing worker dispatch with no cursor anywhere) from
|
|
331
|
-
* rule 5 (a file that appeared later, which always starts at 0 so none of its
|
|
332
|
-
* lines are lost).
|
|
333
|
-
*/
|
|
334
|
-
private resolveStart;
|
|
335
|
-
/**
|
|
336
|
-
* DX-2954 — every open source's cursor state.
|
|
337
|
-
*
|
|
338
|
-
* `delivered` is the byte up to which this watcher has handed lines to
|
|
339
|
-
* subscribers, which the collector reports as the `delivered` half of its
|
|
340
|
-
* stats write. `startOffset` is where this watcher OPENED the file, and it is
|
|
341
|
-
* the floor for the `acked` half: a source that started at EOF under rule 4
|
|
342
|
-
* must never report `acked: 0`, or the next restart would read the whole file
|
|
343
|
-
* from the start and re-sum the history rule 4 exists to skip.
|
|
344
|
-
*/
|
|
345
|
-
cursorState(): Record<string, {
|
|
346
|
-
startOffset: number;
|
|
347
|
-
delivered: number;
|
|
348
|
-
}>;
|
|
349
|
-
/**
|
|
350
|
-
* DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
|
|
351
|
-
* or 0 when it has none.
|
|
352
|
-
*
|
|
353
|
-
* Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
|
|
354
|
-
* watcher attach mid-session?". The offset alone is not enough: because `acked`
|
|
355
|
-
* lags `delivered` by one write, a crash right after the first cursor-bearing
|
|
356
|
-
* write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
|
|
357
|
-
* every byte up to D is a re-read.
|
|
358
|
-
*/
|
|
359
|
-
mainReplayUntil(): number;
|
|
360
|
-
/**
|
|
361
|
-
* DX-2954 — the main transcript's starting offset once discovery has run, or
|
|
362
|
-
* `null` before that.
|
|
363
|
-
*
|
|
364
|
-
* This is what "is this a mid-session attach?" is derived from now. The
|
|
365
|
-
* declared-loaded observer used to be told `reattached: true` explicitly;
|
|
366
|
-
* a non-zero main start is the same fact stated as an observation rather than
|
|
367
|
-
* a flag, and it is true for every case that matters (a reattach, a
|
|
368
|
-
* `--resume`, a resumed operator session) without a caller having to
|
|
369
|
-
* enumerate them.
|
|
370
|
-
*/
|
|
371
|
-
mainStartOffset(): number | null;
|
|
372
207
|
/** Register a consumer callback that receives each new AgentLogEntry. */
|
|
373
208
|
onEntry(consumer: EntryConsumer): void;
|
|
374
209
|
/** Returns the discovered parent session file path, or null if not yet found. */
|
|
@@ -429,10 +264,15 @@ export declare class SessionLogWatcher {
|
|
|
429
264
|
/**
|
|
430
265
|
* Adopt a parent session file and derive the sub-agent directory.
|
|
431
266
|
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
267
|
+
* When `this.fromEof` is set (Phase 2c reattach OR a DX-2583 resume — see
|
|
268
|
+
* the option's doc), seed `byteOffset` to the file's current size so the
|
|
269
|
+
* next poll reads strictly forward — historical entries are NOT re-emitted.
|
|
270
|
+
* A fresh `stat` failure (race
|
|
271
|
+
* with a delete that happens between discover and adopt) falls back to
|
|
272
|
+
* 0 so the watcher tails from the start; that is preferable to throwing
|
|
273
|
+
* during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
|
|
274
|
+
* file claude writes AFTER reattach is genuinely new and should be
|
|
275
|
+
* tailed normally.
|
|
436
276
|
*/
|
|
437
277
|
private setParentFile;
|
|
438
278
|
/**
|
|
@@ -457,27 +297,6 @@ export declare class SessionLogWatcher {
|
|
|
457
297
|
* `pollFile` — the inline call is the defense-in-depth that closes the race
|
|
458
298
|
* where bytes appear before `refreshPendingAgentTypes` has a chance to run.
|
|
459
299
|
*/
|
|
460
|
-
/**
|
|
461
|
-
* DX-2954 — report a running sub-agent to the consumer, normalizing the
|
|
462
|
-
* absent-type shape and CONTAINING any throw.
|
|
463
|
-
*
|
|
464
|
-
* The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
|
|
465
|
-
* every consumer for the same reason, and it is load-bearing here because of
|
|
466
|
-
* where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
|
|
467
|
-
* `poll()` has `try/finally` without one, so an escaping throw would:
|
|
468
|
-
*
|
|
469
|
-
* - on the first pass (inside `start()`), kill the watcher for the whole
|
|
470
|
-
* dispatch — the caller only logs `start()`'s rejection;
|
|
471
|
-
* - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
|
|
472
|
-
* turns into process exit — a worker crash caused by a consumer bug;
|
|
473
|
-
* - either way abort the discovery loop mid-list, skipping
|
|
474
|
-
* `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
|
|
475
|
-
* sends later-appearing files down rule 4 instead of rule 5 — losing
|
|
476
|
-
* exactly the lines that rule split exists to preserve.
|
|
477
|
-
*
|
|
478
|
-
* Reporting the running set must never cost the monitoring it rides on.
|
|
479
|
-
*/
|
|
480
|
-
private emitSourceDiscovered;
|
|
481
300
|
private refreshLineage;
|
|
482
301
|
/**
|
|
483
302
|
* Read agentType from the `<subagentId>.meta.json` sidecar. Returns
|
|
@@ -26,7 +26,6 @@ import { homedir } from "node:os";
|
|
|
26
26
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
27
27
|
import { createLogger } from "./internal-logger.js";
|
|
28
28
|
import { buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
|
|
29
|
-
import { MAIN_SOURCE } from "./usage-ledger.js";
|
|
30
29
|
const log = createLogger("session-log-watcher");
|
|
31
30
|
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
32
31
|
const FILE_DISCOVERY_POLL_MS = 1_000;
|
|
@@ -220,52 +219,29 @@ export async function findSessionFileByDispatchId(dir, dispatchId) {
|
|
|
220
219
|
try {
|
|
221
220
|
const files = await readdir(dir);
|
|
222
221
|
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// co-located multi-MB files can momentarily fail an `open`/`stat`/
|
|
247
|
-
// `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
|
|
248
|
-
// worktree torn down under the agent), EACCES (perms race), EMFILE
|
|
249
|
-
// (fd pressure across N concurrent watchers). Before this guard the
|
|
250
|
-
// throw propagated out of the per-file body and rejected the entire
|
|
251
|
-
// `findSessionFileByDispatchId` call → `discoverSessionFile` →
|
|
252
|
-
// `start()` (fire-and-forget, no `.catch`) → the watcher silently
|
|
253
|
-
// never attached → null `session_uuid` + zero usage/tool-call
|
|
254
|
-
// counters on ~20% of host dispatches (intermittent because the
|
|
255
|
-
// outcome hinged on `readdir` ordering vs the failing sibling).
|
|
256
|
-
// Skip this file and keep scanning the rest — the tagged file the
|
|
257
|
-
// caller actually wants may be later in the directory.
|
|
258
|
-
const code = err.code;
|
|
259
|
-
if (code !== "ENOENT") {
|
|
260
|
-
log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
|
|
261
|
-
}
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
finally {
|
|
265
|
-
await fd?.close();
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return null;
|
|
222
|
+
// DX-3395 — scan every sibling CONCURRENTLY instead of one at a time.
|
|
223
|
+
// A dispatched agent's cwd is a busy, SHARED session dir (DX-1201's own
|
|
224
|
+
// comment below: every issue-worker dispatch of one card writes under
|
|
225
|
+
// the same encoded `~/.claude/projects/<cwd>/` dir), so this directory
|
|
226
|
+
// accumulates one multi-MB `.jsonl` per distinct session ever launched
|
|
227
|
+
// there. The previous `for...of` paid each sibling's bounded
|
|
228
|
+
// open+stat+read (<=2*SCAN_BYTES) back-to-back even though they are
|
|
229
|
+
// independent filesystem operations the OS can service in parallel —
|
|
230
|
+
// this is the ONE stage DX-3395's resume-latency investigation found
|
|
231
|
+
// that is both real, sequential, and entirely danxbot-owned (unlike the
|
|
232
|
+
// dominant cost, `claude --resume`'s own session reload, which lives
|
|
233
|
+
// outside this codebase — see the card for the measured comparison).
|
|
234
|
+
// `Promise.all` lets every sibling's I/O overlap; each check keeps its
|
|
235
|
+
// own try/catch (unchanged from the DX-1201 per-file isolation below),
|
|
236
|
+
// so one unreadable sibling still can never abort the others. At most
|
|
237
|
+
// ONE sibling can ever contain a given dispatch's unique tag, so which
|
|
238
|
+
// promise settles first is never ambiguous — the result is looked up by
|
|
239
|
+
// `files`' ORIGINAL index, so the return value stays deterministic
|
|
240
|
+
// regardless of resolution order (proven by the multi-sibling test in
|
|
241
|
+
// `session-log-watcher.test.ts`).
|
|
242
|
+
const hits = await Promise.all(jsonlFiles.map((file) => fileContainsTag(dir, file, tag, SCAN_BYTES)));
|
|
243
|
+
const index = hits.findIndex(Boolean);
|
|
244
|
+
return index === -1 ? null : join(dir, jsonlFiles[index]);
|
|
269
245
|
}
|
|
270
246
|
catch (err) {
|
|
271
247
|
if (err.code === "ENOENT")
|
|
@@ -273,6 +249,56 @@ export async function findSessionFileByDispatchId(dir, dispatchId) {
|
|
|
273
249
|
throw err;
|
|
274
250
|
}
|
|
275
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* One sibling's head+tail scan for `findSessionFileByDispatchId`'s
|
|
254
|
+
* concurrent `Promise.all` — factored out so each file's try/catch/finally
|
|
255
|
+
* is self-contained and never affects any other sibling's check.
|
|
256
|
+
*/
|
|
257
|
+
async function fileContainsTag(dir, file, tag, scanBytes) {
|
|
258
|
+
const filePath = join(dir, file);
|
|
259
|
+
let fd;
|
|
260
|
+
try {
|
|
261
|
+
fd = await open(filePath, "r");
|
|
262
|
+
const fileStats = await stat(filePath);
|
|
263
|
+
if (await windowIncludesTag(fd, 0, fileStats.size, scanBytes, tag)) {
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
// Resume: the new tag is at the tail of a large prior-session file.
|
|
267
|
+
// Skip when the file fits in one head window (already fully read).
|
|
268
|
+
if (fileStats.size > scanBytes) {
|
|
269
|
+
const tailStart = fileStats.size - scanBytes;
|
|
270
|
+
if (await windowIncludesTag(fd, tailStart, fileStats.size, scanBytes, tag)) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
// DX-1201 — a single unreadable SIBLING file must NOT abort the
|
|
278
|
+
// whole scan. Dispatched agents share a busy session dir (e.g.
|
|
279
|
+
// every issue-worker dispatch of one repo writing under the same
|
|
280
|
+
// encoded `~/.claude/projects/<cwd>/` dir), so any one of the
|
|
281
|
+
// co-located multi-MB files can momentarily fail an `open`/`stat`/
|
|
282
|
+
// `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
|
|
283
|
+
// worktree torn down under the agent), EACCES (perms race), EMFILE
|
|
284
|
+
// (fd pressure across N concurrent watchers). Before this guard the
|
|
285
|
+
// throw propagated out of the per-file body and rejected the entire
|
|
286
|
+
// `findSessionFileByDispatchId` call → `discoverSessionFile` →
|
|
287
|
+
// `start()` (fire-and-forget, no `.catch`) → the watcher silently
|
|
288
|
+
// never attached → null `session_uuid` + zero usage/tool-call
|
|
289
|
+
// counters on ~20% of host dispatches (intermittent because the
|
|
290
|
+
// outcome hinged on `readdir` ordering vs the failing sibling).
|
|
291
|
+
// Report this sibling as a non-match and let the others resolve.
|
|
292
|
+
const code = err.code;
|
|
293
|
+
if (code !== "ENOENT") {
|
|
294
|
+
log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await fd?.close();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
276
302
|
/**
|
|
277
303
|
* Read a `<= maxBytes` window starting at `from` (clamped to the file) and
|
|
278
304
|
* report whether `tag` appears in it. Factored out so the head + tail scans
|
|
@@ -414,29 +440,6 @@ export function convertJsonlEntry(raw, lastTimestamp) {
|
|
|
414
440
|
return null;
|
|
415
441
|
}
|
|
416
442
|
}
|
|
417
|
-
/**
|
|
418
|
-
* DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
|
|
419
|
-
* {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
|
|
420
|
-
* the right `startCursors` / `startExistingFilesAtEof`.
|
|
421
|
-
*
|
|
422
|
-
* | # | Condition | Start |
|
|
423
|
-
* |---|---|---|
|
|
424
|
-
* | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
|
|
425
|
-
* | 2 | a worker dispatch continuing a session: its predecessor's cursor for this source (the caller resolves it INTO `startCursors`, so it arrives as rule 1) | the predecessor's final `acked`, replaying to its final `delivered` |
|
|
426
|
-
* | 3 | the FIRST dispatch of its Claude session — a new worker session, or any operator dispatch (always the only one for its session id, including one opened by a mid-session `plan_connect` and a forked session's new id) | 0 |
|
|
427
|
-
* | 4 | a WORKER dispatch continuing a session with NO cursor for ANY source (a row from before this card; a `--resume` whose predecessor predates it) — `startExistingFilesAtEof` | every ALREADY-EXISTING file at its current end |
|
|
428
|
-
* | 5 | otherwise — in particular a sub-agent file that appeared AFTER the last stats write, on a dispatch that has cursors for other sources | 0 |
|
|
429
|
-
*
|
|
430
|
-
* Rules 1 and 2 arrive identically because "where does this source resume" has
|
|
431
|
-
* ONE answer; rule 4 is the only branch that skips content, and it is narrow by
|
|
432
|
-
* construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
|
|
433
|
-
* late sub-agent file's lines).
|
|
434
|
-
*
|
|
435
|
-
* Rules 3 and 5 both read from 0, but for different reasons, and both are
|
|
436
|
-
* correct: a fresh session's full usage SHOULD be counted, and a forked
|
|
437
|
-
* session's copied history is read and credited nothing (the ledger's excess
|
|
438
|
-
* rule already covers it).
|
|
439
|
-
*/
|
|
440
443
|
export class SessionLogWatcher {
|
|
441
444
|
consumers = [];
|
|
442
445
|
pollTimer;
|
|
@@ -448,10 +451,8 @@ export class SessionLogWatcher {
|
|
|
448
451
|
sessionId;
|
|
449
452
|
dispatchId;
|
|
450
453
|
pollIntervalMs;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
onCursorPastEof;
|
|
454
|
-
onSourceDiscovered;
|
|
454
|
+
fromEof;
|
|
455
|
+
existingSubagentFilesFromEof;
|
|
455
456
|
subagentDiscoveryDone = false;
|
|
456
457
|
running = false;
|
|
457
458
|
polling = false;
|
|
@@ -467,110 +468,9 @@ export class SessionLogWatcher {
|
|
|
467
468
|
this.sessionId = options.sessionId;
|
|
468
469
|
this.dispatchId = options.dispatchId;
|
|
469
470
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
470
|
-
this.
|
|
471
|
-
this.
|
|
472
|
-
|
|
473
|
-
this.onSourceDiscovered = options.onSourceDiscovered;
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* DX-2954 — resolve one file's starting offset and replay boundary, per the
|
|
477
|
-
* start order on {@link SessionLogWatcher}.
|
|
478
|
-
*
|
|
479
|
-
* `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
|
|
480
|
-
* attached, on a continuing worker dispatch with no cursor anywhere) from
|
|
481
|
-
* rule 5 (a file that appeared later, which always starts at 0 so none of its
|
|
482
|
-
* lines are lost).
|
|
483
|
-
*/
|
|
484
|
-
async resolveStart(source, filePath, fileAlreadyExisted) {
|
|
485
|
-
const stored = this.startCursors[source];
|
|
486
|
-
// Rules 1-2: this dispatch's own stored cursor, or the predecessor's that
|
|
487
|
-
// the caller already resolved into `startCursors`. Both arrive here the
|
|
488
|
-
// same way, because "where does this source resume" has ONE answer.
|
|
489
|
-
if (stored !== undefined) {
|
|
490
|
-
return { byteOffset: stored.acked, replayUntil: stored.delivered };
|
|
491
|
-
}
|
|
492
|
-
// Rule 4: no cursor for ANY source on a continuing worker dispatch.
|
|
493
|
-
if (this.startExistingFilesAtEof && fileAlreadyExisted) {
|
|
494
|
-
try {
|
|
495
|
-
const size = (await stat(filePath)).size;
|
|
496
|
-
return { byteOffset: size, replayUntil: 0 };
|
|
497
|
-
}
|
|
498
|
-
catch (err) {
|
|
499
|
-
// NOT a fall-back to 0. This branch fires precisely on the rows whose
|
|
500
|
-
// stored totals already count this file, so reading it from the start
|
|
501
|
-
// re-sums the whole session — the 421M-cache-read inflation (DX-2583)
|
|
502
|
-
// that rule 4 exists to prevent. `null` means "do not open this source
|
|
503
|
-
// yet"; the caller leaves it undiscovered and the next poll retries,
|
|
504
|
-
// still treating it as pre-existing.
|
|
505
|
-
log.warn(`startExistingFilesAtEof: stat failed for ${filePath}; NOT opening it this pass (reading from 0 would re-sum the session): ${String(err)}`);
|
|
506
|
-
return null;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
// Rules 3 and 5: the first dispatch of a session, and any file that appeared
|
|
510
|
-
// after the last stats write, both read from the start.
|
|
511
|
-
return { byteOffset: 0, replayUntil: 0 };
|
|
512
|
-
}
|
|
513
|
-
/**
|
|
514
|
-
* DX-2954 — every open source's cursor state.
|
|
515
|
-
*
|
|
516
|
-
* `delivered` is the byte up to which this watcher has handed lines to
|
|
517
|
-
* subscribers, which the collector reports as the `delivered` half of its
|
|
518
|
-
* stats write. `startOffset` is where this watcher OPENED the file, and it is
|
|
519
|
-
* the floor for the `acked` half: a source that started at EOF under rule 4
|
|
520
|
-
* must never report `acked: 0`, or the next restart would read the whole file
|
|
521
|
-
* from the start and re-sum the history rule 4 exists to skip.
|
|
522
|
-
*/
|
|
523
|
-
cursorState() {
|
|
524
|
-
// `delivered` is `lineBufferStartOffset`, NOT `byteOffset`.
|
|
525
|
-
//
|
|
526
|
-
// `pollFile` advances `byteOffset` by every byte it READ, and `processText`
|
|
527
|
-
// deliberately buffers a trailing newline-less line (the whole reason
|
|
528
|
-
// `lineBuffer` exists — a poll routinely catches a line mid-append). So
|
|
529
|
-
// `byteOffset` can sit PAST the first byte of a line that was never emitted;
|
|
530
|
-
// promoting that to `acked` makes a restart open the file mid-JSON, and the
|
|
531
|
-
// fragment is dropped as a malformed line — losing that reply's usage, its
|
|
532
|
-
// tool-use ids and its side effects permanently.
|
|
533
|
-
//
|
|
534
|
-
// `lineBufferStartOffset` is the start of the next UNCONSUMED line, which is
|
|
535
|
-
// exactly the resume point. It equals `byteOffset` whenever the buffer is
|
|
536
|
-
// empty, so the happy path is unchanged.
|
|
537
|
-
const at = (s) => ({
|
|
538
|
-
startOffset: s.startOffset,
|
|
539
|
-
delivered: s.lineBufferStartOffset,
|
|
540
|
-
});
|
|
541
|
-
const out = {};
|
|
542
|
-
if (this.parentTail)
|
|
543
|
-
out[this.parentTail.source] = at(this.parentTail);
|
|
544
|
-
for (const state of this.subagentTails.values())
|
|
545
|
-
out[state.source] = at(state);
|
|
546
|
-
return out;
|
|
547
|
-
}
|
|
548
|
-
/**
|
|
549
|
-
* DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
|
|
550
|
-
* or 0 when it has none.
|
|
551
|
-
*
|
|
552
|
-
* Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
|
|
553
|
-
* watcher attach mid-session?". The offset alone is not enough: because `acked`
|
|
554
|
-
* lags `delivered` by one write, a crash right after the first cursor-bearing
|
|
555
|
-
* write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
|
|
556
|
-
* every byte up to D is a re-read.
|
|
557
|
-
*/
|
|
558
|
-
mainReplayUntil() {
|
|
559
|
-
return this.parentTail?.replayUntil ?? 0;
|
|
560
|
-
}
|
|
561
|
-
/**
|
|
562
|
-
* DX-2954 — the main transcript's starting offset once discovery has run, or
|
|
563
|
-
* `null` before that.
|
|
564
|
-
*
|
|
565
|
-
* This is what "is this a mid-session attach?" is derived from now. The
|
|
566
|
-
* declared-loaded observer used to be told `reattached: true` explicitly;
|
|
567
|
-
* a non-zero main start is the same fact stated as an observation rather than
|
|
568
|
-
* a flag, and it is true for every case that matters (a reattach, a
|
|
569
|
-
* `--resume`, a resumed operator session) without a caller having to
|
|
570
|
-
* enumerate them.
|
|
571
|
-
*/
|
|
572
|
-
mainStartOffset() {
|
|
573
|
-
return this.parentTail?.startOffset ?? null;
|
|
471
|
+
this.fromEof = options.fromEof ?? false;
|
|
472
|
+
this.existingSubagentFilesFromEof =
|
|
473
|
+
options.existingSubagentFilesFromEof ?? false;
|
|
574
474
|
}
|
|
575
475
|
/** Register a consumer callback that receives each new AgentLogEntry. */
|
|
576
476
|
onEntry(consumer) {
|
|
@@ -671,7 +571,8 @@ export class SessionLogWatcher {
|
|
|
671
571
|
const filePath = this.dispatchId
|
|
672
572
|
? await findSessionFileByDispatchId(this.sessionDir, this.dispatchId)
|
|
673
573
|
: await findNewestJsonlFile(this.sessionDir, this.sessionId);
|
|
674
|
-
if (filePath
|
|
574
|
+
if (filePath) {
|
|
575
|
+
await this.setParentFile(filePath);
|
|
675
576
|
const tag = this.dispatchId ? `[Dispatch ${this.dispatchId}] ` : "";
|
|
676
577
|
log.info(`${tag}Watching session file: ${filePath}`);
|
|
677
578
|
return;
|
|
@@ -683,35 +584,35 @@ export class SessionLogWatcher {
|
|
|
683
584
|
/**
|
|
684
585
|
* Adopt a parent session file and derive the sub-agent directory.
|
|
685
586
|
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
587
|
+
* When `this.fromEof` is set (Phase 2c reattach OR a DX-2583 resume — see
|
|
588
|
+
* the option's doc), seed `byteOffset` to the file's current size so the
|
|
589
|
+
* next poll reads strictly forward — historical entries are NOT re-emitted.
|
|
590
|
+
* A fresh `stat` failure (race
|
|
591
|
+
* with a delete that happens between discover and adopt) falls back to
|
|
592
|
+
* 0 so the watcher tails from the start; that is preferable to throwing
|
|
593
|
+
* during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
|
|
594
|
+
* file claude writes AFTER reattach is genuinely new and should be
|
|
595
|
+
* tailed normally.
|
|
690
596
|
*/
|
|
691
597
|
async setParentFile(filePath) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
598
|
+
let byteOffset = 0;
|
|
599
|
+
if (this.fromEof) {
|
|
600
|
+
try {
|
|
601
|
+
const fileStats = await stat(filePath);
|
|
602
|
+
byteOffset = fileStats.size;
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
log.warn(`fromEof: stat failed for ${filePath}; tailing from 0: ${String(err)}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
702
608
|
this.parentTail = {
|
|
703
609
|
filePath,
|
|
704
610
|
byteOffset,
|
|
705
611
|
lineBuffer: "",
|
|
706
612
|
lineBufferStartOffset: byteOffset,
|
|
707
613
|
lastTimestamp: 0,
|
|
708
|
-
source: MAIN_SOURCE,
|
|
709
|
-
startOffset: byteOffset,
|
|
710
|
-
replayUntil,
|
|
711
|
-
discoveredAt: Date.now(),
|
|
712
614
|
};
|
|
713
615
|
this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
|
|
714
|
-
return true;
|
|
715
616
|
}
|
|
716
617
|
/**
|
|
717
618
|
* Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
|
|
@@ -753,41 +654,8 @@ export class SessionLogWatcher {
|
|
|
753
654
|
const fileStats = await stat(state.filePath);
|
|
754
655
|
if (!this.running)
|
|
755
656
|
return;
|
|
756
|
-
|
|
757
|
-
// or truncated under us. `size === byteOffset` is the ordinary "no new
|
|
758
|
-
// bytes" case; only a STRICTLY smaller file is the pathological one, and
|
|
759
|
-
// before this card it fell into the same early return and silently tailed
|
|
760
|
-
// nothing for the rest of the dispatch. Restart that source at 0 and
|
|
761
|
-
// report it, so the caller records a visible event and marks the reset in
|
|
762
|
-
// its next stats write (the one sanctioned backwards move of `acked`).
|
|
763
|
-
if (fileStats.size < state.byteOffset) {
|
|
764
|
-
this.onCursorPastEof?.({
|
|
765
|
-
source: state.source,
|
|
766
|
-
storedOffset: state.byteOffset,
|
|
767
|
-
fileSize: fileStats.size,
|
|
768
|
-
});
|
|
769
|
-
state.byteOffset = 0;
|
|
770
|
-
state.lineBufferStartOffset = 0;
|
|
771
|
-
state.lineBuffer = "";
|
|
772
|
-
// `startOffset` moves to 0 with it: it is the FLOOR the collector uses
|
|
773
|
-
// for this source's reported `acked`, so leaving it at the old (now
|
|
774
|
-
// impossible) offset would make the next write claim to have
|
|
775
|
-
// acknowledged bytes the file no longer has.
|
|
776
|
-
state.startOffset = 0;
|
|
777
|
-
// Nothing below the new position was ever delivered from THIS file's
|
|
778
|
-
// current content, so nothing in it is a replay.
|
|
779
|
-
state.replayUntil = 0;
|
|
780
|
-
// The replacement file's first entry must not have its `delta_ms`
|
|
781
|
-
// computed against the OLD file's last timestamp (which can produce a
|
|
782
|
-
// negative or absurd gap). This is a different file; its timeline starts
|
|
783
|
-
// fresh.
|
|
784
|
-
state.lastTimestamp = 0;
|
|
785
|
-
if (fileStats.size === 0)
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
else if (fileStats.size === state.byteOffset) {
|
|
657
|
+
if (fileStats.size <= state.byteOffset)
|
|
789
658
|
return;
|
|
790
|
-
}
|
|
791
659
|
const fd = await open(state.filePath, "r");
|
|
792
660
|
try {
|
|
793
661
|
if (!this.running)
|
|
@@ -845,62 +713,40 @@ export class SessionLogWatcher {
|
|
|
845
713
|
log.error("Failed to enumerate sub-agent directory:", err);
|
|
846
714
|
return;
|
|
847
715
|
}
|
|
848
|
-
// DX-
|
|
849
|
-
// existed when this watcher attached
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const firstPass = !this.subagentDiscoveryDone;
|
|
853
|
-
// Marked done only if this pass established a start offset for EVERY file it
|
|
854
|
-
// saw. A file skipped by `resolveStart` must still count as PRE-EXISTING on
|
|
855
|
-
// the retry — treating it as "appeared later" would send it to rule 5 (read
|
|
856
|
-
// from 0) and re-sum exactly the history rule 4 skipped.
|
|
857
|
-
let allResolved = true;
|
|
716
|
+
// DX-2963 — the FIRST discovery pass sees exactly the sub-agent files
|
|
717
|
+
// that existed when this watcher attached; only those may open at EOF.
|
|
718
|
+
const openExistingAtEof = this.existingSubagentFilesFromEof && !this.subagentDiscoveryDone;
|
|
719
|
+
this.subagentDiscoveryDone = true;
|
|
858
720
|
for (const file of files) {
|
|
859
721
|
if (!file.endsWith(".jsonl"))
|
|
860
722
|
continue;
|
|
861
723
|
const fullPath = join(this.subagentsDir, file);
|
|
862
724
|
if (this.subagentTails.has(fullPath))
|
|
863
725
|
continue;
|
|
864
|
-
// A sub-agent's ledger source key IS its file's basename (`agent-<hash>`),
|
|
865
|
-
// the same value `usage-ledger.ts` credits usage under and the same value
|
|
866
|
-
// `dispatch_activities.activity_id` holds — so a cursor, a per-source
|
|
867
|
-
// token total and a running-set row all join on one key.
|
|
868
726
|
const subagentId = basename(file, ".jsonl");
|
|
869
727
|
const agentType = await this.readAgentType(subagentId);
|
|
870
|
-
|
|
871
|
-
if (
|
|
872
|
-
|
|
873
|
-
|
|
728
|
+
let startOffset = 0;
|
|
729
|
+
if (openExistingAtEof) {
|
|
730
|
+
try {
|
|
731
|
+
startOffset = (await stat(fullPath)).size;
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
log.warn(`existingSubagentFilesFromEof: stat failed for ${fullPath}; tailing from 0: ${String(err)}`);
|
|
735
|
+
}
|
|
874
736
|
}
|
|
875
|
-
const { byteOffset, replayUntil } = start;
|
|
876
|
-
const discoveredAt = Date.now();
|
|
877
737
|
this.subagentTails.set(fullPath, {
|
|
878
738
|
filePath: fullPath,
|
|
879
|
-
byteOffset,
|
|
880
|
-
lineBufferStartOffset:
|
|
739
|
+
byteOffset: startOffset,
|
|
740
|
+
lineBufferStartOffset: startOffset,
|
|
881
741
|
lineBuffer: "",
|
|
882
742
|
lastTimestamp: 0,
|
|
883
|
-
source: subagentId,
|
|
884
|
-
startOffset: byteOffset,
|
|
885
|
-
replayUntil,
|
|
886
|
-
discoveredAt,
|
|
887
743
|
lineage: {
|
|
888
744
|
subagent_id: subagentId,
|
|
889
745
|
parent_session_id: this.parentSessionId,
|
|
890
746
|
agent_type: agentType,
|
|
891
747
|
},
|
|
892
748
|
});
|
|
893
|
-
// DX-2954 — fired AFTER the tail is registered, so a consumer that throws
|
|
894
|
-
// cannot leave a half-registered source, and the `subagentTails.has()`
|
|
895
|
-
// guard above makes it exactly-once per source for this watcher's life. A
|
|
896
|
-
// re-discovery by a RESTARTED watcher fires again by design: the row it
|
|
897
|
-
// reports is an idempotent upsert keyed on this same id, and re-reporting
|
|
898
|
-
// is how a reattached collector re-learns the sub-agents that were already
|
|
899
|
-
// running before it existed.
|
|
900
|
-
this.emitSourceDiscovered(subagentId, agentType, discoveredAt);
|
|
901
749
|
}
|
|
902
|
-
if (allResolved)
|
|
903
|
-
this.subagentDiscoveryDone = true;
|
|
904
750
|
}
|
|
905
751
|
/** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
|
|
906
752
|
async refreshPendingAgentTypes() {
|
|
@@ -916,62 +762,13 @@ export class SessionLogWatcher {
|
|
|
916
762
|
* `pollFile` — the inline call is the defense-in-depth that closes the race
|
|
917
763
|
* where bytes appear before `refreshPendingAgentTypes` has a chance to run.
|
|
918
764
|
*/
|
|
919
|
-
/**
|
|
920
|
-
* DX-2954 — report a running sub-agent to the consumer, normalizing the
|
|
921
|
-
* absent-type shape and CONTAINING any throw.
|
|
922
|
-
*
|
|
923
|
-
* The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
|
|
924
|
-
* every consumer for the same reason, and it is load-bearing here because of
|
|
925
|
-
* where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
|
|
926
|
-
* `poll()` has `try/finally` without one, so an escaping throw would:
|
|
927
|
-
*
|
|
928
|
-
* - on the first pass (inside `start()`), kill the watcher for the whole
|
|
929
|
-
* dispatch — the caller only logs `start()`'s rejection;
|
|
930
|
-
* - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
|
|
931
|
-
* turns into process exit — a worker crash caused by a consumer bug;
|
|
932
|
-
* - either way abort the discovery loop mid-list, skipping
|
|
933
|
-
* `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
|
|
934
|
-
* sends later-appearing files down rule 4 instead of rule 5 — losing
|
|
935
|
-
* exactly the lines that rule split exists to preserve.
|
|
936
|
-
*
|
|
937
|
-
* Reporting the running set must never cost the monitoring it rides on.
|
|
938
|
-
*/
|
|
939
|
-
emitSourceDiscovered(source, agentType, discoveredAt) {
|
|
940
|
-
try {
|
|
941
|
-
this.onSourceDiscovered?.({
|
|
942
|
-
source,
|
|
943
|
-
// ONE absent-shape on the wire, never two: this class's own reader
|
|
944
|
-
// answers `undefined` for "no meta file yet", while the stats payload and
|
|
945
|
-
// the `dispatch_activities.agent_type` column both spell absent as
|
|
946
|
-
// `null`. Normalizing here keeps every consumer from accepting both.
|
|
947
|
-
agentType: agentType ?? null,
|
|
948
|
-
discoveredAt,
|
|
949
|
-
});
|
|
950
|
-
}
|
|
951
|
-
catch (err) {
|
|
952
|
-
log.error("onSourceDiscovered consumer error:", err);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
765
|
async refreshLineage(state) {
|
|
956
766
|
if (!state.lineage)
|
|
957
767
|
return;
|
|
958
768
|
let { agent_type, parent_session_id } = state.lineage;
|
|
959
|
-
// DX-2954 — remember whether the type was still unknown coming in, so the
|
|
960
|
-
// `undefined -> string` transition can be reported. Discovery reads the
|
|
961
|
-
// sidecar the instant the `.jsonl` appears, and claude writes the `.jsonl`
|
|
962
|
-
// BEFORE the `.meta.json` (the race this method exists for), so "no type
|
|
963
|
-
// yet" at discovery is the COMMON case, not an edge. Without a report here
|
|
964
|
-
// the running-set row's `agent_type` would stay NULL for the whole dispatch
|
|
965
|
-
// even though the watcher learned the real value one poll later.
|
|
966
|
-
const typeWasUnknown = agent_type === undefined;
|
|
967
769
|
if (agent_type === undefined) {
|
|
968
770
|
agent_type = await this.readAgentType(state.lineage.subagent_id);
|
|
969
771
|
}
|
|
970
|
-
if (typeWasUnknown && agent_type !== undefined && state.source !== MAIN_SOURCE) {
|
|
971
|
-
// The ORIGINAL discovery stamp, never a fresh `Date.now()`: this is the
|
|
972
|
-
// same activity, learning its type late, not a new one starting now.
|
|
973
|
-
this.emitSourceDiscovered(state.source, agent_type, state.discoveredAt);
|
|
974
|
-
}
|
|
975
772
|
if (parent_session_id === null && this.parentSessionId !== null) {
|
|
976
773
|
parent_session_id = this.parentSessionId;
|
|
977
774
|
}
|
|
@@ -1072,7 +869,7 @@ export class SessionLogWatcher {
|
|
|
1072
869
|
synthesized: true,
|
|
1073
870
|
},
|
|
1074
871
|
};
|
|
1075
|
-
this.emitEntry(initEntry, state
|
|
872
|
+
this.emitEntry(initEntry, state);
|
|
1076
873
|
this.initSynthesized = true;
|
|
1077
874
|
}
|
|
1078
875
|
}
|
|
@@ -1090,7 +887,7 @@ export class SessionLogWatcher {
|
|
|
1090
887
|
state.lastTimestamp = result.timestamp;
|
|
1091
888
|
// DX-2963 — the line's own byte offset (see `TailState.lineBufferStartOffset`).
|
|
1092
889
|
result.entry.data = { ...result.entry.data, byte_offset: lineStart };
|
|
1093
|
-
this.emitEntry(result.entry, state
|
|
890
|
+
this.emitEntry(result.entry, state);
|
|
1094
891
|
}
|
|
1095
892
|
/**
|
|
1096
893
|
* Emit an entry to registered consumers. For sub-agent streams,
|
|
@@ -1103,7 +900,7 @@ export class SessionLogWatcher {
|
|
|
1103
900
|
* tests; nothing else read it. Every real consumer subscribes via
|
|
1104
901
|
* `onEntry` instead.
|
|
1105
902
|
*/
|
|
1106
|
-
emitEntry(entry, state
|
|
903
|
+
emitEntry(entry, state) {
|
|
1107
904
|
if (state.lineage) {
|
|
1108
905
|
entry.data = {
|
|
1109
906
|
...entry.data,
|
|
@@ -1112,18 +909,9 @@ export class SessionLogWatcher {
|
|
|
1112
909
|
agent_type: state.lineage.agent_type,
|
|
1113
910
|
};
|
|
1114
911
|
}
|
|
1115
|
-
// DX-2954 — a line whose FIRST byte lies below the source's stored
|
|
1116
|
-
// `delivered` cursor was already handed to a previous incarnation's
|
|
1117
|
-
// subscribers. Strictly `<`: `delivered` is an exclusive end (the next
|
|
1118
|
-
// unread byte), so a line starting exactly AT it is new.
|
|
1119
|
-
const meta = {
|
|
1120
|
-
source: state.source,
|
|
1121
|
-
byteOffset,
|
|
1122
|
-
replay: byteOffset < state.replayUntil,
|
|
1123
|
-
};
|
|
1124
912
|
for (const consumer of this.consumers) {
|
|
1125
913
|
try {
|
|
1126
|
-
consumer(entry
|
|
914
|
+
consumer(entry);
|
|
1127
915
|
}
|
|
1128
916
|
catch (err) {
|
|
1129
917
|
log.error("Consumer error:", err);
|
package/dist/usage-ledger.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-2963 — the ONE usage counter. Turns Claude Code transcript lines into
|
|
3
3
|
* per-reply usage-ledger entries; every counter in danxbot (the worker's
|
|
4
|
-
* accumulator,
|
|
4
|
+
* accumulator, `claude-probe.ts`'s usage summer, the stall sweep's productivity signal,
|
|
5
5
|
* the cross-consumer tests) uses it instead of its own summing.
|
|
6
6
|
*
|
|
7
7
|
* The rule it implements, measured on real transcripts (DX-2963 card,
|
|
@@ -121,7 +121,7 @@ export declare class UsageLedgerCounter {
|
|
|
121
121
|
* Pull the counter's {@link LedgerLine} fields out of one raw transcript
|
|
122
122
|
* line (a parsed JSONL object). Returns `null` for anything that is not an
|
|
123
123
|
* assistant line. Shared by every counter that reads a transcript from disk
|
|
124
|
-
* (the dashboard's reader,
|
|
124
|
+
* (the dashboard's reader, `claude-probe.ts`, the stall sweep).
|
|
125
125
|
*/
|
|
126
126
|
export declare function ledgerLineFromRaw(raw: Record<string, unknown>, source: string, byteOffset: number): LedgerLine | null;
|
|
127
127
|
/**
|
package/dist/usage-ledger.js
CHANGED
|
@@ -214,7 +214,7 @@ function snapshot(e) {
|
|
|
214
214
|
* Pull the counter's {@link LedgerLine} fields out of one raw transcript
|
|
215
215
|
* line (a parsed JSONL object). Returns `null` for anything that is not an
|
|
216
216
|
* assistant line. Shared by every counter that reads a transcript from disk
|
|
217
|
-
* (the dashboard's reader,
|
|
217
|
+
* (the dashboard's reader, `claude-probe.ts`, the stall sweep).
|
|
218
218
|
*/
|
|
219
219
|
export function ledgerLineFromRaw(raw, source, byteOffset) {
|
|
220
220
|
if (raw.type !== "assistant")
|
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.14",
|
|
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",
|