# Handover: pi side of workspace hints The zellij half is **done and on `origin/main`** (`sl0ppij`, `zellij --version` → `0.45.0+sl0p1`). This describes exactly what the terminal now gives you, what it deliberately does not, and the traps that will cost you an afternoon each. Design rationale (and what was rejected): `sl0p/workspace-hints-proposal.md`. ## The contract A pane can carry one **opaque string** called its `purpose`. **zellij never interprets it. You own the entire vocabulary.** Nothing in the terminal knows or cares what `dev-server` means, and nothing enforces anything based on it. That is deliberate: it means you can change the vocabulary whenever you like without touching or rebuilding zellij. ## Reading purposes ```console $ zellij action list-panes --json [ { "id": 0, "title": "editor", "tab_id": 0, "tab_name": "work", "purpose": "human-editor", ... }, { "id": 1, "title": "server", "tab_id": 0, "purpose": "dev-server", ... }, { "id": 2, "title": "plain", "tab_id": 0, ... }, { "id": 3, "title": "buildjob", "tab_id": 0, "purpose": "agent:build", ... } ] ``` - the key is **omitted entirely** when a pane has no purpose — do not expect `null` - `id` + `is_plugin` identify the pane; the CLI id form is `terminal_` / `plugin_`, which is what every `-p/--pane-id` flag wants - `tab_id`, `tab_position`, `tab_name` come along, so you can tell whether a pane is in the agent's own tab Find a pane by purpose: ```bash id=$(zellij action list-panes --json \ | python3 -c 'import json,sys; print(next((f"terminal_{p[\"id\"]}" for p in json.load(sys.stdin) if p.get("purpose")=="dev-server"), ""))') ``` ## Writing purposes: two paths, and they are not equal ### 1. The workspace file (authoritative) ```kdl layout { tab name="work" { pane name="editor" purpose="human-editor" pane name="server" purpose="dev-server" command="npm" { args "run" "dev"; } pane name="logs" purpose="logs" command="tail" { args "-f" "log/dev.log"; } pane name="agent" purpose="agent-scratch" } } ``` Works on tiled and floating panes. Start it with: ```bash zellij --layout .pi/workspace.kdl attach --create myproject ``` **Not** `zellij -s NAME --layout FILE` — that means "add this layout as a *tab* to existing session NAME" and fails if the session does not exist. This bites everyone once. ### 2. `--purpose` at spawn time (authoritative) ```bash id=$(zellij run --no-focus --purpose agent:build --name job -- cargo build) zellij action new-pane --no-focus --floating --purpose agent:scratch ``` Rejected with `--in-place` (that replaces another pane rather than being one), so you get an error rather than a flag that silently does nothing. ### 3. In-band, from inside the pane (fills blanks only) ```bash printf '\033]5577;1;purpose;agent:build\033\\' ``` For a program that decides its own role as it runs. Prefer `--purpose` when you are the one spawning the pane. ```bash zellij run --no-focus --name buildjob -- \ bash -lc 'printf "\033]5577;1;purpose;agent:build\033\\"; exec cargo build' ``` ### Tab purpose ```kdl tab name="work" purpose="agent-work" { ... } ``` Reported by `zellij action list-tabs --json` as `purpose`. Panes carry `tab_id`, so join the two listings when you need "the pane in the agent's tab". Tabs have no in-band path -- a tab is not a thing a program runs inside. ### The asymmetry you must not design against **A layout-declared or `--purpose` purpose outranks an in-band one and cannot be overridden or cleared by it.** (`--purpose` counts as out-of-band: whoever can run the CLI can already do anything.) Because OSC arrives in-band from whatever the pane is showing: without this rule, `cat hostile.txt` in the human's editor pane could relabel it `agent-scratch` and invite an agent to run commands where the human is working. A layout is written by a human in a file they can read; in-band data can only fill a blank. Consequences for you: - a pane declared in the workspace keeps its purpose for life — do not try to re-tag it and silently succeed at nothing (the OSC is ignored, not an error) - an untagged pane can be tagged once by whatever runs in it - **after resurrection everything is authoritative**, because restored purposes come back through the layout. So a pane your agent tagged in-band cannot be re-tagged after a session restart. If a pane's role genuinely changes over its life, close it and spawn a fresh one — cheap, and it keeps the audit trail honest. ## What is sanitised, and what that does not buy you Values are reduced to a bounded token at both entry points: `[A-Za-z0-9_.:/-]`, max 64 chars, and anything sanitising to nothing becomes absent. A purpose is never rendered — it is matched against and pasted into your shell/JSON pipelines, so whitespace, quotes and `;` do not survive. `rm -rf /; echo "x"` arrives as `rm-rf/echox`. **This is not a trust boundary.** Layout files already run arbitrary commands via `command=`, so a workspace file committed to a repo is *already* arbitrary code execution. Sanitising `purpose` changes nothing about that. The real rule is yours: **do not auto-open a workspace file from a repo nobody has read.** Please say so plainly in the pi-side docs rather than letting the presence of a sanitiser imply safety. ## Durability Purposes survive detach/reattach and kill/resurrect. Verified: layout-declared and in-band ones both came back after `kill-session` + `attach`. One timing detail that will confuse you while testing: the resurrection snapshot is written by a **periodic** serialiser (~1 min in this config), not on exit. Kill a session 20 seconds after creating it and there is nothing to resurrect and the session is simply *gone*. Wait for `~/.cache/zellij/contract_version_1/session_info//session-layout.kdl` to exist before testing resurrection. ## Suggested division of labour Nothing below is enforced by zellij; it is convention, and it is yours to set. | purpose kind | who creates it | agent behaviour | |---|---|---| | `human-*` | workspace file | read with `dump-screen -p`, never write | | `dev-server`, `logs` | workspace file, self-starting commands | read; restart only if asked | | `agent:*` | agent spawns and tags | free to use, and **close when done** | The trap worth encoding: using a pane you did not create means writing keystrokes into whatever is currently running there, which types `cargo build` into a vim session the moment state drifts. Prefer spawning your own tagged pane over typing into someone else's. Declared panes are best for things that start themselves, or for the human. ## Agent house rules that already exist There is a skill at `~/.the assistant/agent/skills/zellij-panes/SKILL.md` covering the terminal habits this builds on, all verified against the binary: - `zellij run --no-focus` puts a pane in **your own tab** without stealing focus — plain `new-pane` follows the human's focus and lands in whatever tab they are looking at - `--no-focus` resolves "your tab" from `ZELLIJ_PANE_ID`, and **silently** falls back to the human's focused tab if that variable is missing. Never strip it - read and close by id (`dump-screen -p`, `close-pane -p`); bare `close-pane` closes the *focused* pane, possibly the human's or the agent's own - `--block-until-exit-success` gives you the real exit code from a visible pane, so you never need to scrape output - never `kill-all-sessions` / `delete-all-sessions`; act only on sessions you created, by exact name ## Not implemented, on purpose Ask if you need any of these; each was left out for a stated reason, not forgotten. - **`--purpose` with `--in-place`** — an in-place pane replaces another rather than being one. Rejected by clap, not silently ignored. - **purposes on plugin panes** — plugin panes are zellij's own UI. - **a structured `meta` bag** — one string until it demonstrably is not enough. `command`/`args`/`cwd` already carry "what runs here". - **plugin visibility** — `purpose` is on `PaneListEntry` (CLI JSON), not `PaneInfo`, which crosses the plugin protobuf API we cannot regenerate. If a plugin needs it, that is a separate conversation. - **any enforcement** — zellij cannot enforce agent policy; an agent is just a process running commands. Purposes are advisory, permanently. ## Where the code is `git log --oneline -1` → `df052db4 sl0p: per-pane workspace hints (purpose=)` | concern | file | |---|---| | sanitiser, layout fields | `zellij-utils/src/input/layout.rs` (`sanitize_pane_purpose`) | | KDL parse + allowlist | `zellij-utils/src/kdl/kdl_layout_parser.rs` | | OSC verb, authority rule | `zellij-server/src/panes/grid.rs` (`declare_purpose_in_band`) | | `list-panes` exposure | `zellij-server/src/screen.rs` (`collect_pane_list`), `zellij-utils/src/data.rs` (`PaneListEntry`) | | serialise / resurrect | `zellij-utils/src/session_serialization.rs` | Tests: `cargo test -p zellij-server -p zellij-utils --lib` (1499 + 464 green). Search `sl0p_pane_purpose` and `sl0p_in_band_purpose` for the behaviours above, including the override-attack case.