aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 23:49:19 +0200
committeruser <user@clank>2026-08-12 23:49:19 +0200
commitacf61609ee7b99138251cea879d593d5f1689392 (patch)
tree06f880e8c2e312e1a600c4ee0a63943c29eb902f
parentrelease: publish sl0ppi.sl0p.foo, and a script to keep it current (diff)
downloadsl0ppi-acf61609ee7b99138251cea879d593d5f1689392.tar.gz
sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.tar.xz
sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.zip
cli: survive a dead session, and a flaky terminal
Three bugs found on a fresh Debian box, all of which made sl0ppi look broken when the terminal underneath it was the problem. 1. JSON crash on startup. `action list-panes --json` against a session that has died exits 0 with EMPTY stdout, and JSON.parse("") threw "JSON Parse error: Unexpected EOF" at the user. Empty output is now "nothing", and genuinely malformed output is reported with the command name and the offending text. 2. `up` silently resurrected. A dead-but-resurrectable session is not an empty slot: `attach --create-background NAME` restores it from the serialised copy and IGNORES --layout. The result was a root full of projects coming back as plain shells with no agents and no purposes -- and the resurrected background session then exited again within seconds. sl0ppi now refuses to guess and explains the two real options (`up --fresh` to rebuild, `sl0ppij attach` to restore it properly in a terminal). Verified on a clean slate that a fresh `up` produces exactly the right panes: purpose='agent:main' held=true cmd='pi'. 3. The terminal's CLI actions are racy. Measured against a LIVE session on a 2-vCPU box: list-tabs and list-panes each fail ~60% of the time while the session is detached, ~25% with a client attached, either with empty output or "There is no active session!" (the action client consults get_active_session() before it looks at -s). That is a defect in sl0ppij -- filed as a known issue -- but these are idempotent reads, so sl0ppi retries them with backoff and distinguishes "the session is gone" from "the terminal would not answer". `sl0ppi ls` went from failing most runs to 8/8. Also: - session creation returns before the server is registered, so `up` now waits for readiness instead of racing its own next command - UserError: actionable answers ("no project foo", "that session is a saved dead copy") print and exit 1 instead of falling through to sl0ppij, which buried the explanation and started a session nobody asked for - `sl0ppi print <project>` implemented; it was referenced by the init template and the design doc but did not exist
-rw-r--r--VERSION2
-rw-r--r--cli/src/commands/workspace.ts53
-rw-r--r--cli/src/core/errors.ts14
-rw-r--r--cli/src/core/sl0ppij.ts131
-rw-r--r--cli/src/index.ts45
-rw-r--r--cli/test/sessions.test.ts39
6 files changed, 262 insertions, 22 deletions
diff --git a/VERSION b/VERSION
index 6e8bf73..17e51c3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.0
+0.1.1
diff --git a/cli/src/commands/workspace.ts b/cli/src/commands/workspace.ts
index fe7d9de..bc5d7d2 100644
--- a/cli/src/commands/workspace.ts
+++ b/cli/src/commands/workspace.ts
@@ -13,6 +13,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { checkFragment } from "./doctor.ts";
import { discoverProjects, LAYOUT_FILE, MARKER_DIR } from "../core/discover.ts";
+import { UserError } from "../core/errors.ts";
import { sessionLayoutKdl, singleTabLayoutKdl, type TabSpec } from "../core/layout.ts";
import { isNoOp, planTabs, type Plan } from "../core/plan.ts";
import { projectPurpose } from "../core/purpose.ts";
@@ -24,6 +25,8 @@ export interface Context {
client: Sl0ppij;
depth: number;
dryRun: boolean;
+ /** Discard a dead session of the same name instead of resurrecting it. */
+ fresh?: boolean;
log: (message: string) => void;
}
@@ -96,12 +99,39 @@ export function up(ctx: Context): { plan: Plan; created: boolean } {
const client = ctx.client.forSession(session);
if (!rootExists(root)) {
- throw new Error(`root does not exist: ${root}`);
+ throw new UserError(`root does not exist: ${root}`);
}
const projects = discoverProjects(root, { depth: ctx.depth });
if (!client.sessionExists(session)) {
+ // A dead session of this name is not an empty slot: `attach
+ // --create-background NAME` RESURRECTS it from the serialised copy and
+ // silently ignores --layout. Observed consequences, in order of nastiness:
+ // the tabs come back without pane purposes or commands (so no agents), and
+ // the resurrected background session then exits again within seconds.
+ //
+ // So sl0ppi refuses to guess. Resurrecting is still available -- from
+ // sl0ppij itself, in a terminal, which is where it actually works.
+ const resurrectable = client.deadSessions().includes(session);
+ if (resurrectable && !ctx.fresh) {
+ throw new UserError(
+ [
+ `session "${session}" already exists as a saved (dead) copy.`,
+ ``,
+ ` sl0ppi up --fresh discard it and rebuild from your project layouts`,
+ ` sl0ppij attach ${session}${" ".repeat(Math.max(1, 16 - session.length))}restore it as it was, in a terminal`,
+ ``,
+ `sl0ppi will not resurrect it for you: restoring in the background brings`,
+ `the tabs back without their agents, and the session exits again seconds later.`,
+ ].join("\n"),
+ );
+ }
+ if (resurrectable && ctx.fresh) {
+ if (!ctx.dryRun) client.deleteSession(session);
+ ctx.log(`discarded the saved copy of ${session}`);
+ }
+
const { specs, failures } = usableSpecs(root, projects);
for (const failure of failures) ctx.log(`! ${failure}`);
if (ctx.dryRun) {
@@ -120,6 +150,13 @@ export function up(ctx: Context): { plan: Plan; created: boolean } {
writeFileSync(layoutPath, layout);
client.createBackground(session, layoutPath);
rememberRoot(session, root);
+
+ // Creation returns before the server is registered; without this, the very
+ // next query fails with "There is no active session!".
+ if (!client.waitForSession(session)) {
+ throw new Error(`session ${session} did not come up`);
+ }
+
ctx.log(`created session ${session} (${specs.length} project tab(s))`);
return { plan: emptyPlan(), created: true };
}
@@ -227,7 +264,7 @@ export function open(ctx: Context, name: string): TabInfo {
const relPath = resolveProjectName(ctx, name);
if (!client.sessionExists(session)) {
- throw new Error(`no session ${session}; run \`sl0ppi up\` first`);
+ throw new UserError(`no session ${session}; run \`sl0ppi up\` first`);
}
const purpose = projectPurpose(relPath);
@@ -250,7 +287,7 @@ export function close(ctx: Context, name: string): void {
const client = ctx.client.forSession(session);
const purpose = projectPurpose(resolveProjectName(ctx, name));
const tab = client.listTabs().find((t) => t.purpose === purpose);
- if (!tab) throw new Error(`no open tab for ${name}`);
+ if (!tab) throw new UserError(`no open tab for ${name}`);
client.closeTab(tab.position);
ctx.log(`- ${tab.name}`);
}
@@ -275,11 +312,17 @@ export function resolveProjectName(ctx: Context, name: string): string {
const matches = projects.filter((p) => p.startsWith(name) || basename(p) === name);
if (matches.length === 1) return matches[0] as string;
if (matches.length > 1) {
- throw new Error(`ambiguous: ${name} matches ${matches.join(", ")}`);
+ throw new UserError(`ambiguous: ${name} matches ${matches.join(", ")}`);
}
- throw new Error(`no project ${name} under ${root}`);
+ throw new UserError(`no project ${name} under ${root}`);
}
function emptyPlan(): Plan {
return { create: [], close: [], keep: [], foreign: [] };
}
+
+/** The layout sl0ppi would use for a project — for eyeballing and debugging. */
+export function print(ctx: Context, name: string): string {
+ const relPath = resolveProjectName(ctx, name);
+ return singleTabLayoutKdl(specFor(ctx.resolved.root, relPath));
+}
diff --git a/cli/src/core/errors.ts b/cli/src/core/errors.ts
new file mode 100644
index 0000000..0bab4bd
--- /dev/null
+++ b/cli/src/core/errors.ts
@@ -0,0 +1,14 @@
+/**
+ * An error the user can act on, as opposed to a bug.
+ *
+ * These are printed and the process exits non-zero. They deliberately do NOT
+ * trigger the fail-open handover to sl0ppij: "no project called foo" or "that
+ * session is a saved dead copy" are answers, and replacing them with a raw
+ * multiplexer would hide the answer and start a session nobody asked for.
+ */
+export class UserError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "UserError";
+ }
+}
diff --git a/cli/src/core/sl0ppij.ts b/cli/src/core/sl0ppij.ts
index b8442b2..e5db9e8 100644
--- a/cli/src/core/sl0ppij.ts
+++ b/cli/src/core/sl0ppij.ts
@@ -133,13 +133,51 @@ export class Sl0ppij {
}
listTabs(): TabInfo[] {
- const { stdout } = this.runOk(["action", "list-tabs", "--json"], "list-tabs");
- return JSON.parse(stdout) as TabInfo[];
+ return this.readList<TabInfo>(["action", "list-tabs", "--json"], "list-tabs");
}
listPanes(): PaneInfo[] {
- const { stdout } = this.runOk(["action", "list-panes", "--json"], "list-panes");
- return JSON.parse(stdout) as PaneInfo[];
+ return this.readList<PaneInfo>(["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<T>(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<T>(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. */
@@ -162,26 +200,68 @@ export class Sl0ppij {
}
/**
- * Live sessions, excluding dead-but-resurrectable ones.
+ * 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 and drop them. (The same trap is documented in the
- * pane-finder installer.)
+ * parse the long form. (The same trap is documented in the pane-finder
+ * installer.) The dead ones matter enormously: see `deadSessions`.
*/
- liveSessions(): string[] {
+ sessions(): { name: string; dead: boolean }[] {
const result = this.run(["list-sessions", "--no-formatting"], { session: false });
- if (result.code !== 0) return []; // "no sessions" is an error exit, not a failure
+ if (result.code !== 0) return []; // "no sessions" exits non-zero, not a failure
return result.stdout
.split("\n")
- .filter((line) => line.trim() !== "" && !line.includes("EXITED"))
- .map((line) => line.trim().split(/\s+/)[0] as string)
- .filter((name) => name.length > 0);
+ .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.
*
@@ -199,6 +279,33 @@ export class Sl0ppij {
}
}
+/**
+ * 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<T>(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;
diff --git a/cli/src/index.ts b/cli/src/index.ts
index ee504cb..7211a08 100644
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -9,9 +9,10 @@
*/
import { spawnSync } from "node:child_process";
+import { UserError } from "./core/errors.ts";
import { formatCheck, gather, report, worstLevel } from "./commands/doctor.ts";
import { init, initMessage } from "./commands/init.ts";
-import { close, ls, open, up, type Context } from "./commands/workspace.ts";
+import { close, ls, open, print, up, type Context } from "./commands/workspace.ts";
import { loadRegistry, resolveRoot } from "./core/root.ts";
import { DEFAULT_BIN, insideSession, Sl0ppij } from "./core/sl0ppij.ts";
@@ -23,6 +24,7 @@ usage:
sl0ppi open <project> ensure and focus a project's tab
sl0ppi close <project> close a project's tab
sl0ppi init [dir] make a directory a project (writes .sl0ppi/layout.kdl)
+ sl0ppi print <project> show the layout sl0ppi would use for a project
sl0ppi doctor check the installation
sl0ppi help
@@ -30,6 +32,7 @@ options:
--root DIR treat DIR as the root (default: cwd, or the parent of a project)
--depth N how deep to scan for .sl0ppi markers (default: 1)
--force with init, overwrite an existing layout
+ --fresh with up, discard a dead session instead of resurrecting it
-n, --dry-run
`;
@@ -40,6 +43,7 @@ interface Args {
depth: number;
dryRun: boolean;
force: boolean;
+ fresh: boolean;
}
export function parseArgs(argv: string[]): Args {
@@ -48,6 +52,7 @@ export function parseArgs(argv: string[]): Args {
let depth = 1;
let dryRun = false;
let force = false;
+ let fresh = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i] as string;
@@ -57,11 +62,20 @@ export function parseArgs(argv: string[]): Args {
else if (arg.startsWith("--depth=")) depth = Number.parseInt(arg.slice("--depth=".length), 10);
else if (arg === "-n" || arg === "--dry-run") dryRun = true;
else if (arg === "--force") force = true;
+ else if (arg === "--fresh") fresh = true;
else rest.push(arg);
}
const command = rest.length > 0 && KNOWN_VERBS.has(rest[0] as string) ? (rest.shift() as string) : "up";
- return { command, rest, rootFlag, depth: Number.isFinite(depth) ? depth : 1, dryRun, force };
+ return {
+ command,
+ rest,
+ rootFlag,
+ depth: Number.isFinite(depth) ? depth : 1,
+ dryRun,
+ force,
+ fresh,
+ };
}
/**
@@ -73,9 +87,18 @@ export function parseArgs(argv: string[]): Args {
* anything sl0ppij would itself understand; if nothing does, a bare sl0ppij
* gets you a session.
*/
-const KNOWN_VERBS = new Set(["up", "ls", "open", "close", "init", "doctor", "help"]);
+const KNOWN_VERBS = new Set([
+ "up",
+ "ls",
+ "open",
+ "close",
+ "init",
+ "print",
+ "doctor",
+ "help",
+]);
const OUR_VERBS = KNOWN_VERBS;
-const OUR_FLAGS = new Set(["--root", "--depth", "-n", "--dry-run", "--force"]);
+const OUR_FLAGS = new Set(["--root", "--depth", "-n", "--dry-run", "--force", "--fresh"]);
export function passthroughArgs(argv: string[]): string[] {
const out: string[] = [];
@@ -139,6 +162,7 @@ export function main(argv: string[] = process.argv.slice(2)): void {
client,
depth: args.depth,
dryRun: args.dryRun,
+ fresh: args.fresh,
log: (message) => process.stdout.write(`${message}\n`),
};
} catch (error) {
@@ -173,6 +197,12 @@ export function main(argv: string[] = process.argv.slice(2)): void {
close(ctx, name);
return;
}
+ case "print": {
+ const name = args.rest[0];
+ if (!name) throw new Error("print needs a project name");
+ process.stdout.write(print(ctx, name));
+ return;
+ }
case "init": {
const dir = args.rest[0] ?? process.cwd();
process.stdout.write(`${initMessage(init(dir, { force: args.force }), dir)}\n`);
@@ -211,6 +241,13 @@ export function main(argv: string[] = process.argv.slice(2)): void {
}
}
} catch (error) {
+ // A UserError is an answer, not a malfunction: print it and stop. Handing
+ // over to sl0ppij here would bury the explanation and start a session the
+ // user never asked for.
+ if (error instanceof UserError) {
+ process.stderr.write(`sl0ppi: ${error.message}\n`);
+ process.exit(1);
+ }
fallThrough(argv, describe(error));
}
}
diff --git a/cli/test/sessions.test.ts b/cli/test/sessions.test.ts
new file mode 100644
index 0000000..00e55a2
--- /dev/null
+++ b/cli/test/sessions.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test";
+import { parseJsonList } from "../src/core/sl0ppij.ts";
+
+// The crash a user actually hit: `action list-panes --json` on a session that
+// died underneath us exits 0 with EMPTY stdout, and JSON.parse("") threw
+// "JSON Parse error: Unexpected EOF" at them on startup.
+describe("parseJsonList", () => {
+ test("empty output is nothing, not a crash", () => {
+ expect(parseJsonList("", "list-panes")).toEqual([]);
+ expect(parseJsonList(" \n", "list-panes")).toEqual([]);
+ });
+
+ test("parses a normal listing", () => {
+ expect(parseJsonList('[{"position":0}]', "list-tabs")).toEqual([{ position: 0 }] as never);
+ });
+
+ test("genuinely malformed output names the command and shows the text", () => {
+ expect(() => parseJsonList("There is no active session!", "list-tabs")).toThrow(
+ /list-tabs returned something that is not JSON: There is no active session!/,
+ );
+ });
+
+ test("a non-array payload is treated as nothing rather than crashing later", () => {
+ expect(parseJsonList('{"error":"nope"}', "list-tabs")).toEqual([]);
+ });
+});
+
+import { UserError } from "../src/core/errors.ts";
+
+// Actionable answers must not be replaced by a raw multiplexer. Falling through
+// on "no project called foo" hides the answer AND starts a session nobody asked
+// for.
+describe("UserError", () => {
+ test("is distinguishable from a bug", () => {
+ expect(new UserError("nope") instanceof UserError).toBe(true);
+ expect(new UserError("nope") instanceof Error).toBe(true);
+ expect(new Error("boom") instanceof UserError).toBe(false);
+ });
+});