aboutsummaryrefslogtreecommitdiffstats
path: root/docs/roadmap.md
blob: ef71070341bc52a45abf5b01465d6ab702e99ce7 (plain) (blame)
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# Roadmap

## Phase 0 — scaffolding ✅

- [x] monorepo with real history: 62 fork commits replayed into `sl0ppij/`, 33
      pi-ext commits replayed into `pi-ext/`, both verified byte-identical to
      their source trees
- [x] `config/` — the live dotfiles imported as source of truth
- [x] `FORKS.md` + `VERSION` — one sl0ppi version, explicit upstream bases
- [x] `Makefile` + `packaging/` — build → vendor → stage → tarball
- [x] proven: vendored bun runs vendored pi, so the tarball needs no system node

## Phase 1 — the rename ✅

[rename.md](rename.md) is implemented: own binary name, config dir, cache dir,
socket dir, log file and plugin URL scheme, all behind a single `branding.rs`
seam. `$ZELLIJ*` kept and `$SL0PPIJ*` added on top, ours winning on read.
Verified live beside a running stock zellij; both suites green.

Left for later (both need work disproportionate to their value right now):

- [ ] strings *inside* the default plugins (About screen, status-bar tips) still
      say "zellij" — they are baked into prebuilt `.wasm` assets
- [ ] `assets/zellij.desktop` / `zellij.rc`, used only by distro/Windows packaging

## Phase 2 — config becomes portable

- [x] `config/config.kdl` repointed at `~/.config/sl0ppij/` and the `sl0ppij:`
      plugin scheme; verified to load under the renamed binary
- [ ] decide whether the opinionated defaults are also *compiled in* as
      fallbacks, so a bare `sl0ppij` with no config dir still looks right

## Phase 3 — prove the artifact ✅

- [x] `make package` end to end → `sl0ppi-0.1.0-x86_64-linux.tar.gz` (83M,
      328M staged) + sha256
- [x] installed into a throwaway `HOME` on a minimal PATH **with `node`, `npm`,
      `bun`, `cargo` replaced by poison stubs that fail loudly**: `sl0ppij`,
      `pi` (0.84.1, on the bundled bun) and `pir` all run. Nothing reached for a
      system runtime.
- [x] real session started under a pty from the installed tree: config loads
      ("Well defined"), layout + fork pane titles render, both wasm plugins
      load. Only log line on a fresh profile is the benign "permission cache
      file not found".
- [x] **found and fixed two real packaging bugs** (see below)
- [x] **`musl` is now the release target.** The `gnu` build demanded
      `GLIBC_2.39` (Jan 2024), so the tarball would have failed on Debian 12,
      Ubuntu 22.04 and RHEL 9 -- the very machines this repo exists to install
      on. The shipped binary is now `statically linked`, with zero `GLIBC_`
      symbols. `stage.sh` fails loudly if a dynamically linked binary ever
      sneaks back in.

      Cross-compiling needs a musl C toolchain, because `isahc`/curl drags in
      `libz-sys` and `libnghttp2-sys` unconditionally (not behind
      `vendored_curl`, so it cannot be feature-flagged away). We use
      `cargo-zigbuild` + the `ziglang` python module, both already present on
      this box: no sudo, no docker daemon, no system packages.

      The tarball's remaining libc floor is **bun's glibc 2.17** (2012), which
      is effectively universal. If Alpine support is ever wanted, bun also
      publishes `bun-linux-x64-musl.zip` -- a one-line change in
      `fetch-vendor.sh`.
- [x] the tarball now ships the `sl0ppi` CLI too, bundled to a single 31K file
      by `bun build` and run on the bundled bun — no source tree, no extra
      runtime. Re-cut after the protobuf fix, so the artifact is coherent again.
- [ ] optional: `bun build --compile` pi into a single executable instead of
      shipping 183M of `node_modules` — but only if runtime-loaded `.ts`
      extensions still work in a compiled bundle. Test before committing to it.
- [ ] optional: pre-seed the plugin permission cache so a fresh install does not
      open on a "Allow? (y/n)" prompt.

### What the smoke test caught

Both were invisible to `make package` and to any amount of reading:

1. **`zjstatus` was missing entirely.** The config snapshot commit dropped it as
   "a third-party blob no longer referenced by config.kdl" — true of `config.kdl`,
   false of `layouts/default.kdl`, which builds the whole status bar out of it.
   It turned out to be a *third fork* (`sl0pstatus/`, now vendored) whose live
   `.wasm` was a custom build. Sessions started with a plugin load error.
2. **`install.sh` installed a hardcoded plugin list**, so even once staged,
   `zjstatus.wasm` never reached the config dir. Now it installs whatever the
   package contains.

Both failure modes only appear *after* a session starts, which is the argument
for keeping the clean-room session test in the release routine.

## Phase 4 — the `sl0ppi` CLI

Design agreed: [cli-design.md](cli-design.md). One root dir = one session, one
project = one tab keyed by its `project:` tab purpose, panes from a
`.sl0ppi/layout.kdl` fragment. `up` is idempotent and everything rests on that.
Entry point fails open to plain `sl0ppij` so an orchestrator bug can never cost
you a terminal.

MVP is complete: discovery + idempotent tab diff, `up`/`ls`/`open`/`close`,
`init`, `doctor`, and the fail-open entry point. 88 tests.

Deferred: `agent --resume` (needs the pir port off python3), fuzzy picker for
tab overflow, root order file, per-project eager.

## Phase 5 — distribution ✅ (scripts, not CI)

Live at **https://sl0ppi.sl0p.foo**.

- `make release` — tests, then a fresh tarball
- `make publish` — renders the site, uploads artifact + pointers, then asks the
  live site whether it agrees (status codes plus a re-downloaded checksum)
- `curl -fsSL https://sl0ppi.sl0p.foo/install.sh | sh` — verifies sha256 before
  unpacking, and refuses to install anything on mismatch (tested by pointing it
  at a doctored latest.json)

The vhost lives in the sl0pday repo (`nginx/sl0p.conf`), because that is where
that box's nginx config is deployed from. It is static-only: no app, no build on
the box.

Deliberately still not CI. A build host and a pipeline are worth it once there
is more than one target to build, and the publish path is scripted either way.

Guard rails worth knowing:

- `publish.sh` refuses to overwrite an already-published version whose bytes
  differ. `/releases/` is cached for a year, so republishing changed bytes under
  one name would serve two different things to two people. Bump `VERSION`.
- artifacts upload before pointers, so `latest.json` never names a file that is
  not downloadable yet.

---

## Fixed: sl0ppij CLI actions were racy

`zellij action` used to fail ~20-25% of the time against a live session on a
2-vCPU box, and attached clients were dropped with "lost connection to the
zellij server". Two independent causes, both now fixed and measured:

1. **Client ids were recycled instantly** (`sl0p: stop recycling client ids`).
   Every action probes liveness first (`assert_socket` connects, sends
   `ConnStatus`, disconnects), and that probe is a full client. `new_client()`
   returned the lowest free id, so the action connection got the probe's id
   back; the queued `ConnStatus` removal then deleted the *action* client, and
   its reply was dropped by `send_to_client`, which returns `Ok(())` for an
   unknown id. Result: exit 0, no output, nothing logged.

2. **Suspended panes spammed resize errors** (`sl0p: stop suspended panes
   spamming resize errors`). A reserved-but-unstarted terminal has no fd, which
   was treated as an error and logged per pane per resize, on the server's hot
   path. sl0ppi puts a suspended agent in every project, so it was the heaviest
   possible user of that path.

|  | before | after |
|---|---|---|
| `list-tabs` | ~20-25% fail | **0/50** |
| `list-panes` | ~20-25% fail | **0/50** |
| `list-tabs`, client attached | ~25% fail | **0/30** |
| `sl0ppi ls` | failed most runs | **0/15** |
| 5 attach cycles | dropped | **5/5 stable** |
| resize errors logged | 126+ | **0** |

Worth revisiting: `send_to_client` silently returns `Ok(())` for an unknown
client id. An unknown id is a caller bug, and swallowing it is what made this
take a day to find instead of a minute.

## Known issue: `attach --create-background` leaves a phantom client

A session created with `attach --create-background` keeps one client in its
connected set for the rest of its life. Nothing renders that client directly,
but it makes `connected_clients.len() > 1`, so `multiple_users_exist_in_session`
is true and **every pane frame shows its multi-user form** — `MY FOCUS AND:`
followed by one anonymous colour block. It looks like a stranger is in your
session.

Isolated by comparison, on the test box:

| session created by | marker on first attach |
|---|---|
| `attach --create-background` | **yes**, and on every later attach |
| ordinary `attach --create` whose client later dies | no |

Exactly one phantom, always the session's first client; it does not accumulate.
Not caused by the client-id change — that only made it *visible*, since recycled
ids used to hand the phantom's id straight to the next attaching client.

Things ruled out by measurement: it is not the CLI-action clients (a session with
15 actions and no attach shows nothing extra), and it is not a race with the
creating client exiting too early (making that client wait for a server ack
before exiting — verified 94ms, server answered — changes nothing, so that
experiment was reverted rather than shipped).

**Worked around, not fixed.** `sl0ppi up` now creates the session by *attaching*
to it whenever it has a terminal, which is the path that does not leak; the
background path remains for headless use, where no one is looking at a frame.
The fork bug is still there for anyone calling `zellij attach -b`, and the next
step is to instrument `ScreenInstruction::RemoveClient` on a debug build to find
why the removal never reaches `screen.connected_clients`.

## Fixed: panes stacked after a responsive collapse/expand cycle

A pane added after a narrow->wide cycle was absorbed into a stack at full width.
The expand had never actually happened: `SwapLayouts::tiled_layout_at` checked
whether a layout fits by positioning it against `visible_panes_count()`, and a
stack hides all its members but one, so a layout with no room for the hidden
panes passed as feasible. Fixed by checking against every tiled pane.

**How to re-run the check** (the tab test harness cannot express this -- it does
no rendering, so `panes_to_hide` is always empty and both counts are equal):

```bash
# a pty that starts narrow and widens mid-session
python3 resz.py 80 24 160 45 30 sl0ppi     # see the commit for the script
sl0ppij -s dev action new-pane
sl0ppij -s dev action list-panes --json    # compare (x, y, cols, rows) per pane
```

Two panes sharing one rect means a stack; distinct rects mean it really
expanded. Check both directions (create narrow then widen, create wide then
narrow) and add panes afterwards.

## Fixed: the `+` split button was drawn nowhere and clicked in the gap

Shipped in 0.1.7 and 0.1.8 without ever being visible on a live session, while
rendering in 265 unit snapshots. Four separate faults, each individually enough
to hide it:

- `title_line_without_middle` computed the right side only if the pane had a
  title -- and `hide_default_pane_names true` means unnamed panes have none;
- `title_line_with_middle` dropped the right side whenever the left was absent,
  which is every frame in a session with a second client (`MY FOCUS AND:`);
- the title was budgeted before the button, so a title longer than the frame
  (i.e. any shell showing a path) consumed its columns;
- the click hit-tested the pane's allocation while the frame is drawn inset by
  the pane gap, putting the clickable columns two to the right of the glyph;
- and, found after that shipped, stacks move the frame twice more: a stack list
  pushes the visible member's frame down by its reserved rows, and the collapsed
  members drew a button on a one-row title that nothing could ever honour.

The first three are fixed in `ui/pane_boundaries_frame.rs` by reserving the
anchored button before anything else and never discarding the right side; the
rest by `Pane::frame_rect_with_top_reserved()`, the rect the frame is actually
drawn in, and by not drawing a button on a stack's collapsed members.

**How to re-run the check** -- unit tests were necessary and insufficient here
for the fourth time, so this one reads the screen:

```bash
make check-live      # packaging/checks/split-button-live.py
```

It starts a session at a pty, finds the `+` on the frame, attaches a second
client (the middle builder), splits and stacks, then clicks every button it can
see and expects one new pane per click. It runs twice, with a minimal config and
with `config/config.kdl` -- the real one, whose stacks are where the second
round of bugs was. Every fix was confirmed by reintroducing it and watching the
check fail.

If it ever disagrees with the code, do not theorise about the screen: put a
`log::info!` in `intercept_split_button_click` and read
`/tmp/sl0ppij-$UID/sl0ppij-log/`. The server's own geometry settles in one run
what screen-scraping cannot.

## Fixed: the shipped plugins asked for permission on first launch

pane-finder requests three permissions as it loads, so the first press of its
keybinding on any new machine opened a consent screen instead of a fuzzy finder
-- a nag about our own software, on a binding we ship.

Plugins we ship are now granted implicitly: builtins (via the existing
`PluginConfig::is_builtin`, the same predicate `check_command_permission`
already uses) and any wasm inside a plugin directory we install into -- the
config dir's `plugins/` and the data dir's `plugins/`. The rule is
`plugin_is_shipped_with_sl0ppi` in `zellij-utils/src/input/permission.rs`, and
it is deliberately narrow:

- the same wasm from any other path still prompts;
- remote plugins are never trusted;
- paths are normalised lexically first, so `plugins/../../evil.wasm` is not
  "inside" the plugin dir;
- the trusted directories are derived from the environment and the default
  locations, so getting them wrong costs a prompt and can never grant one
  somewhere else.

The trade is that `<config dir>/plugins/` is now a trusted directory, documented
in `config/README.md`: anything that can write there can already put arbitrary
commands on a keybinding in `config.kdl`.

**How to re-run the check**:

```bash
make check-live      # includes packaging/checks/plugin-permissions-live.py
```

It launches the real pane-finder wasm from a temp config dir and asserts it
runs without the prompt, then launches the *same file* from outside that dir and
asserts it still asks. Confirmed by disabling the rule and watching the first
half fail with the exact "asks permission to ... Allow? (y/n)" screen.

## Standing risks

**`sl0ppij/target` is a symlink to `~/lol/zellij/target`.** Introduced when the
box had 12G free, to reuse the old checkout's compiled dependencies instead of
building a second multi-GB tree. `CARGO_TARGET_DIR` alone was not enough,
because the debug-plugin `include!` path resolves relative to the source tree.

This is the one thing tying the monorepo to the old checkout: **deleting
`~/lol/zellij` leaves a dangling symlink and confusing build errors.** To cut the
cord (costs one ~5min cold release build):

```bash
rm sl0ppij/target && make build plugins
```

`sl0pstatus/target` is already a real directory (572M).

**Release builds are slow by design.** `make build` is 5m16s here (LTO,
`codegen-units = 1`); `make dev` is the ~5s iteration loop. Do not put `make
build` in an edit cycle.

That loop was 52s until the `fastdev` profile started splitting the crates we
edit into 256 codegen units. zellij-server is one enormous crate, so with 16
units a one-line change re-optimised a sixteenth of it; the incremental cache
can only skip work at codegen-unit granularity. Measured on the same one-line
change, checkout to linked binary: 52s at `codegen-units = 16`, 6s at 256, 5s
at 256 plus `opt-level = 1` (not worth a slower binary). Linking the 69MB
binary is only ~2s, and lld did not improve on it -- the linker is not the
problem here, and neither is opt-level.

**pi version coupling.** `pi-ext/` targets pi's extension API, pinned at 0.84.1.
Bumping the pin is an integration test, not a version bump.

**The OSC 5577 protocol is unversioned.** Fork and extensions are currently
kept in sync by being released together. If sl0ppij and pi-ext ever get
installed independently, this needs a handshake.