aboutsummaryrefslogtreecommitdiffstats
path: root/sl0p/workspace-hints-handover.md
blob: bcbd9d18060a7bc50db1b14d4c525b6a6f5aa415 (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
# 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_<id>` /
  `plugin_<id>`, 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/<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-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.