From 8131ac2f9307a8ff612329dfb95ae04273eedaa6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:28:48 +0800 Subject: [PATCH] Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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". --- crates/tty7-cli/src/address.rs | 51 ++++++++++++++++++++++---- skills/tty7/SKILL.md | 67 +++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/crates/tty7-cli/src/address.rs b/crates/tty7-cli/src/address.rs index 66747f96..7bb4bbaa 100644 --- a/crates/tty7-cli/src/address.rs +++ b/crates/tty7-cli/src/address.rs @@ -60,19 +60,28 @@ pub fn parse_pane(s: &str) -> Result { } pub fn parse_tab(s: &str) -> Result { - 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::() { - 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 @"); + 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 diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md index 354a2a86..36c23f67 100644 --- a/skills/tty7/SKILL.md +++ b/skills/tty7/SKILL.md @@ -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 | +| `@` | 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