diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 22f9ddb3..485be040 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -82,6 +82,12 @@ pub enum Command { #[command(about = "Every agent on this machine: running, waiting for a reply, idle")] Agents, + #[command( + about = "Block until a pane's agent needs input, finishes its turn, or the pane \ + exits — the orchestration primitive: `tty7 wait %3 && tty7 capture %3 --plain`" + )] + Wait(WaitArgs), + #[command(about = "Stream server events, one per line (NDJSON with --json)")] Events, @@ -188,6 +194,54 @@ pub struct SendArgs { pub enter: bool, } +/// One resting place a `wait` can end on. `Exit` is pane-level (the child +/// died or the pane is gone), the rest are the agent-status ladder the +/// server maintains from hook events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum WaitState { + Idle, + Working, + Waiting, + Done, + Exit, +} + +#[derive(Debug, Args)] +pub struct WaitArgs { + #[arg( + value_name = "%PANE", + help = "Pane to watch; defaults to $TTY7_PANE inside a tty7 shell" + )] + pub target: Option, + + // The default is the two states worth waking for plus the one nobody can + // wait past: "my peer needs input", "my peer finished", "my peer died". + #[arg( + long, + value_name = "STATE,…", + value_delimiter = ',', + default_values = ["waiting", "done", "exit"], + help = "States that end the wait" + )] + pub until: Vec, + + #[arg( + long, + value_name = "SECS", + help = "Give up after this many seconds, with exit code 124 (the `timeout(1)` \ + convention, so scripts can tell \"not yet\" from \"broken\")" + )] + pub timeout: Option, + + #[arg( + long, + value_name = "MS", + default_value_t = 500, + help = "Poll every this many milliseconds" + )] + pub interval: u64, +} + #[derive(Debug, Args)] pub struct CaptureArgs { #[arg( diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 386a1c74..0a8aad32 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -9,7 +9,7 @@ use crate::address::{self, Context, WorkspaceAddress}; use crate::backend::{Backend, RunSpec}; use crate::cli::{ CaptureArgs, Cli, Command, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, SplitArgs, - TabCmd, WsCmd, + TabCmd, WaitArgs, WaitState, WsCmd, }; use crate::output; use crate::resolve; @@ -87,6 +87,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result events(json_mode, backend), Some(Command::Agents) => agents(backend), + Some(Command::Wait(args)) => wait(args, ctx, backend), Some(Command::Status) | Some(Command::Server(ServerCmd::Status)) => status(backend), Some(Command::Machine(MachineCmd::Ls)) => machine_ls(backend), Some(Command::Machine(MachineCmd::Connect { .. })) @@ -627,6 +628,86 @@ fn event_line(event: &ControlEvent) -> String { } } +/// The one verb that *blocks*: poll until the watched pane's agent reaches a +/// requested state, then report it. This is what turns the CLI into an +/// orchestration tool — "wake me when my peer agent needs input, or finishes +/// its turn" — without the screen-scraping a tmux-based agent team resorts to. +/// +/// A poll of `AgentStates` rather than an `events` subscription on purpose: a +/// one-shot, stateless question composes into scripts (`tty7 wait %3 && +/// tty7 capture %3 --plain`), survives a server restart mid-wait, and needs no +/// cursor management. At the default 500ms interval the cost is one aggregate +/// control request per tick — the same request `tty7 agents` makes once. +fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { + use std::time::{Duration, Instant}; + + let pane = address::pane_or_context(args.target.as_deref(), ctx)?; + let deadline = args + .timeout + .map(|t| Instant::now() + Duration::from_secs(t)); + loop { + let states = match backend.control(ControlRequest::AgentStates)? { + ReplyOk::AgentStates(states) => states, + other => bail!("the server answered AgentStates with {other:?}"), + }; + let entry = states.into_iter().find(|s| s.pane_id == pane); + let current = match &entry { + Some(e) => { + use tty7_core::core::cli_agent::AgentStatus; + match e.state.status { + AgentStatus::Idle => WaitState::Idle, + AgentStatus::Working => WaitState::Working, + AgentStatus::Waiting => WaitState::Waiting, + AgentStatus::Done => WaitState::Done, + } + } + // No agent state for the pane: an agentless-but-live pane reads + // as idle; a dead or vanished one as exit. The machine tree is + // only fetched on this branch — while an agent is reporting, its + // state alone answers the question. + None => match fetch_machine(backend)?.panes.iter().find(|p| p.id == pane) { + Some(record) if record.live => WaitState::Idle, + _ => WaitState::Exit, + }, + }; + let matched = args.until.contains(¤t); + // Exit ends every wait, requested or not: whatever the caller was + // waiting for can no longer happen, and reporting beats spinning + // forever on a ghost. + if matched || current == WaitState::Exit { + let session = entry.as_ref().map(|e| &e.state); + let status = format!("{current:?}").to_lowercase(); + let json = json!({ + "pane": pane, + "status": status, + "matched": matched, + "message": session.and_then(|s| s.message.clone()), + "session_id": session.and_then(|s| s.session_id.clone()), + }); + if !matched { + bail!("pane %{pane} exited before reaching the awaited state"); + } + let mut human = format!("pane %{pane}: {status}"); + if let Some(msg) = session.and_then(|s| s.message.as_deref()) { + human.push_str(&format!(" — {msg}")); + } + return report(human, json); + } + if deadline.is_some_and(|d| Instant::now() >= d) { + // 124 = the `timeout(1)` convention: "gave up", distinct from + // both success and error, so orchestration scripts can branch. + return Ok(Outcome::Exit( + 124, + Report { + human: format!("pane %{pane}: still {:?} — timed out", current), + json: json!({ "pane": pane, "timed_out": true }), + }, + )); + } + std::thread::sleep(Duration::from_millis(args.interval.max(50))); + } +} + fn agents(backend: &mut dyn Backend) -> Result { match backend.control(ControlRequest::AgentStates)? { ReplyOk::AgentStates(states) => report( @@ -1544,6 +1625,104 @@ mod tests { } } + fn agent_state( + pane_id: u64, + status: tty7_core::core::cli_agent::AgentStatus, + ) -> tty7_core::daemon::control::PaneAgentState { + tty7_core::daemon::control::PaneAgentState { + pane_id, + agent: None, + state: tty7_core::core::cli_agent::AgentSessionState { + status, + message: Some("needs permission".into()), + session_id: Some("sess-9".into()), + ..Default::default() + }, + } + } + + /// The happy path is one aggregate poll: a matching agent state answers + /// immediately, carrying the event's message and native session id — the + /// two things an orchestrator needs to act on the wake-up. + #[test] + fn wait_returns_the_moment_the_state_matches() { + use tty7_core::core::cli_agent::AgentStatus; + let mut backend = mock(); + backend + .replies + .push_back(ReplyOk::AgentStates(vec![agent_state( + 3, + AgentStatus::Waiting, + )])); + let out = run_cli(&["tty7", "wait", "%3"], &Context::default(), &mut backend); + let json = json_of(out); + assert_eq!(json["status"], "waiting"); + assert_eq!(json["matched"], true); + assert_eq!(json["message"], "needs permission"); + assert_eq!(json["session_id"], "sess-9"); + // The machine tree was never consulted — the agent state alone answered. + assert_eq!(backend.control_calls, vec![ControlRequest::AgentStates]); + } + + /// Panes without an agent state fall back to the machine tree: live means + /// idle, dead-or-gone means exit — which ends every wait, but only counts + /// as *matched* when the caller listed it. + #[test] + fn wait_reads_agentless_panes_from_the_tree() { + let mut backend = mock(); + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + let out = run_cli( + &["tty7", "wait", "%3", "--until", "idle"], + &Context::default(), + &mut backend, + ); + assert_eq!(json_of(out)["status"], "idle"); + + // Pane 9 exists nowhere: "exit", matched by the default until-set. + let mut backend = mock(); + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + let out = run_cli(&["tty7", "wait", "%9"], &Context::default(), &mut backend); + let json = json_of(out); + assert_eq!(json["status"], "exit"); + assert_eq!(json["matched"], true); + + // Waiting for a state a dead pane can never reach is an error, not a + // silent success — the caller's plan is broken and should know. + let mut backend = mock(); + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + let err = execute( + cli(&["tty7", "wait", "%9", "--until", "done"]), + &Context::default(), + &mut backend, + ) + .expect_err("a dead pane cannot reach done"); + assert!(err.to_string().contains("exited"), "{err}"); + } + + /// A `--timeout` that runs out exits 124 — the `timeout(1)` convention — + /// so scripts can branch on "not yet" separately from "broken". + #[test] + fn wait_timeout_exits_124() { + use tty7_core::core::cli_agent::AgentStatus; + let mut backend = mock(); + backend + .replies + .push_back(ReplyOk::AgentStates(vec![agent_state( + 3, + AgentStatus::Working, + )])); + let out = execute( + cli(&["tty7", "wait", "%3", "--until", "done", "--timeout", "0"]), + &Context::default(), + &mut backend, + ) + .expect("a timeout is an exit code, not an error"); + match out { + Outcome::Exit(124, r) => assert_eq!(r.json["timed_out"], true), + other => panic!("expected exit 124, got {other:?}"), + } + } + #[test] fn agents_status_and_machine_ls_are_single_aggregate_requests() { use tty7_core::daemon::control::{RouteInfo, ServerStatus}; diff --git a/docs/features.md b/docs/features.md index 4c5c6f09..bad2eb59 100644 --- a/docs/features.md +++ b/docs/features.md @@ -64,6 +64,8 @@ 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 +- **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` ## SSH diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 692cf9e5..981d3eba 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -61,6 +61,8 @@ 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` 收结果 +- **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` ## SSH diff --git a/src/core/mod.rs b/src/core/mod.rs index 22fb526f..5b23c05d 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod agent_prompt; pub mod cli_install; pub mod config; pub mod keychain; +pub mod orchestration_skill; pub mod session; pub mod ssh_config; pub mod update; diff --git a/src/core/orchestration_skill.rs b/src/core/orchestration_skill.rs new file mode 100644 index 00000000..82bc4968 --- /dev/null +++ b/src/core/orchestration_skill.rs @@ -0,0 +1,187 @@ +//! The tty7 orchestration *skill*: a Claude Code skill file describing how a +//! primary agent delegates work to worker panes over the session CLI +//! (`tab new` / `send` / `wait` / `capture`), installed at +//! `~/.claude/skills/tty7-orchestration/SKILL.md`. +//! +//! A skill, deliberately not a global instruction. An earlier cut of this +//! feature appended guidance to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md`, +//! which taxed every session's context window and — worse — encouraged *every* +//! agent to discover and orchestrate its neighbours. The common shape is +//! primary → workers: one agent owns decomposition, dispatch, waiting and +//! aggregation, and the workers just do bounded tasks. A skill fits that +//! exactly: only its one-line description rides in context until the user or +//! the primary agent explicitly reaches for it, and workers never see it. +//! +//! The file is wholly tty7-owned (marker inside, checked before any delete), +//! so install is a plain overwrite — also the version-refresh path — and +//! uninstall removes the file, never guessing at merged user edits. + +use std::path::PathBuf; + +/// Ownership marker. Uninstall refuses to delete a file without it, so a +/// hand-written skill that happens to share the directory name survives. +const MARKER: &str = ""; + +const SKILL_DIR: &str = "tty7-orchestration"; + +/// The skill itself. The frontmatter description is what Claude Code matches +/// against a session's intent, so it names the *tasks* that should trigger it; +/// the body can afford real workflow detail because it only loads on use. +const SKILL: &str = "\ +--- +name: tty7-orchestration +description: Delegate work to other coding agents running in tty7 terminal panes — spawn a worker pane, send it a prompt, wait until it needs input or finishes, and capture its output. Use when asked to parallelize work across agents, run an agent team, or drive another terminal session in tty7. +--- + + + +# Orchestrating tty7 sessions + +You are the primary agent; panes you create are workers. Keep workers +bounded: give each a self-contained task, and keep decomposition, waiting, +and aggregation here. Do not hand workers orchestration duties — a worker +that finishes its task and stops is what keeps an agent team debuggable. + +Prerequisites: you are inside tty7 (the `TTY7` env var is set) and the +`tty7` CLI is on PATH. `%N` addresses a pane by id; `$TTY7_PANE` is your own +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` + - 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 +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) +5. When *done*, collect the result: `tty7 capture %N --plain` +6. Clean up: `tty7 pane close %N` + +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. +"; + +/// The skill's install path: `~/.claude/skills/tty7-orchestration/SKILL.md`, +/// honoring the same `CLAUDE_CONFIG_DIR` override the hooks installer honors. +fn skill_path() -> Option { + let base = if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|d| !d.is_empty()) { + PathBuf::from(dir) + } else { + home_dir()?.join(".claude") + }; + Some(base.join("skills").join(SKILL_DIR).join("SKILL.md")) +} + +fn home_dir() -> Option { + #[cfg(unix)] + { + std::env::var_os("HOME").map(PathBuf::from) + } + #[cfg(not(unix))] + { + std::env::var_os("USERPROFILE").map(PathBuf::from) + } +} + +/// Install (or refresh) the skill. A plain overwrite: the file is wholly +/// tty7-owned, and this doubling as the version-refresh path is the point. +pub fn install() -> anyhow::Result { + let path = skill_path().ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + crate::core::config::write_atomic(&path, SKILL.as_bytes())?; + Ok("Installed".to_string()) +} + +/// Remove the skill — but only a file carrying the ownership marker, so a +/// user's own `tty7-orchestration` skill is never deleted by tty7. The +/// directory goes too once empty; an empty skill dir would read as a broken +/// skill in Claude Code's listing. +pub fn uninstall() -> anyhow::Result { + let path = skill_path().ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?; + match std::fs::read_to_string(&path) { + Ok(content) if content.contains(MARKER) => { + std::fs::remove_file(&path)?; + if let Some(dir) = path.parent() { + let _ = std::fs::remove_dir(dir); // fails non-empty; that's the guard + } + Ok("Removed".to_string()) + } + Ok(_) => anyhow::bail!( + "{} exists but was not installed by tty7 — not touching it", + path.display() + ), + Err(_) => Ok("Removed".to_string()), + } +} + +/// Whether the tty7-owned skill is currently installed. +pub fn installed() -> bool { + skill_path().is_some_and(|p| { + std::fs::read_to_string(p) + .map(|s| s.contains(MARKER)) + .unwrap_or(false) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The skill text itself is load-bearing: the frontmatter must parse as a + /// skill (name + description) and the body must carry the ownership + /// marker `uninstall` keys on. + #[test] + fn skill_content_is_well_formed() { + assert!(SKILL.starts_with("---\nname: tty7-orchestration\n")); + assert!(SKILL.contains("\ndescription: ")); + // Frontmatter is closed before the body starts. + assert_eq!(SKILL.matches("\n---\n").count(), 1); + assert!(SKILL.contains(MARKER)); + // The loop teaches the four primitives, not a stale verb set. + for verb in ["tab new", "send %N", "wait %N", "capture %N", "pane close"] { + assert!(SKILL.contains(verb), "skill body lost `{verb}`"); + } + } + + /// Install → installed → uninstall round-trips against a scratch + /// `CLAUDE_CONFIG_DIR`; a foreign (marker-less) file is refused, not + /// 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) }; + + assert!(!installed()); + install().unwrap(); + assert!(installed()); + let path = scratch.join("skills").join(SKILL_DIR).join("SKILL.md"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), SKILL); + + // Re-install is the refresh path: same content, no error. + install().unwrap(); + assert!(installed()); + + uninstall().unwrap(); + assert!(!installed()); + assert!(!path.exists()); + assert!(!path.parent().unwrap().exists(), "empty skill dir lingers"); + + // A user's own skill under our name must survive an uninstall. + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "---\nname: tty7-orchestration\n---\nmy own\n").unwrap(); + 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); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 7d79a9c9..247dde70 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4317,6 +4317,21 @@ impl Tty7App { Some((host, Some(home))) } + /// Settings → Agents: the "Orchestration skill" switch — install or + /// remove the Claude Code skill that teaches a primary agent the + /// delegation workflow over the session CLI. + pub(crate) fn set_orchestration_skill(&mut self, on: bool, cx: &mut Context) { + let result = if on { + crate::core::orchestration_skill::install() + } else { + crate::core::orchestration_skill::uninstall() + }; + if let Err(e) = result { + log::warn!("orchestration-skill change failed: {e}"); + } + cx.notify(); + } + pub(crate) fn settings_install_agent_hooks( &mut self, agent: crate::core::agent_hooks::HookAgent, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index b04070e8..febab3c2 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -3567,6 +3567,26 @@ impl Tty7App { cx, )); + // The orchestration skill (see `core::orchestration_skill`): a Claude + // Code skill a primary agent invokes explicitly to delegate work to + // worker panes over the session CLI. State is read from the file + // itself — like the hook rows, the filesystem is the truth, so an + // edit made outside this panel shows up here. Above the machine + // picker: the skill lives on this machine's disk regardless of which + // host's hooks are managed below. + let skill_switch = crate::ui::theme::switch("orchestration-skill", cx) + .checked(crate::core::orchestration_skill::installed()) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_orchestration_skill(*on, cx))) + .into_any_element(); + page = page.child(self.settings_row( + "Orchestration skill", + "Install a Claude Code skill (~/.claude/skills/tty7-orchestration) that teaches a \ + primary agent to delegate work to worker panes over the `tty7` CLI — spawn, send, \ + wait, capture — invoked explicitly, never injected globally", + skill_switch, + cx, + )); + page = page.children(self.agent_hooks_machine_picker(selected_host, cx)); match view {