← 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 };