← back to Answer Cockpit
lib/writeback.js
202 lines
'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 path = require('path');
const os = require('os');
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 = path.join(process.env.HOME || 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 };