aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/commands
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 /cli/src/commands
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
Diffstat (limited to 'cli/src/commands')
-rw-r--r--cli/src/commands/workspace.ts53
1 files changed, 48 insertions, 5 deletions
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));
+}