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
$ 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_pluginidentify the pane; the CLI id form isterminal_<id>/plugin_<id>, which is what every-p/--pane-idflag wantstab_id,tab_position,tab_namecome along, so you can tell whether a pane is in the agent's own tab
Find a pane by purpose:
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)
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:
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)
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)
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.
zellij run --no-focus --name buildjob -- \
bash -lc 'printf "\033]5577;1;purpose;agent:build\033\\"; exec cargo build'
Tab purpose
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/<name>/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-focusputs a pane in your own tab without stealing focus — plainnew-panefollows the human's focus and lands in whatever tab they are looking at--no-focusresolves "your tab" fromZELLIJ_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); bareclose-panecloses the focused pane, possibly the human's or the agent's own --block-until-exit-successgives 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.
--purposewith--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
metabag — one string until it demonstrably is not enough.command/args/cwdalready carry "what runs here". - plugin visibility —
purposeis onPaneListEntry(CLI JSON), notPaneInfo, 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.
