@newrelic/preflight 1.4.47 → 1.5.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/dist/alerts/local-alert-engine.d.ts +1 -1
- package/dist/alerts/local-alert-engine.d.ts.map +1 -1
- package/dist/alerts/local-alert-engine.js +2 -2
- package/dist/alerts/local-alert-engine.js.map +1 -1
- package/dist/dashboard/dashboard-server.d.ts +2 -2
- package/dist/dashboard/dashboard-server.d.ts.map +1 -1
- package/dist/dashboard/dashboard-server.js +2 -2
- package/dist/dashboard/dashboard-server.js.map +1 -1
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/live-event-bus.d.ts +32 -0
- package/dist/dashboard/live-event-bus.d.ts.map +1 -1
- package/dist/dashboard/live-event-bus.js.map +1 -1
- package/dist/dashboard/routes/api-handler.d.ts +46 -0
- package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
- package/dist/dashboard/routes/api-handler.js +188 -5
- package/dist/dashboard/routes/api-handler.js.map +1 -1
- package/dist/dashboard/subagent-timeline-store.d.ts +156 -0
- package/dist/dashboard/subagent-timeline-store.d.ts.map +1 -0
- package/dist/dashboard/subagent-timeline-store.js +674 -0
- package/dist/dashboard/subagent-timeline-store.js.map +1 -0
- package/dist/dashboard/workflow-store.d.ts +85 -0
- package/dist/dashboard/workflow-store.d.ts.map +1 -0
- package/dist/dashboard/workflow-store.js +330 -0
- package/dist/dashboard/workflow-store.js.map +1 -0
- package/dist/hooks/event-processor.d.ts +86 -1
- package/dist/hooks/event-processor.d.ts.map +1 -1
- package/dist/hooks/event-processor.js +182 -0
- package/dist/hooks/event-processor.js.map +1 -1
- package/dist/hooks/subagent-watcher.d.ts +187 -0
- package/dist/hooks/subagent-watcher.d.ts.map +1 -0
- package/dist/hooks/subagent-watcher.js +797 -0
- package/dist/hooks/subagent-watcher.js.map +1 -0
- package/dist/hooks/workflow-script-parser.d.ts +44 -0
- package/dist/hooks/workflow-script-parser.d.ts.map +1 -0
- package/dist/hooks/workflow-script-parser.js +255 -0
- package/dist/hooks/workflow-script-parser.js.map +1 -0
- package/dist/hooks/workflow-watcher.d.ts +67 -0
- package/dist/hooks/workflow-watcher.d.ts.map +1 -0
- package/dist/hooks/workflow-watcher.js +453 -0
- package/dist/hooks/workflow-watcher.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +316 -12
- package/dist/index.js.map +1 -1
- package/dist/metrics/cost-tracker.d.ts +113 -17
- package/dist/metrics/cost-tracker.d.ts.map +1 -1
- package/dist/metrics/cost-tracker.js +151 -8
- package/dist/metrics/cost-tracker.js.map +1 -1
- package/dist/metrics/recommendation-engine.d.ts +1 -1
- package/dist/metrics/recommendation-engine.js +1 -1
- package/dist/metrics/workflow-run-tracker.d.ts +140 -0
- package/dist/metrics/workflow-run-tracker.d.ts.map +1 -0
- package/dist/metrics/workflow-run-tracker.js +306 -0
- package/dist/metrics/workflow-run-tracker.js.map +1 -0
- package/dist/storage/local-store.d.ts +1 -1
- package/dist/storage/local-store.js +2 -2
- package/dist/storage/local-store.js.map +1 -1
- package/dist/storage/session-store.d.ts +8 -0
- package/dist/storage/session-store.d.ts.map +1 -1
- package/dist/storage/session-store.js +1 -1
- package/dist/storage/session-store.js.map +1 -1
- package/dist/storage/types.d.ts +40 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/tools/extended-analytics-tools.d.ts +1 -1
- package/dist/tools/extended-analytics-tools.js +1 -1
- package/dist/transport/nr-ingest.d.ts +153 -2
- package/dist/transport/nr-ingest.d.ts.map +1 -1
- package/dist/transport/nr-ingest.js +275 -0
- package/dist/transport/nr-ingest.js.map +1 -1
- package/dist/web/assets/index-B4DkT4Go.css +2 -0
- package/dist/web/assets/index-g9I2w7LV.js +64 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CrBs4WEp.js +0 -64
- package/dist/web/assets/index-DwBQxRYb.css +0 -2
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Watcher — polls Claude Code subagent JSONL transcripts and emits
|
|
3
|
+
* one `mode: 'subagent_token'` line per assistant turn into the parent
|
|
4
|
+
* session's hook buffer.
|
|
5
|
+
*
|
|
6
|
+
* This closes a cost-correctness gap: subagent tokens (visible only
|
|
7
|
+
* inside `~/.claude/projects/<slug>/<sessionId>/subagents/agent-*.jsonl`) never
|
|
8
|
+
* reached `CostTracker.recordTokenUsage()` because the existing collector at
|
|
9
|
+
* `collector-script.ts:178-219 readLastAssistantUsage()` only tails the parent
|
|
10
|
+
* session's transcript.
|
|
11
|
+
*
|
|
12
|
+
* Watches two paths under each session directory:
|
|
13
|
+
* - `subagents/agent-{id}.jsonl` (ad-hoc Task calls)
|
|
14
|
+
* - `subagents/workflows/wf_{runId}/agent-{id}.jsonl` (workflow-spawned)
|
|
15
|
+
*
|
|
16
|
+
* Cursor durability: byte cursors persisted to
|
|
17
|
+
* `~/.newrelic-preflight/.subagent-pos-<parentSessionId>-<agentId>` survive restart.
|
|
18
|
+
* On crash mid-emit the next poll re-reads from the previous cursor →
|
|
19
|
+
* potential duplicates which downstream dedupes by (agent_id, message.id).
|
|
20
|
+
*
|
|
21
|
+
* Startup-discovery budget: only files with mtime in the last 24h are eligible
|
|
22
|
+
* for cold scan (configurable via `NR_AI_WATCHER_DISCOVERY_HOURS`); older
|
|
23
|
+
* files emit `discovery_skipped` once each. Backfill of older files is
|
|
24
|
+
* a separate, future concern.
|
|
25
|
+
*/
|
|
26
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { dirname, join, resolve } from 'node:path';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { createLogger } from '../shared/index.js';
|
|
31
|
+
const logger = createLogger('subagent-watcher');
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Constants
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
36
|
+
const DEFAULT_DISCOVERY_HOURS = 24;
|
|
37
|
+
const MAX_BYTES_PER_POLL = 64 * 1024;
|
|
38
|
+
/**
|
|
39
|
+
* Hard cap on the retained partial-line (un-terminated tail) per file.
|
|
40
|
+
*
|
|
41
|
+
* A single JSONL assistant turn is normally a few tens of KiB; the largest
|
|
42
|
+
* legitimate lines observed in the wild are well under 1 MiB. When a file
|
|
43
|
+
* contains a line longer than this — pathologically large content, a corrupt
|
|
44
|
+
* never-terminated record, or a binary blob that happens to have no `\n` — the
|
|
45
|
+
* watcher must NOT keep accumulating it across polls.
|
|
46
|
+
*
|
|
47
|
+
* Without this cap, any line longer than MAX_BYTES_PER_POLL caused an
|
|
48
|
+
* unbounded leak: the byte cursor could only advance to a newline boundary, so
|
|
49
|
+
* a chunk with no newline left the cursor frozen, and every 2s poll re-read the
|
|
50
|
+
* same bytes and appended them to `partialByPath` forever (~64 KiB / poll / file
|
|
51
|
+
* → multi-GB RSS in minutes). Capping the partial bounds `partialByPath` values
|
|
52
|
+
* to MAX_PARTIAL_LINE_BYTES + one chunk and guarantees forward progress.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_PARTIAL_LINE_BYTES = 1024 * 1024; // 1 MiB
|
|
55
|
+
const HEALTH_INTERVAL_MS = 60_000;
|
|
56
|
+
const SCHEMA_FINGERPRINT_REEMIT_MS = 60 * 60 * 1000; // 1h
|
|
57
|
+
const COST_SELF_CHECK_MS = 60 * 60 * 1000; // 1h
|
|
58
|
+
const SESSION_ID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
59
|
+
const AGENT_ID_RE = /^a[a-f0-9]{16}$/;
|
|
60
|
+
const PROJECTS_DIR_NAME = '.claude/projects';
|
|
61
|
+
export class SubagentWatcher {
|
|
62
|
+
storagePath;
|
|
63
|
+
projectsDir;
|
|
64
|
+
pollIntervalMs;
|
|
65
|
+
discoveryHours;
|
|
66
|
+
parentSessionFilter;
|
|
67
|
+
costSelfCheck;
|
|
68
|
+
intervalId = null;
|
|
69
|
+
healthIntervalId = null;
|
|
70
|
+
running = false;
|
|
71
|
+
// Per-file in-memory partial-line retention. The persisted byte cursor
|
|
72
|
+
// points to the start of the next un-read byte; this map carries any trailing
|
|
73
|
+
// content past the last newline that didn't form a complete line yet. It is
|
|
74
|
+
// an in-memory fast path that mirrors the `partialLine` persisted in the
|
|
75
|
+
// cursor file. Bounded per-entry by MAX_PARTIAL_LINE_BYTES (see processFile)
|
|
76
|
+
// and per-key by the number of files discovered this poll (see poll()).
|
|
77
|
+
partialByPath = new Map();
|
|
78
|
+
// Health counters
|
|
79
|
+
filesWatched = 0;
|
|
80
|
+
linesRead = 0;
|
|
81
|
+
bytesRead = 0;
|
|
82
|
+
parseErrors = 0;
|
|
83
|
+
schemaDrifts = 0;
|
|
84
|
+
lastError = null;
|
|
85
|
+
// Schema-drift dedup across a 1h window. Persisted to
|
|
86
|
+
// ~/.newrelic-preflight/.schema-fingerprints.
|
|
87
|
+
seenFingerprints = new Map();
|
|
88
|
+
// Files that already emitted `discovery_skipped` so we don't re-emit on
|
|
89
|
+
// every poll for the same too-old file.
|
|
90
|
+
discoverySkippedAnnounced = new Set();
|
|
91
|
+
// Files shaped like `agent-*.jsonl` whose id doesn't match AGENT_ID_RE that
|
|
92
|
+
// already emitted `discovery_skipped`, so we don't re-emit on every poll.
|
|
93
|
+
agentIdMismatchAnnounced = new Set();
|
|
94
|
+
lastCostSelfCheckMs = 0;
|
|
95
|
+
constructor(options = {}) {
|
|
96
|
+
this.storagePath = options.storagePath ?? join(homedir(), '.newrelic-preflight');
|
|
97
|
+
this.projectsDir = options.projectsDir ?? join(homedir(), PROJECTS_DIR_NAME);
|
|
98
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
99
|
+
const envHours = parseInt(process.env.NR_AI_WATCHER_DISCOVERY_HOURS ?? '', 10);
|
|
100
|
+
this.discoveryHours =
|
|
101
|
+
options.discoveryHours ??
|
|
102
|
+
(Number.isFinite(envHours) && envHours > 0 ? envHours : DEFAULT_DISCOVERY_HOURS);
|
|
103
|
+
this.parentSessionFilter = options.parentSessionId ?? null;
|
|
104
|
+
this.costSelfCheck = options.costSelfCheck;
|
|
105
|
+
this.loadFingerprints();
|
|
106
|
+
}
|
|
107
|
+
start() {
|
|
108
|
+
if (this.running) {
|
|
109
|
+
logger.warn('SubagentWatcher already running');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.running = true;
|
|
113
|
+
if (!existsSync(this.storagePath)) {
|
|
114
|
+
mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
|
|
115
|
+
}
|
|
116
|
+
this.intervalId = setInterval(() => this.poll(), this.pollIntervalMs);
|
|
117
|
+
this.intervalId.unref();
|
|
118
|
+
this.healthIntervalId = setInterval(() => this.emitHealth(), HEALTH_INTERVAL_MS);
|
|
119
|
+
this.healthIntervalId.unref();
|
|
120
|
+
logger.info('SubagentWatcher started', {
|
|
121
|
+
pollIntervalMs: this.pollIntervalMs,
|
|
122
|
+
discoveryHours: this.discoveryHours,
|
|
123
|
+
projectsDir: this.projectsDir,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
stop() {
|
|
127
|
+
if (!this.running)
|
|
128
|
+
return;
|
|
129
|
+
this.running = false;
|
|
130
|
+
if (this.intervalId !== null) {
|
|
131
|
+
clearInterval(this.intervalId);
|
|
132
|
+
this.intervalId = null;
|
|
133
|
+
}
|
|
134
|
+
if (this.healthIntervalId !== null) {
|
|
135
|
+
clearInterval(this.healthIntervalId);
|
|
136
|
+
this.healthIntervalId = null;
|
|
137
|
+
}
|
|
138
|
+
this.persistFingerprints();
|
|
139
|
+
logger.info('SubagentWatcher stopped');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Single poll cycle. Public so tests can drive deterministically without
|
|
143
|
+
* waiting on the interval timer.
|
|
144
|
+
*/
|
|
145
|
+
poll() {
|
|
146
|
+
try {
|
|
147
|
+
const files = this.discoverFiles();
|
|
148
|
+
this.filesWatched = files.length;
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
this.processFile(file);
|
|
151
|
+
}
|
|
152
|
+
this.evictStalePartials(files);
|
|
153
|
+
this.maybeRunCostSelfCheck();
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
this.recordError(err);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Reset counters; for tests. */
|
|
160
|
+
resetHealth() {
|
|
161
|
+
this.filesWatched = 0;
|
|
162
|
+
this.linesRead = 0;
|
|
163
|
+
this.bytesRead = 0;
|
|
164
|
+
this.parseErrors = 0;
|
|
165
|
+
this.schemaDrifts = 0;
|
|
166
|
+
this.lastError = null;
|
|
167
|
+
}
|
|
168
|
+
/** Public snapshot of watcher health counters for the dashboard panel. */
|
|
169
|
+
getHealthStats() {
|
|
170
|
+
return {
|
|
171
|
+
filesWatched: this.filesWatched,
|
|
172
|
+
linesRead: this.linesRead,
|
|
173
|
+
bytesRead: this.bytesRead,
|
|
174
|
+
parseErrors: this.parseErrors,
|
|
175
|
+
schemaDrifts: this.schemaDrifts,
|
|
176
|
+
watcherDisabledByLock: false,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
// Discovery
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
discoverFiles() {
|
|
183
|
+
const out = [];
|
|
184
|
+
if (!existsSync(this.projectsDir))
|
|
185
|
+
return out;
|
|
186
|
+
const cutoffMs = Date.now() - this.discoveryHours * 60 * 60 * 1000;
|
|
187
|
+
let projectEntries;
|
|
188
|
+
try {
|
|
189
|
+
projectEntries = readdirSync(this.projectsDir);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
this.recordError(err);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
for (const project of projectEntries) {
|
|
196
|
+
const projectPath = join(this.projectsDir, project);
|
|
197
|
+
let stat;
|
|
198
|
+
try {
|
|
199
|
+
stat = statSync(projectPath);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!stat.isDirectory())
|
|
205
|
+
continue;
|
|
206
|
+
// Each project dir contains <sessionId> subdirs whose name matches
|
|
207
|
+
// SESSION_ID_RE (UUID v4 lower-hex with hyphens).
|
|
208
|
+
// See workflow-watcher.ts's identical short-circuit for the rationale.
|
|
209
|
+
const sessionEntries = this.parentSessionFilter
|
|
210
|
+
? [this.parentSessionFilter]
|
|
211
|
+
: (() => {
|
|
212
|
+
try {
|
|
213
|
+
return readdirSync(projectPath);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
for (const sessionId of sessionEntries) {
|
|
220
|
+
if (!SESSION_ID_RE.test(sessionId))
|
|
221
|
+
continue;
|
|
222
|
+
if (this.parentSessionFilter && sessionId !== this.parentSessionFilter)
|
|
223
|
+
continue;
|
|
224
|
+
const sessionDir = join(projectPath, sessionId);
|
|
225
|
+
const subDir = join(sessionDir, 'subagents');
|
|
226
|
+
if (!existsSync(subDir))
|
|
227
|
+
continue;
|
|
228
|
+
// Ad-hoc: subagents/agent-*.jsonl
|
|
229
|
+
try {
|
|
230
|
+
for (const name of readdirSync(subDir)) {
|
|
231
|
+
if (!name.startsWith('agent-') || !name.endsWith('.jsonl'))
|
|
232
|
+
continue;
|
|
233
|
+
const agentId = name.slice('agent-'.length, -'.jsonl'.length);
|
|
234
|
+
if (!AGENT_ID_RE.test(agentId)) {
|
|
235
|
+
this.announceAgentIdMismatch(join(subDir, name));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const path = join(subDir, name);
|
|
239
|
+
const stat = this.filterByMtime(path, cutoffMs);
|
|
240
|
+
if (stat) {
|
|
241
|
+
out.push({ path, parentSessionId: sessionId, agentId, workflowRunId: null, stat });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* directory unreadable — skip */
|
|
247
|
+
}
|
|
248
|
+
// Workflow-spawned: subagents/workflows/wf_*/agent-*.jsonl
|
|
249
|
+
const wfDir = join(subDir, 'workflows');
|
|
250
|
+
if (!existsSync(wfDir))
|
|
251
|
+
continue;
|
|
252
|
+
try {
|
|
253
|
+
for (const wfName of readdirSync(wfDir)) {
|
|
254
|
+
if (!wfName.startsWith('wf_'))
|
|
255
|
+
continue;
|
|
256
|
+
const wfRunId = wfName;
|
|
257
|
+
const wfRunDir = join(wfDir, wfName);
|
|
258
|
+
let stat2;
|
|
259
|
+
try {
|
|
260
|
+
stat2 = statSync(wfRunDir);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!stat2.isDirectory())
|
|
266
|
+
continue;
|
|
267
|
+
try {
|
|
268
|
+
for (const name of readdirSync(wfRunDir)) {
|
|
269
|
+
if (!name.startsWith('agent-') || !name.endsWith('.jsonl'))
|
|
270
|
+
continue;
|
|
271
|
+
const agentId = name.slice('agent-'.length, -'.jsonl'.length);
|
|
272
|
+
if (!AGENT_ID_RE.test(agentId)) {
|
|
273
|
+
this.announceAgentIdMismatch(join(wfRunDir, name));
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const path = join(wfRunDir, name);
|
|
277
|
+
const stat = this.filterByMtime(path, cutoffMs);
|
|
278
|
+
if (stat) {
|
|
279
|
+
out.push({
|
|
280
|
+
path,
|
|
281
|
+
parentSessionId: sessionId,
|
|
282
|
+
agentId,
|
|
283
|
+
workflowRunId: wfRunId,
|
|
284
|
+
stat,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
/* skip */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
/* skip */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
/** Returns the file's Stats when it passes the mtime cutoff, else null — callers reuse the Stats instead of re-statting the same path. */
|
|
302
|
+
filterByMtime(path, cutoffMs) {
|
|
303
|
+
try {
|
|
304
|
+
const st = statSync(path);
|
|
305
|
+
if (st.mtimeMs < cutoffMs) {
|
|
306
|
+
if (!this.discoverySkippedAnnounced.has(path)) {
|
|
307
|
+
this.discoverySkippedAnnounced.add(path);
|
|
308
|
+
this.appendHealth({
|
|
309
|
+
mode: 'observability_health',
|
|
310
|
+
tool: 'observability_health',
|
|
311
|
+
timestamp: Date.now(),
|
|
312
|
+
watcher: 'subagent',
|
|
313
|
+
filesWatched: 0,
|
|
314
|
+
linesRead: 0,
|
|
315
|
+
bytesRead: 0,
|
|
316
|
+
parseErrors: 0,
|
|
317
|
+
schemaDrifts: 0,
|
|
318
|
+
lastError: null,
|
|
319
|
+
event: 'discovery_skipped',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return st;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Emits a discovery_skipped health event, once per path, when a file
|
|
331
|
+
* shaped like `agent-*.jsonl` doesn't match AGENT_ID_RE — makes an id-format
|
|
332
|
+
* drift observable instead of a silent stop in subagent token capture. */
|
|
333
|
+
announceAgentIdMismatch(path) {
|
|
334
|
+
if (this.agentIdMismatchAnnounced.has(path))
|
|
335
|
+
return;
|
|
336
|
+
this.agentIdMismatchAnnounced.add(path);
|
|
337
|
+
this.appendHealth({
|
|
338
|
+
mode: 'observability_health',
|
|
339
|
+
tool: 'observability_health',
|
|
340
|
+
timestamp: Date.now(),
|
|
341
|
+
watcher: 'subagent',
|
|
342
|
+
filesWatched: 0,
|
|
343
|
+
linesRead: 0,
|
|
344
|
+
bytesRead: 0,
|
|
345
|
+
parseErrors: 0,
|
|
346
|
+
schemaDrifts: 0,
|
|
347
|
+
lastError: null,
|
|
348
|
+
event: 'discovery_skipped',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// -------------------------------------------------------------------------
|
|
352
|
+
// Per-file processing
|
|
353
|
+
// -------------------------------------------------------------------------
|
|
354
|
+
processFile(file) {
|
|
355
|
+
const st = file.stat;
|
|
356
|
+
const cursorPath = this.cursorPath(file.parentSessionId, file.agentId);
|
|
357
|
+
const startCursor = this.readCursor(cursorPath);
|
|
358
|
+
if (startCursor.bytePos >= st.size)
|
|
359
|
+
return;
|
|
360
|
+
const remaining = st.size - startCursor.bytePos;
|
|
361
|
+
const toRead = Math.min(remaining, MAX_BYTES_PER_POLL);
|
|
362
|
+
let buf;
|
|
363
|
+
let actuallyRead = 0;
|
|
364
|
+
let fd = null;
|
|
365
|
+
try {
|
|
366
|
+
fd = openSync(file.path, 'r');
|
|
367
|
+
buf = Buffer.allocUnsafe(toRead);
|
|
368
|
+
actuallyRead = readSync(fd, buf, 0, toRead, startCursor.bytePos);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
this.recordError(err);
|
|
372
|
+
if (fd !== null) {
|
|
373
|
+
try {
|
|
374
|
+
closeSync(fd);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
/* ignore */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
if (fd !== null) {
|
|
384
|
+
try {
|
|
385
|
+
closeSync(fd);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
/* ignore */
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (actuallyRead === 0)
|
|
393
|
+
return;
|
|
394
|
+
const chunk = buf.subarray(0, actuallyRead).toString('utf-8');
|
|
395
|
+
this.bytesRead += actuallyRead;
|
|
396
|
+
// Carry over any partial-line content from the previous poll. The in-memory
|
|
397
|
+
// map is the fast path; on a cold start (e.g. after restart) we fall back to
|
|
398
|
+
// the partial persisted alongside the byte cursor.
|
|
399
|
+
const carried = this.partialByPath.get(file.path) ?? startCursor.partialLine;
|
|
400
|
+
const combined = carried + chunk;
|
|
401
|
+
// The byte cursor ALWAYS advances by the bytes we just read from the file —
|
|
402
|
+
// those bytes are now folded into `combined` and must never be re-read.
|
|
403
|
+
// (The previous implementation only advanced to the last newline, which
|
|
404
|
+
// froze the cursor whenever a single line exceeded MAX_BYTES_PER_POLL and
|
|
405
|
+
// caused `partialByPath` to grow without bound — the OOM root cause. It also
|
|
406
|
+
// double-counted the partial region, prepending bytes that the cursor had
|
|
407
|
+
// already advanced past.)
|
|
408
|
+
const nextBytePos = startCursor.bytePos + actuallyRead;
|
|
409
|
+
// Split into complete lines (terminated by '\n') and a trailing remainder.
|
|
410
|
+
const lastNewline = combined.lastIndexOf('\n');
|
|
411
|
+
let lines = [];
|
|
412
|
+
let newPartial = combined;
|
|
413
|
+
if (lastNewline >= 0) {
|
|
414
|
+
lines = combined.slice(0, lastNewline).split('\n');
|
|
415
|
+
newPartial = combined.slice(lastNewline + 1);
|
|
416
|
+
}
|
|
417
|
+
// Bound the retained partial. A remainder larger than MAX_PARTIAL_LINE_BYTES
|
|
418
|
+
// is not a line we will ever be able to parse (a line that big is corrupt or
|
|
419
|
+
// pathological); drop it so the partial can't accumulate across polls. The
|
|
420
|
+
// cursor has already advanced past these bytes, so we make forward progress
|
|
421
|
+
// and never revisit them.
|
|
422
|
+
let droppedOversized = false;
|
|
423
|
+
if (newPartial.length > MAX_PARTIAL_LINE_BYTES) {
|
|
424
|
+
droppedOversized = true;
|
|
425
|
+
newPartial = '';
|
|
426
|
+
this.parseErrors += 1;
|
|
427
|
+
}
|
|
428
|
+
// Emit token events for each parsed assistant turn
|
|
429
|
+
for (const line of lines) {
|
|
430
|
+
if (!line)
|
|
431
|
+
continue;
|
|
432
|
+
this.linesRead += 1;
|
|
433
|
+
const parsed = this.tryParseLine(line, file);
|
|
434
|
+
if (parsed === null)
|
|
435
|
+
continue;
|
|
436
|
+
this.handleSchemaFingerprint('usage_keys', parsed.usageKeysFingerprint, file.workflowRunId);
|
|
437
|
+
this.handleSchemaFingerprint('content_block_types', parsed.contentBlockTypesFingerprint, file.workflowRunId);
|
|
438
|
+
const event = {
|
|
439
|
+
mode: 'subagent_token',
|
|
440
|
+
tool: 'subagent',
|
|
441
|
+
timestamp: parsed.timestampMs,
|
|
442
|
+
sessionId: file.parentSessionId,
|
|
443
|
+
agentId: file.agentId,
|
|
444
|
+
workflowRunId: file.workflowRunId,
|
|
445
|
+
messageId: parsed.messageId,
|
|
446
|
+
turnUuid: parsed.turnUuid,
|
|
447
|
+
model: parsed.model,
|
|
448
|
+
inputTokens: parsed.inputTokens,
|
|
449
|
+
outputTokens: parsed.outputTokens,
|
|
450
|
+
cacheReadTokens: parsed.cacheReadTokens,
|
|
451
|
+
cacheCreationTokens: parsed.cacheCreationTokens,
|
|
452
|
+
reasoningTokens: parsed.reasoningTokens,
|
|
453
|
+
stopReason: parsed.stopReason,
|
|
454
|
+
schemaFingerprint: parsed.usageKeysFingerprint,
|
|
455
|
+
};
|
|
456
|
+
this.appendToParentBuffer(file.parentSessionId, event);
|
|
457
|
+
}
|
|
458
|
+
// Persist the advanced cursor plus the (bounded) trailing partial so a
|
|
459
|
+
// restart resumes exactly where we left off. Keep the in-memory mirror in
|
|
460
|
+
// sync; when the partial is empty, drop the key entirely so a fully-consumed
|
|
461
|
+
// file leaves no residual entry in the map.
|
|
462
|
+
this.writeCursor(cursorPath, nextBytePos, newPartial);
|
|
463
|
+
if (newPartial.length > 0) {
|
|
464
|
+
this.partialByPath.set(file.path, newPartial);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
this.partialByPath.delete(file.path);
|
|
468
|
+
}
|
|
469
|
+
if (droppedOversized) {
|
|
470
|
+
this.appendHealth({
|
|
471
|
+
mode: 'observability_health',
|
|
472
|
+
tool: 'observability_health',
|
|
473
|
+
timestamp: Date.now(),
|
|
474
|
+
watcher: 'subagent',
|
|
475
|
+
filesWatched: this.filesWatched,
|
|
476
|
+
linesRead: this.linesRead,
|
|
477
|
+
bytesRead: this.bytesRead,
|
|
478
|
+
parseErrors: this.parseErrors,
|
|
479
|
+
schemaDrifts: this.schemaDrifts,
|
|
480
|
+
lastError: this.lastError,
|
|
481
|
+
event: 'oversized_line_dropped',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Drop in-memory partial-line state for files that are no longer discovered
|
|
487
|
+
* (e.g. session directory removed, file aged past the discovery window). This
|
|
488
|
+
* keeps `partialByPath` bounded by the live file set rather than the
|
|
489
|
+
* all-time-seen file set. The persisted cursor file is left untouched — if the
|
|
490
|
+
* file reappears, we resume from it.
|
|
491
|
+
*/
|
|
492
|
+
evictStalePartials(files) {
|
|
493
|
+
if (this.partialByPath.size === 0)
|
|
494
|
+
return;
|
|
495
|
+
const live = new Set();
|
|
496
|
+
for (const f of files)
|
|
497
|
+
live.add(f.path);
|
|
498
|
+
for (const path of this.partialByPath.keys()) {
|
|
499
|
+
if (!live.has(path))
|
|
500
|
+
this.partialByPath.delete(path);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Parse a JSONL line, return non-null only when it's a valid assistant turn
|
|
505
|
+
* with usage. Sets parseErrors counter on JSON parse failures.
|
|
506
|
+
*/
|
|
507
|
+
tryParseLine(line, _file) {
|
|
508
|
+
let parsed;
|
|
509
|
+
try {
|
|
510
|
+
parsed = JSON.parse(line);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
this.parseErrors += 1;
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
if (!parsed || typeof parsed !== 'object')
|
|
517
|
+
return null;
|
|
518
|
+
const obj = parsed;
|
|
519
|
+
if (obj.type !== 'assistant')
|
|
520
|
+
return null;
|
|
521
|
+
const message = obj.message;
|
|
522
|
+
if (!message || typeof message !== 'object')
|
|
523
|
+
return null;
|
|
524
|
+
const m = message;
|
|
525
|
+
const model = typeof m.model === 'string' ? m.model : null;
|
|
526
|
+
if (!model || model === '<synthetic>')
|
|
527
|
+
return null;
|
|
528
|
+
const messageId = typeof m.id === 'string' ? m.id : null;
|
|
529
|
+
if (!messageId)
|
|
530
|
+
return null;
|
|
531
|
+
const usage = m.usage;
|
|
532
|
+
if (!usage || typeof usage !== 'object')
|
|
533
|
+
return null;
|
|
534
|
+
const u = usage;
|
|
535
|
+
const turnUuid = typeof obj.uuid === 'string' ? obj.uuid : '';
|
|
536
|
+
const tsRaw = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
537
|
+
const timestampMs = tsRaw ? Date.parse(tsRaw) : Date.now();
|
|
538
|
+
if (!Number.isFinite(timestampMs))
|
|
539
|
+
return null;
|
|
540
|
+
const inputTokens = num(u.input_tokens);
|
|
541
|
+
const outputTokens = num(u.output_tokens);
|
|
542
|
+
const cacheReadTokens = num(u.cache_read_input_tokens);
|
|
543
|
+
const cacheCreationTokens = num(u.cache_creation_input_tokens);
|
|
544
|
+
let reasoningTokens = 0;
|
|
545
|
+
const otd = u.output_tokens_details;
|
|
546
|
+
if (otd && typeof otd === 'object') {
|
|
547
|
+
reasoningTokens = num(otd.reasoning_tokens);
|
|
548
|
+
}
|
|
549
|
+
const stopReason = typeof m.stop_reason === 'string' ? m.stop_reason : null;
|
|
550
|
+
const usageKeysFingerprint = computeUsageKeysFingerprint(u);
|
|
551
|
+
const contentBlockTypesFingerprint = computeContentBlockTypesFingerprint(m.content);
|
|
552
|
+
return {
|
|
553
|
+
timestampMs,
|
|
554
|
+
messageId,
|
|
555
|
+
turnUuid,
|
|
556
|
+
model,
|
|
557
|
+
inputTokens,
|
|
558
|
+
outputTokens,
|
|
559
|
+
cacheReadTokens,
|
|
560
|
+
cacheCreationTokens,
|
|
561
|
+
reasoningTokens,
|
|
562
|
+
stopReason,
|
|
563
|
+
usageKeysFingerprint,
|
|
564
|
+
contentBlockTypesFingerprint,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
// -------------------------------------------------------------------------
|
|
568
|
+
// Buffer + cursor I/O
|
|
569
|
+
// -------------------------------------------------------------------------
|
|
570
|
+
cursorPath(parentSessionId, agentId) {
|
|
571
|
+
return join(this.storagePath, `.subagent-pos-${parentSessionId}-${agentId}`);
|
|
572
|
+
}
|
|
573
|
+
readCursor(cursorPath) {
|
|
574
|
+
if (!existsSync(cursorPath))
|
|
575
|
+
return { bytePos: 0, partialLine: '' };
|
|
576
|
+
try {
|
|
577
|
+
const raw = readFileSync(cursorPath, 'utf-8').trim();
|
|
578
|
+
const parsed = JSON.parse(raw);
|
|
579
|
+
const bytePos = typeof parsed.bytePos === 'number' && parsed.bytePos >= 0 ? parsed.bytePos : 0;
|
|
580
|
+
const partialLine = typeof parsed.partialLine === 'string' ? parsed.partialLine : '';
|
|
581
|
+
return { bytePos, partialLine };
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return { bytePos: 0, partialLine: '' };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
writeCursor(cursorPath, bytePos, partialLine) {
|
|
588
|
+
try {
|
|
589
|
+
if (!existsSync(this.storagePath)) {
|
|
590
|
+
mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
|
|
591
|
+
}
|
|
592
|
+
const dir = dirname(cursorPath);
|
|
593
|
+
if (!existsSync(dir))
|
|
594
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
595
|
+
writeFileSync(cursorPath, JSON.stringify({ bytePos, partialLine }), { mode: 0o600 });
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
this.recordError(err);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Append to the parent's per-session buffer file. Mirrors the path-naming
|
|
603
|
+
* used by `LocalStore` so `HookEventProcessor.poll()` will pick it up.
|
|
604
|
+
*/
|
|
605
|
+
appendToParentBuffer(parentSessionId, event) {
|
|
606
|
+
const path = join(this.storagePath, `buffer-${parentSessionId}.jsonl`);
|
|
607
|
+
try {
|
|
608
|
+
if (!existsSync(this.storagePath)) {
|
|
609
|
+
mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
|
|
610
|
+
}
|
|
611
|
+
appendFileSync(path, JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
this.recordError(err);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
appendHealth(event) {
|
|
618
|
+
// Health rides through the same parent-buffer pipeline so the
|
|
619
|
+
// event-processor's poll picks it up uniformly. When no specific session
|
|
620
|
+
// is in scope we fan out to a shared bucket (`buffer-health.jsonl`) which
|
|
621
|
+
// `drainAllBuffers()` covers via its `buffer-*.jsonl` glob; we use a
|
|
622
|
+
// sessionless name when the watcher is unfiltered.
|
|
623
|
+
const sessionId = this.parentSessionFilter ?? 'health';
|
|
624
|
+
this.appendToParentBuffer(sessionId, event);
|
|
625
|
+
}
|
|
626
|
+
// -------------------------------------------------------------------------
|
|
627
|
+
// Schema-drift sentinels
|
|
628
|
+
// -------------------------------------------------------------------------
|
|
629
|
+
handleSchemaFingerprint(dimension, fingerprint, workflowRunId) {
|
|
630
|
+
const key = `${dimension}:${fingerprint}`;
|
|
631
|
+
const lastSeen = this.seenFingerprints.get(key);
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
if (lastSeen !== undefined && now - lastSeen < SCHEMA_FINGERPRINT_REEMIT_MS)
|
|
634
|
+
return;
|
|
635
|
+
this.seenFingerprints.set(key, now);
|
|
636
|
+
this.persistFingerprints();
|
|
637
|
+
if (lastSeen === undefined) {
|
|
638
|
+
this.schemaDrifts += 1;
|
|
639
|
+
}
|
|
640
|
+
this.appendHealth({
|
|
641
|
+
mode: 'observability_health',
|
|
642
|
+
tool: 'observability_health',
|
|
643
|
+
timestamp: now,
|
|
644
|
+
watcher: 'subagent',
|
|
645
|
+
filesWatched: this.filesWatched,
|
|
646
|
+
linesRead: this.linesRead,
|
|
647
|
+
bytesRead: this.bytesRead,
|
|
648
|
+
parseErrors: this.parseErrors,
|
|
649
|
+
schemaDrifts: this.schemaDrifts,
|
|
650
|
+
lastError: this.lastError,
|
|
651
|
+
event: 'schema_drift',
|
|
652
|
+
dimension,
|
|
653
|
+
fingerprint,
|
|
654
|
+
...(workflowRunId ? { workflowRunId } : {}),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
loadFingerprints() {
|
|
658
|
+
const path = join(this.storagePath, '.schema-fingerprints');
|
|
659
|
+
if (!existsSync(path))
|
|
660
|
+
return;
|
|
661
|
+
try {
|
|
662
|
+
const raw = readFileSync(path, 'utf-8');
|
|
663
|
+
const parsed = JSON.parse(raw);
|
|
664
|
+
if (parsed && typeof parsed === 'object') {
|
|
665
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
666
|
+
if (typeof v === 'number')
|
|
667
|
+
this.seenFingerprints.set(k, v);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
/* ignore — corrupt file means a fresh start */
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
persistFingerprints() {
|
|
676
|
+
const path = join(this.storagePath, '.schema-fingerprints');
|
|
677
|
+
try {
|
|
678
|
+
if (!existsSync(this.storagePath)) {
|
|
679
|
+
mkdirSync(this.storagePath, { recursive: true, mode: 0o700 });
|
|
680
|
+
}
|
|
681
|
+
// Trim entries older than the re-emission window so the file does not
|
|
682
|
+
// grow unbounded.
|
|
683
|
+
const now = Date.now();
|
|
684
|
+
const out = {};
|
|
685
|
+
for (const [k, v] of this.seenFingerprints) {
|
|
686
|
+
if (now - v < SCHEMA_FINGERPRINT_REEMIT_MS * 24)
|
|
687
|
+
out[k] = v;
|
|
688
|
+
}
|
|
689
|
+
writeFileSync(path, JSON.stringify(out), { mode: 0o600 });
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
this.recordError(err);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// -------------------------------------------------------------------------
|
|
696
|
+
// Health emission
|
|
697
|
+
// -------------------------------------------------------------------------
|
|
698
|
+
emitHealth() {
|
|
699
|
+
const event = {
|
|
700
|
+
mode: 'observability_health',
|
|
701
|
+
tool: 'observability_health',
|
|
702
|
+
timestamp: Date.now(),
|
|
703
|
+
watcher: 'subagent',
|
|
704
|
+
filesWatched: this.filesWatched,
|
|
705
|
+
linesRead: this.linesRead,
|
|
706
|
+
bytesRead: this.bytesRead,
|
|
707
|
+
parseErrors: this.parseErrors,
|
|
708
|
+
schemaDrifts: this.schemaDrifts,
|
|
709
|
+
lastError: this.lastError,
|
|
710
|
+
};
|
|
711
|
+
this.appendHealth(event);
|
|
712
|
+
}
|
|
713
|
+
maybeRunCostSelfCheck() {
|
|
714
|
+
if (!this.costSelfCheck)
|
|
715
|
+
return;
|
|
716
|
+
const now = Date.now();
|
|
717
|
+
if (now - this.lastCostSelfCheckMs < COST_SELF_CHECK_MS)
|
|
718
|
+
return;
|
|
719
|
+
this.lastCostSelfCheckMs = now;
|
|
720
|
+
let result;
|
|
721
|
+
try {
|
|
722
|
+
result = this.costSelfCheck();
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
this.recordError(err);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const denom = Math.max(result.groundTruthUsd, 1e-9);
|
|
729
|
+
const deltaPct = ((result.groundTruthUsd - result.trackedUsd) / denom) * 100;
|
|
730
|
+
this.appendHealth({
|
|
731
|
+
mode: 'observability_health',
|
|
732
|
+
tool: 'observability_health',
|
|
733
|
+
timestamp: now,
|
|
734
|
+
watcher: 'subagent',
|
|
735
|
+
filesWatched: this.filesWatched,
|
|
736
|
+
linesRead: this.linesRead,
|
|
737
|
+
bytesRead: this.bytesRead,
|
|
738
|
+
parseErrors: this.parseErrors,
|
|
739
|
+
schemaDrifts: this.schemaDrifts,
|
|
740
|
+
lastError: this.lastError,
|
|
741
|
+
event: 'cost_self_check',
|
|
742
|
+
costSelfCheckDeltaPct: deltaPct,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
recordError(err) {
|
|
746
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
747
|
+
const code = err.code ?? 'UNKNOWN';
|
|
748
|
+
const cls = err instanceof Error ? err.constructor.name : 'Error';
|
|
749
|
+
this.lastError = { code: String(code).slice(0, 80), class: String(cls).slice(0, 80) };
|
|
750
|
+
logger.warn('SubagentWatcher error', { code, message: message.slice(0, 200) });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Helpers
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
function num(v) {
|
|
757
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
758
|
+
}
|
|
759
|
+
function computeUsageKeysFingerprint(usage) {
|
|
760
|
+
const keys = [];
|
|
761
|
+
for (const k of Object.keys(usage).sort())
|
|
762
|
+
keys.push(k);
|
|
763
|
+
// Include child keys of `output_tokens_details` so reasoning-token drift
|
|
764
|
+
// produces a distinct fingerprint without inflating the dimension space.
|
|
765
|
+
const otd = usage.output_tokens_details;
|
|
766
|
+
if (otd && typeof otd === 'object') {
|
|
767
|
+
for (const k of Object.keys(otd).sort()) {
|
|
768
|
+
keys.push(`output_tokens_details.${k}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return shortHash(keys.join('|'));
|
|
772
|
+
}
|
|
773
|
+
function computeContentBlockTypesFingerprint(content) {
|
|
774
|
+
if (!Array.isArray(content))
|
|
775
|
+
return shortHash('');
|
|
776
|
+
const set = new Set();
|
|
777
|
+
for (const block of content) {
|
|
778
|
+
if (block &&
|
|
779
|
+
typeof block === 'object' &&
|
|
780
|
+
typeof block.type === 'string') {
|
|
781
|
+
set.add(String(block.type));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const sorted = Array.from(set).sort();
|
|
785
|
+
return shortHash(sorted.join('|'));
|
|
786
|
+
}
|
|
787
|
+
function shortHash(input) {
|
|
788
|
+
return createHash('sha1').update(input).digest('hex').slice(0, 16);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Stable cursor file path computation, exported for tests that want to
|
|
792
|
+
* pre-create cursor state without instantiating the watcher.
|
|
793
|
+
*/
|
|
794
|
+
export function buildSubagentCursorPath(storagePath, parentSessionId, agentId) {
|
|
795
|
+
return resolve(storagePath, `.subagent-pos-${parentSessionId}-${agentId}`);
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=subagent-watcher.js.map
|