@thehammer/danx-session-telemetry 0.1.1
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 +66 -0
- package/dist/browser.d.ts +22 -0
- package/dist/browser.js +3 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +23 -0
- package/dist/internal-logger.d.ts +16 -0
- package/dist/internal-logger.js +24 -0
- package/dist/session-log-watcher.d.ts +310 -0
- package/dist/session-log-watcher.js +846 -0
- package/dist/tool-summary.d.ts +19 -0
- package/dist/tool-summary.js +58 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionLogWatcher — Polls Claude Code JSONL session files for new entries.
|
|
3
|
+
*
|
|
4
|
+
* DX-2962: moved unchanged from danxbot root `src/agent/session-log-watcher.ts`
|
|
5
|
+
* into this shared published package, so the danxbot worker and the
|
|
6
|
+
* `danx-dashboard-mcp` desktop bridge (DX-2954) run identical collector
|
|
7
|
+
* code. Only the imports changed — `../logger.js` -> `./internal-logger.js`
|
|
8
|
+
* (this package's own tiny logging shim, since root's `logger.ts` has ~300
|
|
9
|
+
* unrelated consumers and does not move), `../types.js` -> `./types.js`
|
|
10
|
+
* (moved — see that file's own header).
|
|
11
|
+
*
|
|
12
|
+
* Claude Code writes:
|
|
13
|
+
* - Parent session: ~/.claude/projects/<cwd-path>/<session-uuid>.jsonl
|
|
14
|
+
* - Sub-agent sessions: ~/.claude/projects/<cwd-path>/<session-uuid>/subagents/agent-<hash>.jsonl
|
|
15
|
+
* + agent-<hash>.meta.json sidecar with {agentType, description}
|
|
16
|
+
*
|
|
17
|
+
* The watcher tails the parent file and all sub-agent files in parallel, parses
|
|
18
|
+
* new lines, converts them to AgentLogEntry, and emits to registered consumers.
|
|
19
|
+
* Sub-agent entries are decorated with lineage metadata (subagent_id,
|
|
20
|
+
* parent_session_id, agent_type) inside `data` so downstream consumers can
|
|
21
|
+
* attribute usage correctly — they flow under the SAME dispatch, not a separate session.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
24
|
+
import { open, readFile, readdir, stat } from "node:fs/promises";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { basename, dirname, join } from "node:path";
|
|
27
|
+
import { createLogger } from "./internal-logger.js";
|
|
28
|
+
import { buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
|
|
29
|
+
const log = createLogger("session-log-watcher");
|
|
30
|
+
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
31
|
+
const FILE_DISCOVERY_POLL_MS = 1_000;
|
|
32
|
+
// 180s max wait for JSONL to appear. claude --resume on a clean-room with
|
|
33
|
+
// slow pre-turn setup (SFC-deps provision, vitest cold start, mcp probe)
|
|
34
|
+
// can take 60-90s before the first assistant turn writes to the JSONL.
|
|
35
|
+
// 60s was too tight — matches DX-825 stall-threshold raise (5s→120s).
|
|
36
|
+
const MAX_FILE_DISCOVERY_ATTEMPTS = 180;
|
|
37
|
+
/**
|
|
38
|
+
* Tag prefix injected into the piped prompt by the launcher. The watcher scans
|
|
39
|
+
* JSONL files for this tag to deterministically find the correct session file
|
|
40
|
+
* for a given dispatch, avoiding mtime-based heuristics that can pick up the
|
|
41
|
+
* wrong file (e.g., an active human session).
|
|
42
|
+
*/
|
|
43
|
+
export const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
44
|
+
/**
|
|
45
|
+
* Encode a working directory to the subdir name Claude Code writes JSONL
|
|
46
|
+
* under. Replaces `/`, `.`, AND `_` with `-`. Verified empirically against
|
|
47
|
+
* on-disk entries: a dispatched-agent cwd like
|
|
48
|
+
* `/var/tmp/danxbot-clean-room/<hash>/repo__board__work__dani__DX-1` encodes to
|
|
49
|
+
* `-var-tmp-danxbot-clean-room-<hash>-repo--board--work--dani--DX-1` — each
|
|
50
|
+
* `__` key delimiter collapses to `--`.
|
|
51
|
+
*
|
|
52
|
+
* `realpathSync` runs first so the encoding matches what claude's own
|
|
53
|
+
* resolver produces — the agent's cwd is realpath'd by the kernel before
|
|
54
|
+
* claude reads `process.cwd()`. ENOENT falls back to the normalized path
|
|
55
|
+
* so callers can pre-compute encodings before the dir exists (e.g. unit
|
|
56
|
+
* tests that exercise the pure encoding contract).
|
|
57
|
+
*
|
|
58
|
+
* The `.`/`_` encoding matters: a hidden-dir segment (leading `.`) or a
|
|
59
|
+
* clean-room key's `__` delimiter that the encoder does NOT collapse produces
|
|
60
|
+
* a directory Claude Code never writes to — silently breaking
|
|
61
|
+
* SessionLogWatcher attachment for every host-mode dispatch. The
|
|
62
|
+
* separator-encoding regression tests below pin this.
|
|
63
|
+
*
|
|
64
|
+
* Host mode (claude 2.1.104+, GitHub #51488 — symlinks under
|
|
65
|
+
* `~/.claude/projects/` are no longer followed) lets claude write its
|
|
66
|
+
* transcript to this exact realpath-encoded dir as a REAL directory; the
|
|
67
|
+
* watcher reads the same path, so they converge with no symlink. Docker
|
|
68
|
+
* binds `<repo>/claude-projects/` at `/home/danxbot/.claude/projects/` and
|
|
69
|
+
* relies on the same encoding for the bound subdir name. Keep the encoding
|
|
70
|
+
* logic centralized here so both runtimes stay in lockstep.
|
|
71
|
+
*/
|
|
72
|
+
export function encodeClaudeProjectsCwd(cwd) {
|
|
73
|
+
const normalized = cwd.startsWith("/") ? cwd : `/${cwd}`;
|
|
74
|
+
let resolved;
|
|
75
|
+
try {
|
|
76
|
+
resolved = realpathSync(normalized);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Path doesn't exist yet — use as-is (agent may not have started)
|
|
80
|
+
resolved = normalized;
|
|
81
|
+
}
|
|
82
|
+
// Claude Code encodes its cwd into ~/.claude/projects/<dir> by replacing
|
|
83
|
+
// EVERY non-alphanumeric separator — `/`, `.`, AND `_` — with `-`. The `_`
|
|
84
|
+
// matters: the clean-room key (`cleanRoomKey`, DX-1511)
|
|
85
|
+
// joins its components with `__`, so Claude writes the dir as `--` on disk.
|
|
86
|
+
// Omitting `_` here made the watcher wait on a `__` path Claude never
|
|
87
|
+
// creates → "session file not found after 180s" on every clean-room dispatch.
|
|
88
|
+
return resolved.replace(/[/._]/g, "-");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Derives the Claude Code session directory from a working directory path.
|
|
92
|
+
* Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/. See
|
|
93
|
+
* `encodeClaudeProjectsCwd` for the encoding contract.
|
|
94
|
+
*/
|
|
95
|
+
export function deriveSessionDir(cwd) {
|
|
96
|
+
return join(homedir(), ".claude", "projects", encodeClaudeProjectsCwd(cwd));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* On-disk existence gate for a stored, resumable Claude session UUID
|
|
100
|
+
* (migration 061). Given the clean-room `cwd` a dispatch ran in and a
|
|
101
|
+
* session `uuid`, returns `true` iff `<uuid>.jsonl` lives under that cwd's
|
|
102
|
+
* encoded `~/.claude/projects/<encoded>/` dir.
|
|
103
|
+
*
|
|
104
|
+
* This is the deterministic precondition the issue-bound resume gates check
|
|
105
|
+
* BEFORE passing `--resume <uuid>`: a session pinned in cwd A is only
|
|
106
|
+
* resumable from cwd A (Claude Code keys JSONLs by encoded-cwd), so a uuid
|
|
107
|
+
* stored on a card but orphaned by a cwd / agent move resolves here to a
|
|
108
|
+
* different encoded dir, is not found, and the caller fails SOFT to a fresh
|
|
109
|
+
* launch. This is NOT a swallowed error (which fail-loud forbids) — it is a
|
|
110
|
+
* checked precondition: a genuinely-absent session legitimately means
|
|
111
|
+
* "start fresh", and the per-agent isolation DX-1071 enforced falls out for
|
|
112
|
+
* free (agent B's cwd never contains agent A's session file).
|
|
113
|
+
*/
|
|
114
|
+
export function sessionUuidExistsInCleanRoom(cwd, uuid) {
|
|
115
|
+
return existsSync(join(deriveSessionDir(cwd), `${uuid}.jsonl`));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Finds the newest .jsonl file in a directory.
|
|
119
|
+
* Returns the full path, or null if no .jsonl files exist.
|
|
120
|
+
*/
|
|
121
|
+
export async function findNewestJsonlFile(dir, sessionId) {
|
|
122
|
+
try {
|
|
123
|
+
const files = await readdir(dir);
|
|
124
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
125
|
+
if (jsonlFiles.length === 0)
|
|
126
|
+
return null;
|
|
127
|
+
// If a specific session ID is requested, look for that exact file
|
|
128
|
+
if (sessionId) {
|
|
129
|
+
const target = `${sessionId}.jsonl`;
|
|
130
|
+
if (jsonlFiles.includes(target)) {
|
|
131
|
+
return join(dir, target);
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
// Find the newest by mtime
|
|
136
|
+
let newest = null;
|
|
137
|
+
for (const file of jsonlFiles) {
|
|
138
|
+
const filePath = join(dir, file);
|
|
139
|
+
try {
|
|
140
|
+
const stats = await stat(filePath);
|
|
141
|
+
const mtime = stats.mtimeMs;
|
|
142
|
+
if (!newest || mtime > newest.mtime) {
|
|
143
|
+
newest = { path: filePath, mtime };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
// DX-1201 — one unreadable/rotated sibling must NOT abort the whole
|
|
148
|
+
// mtime scan (see the matching guard in
|
|
149
|
+
// `findSessionFileByDispatchId`). A sibling deleted mid-scan
|
|
150
|
+
// (ENOENT) or otherwise unstattable just gets skipped.
|
|
151
|
+
const code = err.code;
|
|
152
|
+
if (code !== "ENOENT") {
|
|
153
|
+
log.warn(`Skipping unstattable session file during newest-file scan: ${filePath} (${code ?? String(err)})`);
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return newest?.path ?? null;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
// Directory doesn't exist yet — expected during agent startup
|
|
162
|
+
if (err.code === "ENOENT")
|
|
163
|
+
return null;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Finds a JSONL session file that contains the dispatch tag for a specific job ID.
|
|
169
|
+
*
|
|
170
|
+
* Scans each `.jsonl` file for the dispatch tag
|
|
171
|
+
* `<!-- danxbot-dispatch:<dispatchId> -->`, injected into the piped prompt
|
|
172
|
+
* by the launcher.
|
|
173
|
+
*
|
|
174
|
+
* The tag's position depends on whether this is a fresh spawn or a
|
|
175
|
+
* `claude --resume`:
|
|
176
|
+
* - **Fresh** — the tag is in the FIRST `user` entry, near the head of a
|
|
177
|
+
* brand-new file.
|
|
178
|
+
* - **Resume (DX-1106)** — claude appends the new dispatch's tagged
|
|
179
|
+
* prompt to the END of the prior session's already-multi-MB JSONL, so
|
|
180
|
+
* the tag sits in the TAIL, far past any fixed head window. A
|
|
181
|
+
* head-only scan never matches a resumed session → the watcher gives
|
|
182
|
+
* up after 180s → no session_uuid stamp, no heartbeat, no stall
|
|
183
|
+
* detection for that dispatch.
|
|
184
|
+
*
|
|
185
|
+
* So we scan BOTH a head window and a tail window (they overlap, covering
|
|
186
|
+
* the whole file, for any file ≤ 2×`SCAN_BYTES`). A resumed tag always
|
|
187
|
+
* lands in the freshly-appended tail; a fresh tag always lands in the head.
|
|
188
|
+
*
|
|
189
|
+
* Returns the full path to the matching file, or null if no file matches.
|
|
190
|
+
*/
|
|
191
|
+
export async function findSessionFileByDispatchId(dir, dispatchId) {
|
|
192
|
+
const tag = `${DISPATCH_TAG_PREFIX}${dispatchId} -->`;
|
|
193
|
+
const SCAN_BYTES = 65_536; // 64KB — enough to cover a fresh prompt entry
|
|
194
|
+
// (head) or the appended resume prompt (tail).
|
|
195
|
+
try {
|
|
196
|
+
const files = await readdir(dir);
|
|
197
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
198
|
+
for (const file of jsonlFiles) {
|
|
199
|
+
const filePath = join(dir, file);
|
|
200
|
+
let fd;
|
|
201
|
+
try {
|
|
202
|
+
fd = await open(filePath, "r");
|
|
203
|
+
const fileStats = await stat(filePath);
|
|
204
|
+
if (await windowIncludesTag(fd, 0, fileStats.size, SCAN_BYTES, tag)) {
|
|
205
|
+
return filePath;
|
|
206
|
+
}
|
|
207
|
+
// Resume: the new tag is at the tail of a large prior-session file.
|
|
208
|
+
// Skip when the file fits in one head window (already fully read).
|
|
209
|
+
if (fileStats.size > SCAN_BYTES) {
|
|
210
|
+
const tailStart = fileStats.size - SCAN_BYTES;
|
|
211
|
+
if (await windowIncludesTag(fd, tailStart, fileStats.size, SCAN_BYTES, tag)) {
|
|
212
|
+
return filePath;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
// DX-1201 — a single unreadable SIBLING file must NOT abort the
|
|
218
|
+
// whole scan. Dispatched agents share a busy session dir (e.g.
|
|
219
|
+
// host-mode issue-worker dispatches all writing under
|
|
220
|
+
// `~/.claude/projects/-home-newms-web-danxbot/`), so any one of the
|
|
221
|
+
// co-located multi-MB files can momentarily fail an `open`/`stat`/
|
|
222
|
+
// `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
|
|
223
|
+
// worktree torn down under the agent), EACCES (perms race), EMFILE
|
|
224
|
+
// (fd pressure across N concurrent watchers). Before this guard the
|
|
225
|
+
// throw propagated out of the per-file body and rejected the entire
|
|
226
|
+
// `findSessionFileByDispatchId` call → `discoverSessionFile` →
|
|
227
|
+
// `start()` (fire-and-forget, no `.catch`) → the watcher silently
|
|
228
|
+
// never attached → null `session_uuid` + zero usage/tool-call
|
|
229
|
+
// counters on ~20% of host dispatches (intermittent because the
|
|
230
|
+
// outcome hinged on `readdir` ordering vs the failing sibling).
|
|
231
|
+
// Skip this file and keep scanning the rest — the tagged file the
|
|
232
|
+
// caller actually wants may be later in the directory.
|
|
233
|
+
const code = err.code;
|
|
234
|
+
if (code !== "ENOENT") {
|
|
235
|
+
log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
await fd?.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (err.code === "ENOENT")
|
|
247
|
+
return null;
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Read a `<= maxBytes` window starting at `from` (clamped to the file) and
|
|
253
|
+
* report whether `tag` appears in it. Factored out so the head + tail scans
|
|
254
|
+
* in `findSessionFileByDispatchId` share one read path.
|
|
255
|
+
*/
|
|
256
|
+
async function windowIncludesTag(fd, from, fileSize, maxBytes, tag) {
|
|
257
|
+
const len = Math.min(maxBytes, fileSize - from);
|
|
258
|
+
if (len <= 0)
|
|
259
|
+
return false;
|
|
260
|
+
const buffer = Buffer.alloc(len);
|
|
261
|
+
await fd.read(buffer, 0, len, from);
|
|
262
|
+
return buffer.toString("utf-8", 0, len).includes(tag);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Converts a raw JSONL entry from Claude Code's session log into an AgentLogEntry
|
|
266
|
+
* used by downstream consumers (stall detection, event forwarding).
|
|
267
|
+
*/
|
|
268
|
+
export function convertJsonlEntry(raw, lastTimestamp) {
|
|
269
|
+
const type = raw.type;
|
|
270
|
+
const timestamp = raw.timestamp
|
|
271
|
+
? new Date(raw.timestamp).getTime()
|
|
272
|
+
: Date.now();
|
|
273
|
+
const deltaMs = lastTimestamp > 0 ? timestamp - lastTimestamp : 0;
|
|
274
|
+
const message = raw.message;
|
|
275
|
+
switch (type) {
|
|
276
|
+
case "assistant": {
|
|
277
|
+
if (!message)
|
|
278
|
+
return null;
|
|
279
|
+
const content = (message.content ?? []);
|
|
280
|
+
const usage = message.usage;
|
|
281
|
+
// Claude Code splits multi-block assistant turns (text + tool_use,
|
|
282
|
+
// thinking + text + tool_use, etc.) into one JSONL line per content
|
|
283
|
+
// block, but stamps the SAME response-level `message.usage` on every
|
|
284
|
+
// line. Surface `messageId` so downstream usage accumulators can dedupe
|
|
285
|
+
// — without it, every multi-block turn over-counts tokens 2-5×. See
|
|
286
|
+
// `.claude/rules/agent-dispatch.md`.
|
|
287
|
+
const messageId = typeof message.id === "string" && message.id.length > 0
|
|
288
|
+
? message.id
|
|
289
|
+
: undefined;
|
|
290
|
+
// DX-1720 — surface the turn's model EXPLICITLY on `entry.data` so the
|
|
291
|
+
// usage accumulator buckets per-model usage without reaching into
|
|
292
|
+
// `raw`. `message.model` is the JSONL assistant turn's resolved model
|
|
293
|
+
// id (e.g. `claude-opus-4-8`); undefined on the rare malformed turn.
|
|
294
|
+
const model = typeof message.model === "string" && message.model.length > 0
|
|
295
|
+
? message.model
|
|
296
|
+
: undefined;
|
|
297
|
+
return {
|
|
298
|
+
entry: {
|
|
299
|
+
timestamp,
|
|
300
|
+
type: "assistant",
|
|
301
|
+
summary: buildAssistantSummary(content),
|
|
302
|
+
data: {
|
|
303
|
+
messageId,
|
|
304
|
+
model,
|
|
305
|
+
content,
|
|
306
|
+
usage,
|
|
307
|
+
delta_ms: deltaMs,
|
|
308
|
+
raw,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
timestamp,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
case "user": {
|
|
315
|
+
if (!message)
|
|
316
|
+
return null;
|
|
317
|
+
const content = message.content;
|
|
318
|
+
if (typeof content === "string")
|
|
319
|
+
return null;
|
|
320
|
+
const contentArr = (content ?? []);
|
|
321
|
+
if (contentArr.length === 0)
|
|
322
|
+
return null;
|
|
323
|
+
const hasToolResults = contentArr.some((b) => b.type === "tool_result");
|
|
324
|
+
if (!hasToolResults)
|
|
325
|
+
return null;
|
|
326
|
+
return {
|
|
327
|
+
entry: {
|
|
328
|
+
timestamp,
|
|
329
|
+
type: "user",
|
|
330
|
+
summary: buildToolResultSummary(contentArr),
|
|
331
|
+
data: {
|
|
332
|
+
content: contentArr,
|
|
333
|
+
delta_ms: deltaMs,
|
|
334
|
+
raw: raw,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
timestamp,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
case "system": {
|
|
341
|
+
const subtype = raw.subtype;
|
|
342
|
+
if (subtype === "init") {
|
|
343
|
+
return {
|
|
344
|
+
entry: {
|
|
345
|
+
timestamp,
|
|
346
|
+
type: "system",
|
|
347
|
+
subtype: "init",
|
|
348
|
+
summary: `Session initialized: ${raw.model || "unknown"}`,
|
|
349
|
+
data: {
|
|
350
|
+
session_id: raw.session_id,
|
|
351
|
+
model: raw.model,
|
|
352
|
+
tools: raw.tools,
|
|
353
|
+
delta_ms: deltaMs,
|
|
354
|
+
raw,
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
timestamp,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
case "result": {
|
|
363
|
+
const subtype = raw.subtype || "success";
|
|
364
|
+
const costUsd = raw.cost_usd || 0;
|
|
365
|
+
const numTurns = raw.num_turns || 0;
|
|
366
|
+
return {
|
|
367
|
+
entry: {
|
|
368
|
+
timestamp,
|
|
369
|
+
type: "result",
|
|
370
|
+
subtype,
|
|
371
|
+
summary: `${subtype}: ${numTurns} turns, $${costUsd.toFixed(4)}`,
|
|
372
|
+
data: {
|
|
373
|
+
subtype,
|
|
374
|
+
result_text: raw.result || "",
|
|
375
|
+
total_cost_usd: costUsd,
|
|
376
|
+
num_turns: numTurns,
|
|
377
|
+
duration_ms: raw.duration_ms || 0,
|
|
378
|
+
duration_api_ms: raw.duration_api_ms || 0,
|
|
379
|
+
is_error: Boolean(raw.is_error),
|
|
380
|
+
delta_ms: deltaMs,
|
|
381
|
+
raw,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
timestamp,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
default:
|
|
388
|
+
// Skip metadata types: permission-mode, attachment, file-history-snapshot, queue-operation, last-prompt
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export class SessionLogWatcher {
|
|
393
|
+
consumers = [];
|
|
394
|
+
pollTimer;
|
|
395
|
+
parentTail = null;
|
|
396
|
+
subagentsDir = null;
|
|
397
|
+
subagentTails = new Map();
|
|
398
|
+
parentSessionId = null;
|
|
399
|
+
sessionDir;
|
|
400
|
+
sessionId;
|
|
401
|
+
dispatchId;
|
|
402
|
+
pollIntervalMs;
|
|
403
|
+
fromEof;
|
|
404
|
+
running = false;
|
|
405
|
+
polling = false;
|
|
406
|
+
// DX-2962: entries are NOT accumulated here — see `emitEntry`. Only
|
|
407
|
+
// consumers registered via `onEntry` observe entries; the watcher itself
|
|
408
|
+
// never holds a whole-process-lifetime list (that was the old
|
|
409
|
+
// `getEntries()` surface, deleted along with its accumulator — the
|
|
410
|
+
// pre-extraction contract kept an unbounded `AgentLogEntry[]` alive for
|
|
411
|
+
// the life of every dispatch, purely so three tests could read it back).
|
|
412
|
+
initSynthesized = false;
|
|
413
|
+
constructor(options) {
|
|
414
|
+
this.sessionDir = options.sessionDir ?? deriveSessionDir(options.cwd);
|
|
415
|
+
this.sessionId = options.sessionId;
|
|
416
|
+
this.dispatchId = options.dispatchId;
|
|
417
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
418
|
+
this.fromEof = options.fromEof ?? false;
|
|
419
|
+
}
|
|
420
|
+
/** Register a consumer callback that receives each new AgentLogEntry. */
|
|
421
|
+
onEntry(consumer) {
|
|
422
|
+
this.consumers.push(consumer);
|
|
423
|
+
}
|
|
424
|
+
/** Returns the discovered parent session file path, or null if not yet found. */
|
|
425
|
+
getSessionFilePath() {
|
|
426
|
+
return this.parentTail?.filePath ?? null;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Resolve this dispatch's JSONL session file by a one-shot disk scan,
|
|
430
|
+
* independent of the live tail loop (DX-1201).
|
|
431
|
+
*
|
|
432
|
+
* The live watcher records `session_uuid` lazily from the FIRST emitted
|
|
433
|
+
* entry. If discovery ever fails to attach within the dispatch's lifetime —
|
|
434
|
+
* a transient `open`/`stat`/`read` error on a busy shared session dir, or a
|
|
435
|
+
* timing window where the spawn finalizes before the first poll observed an
|
|
436
|
+
* entry — that lazy path never fires and the row's `session_uuid` stays
|
|
437
|
+
* NULL, breaking same-thread Slack resume (the dispatch is not resumable).
|
|
438
|
+
*
|
|
439
|
+
* This method gives the finalize backstop a deterministic, cause-agnostic
|
|
440
|
+
* way to resolve the file: by the time a dispatch finalizes, the agent's
|
|
441
|
+
* `danxbot_complete` tool_use is already written to the JSONL, so the file
|
|
442
|
+
* is guaranteed present on disk. It scans the watcher's OWN `sessionDir`
|
|
443
|
+
* (the dir derived from `agentCwd` at construction — the same dir claude
|
|
444
|
+
* wrote to) for the dispatch tag. Returns the file path, or null when no
|
|
445
|
+
* dispatchId is set or no file carries the tag.
|
|
446
|
+
*
|
|
447
|
+
* Prefers the already-discovered `parentTail.filePath` when the live loop
|
|
448
|
+
* did attach — no redundant scan on the happy path.
|
|
449
|
+
*/
|
|
450
|
+
async resolveSessionFileNow() {
|
|
451
|
+
if (this.parentTail?.filePath)
|
|
452
|
+
return this.parentTail.filePath;
|
|
453
|
+
if (!this.dispatchId)
|
|
454
|
+
return null;
|
|
455
|
+
return findSessionFileByDispatchId(this.sessionDir, this.dispatchId);
|
|
456
|
+
}
|
|
457
|
+
/** Start polling for session log entries. */
|
|
458
|
+
async start() {
|
|
459
|
+
if (this.running)
|
|
460
|
+
return;
|
|
461
|
+
this.running = true;
|
|
462
|
+
await this.discoverSessionFile();
|
|
463
|
+
if (this.running && this.parentTail) {
|
|
464
|
+
this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
|
|
465
|
+
await this.poll();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/** Stop polling and clean up. */
|
|
469
|
+
stop() {
|
|
470
|
+
this.running = false;
|
|
471
|
+
if (this.pollTimer) {
|
|
472
|
+
clearInterval(this.pollTimer);
|
|
473
|
+
this.pollTimer = undefined;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Read any JSONL bytes appended since the last scheduled poll tick. Used by
|
|
478
|
+
* the launcher's cleanup path to capture the agent's final assistant entry
|
|
479
|
+
* (the one carrying the closing `usage` block + the `tool_use` for
|
|
480
|
+
* `danxbot_complete`) before stop() halts polling. Without this, the row
|
|
481
|
+
* finalized by `DispatchTracker.finalize` snapshots stale `job.usage` and
|
|
482
|
+
* the dispatches table undercounts every token + counter field by exactly
|
|
483
|
+
* what the JSONL grew between ticks.
|
|
484
|
+
*
|
|
485
|
+
* Race contract: if a scheduled `poll()` is already in flight when drain
|
|
486
|
+
* is called, the first poll() invoked here would early-return at the
|
|
487
|
+
* `this.polling` guard and silently miss the tail. To avoid that silent
|
|
488
|
+
* skip we yield until the in-flight poll completes, then run our own
|
|
489
|
+
* poll. The combined effect is "every byte written before drain returns
|
|
490
|
+
* has been observed" — exactly the guarantee finalize depends on.
|
|
491
|
+
*
|
|
492
|
+
* No-op when the parent file has not been discovered (drain before
|
|
493
|
+
* start, or start aborted) — there is nothing to read. Safe to call
|
|
494
|
+
* after `stop()`; returns immediately.
|
|
495
|
+
*/
|
|
496
|
+
async drain() {
|
|
497
|
+
if (!this.running || !this.parentTail)
|
|
498
|
+
return;
|
|
499
|
+
// Wait for any in-flight scheduled poll to finish so our subsequent
|
|
500
|
+
// poll() doesn't hit the `this.polling` guard. The await yields so
|
|
501
|
+
// setInterval-driven polls can complete; we only loop a few microtasks
|
|
502
|
+
// in the worst case (one scheduled poll cycle).
|
|
503
|
+
while (this.polling) {
|
|
504
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
505
|
+
}
|
|
506
|
+
await this.poll();
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Discover the JSONL session file. Polls until found or max attempts reached.
|
|
510
|
+
* For dispatched agents, the file appears shortly after the process starts.
|
|
511
|
+
*/
|
|
512
|
+
async discoverSessionFile() {
|
|
513
|
+
for (let attempt = 0; attempt < MAX_FILE_DISCOVERY_ATTEMPTS; attempt++) {
|
|
514
|
+
if (!this.running)
|
|
515
|
+
return;
|
|
516
|
+
const filePath = this.dispatchId
|
|
517
|
+
? await findSessionFileByDispatchId(this.sessionDir, this.dispatchId)
|
|
518
|
+
: await findNewestJsonlFile(this.sessionDir, this.sessionId);
|
|
519
|
+
if (filePath) {
|
|
520
|
+
await this.setParentFile(filePath);
|
|
521
|
+
const tag = this.dispatchId ? `[Dispatch ${this.dispatchId}] ` : "";
|
|
522
|
+
log.info(`${tag}Watching session file: ${filePath}`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
await new Promise((resolve) => setTimeout(resolve, FILE_DISCOVERY_POLL_MS));
|
|
526
|
+
}
|
|
527
|
+
log.error(`Session file not found after ${MAX_FILE_DISCOVERY_ATTEMPTS}s in ${this.sessionDir}`);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Adopt a parent session file and derive the sub-agent directory.
|
|
531
|
+
*
|
|
532
|
+
* When `this.fromEof` is set (Phase 2c reattach OR a DX-2583 resume — see
|
|
533
|
+
* the option's doc), seed `byteOffset` to the file's current size so the
|
|
534
|
+
* next poll reads strictly forward — historical entries are NOT re-emitted.
|
|
535
|
+
* A fresh `stat` failure (race
|
|
536
|
+
* with a delete that happens between discover and adopt) falls back to
|
|
537
|
+
* 0 so the watcher tails from the start; that is preferable to throwing
|
|
538
|
+
* during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
|
|
539
|
+
* file claude writes AFTER reattach is genuinely new and should be
|
|
540
|
+
* tailed normally.
|
|
541
|
+
*/
|
|
542
|
+
async setParentFile(filePath) {
|
|
543
|
+
let byteOffset = 0;
|
|
544
|
+
if (this.fromEof) {
|
|
545
|
+
try {
|
|
546
|
+
const fileStats = await stat(filePath);
|
|
547
|
+
byteOffset = fileStats.size;
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
log.warn(`fromEof: stat failed for ${filePath}; tailing from 0: ${String(err)}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
this.parentTail = {
|
|
554
|
+
filePath,
|
|
555
|
+
byteOffset,
|
|
556
|
+
lineBuffer: "",
|
|
557
|
+
lastTimestamp: 0,
|
|
558
|
+
};
|
|
559
|
+
this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
|
|
563
|
+
* Guarded against concurrent polls and against stop() racing an in-flight poll.
|
|
564
|
+
*/
|
|
565
|
+
async poll() {
|
|
566
|
+
if (this.polling || !this.parentTail || !this.running)
|
|
567
|
+
return;
|
|
568
|
+
this.polling = true;
|
|
569
|
+
try {
|
|
570
|
+
await this.pollFile(this.parentTail);
|
|
571
|
+
if (!this.running)
|
|
572
|
+
return;
|
|
573
|
+
await this.discoverNewSubagentFiles();
|
|
574
|
+
await this.refreshPendingAgentTypes();
|
|
575
|
+
for (const state of this.subagentTails.values()) {
|
|
576
|
+
if (!this.running)
|
|
577
|
+
return;
|
|
578
|
+
await this.pollFile(state);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
this.polling = false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** Read any new bytes from `state.filePath` and emit resulting entries. */
|
|
586
|
+
async pollFile(state) {
|
|
587
|
+
const isSubagent = state.lineage !== undefined;
|
|
588
|
+
// Refresh lineage inline before reading new bytes so emissions reflect
|
|
589
|
+
// the latest meta.json + parent session id even if the central refresh
|
|
590
|
+
// missed this cycle. Closes the race in production where claude writes
|
|
591
|
+
// `.jsonl` then `.meta.json` and a poll catches the new bytes between
|
|
592
|
+
// those writes — without this, the entry would emit permanently stamped
|
|
593
|
+
// with agent_type=undefined. archived investigation.
|
|
594
|
+
if (isSubagent) {
|
|
595
|
+
await this.refreshLineage(state);
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
const fileStats = await stat(state.filePath);
|
|
599
|
+
if (!this.running)
|
|
600
|
+
return;
|
|
601
|
+
if (fileStats.size <= state.byteOffset)
|
|
602
|
+
return;
|
|
603
|
+
const fd = await open(state.filePath, "r");
|
|
604
|
+
try {
|
|
605
|
+
if (!this.running)
|
|
606
|
+
return;
|
|
607
|
+
const bytesToRead = fileStats.size - state.byteOffset;
|
|
608
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
609
|
+
const { bytesRead } = await fd.read(buffer, 0, bytesToRead, state.byteOffset);
|
|
610
|
+
if (!this.running)
|
|
611
|
+
return;
|
|
612
|
+
if (bytesRead === 0)
|
|
613
|
+
return;
|
|
614
|
+
state.byteOffset += bytesRead;
|
|
615
|
+
const text = buffer.toString("utf-8", 0, bytesRead);
|
|
616
|
+
this.processText(text, state);
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
await fd.close();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
if (err.code === "ENOENT") {
|
|
624
|
+
if (isSubagent) {
|
|
625
|
+
// Sub-agent file gone — drop it; next discoverNewSubagentFiles
|
|
626
|
+
// will pick up any replacement.
|
|
627
|
+
this.subagentTails.delete(state.filePath);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
log.warn("Parent session file disappeared, attempting rediscovery");
|
|
631
|
+
this.parentTail = null;
|
|
632
|
+
this.subagentsDir = null;
|
|
633
|
+
this.subagentTails.clear();
|
|
634
|
+
this.parentSessionId = null;
|
|
635
|
+
await this.discoverSessionFile();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
log.error("Poll error:", err);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Re-enumerate the sub-agent directory and add tail states for any
|
|
645
|
+
* `agent-<hash>.jsonl` files not already being tailed.
|
|
646
|
+
*/
|
|
647
|
+
async discoverNewSubagentFiles() {
|
|
648
|
+
if (!this.subagentsDir)
|
|
649
|
+
return;
|
|
650
|
+
let files;
|
|
651
|
+
try {
|
|
652
|
+
files = await readdir(this.subagentsDir);
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
if (err.code === "ENOENT")
|
|
656
|
+
return;
|
|
657
|
+
log.error("Failed to enumerate sub-agent directory:", err);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
for (const file of files) {
|
|
661
|
+
if (!file.endsWith(".jsonl"))
|
|
662
|
+
continue;
|
|
663
|
+
const fullPath = join(this.subagentsDir, file);
|
|
664
|
+
if (this.subagentTails.has(fullPath))
|
|
665
|
+
continue;
|
|
666
|
+
const subagentId = basename(file, ".jsonl");
|
|
667
|
+
const agentType = await this.readAgentType(subagentId);
|
|
668
|
+
this.subagentTails.set(fullPath, {
|
|
669
|
+
filePath: fullPath,
|
|
670
|
+
byteOffset: 0,
|
|
671
|
+
lineBuffer: "",
|
|
672
|
+
lastTimestamp: 0,
|
|
673
|
+
lineage: {
|
|
674
|
+
subagent_id: subagentId,
|
|
675
|
+
parent_session_id: this.parentSessionId,
|
|
676
|
+
agent_type: agentType,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
|
|
682
|
+
async refreshPendingAgentTypes() {
|
|
683
|
+
for (const state of this.subagentTails.values()) {
|
|
684
|
+
await this.refreshLineage(state);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Refresh `state.lineage` by re-reading the meta sidecar and back-filling
|
|
689
|
+
* parent_session_id from the most recent parent observation. Idempotent:
|
|
690
|
+
* once `agent_type` is set it stays set (never read meta twice). Used by
|
|
691
|
+
* both the centralized `refreshPendingAgentTypes` and the inline call from
|
|
692
|
+
* `pollFile` — the inline call is the defense-in-depth that closes the race
|
|
693
|
+
* where bytes appear before `refreshPendingAgentTypes` has a chance to run.
|
|
694
|
+
*/
|
|
695
|
+
async refreshLineage(state) {
|
|
696
|
+
if (!state.lineage)
|
|
697
|
+
return;
|
|
698
|
+
let { agent_type, parent_session_id } = state.lineage;
|
|
699
|
+
if (agent_type === undefined) {
|
|
700
|
+
agent_type = await this.readAgentType(state.lineage.subagent_id);
|
|
701
|
+
}
|
|
702
|
+
if (parent_session_id === null && this.parentSessionId !== null) {
|
|
703
|
+
parent_session_id = this.parentSessionId;
|
|
704
|
+
}
|
|
705
|
+
state.lineage = {
|
|
706
|
+
subagent_id: state.lineage.subagent_id,
|
|
707
|
+
parent_session_id,
|
|
708
|
+
agent_type,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Read agentType from the `<subagentId>.meta.json` sidecar. Returns
|
|
713
|
+
* undefined only for ENOENT (file not written yet). Malformed JSON or
|
|
714
|
+
* permission errors are logged at WARN and return undefined — the watcher
|
|
715
|
+
* must not crash on bad sidecar files, but the failure is visible in logs.
|
|
716
|
+
*/
|
|
717
|
+
async readAgentType(subagentId) {
|
|
718
|
+
if (!this.subagentsDir)
|
|
719
|
+
return undefined;
|
|
720
|
+
const metaPath = join(this.subagentsDir, `${subagentId}.meta.json`);
|
|
721
|
+
try {
|
|
722
|
+
const text = await readFile(metaPath, "utf-8");
|
|
723
|
+
const meta = JSON.parse(text);
|
|
724
|
+
return typeof meta.agentType === "string" ? meta.agentType : undefined;
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
const code = err.code;
|
|
728
|
+
if (code === "ENOENT")
|
|
729
|
+
return undefined;
|
|
730
|
+
log.warn(`Failed to read sub-agent meta for ${subagentId}: ${String(err)}`);
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** Process raw text from a file tail, handling partial lines across poll ticks. */
|
|
735
|
+
processText(text, state) {
|
|
736
|
+
state.lineBuffer += text;
|
|
737
|
+
let newlineIdx;
|
|
738
|
+
while ((newlineIdx = state.lineBuffer.indexOf("\n")) !== -1) {
|
|
739
|
+
const line = state.lineBuffer.substring(0, newlineIdx).trim();
|
|
740
|
+
state.lineBuffer = state.lineBuffer.substring(newlineIdx + 1);
|
|
741
|
+
if (!line)
|
|
742
|
+
continue;
|
|
743
|
+
try {
|
|
744
|
+
const raw = JSON.parse(line);
|
|
745
|
+
this.handleRawEntry(raw, state);
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
log.warn(`Skipping malformed JSONL line: ${line.substring(0, 100)}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Convert a raw JSONL entry and emit to consumers.
|
|
754
|
+
*
|
|
755
|
+
* Init synthesis (parent stream only): the persisted JSONL transcript never
|
|
756
|
+
* carries a `system/init` line in ANY runtime — Claude Code (2.1.201) only
|
|
757
|
+
* streams that event over stdout (`--output-format stream-json`), which the
|
|
758
|
+
* docker spawn path intentionally never reads (`spawn-docker-mode.ts`), and
|
|
759
|
+
* host's interactive JSONL never wrote one either. So we ALWAYS synthesize
|
|
760
|
+
* one from the first assistant message's sessionId and model, on host AND
|
|
761
|
+
* docker alike. Sub-agent streams never synthesize — their entries flow
|
|
762
|
+
* under the parent dispatch's existing session.
|
|
763
|
+
*
|
|
764
|
+
* IMPORTANT (DX-651): synthesized init events carry `tools: []` and
|
|
765
|
+
* `data.synthesized: true` — this watcher never observes the real
|
|
766
|
+
* MCP-registered tool / skill / agent list for EITHER runtime. Any consumer
|
|
767
|
+
* that wants to check what actually loaded (tools, skills, agents) MUST
|
|
768
|
+
* skip an entry carrying `data.synthesized === true` — treating it as a
|
|
769
|
+
* truthful docker `system/init` (as `declared-loaded-observer.ts` used to,
|
|
770
|
+
* gating on runtime alone) manufactures a guaranteed false
|
|
771
|
+
* declared-not-loaded drift for every dispatch with a non-empty declared
|
|
772
|
+
* skill/agent set. The pre-launch probe in `mcp-server-probe.ts` is the
|
|
773
|
+
* tool-registration check that works across both runtimes.
|
|
774
|
+
*/
|
|
775
|
+
handleRawEntry(raw, state) {
|
|
776
|
+
const isSubagent = state.lineage !== undefined;
|
|
777
|
+
if (!isSubagent && !this.initSynthesized && raw.type === "assistant") {
|
|
778
|
+
const message = raw.message;
|
|
779
|
+
const model = message?.model;
|
|
780
|
+
const sessionId = raw.sessionId;
|
|
781
|
+
if (sessionId || model) {
|
|
782
|
+
this.parentSessionId = sessionId ?? this.parentSessionId;
|
|
783
|
+
const initEntry = {
|
|
784
|
+
timestamp: raw.timestamp
|
|
785
|
+
? new Date(raw.timestamp).getTime()
|
|
786
|
+
: Date.now(),
|
|
787
|
+
type: "system",
|
|
788
|
+
subtype: "init",
|
|
789
|
+
summary: `Session initialized: ${model || "unknown"}`,
|
|
790
|
+
data: {
|
|
791
|
+
session_id: sessionId || "",
|
|
792
|
+
model: model || "",
|
|
793
|
+
tools: [],
|
|
794
|
+
delta_ms: 0,
|
|
795
|
+
raw: {},
|
|
796
|
+
synthesized: true,
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
this.emitEntry(initEntry, state);
|
|
800
|
+
this.initSynthesized = true;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
const result = convertJsonlEntry(raw, state.lastTimestamp);
|
|
804
|
+
if (!result)
|
|
805
|
+
return;
|
|
806
|
+
if (!isSubagent &&
|
|
807
|
+
result.entry.type === "system" &&
|
|
808
|
+
result.entry.subtype === "init") {
|
|
809
|
+
this.initSynthesized = true;
|
|
810
|
+
const sid = result.entry.data.session_id;
|
|
811
|
+
if (sid)
|
|
812
|
+
this.parentSessionId = sid;
|
|
813
|
+
}
|
|
814
|
+
state.lastTimestamp = result.timestamp;
|
|
815
|
+
this.emitEntry(result.entry, state);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Emit an entry to registered consumers. For sub-agent streams,
|
|
819
|
+
* `state.lineage` decorates `entry.data` so the emitted EventPayload
|
|
820
|
+
* (via mapEntryToEvents) inherits subagent_id/parent_session_id/agent_type.
|
|
821
|
+
*
|
|
822
|
+
* DX-2962: no longer also pushes onto an internal `this.entries` array —
|
|
823
|
+
* the pre-extraction watcher kept every entry alive for the life of the
|
|
824
|
+
* process purely so `getEntries()` (deleted) could hand it back to three
|
|
825
|
+
* tests; nothing else read it. Every real consumer subscribes via
|
|
826
|
+
* `onEntry` instead.
|
|
827
|
+
*/
|
|
828
|
+
emitEntry(entry, state) {
|
|
829
|
+
if (state.lineage) {
|
|
830
|
+
entry.data = {
|
|
831
|
+
...entry.data,
|
|
832
|
+
subagent_id: state.lineage.subagent_id,
|
|
833
|
+
parent_session_id: state.lineage.parent_session_id,
|
|
834
|
+
agent_type: state.lineage.agent_type,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
for (const consumer of this.consumers) {
|
|
838
|
+
try {
|
|
839
|
+
consumer(entry);
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
log.error("Consumer error:", err);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|