[object Object]

← back to Ticket System

TK-11372: stop the ticket-board event loop wedging on a hung lsof

661b3c57fd472656082406754936394ee7521321 · 2026-09-11 11:42:42 -0700 · Steve Abrams

sample(1) of the live listener proved the main thread was stuck 1972/3794
samples inside execSync, called from the 4500ms warm-up setInterval:
  uv__run_timers -> SyncProcessRunner::Spawn -> uv_run -> uv__io_poll -> kevent

At load average 70 the execSync lsof on rpc.sock overran its 4s timeout; the
timeout killed only the /bin/sh wrapper, so the lsof child was reparented to
PID 1 and kept the stdout pipe open. execSync never saw EOF, so the single
main thread never left the timer callback: the listener stayed bound on
127.0.0.1:9794 while every request, including the 3-line /healthz, timed out.

pm2DaemonReachable is now async execFile with no shell, so the timeout SIGKILL
lands on lsof itself; getRunning answers from cache immediately with one
refresh in flight and serve-last-good. Monitoring subprocesses can no longer
delay health, auth or ticket reads.

Applied source is byte-identical to the reviewed candidate 57baae6e. Baseline
retained at verification/tk11372/server.pre-activation-backup.js (eaff7476).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkwT8hDyw7SCCkYBviZTbq

Files touched

Diff

commit 661b3c57fd472656082406754936394ee7521321
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 11:42:42 2026 -0700

    TK-11372: stop the ticket-board event loop wedging on a hung lsof
    
    sample(1) of the live listener proved the main thread was stuck 1972/3794
    samples inside execSync, called from the 4500ms warm-up setInterval:
      uv__run_timers -> SyncProcessRunner::Spawn -> uv_run -> uv__io_poll -> kevent
    
    At load average 70 the execSync lsof on rpc.sock overran its 4s timeout; the
    timeout killed only the /bin/sh wrapper, so the lsof child was reparented to
    PID 1 and kept the stdout pipe open. execSync never saw EOF, so the single
    main thread never left the timer callback: the listener stayed bound on
    127.0.0.1:9794 while every request, including the 3-line /healthz, timed out.
    
    pm2DaemonReachable is now async execFile with no shell, so the timeout SIGKILL
    lands on lsof itself; getRunning answers from cache immediately with one
    refresh in flight and serve-last-good. Monitoring subprocesses can no longer
    delay health, auth or ticket reads.
    
    Applied source is byte-identical to the reviewed candidate 57baae6e. Baseline
    retained at verification/tk11372/server.pre-activation-backup.js (eaff7476).
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01PkwT8hDyw7SCCkYBviZTbq
---
 server.js                                          |  71 ++--
 .../tk11372/server.pre-activation-backup.js        | 467 +++++++++++++++++++++
 2 files changed, 507 insertions(+), 31 deletions(-)

diff --git a/server.js b/server.js
index b29f5bc2..4ab1e7be 100644
--- a/server.js
+++ b/server.js
@@ -37,42 +37,51 @@ function readJson(req, cb) { let b = ''; req.on('data', d => { b += d; if (b.len
 // of socket-less orphan daemons, so we only jlist when the rpc.sock exists AND a live God daemon
 // holds it; otherwise return the cached/empty pm2 list (never fork).
 const PM2_HOME_TS = process.env.PM2_HOME || require('path').join(require('os').homedir(), '.pm2');
-function pm2DaemonReachable() {
-  try {
-    const rpc = require('path').join(PM2_HOME_TS, 'rpc.sock');
-    if (!require('fs').existsSync(rpc)) return false;
-    const { execSync } = require('child_process');
-    const held = execSync(`lsof -nP ${JSON.stringify(rpc)} 2>/dev/null`, { timeout: 4000 }).toString().trim();
-    if (!held) return false;
-    const ps = execSync('ps ax -o pid,command 2>/dev/null', { maxBuffer: 8 * 1024 * 1024, timeout: 4000 }).toString();
-    return ps.split('\n').some(l => /PM2 v[\d.]+: God Daemon/.test(l) && l.includes(PM2_HOME_TS));
-  } catch (_) { return false; }
+function pm2DaemonReachable(cb) {
+  const rpc = path.join(PM2_HOME_TS, 'rpc.sock');
+  if (!fs.existsSync(rpc)) return cb(false);
+  const options = { timeout: 4000, killSignal: 'SIGKILL', maxBuffer: 8 * 1024 * 1024 };
+  execFile('lsof', ['-nP', rpc], options, (error, held) => {
+    if (error || !String(held).trim()) return cb(false);
+    execFile('ps', ['ax', '-o', 'pid,command'], options, (error, output) => {
+      cb(!error && String(output).split('\n').some(line => /PM2 v[\d.]+: God Daemon/.test(line) && line.includes(PM2_HOME_TS)));
+    });
+  });
 }
 let runCache = { ts: 0, data: { pm2: [], sessions: 0, at: null } };
+let runRefresh = false;
 const PM2_SERIALIZED_TS = path.join(os.homedir(), '.claude', 'skills', 'keep-alive', 'proposals', 'TK-10970', 'pm2-serialized.js');
 function getRunning(cb) {
-  if (Date.now() - runCache.ts < 5000) return cb(runCache.data);
-  if (!pm2DaemonReachable()) {
-    // socket not reachable — do NOT fork a daemon; serve last-known (or empty) pm2 list
-    runCache = { ts: Date.now(), data: { pm2: runCache.data.pm2 || [], sessions: runCache.data.sessions || 0, at: new Date().toISOString() } };
-    return cb(runCache.data);
-  }
-  execFile(process.execPath, [PM2_SERIALIZED_TS, 'jlist'], { maxBuffer: 16 * 1024 * 1024, timeout: 22000, killSignal: 'SIGKILL' }, (e, out) => {
-      let pm2 = [];
-      if (!e) { try {
-        pm2 = JSON.parse(out).filter(p => p.pm2_env && p.pm2_env.status === 'online')
-          .map(p => ({ name: p.name, cpu: (p.monit && p.monit.cpu) || 0,
-            mem: Math.round(((p.monit && p.monit.memory) || 0) / 1048576),
-            up: p.pm2_env.pm_uptime || 0, restarts: p.pm2_env.restart_time || 0 }))
-          .sort((a, b) => a.name < b.name ? -1 : 1);
-      } catch (_) {} }
-      // count live `claude` CLI sessions (exclude skills-dir helpers), best-effort
-      exec("ps -Ao command | grep '[c]laude' | grep -v 'skills/' | wc -l", { timeout: 4000 }, (e2, out2) => {
-        const sessions = e2 ? 0 : (parseInt(String(out2).trim(), 10) || 0);
-        runCache = { ts: Date.now(), data: { pm2, sessions, at: new Date().toISOString() } };
-        cb(runCache.data);
+  if (Date.now() - runCache.ts >= 5000 && !runRefresh) {
+    runRefresh = true;
+    const finish = (data = runCache.data) => {
+      runCache = { ts: Date.now(), data };
+      runRefresh = false;
+    };
+    pm2DaemonReachable(reachable => {
+      if (!reachable) return finish();
+      execFile(process.execPath, [PM2_SERIALIZED_TS, 'jlist'], { maxBuffer: 16 * 1024 * 1024, timeout: 22000, killSignal: 'SIGKILL' }, (error, output) => {
+        if (error) return finish();
+        let pm2;
+        try {
+          pm2 = JSON.parse(output).filter(p => p.pm2_env && p.pm2_env.status === 'online')
+            .map(p => ({ name: p.name, cpu: (p.monit && p.monit.cpu) || 0,
+              mem: Math.round(((p.monit && p.monit.memory) || 0) / 1048576),
+              up: p.pm2_env.pm_uptime || 0, restarts: p.pm2_env.restart_time || 0 }))
+            .sort((a, b) => a.name < b.name ? -1 : 1);
+        } catch { return finish(); }
+        // Count in-process so the timeout targets ps itself, not a shell whose
+        // children could survive and keep the refresh pipes open indefinitely.
+        execFile('ps', ['-Ao', 'command'], { timeout: 4000, killSignal: 'SIGKILL', maxBuffer: 8 * 1024 * 1024 }, (error, output) => {
+          const sessions = error ? runCache.data.sessions : String(output).split('\n')
+            .filter(line => line.includes('claude') && !line.includes('skills/')).length;
+          finish({ pm2, sessions, at: new Date().toISOString() });
+        });
       });
-  });
+    });
+  }
+  // Monitoring subprocesses must never delay health, auth, or first-start requests.
+  cb(runCache.data);
 }
 
 const OFFICE_HTML = path.join(__dirname, 'office.html');
diff --git a/verification/tk11372/server.pre-activation-backup.js b/verification/tk11372/server.pre-activation-backup.js
new file mode 100644
index 00000000..b29f5bc2
--- /dev/null
+++ b/verification/tk11372/server.pre-activation-backup.js
@@ -0,0 +1,467 @@
+// Ticket board viewer — kanban over the shared ticket store. :9794, basic-auth admin/DW2024!, open /healthz.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { exec, execFile, spawn } = require('child_process');
+const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList } = require('./lib.js');
+
+// ── ticket-run + DTD wiring (TK-10527) ──
+const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
+const VERDICTS = path.join(DATA_DIR, 'dtd-verdicts.json');       // last batched dtd run-now verdicts
+const DTD_RUNNING = path.join(DATA_DIR, 'dtd-verdicts.running'); // present while a sweep is in flight
+const RUN_SH = path.join(__dirname, 'run-ticket.sh');           // opens an iTerm2 Claude session
+const DTD_RUN = path.join(__dirname, 'dtd-run.js');             // batched panel.sh sweep
+const RUN_PROFILES = new Set(['claude-sonnet', 'claude-opus', 'claude-haiku', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable', 'codex', 'codex-gpt6', 'codex-gpt52', 'local-qwen-27b', 'local-qwen-14b']);
+const DEFAULT_RUN_PROFILE = 'codex';
+const RUN_PROFILE_OVERRIDE = '/tmp/ticket-run-profile-override.json';
+
+// A bounded operator override wins over stale browser localStorage. The file is
+// intentionally self-expiring, so no cleanup job is required to restore the
+// normal default after a short Codex-only launch window.
+function effectiveRunProfile(requested) {
+  try {
+    const override = JSON.parse(fs.readFileSync(RUN_PROFILE_OVERRIDE, 'utf8'));
+    if (RUN_PROFILES.has(override.profile) && Date.parse(override.until) > Date.now()) return override.profile;
+  } catch {}
+  return String(requested || DEFAULT_RUN_PROFILE);
+}
+// IDRE / REFRE / resolveList now live in ./lib.js (co-located with resolveId).
+const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+function readJson(req, cb) { let b = ''; req.on('data', d => { b += d; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { cb(JSON.parse(b || '{}')); } catch { cb(null); } }); }
+
+// ── live "running" signal: online pm2 processes + live claude CLI sessions ──
+// Cached 5s so N polling browsers don't each spawn a `pm2 jlist` on a busy box.
+// TK-10970 daemon-fracture guard: a bare `pm2 jlist` FORKS a new "God" daemon when the rpc.sock is
+// transiently unreachable (not only when pm2 is absent). This always-up board must not be a source
+// of socket-less orphan daemons, so we only jlist when the rpc.sock exists AND a live God daemon
+// holds it; otherwise return the cached/empty pm2 list (never fork).
+const PM2_HOME_TS = process.env.PM2_HOME || require('path').join(require('os').homedir(), '.pm2');
+function pm2DaemonReachable() {
+  try {
+    const rpc = require('path').join(PM2_HOME_TS, 'rpc.sock');
+    if (!require('fs').existsSync(rpc)) return false;
+    const { execSync } = require('child_process');
+    const held = execSync(`lsof -nP ${JSON.stringify(rpc)} 2>/dev/null`, { timeout: 4000 }).toString().trim();
+    if (!held) return false;
+    const ps = execSync('ps ax -o pid,command 2>/dev/null', { maxBuffer: 8 * 1024 * 1024, timeout: 4000 }).toString();
+    return ps.split('\n').some(l => /PM2 v[\d.]+: God Daemon/.test(l) && l.includes(PM2_HOME_TS));
+  } catch (_) { return false; }
+}
+let runCache = { ts: 0, data: { pm2: [], sessions: 0, at: null } };
+const PM2_SERIALIZED_TS = path.join(os.homedir(), '.claude', 'skills', 'keep-alive', 'proposals', 'TK-10970', 'pm2-serialized.js');
+function getRunning(cb) {
+  if (Date.now() - runCache.ts < 5000) return cb(runCache.data);
+  if (!pm2DaemonReachable()) {
+    // socket not reachable — do NOT fork a daemon; serve last-known (or empty) pm2 list
+    runCache = { ts: Date.now(), data: { pm2: runCache.data.pm2 || [], sessions: runCache.data.sessions || 0, at: new Date().toISOString() } };
+    return cb(runCache.data);
+  }
+  execFile(process.execPath, [PM2_SERIALIZED_TS, 'jlist'], { maxBuffer: 16 * 1024 * 1024, timeout: 22000, killSignal: 'SIGKILL' }, (e, out) => {
+      let pm2 = [];
+      if (!e) { try {
+        pm2 = JSON.parse(out).filter(p => p.pm2_env && p.pm2_env.status === 'online')
+          .map(p => ({ name: p.name, cpu: (p.monit && p.monit.cpu) || 0,
+            mem: Math.round(((p.monit && p.monit.memory) || 0) / 1048576),
+            up: p.pm2_env.pm_uptime || 0, restarts: p.pm2_env.restart_time || 0 }))
+          .sort((a, b) => a.name < b.name ? -1 : 1);
+      } catch (_) {} }
+      // count live `claude` CLI sessions (exclude skills-dir helpers), best-effort
+      exec("ps -Ao command | grep '[c]laude' | grep -v 'skills/' | wc -l", { timeout: 4000 }, (e2, out2) => {
+        const sessions = e2 ? 0 : (parseInt(String(out2).trim(), 10) || 0);
+        runCache = { ts: Date.now(), data: { pm2, sessions, at: new Date().toISOString() } };
+        cb(runCache.data);
+      });
+  });
+}
+
+const OFFICE_HTML = path.join(__dirname, 'office.html');
+const BOARD_HTML = path.join(__dirname, 'board.html');
+const SKILL_ROOTS = [
+  path.join(os.homedir(), '.agents', 'skills'),
+  path.join(os.homedir(), '.codex', 'skills'),
+];
+
+function installedSkills() {
+  const found = new Map();
+  for (const root of SKILL_ROOTS) {
+    let names = []; try { names = fs.readdirSync(root); } catch { continue; }
+    for (const dir of names) {
+      const file = path.join(root, dir, 'SKILL.md');
+      let body, st; try { body = fs.readFileSync(file, 'utf8'); st = fs.statSync(file); } catch { continue; }
+      const fm = body.match(/^---\s*\n([\s\S]*?)\n---/);
+      const meta = fm ? fm[1] : '';
+      const name = (meta.match(/^name:\s*["']?(.+?)["']?\s*$/m) || [])[1] || dir;
+      const rawDesc = (meta.match(/^description:\s*[>|-]?\s*["']?(.+?)["']?\s*$/m) || [])[1] || '';
+      const key = String(name).trim().toLowerCase();
+      if (!found.has(key)) found.set(key, {
+        name: String(name).trim(), slug: dir, description: String(rawDesc).trim(),
+        root: root.includes('.agents') ? 'agents' : 'codex', path: file,
+        created_at: (st.birthtime || st.mtime).toISOString(), updated_at: st.mtime.toISOString(),
+      });
+    }
+  }
+  return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function ticketAgents() {
+  const map = new Map();
+  const touch = (name, ts, role, ticket) => {
+    if (!name) return;
+    let a = map.get(name); if (!a) a = { name, assigned: 0, actions: 0, comments: 0, tickets: new Set(), first_at: ts, last_at: ts };
+    a.tickets.add(ticket.id); if (role === 'assigned') a.assigned++; else a[role]++;
+    if (ts && (!a.first_at || ts < a.first_at)) a.first_at = ts;
+    if (ts && (!a.last_at || ts > a.last_at)) a.last_at = ts;
+    map.set(name, a);
+  };
+  for (const t of tickets().values()) {
+    touch(t.assignee, t.updated_at || t.created_at, 'assigned', t);
+    for (const a of (t.actions || [])) touch(a.agent, a.ts, 'actions', t);
+    for (const c of (t.comments || [])) touch(c.agent, c.ts, 'comments', t);
+  }
+  return [...map.values()].map(a => ({ ...a, tickets: a.tickets.size, created_at: a.first_at, updated_at: a.last_at }))
+    .sort((a, b) => b.tickets - a.tickets || a.name.localeCompare(b.name));
+}
+
+const PORT = process.env.PORT || 9794;
+const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');
+
+// ── brute-force lockout (interim hardening while CF Zero Trust Access is pending) ──
+// The board is now PUBLICLY exposed via the dedicated `tickets` tunnel and its
+// authenticated endpoints spawn Claude sessions (/api/run) — a lockout-less shared
+// Basic cred on the open internet is dictionary-attackable at line speed. Track failed
+// auths per client IP; after FAIL_MAX inside FAIL_WINDOW_MS, that IP is 429'd for LOCK_MS.
+// A correct auth clears the record. In-memory only, pruned so the map can't grow.
+// Loopback (direct 127.0.0.1, not tunnel-proxied) is exempt so local use never locks.
+// Ported from ~/Projects/dw-pitch-followup/server.js. Reversible: delete this block +
+// restore the plain auth check below.
+const authFails = new Map(); // ip -> { count, first, until }
+const FAIL_MAX = 10, FAIL_WINDOW_MS = 15 * 60 * 1000, LOCK_MS = 15 * 60 * 1000;
+const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
+function clientIp(req) {
+  return req.headers['cf-connecting-ip']
+    || (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
+    || (req.socket && req.socket.remoteAddress) || 'unknown';
+}
+// Returns null if the request may proceed to the auth check, or a {code,msg} to reject.
+function lockoutGate(req) {
+  const remote = req.socket && req.socket.remoteAddress;
+  const proxied = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'];
+  if (LOOPBACK.has(remote) && !proxied) return null;      // direct local access — never locked
+  const ip = clientIp(req), now = Date.now();
+  const rec = authFails.get(ip);
+  if (rec && rec.until && now < rec.until) return { code: 429, msg: 'too many failed attempts — try again later', retry: Math.ceil((rec.until - now) / 1000) };
+  return null;
+}
+function noteAuth(req, ok) {
+  const remote = req.socket && req.socket.remoteAddress;
+  const proxied = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'];
+  if (LOOPBACK.has(remote) && !proxied) return;
+  const ip = clientIp(req), now = Date.now();
+  if (ok) { authFails.delete(ip); return; }
+  let rec = authFails.get(ip);
+  if (!rec || (now - rec.first) > FAIL_WINDOW_MS) rec = { count: 0, first: now, until: 0 };
+  rec.count++;
+  if (rec.count >= FAIL_MAX) rec.until = now + LOCK_MS;
+  authFails.set(ip, rec);
+  // prune only truly-inactive records: expired locks OR window-stale (count would reset anyway).
+  // NOT active in-window records (until==0, count<MAX) — deleting those resets a live attacker's count.
+  if (authFails.size > 5000) for (const [k, v] of authFails) if ((v.until && now > v.until) || (now - v.first) > FAIL_WINDOW_MS) authFails.delete(k);
+}
+// Claude-spawning endpoints (/api/run, /api/dtd) must run from a LOCAL operator only.
+// Over the public `tickets` tunnel these would let a remote actor (past the shared cred)
+// spawn Claude sessions on this Mac — Cody Hole 2. Viewing stays public; execution stays
+// local. Reversible: delete this guard's calls to re-open remote exec (do that behind CF
+// Access, not the bare tunnel). Set TK_ALLOW_TUNNEL_EXEC=1 to override (mirrors the
+// dw-pitch-followup ALLOW_TUNNEL_SEND escape hatch).
+const ALLOW_TUNNEL_EXEC = (process.env.TK_ALLOW_TUNNEL_EXEC || '') === '1';
+function execBlockedForRemote(req, res) {
+  if (ALLOW_TUNNEL_EXEC) return false;
+  const remote = req.socket && req.socket.remoteAddress;
+  const proxied = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'];
+  if (LOOPBACK.has(remote) && !proxied) return false;    // local operator — allowed
+  json(res, 403, { error: 'execution endpoints are local-operator only over the public tunnel; run locally or enable behind CF Access' });
+  return true;
+}
+
+const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+
+// ── priority ranking + per-ticket ratings (mirrors the :9801 approvals viewer scoreGate, TK-10695) ──
+// For each OPEN/DOING/BLOCKED ticket compute 0-5 ratings + a composite priority + tier.
+// Same weighting as gated-queue-runner/server.js scoreGate: value*2.4 + urgency*2.4 + ease*1.0 + safety*0.4.
+function ticketText(t) {
+  // full corpus we score over: title + every comment + every action
+  return [t.title, ...(t.comments || []).map(c => c.text), ...(t.actions || []).map(a => a.text)].join('\n');
+}
+function nearestDeadlineDays(body) {
+  const now = Date.now(); let best = null;
+  const iso = body.match(/\b20\d\d-\d\d-\d\d\b/g) || [];
+  const named = body.match(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+20\d\d\b/gi) || [];
+  for (const s of [...iso, ...named]) {
+    const t = Date.parse(s); if (isNaN(t)) continue;
+    const d = Math.round((t - now) / 86400000);
+    if (d >= -3 && (best === null || d < best)) best = d;
+  }
+  if (/\b(today|due today|expires? today)\b/i.test(body)) best = best === null ? 0 : Math.min(best, 0);
+  return best;
+}
+function parseMaxDollars(body) {
+  let max = 0;
+  for (const m of body.matchAll(/\$\s?([\d,]+(?:\.\d+)?)\s*([kKmM])?/g)) {
+    let n = parseFloat(m[1].replace(/,/g, '')); if (isNaN(n)) continue;
+    if (m[2] && /[kK]/.test(m[2])) n *= 1e3; if (m[2] && /[mM]/.test(m[2])) n *= 1e6;
+    if (n > max) max = n;
+  }
+  return max;
+}
+function lastActivityMs(t) {
+  let ms = +new Date(t.updated_at || t.created_at || 0);
+  for (const a of (t.actions || [])) ms = Math.max(ms, +new Date(a.ts));
+  for (const c of (t.comments || [])) ms = Math.max(ms, +new Date(c.ts));
+  return ms || Date.now();
+}
+function scoreTicket(t) {
+  const b = ticketText(t);
+  const now = Date.now();
+  const money = parseMaxDollars(b);
+  const days = nearestDeadlineDays(b);
+  const idleH = (now - lastActivityMs(t)) / 3600000;                 // recency of last activity (stale = higher need)
+  const ageDays = (now - +new Date(t.created_at || now)) / 86400000; // how long it's been open
+
+  // 💰 value/impact: dollars (log) OR project/keyword stakes, whichever higher
+  let value = money >= 1e5 ? 5 : money >= 1e4 ? 4 : money >= 1e3 ? 3 : money >= 100 ? 2 : money > 0 ? 1 : 0;
+  value = Math.max(value, 2); // every open ticket has baseline stakes
+  if (/\b(revenue|prod|production|customer|customer-facing|live|go-live|launch|ship|deploy)\b/i.test(b)) value = Math.min(5, value + 1);
+  if (/\b(urgent|critical|broken|down|502|500|incident|lapse|expir|money (owed|left)|five[- ]figure)\b/i.test(b)) value = Math.min(5, value + 1);
+
+  // ⏰ urgency: deadline + staleness + age + status weighting + urgent words
+  let urgency = 1;
+  if (days !== null) urgency = days <= 1 ? 5 : days <= 3 ? 4 : days <= 7 ? 3 : days <= 30 ? 2 : 1;
+  if (idleH > 72) urgency = Math.max(urgency, 4);           // stale >3d = high need
+  else if (idleH > 24) urgency = Math.max(urgency, 3);      // stale >1d
+  if (ageDays > 14) urgency = Math.min(5, urgency + 1);     // long-open drags priority up
+  if (t.status === 'blocked') urgency = Math.min(5, urgency + 2); // blocked screams for attention
+  else if (t.status === 'doing') urgency = Math.min(5, urgency + 1);
+  if (/\b(urgent|asap|lapsing|due today|deadline|expires? (today|tomorrow)|now|immediately|incident)\b/i.test(b)) urgency = Math.min(5, urgency + 1);
+  urgency = Math.max(1, Math.min(5, urgency));
+
+  // ⚡ ease (higher = quicker / lower-friction to complete)
+  let ease = 3;
+  const wc = b.split(/\s+/).length;
+  if (/\b(reversible|one[- ]click|1[- ]click|toggle|paste|quick|small|single|read-only|one[- ]line|tweak|typo)\b/i.test(b)) ease += 1;
+  if (/\b(build|migration|scrape|onboard|rebuild|multi-part|multi-step|large|backfill|thousands|batch|refactor|overhaul|end-to-end|pipeline)\b/i.test(b)) ease -= 1;
+  if ((t.actions || []).length + (t.comments || []).length <= 1 && wc < 40) ease += 1; // short/single-action ticket
+  if (wc > 400) ease -= 1;
+  ease = Math.max(1, Math.min(5, ease));
+
+  // ✅ safety/confidence (higher = safer / more reversible)
+  let safety = 3;
+  if (/\b(reversible|restore[- ]map|verified|snapshot|dry[- ]?run|git revert|rollback|local|read-only|additive)\b/i.test(b)) safety += 1;
+  if (/\b(destructive|irreversible|delete|purge|drop |wipe|history rewrite|filter-repo|unpublish|cannot be undone|force[- ]push|prod deploy|dns|spend|send-to-list)\b/i.test(b)) safety -= 2;
+  safety = Math.max(1, Math.min(5, safety));
+
+  // composite: value + urgency dominate; ease nudges; low safety slightly demotes
+  const priority = Math.round((value * 2.4 + urgency * 2.4 + ease * 1.0 + safety * 0.4) * 10) / 10;
+  const tier = priority >= 26 ? 'high' : priority >= 18 ? 'med' : 'low';
+  return { value, urgency, ease, safety, priority, tier, money: money || 0, days };
+}
+// Attach ranking/ratings to a flat ticket list. Only open/doing/blocked get ranked
+// (done/stopped are excluded from the ranking per the brief) — those get priority 0 / no rank.
+function withRanking(list) {
+  const RANKABLE = new Set(['open', 'doing', 'blocked']);
+  const scored = [];
+  for (const t of list) {
+    if ((t.kind || 'task') === 'task' && RANKABLE.has(t.status)) {
+      const s = scoreTicket(t);
+      t.priority = s.priority; t.tier = s.tier;
+      t.ratings = { value: s.value, urgency: s.urgency, ease: s.ease, safety: s.safety };
+      t.money = s.money; t.deadlineDays = s.days;
+      scored.push(t);
+    } else {
+      t.priority = 0; t.tier = null; t.rank = null;
+      t.ratings = { value: 0, urgency: 0, ease: 0, safety: 0 };
+      t.money = 0; t.deadlineDays = null;
+    }
+  }
+  scored.sort((a, b) => b.priority - a.priority || (+new Date(b.updated_at) - +new Date(a.updated_at)));
+  scored.forEach((t, i) => { t.rank = i + 1; });
+  return list;
+}
+
+function page() {
+  const cols = { open: [], doing: [], blocked: [], done: [], stopped: [] };
+  for (const t of tickets().values()) (cols[t.status] || (cols[t.status] = [])).push(t);
+  for (const k of STATUSES) cols[k].sort((a, b) => a.updated_at < b.updated_at ? 1 : -1);
+  cols.done = cols.done.slice(0, 40);
+  cols.stopped = cols.stopped.slice(0, 40);
+  const card = t => `<div class="card" onclick="this.classList.toggle('x')">
+    <div class="cid">${t.id}${t.project ? `<span class="proj">${esc(t.project)}</span>` : ''}</div>
+    <div class="ttl">${esc(t.title)}</div>
+    <div class="meta"><span class="who">${esc(t.assignee || 'unassigned')}</span>
+      <span class="when" title="${esc(t.created_at)}">🕓 ${new Date(t.created_at).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span></div>
+    <div class="thread">${t.comments.map(c => `<div class="c k-${c.kind}"><b>${esc(c.agent)}</b> <i>${esc(c.kind)}</i> ${esc(c.text)}<span class="cts">${new Date(c.ts).toLocaleString()}</span></div>`).join('')}
+      ${t.actions.map(a => `<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${new Date(a.ts).toLocaleString()}</span></div>`).join('') || ''}
+      ${!t.comments.length && !t.actions.length ? '<div class="c none">no comments yet</div>' : ''}</div></div>`;
+  // ── Direct-message conversations, grouped into threads ──
+  const mm = messages();
+  const rootOf = mid => { let id = mid, c = mm.get(mid); const seen = new Set([id]); while (c && c.re && mm.get(c.re)) { if (seen.has(c.re)) break; seen.add(c.re); id = c.re; c = mm.get(id); } return id; };
+  const threads = new Map();
+  for (const m of mm.values()) { const r = rootOf(m.mid); (threads.get(r) || threads.set(r, []).get(r)).push(m); }
+  const convos = [...threads.values()].map(ms => ms.sort((a, b) => a.ts < b.ts ? -1 : 1))
+    .sort((a, b) => a[a.length - 1].ts < b[b.length - 1].ts ? 1 : -1).slice(0, 40);
+  const dmLine = m => `<div class="dm"><b>${esc(m.from)}</b> <span class="arw">→</span> <b>${esc(m.to)}</b>${m.ticket ? `<span class="dtk">${esc(m.ticket.replace(/^(TK-\d+).*/, '$1'))}</span>` : ''}
+    <span class="dts">${new Date(m.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
+    <div class="dtx">${esc(m.text)}</div></div>`;
+  const convoCard = ms => `<div class="convo"><div class="chd">${esc([...new Set(ms.flatMap(m => [m.from, m.to]))].filter(Boolean).join(' ⇄ '))}<span class="cnt">${ms.length} msg${ms.length > 1 ? 's' : ''}</span></div>${ms.map(dmLine).join('')}</div>`;
+  const dmPanel = `<details class="dms" open><summary>DIRECT MESSAGES <small>${mm.size} total · ${convos.length} conversations · agents talk via <code>tk dm / reply / @mention</code></small></summary>
+    <div class="convos">${convos.length ? convos.map(convoCard).join('') : '<div class="c none">no direct messages yet</div>'}</div></details>`;
+  return `<!doctype html><meta charset="utf-8"><title>Fleet Tickets</title><meta http-equiv="refresh" content="30">
+<style>
+  body{margin:0;font:14px -apple-system,sans-serif;background:#0f1115;color:#e6e6e6}
+  h1{font-size:16px;margin:0;padding:14px 18px;border-bottom:1px solid #262a33;letter-spacing:.06em}
+  h1 small{color:#8a93a5;font-weight:400;margin-left:10px}
+  .board{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;padding:14px;align-items:start}
+  .col h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#8a93a5;margin:4px 2px 8px}
+  .card{background:#171b22;border:1px solid #262a33;border-radius:8px;padding:10px 12px;margin-bottom:8px;cursor:pointer}
+  .cid{font-weight:600;color:#6db3f2;font-size:12px}.proj{float:right;color:#8a93a5;font-weight:400}
+  .ttl{margin:4px 0 6px}
+  .meta{display:flex;justify-content:space-between;font-size:11px;color:#8a93a5}
+  .when{white-space:nowrap}
+  .thread{display:none;margin-top:8px;border-top:1px dashed #2c3140;padding-top:6px}
+  .card.x .thread{display:block}
+  .c{font-size:12px;margin:4px 0;color:#c6ccd8}.c i{color:#8a93a5;font-style:normal;font-size:10px;margin:0 4px}
+  .c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.none{color:#5b6270}
+  .c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}
+  .cts{display:block;font-size:10px;color:#5b6270}
+  .col-doing .card{border-left:3px solid #6db3f2}.col-blocked .card{border-left:3px solid #e06c75}.col-done .card{opacity:.55}
+  .col-stopped .card{opacity:.4;border-left:3px solid #6b7280}.col-stopped h2{color:#9aa2b1}
+  .dms{margin:0 14px 6px;background:#141821;border:1px solid #262a33;border-radius:8px}
+  .dms>summary{cursor:pointer;padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#c9a4f2}
+  .dms>summary small{text-transform:none;letter-spacing:0;color:#8a93a5;margin-left:8px;font-size:11px}
+  .dms code{color:#c9a4f2;background:#1c2130;padding:1px 4px;border-radius:4px}
+  .convos{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:10px;padding:4px 14px 14px}
+  .convo{background:#171b22;border:1px solid #262a33;border-left:3px solid #a06ef2;border-radius:8px;padding:8px 10px}
+  .chd{font-size:11px;color:#c9a4f2;font-weight:600;margin-bottom:6px}.chd .cnt{float:right;color:#5b6270;font-weight:400}
+  .dm{font-size:12px;margin:5px 0;padding-top:5px;border-top:1px dashed #2c3140}.dm:first-of-type{border-top:0}
+  .dm .arw{color:#8a93a5;margin:0 3px}.dm b{color:#cdd4e0}
+  .dm .dtk{color:#6db3f2;font-size:10px;margin-left:6px}.dm .dts{float:right;color:#5b6270;font-size:10px}
+  .dm .dtx{color:#c6ccd8;margin-top:2px}
+</style>
+<h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / dm / inbox / reply / @mention / take / done</small><a href="/office" style="float:right;color:#c9a4f2;text-decoration:none;font-size:13px;border:1px solid #2c3140;padding:4px 10px;border-radius:6px">🏢 3D Office →</a></h1>
+${dmPanel}
+<div class="board">${STATUSES.map(s => `<div class="col col-${s}"><h2>${s} (${cols[s].length})</h2>${cols[s].map(card).join('') || '<div class="c none">empty</div>'}</div>`).join('')}</div>`;
+}
+
+http.createServer((req, res) => {
+  if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
+  const locked = lockoutGate(req);
+  if (locked) { res.writeHead(locked.code, { 'Retry-After': String(locked.retry) }); return res.end(locked.msg); }
+  if (req.headers.authorization !== AUTH) { noteAuth(req, false); res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
+  noteAuth(req, true);
+
+  // ── writes / actions (all auth-gated; ids resolved server-side from the real store) ──
+
+  // Run Now — open one iTerm2 Claude session per selected ticket (staggered so iTerm doesn't drop windows).
+  if (req.method === 'POST' && req.url === '/api/run') {
+    if (execBlockedForRemote(req, res)) return;
+    return readJson(req, body => {
+      if (!body) return json(res, 400, { error: 'bad json' });
+      const profile = effectiveRunProfile(body.profile);
+      if (!RUN_PROFILES.has(profile)) return json(res, 400, { error: 'invalid run profile' });
+      const map = tickets(); const ids = resolveList(body.ids, map);
+      const launched = [], skipped = [];
+      ids.forEach((id, i) => {
+        const t = map.get(id);
+        if (t && t.status === 'stopped') { skipped.push({ id, why: 'stopped' }); return; }
+        if (t && t.status === 'doing') { skipped.push({ id, why: 'already-doing' }); return; }
+        if (t && (t.kind || 'task') !== 'task') { skipped.push({ id, why: 'designation-' + t.kind }); return; }
+        let cwd = os.homedir();
+        const proj = t && t.project;
+        if (proj && /^[a-z0-9._-]+$/i.test(proj)) { const p = path.join(os.homedir(), 'Projects', proj); if (fs.existsSync(p)) cwd = p; }
+        setTimeout(() => execFile('bash', [RUN_SH, id, cwd, profile], { timeout: 25000 }, (err) => {
+          // Log the REAL outcome from the launch callback — never an optimistic "launched" before the window opens.
+          // If osascript/iTerm fails (app quit, Automation permission denied), surface it on the board instead of a false success.
+          withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board',
+            text: err ? ('⚠ RUN NOW failed to launch iTerm2 session — ' + String(err.message || err).split('\n')[0])
+                      : `▶ RUN NOW — launched iTerm2 session from the board · profile=${profile}` }));
+        }), i * 1300); // stagger ~1.3s
+        launched.push(id);
+      });
+      json(res, 200, { launched, skipped, profile });
+    });
+  }
+  // Stop Forever — mark selected tickets TicketStopped (status 'stopped'); reversible via /api/reopen.
+  if (req.method === 'POST' && req.url === '/api/stop') {
+    return readJson(req, body => {
+      if (!body) return json(res, 400, { error: 'bad json' });
+      const map = tickets(); const ids = resolveList(body.ids, map);
+      withLock(() => { for (const id of ids) {
+        append({ ts: new Date().toISOString(), type: 'status', id, status: 'stopped', agent: 'board' });
+        append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: '⛔ TicketStopped — stopped forever from the board' });
+      } });
+      json(res, 200, { stopped: ids });
+    });
+  }
+  // Reopen — un-stop (or un-close) selected tickets back to 'open'.
+  if (req.method === 'POST' && req.url === '/api/reopen') {
+    return readJson(req, body => {
+      if (!body) return json(res, 400, { error: 'bad json' });
+      const map = tickets(); const ids = resolveList(body.ids, map);
+      withLock(() => { for (const id of ids) {
+        append({ ts: new Date().toISOString(), type: 'status', id, status: 'open', agent: 'board' });
+        append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: '↩ reopened from the board' });
+      } });
+      json(res, 200, { reopened: ids });
+    });
+  }
+  // DTD — trigger a batched run-now sweep (POST) / read the latest verdicts + running state (GET).
+  if (req.method === 'POST' && req.url === '/api/dtd') {
+    if (execBlockedForRemote(req, res)) return;
+    return readJson(req, body => {
+      if (fs.existsSync(DTD_RUNNING)) return json(res, 200, { started: false, already: true });
+      const args = [DTD_RUN];
+      const explicit = body && Array.isArray(body.ids) && body.ids.length ? resolveList(body.ids, tickets()) : [];
+      if (explicit.length) args.push(...explicit); else args.push('--recent');
+      try { const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', cwd: __dirname }); child.unref(); }
+      catch (e) { return json(res, 500, { started: false, error: e.message }); }
+      json(res, 200, { started: true, ids: explicit.length ? explicit : 'recent' });
+    });
+  }
+  if (req.method === 'GET' && req.url === '/api/dtd') {
+    let verdicts = null, running = false, runningInfo = null;
+    try { verdicts = JSON.parse(fs.readFileSync(VERDICTS, 'utf8')); } catch {}
+    try { runningInfo = JSON.parse(fs.readFileSync(DTD_RUNNING, 'utf8')); running = true; } catch {}
+    return json(res, 200, { running, runningInfo, verdicts });
+  }
+
+  // shared nav-agent drop-in (grid-controls standard) — static assets
+  if (req.url === '/nav-agent/nav-agent.js' || req.url === '/nav-agent/nav-agent.css') {
+    const file = path.join(__dirname, req.url.replace(/^\//, ''));
+    const type = req.url.endsWith('.css') ? 'text/css' : 'application/javascript';
+    return fs.readFile(file, (e, buf) => {
+      if (e) { res.writeHead(404); return res.end('nav-agent asset missing'); }
+      res.writeHead(200, { 'Content-Type': type + '; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(buf);
+    });
+  }
+  if (req.url === '/office' || req.url === '/office.html') {
+    return fs.readFile(OFFICE_HTML, (e, buf) => {
+      if (e) { res.writeHead(500); return res.end('office view missing'); }
+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(buf);
+    });
+  }
+  if (req.url === '/api/running') { return getRunning(d => { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(d)); }); }
+  if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(withRanking([...tickets().values()]))); }
+  if (req.url === '/api/agents') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(ticketAgents())); }
+  if (req.url === '/api/skills') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(installedSkills())); }
+  if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify([...messages().values()])); }
+  if (req.url === '/kanban') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(page()); }
+  // default (/) = the adjustable-columns TABLE view (Steve's list-builds rule, 2026-08-10)
+  return fs.readFile(BOARD_HTML, (e, buf) => {
+    if (e) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(page()); } // fall back to kanban
+    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(buf);
+  });
+}).listen(PORT, '127.0.0.1', () => {
+  console.log('ticket board on :' + PORT);
+  getRunning(() => {});                          // warm the pm2/sessions cache so first client fetch is instant
+  setInterval(() => getRunning(() => {}), 4500); // keep it warm ahead of the 5s TTL
+});

← 8ea7c71d auto-data-snapshot: 2026-09-11T11:23:31 (3 data files) — dat  ·  back to Ticket System  ·  TK-11372: prove the root cause and correct the pipe-EOF hypo a5e9b14e →