From e6bdf44f3c1e45cbb3c99b156de7039e5948ed2d Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 2 Aug 2026 09:37:09 +0800 Subject: [PATCH] feat(cli): session CLI for scripting and agent orchestration (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): `tty7 wait` + the agent-coordination note The two pieces of the original session-CLI PR that main's own CLI doesn't cover, rebuilt as a minimal delta against it. `tty7 wait %N --until waiting,done --timeout 600` blocks until a pane's agent reaches a requested state — the orchestration primitive that lets one agent sleep until its peer blocks on a permission prompt or finishes a turn, instead of screen-scraping. A poll of `AgentStates` rather than an `events` subscription on purpose: a one-shot stateless question composes into scripts, survives a server restart mid-wait, and needs no cursor management. Agentless-but-live panes read as idle via the machine tree; a dead or vanished pane reads as exit, which ends every wait (matched only when asked for). Timeout exits 124, the `timeout(1)` convention. The coordination note is discovery for the whole CLI: a marked, idempotent block describing the verbs, installed into ~/.claude/CLAUDE.md (always; CLAUDE_CONFIG_DIR honored) and ~/.codex/AGENTS.md (only when ~/.codex exists). A one-time "Let your agents coordinate?" prompt fires the first time a pane detects a coding agent; a Settings → Agents switch drives the same install/remove, with state read from the files themselves. Uninstall strips exactly the marked block; an unterminated block is left alone rather than truncated at a guess. * feat(agents): replace the global note with an orchestration skill Per review: global instructions tax every session's context and hand every agent — workers included — the ambient authority to orchestrate its neighbours. The common shape is primary → workers: one agent owns decomposition, dispatch, waiting and aggregation; workers just do bounded tasks. A Claude Code skill fits that exactly. `core::orchestration_skill` installs ~/.claude/skills/tty7-orchestration/SKILL.md — only its one-line description rides in context until the user or the primary agent explicitly invokes it, and workers never see it. The body can therefore afford the full delegation loop (tab new → send → wait → answer-or-capture → pane close) instead of a token-starved cheat sheet. The file is wholly tty7-owned: install is a plain overwrite (also the version-refresh path), and uninstall keys on an ownership marker so a user's hand-written skill under the same name is refused, not deleted. Gone with the global note: the first-agent-detected prompt, its config flag, and the CLAUDE.md/AGENTS.md writers — the Settings → Agents switch now drives the skill install instead. --------- Co-authored-by: l0ng-ai --- crates/tty7-cli/src/cli.rs | 54 +++++++++ crates/tty7-cli/src/commands.rs | 181 ++++++++++++++++++++++++++++++- docs/features.md | 2 + docs/features.zh-CN.md | 2 + src/core/mod.rs | 1 + src/core/orchestration_skill.rs | 187 ++++++++++++++++++++++++++++++++ src/ui/app.rs | 15 +++ src/ui/settings.rs | 20 ++++ 8 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 src/core/orchestration_skill.rs 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 {