@indigoai-us/hq-cli 5.108.16 → 5.108.17

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.17] — 2026-09-07
6
+
5
7
  ## [5.108.16] — 2026-09-07
6
8
 
7
9
  ### Fixed
@@ -39,6 +41,15 @@
39
41
  at most every five minutes, and `held.jsonl` is capped at 20,000 lines with
40
42
  the oldest overflow dead-lettered as `HELD_OVERFLOW` (loss-free under failure
41
43
  and concurrent flushers). (#525)
44
+ - The Work Mesh Live daemon's transcript watcher no longer rescans every file
45
+ under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
46
+ per-directory mtime cache and only relists directories that changed, re-stats
47
+ recently active files, revalidates every cached directory at least every five
48
+ minutes (content appends do not change a directory's mtime, so a session that
49
+ resumes after idling is still picked up), backs the interval off from 30 s to
50
+ 120 s on busy machines, caps the entries examined per tick with per-root
51
+ budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
52
+ disappearance reconciliation so an unvisited session is never ended falsely.
42
53
 
43
54
  ## [5.108.14] — 2026-09-07
44
55
 
@@ -70,6 +81,15 @@
70
81
  `incomplete_install` context so the next occurrence is attributable. The drop
71
82
  is wired both at the top-level boundary and in the shared `beforeSend`, so it
72
83
  covers every capture route.
84
+ - The Work Mesh Live daemon's transcript watcher no longer rescans every file
85
+ under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
86
+ per-directory mtime cache and only relists directories that changed, re-stats
87
+ recently active files, revalidates every cached directory at least every five
88
+ minutes (content appends do not change a directory's mtime, so a session that
89
+ resumes after idling is still picked up), backs the interval off from 30 s to
90
+ 120 s on busy machines, caps the entries examined per tick with per-root
91
+ budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
92
+ disappearance reconciliation so an unvisited session is never ended falsely.
73
93
 
74
94
  ## [5.108.13] — 2026-09-06
75
95
 
@@ -231,7 +251,6 @@
231
251
  and `hq reindex` now prunes local session-log copies 7 days after the vault
232
252
  confirms them (`HQ_SESSION_LOG_LOCAL_RETENTION_DAYS`, `off` to disable).
233
253
 
234
-
235
254
  ## [5.108.5] — 2026-09-04
236
255
 
237
256
  ### Added
@@ -1672,7 +1691,6 @@ All self-update behavior honors the existing `HQ_NO_UPDATE_CHECK=1` opt-out.
1672
1691
 
1673
1692
  - **`hq db provision` / `hq db status --remote` use Cognito + vault API** to call the live control plane (`POST /v1/db/provision`, `GET /v1/db/status`). Team plan required for provision; local `status|sql|migrate` unchanged.
1674
1693
 
1675
-
1676
1694
  ## [5.55.1]
1677
1695
 
1678
1696
  ### Fixed
@@ -19,6 +19,6 @@ export type { DaemonDoctorDeps, DaemonDoctorReport } from "./doctor.js";
19
19
  export { scopeBundleToAgentIdentity, } from "./credentials.js";
20
20
  export { FLUSH_INTERVAL_MS, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
21
21
  export type { DaemonHandle, DaemonRunDeps } from "./run.js";
22
- export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, isTranscriptHookCovered, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, } from "./transcript-watch.js";
23
- export type { DiscoveredTranscript, TranscriptFs, TranscriptKind, TranscriptWatchDeps, TranscriptWatchTickResult, } from "./transcript-watch.js";
22
+ export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_BACKOFF_DURATION_MS, TRANSCRIPT_WATCH_BACKOFF_ENTRIES, TRANSCRIPT_WATCH_ENTRY_CAP, TRANSCRIPT_WATCH_INTERVAL_MAX_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TRANSCRIPT_WATCH_REVALIDATE_MS, TranscriptWatcher, createTranscriptDiscoverCache, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, discoverTranscriptsWithMeta, isTranscriptHookCovered, nextTranscriptWatchIntervalMs, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, sortDiscoveries, } from "./transcript-watch.js";
23
+ export type { DiscoveredTranscript, DiscoverScanMeta, DiscoverTranscriptsResult, TranscriptDiscoverCache, TranscriptFs, TranscriptKind, TranscriptWatchDeps, TranscriptWatchTickResult, } from "./transcript-watch.js";
24
24
  //# sourceMappingURL=index.d.ts.map
@@ -10,5 +10,5 @@ export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonSe
10
10
  export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
11
11
  export { scopeBundleToAgentIdentity, } from "./credentials.js";
12
12
  export { FLUSH_INTERVAL_MS, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
13
- export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, isTranscriptHookCovered, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, } from "./transcript-watch.js";
13
+ export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_BACKOFF_DURATION_MS, TRANSCRIPT_WATCH_BACKOFF_ENTRIES, TRANSCRIPT_WATCH_ENTRY_CAP, TRANSCRIPT_WATCH_INTERVAL_MAX_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TRANSCRIPT_WATCH_REVALIDATE_MS, TranscriptWatcher, createTranscriptDiscoverCache, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, discoverTranscriptsWithMeta, isTranscriptHookCovered, nextTranscriptWatchIntervalMs, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, sortDiscoveries, } from "./transcript-watch.js";
14
14
  //# sourceMappingURL=index.js.map
@@ -358,6 +358,17 @@ export async function runMeshDaemon(deps = {}) {
358
358
  return;
359
359
  try {
360
360
  const summary = await transcriptWatcher.tick();
361
+ if (summary.skippedConcurrent)
362
+ return;
363
+ if (summary.intervalChanged) {
364
+ log(dir, `transcript-watch interval ${summary.previousIntervalMs}ms -> ${summary.intervalMs}ms`);
365
+ }
366
+ if (summary.capEpisodeStart) {
367
+ log(dir, `transcript-watch entry cap hit (examined=${summary.entriesExamined})`);
368
+ }
369
+ if (summary.cacheOverflowCleared) {
370
+ log(dir, "transcript-watch cache overflow; cleared for full rescan");
371
+ }
361
372
  if (summary.started > 0 ||
362
373
  summary.turns > 0 ||
363
374
  summary.ended > 0) {
@@ -377,7 +388,8 @@ export async function runMeshDaemon(deps = {}) {
377
388
  void runTranscriptTick().finally(() => {
378
389
  if (stopped)
379
390
  return;
380
- transcriptIntervalHandle = timers.setTimeout(transcriptTick, TRANSCRIPT_WATCH_INTERVAL_MS);
391
+ const delay = transcriptWatcher?.getIntervalMs() ?? TRANSCRIPT_WATCH_INTERVAL_MS;
392
+ transcriptIntervalHandle = timers.setTimeout(transcriptTick, delay);
381
393
  });
382
394
  }, TRANSCRIPT_WATCH_INTERVAL_MS);
383
395
  }
@@ -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
- * every 15s using only readdir + stat (mtime). Never opens or reads transcript bytes.
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
@@ -22,7 +31,21 @@ import { reconcileObservation, type ReconcileDeps } from "../../../work-context/
22
31
  import { readSessionState, writeSessionState, type SessionStateFile } from "../../../work-context/state.js";
23
32
  import { enqueueSessionEvent } from "../enqueue.js";
24
33
  import type { SessionEventHarness } from "../format-spool-line.js";
25
- export declare const TRANSCRIPT_WATCH_INTERVAL_MS = 15000;
34
+ export declare const TRANSCRIPT_WATCH_INTERVAL_MS = 30000;
35
+ /** Ceiling for adaptive backoff after heavy ticks. */
36
+ export declare const TRANSCRIPT_WATCH_INTERVAL_MAX_MS = 120000;
37
+ /** Double the interval when a tick examines more than this many entries. */
38
+ export declare const TRANSCRIPT_WATCH_BACKOFF_ENTRIES = 2000;
39
+ /** Double the interval when a tick takes longer than this many ms. */
40
+ export declare const TRANSCRIPT_WATCH_BACKOFF_DURATION_MS = 500;
41
+ /** Hard cap on entries examined (dir stats, readdir children, file stats) per tick. */
42
+ export declare const TRANSCRIPT_WATCH_ENTRY_CAP = 20000;
43
+ /**
44
+ * Even when a directory mtime matches the cache, force a full relist (readdir +
45
+ * re-stat) after this many ms so content appends and coarse mtime granularity
46
+ * cannot hide membership or mtime changes indefinitely.
47
+ */
48
+ export declare const TRANSCRIPT_WATCH_REVALIDATE_MS: number;
26
49
  export declare const TRANSCRIPT_TURN_QUIET_MS = 5000;
27
50
  export declare const TRANSCRIPT_SESSION_END_MS: number;
28
51
  export declare const TRANSCRIPT_HOOK_COVER_MS = 60000;
@@ -86,6 +109,8 @@ export interface TranscriptWatchDeps {
86
109
  reconcile?: typeof reconcileObservation;
87
110
  claudeProjectsDir?: string;
88
111
  codexSessionsDir?: string;
112
+ /** Override entry cap (tests). Defaults to TRANSCRIPT_WATCH_ENTRY_CAP. */
113
+ entryCap?: number;
89
114
  }
90
115
  export interface TranscriptWatchTickResult {
91
116
  discovered: number;
@@ -94,7 +119,43 @@ export interface TranscriptWatchTickResult {
94
119
  ended: number;
95
120
  skippedHookCovered: number;
96
121
  skippedStale: number;
122
+ /** Dir stats + readdir children + file stats examined this tick. */
123
+ entriesExamined: number;
124
+ durationMs: number;
125
+ /** True when TRANSCRIPT_WATCH_ENTRY_CAP stopped the walk early. */
126
+ capped: boolean;
127
+ /** True on the first capped tick of a contiguous capped episode (for logging). */
128
+ capEpisodeStart: boolean;
129
+ /** True when the discover cache was cleared due to exceeding the file cap. */
130
+ cacheOverflowCleared: boolean;
131
+ /** Interval after this tick's adaptive backoff adjustment. */
132
+ intervalMs: number;
133
+ previousIntervalMs: number;
134
+ intervalChanged: boolean;
135
+ /** True when a previous tick was still running (this call was a no-op). */
136
+ skippedConcurrent: boolean;
137
+ }
138
+ /** Per-directory mtime + child cache for incremental discovery. */
139
+ export interface TranscriptDirCacheEntry {
140
+ mtimeMs: number;
141
+ files: DiscoveredTranscript[];
142
+ subdirs: string[];
143
+ /** Wall time of the last complete listing of this directory. */
144
+ lastFullStatAtMs: number;
145
+ }
146
+ export interface TranscriptDiscoverCache {
147
+ dirs: Map<string, TranscriptDirCacheEntry>;
97
148
  }
149
+ export interface DiscoverScanMeta {
150
+ entriesExamined: number;
151
+ dirsListed: number;
152
+ dirsSkipped: number;
153
+ filesStatted: number;
154
+ capped: boolean;
155
+ /** True when cache file count exceeded the entry cap and the cache was cleared. */
156
+ cacheOverflowCleared: boolean;
157
+ }
158
+ export declare function createTranscriptDiscoverCache(): TranscriptDiscoverCache;
98
159
  /**
99
160
  * Claude Code encodes an absolute cwd as a directory name by replacing every
100
161
  * "/" with "-". Decoding is best-effort (hyphenated path segments are ambiguous).
@@ -110,15 +171,41 @@ export declare function sessionIdFromClaudeTranscriptPath(filePath: string): str
110
171
  * Rule: trailing UUID in basename, else basename without extension.
111
172
  */
112
173
  export declare function sessionIdFromCodexTranscriptPath(filePath: string): string | null;
113
- /**
114
- * Enumerate transcript files with mtime only (no content read).
115
- */
116
- export declare function discoverTranscripts(opts: {
174
+ export interface DiscoverTranscriptsOptions {
117
175
  home?: string;
118
176
  fs?: TranscriptFs;
119
177
  claudeProjectsDir?: string;
120
178
  codexSessionsDir?: string;
121
- }): DiscoveredTranscript[];
179
+ /** When set, reuse/update this cache for incremental scans. */
180
+ cache?: TranscriptDiscoverCache;
181
+ /** Clock for active-window re-stat decisions. Defaults to Date.now(). */
182
+ nowMs?: number;
183
+ /** Override entry cap (tests). Defaults to TRANSCRIPT_WATCH_ENTRY_CAP. */
184
+ entryCap?: number;
185
+ /** Active mtime window for re-stat in unchanged dirs. */
186
+ activeWindowMs?: number;
187
+ }
188
+ export interface DiscoverTranscriptsResult {
189
+ transcripts: DiscoveredTranscript[];
190
+ meta: DiscoverScanMeta;
191
+ }
192
+ /**
193
+ * Enumerate transcript files with mtime only (no content read).
194
+ * Pass `cache` to skip readdir on directories whose mtime is unchanged
195
+ * (until TRANSCRIPT_WATCH_REVALIDATE_MS forces a relist).
196
+ *
197
+ * Each root gets half of `entryCap`; unused budget from the first root is
198
+ * available to the second so a huge Claude tree cannot starve Codex.
199
+ */
200
+ export declare function discoverTranscriptsWithMeta(opts?: DiscoverTranscriptsOptions): DiscoverTranscriptsResult;
201
+ /**
202
+ * Enumerate transcript files with mtime only (no content read).
203
+ * Without a cache this is a full walk; with `cache` it is incremental.
204
+ */
205
+ export declare function discoverTranscripts(opts?: DiscoverTranscriptsOptions): DiscoveredTranscript[];
206
+ /** Sort key for comparing discovery results independent of walk order. */
207
+ export declare function discoveryFingerprint(d: DiscoveredTranscript): string;
208
+ export declare function sortDiscoveries(items: DiscoveredTranscript[]): DiscoveredTranscript[];
122
209
  /**
123
210
  * Whether hooks already cover this session: durable hook-written state, or a
124
211
  * hook event observed within the last TRANSCRIPT_HOOK_COVER_MS.
@@ -156,10 +243,23 @@ export declare class TranscriptWatcher {
156
243
  private readonly readState;
157
244
  private readonly writeState;
158
245
  private readonly reconcileFn;
246
+ private readonly discoverCache;
247
+ private intervalMs;
248
+ private tickInFlight;
249
+ /** Tracks contiguous capped ticks so we log once per cap episode. */
250
+ private inCapEpisode;
159
251
  constructor(deps: TranscriptWatchDeps);
252
+ /** Current adaptive poll interval (after the last tick's backoff). */
253
+ getIntervalMs(): number;
254
+ /** Expose discovery cache for tests. */
255
+ getDiscoverCache(): TranscriptDiscoverCache;
160
256
  /** Record that a hook path emitted an event for sessionId (daemon flush). */
161
257
  noteHookEvent(sessionId: string, atMs?: number): void;
162
258
  tick(): Promise<TranscriptWatchTickResult>;
259
+ /**
260
+ * Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
261
+ */
262
+ private applyIntervalBackoff;
163
263
  /**
164
264
  * Register via reconcileObservation (membership-validated slug→uid), then
165
265
  * enqueue session_start. Never overwrites a hook-written state.
@@ -167,4 +267,11 @@ export declare class TranscriptWatcher {
167
267
  private emitStart;
168
268
  private emitKind;
169
269
  }
270
+ /**
271
+ * Adaptive interval: busy ticks double up to MAX; quiet ticks return to base.
272
+ */
273
+ export declare function nextTranscriptWatchIntervalMs(currentMs: number, opts: {
274
+ entriesExamined: number;
275
+ durationMs: number;
276
+ }): number;
170
277
  //# sourceMappingURL=transcript-watch.d.ts.map
@@ -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
- * every 15s using only readdir + stat (mtime). Never opens or reads transcript bytes.
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 = 15_000;
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 listJsonlFiles(root, tfs) {
93
- if (!tfs.existsSync(root))
94
- return [];
95
- const out = [];
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
- let entries;
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
- entries = tfs.readdirSync(dir, { withFileTypes: true });
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
- for (const entry of entries) {
107
- const name = typeof entry === "string" ? entry : entry.name;
108
- const full = path.join(dir, name);
109
- let isDir;
110
- let isFile;
111
- if (typeof entry !== "string" && typeof entry.isDirectory === "function") {
112
- isDir = entry.isDirectory();
113
- isFile = entry.isFile();
114
- }
115
- else {
116
- try {
117
- const st = tfs.statSync(full);
118
- isDir = st.isDirectory();
119
- isFile = st.isFile();
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
- catch {
122
- continue;
262
+ else {
263
+ out.push({ ...f });
123
264
  }
124
265
  }
125
- if (isDir) {
126
- stack.push(full);
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
- let isDir;
153
- let isFile;
154
- if (typeof entry !== "string" && typeof entry.isDirectory === "function") {
155
- isDir = entry.isDirectory();
156
- isFile = entry.isFile();
290
+ const kindFlags = entryIsDirFile(entry, full, tfs, meta, entryCap);
291
+ if (!kindFlags) {
292
+ listingComplete = false;
293
+ break;
157
294
  }
158
- else {
159
- try {
160
- const st = tfs.statSync(full);
161
- isDir = st.isDirectory();
162
- isFile = st.isFile();
163
- }
164
- catch {
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
- if (isDir) {
169
- stack.push(full);
314
+ catch {
315
+ continue;
170
316
  }
171
- else if (isFile) {
172
- // Codex stores rollout-*.jsonl (and occasionally other session files).
173
- out.push(full);
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 discoverTranscripts(opts) {
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 found = [];
188
- for (const filePath of listJsonlFiles(claudeRoot, tfs)) {
189
- const sessionId = sessionIdFromClaudeTranscriptPath(filePath);
190
- if (!sessionId)
191
- continue;
192
- let mtimeMs;
193
- try {
194
- const st = tfs.statSync(filePath);
195
- if (!st.isFile())
196
- continue;
197
- mtimeMs = st.mtimeMs;
198
- }
199
- catch {
200
- continue;
201
- }
202
- const projectDir = path.basename(path.dirname(filePath));
203
- const cwd = decodeClaudeProjectDirName(projectDir);
204
- found.push({
205
- path: filePath,
206
- sessionId,
207
- kind: "claude",
208
- cwd,
209
- mtimeMs,
210
- harness: "claude-desktop",
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
- for (const filePath of listCodexSessionFiles(codexRoot, tfs)) {
214
- if (!filePath.endsWith(".jsonl") && !path.basename(filePath).startsWith("rollout-")) {
215
- // Prefer jsonl; still accept rollout-* without extension edge cases via id parse.
216
- if (!path.basename(filePath).startsWith("rollout-"))
217
- continue;
218
- }
219
- const sessionId = sessionIdFromCodexTranscriptPath(filePath);
220
- if (!sessionId)
221
- continue;
222
- let mtimeMs;
223
- try {
224
- const st = tfs.statSync(filePath);
225
- if (!st.isFile())
226
- continue;
227
- mtimeMs = st.mtimeMs;
228
- }
229
- catch {
230
- continue;
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
- return found;
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 nowMs = this.deps.now?.() ?? Date.now();
370
- const home = this.deps.home ?? os.homedir();
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
- const discovered = discoverTranscripts({
380
- home,
381
- fs: this.tfs,
382
- claudeProjectsDir: this.deps.claudeProjectsDir,
383
- codexSessionsDir: this.deps.codexSessionsDir,
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
- result.discovered = discovered.length;
386
- const seenPaths = new Set();
387
- for (const d of discovered) {
388
- seenPaths.add(d.path);
389
- if (isTranscriptHookCovered(d.sessionId, {
390
- workContextRoot: this.deps.workContextRoot,
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
- lastHookEventAt: this.deps.lastHookEventAt,
393
- readState: this.readState,
394
- })) {
395
- result.skippedHookCovered += 1;
396
- this.tracked.delete(d.path);
397
- continue;
398
- }
399
- let track = this.tracked.get(d.path);
400
- if (!track) {
401
- // First sight: only adopt fresh or actively-written transcripts.
402
- if (nowMs - d.mtimeMs > TRANSCRIPT_FRESH_MS) {
403
- result.skippedStale += 1;
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
- path: d.path,
408
- sessionId: d.sessionId,
409
- kind: d.kind,
410
- cwd: d.cwd,
411
- harness: d.harness,
412
- lastMtimeMs: d.mtimeMs,
413
- lastChangeAtMs: d.mtimeMs,
414
- started: false,
415
- ended: false,
416
- pendingTurn: false,
417
- seq: 0,
418
- };
419
- this.tracked.set(d.path, track);
420
- const started = await this.emitStart(track, nowMs);
421
- if (started)
422
- result.started += 1;
423
- continue;
424
- }
425
- if (track.ended)
426
- continue;
427
- if (d.mtimeMs > track.lastMtimeMs) {
428
- track.lastMtimeMs = d.mtimeMs;
429
- track.lastChangeAtMs = nowMs;
430
- if (track.started) {
431
- track.pendingTurn = true;
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 (track.pendingTurn &&
435
- nowMs - track.lastChangeAtMs >= TRANSCRIPT_TURN_QUIET_MS) {
436
- this.emitKind(track, "turn_end", nowMs);
437
- track.pendingTurn = false;
438
- result.turns += 1;
439
- }
440
- if (track.started &&
441
- !track.ended &&
442
- nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
443
- this.emitKind(track, "session_end", nowMs);
444
- track.ended = true;
445
- result.ended += 1;
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
- // Sessions that disappeared from disk: end if still open.
449
- for (const [p, track] of [...this.tracked.entries()]) {
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
- return result;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.16",
3
+ "version": "5.108.17",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {