/** * up / ls / open / close. * * These are the plumbing on top of the planner. The rule they all obey: read * the world (discovery + list-tabs), compute a plan, then apply it — never * mutate opportunistically while walking, because that is how `up` stops being * idempotent. */ import { existsSync, readFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; 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 { templatesFrom, sessionLayoutKdl, singleTabLayoutKdl, type TabSpec, } from "../core/layout.ts"; import { isNoOp, planTabs, type Plan } from "../core/plan.ts"; import { projectPurpose } from "../core/purpose.ts"; import { rememberRoot, rootExists, type ResolvedRoot } from "../core/root.ts"; import { Sl0ppij, type TabInfo } from "../core/sl0ppij.ts"; export interface Context { resolved: ResolvedRoot; client: Sl0ppij; depth: number; dryRun: boolean; /** Discard a dead session of the same name instead of resurrecting it. */ fresh?: boolean; /** * Do not create the session here; return the layout so the caller can create * it BY ATTACHING to it. See `UpResult.createWith`. */ deferCreate?: boolean; /** Cached tab template; "" means "looked, found none". */ tabTemplate?: string; /** Cached pane_template definitions the tab template may refer to. */ paneTemplates?: string[]; log: (message: string) => void; } /** * The tab template every generated tab is wrapped in, so sessions keep the * status/tab bar the shipped config provides. Without it a multi-project * session shows no tab bar at all and the other projects are invisible. */ function tabTemplate(ctx: Context): string | undefined { loadTemplates(ctx); return ctx.tabTemplate === "" ? undefined : ctx.tabTemplate; } /** * The `pane_template` definitions the tab template refers to. * * A generated layout is a file of its own: if the template body says * `sl0pbar size=3`, the definition has to travel with it or the session fails * to parse with "Invalid tab property: sl0pbar". */ function paneTemplates(ctx: Context): string[] { loadTemplates(ctx); return ctx.paneTemplates ?? []; } function loadTemplates(ctx: Context): void { if (ctx.tabTemplate !== undefined) return; const templates = templatesFrom(sl0ppijConfigDir()); ctx.tabTemplate = templates.tabTemplate ?? ""; ctx.paneTemplates = templates.paneTemplates; } export function sl0ppijConfigDir(env: NodeJS.ProcessEnv = process.env): string { if (env.SL0PPIJ_CONFIG_DIR) return env.SL0PPIJ_CONFIG_DIR; return join(env.HOME ?? "", ".config", "sl0ppij"); } function fragmentFor(root: string, relPath: string): string | undefined { const path = join(root, relPath, MARKER_DIR, LAYOUT_FILE); try { const text = readFileSync(path, "utf8").trim(); return text.length > 0 ? text : undefined; } catch { return undefined; // no fragment: the default (one suspended agent) applies } } function specFor(root: string, relPath: string): TabSpec { return { name: basename(relPath), relPath, cwd: join(root, relPath), fragment: fragmentFor(root, relPath), }; } /** * Specs for the projects we can actually open, plus what went wrong with the * rest. * * A fragment is checked before it is used, on BOTH paths. This matters most * when creating a session, where every tab goes into one layout: a single * unbalanced brace would otherwise take down the whole root rather than the one * project that is broken. */ export function usableSpecs( root: string, relPaths: string[], ): { specs: TabSpec[]; failures: string[] } { const specs: TabSpec[] = []; const failures: string[] = []; for (const relPath of relPaths) { const spec = specFor(root, relPath); const issue = spec.fragment ? checkFragment(spec.fragment) : undefined; if (issue && !issue.startsWith("empty")) { failures.push(`${relPath}: ${issue}`); continue; } specs.push(spec); } return { specs, failures }; } function computePlan(ctx: Context, tabs: TabInfo[]): Plan { const { root } = ctx.resolved; return planTabs({ root, rootExists: rootExists(root), projects: discoverProjects(root, { depth: ctx.depth }), tabs, pathExists: existsSync, }); } /** * Create the session, or bring an existing one in line with the filesystem. * * Two paths, one composer: a brand new session is created from a whole-session * layout (which is also the only way tab purposes survived before the fork fix), * and an existing one gets tabs added individually. */ export interface UpResult { plan: Plan; created: boolean; /** * Set when the session still needs creating and the caller should do it by * attaching: `sl0ppij --layout attach --create `. * * Creating with `attach --create-background` leaves a phantom client in the * session for its whole life -- measured: a background-created session shows * "MY FOCUS AND:" on its very first attach, one created by an ordinary attach * never does. Deferring to the attach we were about to do anyway avoids it * entirely; the background path stays for headless use, where nobody is * looking at a pane frame. */ createWith?: string; } export function up(ctx: Context): UpResult { const { root, session } = ctx.resolved; const client = ctx.client.forSession(session); if (!rootExists(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) { ctx.log(`would create session ${session} with ${specs.length} tab(s)`); for (const spec of specs) ctx.log(` + ${spec.relPath}`); return { plan: emptyPlan(), created: true }; } // A session must be born with at least one tab; an empty root gets a plain // one rather than an error, so `sl0ppi` still gives you a terminal. const layout = specs.length > 0 ? sessionLayoutKdl(specs, tabTemplate(ctx), paneTemplates(ctx)) : "layout {\n tab name=\"scratch\" {\n pane\n }\n}\n"; const dir = mkdtempSync(join(tmpdir(), "sl0ppi-layout-")); const layoutPath = join(dir, "session.kdl"); writeFileSync(layoutPath, layout); if (ctx.deferCreate) { rememberRoot(session, root); ctx.log(`creating session ${session} (${specs.length} project tab(s))`); return { plan: emptyPlan(), created: true, createWith: layoutPath }; } 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 }; } const plan = computePlan(ctx, client.listTabs()); if (ctx.dryRun) { if (isNoOp(plan)) ctx.log("nothing to do"); for (const action of plan.create) ctx.log(` + ${action.relPath}`); for (const tab of plan.close) ctx.log(` - ${tab.name} (stale)`); return { plan, created: false }; } // Closes first, in DESCENDING position order: closing a tab renumbers every // tab after it, so ascending order would target the wrong ones. for (const tab of [...plan.close].sort((a, b) => b.position - a.position)) { client.closeTab(tab.position); ctx.log(`- ${tab.name} (project gone)`); } // One project must not be able to stop the others opening. A broken fragment // is the project's problem; `up` reports it and carries on, because the whole // point is to bring up a working root. const { specs, failures } = usableSpecs( root, plan.create.map((action) => action.relPath), ); for (const failure of failures) ctx.log(`! ${failure}`); for (const spec of specs) { try { client.newTab(singleTabLayoutKdl(spec, tabTemplate(ctx), paneTemplates(ctx))); ctx.log(`+ ${spec.relPath}`); } catch (error) { const message = error instanceof Error ? error.message.split("\n")[0] : String(error); failures.push(`${spec.relPath}: ${message}`); ctx.log(`! ${spec.relPath} failed — ${message}`); } } // Registered even when some projects failed: the session exists and is ours, // and forgetting it would orphan every tab that did open. rememberRoot(session, root); if (isNoOp(plan)) ctx.log("up to date"); if (failures.length > 0) { ctx.log(`${failures.length} project(s) did not open; \`sl0ppi doctor\` explains why`); } return { plan, created: false }; } export type AgentState = "running" | "suspended" | "exited" | "none"; export interface ProjectStatus { relPath: string; tab?: TabInfo; /** Terminal panes only; plugin panes (status bar, link handler) are chrome. */ panes: number; agent: AgentState; } /** * `is_held` is how a `start_suspended` pane reports itself: the command is * staged but has not run, waiting for a keypress. It is NOT `exited` — that * means the command ran and finished. */ export function agentStateOf(pane: { is_held?: boolean; exited?: boolean } | undefined): AgentState { if (pane === undefined) return "none"; if (pane.is_held) return "suspended"; if (pane.exited) return "exited"; return "running"; } export function ls(ctx: Context): { projects: ProjectStatus[]; stale: TabInfo[] } { const { root, session } = ctx.resolved; const client = ctx.client.forSession(session); const projects = discoverProjects(root, { depth: ctx.depth }); if (!client.sessionExists(session)) { return { projects: projects.map((relPath) => ({ relPath, panes: 0, agent: "none" })), stale: [], }; } const tabs = client.listTabs(); const panes = client.listPanes(); const byPurpose = new Map(tabs.filter((t) => t.purpose).map((t) => [t.purpose as string, t])); const statuses: ProjectStatus[] = projects.map((relPath) => { const tab = byPurpose.get(projectPurpose(relPath)); if (!tab) return { relPath, panes: 0, agent: "none" }; const tabPanes = panes.filter((p) => p.tab_id === tab.position && !p.is_plugin); const agentPane = tabPanes.find((p) => p.purpose?.startsWith("agent:")); return { relPath, tab, panes: tabPanes.length, agent: agentStateOf(agentPane) }; }); const plan = computePlan(ctx, tabs); return { projects: statuses, stale: plan.close }; } /** Ensure a tab exists for `name`, then focus it. */ export function open(ctx: Context, name: string): TabInfo { const { root, session } = ctx.resolved; const client = ctx.client.forSession(session); const relPath = resolveProjectName(ctx, name); if (!client.sessionExists(session)) { throw new UserError(`no session ${session}; run \`sl0ppi up\` first`); } const purpose = projectPurpose(relPath); let tab = client.listTabs().find((t) => t.purpose === purpose); if (!tab) { const position = client.newTab( singleTabLayoutKdl(specFor(root, relPath), tabTemplate(ctx), paneTemplates(ctx)), ); ctx.log(`+ ${relPath}`); tab = client.listTabs().find((t) => t.position === position) ?? { position, name: basename(relPath), purpose, }; } client.focusTab(tab.position); return tab; } export function close(ctx: Context, name: string): void { const { session } = ctx.resolved; 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 UserError(`no open tab for ${name}`); client.closeTab(tab.position); ctx.log(`- ${tab.name}`); } /** * Turn what the user typed into a project path. * * Exact match wins, then unique prefix. An ambiguous prefix is an error rather * than a guess — picking one for them is how you end up closing the wrong tab. */ export function resolveProjectName(ctx: Context, name: string): string { const { root } = ctx.resolved; const projects = discoverProjects(root, { depth: ctx.depth }); if (projects.includes(name)) return name; const asPath = resolve(ctx.resolved.root, name); if (existsSync(join(asPath, MARKER_DIR))) { const rel = asPath.startsWith(`${root}/`) ? asPath.slice(root.length + 1) : null; if (rel) return rel; } 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 UserError(`ambiguous: ${name} matches ${matches.join(", ")}`); } 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), tabTemplate(ctx), paneTemplates(ctx), ); }