// Run an interactive CLI inside tmux and record its screen as an asciinema v2 file.
//
// node screen.mjs run
start tmux, record, and wait for /stop or the CLI exit
// node screen.mjs launch (inside tmux) start the command in /command.json
// node screen.mjs record (tmux pipe-pane) write the pane output to /screen.cast
//
// /command.json is {"argv": [...], "cols": 120, "rows": 36, "input": "prompt",
// "readyText": "text on the screen when the CLI accepts input", "settleMs": 0}. tmux answers the terminal
// queries of the CLI, so the CLI behaves as in a real terminal. The recording has the raw
// output of the CLI, so a player shows the exact screen.
import { spawn, spawnSync } from 'node:child_process';
import { createWriteStream, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const [mode, dir] = process.argv.slice(2);
const command = JSON.parse(readFileSync(join(dir, 'command.json'), 'utf8'));
const READY = '/tmp/screen-ready';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const tmux = (...args) => spawnSync('tmux', args, { stdio: 'inherit' }).status;
const screenText = () =>
spawnSync('tmux', ['capture-pane', '-p', '-t', 'run'], { encoding: 'utf8' }).stdout ?? '';
// The CLIs connect MCP servers in the background. A prompt on the command line can start
// before the n8n tools exist, so the prompt is pasted when the input box is ready.
async function typePrompt({ input, readyText, settleMs = 0 }) {
const deadline = Date.now() + 60_000;
while (readyText && !screenText().includes(readyText) && Date.now() < deadline) await sleep(250);
await sleep(settleMs);
tmux('set-buffer', '-b', 'prompt', '--', input);
tmux('paste-buffer', '-p', '-d', '-b', 'prompt', '-t', 'run');
await sleep(500);
tmux('send-keys', '-t', 'run', 'Enter');
}
if (mode === 'record') {
const out = createWriteStream(join(dir, 'screen.cast'));
out.write(
JSON.stringify({
version: 2,
width: command.cols,
height: command.rows,
timestamp: Math.floor(Date.now() / 1000),
env: { TERM: 'xterm-256color' },
}) + '\n',
);
const start = process.hrtime.bigint();
const decoder = new TextDecoder('utf-8');
process.stdin.on('data', (chunk) => {
const text = decoder.decode(chunk, { stream: true });
if (!text) return;
const t = Number(process.hrtime.bigint() - start) / 1e9;
out.write(JSON.stringify([Number(t.toFixed(6)), 'o', text]) + '\n');
});
process.stdin.on('end', () => out.end());
} else if (mode === 'launch') {
while (!existsSync(READY)) await sleep(50);
const [bin, ...args] = command.argv;
const child = spawn(bin, args, { stdio: 'inherit' });
child.on('exit', (code) => {
writeFileSync(join(dir, 'exited'), String(code ?? 1));
process.exit(code ?? 1);
});
// A CLI that cannot start emits error, not exit. Without the marker, the run waits for ever.
child.on('error', (error) => {
console.error(`Cannot start ${bin}: ${error.message}`);
writeFileSync(join(dir, 'exited'), '127');
process.exit(127);
});
} else if (mode === 'run') {
const self = new URL(import.meta.url).pathname;
// mouse and focus-events stop the tmux tips that Claude Code shows in the recorded screen.
writeFileSync(
'/tmp/tmux.conf',
'set -g status off\nset -g default-terminal xterm-256color\nset -g mouse on\nset -g focus-events on\n',
);
tmux(
'-f',
'/tmp/tmux.conf',
'new-session',
'-d',
'-s',
'run',
'-x',
String(command.cols),
'-y',
String(command.rows),
`node ${self} launch ${dir}`,
);
// A watcher in another terminal size must not resize the recorded screen. tmux 3.5 stops
// when this option is in the startup config, so it is set after the session starts.
tmux('set-option', '-g', 'window-size', 'manual');
tmux('pipe-pane', '-O', '-t', 'run', `node ${self} record ${dir}`);
writeFileSync(READY, '');
if (command.input) await typePrompt(command);
while (!existsSync(join(dir, 'stop')) && !existsSync(join(dir, 'exited'))) await sleep(500);
// Keep the final screen in the recording for a moment.
await sleep(2500);
tmux('kill-server');
await sleep(500);
}