diff --git a/docs/features.md b/docs/features.md index bad2eb59..e4878b7d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -64,7 +64,7 @@ it never wraps or replaces the agent. - **Copy Session ID** — put the agent's native session id on the clipboard, beside *Copy Working Directory*, for pasting into `codex resume`, a bug report, or another tool - **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt - **Tray icon** — a system tray / menu bar item that flips to an attention state the moment any agent needs your input; its menu lists every agent pane (brand avatar + status dot, click to reveal), switches the notification policy, and offers *Quit and Stop Daemon* alongside the plain session-keeping quit (`show_tray_icon`, on by default) -- **`tty7 wait`** — the CLI's orchestration primitive: block until a pane's agent needs input or finishes its turn (`tty7 wait %3 --until waiting,done --timeout 600`, exit 124 on timeout), so one agent can sleep until its peer blocks on a permission prompt instead of screen-scraping — then `tty7 capture %3 --plain` to read the result +- **`tty7 wait`** — the CLI's orchestration primitive: block until a pane's agent needs input or finishes its turn (`tty7 wait %3 --until waiting,done --changed --timeout 600`, exit 124 on timeout), so one agent can sleep until its peer blocks on a permission prompt instead of screen-scraping — then `tty7 capture %3 --plain` to read the result. The agent status is a level, not an event, so `--changed` ignores the state the pane was already in when the wait began; without it, the JSON's `stale` flag says whether the answer might belong to the previous turn - **Orchestration skill** — a switch (Settings → Agents) that installs a Claude Code skill (`~/.claude/skills/tty7-orchestration`) teaching a *primary* agent the delegation loop — spawn a worker pane, send it a bounded task, `wait` on it, capture the result. A skill rather than a global instruction on purpose: only its one-line description rides in context until explicitly invoked, and worker agents never inherit orchestration authority - **`tty7` on PATH** — the CLI ships inside every installer and is put on PATH at launch, so a script or a coding agent can drive tty7 from any terminal. Inside a tty7 pane it works regardless, since panes inherit the app's environment. On Unix it is a symlink into whichever of `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/bin`, `~/.cargo/bin` your PATH already covers; on Windows the install directory is appended to your user PATH, and the uninstaller takes it back out. A `tty7` you installed yourself is left alone, never replaced. Off via Settings → About or `install_cli_on_path: false` in `config.json` diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 981d3eba..526d3bba 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -61,7 +61,7 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包 - **复制 Session ID** —— 把 agent 的原生 session id 复制到剪贴板,就在 *Copy Working Directory* 旁边,方便粘进 `codex resume`、bug 报告或别的工具 - **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent - **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *Quit and Stop Daemon*(`show_tray_icon`,默认开启) -- **`tty7 wait`** —— CLI 的编排原语:阻塞到某个 pane 的 agent 等待输入或完成一轮(`tty7 wait %3 --until waiting,done --timeout 600`,超时退出码 124),让一个 agent 睡到同伴卡在权限确认的那一刻,而不是抓屏猜——然后 `tty7 capture %3 --plain` 收结果 +- **`tty7 wait`** —— CLI 的编排原语:阻塞到某个 pane 的 agent 等待输入或完成一轮(`tty7 wait %3 --until waiting,done --changed --timeout 600`,超时退出码 124),让一个 agent 睡到同伴卡在权限确认的那一刻,而不是抓屏猜——然后 `tty7 capture %3 --plain` 收结果。agent 状态是电平不是边沿,所以 `--changed` 会忽略 wait 开始时 pane 本来就处在的那个状态;不加它的话,JSON 里的 `stale` 标记会告诉你这个答案是不是上一轮留下的 - **Orchestration skill** —— 一个开关(设置 → Agents),安装一个 Claude Code skill(`~/.claude/skills/tty7-orchestration`),教 *primary* agent 完整的委派循环——开 worker pane、发一个边界清晰的任务、`wait` 等待、收结果。特意做成 skill 而非全局指令:平时只有一行描述占上下文,显式调用才加载全文,worker agent 也不会继承编排权限 - **`tty7` 上 PATH** —— CLI 随每个安装包一起发布,启动时自动放到 PATH 上,脚本和 coding agent 在任何终端里都能驱动 tty7。tty7 自己的 pane 里则一定可用,因为 pane 继承 app 的环境。Unix 上是往 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.local/bin`、`~/bin`、`~/.cargo/bin` 中你 PATH 已经覆盖的那个目录里放一个软链;Windows 上是把安装目录追加到用户 PATH,卸载时再摘掉。你自己装的 `tty7` 一律保持原样,不会被覆盖。关掉:设置 → About,或 `config.json` 里 `install_cli_on_path: false` diff --git a/src/core/orchestration_skill.rs b/src/core/orchestration_skill.rs index 82bc4968..0725d2d9 100644 --- a/src/core/orchestration_skill.rs +++ b/src/core/orchestration_skill.rs @@ -49,17 +49,29 @@ pane. Every verb takes `--json`. ## The delegation loop 1. Create a worker pane: `tty7 tab new --cwd DIR` — prints the pane id (`%N`) -2. Start the worker: `tty7 send %N 'claude -p \"one bounded task\"' --enter` -3. Sleep until it needs you: `tty7 wait %N --until waiting,done --timeout 600` +2. Start the worker: `tty7 send %N 'claude \"one bounded task\"' --enter` + - interactive, not `claude -p`: headless print mode never stops to ask, + so the `waiting` state this loop turns on would never arrive +3. Sleep until it needs you: + `tty7 wait %N --until waiting,done --changed --timeout 600` - exit 0: the JSON report names the matched state, with the agent's message and native session id - exit 124: still working — wait again, or look in on it + - exit 1 with `\"status\": \"exit\"`: the worker died; do not wait again 4. If it is *waiting* (a permission prompt or question), read and answer it: `tty7 capture %N --plain`, then `tty7 send %N 'y' --enter` (or whatever - the prompt asks) + the prompt asks) — then go back to step 3 5. When *done*, collect the result: `tty7 capture %N --plain` 6. Clean up: `tty7 pane close %N` +Always pass `--changed` when you wait after sending something. The status the +server keeps is a level, not an event: `done` stands until the next turn +begins and `waiting` stands until the agent moves, so a plain `wait` issued +right after a `send` answers with the *previous* turn's state before the +worker has even read your input. `--changed` ignores the state the pane was +already in. Without it, check `\"stale\": true` in the JSON before trusting a +wake-up. + Run workers in parallel by repeating steps 1–2, then waiting on each pane. `tty7 ls` shows every workspace, tab and pane; `tty7 agents` shows every agent and its status at a glance. @@ -147,6 +159,43 @@ mod tests { for verb in ["tab new", "send %N", "wait %N", "capture %N", "pane close"] { assert!(SKILL.contains(verb), "skill body lost `{verb}`"); } + // The whole loop rests on `--changed`: without it a wait issued right + // after a send answers with the previous turn's status. + assert!(SKILL.contains("--changed"), "the loop lost --changed"); + // And the worker must be *launched* interactively — `claude -p` never + // reaches the `waiting` state steps 3–4 are built on. The prose may + // still name it; the command in step 2 may not start with it. + assert!( + !SKILL.contains("'claude -p"), + "headless print mode cannot produce the `waiting` state this loop waits for" + ); + } + + /// Owns `CLAUDE_CONFIG_DIR` and a scratch directory for the length of a + /// test, and puts both back on the way out — including on a panic, which + /// a plain tail cleanup would skip, leaving the var set for whatever runs + /// next in this process. + struct ScratchConfigDir(PathBuf); + + impl ScratchConfigDir { + fn new(tag: &str) -> ScratchConfigDir { + let dir = + std::env::temp_dir().join(format!("tty7-skill-test-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // SAFETY: test-scoped env mutation. `skill_path` is the only + // reader of this var in this binary, and the guard restores it. + unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &dir) }; + ScratchConfigDir(dir) + } + } + + impl Drop for ScratchConfigDir { + fn drop(&mut self) { + // SAFETY: as above — undoing what `new` did. + unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }; + let _ = std::fs::remove_dir_all(&self.0); + } } /// Install → installed → uninstall round-trips against a scratch @@ -154,10 +203,8 @@ mod tests { /// deleted. Env-var scoped: this test owns the var for its duration. #[test] fn install_roundtrip_and_foreign_file_safety() { - let scratch = std::env::temp_dir().join(format!("tty7-skill-test-{}", std::process::id())); - std::fs::create_dir_all(&scratch).unwrap(); - // SAFETY: test-scoped env mutation; no other test reads this var. - unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &scratch) }; + let guard = ScratchConfigDir::new("roundtrip"); + let scratch = guard.0.clone(); assert!(!installed()); install().unwrap(); @@ -180,8 +227,5 @@ mod tests { assert!(!installed(), "a foreign file is not a tty7 install"); assert!(uninstall().is_err()); assert!(path.exists(), "the user's file was deleted"); - - unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }; - let _ = std::fs::remove_dir_all(&scratch); } }