Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28778b2224 | ||
|
|
766eabbcb6 | ||
|
|
c67fa2bd4e |
@@ -11,6 +11,7 @@ persistence, WebSocket for transport.
|
|||||||
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
||||||
├── GET/POST /api/sessions list / spawn
|
├── GET/POST /api/sessions list / spawn
|
||||||
├── DELETE /api/sessions/:id kill
|
├── DELETE /api/sessions/:id kill
|
||||||
|
├── POST /api/sessions/:id/input type text into a session
|
||||||
├── GET /api/info hostname, version, home dir, spawn presets
|
├── GET /api/info hostname, version, home dir, spawn presets
|
||||||
├── GET /api/dirs directory listing (spawn autocomplete)
|
├── GET /api/dirs directory listing (spawn autocomplete)
|
||||||
├── GET /api/recent-dirs spawn history
|
├── GET /api/recent-dirs spawn history
|
||||||
@@ -18,7 +19,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
|||||||
├── WS /api/events Snapshot + session deltas
|
├── WS /api/events Snapshot + session deltas
|
||||||
└── WS /api/attach/:id raw PTY bytes
|
└── WS /api/attach/:id raw PTY bytes
|
||||||
|
|
||||||
maestro list/spawn/attach/kill CLI client
|
maestro list/spawn/attach/kill/send CLI client
|
||||||
maestro tui ratatui client
|
maestro tui ratatui client
|
||||||
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
|
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
|
/// Type text into a running session, as if it had been typed on an
|
||||||
|
/// attached terminal.
|
||||||
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
}
|
}
|
||||||
@@ -103,6 +106,20 @@ pub struct AttachArgs {
|
|||||||
pub detach_key: String,
|
pub detach_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `maestro send <session> <text...>`
|
||||||
|
#[derive(Debug, clap::Args)]
|
||||||
|
pub struct SendArgs {
|
||||||
|
/// Session to type into: its ID, a unique ID prefix, or its name.
|
||||||
|
pub session: String,
|
||||||
|
/// Text to send. Omit it to read the text from stdin.
|
||||||
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||||
|
pub text: Vec<String>,
|
||||||
|
/// Leave the text sitting in the session's prompt instead of pressing
|
||||||
|
/// Enter after it.
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_enter: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
pub struct KillArgs {
|
pub struct KillArgs {
|
||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
@@ -245,6 +262,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_send_joining_the_message_words() {
|
||||||
|
let cli =
|
||||||
|
Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap();
|
||||||
|
let Command::Send(a) = cli.command else {
|
||||||
|
panic!("expected send")
|
||||||
|
};
|
||||||
|
assert_eq!(a.session, "planner");
|
||||||
|
assert_eq!(a.text, vec!["run", "the", "tests"]);
|
||||||
|
assert!(!a.no_enter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message is trailing_var_arg, so a word starting with `-` inside it
|
||||||
|
/// is text, not a flag clap should reject.
|
||||||
|
#[test]
|
||||||
|
fn send_message_may_start_with_a_dash() {
|
||||||
|
let cli =
|
||||||
|
Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap();
|
||||||
|
let Command::Send(a) = cli.command else {
|
||||||
|
panic!("expected send")
|
||||||
|
};
|
||||||
|
assert!(a.no_enter);
|
||||||
|
assert_eq!(a.text, vec!["--force"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_text_may_be_omitted_for_stdin() {
|
||||||
|
let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap();
|
||||||
|
let Command::Send(a) = cli.command else {
|
||||||
|
panic!("expected send")
|
||||||
|
};
|
||||||
|
assert!(a.text.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_env_without_equals() {
|
fn rejects_env_without_equals() {
|
||||||
let err = Cli::try_parse_from([
|
let err = Cli::try_parse_from([
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use maestro_protocol::DaemonClient;
|
|||||||
|
|
||||||
use crate::attach::{parse_detach_key, run_attach_url};
|
use crate::attach::{parse_detach_key, run_attach_url};
|
||||||
use crate::cli::Command;
|
use crate::cli::Command;
|
||||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
use crate::sessions::{run_kill, run_list, run_send, run_spawn};
|
||||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -24,6 +24,10 @@ pub enum CliError {
|
|||||||
Attach(#[from] crate::attach::AttachError),
|
Attach(#[from] crate::attach::AttachError),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Hooks(#[from] maestro_hooks::HooksError),
|
Hooks(#[from] maestro_hooks::HooksError),
|
||||||
|
#[error("{0}")]
|
||||||
|
Resolve(#[from] crate::sessions::ResolveError),
|
||||||
|
#[error("nothing to send: pass the text as arguments or pipe it on stdin")]
|
||||||
|
NothingToSend,
|
||||||
#[error("could not determine the home directory ($HOME unset?)")]
|
#[error("could not determine the home directory ($HOME unset?)")]
|
||||||
NoHome,
|
NoHome,
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -82,6 +86,10 @@ pub async fn dispatch(
|
|||||||
run_kill(&daemon, &args).await?;
|
run_kill(&daemon, &args).await?;
|
||||||
println!("killed {}", args.session_id);
|
println!("killed {}", args.session_id);
|
||||||
}
|
}
|
||||||
|
Command::Send(args) => {
|
||||||
|
let id = run_send(&daemon, &args, &mut std::io::stdin()).await?;
|
||||||
|
println!("sent to {id}");
|
||||||
|
}
|
||||||
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ use std::io::Write;
|
|||||||
|
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
SessionStatus, WorktreeSpec,
|
Session, SessionStatus, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
|
use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs};
|
||||||
|
use crate::dispatch::CliError;
|
||||||
|
|
||||||
/// `maestro list`
|
/// `maestro list`
|
||||||
pub async fn run_list(
|
pub async fn run_list(
|
||||||
@@ -66,6 +67,92 @@ pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), Clie
|
|||||||
client.kill_session(args.session_id, args.force).await
|
client.kill_session(args.session_id, args.force).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nothing on the daemon answers to what the operator named.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ResolveError {
|
||||||
|
#[error("no session matches {0:?} — run `maestro list` to see what is running")]
|
||||||
|
NoMatch(String),
|
||||||
|
#[error("{0:?} matches {1} sessions; use a session id instead")]
|
||||||
|
Ambiguous(String, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn what the operator typed into a session id: a full ID, a unique ID
|
||||||
|
/// prefix, or a session's name (#125), matched case-insensitively. Names come
|
||||||
|
/// first — an operator who named a session after a hex string meant the name.
|
||||||
|
pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid, ResolveError> {
|
||||||
|
let needle = needle.trim();
|
||||||
|
let by_name: Vec<&Session> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
maestro_protocol::display::custom_name(s)
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case(needle))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match by_name.len() {
|
||||||
|
1 => return Ok(by_name[0].id),
|
||||||
|
0 => {}
|
||||||
|
n => return Err(ResolveError::Ambiguous(needle.to_string(), n)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let by_id: Vec<&Session> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
s.id.to_string()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with(&needle.to_ascii_lowercase())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match by_id.len() {
|
||||||
|
1 => Ok(by_id[0].id),
|
||||||
|
0 => Err(ResolveError::NoMatch(needle.to_string())),
|
||||||
|
n => Err(ResolveError::Ambiguous(needle.to_string(), n)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
|
/// caller can name it once the daemon has accepted the text.
|
||||||
|
///
|
||||||
|
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
||||||
|
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||||
|
/// the shape a heredoc or a pipe wants.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub async fn run_send(
|
||||||
|
client: &DaemonClient,
|
||||||
|
args: &SendArgs,
|
||||||
|
stdin: &mut impl std::io::Read,
|
||||||
|
) -> Result<uuid::Uuid, CliError> {
|
||||||
|
let text = if args.text.is_empty() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
stdin.read_to_string(&mut buf)?;
|
||||||
|
// One trailing newline is how a pipe ends, not part of the message —
|
||||||
|
// keeping it would submit the text twice.
|
||||||
|
buf.strip_suffix('\n')
|
||||||
|
.map(|t| t.strip_suffix('\r').unwrap_or(t))
|
||||||
|
.unwrap_or(&buf)
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
args.text.join(" ")
|
||||||
|
};
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(CliError::NothingToSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
let sessions = client.list_sessions().await?;
|
||||||
|
resolve_session(&sessions, &args.session)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = SendInputRequest {
|
||||||
|
text,
|
||||||
|
submit: !args.no_enter,
|
||||||
|
};
|
||||||
|
client.send_input(id, &req).await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::write_literal)]
|
#[allow(clippy::write_literal)]
|
||||||
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
|
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
|
||||||
if sessions.is_empty() {
|
if sessions.is_empty() {
|
||||||
@@ -218,6 +305,63 @@ mod tests {
|
|||||||
assert!(out.contains("bash [claude]"), "{out}");
|
assert!(out.contains("bash [claude]"), "{out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn named(id: u128, name: &str) -> Session {
|
||||||
|
let mut s = session("/tmp", "claude", id);
|
||||||
|
s.metadata
|
||||||
|
.insert("name".into(), serde_json::Value::String(name.into()));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_by_name_id_prefix_and_reports_what_it_cannot() {
|
||||||
|
use super::{resolve_session, ResolveError};
|
||||||
|
|
||||||
|
// Ids that differ in their first characters, so a short prefix is
|
||||||
|
// enough to pick one — the ergonomics the prefix match exists for.
|
||||||
|
let (a, b) = (1u128 << 124, 2u128 << 124);
|
||||||
|
let sessions = vec![named(a, "planner"), named(b, "builder")];
|
||||||
|
let one = Uuid::from_u128(a);
|
||||||
|
|
||||||
|
assert_eq!(resolve_session(&sessions, "planner").unwrap(), one);
|
||||||
|
// Names match case-insensitively and ignore surrounding space.
|
||||||
|
assert_eq!(resolve_session(&sessions, " Planner ").unwrap(), one);
|
||||||
|
// An id prefix works when it picks exactly one session.
|
||||||
|
let prefix = &one.to_string()[..8];
|
||||||
|
assert_eq!(resolve_session(&sessions, prefix).unwrap(), one);
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_session(&sessions, "nope"),
|
||||||
|
Err(ResolveError::NoMatch(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two sessions sharing a name is legal — the daemon never enforced
|
||||||
|
/// uniqueness — so `send` must refuse rather than pick one.
|
||||||
|
#[test]
|
||||||
|
fn an_ambiguous_name_is_refused() {
|
||||||
|
use super::{resolve_session, ResolveError};
|
||||||
|
|
||||||
|
let sessions = vec![named(1 << 124, "worker"), named(2 << 124, "worker")];
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_session(&sessions, "worker"),
|
||||||
|
Err(ResolveError::Ambiguous(_, 2))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session named after its own id prefix means the name: an operator
|
||||||
|
/// who typed a name they chose should not be routed by hex coincidence.
|
||||||
|
#[test]
|
||||||
|
fn names_win_over_id_prefixes() {
|
||||||
|
use super::resolve_session;
|
||||||
|
|
||||||
|
let two = Uuid::from_u128(2 << 124);
|
||||||
|
let prefix = two.to_string()[..8].to_string();
|
||||||
|
let sessions = vec![named(1 << 124, &prefix), session("/tmp", "bash", 2 << 124)];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_session(&sessions, &prefix).unwrap(),
|
||||||
|
Uuid::from_u128(1 << 124)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_sessions() {
|
fn empty_sessions() {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
|
|||||||
@@ -108,3 +108,110 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
.expect("kill");
|
.expect("kill");
|
||||||
assert_eq!(mock.session_count(), 0);
|
assert_eq!(mock.session_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_types_text_and_submits_by_default() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(7);
|
||||||
|
mock.add_session(session(id, "/home/mock/work", "claude"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["run".into(), "the".into(), "tests".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
let inputs = mock.inputs();
|
||||||
|
assert_eq!(inputs.len(), 1);
|
||||||
|
assert_eq!(inputs[0].0, id);
|
||||||
|
assert_eq!(inputs[0].1.text, "run the tests");
|
||||||
|
assert!(inputs[0].1.submit, "Enter is the default");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--no-enter` leaves the text in the prompt; the flag has to survive the
|
||||||
|
/// whole way to the daemon or the message is sent anyway.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_without_enter_leaves_the_text_unsubmitted() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(8);
|
||||||
|
mock.add_session(session(id, "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["ls".into()],
|
||||||
|
no_enter: true,
|
||||||
|
};
|
||||||
|
maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert!(!mock.inputs()[0].1.submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No text arguments → the message comes from stdin, minus the trailing
|
||||||
|
/// newline a pipe or heredoc adds.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_reads_stdin_and_drops_its_trailing_newline() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(9);
|
||||||
|
mock.add_session(session(id, "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: Vec::new(),
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec());
|
||||||
|
maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin)
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(mock.inputs()[0].1.text, "multi\nline");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session named through `PATCH /api/sessions/:id` is addressable by that
|
||||||
|
/// name, so scripts don't have to carry a UUID around.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_resolves_a_session_by_name() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(10);
|
||||||
|
let mut named = session(id, "/tmp", "claude");
|
||||||
|
named
|
||||||
|
.metadata
|
||||||
|
.insert("name".into(), serde_json::Value::String("planner".into()));
|
||||||
|
mock.add_session(named);
|
||||||
|
mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: "Planner".into(),
|
||||||
|
text: vec!["go".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash"));
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: "nope".into(),
|
||||||
|
text: vec!["hi".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect_err("should not resolve");
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("no session matches"), "{err}");
|
||||||
|
assert!(mock.inputs().is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use axum::{Json, Router};
|
|||||||
use maestro_config::SpawnPreset;
|
use maestro_config::SpawnPreset;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -44,6 +44,7 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||||
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
.route(
|
.route(
|
||||||
routes::patterns::SESSION,
|
routes::patterns::SESSION,
|
||||||
delete(kill_session).patch(rename_session),
|
delete(kill_session).patch(rename_session),
|
||||||
@@ -100,6 +101,9 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
|
// the daemon can do about it.
|
||||||
|
"not_running" => StatusCode::CONFLICT,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +251,28 @@ async fn report_activity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/input`: type text into a session's pane, then
|
||||||
|
/// press Enter unless the request turns `submit` off. Answers `204 No
|
||||||
|
/// Content`. A session that has exited is refused (409) rather than accepted
|
||||||
|
/// into nowhere.
|
||||||
|
async fn send_input(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<SendInputRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s.manager.send_input(id, &body.text, body.submit).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
info.code.as_str(),
|
||||||
|
info.message.as_str(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||||
(status, Json(ApiError::new(code, message))).into_response()
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
|||||||
/// attempt, which is all #83 needs.
|
/// attempt, which is all #83 needs.
|
||||||
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Gap between the text of a `send` and the Enter that submits it.
|
||||||
|
///
|
||||||
|
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
|
||||||
|
/// burst of input into one paste; a newline inside that burst becomes a line
|
||||||
|
/// break in the message instead of sending it. Landing the Enter as its own
|
||||||
|
/// write, after the reader has drained the text, is what makes the message
|
||||||
|
/// actually go.
|
||||||
|
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SessionRecord {
|
pub struct SessionRecord {
|
||||||
@@ -78,6 +87,11 @@ pub enum SessionError {
|
|||||||
NotFound(Uuid),
|
NotFound(Uuid),
|
||||||
#[error("invalid environment variable name: {0:?}")]
|
#[error("invalid environment variable name: {0:?}")]
|
||||||
InvalidEnv(String),
|
InvalidEnv(String),
|
||||||
|
/// The session exists but its pane is gone, so there is nothing to type
|
||||||
|
/// into. Writing to a dead session is a client bug worth reporting, unlike
|
||||||
|
/// a stray keystroke on an attach socket, which is dropped.
|
||||||
|
#[error("session is no longer running: {0}")]
|
||||||
|
NotRunning(Uuid),
|
||||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||||
/// so it stays the operator's handle on their own unfinished work.
|
/// so it stays the operator's handle on their own unfinished work.
|
||||||
@@ -94,6 +108,7 @@ impl SessionError {
|
|||||||
SessionError::Io(_) => "io_failed",
|
SessionError::Io(_) => "io_failed",
|
||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
|
SessionError::NotRunning(_) => "not_running",
|
||||||
SessionError::KillGuarded(_) => "kill_guarded",
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
};
|
};
|
||||||
let unsaved_work = match self {
|
let unsaved_work = match self {
|
||||||
@@ -671,6 +686,33 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Type `text` into a session's pane, with an Enter after it when
|
||||||
|
/// `submit` is set.
|
||||||
|
///
|
||||||
|
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
||||||
|
/// asked for text to land somewhere, and a silent success would let a
|
||||||
|
/// script believe an agent had been told something it never heard.
|
||||||
|
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||||
|
match self.status(id).await {
|
||||||
|
None => return Err(SessionError::NotFound(id)),
|
||||||
|
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
||||||
|
Some(SessionStatus::Running) => {}
|
||||||
|
}
|
||||||
|
let tmux = self.inner.tmux.clone();
|
||||||
|
let bytes = text.as_bytes().to_vec();
|
||||||
|
tmux_blocking(move || tmux.send_keys(id, &bytes))
|
||||||
|
.await
|
||||||
|
.map_err(SessionError::Tmux)?;
|
||||||
|
if submit {
|
||||||
|
tokio::time::sleep(SUBMIT_DELAY).await;
|
||||||
|
let tmux = self.inner.tmux.clone();
|
||||||
|
tmux_blocking(move || tmux.send_keys(id, b"\r"))
|
||||||
|
.await
|
||||||
|
.map_err(SessionError::Tmux)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Resize a session's window. No-op for a dead session, as with
|
/// Resize a session's window. No-op for a dead session, as with
|
||||||
/// [`Self::send_keys`].
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
|
|||||||
@@ -1250,6 +1250,143 @@ async fn cli_list_reads_from_daemon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `maestro send` end to end: the shipped binary, a real daemon, a real tmux
|
||||||
|
/// pane. The shell only runs the command if the Enter arrived as its own
|
||||||
|
/// write after the text, so the file appearing is the whole path working.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn cli_send_types_a_command_into_a_live_shell() {
|
||||||
|
let (daemon, client) = bring_up().await;
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let sess = client
|
||||||
|
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
|
||||||
|
.await
|
||||||
|
.expect("spawn bash");
|
||||||
|
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let cfg_path = home.path().join("config.toml");
|
||||||
|
let out = run_cli(
|
||||||
|
&["send", &sess.id.to_string(), "touch", "marker.txt"],
|
||||||
|
&[
|
||||||
|
("HOME", home.path().to_str().unwrap()),
|
||||||
|
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
|
||||||
|
("MAESTRO_DAEMON_URL", &daemon.http_base()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"send failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let marker = cwd.path().join("marker.txt");
|
||||||
|
wait_for_async(
|
||||||
|
"the shell runs the sent command",
|
||||||
|
Duration::from_secs(10),
|
||||||
|
|| {
|
||||||
|
let marker = marker.clone();
|
||||||
|
async move { marker.exists() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client.kill_session(sess.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--no-enter` fills the prompt without submitting: the command must sit
|
||||||
|
/// there unrun. Same setup as the test above, so the only difference is the
|
||||||
|
/// flag.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn send_without_enter_does_not_run_the_command() {
|
||||||
|
let (daemon, client) = bring_up().await;
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let sess = client
|
||||||
|
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
|
||||||
|
.await
|
||||||
|
.expect("spawn bash");
|
||||||
|
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let out = run_cli(
|
||||||
|
&[
|
||||||
|
"send",
|
||||||
|
"--no-enter",
|
||||||
|
&sess.id.to_string(),
|
||||||
|
"touch",
|
||||||
|
"unsubmitted.txt",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
("HOME", home.path().to_str().unwrap()),
|
||||||
|
(
|
||||||
|
"MAESTRO_CONFIG",
|
||||||
|
home.path().join("c.toml").to_str().unwrap(),
|
||||||
|
),
|
||||||
|
("MAESTRO_DAEMON_URL", &daemon.http_base()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"send failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
assert!(
|
||||||
|
!cwd.path().join("unsubmitted.txt").exists(),
|
||||||
|
"the command ran despite --no-enter"
|
||||||
|
);
|
||||||
|
|
||||||
|
client.kill_session(sess.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session whose command exited still exists in the catalog, but its pane
|
||||||
|
/// is gone. Typing into it is refused instead of being swallowed — a script
|
||||||
|
/// that sends an instruction to a dead agent has to hear about it.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn send_to_a_dead_session_is_refused() {
|
||||||
|
use maestro_protocol::{SendInputRequest, SessionStatus};
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let sess = client
|
||||||
|
.spawn_session(&create_req(cwd.path(), "true"))
|
||||||
|
.await
|
||||||
|
.expect("spawn true");
|
||||||
|
|
||||||
|
wait_for_async(
|
||||||
|
"the session to be seen dead",
|
||||||
|
Duration::from_secs(15),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let id = sess.id;
|
||||||
|
async move {
|
||||||
|
client
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.map(|ss| {
|
||||||
|
ss.iter()
|
||||||
|
.any(|s| s.id == id && s.status == SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.daemon()
|
||||||
|
.send_input(
|
||||||
|
sess.id,
|
||||||
|
&SendInputRequest {
|
||||||
|
text: "hello".into(),
|
||||||
|
submit: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a dead session cannot be typed into");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("no longer running"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn cli_list_fails_when_daemon_is_down() {
|
async fn cli_list_fails_when_daemon_is_down() {
|
||||||
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
|
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session,
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -155,6 +155,17 @@ impl DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/input`: type text into a session's pane, with
|
||||||
|
/// an Enter after it unless `submit` is off. Answers `204 No Content`; a
|
||||||
|
/// session that has exited is refused rather than silently accepted.
|
||||||
|
pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(Method::POST, &routes::session_input(id), &[], Some(req))
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
self.get(routes::INFO, &[]).await
|
self.get(routes::INFO, &[]).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use crate::Session;
|
use crate::Session;
|
||||||
|
|
||||||
|
/// The name a session was given (#125), if any. `None` for one that was never
|
||||||
|
/// renamed — clients fall back to the command.
|
||||||
|
pub fn custom_name(s: &Session) -> Option<&str> {
|
||||||
|
match s.metadata.get("name") {
|
||||||
|
Some(serde_json::Value::String(name)) => Some(name.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
||||||
/// untouched. Mirrors the web's `abbreviateCwd`.
|
/// untouched. Mirrors the web's `abbreviateCwd`.
|
||||||
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
||||||
|
|||||||
@@ -512,6 +512,24 @@ pub struct NameBatch {
|
|||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's
|
||||||
|
/// pane, as if the operator had typed it on an attached terminal.
|
||||||
|
///
|
||||||
|
/// `submit` presses Enter after the text — what makes `maestro send` a way to
|
||||||
|
/// give an agent an instruction rather than only to fill its prompt. It goes
|
||||||
|
/// out as its own write a beat later, because a TUI agent that reads the text
|
||||||
|
/// as one paste would otherwise swallow the newline into the message.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SendInputRequest {
|
||||||
|
pub text: String,
|
||||||
|
#[serde(default = "submit_default")]
|
||||||
|
pub submit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_default() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Longest session name the daemon stores (#125), in characters. A name is a
|
/// Longest session name the daemon stores (#125), in characters. A name is a
|
||||||
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
||||||
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ pub fn session_activity(id: Uuid) -> String {
|
|||||||
format!("{SESSIONS}/{id}/activity")
|
format!("{SESSIONS}/{id}/activity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/sessions/:id/input`: type text into a session's pane.
|
||||||
|
pub fn session_input(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}/input")
|
||||||
|
}
|
||||||
|
|
||||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||||
pub fn attach(id: Uuid) -> String {
|
pub fn attach(id: Uuid) -> String {
|
||||||
format!("{API_PREFIX}/attach/{id}")
|
format!("{API_PREFIX}/attach/{id}")
|
||||||
@@ -59,6 +64,7 @@ pub fn attach(id: Uuid) -> String {
|
|||||||
pub mod patterns {
|
pub mod patterns {
|
||||||
pub const SESSION: &str = "/api/sessions/:id";
|
pub const SESSION: &str = "/api/sessions/:id";
|
||||||
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||||
|
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
|
||||||
pub const ATTACH: &str = "/api/attach/:id";
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +176,10 @@ mod tests {
|
|||||||
session_activity(id()),
|
session_activity(id()),
|
||||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session_input(id()),
|
||||||
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
attach(id()),
|
attach(id()),
|
||||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
@@ -192,6 +202,10 @@ mod tests {
|
|||||||
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
|
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
|
||||||
session(id()).trim_end_matches(&id().to_string())
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
|
||||||
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
patterns::ATTACH.trim_end_matches(":id"),
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
attach(id()).trim_end_matches(&id().to_string())
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
|||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get};
|
use axum::routing::{any, delete, get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
|
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
@@ -55,6 +55,8 @@ struct Inner {
|
|||||||
info: Option<DaemonInfo>,
|
info: Option<DaemonInfo>,
|
||||||
/// When set, every route answers with this instead of its normal response.
|
/// When set, every route answers with this instead of its normal response.
|
||||||
forced_error: Option<(u16, ApiError)>,
|
forced_error: Option<(u16, ApiError)>,
|
||||||
|
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||||
|
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -86,6 +88,7 @@ impl MockDaemon {
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
|
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
@@ -167,6 +170,11 @@ impl MockDaemon {
|
|||||||
self.state.inner.lock().expect("poisoned").forced_error = None;
|
self.state.inner.lock().expect("poisoned").forced_error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every `send` this daemon received, oldest first.
|
||||||
|
pub fn inputs(&self) -> Vec<(Uuid, SendInputRequest)> {
|
||||||
|
self.state.inner.lock().expect("poisoned").inputs.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn session_count(&self) -> usize {
|
pub fn session_count(&self) -> usize {
|
||||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
}
|
}
|
||||||
@@ -233,6 +241,28 @@ async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Respon
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records the text instead of typing it — the subject under test is the
|
||||||
|
/// client's request, not tmux.
|
||||||
|
async fn send_input(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<SendInputRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let mut guard = s.inner.lock().expect("poisoned");
|
||||||
|
if !guard.sessions.iter().any(|x| x.id == id) {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
guard.inputs.push((id, req));
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn daemon_info(State(s): State<AppState>) -> Response {
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
if let Some(r) = forced(&s) {
|
if let Some(r) = forced(&s) {
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ enum Command {
|
|||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
Kill(maestro_cli::cli::KillArgs),
|
Kill(maestro_cli::cli::KillArgs),
|
||||||
|
/// Type text into a running session, as if it had been typed on an
|
||||||
|
/// attached terminal.
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
Send(maestro_cli::cli::SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
Hooks(maestro_cli::cli::HooksArgs),
|
Hooks(maestro_cli::cli::HooksArgs),
|
||||||
@@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
|
Command::Send(args) => {
|
||||||
|
maestro_cli::dispatch(
|
||||||
|
maestro_cli::cli::Command::Send(args),
|
||||||
|
cli.config,
|
||||||
|
cli.daemon_url,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
Command::Hooks(args) => {
|
Command::Hooks(args) => {
|
||||||
maestro_cli::dispatch(
|
maestro_cli::dispatch(
|
||||||
maestro_cli::cli::Command::Hooks(args),
|
maestro_cli::cli::Command::Hooks(args),
|
||||||
@@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option<Command>) -> bool {
|
|||||||
| Some(Command::Spawn(_))
|
| Some(Command::Spawn(_))
|
||||||
| Some(Command::Attach(_))
|
| Some(Command::Attach(_))
|
||||||
| Some(Command::Kill(_))
|
| Some(Command::Kill(_))
|
||||||
|
| Some(Command::Send(_))
|
||||||
| Some(Command::Hooks(_))
|
| Some(Command::Hooks(_))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ would have blocked. A tree whose guard is clear never needed the override, so a
|
|||||||
forced kill removes it as usual — force changes what happens to blocked work,
|
forced kill removes it as usual — force changes what happens to blocked work,
|
||||||
not ordinary cleanup.
|
not ordinary cleanup.
|
||||||
|
|
||||||
|
In the web client the kill button sends the unforced `DELETE` straight away —
|
||||||
|
there is no pre-confirmation, because the daemon is the one that decides. Only
|
||||||
|
the refusal raises a prompt, and that prompt names what is in the way and offers
|
||||||
|
"Force kill", which repeats the request with `force=true`.
|
||||||
|
|
||||||
Ending a session and destroying its work are two separate acts. Maestro only
|
Ending a session and destroying its work are two separate acts. Maestro only
|
||||||
performs the first one. Removing a tree it left behind is the operator's own
|
performs the first one. Removing a tree it left behind is the operator's own
|
||||||
`git worktree remove`, chosen while looking at exactly what would be lost.
|
`git worktree remove`, chosen while looking at exactly what would be lost.
|
||||||
|
|||||||
+32
-7
@@ -1,6 +1,7 @@
|
|||||||
import * as routes from "./routes.js";
|
import * as routes from "./routes.js";
|
||||||
import type {
|
import type {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
InTheWay,
|
||||||
Branches,
|
Branches,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
DaemonInfo,
|
DaemonInfo,
|
||||||
@@ -13,19 +14,38 @@ import type {
|
|||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
|
||||||
|
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
|
||||||
|
* render `unsavedWork` rather than re-parsing the message. */
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly detail: string;
|
||||||
|
readonly unsavedWork: InTheWay | null;
|
||||||
|
|
||||||
|
constructor(res: Response, info?: ApiError["error"], text?: string) {
|
||||||
|
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
|
||||||
|
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||||||
|
this.name = "ApiRequestError";
|
||||||
|
this.status = res.status;
|
||||||
|
this.code = info?.code ?? "";
|
||||||
|
this.detail = info?.message ?? text ?? "";
|
||||||
|
this.unsavedWork = info?.unsaved_work ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 204) return undefined as unknown as T;
|
if (res.status === 204) return undefined as unknown as T;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
let detail = "";
|
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as ApiError;
|
const body = (await res.json()) as ApiError;
|
||||||
detail = `${body.error.code}: ${body.error.message}`;
|
throw new ApiRequestError(res, body.error);
|
||||||
} catch {
|
} catch (e) {
|
||||||
detail = await res.text();
|
if (e instanceof ApiRequestError) throw e;
|
||||||
|
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
|
||||||
}
|
}
|
||||||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSessions(): Promise<Session[]> {
|
export async function listSessions(): Promise<Session[]> {
|
||||||
@@ -57,9 +77,14 @@ export async function renameSession(id: Uuid, name: string): Promise<Session> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function killSession(id: Uuid): Promise<void> {
|
/** Kill a session. With `force` (#81) the daemon overrides its refusal over a
|
||||||
|
* worktree holding unsaved work and leaves that worktree on disk. */
|
||||||
|
export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||||
|
const url = force
|
||||||
|
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
|
||||||
|
: routes.session(id);
|
||||||
await jsonOrError(
|
await jsonOrError(
|
||||||
await fetch(routes.session(id), {
|
await fetch(url, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
}),
|
}),
|
||||||
|
|||||||
+6
-4
@@ -143,8 +143,10 @@ export function createAttachConnection(
|
|||||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||||
// gesture stream; only the terminal surface opts out of native page panning.
|
// gesture stream; only the terminal surface opts out of native page panning.
|
||||||
|
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
|
||||||
|
// later sibling of the viewport, so it paints over it and takes every touch.
|
||||||
{
|
{
|
||||||
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
const root = term.element as HTMLElement | undefined;
|
||||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||||
const cellH = () => {
|
const cellH = () => {
|
||||||
const core = (term as unknown as {
|
const core = (term as unknown as {
|
||||||
@@ -152,11 +154,11 @@ export function createAttachConnection(
|
|||||||
})._core;
|
})._core;
|
||||||
return core?._renderService?.dimensions?.css?.cell?.height || 16;
|
return core?._renderService?.dimensions?.css?.cell?.height || 16;
|
||||||
};
|
};
|
||||||
if (vp) {
|
if (root) {
|
||||||
let lastY = 0;
|
let lastY = 0;
|
||||||
let lastX = 0;
|
let lastX = 0;
|
||||||
let remainder = 0;
|
let remainder = 0;
|
||||||
vp.addEventListener(
|
root.addEventListener(
|
||||||
"touchstart",
|
"touchstart",
|
||||||
(e) => {
|
(e) => {
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
@@ -166,7 +168,7 @@ export function createAttachConnection(
|
|||||||
},
|
},
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
vp.addEventListener(
|
root.addEventListener(
|
||||||
"touchmove",
|
"touchmove",
|
||||||
(e) => {
|
(e) => {
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
|
|||||||
+73
-24
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { renderSidebar } from "./catalog.js";
|
import { renderSidebar } from "./catalog.js";
|
||||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
@@ -16,6 +16,10 @@ beforeAll(() => {
|
|||||||
window.fetch = () => Promise.reject(new Error("offline"));
|
window.fetch = () => Promise.reject(new Error("offline"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector(".kill-dialog")?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
function session(overrides: Partial<Session>): Session {
|
function session(overrides: Partial<Session>): Session {
|
||||||
return {
|
return {
|
||||||
id: "11111111-2222-3333-4444-555555555555",
|
id: "11111111-2222-3333-4444-555555555555",
|
||||||
@@ -46,6 +50,11 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
|||||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
describe("blocked-work indicator (#83)", () => {
|
describe("blocked-work indicator (#83)", () => {
|
||||||
it("marks the row of a blocked session in diff notation", () => {
|
it("marks the row of a blocked session in diff notation", () => {
|
||||||
const blocked = session({
|
const blocked = session({
|
||||||
@@ -89,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restates the warning at the kill confirm", async () => {
|
it("kills straight away, without a first confirmation", async () => {
|
||||||
const confirmations: string[] = [];
|
const calls: string[] = [];
|
||||||
window.confirm = (msg?: string) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
confirmations.push(msg ?? "");
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
return false;
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
};
|
}) as typeof fetch;
|
||||||
|
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||||
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: "kill_guarded",
|
||||||
|
message: "worktree has unsaved work",
|
||||||
|
unsaved_work: { untracked_files: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
const blocked = session({
|
const blocked = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||||
command: "bash",
|
command: "bash",
|
||||||
blocked_work: { untracked_files: 1 },
|
blocked_work: { untracked_files: 1 },
|
||||||
});
|
});
|
||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
kill?.click();
|
await flush();
|
||||||
await Promise.resolve();
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
expect(confirmations).toHaveLength(1);
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
expect(confirmations[0]).toContain("1 untracked file");
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||||
expect(confirmations[0]).toContain("refused unless forced");
|
await flush();
|
||||||
|
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("confirms plainly when nothing blocks", async () => {
|
it("closes the override prompt without killing on cancel", async () => {
|
||||||
const confirmations: string[] = [];
|
const calls: string[] = [];
|
||||||
window.confirm = (msg?: string) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
confirmations.push(msg ?? "");
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
return false;
|
return Promise.resolve(
|
||||||
};
|
new Response(
|
||||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
JSON.stringify({
|
||||||
const el = renderSidebar(storeWith([plain])).el;
|
error: { code: "kill_guarded", message: "worktree has unsaved work" },
|
||||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
}),
|
||||||
await Promise.resolve();
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const blocked = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000006",
|
||||||
|
blocked_work: { unpushed_commits: 2 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+20
-10
@@ -1,5 +1,6 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
|
import { openForceKillDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { isTypingTarget, toggleHelp } from "./help.js";
|
import { isTypingTarget, toggleHelp } from "./help.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
@@ -180,7 +181,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
killBtn.addEventListener("click", (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void confirmAndKill(s, name);
|
void requestKill(s, name);
|
||||||
});
|
});
|
||||||
row.addEventListener("contextmenu", (ev) => {
|
row.addEventListener("contextmenu", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -192,22 +193,31 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
||||||
},
|
},
|
||||||
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
|
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** #83: the confirm restates the warning at the moment of decision. */
|
/**
|
||||||
async function confirmAndKill(s: Session, name: string): Promise<void> {
|
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||||
const blockers = sessionBlockers(s);
|
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||||
const question = blockers
|
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
||||||
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
* a forced kill. Anything else surfaces as an alert.
|
||||||
: `Kill session "${name}"?`;
|
*/
|
||||||
if (!confirm(question)) return;
|
async function requestKill(s: Session, name: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await killSession(s.id);
|
await killSession(s.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||||
|
openForceKillDialog({
|
||||||
|
name,
|
||||||
|
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||||
|
reason: e.detail,
|
||||||
|
onForce: () => killSession(s.id, true),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert(`Failed to kill: ${(e as Error).message}`);
|
alert(`Failed to kill: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Forced-kill prompt (#81).
|
||||||
|
*
|
||||||
|
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
||||||
|
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
||||||
|
* refusal gets its own dialog: it names what is in the way and offers the
|
||||||
|
* override the daemon's `force=true` already implements. A kill the daemon
|
||||||
|
* accepts never reaches here — there is nothing to warn about.
|
||||||
|
*/
|
||||||
|
import { formatInTheWay, type InTheWay } from "./types.js";
|
||||||
|
|
||||||
|
export interface ForceKillPrompt {
|
||||||
|
/** Session name, as the sidebar shows it. */
|
||||||
|
name: string;
|
||||||
|
/** What the daemon reported in the way, when it reported anything. */
|
||||||
|
blockers: InTheWay | null;
|
||||||
|
/** Message from the daemon's refusal, shown when there is no breakdown. */
|
||||||
|
reason: string;
|
||||||
|
/** Run the forced kill. A rejection is rendered in the dialog, which stays
|
||||||
|
* open so the override can be retried. */
|
||||||
|
onForce: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
|
||||||
|
|
||||||
|
export function closeForceKillDialog(): void {
|
||||||
|
if (!open) return;
|
||||||
|
window.removeEventListener("keydown", open.onKeydown, true);
|
||||||
|
open.el.remove();
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
||||||
|
* can never stack two dialogs. */
|
||||||
|
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||||
|
closeForceKillDialog();
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "kill-dialog";
|
||||||
|
el.setAttribute("role", "dialog");
|
||||||
|
el.setAttribute("aria-modal", "true");
|
||||||
|
el.setAttribute("aria-label", `Kill ${p.name}`);
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "kill-dialog-panel";
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.className = "kill-dialog-title";
|
||||||
|
title.textContent = `Kill "${p.name}"?`;
|
||||||
|
|
||||||
|
const body = document.createElement("p");
|
||||||
|
body.className = "kill-dialog-body";
|
||||||
|
body.textContent = p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||||
|
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
|
||||||
|
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "kill-dialog-error";
|
||||||
|
error.hidden = true;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "kill-dialog-actions";
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||||
|
cancel.textContent = "Cancel";
|
||||||
|
cancel.addEventListener("click", closeForceKillDialog);
|
||||||
|
const force = document.createElement("button");
|
||||||
|
force.type = "button";
|
||||||
|
force.className = "btn btn-danger kill-dialog-force";
|
||||||
|
force.textContent = "Force kill";
|
||||||
|
force.addEventListener("click", () => {
|
||||||
|
force.disabled = true;
|
||||||
|
void p
|
||||||
|
.onForce()
|
||||||
|
.then(closeForceKillDialog)
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
force.disabled = false;
|
||||||
|
error.hidden = false;
|
||||||
|
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.append(cancel, force);
|
||||||
|
|
||||||
|
panel.append(title, body, error, actions);
|
||||||
|
|
||||||
|
// A press on the scrim cancels; one inside the panel is left alone.
|
||||||
|
el.addEventListener("click", (ev) => {
|
||||||
|
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
|
||||||
|
});
|
||||||
|
el.appendChild(panel);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
const onKeydown = (ev: KeyboardEvent): void => {
|
||||||
|
if (ev.key !== "Escape") return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeForceKillDialog();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
|
open = { el, onKeydown };
|
||||||
|
cancel.focus();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ export const EVENTS = "/api/events";
|
|||||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||||
export const PATH_QUERY = "path";
|
export const PATH_QUERY = "path";
|
||||||
|
|
||||||
|
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
|
||||||
|
* `force=true` overrides the kill refusal over a guarded worktree. */
|
||||||
|
export const FORCE_QUERY = "force";
|
||||||
|
|
||||||
/** Name of the query parameter `NAMES` takes. */
|
/** Name of the query parameter `NAMES` takes. */
|
||||||
export const COUNT_QUERY = "count";
|
export const COUNT_QUERY = "count";
|
||||||
|
|
||||||
|
|||||||
+64
-4
@@ -1058,6 +1058,64 @@ input.leaf-rename:focus {
|
|||||||
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- forced-kill prompt (#81) ---------- */
|
||||||
|
.kill-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: oklch(0 0 0 / 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-panel {
|
||||||
|
width: min(400px, 100%);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-3);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-title {
|
||||||
|
margin: 0 0 var(--sp-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-15);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-error {
|
||||||
|
margin: var(--sp-2) 0 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- empty / loading ---------- */
|
/* ---------- empty / loading ---------- */
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -1274,10 +1332,12 @@ input.leaf-rename:focus {
|
|||||||
.attach-body .xterm .xterm-viewport {
|
.attach-body .xterm .xterm-viewport {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
|
}
|
||||||
the gesture to the browser, which may cancel the event stream before the
|
|
||||||
terminal sees it; `none` keeps a drag inside the terminal under our
|
/* Maestro drives scrollback from touchmove (see attach.ts), so the browser must
|
||||||
control while the rest of the page retains native scrolling. */
|
not claim the gesture. On `.xterm`, not the viewport: `.xterm-screen` paints
|
||||||
|
over the viewport and is what a finger lands on. */
|
||||||
|
.attach-body .xterm {
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -253,7 +253,12 @@ export type ClientControl =
|
|||||||
| { type: "Error"; code: string; message: string };
|
| { type: "Error"; code: string; message: string };
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string };
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
/** Set only by the kill guard (#79): what blocked the unforced kill. */
|
||||||
|
unsaved_work?: InTheWay;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionName(s: Session): string {
|
export function sessionName(s: Session): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user