/** * Thin wrapper around the sl0ppij binary. * * sl0ppi orchestrates, it does not reimplement: every piece of state comes back * out of `list-tabs`/`list-panes`, so there is nothing here to desync. * * Index conventions, both verified against a live session rather than inferred, * because getting either wrong closes or focuses the wrong tab: * * list-tabs `position` 0-based * close-tab --tab-id 0-based (same numbering as `position`) * go-to-tab 1-BASED (matches the `GoToTab 1` keybinds) * * Closing also RENUMBERS the tabs after it, so a batch of closes must run in * descending position order. */ import { spawnSync } from "node:child_process"; export const DEFAULT_BIN = "sl0ppij"; export interface TabInfo { position: number; name: string; purpose?: string; active?: boolean; } export interface PaneInfo { id: number; tab_id: number; is_plugin: boolean; title: string; purpose?: string; exited?: boolean; is_held?: boolean; terminal_command?: string | null; } export interface RunResult { code: number; stdout: string; stderr: string; } export class Sl0ppijError extends Error { constructor( message: string, readonly result: RunResult, ) { super(message); this.name = "Sl0ppijError"; } } /** * The variables that tell a sl0ppij process "you are inside a session". * * Both spellings, because the fork exports `SL0PPIJ*` and keeps `ZELLIJ*` for * ecosystem compatibility. */ export const SESSION_ENV_KEYS = [ "SL0PPIJ", "SL0PPIJ_SESSION_NAME", "SL0PPIJ_PANE_ID", "ZELLIJ", "ZELLIJ_SESSION_NAME", "ZELLIJ_PANE_ID", ] as const; /** A copy of `env` with the "inside a session" markers removed. */ export function withoutSessionEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { const copy = { ...env }; for (const key of SESSION_ENV_KEYS) delete copy[key]; return copy; } export interface ClientOptions { bin?: string; session?: string; } export class Sl0ppij { readonly bin: string; readonly session?: string; constructor(options: ClientOptions = {}) { this.bin = options.bin ?? process.env.SL0PPI_BIN ?? DEFAULT_BIN; this.session = options.session; } /** Same client, pinned to a session. */ forSession(session: string): Sl0ppij { return new Sl0ppij({ bin: this.bin, session }); } run( args: string[], { session = true, detached = false }: { session?: boolean; detached?: boolean } = {}, ): RunResult { const argv = session && this.session ? ["-s", this.session, ...args] : args; if (process.env.SL0PPI_DEBUG) { process.stderr.write(`sl0ppi: exec ${this.bin} ${argv.map((a) => JSON.stringify(a)).join(" ")}\n`); } const proc = spawnSync(this.bin, argv, { encoding: "utf8", env: detached ? withoutSessionEnv() : process.env, }); if (proc.error) { throw new Sl0ppijError(`could not run ${this.bin}: ${proc.error.message}`, { code: -1, stdout: "", stderr: String(proc.error.message), }); } return { code: proc.status ?? -1, stdout: proc.stdout ?? "", stderr: proc.stderr ?? "", }; } private runOk( args: string[], what: string, opts?: { session?: boolean; detached?: boolean }, ): RunResult { const result = this.run(args, opts); if (result.code !== 0) { throw new Sl0ppijError(`${what} failed: ${result.stderr.trim() || result.stdout.trim()}`, result); } return result; } listTabs(): TabInfo[] { return this.readList(["action", "list-tabs", "--json"], "list-tabs"); } listPanes(): PaneInfo[] { return this.readList(["action", "list-panes", "--json"], "list-panes"); } /** * Read a `--json` listing, tolerating a flaky underlying CLI. * * Measured on a 2-vCPU Debian box against a live session: `list-tabs` and * `list-panes` each fail about 60% of the time when the session is detached, * and about 25% when a client is attached. The failure is either an empty * stdout with exit 0, or "There is no active session!" from the action client * -- which checks `get_active_session()` before it even looks at `-s`. * * That is a defect in the terminal, not something a workspace tool should * inherit: these are idempotent reads, so retry them. If the session really * is gone, say so instead of retrying into a wall. */ private readList(args: string[], what: string, attempts = 5): T[] { let lastProblem = ""; for (let attempt = 0; attempt < attempts; attempt++) { const result = this.run(args); if (result.code === 0) { const text = result.stdout.trim(); if (text.length > 0) return parseJsonList(text, what); lastProblem = "empty output"; } else { lastProblem = (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? ""; } if (process.env.SL0PPI_DEBUG) { process.stderr.write(`sl0ppi: ${what} attempt ${attempt + 1}: ${lastProblem}\n`); } pause(120 * (attempt + 1)); } // Out of retries. Distinguish "the session is gone" (an answer) from "the // terminal would not talk to us" (a problem worth reporting). if (this.session !== undefined && !this.sessionExists(this.session)) return []; throw new Sl0ppijError(`${what} failed ${attempts}x (last: ${lastProblem})`, { code: -1, stdout: "", stderr: lastProblem, }); } /** Adds a tab from a KDL layout string. Returns its new position. */ newTab(layoutKdl: string): number { const { stdout } = this.runOk( ["action", "new-tab", "--layout-string", layoutKdl], "new-tab", ); return Number.parseInt(stdout.trim(), 10); } /** `position` is the 0-based `position` from list-tabs. */ closeTab(position: number): void { this.runOk(["action", "close-tab", "--tab-id", String(position)], "close-tab"); } /** Takes a 0-based position and converts to go-to-tab's 1-based index. */ focusTab(position: number): void { this.runOk(["action", "go-to-tab", String(position + 1)], "go-to-tab"); } /** * Every session sl0ppij knows about, live or dead-but-resurrectable. * * `--short` lists EXITED sessions too with no way to tell them apart, so we * parse the long form. (The same trap is documented in the pane-finder * installer.) The dead ones matter enormously: see `deadSessions`. */ sessions(): { name: string; dead: boolean }[] { const result = this.run(["list-sessions", "--no-formatting"], { session: false }); if (result.code !== 0) return []; // "no sessions" exits non-zero, not a failure return result.stdout .split("\n") .filter((line) => line.trim().length > 0) .map((line) => ({ name: line.trim().split(/\s+/)[0] as string, dead: line.includes("EXITED"), })) .filter((session) => session.name.length > 0); } liveSessions(): string[] { return this.sessions().filter((s) => !s.dead).map((s) => s.name); } /** * Sessions that are gone but still resurrectable. * * These are not cosmetic. `attach --create-background NAME` on one of these * RESURRECTS it from the serialised copy and silently ignores `--layout`, so * "create a session from this layout" quietly becomes "restore whatever was * there before" -- which is how a root full of projects came back as plain * shells with no agents. */ deadSessions(): string[] { return this.sessions().filter((s) => s.dead).map((s) => s.name); } sessionExists(name: string): boolean { return this.liveSessions().includes(name); } /** Discards a session AND its serialised copy, so the name is reusable. */ deleteSession(name: string): void { this.run(["delete-session", name], { session: false, detached: true }); } /** * Block until a session is actually answering, or give up. * * Creating (or resurrecting) a session returns before the server is * registered, so querying it straight away fails with "There is no active * session!". Poll for readiness rather than sleeping a guessed interval: * usually it is ready on the first check, and a slow box still works. */ waitForSession(name: string, timeoutMs = 10_000): boolean { const deadline = Date.now() + timeoutMs; for (;;) { if (this.sessionExists(name)) return true; if (Date.now() >= deadline) return false; pause(150); } } /** * Creates a detached session from a layout file, without attaching. * * Runs with the session markers stripped: `attach --create-background` fails * with "There is no active session!" when the caller is itself inside a * session, and sl0ppi is nearly always invoked from inside one. We are * creating a new detached session, not nesting, so dropping the markers is * exactly right. */ createBackground(name: string, layoutPath: string): void { this.runOk(["--layout", layoutPath, "attach", "--create-background", name], "create session", { session: false, detached: true, }); } } /** * Parse a `--json` listing. * * Empty stdout with a zero exit is a real state, not corruption: it is what * `action list-panes` returns once the session has died underneath us. Treat it * as "nothing", and keep the raw text in the error for anything genuinely * malformed -- a bare "JSON Parse error: Unexpected EOF" tells a user nothing. */ export function parseJsonList(stdout: string, what: string): T[] { const text = stdout.trim(); if (text.length === 0) return []; try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? (parsed as T[]) : []; } catch (error) { throw new Sl0ppijError( `${what} returned something that is not JSON: ${text.slice(0, 200)}`, { code: 0, stdout, stderr: "" }, ); } } /** Synchronous pause. These commands are one-shot and strictly sequential. */ function pause(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } /** True when we are running inside a sl0ppij/zellij pane already. */ export function insideSession(env: NodeJS.ProcessEnv = process.env): string | null { return env.SL0PPIJ_SESSION_NAME ?? env.ZELLIJ_SESSION_NAME ?? null; }