@indigoai-us/hq-cli 5.108.16 → 5.108.18
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/CHANGELOG.md +38 -2
- package/dist/lib/mesh/live/daemon/index.d.ts +2 -2
- package/dist/lib/mesh/live/daemon/index.js +1 -1
- package/dist/lib/mesh/live/daemon/run.js +13 -1
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +114 -7
- package/dist/lib/mesh/live/daemon/transcript-watch.js +458 -187
- package/dist/main.js +51 -7
- package/dist/startup-registration.d.ts +79 -0
- package/dist/startup-registration.js +134 -0
- package/dist/utils/install-tree-torn.d.ts +204 -0
- package/dist/utils/install-tree-torn.js +359 -0
- package/dist/utils/sentry-fingerprint.js +26 -0
- package/package.json +1 -1
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
* Transcript-watch fallback for hookless hosts (US-018 / AMEND-001).
|
|
3
3
|
*
|
|
4
4
|
* Enumerates ~/.claude/projects/ (recursive *.jsonl) and ~/.codex/sessions/
|
|
5
|
-
*
|
|
5
|
+
* on an adaptive interval (default 30s, backoff to 120s) using only readdir +
|
|
6
|
+
* stat (mtime). Never opens or reads transcript bytes.
|
|
7
|
+
*
|
|
8
|
+
* Scans are incremental: directory mtime is cached so unchanged directories are
|
|
9
|
+
* not re-listed; only files in changed directories (plus files still inside the
|
|
10
|
+
* active mtime window) are re-statted. Every TRANSCRIPT_WATCH_REVALIDATE_MS a
|
|
11
|
+
* cached directory is force-relisted so idle appends and coarse mtimes cannot
|
|
12
|
+
* hide changes forever. A per-tick entry cap (split across Claude/Codex roots)
|
|
13
|
+
* bounds pathological trees; capped ticks skip disappearance reconciliation.
|
|
14
|
+
* Ticks never overlap (a concurrent call is skipped).
|
|
6
15
|
*
|
|
7
16
|
* Session identity (deterministic):
|
|
8
17
|
* - Claude: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl
|
|
@@ -28,7 +37,21 @@ import { deriveRemoteOwnerSlug } from "../../../work-context/repo-remote.js";
|
|
|
28
37
|
import { isHookWrittenSessionState, readSessionState, writeSessionState, } from "../../../work-context/state.js";
|
|
29
38
|
import { enqueueSessionEvent } from "../enqueue.js";
|
|
30
39
|
import { isValidSessionId } from "../session-identity.js";
|
|
31
|
-
export const TRANSCRIPT_WATCH_INTERVAL_MS =
|
|
40
|
+
export const TRANSCRIPT_WATCH_INTERVAL_MS = 30_000;
|
|
41
|
+
/** Ceiling for adaptive backoff after heavy ticks. */
|
|
42
|
+
export const TRANSCRIPT_WATCH_INTERVAL_MAX_MS = 120_000;
|
|
43
|
+
/** Double the interval when a tick examines more than this many entries. */
|
|
44
|
+
export const TRANSCRIPT_WATCH_BACKOFF_ENTRIES = 2_000;
|
|
45
|
+
/** Double the interval when a tick takes longer than this many ms. */
|
|
46
|
+
export const TRANSCRIPT_WATCH_BACKOFF_DURATION_MS = 500;
|
|
47
|
+
/** Hard cap on entries examined (dir stats, readdir children, file stats) per tick. */
|
|
48
|
+
export const TRANSCRIPT_WATCH_ENTRY_CAP = 20_000;
|
|
49
|
+
/**
|
|
50
|
+
* Even when a directory mtime matches the cache, force a full relist (readdir +
|
|
51
|
+
* re-stat) after this many ms so content appends and coarse mtime granularity
|
|
52
|
+
* cannot hide membership or mtime changes indefinitely.
|
|
53
|
+
*/
|
|
54
|
+
export const TRANSCRIPT_WATCH_REVALIDATE_MS = 5 * 60_000;
|
|
32
55
|
export const TRANSCRIPT_TURN_QUIET_MS = 5_000;
|
|
33
56
|
export const TRANSCRIPT_SESSION_END_MS = 10 * 60_000;
|
|
34
57
|
export const TRANSCRIPT_HOOK_COVER_MS = 60_000;
|
|
@@ -45,6 +68,9 @@ export const defaultTranscriptFs = {
|
|
|
45
68
|
statSync: (p) => fs.statSync(p),
|
|
46
69
|
existsSync: (p) => fs.existsSync(p),
|
|
47
70
|
};
|
|
71
|
+
export function createTranscriptDiscoverCache() {
|
|
72
|
+
return { dirs: new Map() };
|
|
73
|
+
}
|
|
48
74
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
49
75
|
/**
|
|
50
76
|
* Claude Code encodes an absolute cwd as a directory name by replacing every
|
|
@@ -89,155 +115,315 @@ export function sessionIdFromCodexTranscriptPath(filePath) {
|
|
|
89
115
|
return null;
|
|
90
116
|
return id;
|
|
91
117
|
}
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
function emptyScanMeta() {
|
|
119
|
+
return {
|
|
120
|
+
entriesExamined: 0,
|
|
121
|
+
dirsListed: 0,
|
|
122
|
+
dirsSkipped: 0,
|
|
123
|
+
filesStatted: 0,
|
|
124
|
+
capped: false,
|
|
125
|
+
cacheOverflowCleared: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function countCachedFiles(cache) {
|
|
129
|
+
let n = 0;
|
|
130
|
+
for (const entry of cache.dirs.values())
|
|
131
|
+
n += entry.files.length;
|
|
132
|
+
return n;
|
|
133
|
+
}
|
|
134
|
+
function bumpExamined(meta, entryCap) {
|
|
135
|
+
if (meta.entriesExamined >= entryCap) {
|
|
136
|
+
meta.capped = true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
meta.entriesExamined += 1;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
function entryIsDirFile(entry, full, tfs, meta, entryCap) {
|
|
143
|
+
if (typeof entry !== "string" && typeof entry.isDirectory === "function") {
|
|
144
|
+
return { isDir: entry.isDirectory(), isFile: entry.isFile() };
|
|
145
|
+
}
|
|
146
|
+
if (!bumpExamined(meta, entryCap))
|
|
147
|
+
return null;
|
|
148
|
+
try {
|
|
149
|
+
const st = tfs.statSync(full);
|
|
150
|
+
return { isDir: st.isDirectory(), isFile: st.isFile() };
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isCodexSessionCandidate(filePath) {
|
|
157
|
+
const base = path.basename(filePath);
|
|
158
|
+
return filePath.endsWith(".jsonl") || base.startsWith("rollout-");
|
|
159
|
+
}
|
|
160
|
+
function pruneCacheTree(cache, root) {
|
|
96
161
|
const stack = [root];
|
|
97
162
|
while (stack.length > 0) {
|
|
98
163
|
const dir = stack.pop();
|
|
99
|
-
|
|
164
|
+
const entry = cache.dirs.get(dir);
|
|
165
|
+
cache.dirs.delete(dir);
|
|
166
|
+
if (!entry)
|
|
167
|
+
continue;
|
|
168
|
+
for (const sub of entry.subdirs)
|
|
169
|
+
stack.push(sub);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function buildClaudeDiscovery(filePath, mtimeMs) {
|
|
173
|
+
const sessionId = sessionIdFromClaudeTranscriptPath(filePath);
|
|
174
|
+
if (!sessionId)
|
|
175
|
+
return null;
|
|
176
|
+
const projectDir = path.basename(path.dirname(filePath));
|
|
177
|
+
const cwd = decodeClaudeProjectDirName(projectDir);
|
|
178
|
+
return {
|
|
179
|
+
path: filePath,
|
|
180
|
+
sessionId,
|
|
181
|
+
kind: "claude",
|
|
182
|
+
cwd,
|
|
183
|
+
mtimeMs,
|
|
184
|
+
harness: "claude-desktop",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function buildCodexDiscovery(filePath, mtimeMs) {
|
|
188
|
+
if (!isCodexSessionCandidate(filePath))
|
|
189
|
+
return null;
|
|
190
|
+
const sessionId = sessionIdFromCodexTranscriptPath(filePath);
|
|
191
|
+
if (!sessionId)
|
|
192
|
+
return null;
|
|
193
|
+
return {
|
|
194
|
+
path: filePath,
|
|
195
|
+
sessionId,
|
|
196
|
+
kind: "codex",
|
|
197
|
+
mtimeMs,
|
|
198
|
+
harness: "codex",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function acceptFileName(kind, name) {
|
|
202
|
+
if (kind === "claude")
|
|
203
|
+
return name.endsWith(".jsonl");
|
|
204
|
+
// Codex: keep parity with prior walk (all files; filtered when building).
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Walk one transcript root with optional per-directory mtime cache.
|
|
209
|
+
* Unchanged directories skip readdir until TRANSCRIPT_WATCH_REVALIDATE_MS elapses;
|
|
210
|
+
* active-window files are still re-statted on the skip path.
|
|
211
|
+
*/
|
|
212
|
+
function walkTranscriptRoot(opts) {
|
|
213
|
+
const { root, kind, tfs, cache, nowMs, activeWindowMs, revalidateMs, entryCap, meta, out, visited, } = opts;
|
|
214
|
+
if (!tfs.existsSync(root)) {
|
|
215
|
+
if (cache)
|
|
216
|
+
pruneCacheTree(cache, root);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const stack = [root];
|
|
220
|
+
while (stack.length > 0) {
|
|
221
|
+
if (meta.capped)
|
|
222
|
+
return;
|
|
223
|
+
const dir = stack.pop();
|
|
224
|
+
if (!bumpExamined(meta, entryCap))
|
|
225
|
+
return;
|
|
226
|
+
let dirMtime;
|
|
100
227
|
try {
|
|
101
|
-
|
|
228
|
+
const st = tfs.statSync(dir);
|
|
229
|
+
if (!st.isDirectory())
|
|
230
|
+
continue;
|
|
231
|
+
dirMtime = st.mtimeMs;
|
|
102
232
|
}
|
|
103
233
|
catch {
|
|
234
|
+
if (cache)
|
|
235
|
+
pruneCacheTree(cache, dir);
|
|
104
236
|
continue;
|
|
105
237
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
238
|
+
visited?.add(dir);
|
|
239
|
+
const cached = cache?.dirs.get(dir);
|
|
240
|
+
const cacheFresh = cached !== undefined &&
|
|
241
|
+
nowMs - cached.lastFullStatAtMs < revalidateMs;
|
|
242
|
+
if (cache && cached && cached.mtimeMs === dirMtime && cacheFresh) {
|
|
243
|
+
meta.dirsSkipped += 1;
|
|
244
|
+
for (const f of cached.files) {
|
|
245
|
+
if (meta.capped)
|
|
246
|
+
return;
|
|
247
|
+
if (nowMs - f.mtimeMs <= activeWindowMs) {
|
|
248
|
+
if (!bumpExamined(meta, entryCap))
|
|
249
|
+
return;
|
|
250
|
+
meta.filesStatted += 1;
|
|
251
|
+
try {
|
|
252
|
+
const st = tfs.statSync(f.path);
|
|
253
|
+
if (!st.isFile())
|
|
254
|
+
continue;
|
|
255
|
+
f.mtimeMs = st.mtimeMs;
|
|
256
|
+
out.push({ ...f });
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
/* file gone; parent mtime should refresh on next listing */
|
|
260
|
+
}
|
|
120
261
|
}
|
|
121
|
-
|
|
122
|
-
|
|
262
|
+
else {
|
|
263
|
+
out.push({ ...f });
|
|
123
264
|
}
|
|
124
265
|
}
|
|
125
|
-
|
|
126
|
-
stack.push(
|
|
127
|
-
|
|
128
|
-
else if (isFile && name.endsWith(".jsonl")) {
|
|
129
|
-
out.push(full);
|
|
130
|
-
}
|
|
266
|
+
for (const sub of cached.subdirs)
|
|
267
|
+
stack.push(sub);
|
|
268
|
+
continue;
|
|
131
269
|
}
|
|
132
|
-
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
function listCodexSessionFiles(root, tfs) {
|
|
136
|
-
if (!tfs.existsSync(root))
|
|
137
|
-
return [];
|
|
138
|
-
const out = [];
|
|
139
|
-
const stack = [root];
|
|
140
|
-
while (stack.length > 0) {
|
|
141
|
-
const dir = stack.pop();
|
|
270
|
+
meta.dirsListed += 1;
|
|
142
271
|
let entries;
|
|
143
272
|
try {
|
|
144
273
|
entries = tfs.readdirSync(dir, { withFileTypes: true });
|
|
145
274
|
}
|
|
146
275
|
catch {
|
|
276
|
+
if (cache)
|
|
277
|
+
pruneCacheTree(cache, dir);
|
|
147
278
|
continue;
|
|
148
279
|
}
|
|
280
|
+
const files = [];
|
|
281
|
+
const subdirs = [];
|
|
282
|
+
let listingComplete = true;
|
|
149
283
|
for (const entry of entries) {
|
|
284
|
+
if (!bumpExamined(meta, entryCap)) {
|
|
285
|
+
listingComplete = false;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
150
288
|
const name = typeof entry === "string" ? entry : entry.name;
|
|
151
289
|
const full = path.join(dir, name);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
isFile = entry.isFile();
|
|
290
|
+
const kindFlags = entryIsDirFile(entry, full, tfs, meta, entryCap);
|
|
291
|
+
if (!kindFlags) {
|
|
292
|
+
listingComplete = false;
|
|
293
|
+
break;
|
|
157
294
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
295
|
+
if (kindFlags.isDir) {
|
|
296
|
+
subdirs.push(full);
|
|
297
|
+
stack.push(full);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!kindFlags.isFile || !acceptFileName(kind, name))
|
|
301
|
+
continue;
|
|
302
|
+
if (!bumpExamined(meta, entryCap)) {
|
|
303
|
+
listingComplete = false;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
meta.filesStatted += 1;
|
|
307
|
+
let mtimeMs;
|
|
308
|
+
try {
|
|
309
|
+
const st = tfs.statSync(full);
|
|
310
|
+
if (!st.isFile())
|
|
165
311
|
continue;
|
|
166
|
-
|
|
312
|
+
mtimeMs = st.mtimeMs;
|
|
167
313
|
}
|
|
168
|
-
|
|
169
|
-
|
|
314
|
+
catch {
|
|
315
|
+
continue;
|
|
170
316
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
317
|
+
const discovered = kind === "claude"
|
|
318
|
+
? buildClaudeDiscovery(full, mtimeMs)
|
|
319
|
+
: buildCodexDiscovery(full, mtimeMs);
|
|
320
|
+
if (!discovered)
|
|
321
|
+
continue;
|
|
322
|
+
files.push(discovered);
|
|
323
|
+
out.push({ ...discovered });
|
|
324
|
+
}
|
|
325
|
+
// Only replace the cache after a complete listing. A mid-listing cap must
|
|
326
|
+
// not overwrite a prior complete entry with a partial files/subdirs set.
|
|
327
|
+
if (cache && listingComplete) {
|
|
328
|
+
const prev = cache.dirs.get(dir);
|
|
329
|
+
if (prev) {
|
|
330
|
+
const keep = new Set(subdirs);
|
|
331
|
+
for (const sub of prev.subdirs) {
|
|
332
|
+
if (!keep.has(sub))
|
|
333
|
+
pruneCacheTree(cache, sub);
|
|
334
|
+
}
|
|
174
335
|
}
|
|
336
|
+
cache.dirs.set(dir, {
|
|
337
|
+
mtimeMs: dirMtime,
|
|
338
|
+
files,
|
|
339
|
+
subdirs,
|
|
340
|
+
lastFullStatAtMs: nowMs,
|
|
341
|
+
});
|
|
175
342
|
}
|
|
176
343
|
}
|
|
177
|
-
return out;
|
|
178
344
|
}
|
|
179
345
|
/**
|
|
180
346
|
* Enumerate transcript files with mtime only (no content read).
|
|
347
|
+
* Pass `cache` to skip readdir on directories whose mtime is unchanged
|
|
348
|
+
* (until TRANSCRIPT_WATCH_REVALIDATE_MS forces a relist).
|
|
349
|
+
*
|
|
350
|
+
* Each root gets half of `entryCap`; unused budget from the first root is
|
|
351
|
+
* available to the second so a huge Claude tree cannot starve Codex.
|
|
181
352
|
*/
|
|
182
|
-
export function
|
|
353
|
+
export function discoverTranscriptsWithMeta(opts = {}) {
|
|
183
354
|
const home = opts.home ?? os.homedir();
|
|
184
355
|
const tfs = opts.fs ?? defaultTranscriptFs;
|
|
185
356
|
const claudeRoot = opts.claudeProjectsDir ?? path.join(home, ".claude", "projects");
|
|
186
357
|
const codexRoot = opts.codexSessionsDir ?? path.join(home, ".codex", "sessions");
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
358
|
+
const cache = opts.cache ?? null;
|
|
359
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
360
|
+
const entryCap = opts.entryCap ?? TRANSCRIPT_WATCH_ENTRY_CAP;
|
|
361
|
+
const activeWindowMs = opts.activeWindowMs ?? TRANSCRIPT_FRESH_MS;
|
|
362
|
+
const revalidateMs = TRANSCRIPT_WATCH_REVALIDATE_MS;
|
|
363
|
+
const meta = emptyScanMeta();
|
|
364
|
+
const transcripts = [];
|
|
365
|
+
const visited = cache ? new Set() : null;
|
|
366
|
+
// Per-root budgets: first root limited to half; remainder rolls to second.
|
|
367
|
+
const firstRootBudget = Math.floor(entryCap / 2);
|
|
368
|
+
walkTranscriptRoot({
|
|
369
|
+
root: claudeRoot,
|
|
370
|
+
kind: "claude",
|
|
371
|
+
tfs,
|
|
372
|
+
cache,
|
|
373
|
+
nowMs,
|
|
374
|
+
activeWindowMs,
|
|
375
|
+
revalidateMs,
|
|
376
|
+
entryCap: firstRootBudget,
|
|
377
|
+
meta,
|
|
378
|
+
out: transcripts,
|
|
379
|
+
visited,
|
|
380
|
+
});
|
|
381
|
+
const firstRootIncomplete = meta.capped;
|
|
382
|
+
// Allow the second root to consume leftover budget up to the global cap.
|
|
383
|
+
if (meta.capped && meta.entriesExamined < entryCap) {
|
|
384
|
+
meta.capped = false;
|
|
212
385
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
386
|
+
walkTranscriptRoot({
|
|
387
|
+
root: codexRoot,
|
|
388
|
+
kind: "codex",
|
|
389
|
+
tfs,
|
|
390
|
+
cache,
|
|
391
|
+
nowMs,
|
|
392
|
+
activeWindowMs,
|
|
393
|
+
revalidateMs,
|
|
394
|
+
entryCap,
|
|
395
|
+
meta,
|
|
396
|
+
out: transcripts,
|
|
397
|
+
visited,
|
|
398
|
+
});
|
|
399
|
+
meta.capped = meta.capped || firstRootIncomplete;
|
|
400
|
+
if (cache && !meta.capped && visited) {
|
|
401
|
+
for (const key of [...cache.dirs.keys()]) {
|
|
402
|
+
if (!visited.has(key))
|
|
403
|
+
cache.dirs.delete(key);
|
|
231
404
|
}
|
|
232
|
-
found.push({
|
|
233
|
-
path: filePath,
|
|
234
|
-
sessionId,
|
|
235
|
-
kind: "codex",
|
|
236
|
-
mtimeMs,
|
|
237
|
-
harness: "codex",
|
|
238
|
-
});
|
|
239
405
|
}
|
|
240
|
-
|
|
406
|
+
// Bound by the production constant (not a test-reduced entryCap) so a low
|
|
407
|
+
// scan budget cannot wipe a still-valid cache.
|
|
408
|
+
if (cache && countCachedFiles(cache) > TRANSCRIPT_WATCH_ENTRY_CAP) {
|
|
409
|
+
cache.dirs.clear();
|
|
410
|
+
meta.cacheOverflowCleared = true;
|
|
411
|
+
}
|
|
412
|
+
return { transcripts, meta };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Enumerate transcript files with mtime only (no content read).
|
|
416
|
+
* Without a cache this is a full walk; with `cache` it is incremental.
|
|
417
|
+
*/
|
|
418
|
+
export function discoverTranscripts(opts = {}) {
|
|
419
|
+
return discoverTranscriptsWithMeta(opts).transcripts;
|
|
420
|
+
}
|
|
421
|
+
/** Sort key for comparing discovery results independent of walk order. */
|
|
422
|
+
export function discoveryFingerprint(d) {
|
|
423
|
+
return `${d.path}\0${d.sessionId}\0${d.kind}\0${d.mtimeMs}\0${d.cwd ?? ""}\0${d.harness}`;
|
|
424
|
+
}
|
|
425
|
+
export function sortDiscoveries(items) {
|
|
426
|
+
return [...items].sort((a, b) => a.path.localeCompare(b.path));
|
|
241
427
|
}
|
|
242
428
|
/**
|
|
243
429
|
* Whether hooks already cover this session: durable hook-written state, or a
|
|
@@ -350,6 +536,11 @@ export class TranscriptWatcher {
|
|
|
350
536
|
readState;
|
|
351
537
|
writeState;
|
|
352
538
|
reconcileFn;
|
|
539
|
+
discoverCache = createTranscriptDiscoverCache();
|
|
540
|
+
intervalMs = TRANSCRIPT_WATCH_INTERVAL_MS;
|
|
541
|
+
tickInFlight = false;
|
|
542
|
+
/** Tracks contiguous capped ticks so we log once per cap episode. */
|
|
543
|
+
inCapEpisode = false;
|
|
353
544
|
constructor(deps) {
|
|
354
545
|
this.deps = deps;
|
|
355
546
|
this.tfs = deps.fs ?? defaultTranscriptFs;
|
|
@@ -358,6 +549,14 @@ export class TranscriptWatcher {
|
|
|
358
549
|
this.writeState = deps.writeState ?? writeSessionState;
|
|
359
550
|
this.reconcileFn = deps.reconcile ?? reconcileObservation;
|
|
360
551
|
}
|
|
552
|
+
/** Current adaptive poll interval (after the last tick's backoff). */
|
|
553
|
+
getIntervalMs() {
|
|
554
|
+
return this.intervalMs;
|
|
555
|
+
}
|
|
556
|
+
/** Expose discovery cache for tests. */
|
|
557
|
+
getDiscoverCache() {
|
|
558
|
+
return this.discoverCache;
|
|
559
|
+
}
|
|
361
560
|
/** Record that a hook path emitted an event for sessionId (daemon flush). */
|
|
362
561
|
noteHookEvent(sessionId, atMs) {
|
|
363
562
|
const map = this.deps.lastHookEventAt;
|
|
@@ -366,97 +565,158 @@ export class TranscriptWatcher {
|
|
|
366
565
|
map.set(sessionId, atMs ?? (this.deps.now?.() ?? Date.now()));
|
|
367
566
|
}
|
|
368
567
|
async tick() {
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
const result = {
|
|
568
|
+
const previousIntervalMs = this.intervalMs;
|
|
569
|
+
const idle = () => ({
|
|
372
570
|
discovered: 0,
|
|
373
571
|
started: 0,
|
|
374
572
|
turns: 0,
|
|
375
573
|
ended: 0,
|
|
376
574
|
skippedHookCovered: 0,
|
|
377
575
|
skippedStale: 0,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
576
|
+
entriesExamined: 0,
|
|
577
|
+
durationMs: 0,
|
|
578
|
+
capped: false,
|
|
579
|
+
capEpisodeStart: false,
|
|
580
|
+
cacheOverflowCleared: false,
|
|
581
|
+
intervalMs: this.intervalMs,
|
|
582
|
+
previousIntervalMs,
|
|
583
|
+
intervalChanged: false,
|
|
584
|
+
skippedConcurrent: true,
|
|
384
585
|
});
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
586
|
+
if (this.tickInFlight)
|
|
587
|
+
return idle();
|
|
588
|
+
this.tickInFlight = true;
|
|
589
|
+
// Wall clock for backoff — independent of injectable session `now`.
|
|
590
|
+
const wallStartedAt = Date.now();
|
|
591
|
+
try {
|
|
592
|
+
const nowMs = this.deps.now?.() ?? Date.now();
|
|
593
|
+
const home = this.deps.home ?? os.homedir();
|
|
594
|
+
const result = {
|
|
595
|
+
discovered: 0,
|
|
596
|
+
started: 0,
|
|
597
|
+
turns: 0,
|
|
598
|
+
ended: 0,
|
|
599
|
+
skippedHookCovered: 0,
|
|
600
|
+
skippedStale: 0,
|
|
601
|
+
entriesExamined: 0,
|
|
602
|
+
durationMs: 0,
|
|
603
|
+
capped: false,
|
|
604
|
+
capEpisodeStart: false,
|
|
605
|
+
cacheOverflowCleared: false,
|
|
606
|
+
intervalMs: this.intervalMs,
|
|
607
|
+
previousIntervalMs,
|
|
608
|
+
intervalChanged: false,
|
|
609
|
+
skippedConcurrent: false,
|
|
610
|
+
};
|
|
611
|
+
const { transcripts: discovered, meta } = discoverTranscriptsWithMeta({
|
|
612
|
+
home,
|
|
613
|
+
fs: this.tfs,
|
|
614
|
+
claudeProjectsDir: this.deps.claudeProjectsDir,
|
|
615
|
+
codexSessionsDir: this.deps.codexSessionsDir,
|
|
616
|
+
cache: this.discoverCache,
|
|
391
617
|
nowMs,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
618
|
+
entryCap: this.deps.entryCap,
|
|
619
|
+
});
|
|
620
|
+
result.discovered = discovered.length;
|
|
621
|
+
result.entriesExamined = meta.entriesExamined;
|
|
622
|
+
result.capped = meta.capped;
|
|
623
|
+
result.capEpisodeStart = meta.capped && !this.inCapEpisode;
|
|
624
|
+
this.inCapEpisode = meta.capped;
|
|
625
|
+
result.cacheOverflowCleared = meta.cacheOverflowCleared;
|
|
626
|
+
const seenPaths = new Set();
|
|
627
|
+
for (const d of discovered) {
|
|
628
|
+
seenPaths.add(d.path);
|
|
629
|
+
if (isTranscriptHookCovered(d.sessionId, {
|
|
630
|
+
workContextRoot: this.deps.workContextRoot,
|
|
631
|
+
nowMs,
|
|
632
|
+
lastHookEventAt: this.deps.lastHookEventAt,
|
|
633
|
+
readState: this.readState,
|
|
634
|
+
})) {
|
|
635
|
+
result.skippedHookCovered += 1;
|
|
636
|
+
this.tracked.delete(d.path);
|
|
404
637
|
continue;
|
|
405
638
|
}
|
|
406
|
-
track =
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
639
|
+
let track = this.tracked.get(d.path);
|
|
640
|
+
if (!track) {
|
|
641
|
+
// First sight: only adopt fresh or actively-written transcripts.
|
|
642
|
+
if (nowMs - d.mtimeMs > TRANSCRIPT_FRESH_MS) {
|
|
643
|
+
result.skippedStale += 1;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
track = {
|
|
647
|
+
path: d.path,
|
|
648
|
+
sessionId: d.sessionId,
|
|
649
|
+
kind: d.kind,
|
|
650
|
+
cwd: d.cwd,
|
|
651
|
+
harness: d.harness,
|
|
652
|
+
lastMtimeMs: d.mtimeMs,
|
|
653
|
+
lastChangeAtMs: d.mtimeMs,
|
|
654
|
+
started: false,
|
|
655
|
+
ended: false,
|
|
656
|
+
pendingTurn: false,
|
|
657
|
+
seq: 0,
|
|
658
|
+
};
|
|
659
|
+
this.tracked.set(d.path, track);
|
|
660
|
+
const started = await this.emitStart(track, nowMs);
|
|
661
|
+
if (started)
|
|
662
|
+
result.started += 1;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (track.ended)
|
|
666
|
+
continue;
|
|
667
|
+
if (d.mtimeMs > track.lastMtimeMs) {
|
|
668
|
+
track.lastMtimeMs = d.mtimeMs;
|
|
669
|
+
track.lastChangeAtMs = nowMs;
|
|
670
|
+
if (track.started) {
|
|
671
|
+
track.pendingTurn = true;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (track.pendingTurn &&
|
|
675
|
+
nowMs - track.lastChangeAtMs >= TRANSCRIPT_TURN_QUIET_MS) {
|
|
676
|
+
this.emitKind(track, "turn_end", nowMs);
|
|
677
|
+
track.pendingTurn = false;
|
|
678
|
+
result.turns += 1;
|
|
679
|
+
}
|
|
680
|
+
if (track.started &&
|
|
681
|
+
!track.ended &&
|
|
682
|
+
nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
|
|
683
|
+
this.emitKind(track, "session_end", nowMs);
|
|
684
|
+
track.ended = true;
|
|
685
|
+
result.ended += 1;
|
|
432
686
|
}
|
|
433
687
|
}
|
|
434
|
-
if
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
track.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
688
|
+
// Sessions that disappeared from disk: end if still open. Skip when the
|
|
689
|
+
// walk was capped — missing paths may simply be unvisited, not gone.
|
|
690
|
+
if (!meta.capped) {
|
|
691
|
+
for (const [p, track] of [...this.tracked.entries()]) {
|
|
692
|
+
if (seenPaths.has(p))
|
|
693
|
+
continue;
|
|
694
|
+
if (track.started && !track.ended) {
|
|
695
|
+
this.emitKind(track, "session_end", nowMs);
|
|
696
|
+
track.ended = true;
|
|
697
|
+
result.ended += 1;
|
|
698
|
+
}
|
|
699
|
+
this.tracked.delete(p);
|
|
700
|
+
}
|
|
446
701
|
}
|
|
702
|
+
result.durationMs = Math.max(0, Date.now() - wallStartedAt);
|
|
703
|
+
this.applyIntervalBackoff(result.entriesExamined, result.durationMs);
|
|
704
|
+
result.intervalMs = this.intervalMs;
|
|
705
|
+
result.intervalChanged = this.intervalMs !== previousIntervalMs;
|
|
706
|
+
return result;
|
|
447
707
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (seenPaths.has(p))
|
|
451
|
-
continue;
|
|
452
|
-
if (track.started && !track.ended) {
|
|
453
|
-
this.emitKind(track, "session_end", nowMs);
|
|
454
|
-
track.ended = true;
|
|
455
|
-
result.ended += 1;
|
|
456
|
-
}
|
|
457
|
-
this.tracked.delete(p);
|
|
708
|
+
finally {
|
|
709
|
+
this.tickInFlight = false;
|
|
458
710
|
}
|
|
459
|
-
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
|
|
714
|
+
*/
|
|
715
|
+
applyIntervalBackoff(entriesExamined, durationMs) {
|
|
716
|
+
this.intervalMs = nextTranscriptWatchIntervalMs(this.intervalMs, {
|
|
717
|
+
entriesExamined,
|
|
718
|
+
durationMs,
|
|
719
|
+
});
|
|
460
720
|
}
|
|
461
721
|
/**
|
|
462
722
|
* Register via reconcileObservation (membership-validated slug→uid), then
|
|
@@ -517,4 +777,15 @@ export class TranscriptWatcher {
|
|
|
517
777
|
this.enqueueFn(opts);
|
|
518
778
|
}
|
|
519
779
|
}
|
|
780
|
+
/**
|
|
781
|
+
* Adaptive interval: busy ticks double up to MAX; quiet ticks return to base.
|
|
782
|
+
*/
|
|
783
|
+
export function nextTranscriptWatchIntervalMs(currentMs, opts) {
|
|
784
|
+
const busy = opts.entriesExamined > TRANSCRIPT_WATCH_BACKOFF_ENTRIES ||
|
|
785
|
+
opts.durationMs > TRANSCRIPT_WATCH_BACKOFF_DURATION_MS;
|
|
786
|
+
if (busy) {
|
|
787
|
+
return Math.min(currentMs * 2, TRANSCRIPT_WATCH_INTERVAL_MAX_MS);
|
|
788
|
+
}
|
|
789
|
+
return TRANSCRIPT_WATCH_INTERVAL_MS;
|
|
790
|
+
}
|
|
520
791
|
//# sourceMappingURL=transcript-watch.js.map
|