[object Object]

← 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

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 →