#!/usr/bin/env node // jasellite — open a new macOS Terminal window running `claude` on the // remote jasellite box, registered so the slab menubar tracks and colorizes // it exactly like a native local session. // // How the slab integration works without touching the Swift app: slab themes // a Terminal window by matching a marker file's `tty` to the window and // checking its `claude_pid` is alive locally. A remote claude launched here // runs *inside a local Terminal window*, so that window has a local tty and // the transport (mosh/ssh/et) has a local pid. We record those here; the // companion bridge (claude-remote-bridge.mjs) mirrors jasellite's session // markers down with the tty/pid rewritten to these local ones. // // Usage: // jasellite open claude in the configured remote dir // jasellite open claude cd'd into // jasellite --et|--ssh|--mosh override transport for this launch // jasellite -r resume a remote claude session by id // // Config (untracked): ~/.config/slab/remote-claude.json — see DEFAULTS below. import { execFileSync, spawn } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const HOME = homedir(); const SLAB_STATE = join(HOME, ".local/share/slab/state"); const LAUNCH_DIR = join(SLAB_STATE, "remote-launches"); const CONFIG = join(HOME, ".config/slab/remote-claude.json"); // Defaults are overridable per-key by ~/.config/slab/remote-claude.json. // `sshAlias` is the Host entry in ~/.ssh/config (works over the public IP or // the tailnet name). `etTarget` is user@host for Eternal Terminal. const DEFAULTS = { name: "jasellite", sshAlias: "jasellite", etTarget: "jas@24.144.92.66", transport: "mosh", // mosh | et | ssh — closest-to-resilient default remoteDir: "~/aesthetic-computer", terminalApp: "Terminal", // Terminal | iTerm2 }; function loadConfig() { if (!existsSync(CONFIG)) return { ...DEFAULTS }; try { return { ...DEFAULTS, ...JSON.parse(readFileSync(CONFIG, "utf8")) }; } catch (err) { console.warn(`jasellite: ignoring bad config (${err.message})`); return { ...DEFAULTS }; } } // Parse args: a bare arg is the remote dir; flags pick transport / resume. function parseArgs(argv) { const out = { dir: null, transport: null, session: null, dry: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--mosh" || a === "--et" || a === "--ssh") out.transport = a.slice(2); else if (a === "-s" || a === "--session") out.session = argv[++i]; else if (a === "--print" || a === "--dry") out.dry = true; else if (!a.startsWith("-")) out.dir = a; } return out; } // Single-quote a string for safe embedding in a bash -lc command. const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // A filesystem-safe session slug from a remote dir (its last path component). const slug = (s) => s.replace(/^~\/?/, "").replace(/\/+$/, "").split("/").pop() .replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "home"; // Build the command the local Terminal window runs: a transport hop into // jasellite that hands off to the box-side `jasellite-session` helper, which // attaches to (or creates) a named zellij session running claude. Keeping // claude inside zellij means a closed/dropped window reattaches to the live // session instead of starting fresh. The session name doubles as SLAB_LAUNCH_ID // (stable across reattach), so the bridge always maps it to the current window. function buildCommand(cfg, transport, sessionName, dir) { // Expand a leading ~ on the remote — single-quoting it would make it literal. let dirExpr; if (dir === "~" || dir === "~/") dirExpr = `"$HOME"`; else if (dir.startsWith("~/")) dirExpr = `"$HOME"/${shq(dir.slice(2))}`; else dirExpr = shq(dir); const helper = `"$HOME"/.local/bin/jasellite-session`; // On detach/exit drop into fish (jas's shell) so the window stays usable. const remote = `DIR=${dirExpr}; ` + `${helper} ${shq(sessionName)} "$DIR" ${shq(sessionName)}; ` + `exec fish -l`; const inner = `bash -lc ${shq(remote)}`; if (transport === "mosh") return `mosh ${cfg.sshAlias} -- ${inner}`; if (transport === "et") return `et ${cfg.etTarget} -c ${shq(inner)}`; return `ssh -t ${cfg.sshAlias} ${shq(inner)}`; // ssh } // Open a new Terminal/iTerm2 window running `cmd`; return the window's tty // (e.g. "ttys004"). Terminal.app's `do script` returns the tab it spawned, so // we can read its tty directly — that's the handle the bridge themes by. function openWindow(app, cmd) { const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); let script; if (app === "iTerm2") { // iTerm2 doesn't hand back a tty as cleanly; settle the session then read. script = `tell application "iTerm2" activate set w to (create window with default profile) tell current session of w to write text "${esc(cmd)}" delay 0.4 set theTty to tty of current session of w end tell return theTty`; } else { script = `tell application "Terminal" activate set theTab to do script "${esc(cmd)}" delay 0.3 set theTty to tty of theTab end tell return theTty`; } const out = execFileSync("/usr/bin/osascript", ["-e", script], { encoding: "utf8", }).trim(); return out.replace(/^\/dev\//, ""); // normalize "/dev/ttys004" -> "ttys004" } // Find the local transport process pid sitting on `tty` — that's the pid the // mirror marker uses for liveness, so closing the window marks it stale. // Make sure the marker bridge daemon is running; start it detached if not. function ensureBridge() { const pidFile = join(SLAB_STATE, "remote-bridge.pid"); try { const rec = JSON.parse(readFileSync(pidFile, "utf8")); if (rec?.pid) { process.kill(rec.pid, 0); return; } // already alive } catch {} const bridge = join(dirname(fileURLToPath(import.meta.url)), "claude-remote-bridge.mjs"); const child = spawn(process.execPath, [bridge], { detached: true, stdio: "ignore" }); child.unref(); } function main() { const cfg = loadConfig(); const args = parseArgs(process.argv.slice(2)); const transport = args.transport || cfg.transport; const dir = args.dir || cfg.remoteDir; // Stable session name (default per dir) so re-running reattaches the same // zellij session. It doubles as the launch id the bridge correlates on, and // the launch record is rewritten each launch with the *current* window's // tty/pid — so a reattach from a fresh window re-points the mirror correctly. const sessionName = args.session || (args.dir ? `jasellite-${slug(args.dir)}` : "jasellite"); const cmd = buildCommand(cfg, transport, sessionName, dir); if (args.dry) { console.log(cmd); return; } const tty = openWindow(cfg.terminalApp, cmd); if (!tty || tty.startsWith("tty-not")) { console.error("jasellite: couldn't read the new window's tty"); process.exit(1); } mkdirSync(LAUNCH_DIR, { recursive: true }); // Liveness + the marker's pid are resolved from local_tty by the bridge each // tick (mosh's client is slow to appear, so a pid snapshot here is racy). const record = { launch_id: sessionName, session: sessionName, host: cfg.name, transport, local_tty: tty, remote_dir: dir, started: new Date().toISOString(), }; writeFileSync(join(LAUNCH_DIR, sessionName), JSON.stringify(record, null, 2)); ensureBridge(); console.log( `jasellite: ${transport} → ${cfg.name} · session ${sessionName} · ${dir}\n` + ` window tty ${tty} (close & re-run to reattach)`, ); } main();