← back to Answer Cockpit
TK-11793: pane watcher only while a viewer is polling, 60s interval — iTerm was at 131% CPU and every AppleScript caller on the box queued behind it (dot scanner reported terminal_api unavailable x57)
191e46bb671fe70ceabdde1f97e79a2869b946a0 · 2026-09-16 09:13:24 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
M lib/transcript.jsM server.js
Diff
commit 191e46bb671fe70ceabdde1f97e79a2869b946a0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 16 09:13:24 2026 -0700
TK-11793: pane watcher only while a viewer is polling, 60s interval — iTerm was at 131% CPU and every AppleScript caller on the box queued behind it (dot scanner reported terminal_api unavailable x57)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
lib/transcript.js | 22 +++++++++++++++-------
server.js | 2 +-
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/lib/transcript.js b/lib/transcript.js
index 91ea107..a1b8560 100644
--- a/lib/transcript.js
+++ b/lib/transcript.js
@@ -249,6 +249,12 @@ function invalidatePanes() { paneBatch = { ts: 0, map: paneBatch.map }; }
// A click (guardTarget) still forces ONE fresh sync read via refreshPanesSync() — rare, and
// that is the moment freshness actually matters.
let watcher = null, refreshing = false;
+// Materializing 45 sessions' scrollback is expensive FOR ITERM (it was at 131% CPU with the
+// watcher on a 12s timer, and every AppleScript caller on the box — the dot scanner, the
+// router — queued behind it). So: refresh only while a viewer is actually polling, and slowly.
+const WATCH_INTERVAL_MS = 60000, VIEWER_IDLE_MS = 60000;
+let lastViewerAt = 0;
+function noteViewer() { lastViewerAt = Date.now(); }
function paneScript(BEGIN, END) {
return `tell application "iTerm2"
set out to ""
@@ -289,10 +295,10 @@ function refreshPanesAsync() {
child.stdin.on('error', () => {});
child.stdin.end(paneScript(BEGIN, END));
}
-function startPaneWatcher(intervalMs = PANE_BATCH_MS) {
+function startPaneWatcher(intervalMs = WATCH_INTERVAL_MS) {
if (watcher) return watcher;
- refreshPanesAsync();
- watcher = setInterval(refreshPanesAsync, intervalMs);
+ const tick = () => { if (Date.now() - lastViewerAt < VIEWER_IDLE_MS) refreshPanesAsync(); };
+ watcher = setInterval(tick, intervalMs);
if (watcher.unref) watcher.unref();
return watcher;
}
@@ -308,9 +314,11 @@ function refreshPanesSync() {
}
function allPaneContents() {
if (Date.now() - paneBatch.ts < PANE_BATCH_MS) return paneBatch.map;
- // With the watcher running, NEVER block a request path: serve the last batch (≤ a few
- // seconds older than the TTL while a refresh is in flight) and let the timer catch up.
- if (watcher) { refreshPanesAsync(); return paneBatch.map; }
+ // With the watcher running, NEVER block a request path: serve the last batch and kick ONE
+ // async refresh (a viewer is evidently here). If we have never read at all, fall through to
+ // the sync read below so the very first page load is not empty.
+ if (watcher && paneBatch.ts > 0) { noteViewer(); refreshPanesAsync(); return paneBatch.map; }
+ if (watcher) noteViewer();
// Per-call NONCE delimiters: a pane that happens to print the literal delimiter (e.g. a session
// reviewing this file) can no longer truncate its own capture (Cody FIX-FIRST #4).
const nonce = crypto.randomBytes(6).toString('hex');
@@ -514,4 +522,4 @@ function resolveUncached(row, pid, tty) {
return { sessionId: null, transcriptPath: null, cwd, confidence: 'ambiguous', how: survivors.length ? `validator kept ${survivors.length}` : 'validator kept 0', candidates: paths, detail: null };
}
-module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, allPaneContents, invalidatePanes, refreshPanesSync, startPaneWatcher, MAP_DIR, _cache: cache };
+module.exports = { resolve, parseTail, tailJsonl, pendingState, cwdOf, candidateDirs, parsePane, paneContents, allPaneContents, invalidatePanes, refreshPanesSync, startPaneWatcher, noteViewer, MAP_DIR, _cache: cache };
diff --git a/server.js b/server.js
index 586e186..73ac5d9 100644
--- a/server.js
+++ b/server.js
@@ -136,7 +136,7 @@ const server = http.createServer(async (req, res) => {
try {
// ---- GET ----
if (req.method === 'GET') {
- if (p === '/api/queue') return json(res, 200, await queue.build({ orphans: u.searchParams.get('orphans') === '1' }));
+ if (p === '/api/queue') { transcript.noteViewer(); return json(res, 200, await queue.build({ orphans: u.searchParams.get('orphans') === '1' })); }
if (p.startsWith('/api/item/')) {
const tty = p.slice('/api/item/'.length);
if (!queue.TTY_RE.test(tty)) return json(res, 400, { error: 'bad tty' });
← b5244e5 TK-11793: README — scale design note (async pane watcher, st
·
back to Answer Cockpit
·
TK-11793: scan budget 150s + stale-while-revalidate scan (fl 6b98c03 →