1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
|
# 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.
- **`pir`** knows 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.txt` in 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:
```kdl
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 `exec`s 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 foo`
sessions are none of its business.
- No daemon, no database. All state is derived from `list-tabs --json`,
`list-panes --json` and 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`/`FloatingPaneLayout` had no
protobuf `purpose` field, 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
1. discovery + slug + `list-tabs --json` diff (the idempotency core)
2. `up`, `ls`, `open`, `close`
3. `init` scaffolding a layout with `agent:main` + `start_suspended`
4. fail-open wrapper (D9)
5. `doctor`
Deferred: `agent --resume` (needs the pir port), fuzzy picker, root order file,
eager/lazy per project, multi-root listing.
|