Files
iris/sidecar/index.ts
T
pastilhasandClaude Fable 5 f0c811d387 iris: screenshots for agents — manifest, cargo-building sidecar, session wrapper, affordance and skill
Designed 2026-08-20 in conversation; PLUGIN.md is the decision record. Nothing here has
run yet: no live install, no build, no capture. The wrapper's two launch branches and the
cookie-seeding are written against documented behaviour, not observed behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:14:29 +00:00

139 lines
5.5 KiB
TypeScript

import { chmodSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
// The iris sidecar: a builder wearing a supervisor's clothes.
//
// The install pipeline installs packages and fetches release binaries, but deliberately runs no build
// step — so compiling a Rust crate is this plugin's own job, and the sidecar is the only process the
// platform starts for a plugin. First start pays for the build (minutes — watch `pm2 logs officer-iris`);
// every later start finds the binary and settles into a heartbeat.
//
// Everything lands where the platform already looks: `~/.local/bin` is first on the probe path in
// os-deps.ts, so `iris` and `iris-browser` are found by whoever asks, without anyone's PATH changing.
const name = 'officer-iris';
const CRATE = 'iris-screenshot';
// Upstream's declared rust-version. Distro cargo lags real Rust by a year or more, so this is checked
// BEFORE building — a clear refusal with instructions beats cargo's own MSRV error buried in build spam.
const MSRV: readonly [number, number] = [1, 88];
const home = homedir();
const localBin = join(home, '.local', 'bin');
// `Bun.which` alone reads the PATH this process started with, which under PM2 is minimal. Look where
// things actually are: our install target first, rustup's home second, then whatever PATH says.
const searchPath = (): string => `${localBin}:${join(home, '.cargo', 'bin')}:${process.env.PATH ?? ''}`;
const which = (binary: string): string | null => Bun.which(binary, { PATH: searchPath() });
const log = (message: string): void => console.log(`[${name}] ${message}`);
/** The wrapper ships in this repository; agents need it on a path they can name. Overwrite = upgrade. */
function installWrapper(): void {
const source = join(import.meta.dir, '..', 'bin', 'iris-browser');
const target = join(localBin, 'iris-browser');
mkdirSync(localBin, { recursive: true });
copyFileSync(source, target);
chmodSync(target, 0o755);
log(`iris-browser installed to ${target}`);
}
/** `cargo 1.75.0 (…)` → [1, 75], or null when it will not even say. */
function cargoVersion(cargo: string): [number, number] | null {
try {
const out = Bun.spawnSync([cargo, '--version'], { stdout: 'pipe', stderr: 'pipe' });
const match = new TextDecoder().decode(out.stdout).match(/cargo (\d+)\.(\d+)/);
return match ? [Number(match[1]), Number(match[2])] : null;
} catch {
return null;
}
}
const meetsMsrv = ([major, minor]: [number, number]): boolean =>
major > MSRV[0] || (major === MSRV[0] && minor >= MSRV[1]);
/**
* Build iris into ~/.local/bin. Streams cargo's output straight to PM2's log, because a silent
* multi-minute compile is indistinguishable from a hang.
*
* `--locked` builds against upstream's committed lockfile rather than re-resolving dependencies at
* install time — the platform's frozen-lockfile rule, honoured one language over.
*/
async function build(cargo: string): Promise<boolean> {
log(`building ${CRATE} — first start only, takes a few minutes`);
const proc = Bun.spawn([cargo, 'install', CRATE, '--locked', '--root', join(home, '.local')], {
stdout: 'inherit',
stderr: 'inherit',
});
const code = await proc.exited;
if (code !== 0) {
log(`build failed (cargo exited ${code}) — read the output above; restart this process to retry`);
return false;
}
return true;
}
/** True when the binary is usable. Says why not, with the fix, when it is not. */
async function ensureIris(): Promise<boolean> {
const present = which('iris');
if (present) {
log(`iris ready at ${present}`);
return true;
}
const cargo = which('cargo');
if (!cargo) {
log('cargo is not on PATH — the install should have provided it; install rust and restart this process');
return false;
}
const version = cargoVersion(cargo);
if (!version || !meetsMsrv(version)) {
log(`${cargo} is ${version ? `cargo ${version.join('.')}` : 'unidentifiable'}, and ${CRATE} needs ${MSRV.join('.')}+`);
log('your distribution\'s rust is too old. Install rustup as the user officer runs as:');
log(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal");
log('then restart this process: pm2 restart officer-iris');
return false;
}
if (!(await build(cargo))) return false;
const built = which('iris');
if (!built) {
log('cargo reported success but iris is not in ~/.local/bin — read the build output above');
return false;
}
log(`iris built and ready at ${built}`);
return true;
}
log(`started (pid ${process.pid})`);
installWrapper();
let ready = await ensureIris();
// The heartbeat re-probes rather than repeating a remembered answer: a binary deleted out from under us
// shows up here, and a rustup installed after a refusal turns the next restart into a build — which the
// log has already told the owner. Building inside the interval is deliberately avoided: a periodic
// compile that nobody asked for is not a heartbeat.
const beat = setInterval(() => {
const present = which('iris');
if (present && !ready) {
ready = true;
log(`iris appeared at ${present}`);
} else if (!present && ready) {
ready = false;
log('iris is GONE from PATH — restart this process to rebuild');
} else {
log(`alive — iris ${present ? 'ready' : 'missing'}`);
}
}, 300_000);
const shutdown = (signal: string): void => {
log(`${signal} — exiting`);
clearInterval(beat);
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));