← 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
M server.jsA verification/tk11372/server.pre-activation-backup.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →