The sl0ppi CLI — design
Status: agreed, not yet implemented. This is the contract the MVP is built against. Decisions marked (deferred) are deliberately out of the MVP.
The problem
sl0ppij list-sessions on the development box returns 65 sessions, nearly
all EXITED, named effulgent-magpie, chatty-ocelot, rusty-capsicum.
Nothing maps a session to a project, so resuming work means remembering which
animal was which repo.
The two halves of sl0ppi each solve part of this and do not talk to each other:
- sl0ppij knows the terminal arrangement, and via
purpose=can record what each pane and tab is for — but it deliberately never interprets that string. pirknows the agent history for a directory — but nothing about the terminal.
The CLI's job is to own the vocabulary the fork refuses to own, and to make reattaching restore both halves at once.
Model
sl0ppij session = a ROOT dir ~/dev -> session "dev"
tab = a PROJECT ~/dev/foo -> purpose "project:foo.a1b2c3"
panes = .sl0ppi/layout.kdl -> purposes "agent:main", "service:web", ...
One root, one session. One project, one tab. Panes come from the project.
Identity lives in the tab purpose
The tab name is a UI string a human renames; the tab purpose is the key.
This is not a preference, it is what the fork guarantees (see
sl0ppij/sl0p/workspace-hints-handover.md):
- reported by
sl0ppij action list-tabs --json - layout-declared purposes outrank in-band ones and cannot be overridden, so
cat hostile.txtin a pane cannot relabel a project tab - purposes survive resurrection, because restored tabs come back through the layout — so identity is stable across a session restart
Slug format: project:<basename>.<hash8> where the hash is of the absolute
path. Keeps identity unique when two roots both contain api, and stays well
inside the 64-char limit. The sanitiser accepts [A-Za-z0-9_.:/-], so the
format survives verbatim.
Commands
sl0ppi attach the root session. Inside a project? focus its tab
sl0ppi up [--root DIR] create/attach, then ensure one tab per discovered project
sl0ppi ls projects x (tab? panes? agent alive?)
sl0ppi open <project> ensure + focus a tab (creates on demand)
sl0ppi close <project> close the tab, leave the session
sl0ppi init [dir] scaffold .sl0ppi/layout.kdl
sl0ppi agent [project] focus the agent:* pane; --resume picks up its pi history
sl0ppi doctor verify install, plugins, config, versions
up is idempotent, and everything rests on that
up diffs the discovered projects against list-tabs --json and creates only
what is missing. Running it fifty times produces the same session. If this
property ever breaks, the tool is worse than useless, so it is the first thing
the tests assert.
Decided
D1 — Discovery is depth-1. Direct children of the root containing a
.sl0ppi/ directory. Recursive scanning of ~/dev is slow and surprising.
sl0ppi open path/to/nested always works explicitly.
D2 — Directories without .sl0ppi/ are ignored. No implicit tabs. Use
sl0ppi init to opt a project in.
D3 — Tab order is alphabetical. MVP. A root-level order file is (deferred).
D4 — Stale tabs auto-close. A tab whose project: purpose points at a
missing directory is closed by up/ls. Rationale: the purpose encodes an
absolute path, so "the directory is gone" is unambiguous. Caveat to watch: a
temporarily unmounted path would look identical to a deleted one, so auto-close
must skip closing when the root itself is missing (that is a mount problem,
not a deleted project).
D5 — Every project declares an agent. The scaffolded layout includes a pane
with purpose="agent:main" running pi.
D6 — Command panes are start_suspended by default. A root with twelve
projects must not become twelve running servers and twelve agents on up. The
tab and its arrangement exist; nothing executes until the pane is focused and
gets a keypress. --eager opts in per run.
D7 — .sl0ppi/layout.kdl is a fragment: pane nodes only, no wrapping
layout {} or tab {}. sl0ppi composes the tab around it:
tab name="foo" purpose="project:foo.a1b2c3" cwd="/home/user/dev/foo" {
<fragment verbatim>
}
Pure string composition, no KDL parser, and sl0ppi keeps sole control of
identity and cwd. sl0ppi print <project> emits the full layout for use with
plain sl0ppij --layout.
D8 — Running sl0ppi inside the session focuses, it does not nest. Detected
via $SL0PPIJ/$ZELLIJ.
D9 — sl0ppi is the daily entry point, and it fails open. Any error — bad
layout, unreadable root, unexpected state — warns on stderr and execs plain
sl0ppij with the original arguments. An orchestrator bug must never leave you
without a terminal; when the environment breaks, the terminal is what you debug
it with. sl0ppij stays fully usable on its own, and sl0ppi ignores sessions it
did not create.
D10 — TypeScript on the bundled bun. We already ship bun and pi's ./client
SDK, and pi-ext is TypeScript, so this adds no runtime. It is mostly JSON and
process spawning; a Rust binary would share nothing with the extensions. It also
gives pir a path off its undeclared python3 dependency (see below).
Non-goals
- Not a session manager competing with zellij's; ad-hoc
sl0ppij -s foosessions are none of its business. - No daemon, no database. All state is derived from
list-tabs --json,list-panes --jsonand the filesystem, so there is nothing to desync. - Never required. Deleting sl0ppi leaves sl0ppij and pi working exactly as now.
- Does not auto-launch agents you did not ask for (hence D6).
Known problem: tab overflow
Twelve projects is twelve tabs in a status bar that shows maybe six before it
truncates. At that scale the tab bar is a status display, not navigation — which
is presumably why zj-pane-finder exists. So sl0ppi open should be a fuzzy
picker rather than a name argument you must remember.
MVP: sl0ppi open <name> with prefix matching. A picker is the first thing to
add after, and if tabs turn out to be the wrong container at 20+ projects, that
is the design review trigger.
Known problem: pir is not self-contained
The shipped bin/pir is #!/usr/bin/env python3 and shells out to fzf.
Neither is in the tarball; the clean-room test only passed because /usr/bin
was on PATH. So the packaging claim "no system runtime needed" is currently
true for sl0ppij and pi, and false for pir.
Absorbing pir into sl0ppi agent --resume (D10) removes the python3
dependency. fzf then becomes either a declared external dependency or an
internal picker.
Fork changes this design required
Designing against the fork turned up one real gap, found by integration-testing rather than reading:
- purposes were dropped by runtime
new-tab(sl0p: carry pane and tab purposes over the wire).TiledPaneLayout/FloatingPaneLayouthad no protobufpurposefield, so a layout sent as an Action lost every purpose it declared. Session-creation layouts kept theirs, which is why the feature looked fine until an orchestrator tried to add a tab. Both protobuf schemas now carry it, sanitised on ingest.
Also worth recording, because it costs an afternoon otherwise: KDL nodes on a
single line need ; terminators. tab x { pane } fails to parse; the
generator emits multi-line KDL, which sidesteps it entirely.
MVP scope
- discovery + slug +
list-tabs --jsondiff (the idempotency core) up,ls,open,closeinitscaffolding a layout withagent:main+start_suspended- fail-open wrapper (D9)
doctor
Deferred: agent --resume (needs the pir port), fuzzy picker, root order file,
eager/lazy per project, multi-root listing.
