Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699)

* fix(cli): address a tab by the bare id --json hands back

`parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json`
— the one id a caller is certain of — was the one shape the CLI refused.
`parse_pane` already made `%` optional for exactly this reason (#538); this
aligns tabs with it, keeping the digits-only guard so a leading `+` cannot
read as an ordinal now that the sigil is gone.

* docs(skill): hand a pane worker its interactive mode

The worked example passed the task with `-p`, which draws nothing: the pane
stays blank until the turn ends, `capture --plain` reads back empty, and the
user watching their tty7 window sees a worker that looks hung. Putting a
piped worker in a pane discards the only reason it is in one.

Also documents three things that cost real debugging time: a fresh pane can
swallow the Enter while its shell is still running startup files, `tty7 procs`
reports nothing running for a pane with a live agent in it, and the OSC 777
event stream in a raw `capture` is what actually answers "is it moving".
This commit is contained in:
l0ng-ai
2026-08-20 18:28:48 +08:00
committed by GitHub
parent 47e25ef854
commit 8131ac2f93
2 changed files with 109 additions and 9 deletions
+44 -7
View File
@@ -60,19 +60,28 @@ pub fn parse_pane(s: &str) -> Result<u64> {
}
pub fn parse_tab(s: &str) -> Result<TabAddress> {
let body = s
.strip_prefix('@')
.ok_or_else(|| anyhow!("'{s}' is not a tab address tabs look like @7"))?;
// The `@` is optional, for the reason it is optional on a pane (#538): every
// `--json` payload spells tabs bare, so the id `tty7 tab new --json` just
// handed back has to address the tab it created. Demanding the sigil made
// the one id you are certain of the one shape the CLI refused.
let body = s.strip_prefix('@').unwrap_or(s);
let not_an_address =
|| anyhow!("'{s}' is not a tab address — @7 as numbered by `tty7 ls`, or a full tab id");
if body.is_empty() {
bail!("'{s}' is not a tab address — tabs look like @7");
return Err(not_an_address());
}
if let Ok(n) = body.parse::<u64>() {
return Ok(TabAddress::Ordinal(n));
// Digits and nothing else. `u64::from_str` also takes a leading `+`, and
// with the `@` gone that would read `+5` as tab 5 rather than as a typo.
if body.bytes().all(|b| b.is_ascii_digit()) {
return body
.parse()
.map(TabAddress::Ordinal)
.map_err(|_| not_an_address());
}
if looks_like_uuid(body) {
return Ok(TabAddress::Id(body.to_string()));
}
bail!("'{s}' is not a tab address — @7 as numbered by `tty7 ls`, or @<full tab id>");
Err(not_an_address())
}
fn looks_like_uuid(s: &str) -> bool {
@@ -163,6 +172,34 @@ mod tests {
assert_eq!(parse_pane("%42").unwrap(), 42);
}
#[test]
fn a_bare_tab_id_addresses_the_same_tab_as_the_marked_one() {
// Every `--json` payload spells tabs bare, and the id `tty7 tab new`
// hands back is the one tab you are certain of — refusing it there made
// naming a tab you just created impossible without counting `@N` again.
let id = "0d4e1a54-0000-4000-8000-000000000003";
assert_eq!(parse_tab(id).unwrap(), TabAddress::Id(id.into()));
assert_eq!(
parse_tab(&format!("@{id}")).unwrap(),
TabAddress::Id(id.into())
);
assert_eq!(parse_tab("7").unwrap(), TabAddress::Ordinal(7));
assert_eq!(parse_tab("@7").unwrap(), TabAddress::Ordinal(7));
}
#[test]
fn a_tab_ordinal_is_digits_and_nothing_else() {
// Same guard as the pane one, and it matters for the same reason now
// that the sigil is optional on both.
for not_a_tab in ["+5", "@+5", "-5", " 5", "5 ", "", "@", "5.0", "build"] {
assert!(
parse_tab(not_a_tab).is_err(),
"'{not_a_tab}' must not read as a tab address"
);
}
assert!(parse_tab("99999999999999999999999").is_err());
}
#[test]
fn an_address_is_digits_and_nothing_else() {
// `u64::from_str` takes a leading `+`; an address must not, or a bare
+65 -2
View File
@@ -62,10 +62,17 @@ Reach for tty7 when one of these is true:
|---|---|---|
| `%42` | a pane | yes — a pane keeps its id for its whole life |
| `@7` | a tab, numbered across the **whole machine** in tree order | **no** — it shifts whenever a workspace or tab appears or disappears |
| `@<full tab UUID>` | that same tab, by id | yes |
| `api` / `76698a44` / a full UUID | a workspace, by name, by unique id prefix, or by id | yes |
Re-resolve `@N` right before you use it; never cache one across a step that
creates or removes a tab. Pane ids and workspace ids are safe to remember.
creates or removes a tab. Pane ids, tab ids and workspace ids are safe to
remember — so when you create a tab and mean to address it again later, keep the
id `tty7 tab new --json` hands back rather than counting `@N` a second time.
The sigils are optional wherever an address is expected: `%42` and `42` are the
same pane, `@7` and `7` the same tab. Ids copied out of `--json` paste straight
back in.
Omitting the address inside a tty7 shell means "this pane" / "this workspace".
An explicit address always wins over the environment.
@@ -127,6 +134,22 @@ For keystrokes rather than characters — Ctrl-C, Escape, the arrow keys — use
`--key` (see [Answering a prompt](#answering-a-prompt)). Typing `^C` as text
does nothing; it arrives as two characters.
**A brand-new pane can swallow the Enter.** A shell still working through its
startup files — a prompt framework, `fastfetch`, anything that paints on login —
takes the text you send but loses the carriage return that follows it, and the
command just sits on the prompt line unexecuted. Nothing reports this: the
`send` succeeded, and the pane looks like a worker that has not got going yet.
So after sending the first command into a pane you just created, read the screen
back and check it actually left the prompt:
```bash
tty7 capture "$PANE" --plain | tail -3 # command still sitting on the prompt?
tty7 send "$PANE" --enter # then give it the Enter it lost
```
Cheaper than diagnosing it later, and only the first `send` into a fresh pane
needs the check.
## Reading a pane
### If you want the screen, use `--plain`
@@ -197,6 +220,11 @@ If you want the process tree itself — "what is running in there", "which port
this pane serving" — that is `tty7 procs %83`: indented by depth, `*` on the
foreground process, then the ports those processes are listening on.
It is not the way to check on a coding agent, though. `procs` reports
`nothing running in this pane` for a pane with a busy agent in it, so reading it
as "the worker died" is wrong. Ask `tty7 agents` about those, or see
[When a worker never moves](#when-a-worker-never-moves).
## Handing work to another agent
Everything above also works when the thing in the pane is a coding agent, and
@@ -206,7 +234,7 @@ process tree:
```bash
PANE=$(tty7 split --v)
tty7 send "$PANE" 'claude -p "add tests for the parser"' --enter
tty7 send "$PANE" 'claude --dangerously-skip-permissions "add tests for the parser"' --enter
tty7 wait "$PANE" --until waiting,done --changed --timeout 900
tty7 capture "$PANE" --plain | tail -40
tty7 pane close "$PANE"
@@ -216,6 +244,27 @@ Five steps: give it a pane, hand it the task, sleep until it needs you or
finishes, read what happened, clean up. The third is the one worth
understanding.
### Give the worker its interactive mode
Hand the task as an argument, **not** with `-p`. Both run one turn and stop, so
the difference is not what the worker does — it is what anybody can see while it
does it.
Interactive is the mode that draws a TUI, so the pane fills with the worker's
reasoning and tool calls as they happen. That is visible to the user in their
tty7 window, and it is what `capture --plain` reads back. `-p` is the piped
mode: it draws nothing, streams its answer to stdout when the turn ends, and
until then the pane's screen stays **empty**`capture --plain` on it returns
nothing at all, which reads exactly like a worker that hung. Putting a `-p`
worker in a pane throws away the only reason it is in a pane.
Interactive also leaves the session alive at the prompt, so you can `send` a
follow-up into the same context. A `-p` worker is gone after its one turn.
Reach for `-p` only when you want the answer as a string and nobody needs to
watch — and then prefer `tty7 run` or the Bash tool, which is what that shape
is for.
### What the states mean
| State | The pane is |
@@ -293,6 +342,20 @@ it can see the gap, and `tty7 doctor` reports where every agent's hooks stand.
Hooks are installed from the GUI's **Settings → Agents**; tell the user rather
than trying to install them yourself.
Before concluding anything, check whether it is moving. Those same hooks emit an
OSC 777 line on every tool call, and `capture` **without** `--plain` shows them —
one of the few times the raw bytes beat the rendered screen:
```bash
tty7 capture "$PANE" | grep -c 'tool-complete' # rising = alive and working
```
That is also the answer when a worker's screen looks empty: a `-p` worker paints
nothing until its turn ends, so `capture --plain` is blank the whole way through
while the event stream underneath is busy. Two things that do *not* answer this
question: `tty7 procs`, which reports nothing running for a pane with a live
agent in it, and the absence of output on a `--plain` capture.
## Looking around
```bash