← back to Answer Cockpit
TK-11793: backend (server.js + lib/queue,transcript,memo,writeback,audit) + start.sh; drop .deploy.conf (local-only tool, never deploy)
a0798f03a3196d71d7cc17dfd5cd17990fffade0 · 2026-09-15 18:42:55 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
D .deploy.confA lib/audit.jsA lib/memo.jsA lib/queue.jsA lib/transcript.jsA lib/writeback.jsM public/index.htmlA server.jsA start.sh
Diff
commit a0798f03a3196d71d7cc17dfd5cd17990fffade0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 15 18:42:55 2026 -0700
TK-11793: backend (server.js + lib/queue,transcript,memo,writeback,audit) + start.sh; drop .deploy.conf (local-only tool, never deploy)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
.deploy.conf | 5 -
lib/audit.js | 98 +++++++++++++++++++
lib/memo.js | 159 +++++++++++++++++++++++++++++++
lib/queue.js | 179 +++++++++++++++++++++++++++++++++++
lib/transcript.js | 277 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib/writeback.js | 199 +++++++++++++++++++++++++++++++++++++++
public/index.html | 5 +-
server.js | 222 +++++++++++++++++++++++++++++++++++++++++++
start.sh | 57 +++++++++++
9 files changed, 1194 insertions(+), 7 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
deleted file mode 100644
index d7fe65b..0000000
--- a/.deploy.conf
+++ /dev/null
@@ -1,5 +0,0 @@
-# Answer Cockpit deploy config. LOCAL-ONLY tool (Mac2); not for Kamatera by default.
-PROJECT_NAME=answer-cockpit
-DEPLOY_PATH=/root/Projects/answer-cockpit
-HEALTH_URL=http://127.0.0.1:9805/api/health
-PORT=9805
diff --git a/lib/audit.js b/lib/audit.js
new file mode 100644
index 0000000..d6f7a08
--- /dev/null
+++ b/lib/audit.js
@@ -0,0 +1,98 @@
+'use strict';
+// audit.js — append-only audit trail + PASS/WARN/FAIL heartbeat.
+//
+// data/audit.jsonl one line per write-back attempt INCLUDING refusals
+// {ts,action,tty,ticket,color,text,memoFile,ok,err}
+// data/latest.json fleet-health-rollup heartbeat. verdict AND status are
+// one of exactly PASS | WARN | FAIL (CLAUDE.md TK-10546).
+// PASS scan ok + iTerm reachable (measured)
+// WARN empty scan / terminal_api unavailable — an
+// unmeasured input is never green (TK-11431 #1)
+// FAIL the last write-back threw (iTerm unreachable)
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const AUDIT = path.join(DATA_DIR, 'audit.jsonl');
+const LATEST = path.join(DATA_DIR, 'latest.json');
+
+function ensureDir() { try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch {} }
+
+// Last write-back outcome, kept in memory so the heartbeat can reflect it.
+const state = { lastWriteback: null, lastScan: null };
+
+function audit(entry) {
+ ensureDir();
+ const line = {
+ ts: new Date().toISOString(),
+ action: entry.action || 'unknown',
+ tty: entry.tty || null,
+ ticket: entry.ticket || null,
+ color: entry.color || null,
+ text: entry.text == null ? null : String(entry.text).slice(0, 2000),
+ memoFile: entry.memoFile || null,
+ ok: !!entry.ok,
+ err: entry.err ? String(entry.err).slice(0, 500) : null,
+ };
+ if (entry.dry) line.dry = true;
+ if (entry.refused) line.refused = true;
+ try { fs.appendFileSync(AUDIT, JSON.stringify(line) + '\n'); } catch {}
+ // Only real osascript attempts (not guard refusals, not dry runs) move the
+ // FAIL needle — a 403 refusal is the cockpit working, not iTerm failing.
+ if (entry.osascriptAttempted) {
+ state.lastWriteback = { ts: line.ts, ok: line.ok, err: line.err, action: line.action };
+ }
+ return line;
+}
+
+function tailAudit(n = 20) {
+ try {
+ const txt = fs.readFileSync(AUDIT, 'utf8');
+ const lines = txt.split('\n').filter(Boolean);
+ return lines.slice(-n).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+ } catch { return []; }
+}
+
+/**
+ * heartbeat(scan) — called after every scan. scan = { rows, stale, terminalApi, source, error }
+ * Three states, never two: MEASURED-GOOD / MEASURED-BAD / NOT-MEASURED (WARN).
+ */
+function heartbeat(scan) {
+ ensureDir();
+ state.lastScan = { ts: new Date().toISOString(), n: scan.rows ? scan.rows.length : 0, stale: !!scan.stale, terminalApi: scan.terminalApi || 'unknown', source: scan.source || null };
+ let verdict = 'PASS';
+ let reason = 'scan ok, iTerm reachable';
+ const population = scan.rows ? scan.rows.length : 0;
+ if (state.lastWriteback && state.lastWriteback.ok === false) {
+ verdict = 'FAIL';
+ reason = 'last write-back threw: ' + (state.lastWriteback.err || 'unknown');
+ } else if (scan.error) {
+ verdict = 'WARN';
+ reason = 'scan failed (NOT-MEASURED): ' + scan.error;
+ } else if (scan.stale || scan.terminalApi === 'unavailable') {
+ verdict = 'WARN';
+ reason = 'terminal_api unavailable / stale scan (NOT-MEASURED, never green)';
+ } else if (population === 0) {
+ verdict = 'WARN';
+ reason = 'scan returned 0 rows (0 of 0 is indistinguishable from broken — NOT-MEASURED)';
+ }
+ const doc = {
+ ts: state.lastScan.ts,
+ skill: 'answer-cockpit',
+ verdict, status: verdict,
+ reason,
+ population,
+ observed_needs_steve: scan.needsSteve == null ? null : scan.needsSteve,
+ terminal_api: state.lastScan.terminalApi,
+ scan_source: state.lastScan.source,
+ stale: state.lastScan.stale,
+ last_writeback: state.lastWriteback,
+ cost: '$0 (local)',
+ };
+ try { fs.writeFileSync(LATEST, JSON.stringify(doc, null, 2) + '\n'); } catch {}
+ return doc;
+}
+
+function latest() { try { return JSON.parse(fs.readFileSync(LATEST, 'utf8')); } catch { return null; } }
+
+module.exports = { audit, tailAudit, heartbeat, latest, state, DATA_DIR, AUDIT, LATEST };
diff --git a/lib/memo.js b/lib/memo.js
new file mode 100644
index 0000000..5e0ac76
--- /dev/null
+++ b/lib/memo.js
@@ -0,0 +1,159 @@
+'use strict';
+// memo.js — pending-approval reader + decide/undo, MIRRORING
+// ~/Projects/approvals-viewer/server.js:6-11,61-70 semantics:
+// approve → mv to _approved/ ; block(reject) → mv to _rejected/ ; revise → note only
+// every decision appended to _decisions.jsonl {ts,file,decision,note}
+// PLUS a reversible-ledger line in executed-reversible/ledger.jsonl with undo_cmd.
+// HARD RULE: this module NEVER reads-to-execute or executes ```ungate-run``` blocks
+// or `! ` paste lines. It only classifies text. Copy is the client's only verb.
+const fs = require('fs');
+const path = require('path');
+
+const HOME = process.env.HOME || require('os').homedir();
+const QUEUE = path.join(HOME, '.claude/yolo-queue/pending-approval');
+const APPROVED = path.join(QUEUE, '_approved');
+const REJECTED = path.join(QUEUE, '_rejected');
+const DECISIONS = path.join(QUEUE, '_decisions.jsonl');
+const LEDGER = path.join(HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+
+const FILE_RE = /^[\w.\-]+\.md$/;
+const TK_RE = /TK-\d{2,6}/;
+const REC_RE = /^\*\*(APPROVE|REVISE|BLOCK)\*\*/gm;
+const PASTE_RE = /^!\s+.+$/gm;
+
+function safeFile(file) {
+ // top-level, no `_` prefix, no traversal
+ return typeof file === 'string' && FILE_RE.test(file) && !file.startsWith('_') && !file.includes('/') && !file.includes('..');
+}
+
+function parse(file, body, st) {
+ const title = (body.match(/^#\s+(.+)$/m) || [, file.replace(/\.md$/, '')])[1].slice(0, 160);
+ let ticket = (file.match(TK_RE) || [])[0] || null;
+ if (!ticket) ticket = (body.match(TK_RE) || [])[0] || null;
+ let recommendation = null;
+ let m; REC_RE.lastIndex = 0;
+ while ((m = REC_RE.exec(body)) !== null) recommendation = m[1]; // last one wins
+ const pasteLines = (body.match(PASTE_RE) || []).map((l) => l.trim()).slice(0, 20);
+ const hasUngateRun = /```ungate-run/.test(body);
+ // createdAt: birthtime when available (macOS), else mtime
+ const created = st.birthtime && st.birthtime.getTime() > 0 ? st.birthtime : st.mtime;
+ return {
+ file,
+ path: path.join(QUEUE, file),
+ title,
+ ticket,
+ recommendation,
+ hasUngateRun,
+ pasteLines,
+ createdAt: created.toISOString(),
+ mtime: st.mtime.toISOString(),
+ size: st.size,
+ excerpt: body.slice(0, 1500),
+ };
+}
+
+function list() {
+ let names = [];
+ try { names = fs.readdirSync(QUEUE); } catch { return []; }
+ const out = [];
+ for (const f of names) {
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
+ const fp = path.join(QUEUE, f);
+ let st; try { st = fs.statSync(fp); } catch { continue; }
+ if (!st.isFile()) continue;
+ let body; try { body = fs.readFileSync(fp, 'utf8'); } catch { continue; }
+ out.push(parse(f, body, st));
+ }
+ out.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
+ return out;
+}
+
+function byTicket(memos) {
+ const map = new Map();
+ for (const m of memos) {
+ if (!m.ticket) continue;
+ if (!map.has(m.ticket)) map.set(m.ticket, []);
+ map.get(m.ticket).push(m);
+ }
+ return map;
+}
+
+function noClobber(dir, file) {
+ let dest = path.join(dir, file);
+ if (fs.existsSync(dest)) dest = path.join(dir, file.replace(/\.md$/, '') + '.' + Date.now() + '.md');
+ return dest;
+}
+
+function appendJsonl(fp, obj) {
+ try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch {}
+ fs.appendFileSync(fp, JSON.stringify(obj) + '\n');
+}
+
+/**
+ * decide({file, decision, note}) → {ok, moved, dest, undo_cmd}
+ * decision ∈ approve | block | revise. `reject` accepted as alias of block.
+ */
+function decide({ file, decision, note }) {
+ if (!safeFile(file)) throw new Error('bad file');
+ if (decision === 'reject') decision = 'block';
+ if (!['approve', 'block', 'revise'].includes(decision)) throw new Error('bad decision');
+ const src = path.join(QUEUE, file);
+ if (!fs.existsSync(src)) throw new Error('gone');
+ const st = fs.statSync(src); if (!st.isFile()) throw new Error('not a file');
+ for (const d of [APPROVED, REJECTED]) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
+ const ticket = (file.match(TK_RE) || [])[0] || null;
+ const ts = new Date().toISOString();
+ let dest = null, undo_cmd = null;
+ if (decision === 'approve') dest = noClobber(APPROVED, file);
+ else if (decision === 'block') dest = noClobber(REJECTED, file);
+ if (dest) {
+ fs.renameSync(src, dest);
+ undo_cmd = `mv ${JSON.stringify(dest)} ${JSON.stringify(src)}`;
+ }
+ const cleanNote = note ? String(note).slice(0, 500) : '';
+ appendJsonl(DECISIONS, { ts, file, decision, note: cleanNote, via: 'answer-cockpit', dest: dest ? path.basename(dest) : null });
+ appendJsonl(LEDGER, {
+ ts, agent: 'answer-cockpit', ticket: ticket || 'TK-11793',
+ action: `memo ${decision}: ${file}` + (dest ? ` → ${path.relative(QUEUE, dest)}` : ' (note only)'),
+ blast_radius: 1,
+ undo_cmd: undo_cmd || `# revise is note-only; nothing to undo (see ${DECISIONS})`,
+ verify: dest ? `test -f ${JSON.stringify(dest)}` : `tail -1 ${DECISIONS}`,
+ note: cleanNote || undefined,
+ });
+ return { ok: true, decision, moved: !!dest, dest, undo_cmd, ticket };
+}
+
+/** undo({file, decision}) — mirror of :9795 /api/undo. decision ∈ approve|block (reject alias). */
+function undo({ file, decision }) {
+ if (!safeFile(file)) throw new Error('bad file');
+ if (decision === 'reject') decision = 'block';
+ if (!['approve', 'block'].includes(decision)) throw new Error('bad decision');
+ const from = decision === 'approve' ? APPROVED : REJECTED;
+ // exact name first, else the newest no-clobber-suffixed sibling
+ let src = path.join(from, file);
+ if (!fs.existsSync(src)) {
+ const base = file.replace(/\.md$/, '');
+ let cands = [];
+ try { cands = fs.readdirSync(from).filter((n) => n.startsWith(base + '.') && n.endsWith('.md')); } catch {}
+ cands.sort().reverse();
+ if (cands.length) src = path.join(from, cands[0]);
+ }
+ const ts = new Date().toISOString();
+ let restored = false, dest = null;
+ if (fs.existsSync(src)) {
+ dest = noClobber(QUEUE, file);
+ fs.renameSync(src, dest);
+ restored = true;
+ }
+ appendJsonl(DECISIONS, { ts, file, decision: 'undo:' + decision, via: 'answer-cockpit', restored });
+ appendJsonl(LEDGER, {
+ ts, agent: 'answer-cockpit', ticket: (file.match(TK_RE) || [])[0] || 'TK-11793',
+ action: `memo undo:${decision}: ${file}` + (restored ? ' restored to queue' : ' (nothing to restore)'),
+ blast_radius: 1,
+ undo_cmd: restored ? `mv ${JSON.stringify(dest)} ${JSON.stringify(src)}` : '# no-op',
+ verify: restored ? `test -f ${JSON.stringify(dest)}` : '# no-op',
+ });
+ return { ok: true, restored, dest };
+}
+
+module.exports = { list, byTicket, decide, undo, safeFile, QUEUE, APPROVED, REJECTED, DECISIONS, LEDGER };
diff --git a/lib/queue.js b/lib/queue.js
new file mode 100644
index 0000000..fb8b5eb
--- /dev/null
+++ b/lib/queue.js
@@ -0,0 +1,179 @@
+'use strict';
+// queue.js — scan → needs-Steve items, urgency sort, 4s cache, tty allowlist.
+//
+// scan() python3 ~/Projects/terminal-status/terminal_status.py scan --json
+// fallback: bash ~/.claude/skills/allcolordots/allcolordots.sh --json
+// 4s cache. Returns {rows, stale, terminalApi, source, error, scannedAt}
+// freshLive() UNCACHED scan → Map tty→row (POST guards use this, never the cache)
+// build(opts) filter needs-Steve colors {lightblue,orange,purple,yellow}, live only,
+// sort variant=="stopped" first then PRIORITY, attach rgb/emoji from
+// COLORS (terminal_status.py:48-61), merge memos by TK, resolve detail.
+const path = require('path');
+const { execFile } = require('child_process');
+const transcript = require('./transcript');
+const memo = require('./memo');
+const audit = require('./audit');
+const writeback = require('./writeback');
+
+const HOME = process.env.HOME || require('os').homedir();
+const TS_PY = path.join(HOME, 'Projects/terminal-status/terminal_status.py');
+const ALL_SH = path.join(HOME, '.claude/skills/allcolordots/allcolordots.sh');
+const CACHE_MS = 4000;
+const SCAN_TIMEOUT_MS = 45000;
+
+// copied from terminal_status.py:48-63
+const COLORS = {
+ green: { emoji: '🟢', rgb: [0, 200, 83], meaning: 'WORKING' },
+ yellow: { emoji: '🟡', rgb: [255, 204, 0], meaning: 'DIRECTION?' },
+ purple: { emoji: '🟣', rgb: [148, 0, 211], meaning: 'GATED' },
+ orange: { emoji: '🟠', rgb: [255, 140, 0], meaning: 'PASTE waiting' },
+ pink: { emoji: '🩷', rgb: [255, 105, 180], meaning: 'PARKED' },
+ lightblue: { emoji: '🔵', rgb: [0, 176, 240], meaning: 'NEEDS STEVE' },
+ none: { emoji: '', rgb: [0, 0, 0], meaning: '' },
+};
+const PRIORITY = { lightblue: 0, orange: 1, purple: 2, yellow: 3, green: 4, pink: 5, none: 6 };
+const NEEDS_STEVE = new Set(['lightblue', 'orange', 'purple', 'yellow']);
+const TTY_RE = /^ttys\d{3}$/;
+
+let cached = null; // {ts, result}
+let inflight = null;
+
+function run(cmd, args, timeout) {
+ return new Promise((resolve) => {
+ execFile(cmd, args, { timeout, maxBuffer: 8 << 20, env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' } }, (err, stdout, stderr) => {
+ if (err) return resolve({ ok: false, err: (stderr || err.message).trim().slice(0, 300), stdout: stdout || '' });
+ resolve({ ok: true, stdout: stdout || '', err: null });
+ });
+ });
+}
+
+function normalizeAllcolordotsRow(r) {
+ return {
+ tty: r.tty, pid: r.pid || '', runtime: 'unknown', live: !!r.live, ticket: (String(r.label || '').match(/TK-\d{2,6}/) || [])[0] || '',
+ owner: { tty: r.tty, pid: r.pid ? parseInt(r.pid, 10) : null, runtime: 'unknown', started: null },
+ color: r.color, label: r.label || '', variant: '', updated_at: null, revision: null, warnings: ['fallback_scanner'],
+ terminal_api: 'unknown',
+ };
+}
+
+async function rawScan() {
+ const scannedAt = new Date().toISOString();
+ let r = await run('python3', [TS_PY, 'scan', '--json'], SCAN_TIMEOUT_MS);
+ if (r.ok) {
+ try {
+ const rows = JSON.parse(r.stdout);
+ if (Array.isArray(rows)) {
+ const terminalApi = rows.length ? (rows.every((x) => x.terminal_api === 'available') ? 'available' : (rows.some((x) => x.terminal_api === 'unavailable') ? 'unavailable' : 'mixed')) : 'unknown';
+ return { rows, stale: terminalApi === 'unavailable' || rows.length === 0, terminalApi, source: 'terminal_status.py', error: null, scannedAt };
+ }
+ } catch (e) { r = { ok: false, err: 'terminal_status.py bad json: ' + e.message }; }
+ }
+ const primaryErr = r.err;
+ const f = await run('bash', [ALL_SH, '--json'], SCAN_TIMEOUT_MS);
+ if (f.ok) {
+ try {
+ const rows = JSON.parse(f.stdout).map(normalizeAllcolordotsRow);
+ // a fallback that substitutes a different scanner is NEVER a clean PASS → stale:true
+ return { rows, stale: true, terminalApi: 'unknown', source: 'allcolordots.sh (fallback)', error: 'primary scanner failed: ' + primaryErr, scannedAt };
+ } catch (e) { return { rows: [], stale: true, terminalApi: 'unknown', source: 'none', error: 'both scanners failed: ' + primaryErr + ' / ' + e.message, scannedAt }; }
+ }
+ return { rows: [], stale: true, terminalApi: 'unknown', source: 'none', error: 'both scanners failed: ' + primaryErr + ' / ' + f.err, scannedAt };
+}
+
+async function scan() {
+ if (cached && Date.now() - cached.ts < CACHE_MS) return cached.result;
+ if (inflight) return inflight;
+ inflight = rawScan().then((res) => {
+ cached = { ts: Date.now(), result: res };
+ inflight = null;
+ res.needsSteve = res.rows.filter((x) => x.live && NEEDS_STEVE.has(x.color)).length;
+ audit.heartbeat(res);
+ return res;
+ }).catch((e) => { inflight = null; const res = { rows: [], stale: true, terminalApi: 'unknown', source: 'none', error: String(e.message), scannedAt: new Date().toISOString() }; audit.heartbeat(res); return res; });
+ return inflight;
+}
+
+/** freshLive() — UNCACHED. Map tty → row for live rows. Used by every POST guard. */
+async function freshLive() {
+ const res = await rawScan();
+ cached = { ts: Date.now(), result: res };
+ res.needsSteve = res.rows.filter((x) => x.live && NEEDS_STEVE.has(x.color)).length;
+ audit.heartbeat(res);
+ const live = new Map();
+ for (const r of res.rows) if (r.live && TTY_RE.test(String(r.tty))) live.set(r.tty, r);
+ return { scan: res, live };
+}
+
+function keyOf(row) { return `${row.tty}|${row.ticket || '-'}|${row.color}`; }
+
+function toIso(started) {
+ if (!started) return null;
+ const t = Date.parse(started);
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
+}
+
+function kindOf(row, detail, session) {
+ const c = row.color;
+ if (c === 'purple') return 'gated';
+ if (c === 'orange') return 'paste';
+ if (c === 'lightblue') return 'needs-steve';
+ if (c === 'yellow') return detail && detail.question ? 'question' : 'text';
+ return 'text';
+}
+
+function buildItem(row, memosByTk, opts) {
+ const color = COLORS[row.color] || COLORS.none;
+ const session = transcript.resolve(row);
+ const detail = session.detail; // null when ambiguous/none
+ const warnings = [...(row.warnings || [])];
+ if (row.runtime === 'codex') warnings.push('runtime:codex');
+ if (session.confidence === 'ambiguous') warnings.push(`transcript ambiguous (${session.candidates.length} candidates) — label only`);
+ if (session.confidence === 'none') warnings.push('transcript not found — label only');
+ if (row.color === 'yellow' && detail && !detail.question && detail.answeredQuestion) warnings.push('stale dot? (question already answered)');
+ const memos = row.ticket ? (memosByTk.get(row.ticket) || []) : [];
+ const kind = kindOf(row, detail, session);
+ return {
+ key: keyOf(row),
+ tty: row.tty, pid: row.pid, runtime: row.runtime || 'unknown',
+ color: row.color, rgb: color.rgb, emoji: color.emoji, meaning: color.meaning,
+ ticket: row.ticket || null, label: row.label || '', variant: row.variant || '',
+ kind,
+ createdAt: toIso(row.owner && row.owner.started),
+ updatedAt: row.updated_at || null,
+ session: { sessionId: session.sessionId, transcriptPath: session.transcriptPath, cwd: session.cwd, confidence: session.confidence, how: session.how, candidates: session.candidates.length },
+ lastText: detail ? detail.lastText : null,
+ question: detail ? detail.question : null,
+ pasteCmd: detail && detail.pasteCmd ? detail.pasteCmd : (memos[0] && memos[0].pasteLines[0]) || null,
+ memo: memos[0] || null,
+ memos,
+ warnings,
+ selfTty: row.tty === writeback.SELF_TTY,
+ };
+}
+
+/**
+ * build({orphans}) → { items, remaining, orphanMemos, scannedAt, cost, stale, source, error }
+ */
+async function build(opts = {}) {
+ const res = await scan();
+ const memos = memo.list();
+ const byTk = memo.byTicket(memos);
+ const rows = res.rows.filter((r) => r.live && NEEDS_STEVE.has(r.color) && TTY_RE.test(String(r.tty)) && r.tty !== writeback.SELF_TTY);
+ rows.sort((a, b) => (a.variant === 'stopped' ? 0 : 1) - (b.variant === 'stopped' ? 0 : 1) || (PRIORITY[a.color] ?? 9) - (PRIORITY[b.color] ?? 9) || String(a.tty).localeCompare(String(b.tty)));
+ const items = rows.map((r) => buildItem(r, byTk, opts));
+ const linked = new Set(items.map((i) => i.ticket).filter(Boolean));
+ const orphanMemos = opts.orphans ? memos.filter((m) => !m.ticket || !linked.has(m.ticket)).map((m) => ({ ...m, excerpt: m.excerpt.slice(0, 600) })) : [];
+ return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
+}
+
+/** one item, fresh (uncached scan) — for the post-answer confirm. */
+async function item(tty) {
+ transcript._cache.delete && [...transcript._cache.keys()].forEach((k) => transcript._cache.delete(k));
+ const { scan: res, live } = await freshLive();
+ const row = live.get(tty);
+ if (!row) return { item: null, gone: true, scannedAt: res.scannedAt, stale: !!res.stale };
+ const memos = memo.list();
+ return { item: buildItem(row, memo.byTicket(memos), {}), gone: false, scannedAt: res.scannedAt, stale: !!res.stale };
+}
+
+module.exports = { scan, freshLive, build, item, keyOf, COLORS, PRIORITY, NEEDS_STEVE, TTY_RE };
diff --git a/lib/transcript.js b/lib/transcript.js
new file mode 100644
index 0000000..de1e8d9
--- /dev/null
+++ b/lib/transcript.js
@@ -0,0 +1,277 @@
+'use strict';
+// transcript.js — tty → transcript ranked resolver + 256KB tail parser.
+//
+// Resolution order (plan "Detail resolution"):
+// 0. ~/.claude/answer-cockpit/map/<tty>.json {sessionId|transcriptPath} → exact
+// 1. ~/.claude/sessions/<pid>.json → sessionId → ~/.claude/projects/*/<sid>.jsonl → exact
+// 2. lsof -a -p <pid> -d cwd -Fn → cwd → candidate dirs whose newest file carries
+// "cwd":"<cwd>" (fast path: mangled name; NEVER decode dash-names)
+// 3. candidates = jsonl mtime ≥ Date.parse(owner.started) − 60s, mtime desc.
+// 1 → likely. >1 → pending-state validator on each 256KB tail → 1 survivor
+// → likely, else ambiguous (NO lastText — never render another session's
+// question). 0 → none.
+// Cache 30s per pid; re-resolve when updated_at changes.
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const HOME = process.env.HOME || require('os').homedir();
+const PROJECTS = path.join(HOME, '.claude/projects');
+const SESSIONS = path.join(HOME, '.claude/sessions');
+const MAP_DIR = path.join(HOME, '.claude/answer-cockpit/map');
+const TAIL_BYTES = 256 * 1024;
+const CACHE_MS = 30 * 1000;
+const cache = new Map(); // pid → {key, ts, result}
+
+// ---- tail parser (copied from claude-control-center/server.js:337-356 tailJsonl) ----
+function tailJsonl(filepath, n = TAIL_BYTES) {
+ let buf;
+ try {
+ const fd = fs.openSync(filepath, 'r');
+ const st = fs.fstatSync(fd);
+ const len = Math.min(n, st.size);
+ buf = Buffer.alloc(len);
+ fs.readSync(fd, buf, 0, len, st.size - len);
+ fs.closeSync(fd);
+ var full = len === st.size;
+ } catch { return []; }
+ const text = buf.toString('utf8');
+ // Drop a possibly-incomplete first line (unless we read the whole file)
+ const raw = text.split('\n');
+ const lines = (full ? raw : raw.slice(1)).filter(Boolean);
+ const out = [];
+ for (const l of lines) { try { out.push(JSON.parse(l)); } catch {} }
+ return out;
+}
+
+function blocksOf(turn) {
+ const c = turn && turn.message && turn.message.content;
+ return Array.isArray(c) ? c : [];
+}
+
+/**
+ * parseTail(filepath) → { question, lastText, pasteCmd, lastTs, sessionId, cwd, answeredQuestion }
+ * question = last AskUserQuestion tool_use with NO later tool_result for its id
+ * lastText = last assistant {type:"text"} (≤2000 chars)
+ * pasteCmd = first /^!\s+/m line found in assistant text (scanning newest→oldest)
+ */
+function parseTail(filepath) {
+ const turns = tailJsonl(filepath);
+ const results = new Set();
+ let lastAsk = null, lastText = null, lastTs = null, sessionId = null, cwd = null, pasteCmd = null;
+ for (const t of turns) {
+ if (t.sessionId && !sessionId) sessionId = t.sessionId;
+ if (t.cwd && !cwd) cwd = t.cwd;
+ if (t.timestamp) lastTs = t.timestamp;
+ for (const b of blocksOf(t)) {
+ if (!b || typeof b !== 'object') continue;
+ if (b.type === 'tool_result' && b.tool_use_id) results.add(b.tool_use_id);
+ if (t.type === 'assistant' && b.type === 'tool_use' && b.name === 'AskUserQuestion') lastAsk = { id: b.id, input: b.input, ts: t.timestamp };
+ if (t.type === 'assistant' && b.type === 'text' && typeof b.text === 'string' && b.text.trim()) lastText = b.text;
+ }
+ }
+ // pasteCmd: first `! ` line in the most recent assistant text that has one
+ for (let i = turns.length - 1; i >= 0 && !pasteCmd; i--) {
+ const t = turns[i]; if (t.type !== 'assistant') continue;
+ for (const b of blocksOf(t)) {
+ if (b && b.type === 'text' && typeof b.text === 'string') {
+ const m = b.text.match(/^!\s+.+$/m);
+ if (m) { pasteCmd = m[0].trim(); break; }
+ }
+ }
+ }
+ let question = null, answeredQuestion = false;
+ if (lastAsk) {
+ if (results.has(lastAsk.id)) answeredQuestion = true;
+ else {
+ const qs = (lastAsk.input && Array.isArray(lastAsk.input.questions)) ? lastAsk.input.questions : [];
+ question = {
+ toolUseId: lastAsk.id,
+ askedAt: lastAsk.ts || null,
+ questions: qs.map((q) => ({
+ question: String(q.question || ''),
+ header: String(q.header || ''),
+ multiSelect: !!q.multiSelect,
+ options: Array.isArray(q.options) ? q.options.map((o) => ({ label: String(o.label || ''), description: String(o.description || '') })) : [],
+ })),
+ };
+ }
+ }
+ return {
+ question, answeredQuestion,
+ lastText: lastText ? lastText.slice(-2000) : null,
+ pasteCmd, lastTs, sessionId, cwd, turns: turns.length,
+ };
+}
+
+// ---- helpers ----
+function cwdOf(pid) {
+ try {
+ const out = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 8000 });
+ for (const l of out.split('\n')) if (l.startsWith('n')) return l.slice(1);
+ } catch {}
+ return null;
+}
+
+function findSessionJsonl(sessionId) {
+ if (!/^[a-zA-Z0-9-]+$/.test(sessionId)) return null;
+ let dirs = [];
+ try { dirs = fs.readdirSync(PROJECTS); } catch { return null; }
+ for (const d of dirs) {
+ const fp = path.join(PROJECTS, d, sessionId + '.jsonl');
+ if (fs.existsSync(fp)) return fp;
+ }
+ return null;
+}
+
+function mangled(cwd) {
+ // fast path: cwd.replace('/', '-') and the dot-mangled variant. NEVER decode dash-names.
+ return [cwd.replace(/\//g, '-').replace(/\./g, '-'), cwd.replace(/\//g, '-')];
+}
+
+function jsonlsIn(dir) {
+ let names = [];
+ try { names = fs.readdirSync(dir); } catch { return []; }
+ const out = [];
+ for (const n of names) {
+ if (!n.endsWith('.jsonl')) continue;
+ const fp = path.join(dir, n);
+ let st; try { st = fs.statSync(fp); } catch { continue; }
+ if (st.isFile()) out.push({ path: fp, mtime: st.mtimeMs, size: st.size });
+ }
+ return out.sort((a, b) => b.mtime - a.mtime);
+}
+
+function headHasCwd(fp, cwd) {
+ // read the first 64KB and look for "cwd":"<cwd>" (exact JSON string match)
+ try {
+ const fd = fs.openSync(fp, 'r');
+ const buf = Buffer.alloc(65536);
+ const n = fs.readSync(fd, buf, 0, 65536, 0);
+ fs.closeSync(fd);
+ return buf.toString('utf8', 0, n).includes('"cwd":' + JSON.stringify(cwd));
+ } catch { return false; }
+}
+
+function candidateDirs(cwd) {
+ const dirs = new Set();
+ for (const m of mangled(cwd)) {
+ const d = path.join(PROJECTS, m);
+ if (fs.existsSync(d)) dirs.add(d);
+ }
+ if (dirs.size) return [...dirs];
+ // slow path: dirs touched in the last 24h whose newest file carries this cwd
+ const cutoff = Date.now() - 24 * 3600 * 1000;
+ let names = [];
+ try { names = fs.readdirSync(PROJECTS); } catch { return []; }
+ for (const n of names) {
+ const d = path.join(PROJECTS, n);
+ let st; try { st = fs.statSync(d); } catch { continue; }
+ if (!st.isDirectory() || st.mtimeMs < cutoff) continue;
+ const newest = jsonlsIn(d)[0];
+ if (newest && headHasCwd(newest.path, cwd)) dirs.add(d);
+ }
+ return [...dirs];
+}
+
+function parseStarted(s) {
+ if (!s) return NaN;
+ const t = Date.parse(s);
+ return Number.isNaN(t) ? NaN : t;
+}
+
+/**
+ * validator — does this tail look like a session that is currently WAITING on Steve?
+ * ends in a pending AskUserQuestion, OR has a `! ` paste line, OR its last ts is
+ * ≥ dot updated_at − 5min.
+ */
+function pendingState(parsed, updatedAt) {
+ if (parsed.question) return true;
+ if (parsed.pasteCmd) return true;
+ const ua = Date.parse(updatedAt || '');
+ const lt = Date.parse(parsed.lastTs || '');
+ if (!Number.isNaN(ua) && !Number.isNaN(lt) && lt >= ua - 5 * 60 * 1000) return true;
+ return false;
+}
+
+/**
+ * resolve(row) → {
+ * sessionId, transcriptPath, cwd, confidence: exact|likely|ambiguous|none, candidates:[...],
+ * detail: parseTail(...) | null (null when ambiguous/none — never render another session's question)
+ * }
+ */
+function resolve(row) {
+ const pid = String(row.pid || '');
+ const tty = String(row.tty || '');
+ const key = pid + '|' + (row.updated_at || '');
+ const hit = cache.get(pid);
+ if (hit && hit.key === key && Date.now() - hit.ts < CACHE_MS) return hit.result;
+ const result = resolveUncached(row, pid, tty);
+ cache.set(pid, { key, ts: Date.now(), result });
+ return result;
+}
+
+function exactFrom(fp, how) {
+ const detail = parseTail(fp);
+ return { sessionId: detail.sessionId || path.basename(fp, '.jsonl'), transcriptPath: fp, cwd: detail.cwd || null, confidence: 'exact', how, candidates: [fp], detail };
+}
+
+function resolveUncached(row, pid, tty) {
+ // 0. explicit map file (a future hook can write this → everything becomes exact)
+ if (/^ttys\d{3}$/.test(tty)) {
+ try {
+ const mp = path.join(MAP_DIR, tty + '.json');
+ if (fs.existsSync(mp)) {
+ const m = JSON.parse(fs.readFileSync(mp, 'utf8'));
+ let fp = m.transcriptPath && fs.existsSync(m.transcriptPath) ? m.transcriptPath : null;
+ if (!fp && m.sessionId) fp = findSessionJsonl(String(m.sessionId));
+ if (fp) return exactFrom(fp, 'map');
+ }
+ } catch {}
+ }
+ // 1. ~/.claude/sessions/<pid>.json → sessionId
+ if (/^\d+$/.test(pid)) {
+ try {
+ const sp = path.join(SESSIONS, pid + '.json');
+ if (fs.existsSync(sp)) {
+ const s = JSON.parse(fs.readFileSync(sp, 'utf8'));
+ if (s.sessionId) {
+ const fp = findSessionJsonl(String(s.sessionId));
+ if (fp) return exactFrom(fp, 'sessions-json');
+ }
+ }
+ } catch {}
+ }
+ // 2. lsof cwd → candidate dirs
+ const none = (why) => ({ sessionId: null, transcriptPath: null, cwd: null, confidence: 'none', how: why, candidates: [], detail: null });
+ if (!/^\d+$/.test(pid)) return none('no pid');
+ const cwd = cwdOf(pid);
+ if (!cwd) return none('lsof cwd failed');
+ const dirs = candidateDirs(cwd);
+ if (!dirs.length) return { ...none('no project dir for cwd'), cwd };
+ // 3. candidates by mtime ≥ started − 60s
+ const started = parseStarted(row.owner && row.owner.started);
+ const floor = Number.isNaN(started) ? 0 : started - 60 * 1000;
+ let cands = [];
+ for (const d of dirs) cands.push(...jsonlsIn(d));
+ cands = cands.filter((c) => c.mtime >= floor && c.size > 0).sort((a, b) => b.mtime - a.mtime);
+ if (!cands.length) return { ...none('no transcript newer than session start'), cwd };
+ const paths = cands.map((c) => c.path);
+ if (cands.length === 1) {
+ const detail = parseTail(cands[0].path);
+ return { sessionId: detail.sessionId, transcriptPath: cands[0].path, cwd, confidence: 'likely', how: 'cwd-single', candidates: paths, detail };
+ }
+ // >1: pending-state validator on each tail (bounded to the 8 newest)
+ const survivors = [];
+ for (const c of cands.slice(0, 8)) {
+ const detail = parseTail(c.path);
+ if (pendingState(detail, row.updated_at)) survivors.push({ c, detail });
+ }
+ if (survivors.length === 1) {
+ const { c, detail } = survivors[0];
+ return { sessionId: detail.sessionId, transcriptPath: c.path, cwd, confidence: 'likely', how: 'cwd-validated', candidates: paths, detail };
+ }
+ 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, MAP_DIR, _cache: cache };
diff --git a/lib/writeback.js b/lib/writeback.js
new file mode 100644
index 0000000..35b4a13
--- /dev/null
+++ b/lib/writeback.js
@@ -0,0 +1,199 @@
+'use strict';
+// writeback.js — the ONLY place the cockpit touches a live pane.
+//
+// typeText(tty, text, {dry}) osascript: iterate windows→tabs→sessions, match
+// `tty of s`, `write text "<escaped>"`, delay 0.6,
+// `write text ""` (2nd Enter = submit). Template copied
+// from ~/.claude/skills/colordots/colordots.sh:206-224.
+// focus(tty) select w/t/s + activate (allcolordots.sh:28-54 jump_to)
+//
+// Rails:
+// - text is injected as an escaped AppleScript string LITERAL (\ and " escaped);
+// the script is fed to osascript on STDIN — never `-e` concat of raw input.
+// - reject \r and control chars; \n is space-joined into one `write text` line.
+// - single-flight: mkdir /tmp/answer-cockpit.lock + pid file + stale-pid reap
+// (resumeit.sh:12-24), and a 1.2s MINIMUM spacing between osascript calls so
+// a double-click can never double-type.
+// - every accessor inside `try`; returns {typed:true} only when a tty matched.
+// - refuse the cockpit's own tty (climb ppid like colordots.sh:61-72).
+const fs = require('fs');
+const { execFile, execFileSync } = require('child_process');
+
+const LOCKDIR = '/tmp/answer-cockpit.lock';
+const MIN_GAP_MS = 1200;
+const TTY_RE = /^ttys\d{3}$/;
+const ITERM_APP = (process.argv.includes('--test') && process.env.COCKPIT_ITERM_APP) || 'iTerm2';
+
+let lastCallAt = 0;
+let chain = Promise.resolve(); // in-process serialization
+
+function resolveSelfTty() {
+ // climb ppid until a real ttys* appears (agent-tool bash has no controlling tty)
+ let pid = process.pid;
+ for (let n = 0; n < 10 && pid && pid !== 0 && pid !== 1; n++) {
+ let t = '';
+ try { t = execFileSync('ps', ['-o', 'tty=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 }).trim(); } catch { break; }
+ if (/^ttys\d+$/.test(t)) return t;
+ let pp = '';
+ try { pp = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 }).trim(); } catch { break; }
+ pid = parseInt(pp, 10);
+ }
+ return '';
+}
+const SELF_TTY = resolveSelfTty();
+
+function validateText(text) {
+ if (typeof text !== 'string') return 'text must be a string';
+ if (!text.length) return 'text empty';
+ if (text.length > 2000) return 'text > 2000 chars';
+ if (/\r/.test(text)) return 'text contains \\r';
+ // control chars other than \n and \t
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text)) return 'text contains control chars';
+ return null;
+}
+
+function asLiteral(text) {
+ // space-join newlines, then escape as an AppleScript string literal
+ const one = text.replace(/\r?\n/g, ' ').replace(/\t/g, ' ');
+ return '"' + one.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
+}
+
+function typeScript(tty, text) {
+ const lit = asLiteral(text);
+ return `tell application "${ITERM_APP}"
+ set matched to 0
+ repeat with w in windows
+ repeat with t in tabs of w
+ repeat with s in sessions of t
+ try
+ if (tty of s) is "/dev/${tty}" then
+ tell s to write text ${lit}
+ delay 0.6
+ -- submit-forcing second Enter: Claude Code's TUI often takes the first
+ -- newline as "insert" not "send", so a bare second Enter actually submits.
+ tell s to write text ""
+ set matched to matched + 1
+ end if
+ end try
+ end repeat
+ end repeat
+ end repeat
+ return "matched=" & matched
+end tell`;
+}
+
+function focusScript(tty) {
+ return `tell application "${ITERM_APP}"
+ repeat with w in windows
+ repeat with t in tabs of w
+ repeat with s in sessions of t
+ try
+ if (tty of s) is "/dev/${tty}" then
+ select w
+ select t
+ select s
+ activate
+ return "jumped -> /dev/${tty}"
+ end if
+ end try
+ end repeat
+ end repeat
+ end repeat
+ return "not found: /dev/${tty}"
+end tell`;
+}
+
+// ---- lock (mkdir atomic + pid file + stale reap) ----
+function acquireLock() {
+ for (let i = 0; i < 60; i++) { // up to ~6s
+ try { fs.mkdirSync(LOCKDIR); fs.writeFileSync(LOCKDIR + '/pid', String(process.pid)); return true; } catch {}
+ let owner = 0;
+ try { owner = parseInt(fs.readFileSync(LOCKDIR + '/pid', 'utf8'), 10); } catch {}
+ let alive = false;
+ if (owner) { try { process.kill(owner, 0); alive = true; } catch { alive = false; } }
+ if (!alive) { try { fs.rmSync(LOCKDIR, { recursive: true, force: true }); } catch {} continue; }
+ if (owner === process.pid) return true; // re-entrant (we hold it)
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
+ }
+ return false;
+}
+function releaseLock() { try { fs.rmSync(LOCKDIR, { recursive: true, force: true }); } catch {} }
+
+function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
+
+function runOsascript(script, timeoutMs = 20000) {
+ return new Promise((resolve) => {
+ const child = execFile('/usr/bin/osascript', [], { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
+ if (err) return resolve({ ok: false, err: (stderr || err.message || String(err)).trim().slice(0, 400), stdout: (stdout || '').trim() });
+ resolve({ ok: true, stdout: (stdout || '').trim(), err: null });
+ });
+ child.stdin.on('error', () => {});
+ child.stdin.end(script);
+ });
+}
+
+/** serialized + paced osascript call. */
+function paced(fn) {
+ const p = chain.then(async () => {
+ if (!acquireLock()) throw new Error('write-back lock busy');
+ try {
+ const wait = MIN_GAP_MS - (Date.now() - lastCallAt);
+ if (wait > 0) await sleep(wait);
+ lastCallAt = Date.now();
+ return await fn();
+ } finally { releaseLock(); }
+ });
+ chain = p.catch(() => {});
+ return p;
+}
+
+/**
+ * typeText(tty, text, {dry}) → { typed:boolean, dry:boolean, matched:number, err, script }
+ * Guards here are the LAST line: tty shape, self-tty, text validity. The live-scan
+ * allowlist + color guards live in server.js (they need the fresh scan).
+ */
+async function typeText(tty, text, opts = {}) {
+ if (!TTY_RE.test(String(tty))) return { typed: false, err: 'bad tty', refused: true };
+ if (SELF_TTY && tty === SELF_TTY) return { typed: false, err: 'refusing to type into the cockpit\'s own tty', refused: true };
+ const bad = validateText(text);
+ if (bad) return { typed: false, err: bad, refused: true };
+ const script = typeScript(tty, text);
+ if (opts.dry) return { typed: false, dry: true, matched: 0, err: null, script };
+ return paced(async () => {
+ const r = await runOsascript(script);
+ if (!r.ok) return { typed: false, dry: false, matched: 0, err: r.err, script, attempted: true };
+ const m = /matched=(\d+)/.exec(r.stdout);
+ const matched = m ? parseInt(m[1], 10) : 0;
+ return { typed: matched > 0, dry: false, matched, err: matched > 0 ? null : 'no iTerm session with that tty', script, attempted: true };
+ });
+}
+
+async function focus(tty, opts = {}) {
+ if (!TTY_RE.test(String(tty))) return { ok: false, err: 'bad tty', refused: true };
+ const script = focusScript(tty);
+ if (opts.dry) return { ok: false, dry: true, script };
+ return paced(async () => {
+ const r = await runOsascript(script, 10000);
+ if (!r.ok) return { ok: false, err: r.err, script, attempted: true };
+ return { ok: /^jumped/.test(r.stdout), out: r.stdout, err: /^jumped/.test(r.stdout) ? null : r.stdout, script, attempted: true };
+ });
+}
+
+/** repaint(tty, label) — optional post-answer repaint via terminal_status.py (assert_owner refuses dead tty). */
+function repaint(tty, label) {
+ return new Promise((resolve) => {
+ if (!TTY_RE.test(String(tty))) return resolve({ ok: false, err: 'bad tty' });
+ const py = require('path').join(process.env.HOME || require('os').homedir(), 'Projects/terminal-status/terminal_status.py');
+ execFile('python3', [py, 'set', 'green', String(label || '').slice(0, 120), '--tty', tty, '--quiet'], { timeout: 30000 }, (err, stdout, stderr) => {
+ if (err) return resolve({ ok: false, err: (stderr || err.message).trim().slice(0, 300) });
+ resolve({ ok: true });
+ });
+ });
+}
+
+/** probe() — is the iTerm app reachable at all? (used by /api/health; never types) */
+function probe() {
+ return runOsascript(`tell application "${ITERM_APP}" to return (count of windows)`, 8000);
+}
+
+module.exports = { typeText, focus, repaint, probe, validateText, asLiteral, typeScript, SELF_TTY, ITERM_APP, LOCKDIR, TTY_RE };
diff --git a/public/index.html b/public/index.html
index 5f57f7b..273248d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Answer Cockpit</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ctext y='52' font-size='52'%3E%F0%9F%8E%AF%3C/text%3E%3C/svg%3E">
<style>
:root{
--bg:#f6f7f9;--fg:#111;--muted:#5b6470;--line:#d9dee5;--card:#fff;--code:#f0f2f5;
@@ -224,7 +225,7 @@ async function poll(){
try{q=await api('/api/queue?orphans=1');}
catch(e){
if(e.status===401)return;
- state.down=true;banner((e.status===503||e.status===502)?'iTerm unreachable / scan stale — actions disabled':('backend error '+(e.status||'')+' — actions disabled'));render();return;}
+ state.down=true;banner((e.status===503||e.status===502)?'iTerm unreachable / scan stale — actions disabled':(e.status?('backend error '+e.status+' — actions disabled'):'backend unreachable — actions disabled (retrying every 8s)'));render();return;}
state.down=false;state.stale=!!q.stale;state.scannedAt=q.scannedAt||new Date().toISOString();
state.orphans=q.orphanMemos||[];state.remaining=(typeof q.remaining==='number')?q.remaining:(q.items||[]).length;
$('cost').textContent=q.cost||'$0 (local)';
@@ -282,7 +283,7 @@ async function onErr(e,it,retryForce){
}
async function decide(file,decision,note,it){
if(busy())return;guard();
- try{const r=await api('/api/memo/decide',{method:'POST',body:JSON.stringify({file,decision,note:note||'',tty:it?it.tty:undefined})});
+ try{const r=await api('/api/memo/decide',{method:'POST',body:JSON.stringify({file,decision,note:note||'',tty:it?it.tty:undefined,expectKey:it?it.key:undefined})});
toast(decision.toUpperCase()+' ✓ '+file+(r&&r.typed?' → typed':''),2000);
if(it){removeCurrent();render();confirmLater(it,'gated');}
else{state.orphans=state.orphans.filter(o=>o.file!==file);render();}}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..6da5754
--- /dev/null
+++ b/server.js
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Answer Cockpit — one needs-Steve item at a time, one button to continue.
+ * Zero-dep Node. Binds 127.0.0.1:9805 ONLY (hard-coded; no HOST env).
+ * Basic auth admin / DW2024! (constant-time), realm "Answer Cockpit".
+ * Every mutation is a POST; JSON body ≤ 16KB.
+ *
+ * GET / public/index.html
+ * GET /api/queue?orphans=1 scan (4s cache) → needs-Steve items, detail pre-resolved
+ * GET /api/item/:tty one item, FRESH scan (post-answer confirm)
+ * POST /api/answer {tty,text,expectKey,force?,repaint?,dry?}
+ * POST /api/continue {tty,expectKey,force?,dry?} → types "continue"
+ * POST /api/focus {tty}
+ * POST /api/memo/decide {file,decision,note,tty?,expectKey?,dry?}
+ * POST /api/memo/undo {file,decision}
+ * GET /api/health heartbeat + last 20 audit lines
+ *
+ * Testability seam (TK-11431 #3): `node server.js --test` honours COCKPIT_ITERM_APP
+ * (bogus app name → osascript fails → 502 + heartbeat FAIL) and COCKPIT_TEST_PORT.
+ * The plist must NEVER pass --test.
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const queue = require('./lib/queue');
+const memo = require('./lib/memo');
+const writeback = require('./lib/writeback');
+const audit = require('./lib/audit');
+
+const HOST = '127.0.0.1'; // hard-coded on purpose — never expose (it types into live agents)
+const TEST = process.argv.includes('--test');
+const PORT = TEST && process.env.COCKPIT_TEST_PORT ? parseInt(process.env.COCKPIT_TEST_PORT, 10) : 9805;
+const USER = 'admin', PASS = 'DW2024!';
+const BODY_LIMIT = 16 * 1024;
+const PUBLIC = path.join(__dirname, 'public');
+
+// ---- constant-time credential check (copied from ~/.claude/skills/viewer/server.js:29-48) ----
+function safeEqual(a, b) {
+ const ab = Buffer.from(String(a));
+ const bb = Buffer.from(String(b));
+ if (ab.length !== bb.length) { crypto.timingSafeEqual(ab, ab); return false; }
+ return crypto.timingSafeEqual(ab, bb);
+}
+function authed(req) {
+ const h = req.headers.authorization || '';
+ if (!h.startsWith('Basic ')) return false;
+ let decoded = '';
+ try { decoded = Buffer.from(h.slice(6), 'base64').toString('utf8'); } catch { return false; }
+ const i = decoded.indexOf(':');
+ if (i < 0) return false;
+ return safeEqual(decoded.slice(0, i), USER) && safeEqual(decoded.slice(i + 1), PASS);
+}
+
+const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
+function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)); }
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let size = 0; const chunks = [];
+ req.on('data', (c) => { size += c.length; if (size > BODY_LIMIT) { reject(new Error('body > 16KB')); req.destroy(); return; } chunks.push(c); });
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ if (!raw.trim()) return resolve({});
+ try { const o = JSON.parse(raw); resolve(o && typeof o === 'object' ? o : {}); } catch { reject(new Error('bad json')); }
+ });
+ req.on('error', reject);
+ });
+}
+
+/**
+ * guardTarget — the shared POST rail for anything that types into a pane.
+ * Returns {code, body} on refusal, or {row} when clear.
+ */
+async function guardTarget(body, { needsSteveOnly = true, requireKey = true } = {}) {
+ const tty = String(body.tty || '');
+ if (!queue.TTY_RE.test(tty)) return { code: 400, body: { error: 'bad tty (expected ttysNNN)' } };
+ if (writeback.SELF_TTY && tty === writeback.SELF_TTY) return { code: 403, body: { error: 'refusing the cockpit\'s own tty', selfTty: writeback.SELF_TTY } };
+ const { scan, live } = await queue.freshLive();
+ if (scan.error && !scan.rows.length) return { code: 503, body: { error: 'scanner unavailable', detail: scan.error, stale: true } };
+ if (scan.terminalApi === 'unavailable') return { code: 503, body: { error: 'terminal_api unavailable — unmeasured, refusing to type', stale: true } };
+ const row = live.get(tty);
+ if (!row) {
+ // known a moment ago (client has a key for it) but gone now → 409 gone; never known → 400
+ if (body.expectKey && String(body.expectKey).startsWith(tty + '|')) return { code: 409, body: { error: 'tty vanished', gone: true } };
+ return { code: 400, body: { error: 'tty not in live scan', tty } };
+ }
+ if (requireKey) {
+ const key = queue.keyOf(row);
+ if (!body.expectKey || String(body.expectKey) !== key) return { code: 409, body: { error: 'stale card (expectKey mismatch)', expectKey: body.expectKey || null, currentKey: key } };
+ }
+ const force = body.force === true;
+ if (needsSteveOnly && !queue.NEEDS_STEVE.has(row.color) && !force) return { code: 403, body: { error: `tty is ${row.color} — not waiting on you (pass force:true to override)`, color: row.color } };
+ if (row.runtime === 'codex' && !force) return { code: 403, body: { error: 'tty hosts a codex REPL — answer disabled unless force', runtime: 'codex' } };
+ return { row };
+}
+
+async function doType(row, text, body, action, extra = {}) {
+ const dry = body.dry === true;
+ const r = await writeback.typeText(row.tty, text, { dry });
+ const line = audit.audit({ action, tty: row.tty, ticket: row.ticket || null, color: row.color, text, ok: r.typed || (dry && !r.err), err: r.err, dry, refused: !!r.refused, osascriptAttempted: !!r.attempted, ...extra });
+ if (r.refused) return { code: 400, body: { ok: false, typed: false, error: r.err } };
+ if (dry) return { code: 200, body: { ok: true, typed: false, dry: true, script: r.script, audit: line } };
+ if (!r.attempted || (r.err && !r.typed && /not running|can't get|Can’t get|-600|-1728|-2700|-1743|not allowed|timed out|ETIMEDOUT|SIGTERM/i.test(r.err || ''))) {
+ audit.heartbeat({ rows: [row], stale: false, terminalApi: 'available', source: 'post-writeback' }); // refresh heartbeat → FAIL
+ return { code: 502, body: { ok: false, typed: false, error: 'iTerm unreachable: ' + (r.err || 'unknown') } };
+ }
+ if (!r.typed) return { code: 409, body: { ok: false, typed: false, error: r.err || 'no matching session', gone: true } };
+ let repaint = null;
+ if (body.repaint === true) {
+ const label = `${row.ticket || 'TK'} · answered via cockpit`;
+ repaint = await writeback.repaint(row.tty, label);
+ }
+ return { code: 200, body: { ok: true, typed: true, matched: r.matched, repaint, audit: line } };
+}
+
+const server = http.createServer(async (req, res) => {
+ if (!authed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Answer Cockpit", charset="UTF-8"', 'Content-Type': 'text/plain' });
+ return res.end('Authentication required');
+ }
+ let u; try { u = new URL(req.url || '/', 'http://x'); } catch { return json(res, 400, { error: 'bad url' }); }
+ const p = u.pathname;
+ 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.startsWith('/api/item/')) {
+ const tty = p.slice('/api/item/'.length);
+ if (!queue.TTY_RE.test(tty)) return json(res, 400, { error: 'bad tty' });
+ return json(res, 200, await queue.item(tty));
+ }
+ if (p === '/api/health') {
+ const probe = await writeback.probe();
+ const latest = audit.latest();
+ return json(res, 200, { ok: true, heartbeat: latest, iterm: probe.ok ? { reachable: true, windows: probe.stdout } : { reachable: false, err: probe.err }, selfTty: writeback.SELF_TTY || null, itermApp: writeback.ITERM_APP, test: TEST, audit: audit.tailAudit(20), cost: '$0 (local)' });
+ }
+ if (p === '/api/memos') return json(res, 200, { memos: memo.list().map((m) => ({ ...m, excerpt: m.excerpt.slice(0, 600) })) });
+ // static: / → public/index.html, /x.js → public/x.js (traversal-guarded)
+ let rel = p === '/' ? '/index.html' : p;
+ let dec; try { dec = decodeURIComponent(rel); } catch { return json(res, 400, { error: 'bad path' }); }
+ const abs = path.resolve(PUBLIC, '.' + dec);
+ if (!abs.startsWith(PUBLIC + path.sep)) { res.writeHead(403); return res.end('forbidden'); }
+ let st; try { st = fs.statSync(abs); } catch { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); }
+ if (!st.isFile()) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'Content-Type': TYPES[path.extname(abs).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-store' });
+ return fs.createReadStream(abs).pipe(res);
+ }
+ // ---- POST ----
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
+ let body; try { body = await readBody(req); } catch (e) { return json(res, 400, { error: e.message }); }
+
+ if (p === '/api/answer') {
+ const text = typeof body.text === 'string' ? body.text : '';
+ const bad = writeback.validateText(text);
+ if (bad) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: bad, refused: true }); return json(res, 400, { error: bad }); }
+ const g = await guardTarget(body);
+ if (g.code) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
+ const r = await doType(g.row, text, body, 'answer');
+ return json(res, r.code, r.body);
+ }
+ if (p === '/api/continue') {
+ const g = await guardTarget(body);
+ if (g.code) { audit.audit({ action: 'continue', tty: body.tty, text: 'continue', ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
+ const r = await doType(g.row, 'continue', body, 'continue');
+ return json(res, r.code, r.body);
+ }
+ if (p === '/api/focus') {
+ const g = await guardTarget(body, { needsSteveOnly: false, requireKey: false });
+ if (g.code) { audit.audit({ action: 'focus', tty: body.tty, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
+ const r = await writeback.focus(g.row.tty, { dry: body.dry === true });
+ audit.audit({ action: 'focus', tty: g.row.tty, ticket: g.row.ticket, color: g.row.color, ok: !!r.ok || !!r.dry, err: r.err, dry: !!r.dry, osascriptAttempted: !!r.attempted });
+ if (r.dry) return json(res, 200, { ok: true, dry: true, script: r.script });
+ if (!r.attempted || (r.err && /not running|-600|-1728|-2700|-1743|timed out/i.test(r.err))) return json(res, 502, { ok: false, error: 'iTerm unreachable: ' + r.err });
+ return json(res, r.ok ? 200 : 409, { ok: !!r.ok, out: r.out || null, error: r.err || null });
+ }
+ if (p === '/api/memo/decide') {
+ const { file, decision, note } = body;
+ if (!memo.safeFile(file)) return json(res, 400, { error: 'bad file' });
+ const dec = decision === 'reject' ? 'block' : decision;
+ if (!['approve', 'block', 'revise'].includes(dec)) return json(res, 400, { error: 'bad decision' });
+ // If a tty is linked, validate it BEFORE moving the memo so a stale card can't half-apply.
+ let target = null;
+ if (body.tty) {
+ const g = await guardTarget(body, { requireKey: !!body.expectKey });
+ if (g.code) { audit.audit({ action: 'memo.decide', tty: body.tty, memoFile: file, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
+ target = g.row;
+ }
+ let out;
+ if (body.dry === true) out = { ok: true, dry: true, decision: dec, wouldMove: dec !== 'revise' };
+ else { try { out = memo.decide({ file, decision: dec, note }); } catch (e) { audit.audit({ action: 'memo.decide', memoFile: file, ok: false, err: e.message }); return json(res, e.message === 'gone' ? 409 : 400, { error: e.message }); } }
+ audit.audit({ action: 'memo.decide', tty: target ? target.tty : null, ticket: out.ticket || null, memoFile: file, text: dec + (note ? ': ' + String(note).slice(0, 200) : ''), ok: true, dry: body.dry === true });
+ let typed = null;
+ if (target) {
+ const word = dec === 'approve' ? 'APPROVED' : dec === 'block' ? 'BLOCKED' : 'REVISE';
+ const msg = dec === 'approve' ? `APPROVED: ${file} — proceed under the memo's rails${note ? '. Note: ' + note : ''}`
+ : dec === 'block' ? `BLOCKED: ${file} — do not execute${note ? '. Reason: ' + note : ''}`
+ : `REVISE: ${file}${note ? ' — ' + note : ' — see the cockpit note'}`;
+ const r = await doType(target, msg, body, 'memo.' + word.toLowerCase());
+ typed = r.body;
+ }
+ return json(res, 200, { ...out, typed });
+ }
+ if (p === '/api/memo/undo') {
+ const { file, decision } = body;
+ if (!memo.safeFile(file)) return json(res, 400, { error: 'bad file' });
+ try { const out = memo.undo({ file, decision }); audit.audit({ action: 'memo.undo', memoFile: file, text: String(decision), ok: true }); return json(res, 200, out); }
+ catch (e) { audit.audit({ action: 'memo.undo', memoFile: file, ok: false, err: e.message }); return json(res, 400, { error: e.message }); }
+ }
+ return json(res, 404, { error: 'not found' });
+ } catch (e) {
+ return json(res, 500, { error: String(e && e.message || e) });
+ }
+});
+
+server.listen(PORT, HOST, () => {
+ console.log(`COCKPIT_LISTENING port=${PORT} host=${HOST}${TEST ? ' test=1 itermApp=' + writeback.ITERM_APP : ''}`);
+ console.log(`Answer Cockpit up: http://${HOST}:${PORT} (admin / ${PASS}) selfTty=${writeback.SELF_TTY || '-'} cost=$0 (local)`);
+ // first heartbeat so latest.json exists before the first request
+ queue.scan().catch(() => {});
+});
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..89f1d56
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Answer Cockpit launcher — nohup node server.js on 127.0.0.1:9805 (hard-coded).
+# bash start.sh start (refuses if 9805 is already bound by a different command)
+# bash start.sh --stop stop the instance recorded in .running
+# bash start.sh --status show listener
+set -uo pipefail
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PORT=9805
+mkdir -p "$DIR/tmp" "$DIR/data"
+LOG="$DIR/tmp/server.log"
+
+# The server must NEVER inherit a Claude session's bridge identity — its osascript
+# children would otherwise trip the colordots.sh subagent rail / look like a subagent.
+unset CLAUDE_CODE_CHILD_SESSION CLAUDE_PID CLAUDE_CODE_ENTRYPOINT 2>/dev/null || true
+
+listener() { lsof -nP -iTCP:"$PORT" -sTCP:LISTEN 2>/dev/null | awk 'NR>1{print $2}' | head -1; }
+
+case "${1:-}" in
+ --stop)
+ pid="$(listener)"; [ -z "$pid" ] && { echo "nothing on :$PORT"; exit 0; }
+ cmd="$(ps -o command= -p "$pid" 2>/dev/null)"
+ case "$cmd" in *answer-cockpit/server.js*|*"$DIR/server.js"*) kill "$pid" && echo "stopped pid $pid"; rm -f "$DIR/.running"; exit 0;;
+ *) echo "refusing: :$PORT is held by a different command (pid $pid): $cmd"; exit 1;; esac;;
+ --status)
+ pid="$(listener)"; [ -z "$pid" ] && { echo "not running"; exit 1; }
+ echo "pid $pid $(ps -o command= -p "$pid")"; exit 0;;
+esac
+
+pid="$(listener)"
+if [ -n "$pid" ]; then
+ cmd="$(ps -o command= -p "$pid" 2>/dev/null)"
+ case "$cmd" in
+ *answer-cockpit/server.js*|*"$DIR/server.js"*) echo "already running (pid $pid): http://127.0.0.1:$PORT (admin / DW2024!)"; exit 0;;
+ *) echo "refusing to start: :$PORT is already bound by a different command (pid $pid): $cmd" >&2; exit 1;;
+ esac
+fi
+
+: > "$LOG"
+nohup node "$DIR/server.js" >"$LOG" 2>&1 &
+PID=$!
+disown "$PID" 2>/dev/null || true
+
+for _ in $(seq 1 50); do
+ grep -q 'COCKPIT_LISTENING port=' "$LOG" 2>/dev/null && break
+ kill -0 "$PID" 2>/dev/null || { echo "server failed to start:" >&2; cat "$LOG" >&2; exit 1; }
+ sleep 0.1
+done
+grep -q 'COCKPIT_LISTENING port=' "$LOG" || { echo "timed out waiting for COCKPIT_LISTENING" >&2; cat "$LOG" >&2; exit 1; }
+
+printf '%s\t%s\t%s\t%s\n' "$(date +%FT%T)" "$PID" "$PORT" "$LOG" > "$DIR/.running"
+cat <<EOF
+✅ Answer Cockpit is live
+ URL: http://127.0.0.1:$PORT (admin / DW2024!)
+ PID: $PID (log: $LOG)
+ Stop: bash $DIR/start.sh --stop
+ cost: \$0 (local)
+EOF
← 94bb750 auto-data-snapshot: 2026-09-15T18:34:22 (3 data files) — .de
·
back to Answer Cockpit
·
TK-11793: pane-contents source — iTerm screen as detail when 19f8c3b →