Compare commits

..
Author SHA1 Message Date
Miguel Palhas ec6345cc32 Merge remote-tracking branch 'origin/main' into reboot/172-worktree-field 2026-09-04 08:48:30 +01:00
Miguel PalhasandClaude Sonnet 5 b05b014e84 fix(maestro): clear worktree from the session clone on natural exit
mark_session_dead cleared blocked_work on the local session clone
written to the sidecar, but not worktree — so a pristine tree the
daemon just removed stayed named in the sidecar, and recovery from
that sidecar advertised a branch and path that no longer exist.

Refs #172.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 08:48:24 +01:00
Miguel Palhas aa5e0551b9 merge: per-preset resume_command (#165)
Closes #165
2026-09-04 08:43:11 +01:00
Miguel Palhas ca2b60c17e merge: detect host reboot via boot_id (#164)
Closes #164
2026-09-04 08:43:11 +01:00
Miguel Palhas d548d0b24e feat(maestro): skip the tmux probe on a detected host reboot
Recovery could not tell a daemon restart from a host reboot: both
looked like "some sessions were not in tmux list-sessions", so the
tmux probe ran unconditionally and the logs said nothing useful when
it was actually a reboot.

Record /proc/sys/kernel/random/boot_id in the state directory on
every recover(). When it differs from the stored value, every session
from the previous boot is provably dead, so the tmux probe is skipped
and the second recovery pass registers them dormant directly, logging
"host rebooted, sessions dormant" with the count. A missing stored
value or an unreadable boot id (non-Linux, restricted /proc) falls
back to today's probe-based recovery.

The boot id source is injected via a BootIdSource trait so tests can
simulate a changed or unreadable boot id without rebooting the
machine.

Refs #164
2026-09-04 08:40:10 +01:00
Miguel Palhas aa34d975a9 test(maestro): assert the agent session id survives recover()
The test named for recovery only read the sidecar back; nothing covered
the id reaching the recovered session, which is what a resume reads.

Also narrows RESTART.md's leniency claim: the custom Deserialize lives in
the client's own binary, so an older build still fails on Dormant.
2026-09-04 08:36:43 +01:00
Miguel Palhas a0c97937a1 merge: prune sidecars with a missing cwd (#163)
Closes #163
2026-09-04 08:33:29 +01:00
Miguel Palhas 9728b10ffd feat(maestro): add preset resume commands 2026-09-04 08:33:08 +01:00
Miguel Palhas c004b5d1b6 feat(maestro): prune sidecars whose cwd no longer exists
recover()'s dormant-registration pass (#162) now deletes a sidecar
instead of registering it dormant when the directory it points at is
gone. Nothing can be resumed there, so the honest move is to remove
the record rather than keep an unactionable one around (#163).

Sidecars whose cwd still exists are unaffected, regardless of age.
2026-09-04 08:30:39 +01:00
Miguel Palhas c51d5b8f7b merge: persist agent session id (#161)
Closes #161
2026-09-04 08:27:49 +01:00
Miguel Palhas 1b2b392710 merge: dormant session recovery (#162)
Closes #162
2026-09-04 08:25:44 +01:00
Miguel PalhasandClaude Opus 5 72644dd564 feat(maestro): register dormant sessions on recovery
A host reboot kills the tmux server, so `SessionManager::recover` iterated an
empty list and every session vanished from the API while its sidecar sat
untouched on disk. A second recovery pass now reads the sidecars tmux had
nothing to adopt and registers each as `SessionStatus::Dormant` — a record of
what was running, with no pane and no PTY bridge behind it.

`SessionStatus` deserializes leniently now, like `ActivityState`: an unknown
spelling reads as `Dead` rather than failing the whole `Session`.

Refs #162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:22:52 +01:00
Miguel Palhas fa846000e7 feat(maestro): persist the agent's own session id on the sidecar (#161)
The Claude Code hook already receives a session_id in every payload, but
the activity route dropped it and the daemon had no field to store it on.
Parse it out of the hook body, add Session.agent_session_id, and write it
through to the sidecar once — the first report that carries one wins, and
later reports never overwrite it since an agent's own id never changes
mid-session.

This is the input to a follow-up POST /api/sessions/:id/resume.
2026-09-04 08:22:51 +01:00
113 changed files with 1870 additions and 23109 deletions
-6
View File
@@ -92,12 +92,6 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
the two matchers must agree on, and the fixture table they are tested against.
Read it before changing either bar or either matcher.
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
run as a daemon child over ACP instead of in a tmux pane) — the eight
transcript row kinds, the two projections and their cursor/replay semantics,
the handshake and declined capabilities, and the approval nonce rules. Read
it before changing anything that renders a structured session.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
Generated
+56 -749
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -10,7 +10,6 @@ repository = "https://git.naps.pt/naps62/maestro"
publish = false
[workspace.dependencies]
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
axum = { version = "0.7", features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] }
@@ -18,7 +17,6 @@ crossterm = { version = "0.28", features = ["event-stream"] }
dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2"
maestro-acp = { path = "crates/maestro-acp" }
maestro-config = { path = "crates/maestro-config" }
# `default-features = false` so each consumer opts into `haiku-autoname`
# through its own feature of the same name (#131); a workspace entry that
@@ -40,11 +38,9 @@ tempfile = "3"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tokio-tungstenite = "0.21"
tokio-util = { version = "0.7", features = ["compat"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
unicode-width = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
ratatui = "0.28"
vt100 = "0.15"
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "maestro-acp"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "ACP client role: one provider adapter child per structured session"
[dependencies]
agent-client-protocol.workspace = true
libc.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env python3
"""A deterministic stand-in for `claude-code-acp` (#134).
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
answers `initialize` and `session/new`, logs every method it receives, and
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
the adapter's name, so the real adapter (which needs Claude auth and is
slow) never runs in the deterministic suite.
Environment knobs, all optional:
FAKE_ACP_LOG append one line per event to this file:
start pid=<pid> cwd=<cwd>
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
caps <json of clientCapabilities>
recv <method>
signal SIGTERM
FAKE_ACP_ECHO_ENV name of one env var to log at start
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
`session/new`
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
`session/new`
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
so the SIGTERM path is exercised)
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
notifications ("replayed <i>") before answering,
the way a real agent streams history back
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt`
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
JSON-RPC error
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
callback until a later prompt
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
counting from 1) and logs `emit tick <n>`, so a test can make the agent
speak on demand (#135).
"""
import json
import os
import signal
import sys
import time
LOG = os.environ.get("FAKE_ACP_LOG")
def log(line):
if not LOG:
return
with open(LOG, "a") as f:
f.write(line + "\n")
f.flush()
def send(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def on_sigterm(_signum, _frame):
log("signal SIGTERM")
sys.exit(0)
signal.signal(signal.SIGTERM, on_sigterm)
session_id = f"fake-{os.getpid()}"
ticks = 0
prompt_failures = 0
pending_prompt_id = None
pending_permission_id = None
delayed_permission = False
current_model = "opus"
current_mode = "default"
current_effort = "high"
def settings_response(include_session=False):
result = {}
if include_session:
result["sessionId"] = session_id
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
result.update({
"models": {
"currentModelId": current_model,
"availableModels": [
{"modelId": "opus", "name": "Opus"},
{"modelId": "sonnet", "name": "Sonnet"},
],
},
"modes": {
"currentModeId": current_mode,
"availableModes": [
{"id": "default", "name": "Default"},
{"id": "plan", "name": "Plan"},
],
},
"configOptions": [{
"id": "effort", "name": "Effort", "category": "thought_level",
"type": "select", "currentValue": current_effort,
"options": [
{"value": "low", "name": "Low"},
{"value": "high", "name": "High"},
],
}],
})
return result
def chunk(text):
return {"jsonrpc": "2.0", "method": "session/update", "params": {
"sessionId": session_id,
"update": {"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text}},
}}
def on_sigusr1(_signum, _frame):
global ticks
ticks += 1
send(chunk(f"tick {ticks}"))
log(f"emit tick {ticks}")
signal.signal(signal.SIGUSR1, on_sigusr1)
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
if echo:
log(f"env {echo}={os.environ.get(echo, '')}")
for raw in sys.stdin:
raw = raw.strip()
if not raw:
continue
msg = json.loads(raw)
method = msg.get("method")
if method is None:
# A response to something we sent; nothing to do.
if msg.get("id") == pending_permission_id:
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
send({"jsonrpc": "2.0", "id": pending_prompt_id,
"result": {"stopReason": "end_turn"}})
pending_prompt_id = None
pending_permission_id = None
continue
log(f"recv {method}")
rid = msg.get("id")
params = msg.get("params") or {}
if method == "initialize":
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake init failure"}})
continue
send({"jsonrpc": "2.0", "id": rid, "result": {
"protocolVersion": params.get("protocolVersion", 1),
"agentCapabilities": {
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
"authMethods": [],
}})
elif method == "session/new":
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
while gate and not os.path.exists(gate):
time.sleep(0.01)
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
send(chunk("hello from fake"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0)
elif method == "session/load":
session_id = params.get("sessionId")
log(f"recv session/load sessionId={session_id}")
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake load failure"}})
continue
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
send(chunk(f"replayed {i + 1}"))
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
send(chunk("hello after load"))
elif method == "session/set_model":
current_model = params.get("modelId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_mode":
current_mode = params.get("modeId")
send({"jsonrpc": "2.0", "id": rid, "result": {}})
elif method == "session/set_config_option":
current_effort = params.get("value")
send({"jsonrpc": "2.0", "id": rid, "result": {
"configOptions": settings_response()["configOptions"]}})
elif method == "session/cancel":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
send(chunk("final on cancel"))
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
and pending_prompt_id is not None):
send({"jsonrpc": "2.0", "id": pending_prompt_id,
"result": {"stopReason": "cancelled"}})
pending_prompt_id = None
pending_permission_id = None
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
sys.exit(0)
elif method == "session/prompt":
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
if pending_prompt_id is None:
pending_prompt_id = rid
delayed_permission = True
continue
if delayed_permission:
delayed_permission = False
pending_permission_id = "permission-1"
send({"jsonrpc": "2.0", "id": pending_permission_id,
"method": "session/request_permission", "params": {
"sessionId": session_id,
"toolCall": {"toolCallId": "tool-1", "fields": {}},
"options": [
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
]
}})
continue
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
pending_prompt_id = rid
pending_permission_id = "permission-1"
send({"jsonrpc": "2.0", "id": pending_permission_id,
"method": "session/request_permission", "params": {
"sessionId": session_id,
"toolCall": {"toolCallId": "tool-1", "fields": {}},
"options": [
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
]
}})
continue
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
prompt_failures += 1
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake prompt failure"}})
else:
send({"jsonrpc": "2.0", "id": rid,
"result": {"stopReason": "end_turn"}})
elif rid is not None:
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"method not found: {method}"}})
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
while True:
signal.pause()
-505
View File
@@ -1,505 +0,0 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! ignored smoke tests against each maintained adapter.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py")
}
/// A temp `bin/` holding the fake agent under the adapter's name, plus a
/// log file the agent appends to. `PATH` for the spawn is just that dir.
struct Fake {
dir: TempDir,
}
impl Fake {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
Self { dir }
}
fn log_path(&self) -> PathBuf {
self.dir.path().join("agent.log")
}
fn log(&self) -> String {
std::fs::read_to_string(self.log_path()).unwrap_or_default()
}
/// The fake's `bin/` first, so it shadows any real adapter, then the
/// process `PATH` so the script's `python3` still resolves.
fn env(&self) -> HashMap<String, String> {
let path = format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
HashMap::from([
("PATH".to_string(), path),
(
"FAKE_ACP_LOG".to_string(),
self.log_path().to_string_lossy().into_owned(),
),
])
}
fn config(&self, cwd: &Path) -> SpawnConfig {
let mut config = SpawnConfig::new(cwd, self.env());
config.handshake_timeout = Duration::from_secs(10);
config
}
async fn wait_for_log(&self, needle: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if self.log().contains(needle) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("log never contained {needle:?}:\n{}", self.log());
}
fn pid(&self) -> u32 {
self.log()
.lines()
.find_map(|line| line.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|pid| pid.parse().ok())
.expect("pid in log")
}
async fn wait_for_exit(&self) {
let pid = self.pid();
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} still alive");
}
}
fn pid_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
&& std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|s| !s.contains(") Z "))
.unwrap_or(false)
}
#[tokio::test]
async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
config
.env
.insert("MAESTRO_SESSION_ID".into(), "sess-123".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert!(session.session_id().starts_with("fake-"));
assert!(pid_alive(session.pid()));
let log = fake.log();
let cwd_canon = cwd.path().canonicalize().unwrap();
assert!(
log.contains(&format!("cwd={}", cwd_canon.display())),
"child cwd missing from log:\n{log}"
);
assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}");
assert!(log.contains("recv initialize"), "{log}");
assert!(log.contains("recv session/new"), "{log}");
let caps = log
.lines()
.find_map(|l| l.strip_prefix("caps "))
.expect("caps line");
let caps: serde_json::Value = serde_json::from_str(caps).unwrap();
assert_eq!(caps["fs"]["readTextFile"], false);
assert_eq!(caps["fs"]["writeTextFile"], false);
assert_eq!(caps["terminal"], false);
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn missing_adapter_names_the_install_command() {
let empty = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let env = HashMap::from([(
"PATH".to_string(),
empty.path().to_string_lossy().into_owned(),
)]);
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await
.expect_err("no adapter on PATH");
assert!(matches!(
err,
AcpError::AdapterMissing {
adapter: AcpAdapter::ClaudeCode
}
));
let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
}
#[test]
fn codex_adapter_has_its_own_binary_and_install_guidance() {
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
assert!(AcpAdapter::Codex
.install_command()
.contains("@agentclientprotocol/codex-acp"));
}
#[tokio::test]
async fn spawn_selects_the_codex_adapter() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn Codex adapter");
assert!(fake.log().contains("recv session/new"));
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
.await
.expect("spawn");
let mut updates = session.take_updates().expect("first take");
assert!(session.take_updates().is_none(), "handed out once");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(first.session_id.0.as_ref(), session.session_id());
match first.update {
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn advertised_settings_are_normalized_and_mutable() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let mut updates = session.take_updates().expect("updates");
let initial = loop {
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("settings update")
.expect("channel open");
if let SessionUpdate::ConfigOptionUpdate(options) = update.update {
break options.config_options;
}
};
assert_eq!(initial.len(), 3);
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "effort"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
assert!(initial
.iter()
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
let model = initial
.iter()
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
.expect("legacy model option");
let model = serde_json::to_value(model).unwrap();
assert_eq!(model["options"][0]["name"], "Opus");
assert!(model["options"]
.as_array()
.unwrap()
.iter()
.any(|option| option["value"] == "fable"));
let changed = session
.set_config(
"__maestro_legacy_model".into(),
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
)
.expect("queue model change");
changed
.await
.expect("response delivered")
.expect("model changed");
fake.wait_for_log("recv session/set_model").await;
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await
.expect("spawn");
let pid = session.pid();
session
.terminate(Duration::from_millis(200), Duration::from_secs(2))
.await;
assert!(session.has_exited());
fake.wait_for_log("signal SIGTERM").await;
let log = fake.log();
let cancel = log.find("recv session/cancel").expect("cancel logged");
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
assert!(!pid_alive(pid), "child {pid} still alive");
}
#[tokio::test]
async fn terminate_is_satisfied_by_an_exit_on_cancel() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
session
.terminate(Duration::from_secs(2), Duration::from_secs(2))
.await;
assert!(session.has_exited());
let log = fake.log();
assert!(log.contains("recv session/cancel"), "{log}");
assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}");
}
#[tokio::test]
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake init failure"), "{err}");
// Closing the connection gives the fake EOF on stdin; the SIGTERM that
// follows may find it already gone. Either way it must not survive.
let pid: u32 = fake
.log()
.lines()
.find_map(|l| l.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|p| p.parse().ok())
.expect("pid in log");
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} outlived the failed spawn");
}
#[tokio::test]
async fn early_exit_is_observed_after_a_successful_handshake() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let status = tokio::time::timeout(Duration::from_secs(5), session.wait())
.await
.expect("exit within 5s")
.expect("status");
assert!(status.success(), "{status}");
assert!(session.has_exited());
}
#[tokio::test]
async fn child_exit_before_handshake_reports_exited_early() {
// A "adapter" that dies before answering anything.
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join(ADAPTER_BIN);
std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let cwd = TempDir::new().unwrap();
let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]);
let mut config = SpawnConfig::new(cwd.path(), env);
config.handshake_timeout = Duration::from_secs(10);
let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err {
AcpError::ExitedEarly { status, stderr, .. } => {
assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}");
}
other => panic!("unexpected {other:?}"),
}
}
#[tokio::test]
async fn drop_terminates_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
drop(session);
fake.wait_for_log("signal SIGTERM").await;
fake.wait_for_exit().await;
}
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
#[tokio::test]
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert_eq!(session.session_id(), "fake-old");
let log = fake.log();
assert!(
log.contains("recv session/load sessionId=fake-old"),
"{log}"
);
assert!(!log.contains("recv session/new"), "{log}");
// The three replayed chunks never reach the receiver: the event log
// already holds them. The first notification after the response does.
let mut updates = session.take_updates().expect("updates");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(chunk_text(&first), "hello after load");
// Nothing queued behind it either.
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(updates.try_recv().is_err(), "replay leaked through");
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn load_session_requires_the_load_capability() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config)
.await
.expect_err("no loadSession");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("loadSession"), "{err}");
assert!(!fake.log().contains("recv session/load"), "{}", fake.log());
}
#[tokio::test]
async fn load_session_error_fails_the_spawn() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake load failure"), "{err}");
}
/// End to end against the installed `claude-code-acp`. Needs Claude auth,
/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`.
#[tokio::test]
#[ignore]
async fn real_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()))
.await
.expect("real claude-code-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
/// End to end against the maintained Codex adapter. It needs a locally
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
#[tokio::test]
#[ignore]
async fn real_codex_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session =
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
.await
.expect("real codex-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
+1 -45
View File
@@ -19,12 +19,6 @@ use tokio_tungstenite::tungstenite::Message;
pub enum AttachError {
#[error("ws: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[error("daemon returned {status} ({code}): {message}")]
Rejected {
status: String,
code: String,
message: String,
},
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("termios: {0}")]
@@ -38,21 +32,6 @@ pub enum AttachError {
}
impl AttachError {
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
if let Some(body) = response.body().as_deref() {
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
return Self::Rejected {
status: response.status().to_string(),
code: error_body.error.code,
message: error_body.error.message,
};
}
}
}
Self::WebSocket(error)
}
/// True when the failure was an inability to reach the WS endpoint at the
/// transport level (connection refused), as opposed to an HTTP-level
/// rejection from a daemon that did answer.
@@ -132,9 +111,7 @@ fn current_winsize() -> Option<(u16, u16)> {
/// byte that exits cleanly without killing the session.
#[allow(clippy::result_large_err)]
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
let (ws, _resp) = tokio_tungstenite::connect_async(url)
.await
.map_err(AttachError::from_websocket)?;
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
let (mut sink, mut stream) = ws.split();
let raw = RawTty::enable()?;
@@ -256,25 +233,4 @@ mod tests {
Err(AttachError::InvalidDetachKey(_))
));
}
#[test]
fn attach_surfaces_structured_handshake_errors() {
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
"not_terminal",
"session is not a terminal session",
))
.unwrap();
let response = tokio_tungstenite::tungstenite::http::Response::builder()
.status(409)
.body(Some(body))
.unwrap();
let error =
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
assert_eq!(
error.to_string(),
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
);
}
}
-8
View File
@@ -92,14 +92,6 @@ pub struct SpawnArgs {
/// there.
#[arg(long)]
pub existing_branch: bool,
/// Run a Claude Code or Codex session in a plain terminal instead of the
/// structured (ACP) view it otherwise gets by default.
#[arg(long, conflicts_with = "structured")]
pub terminal: bool,
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
/// Codex commands have an adapter; the daemon refuses anything else.
#[arg(long)]
pub structured: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
pub attach: bool,
+11 -23
View File
@@ -96,32 +96,20 @@ pub async fn dispatch(
}
/// `maestro hooks install` (#106): write the shipped hook script under the
/// user's data dir and register it in every agent's hook file. Idempotent and
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
/// agent failing is reported and does not stop the others.
/// user's data dir and register it in their Claude Code settings. Idempotent
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
#[allow(clippy::result_large_err)]
fn run_hooks_install() -> Result<(), CliError> {
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
let script = maestro_hooks::script_path(&home);
for (target, result) in maestro_hooks::install_for_home(&home) {
let agent = target.label();
let settings = target.settings_path(&home);
match result {
Ok(true) => {
println!(
"installed {agent} activity hook: {} → {}",
script.display(),
settings.display()
);
if target == maestro_hooks::Target::Codex {
// Codex will not run a hook it has not been told to
// trust, and that approval is the operator's to give.
println!(" codex will ask you to trust this hook the next time it starts");
}
}
Ok(false) => println!("{agent} activity hook already up to date"),
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
}
let changed = maestro_hooks::install_for_home(&home)?;
if changed {
println!(
"installed claude-code activity hook: {} → {}",
maestro_hooks::script_path(&home).display(),
maestro_hooks::settings_path(&home).display()
);
} else {
println!("claude-code activity hook already up to date");
}
Ok(())
}
+9 -103
View File
@@ -3,10 +3,10 @@
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, SessionView, WorktreeSpec,
Session, SessionStatus, WorktreeSpec,
};
use serde_json::Value;
@@ -51,33 +51,15 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
};
let body = CreateSessionRequest {
view: spawn_view(&args),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
metadata,
worktree,
auto_approve: false,
};
client.create_session(&body).await
}
/// The view a `maestro spawn` asks for: structured whenever the command
/// resolves to an agent with an ACP adapter, since that is the richer session
/// and the one both other clients now default to. `--terminal` opts out;
/// `--structured` forces the request through even for a command this side
/// does not recognise, leaving the refusal to the daemon.
fn spawn_view(args: &SpawnArgs) -> SessionView {
if args.terminal {
return SessionView::Terminal;
}
if args.structured || supports_structured_view(&args.command) {
SessionView::Structured
} else {
SessionView::Terminal
}
}
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where
/// the work went.
@@ -196,8 +178,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
}
writeln!(
out,
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
)
.ok();
for s in sorted {
@@ -206,10 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let status = match s.status {
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let view = match s.view {
SessionView::Terminal => "terminal",
SessionView::Structured => "structured",
SessionStatus::Dormant => "dormant",
};
let activity = match s.activity {
ActivityState::Active => "active",
@@ -228,8 +207,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let blocked = blocked_label(s);
writeln!(
out,
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
s.id, status, view, activity, dir, command
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
s.id, status, activity, dir, command
)
.ok();
}
@@ -251,70 +230,13 @@ mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use uuid::Uuid;
use super::DaemonInfo;
use clap::Parser;
use crate::cli::{Cli, Command};
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
argv.extend_from_slice(extra);
let cli = Cli::try_parse_from(argv).expect("parses");
let Command::Spawn(args) = cli.command else {
panic!("expected spawn")
};
args
}
/// The CLI follows the two interactive clients: a command with an ACP
/// adapter spawns structured unless `--terminal` says otherwise, and
/// `--structured` still forces the request for a command this side does
/// not recognise.
#[test]
fn spawn_defaults_to_structured_for_supported_agents() {
use super::spawn_view;
assert_eq!(
spawn_view(&spawn_args(&["claude"])),
SessionView::Structured
);
assert_eq!(
spawn_view(&spawn_args(&["npx codex exec"])),
SessionView::Structured
);
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
assert_eq!(
spawn_view(&spawn_args(&["claude", "--terminal"])),
SessionView::Terminal
);
assert_eq!(
spawn_view(&spawn_args(&["opencode", "--structured"])),
SessionView::Structured
);
}
#[test]
fn spawn_refuses_both_view_flags_at_once() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"claude",
"--structured",
"--terminal",
])
.unwrap_err();
assert!(err.to_string().contains("--terminal"), "{err}");
}
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(id),
cwd: PathBuf::from(cwd),
command: command.into(),
@@ -331,7 +253,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}
}
@@ -363,22 +285,6 @@ mod tests {
assert!(s.contains("idle"), "{s}");
}
#[test]
fn table_exposes_terminal_and_structured_views() {
let terminal = session("/tmp", "bash", 1);
let mut structured = session("/tmp", "claude", 2);
structured.view = SessionView::Structured;
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
let out = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = out.lines().collect();
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
assert!(lines[header + 1].contains("terminal"), "{out}");
assert!(lines[header + 2].contains("structured"), "{out}");
}
#[test]
fn live_cwd_overrides_spawn_dir_in_table() {
let mut s = session("/home/naps/maestro", "bash", 1);
@@ -28,7 +28,7 @@ async fn info_carries_version_and_presets() {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
resume_command: None,
}],
..Default::default()
});
@@ -92,13 +92,11 @@ async fn spawn_round_trips_through_the_daemon() {
let created = mock
.client()
.create_session(&maestro_protocol::CreateSessionRequest {
view: Default::default(),
cwd: "/home/mock".into(),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+43 -10
View File
@@ -313,19 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
resume_command: Some("claude --resume {agent_session}".into()),
},
SpawnPreset {
id: "opencode".into(),
label: "opencode".into(),
command: "opencode".into(),
auto_approve: false,
resume_command: None,
},
SpawnPreset {
id: "zsh".into(),
label: "Shell".into(),
command: "zsh".into(),
auto_approve: false,
resume_command: None,
},
]
}
@@ -337,7 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
id: CUSTOM_PRESET_ID.into(),
label: "Custom…".into(),
command: String::new(),
auto_approve: false,
resume_command: None,
}
}
@@ -511,6 +511,18 @@ auto_install = false
assert!(custom.command.is_empty());
}
#[test]
fn builtin_claude_preset_resumes_by_agent_session() {
let claude = builtin_presets()
.into_iter()
.find(|preset| preset.id == "claude")
.expect("Claude preset");
assert_eq!(
claude.resume_command.as_deref(),
Some("claude --resume {agent_session}")
);
}
#[test]
fn spawn_presets_override_replaces_builtins() {
// Present list replaces the built-ins entirely; custom still appended.
@@ -519,7 +531,7 @@ auto_install = false
id: "fish".into(),
label: "fish".into(),
command: "fish".into(),
auto_approve: false,
resume_command: None,
}]),
..Default::default()
};
@@ -556,7 +568,7 @@ auto_install = false
id: " ".into(),
label: "x".into(),
command: "x".into(),
auto_approve: false,
resume_command: None,
}]),
..Default::default()
};
@@ -567,7 +579,7 @@ auto_install = false
id: "x".into(),
label: "x".into(),
command: "".into(),
auto_approve: false,
resume_command: None,
}]),
..Default::default()
};
@@ -585,13 +597,13 @@ auto_install = false
id: "a".into(),
label: "a".into(),
command: "a".into(),
auto_approve: false,
resume_command: None,
},
SpawnPreset {
id: "a".into(),
label: "a2".into(),
command: "a2".into(),
auto_approve: false,
resume_command: None,
},
]),
..Default::default()
@@ -606,7 +618,7 @@ auto_install = false
id: "custom".into(),
label: "Custom".into(),
command: "whatever".into(),
auto_approve: false,
resume_command: None,
}]),
..Default::default()
};
@@ -642,6 +654,27 @@ presets = [
assert_eq!(ids, ["claude", "zsh", "custom"]);
}
#[test]
fn spawn_preset_parses_resume_command_from_issue_example() {
let cfg = toml::from_str::<MaestroConfigFile>(
r#"
[spawn]
presets = [
{ id = "claude", command = "claude --dangerously-skip-permissions", resume_command = "claude --dangerously-skip-permissions --resume {agent_session}" }
]
"#,
)
.expect("issue #165 example parses");
let preset = &cfg.spawn.presets.expect("configured presets")[0];
assert_eq!(preset.id, "claude");
assert_eq!(preset.command, "claude --dangerously-skip-permissions");
assert_eq!(
preset.resume_command.as_deref(),
Some("claude --dangerously-skip-permissions --resume {agent_session}")
);
}
#[test]
fn spawn_project_scan_fields_default_to_none() {
// Absent [spawn].project_* → None; the daemon applies its own defaults.
-2
View File
@@ -28,7 +28,6 @@ dirs.workspace = true
futures-util.workspace = true
include_dir = { workspace = true, optional = true }
libc.workspace = true
maestro-acp.workspace = true
mime_guess = { workspace = true, optional = true }
maestro-config.workspace = true
maestro-hooks = { workspace = true, default-features = false }
@@ -43,7 +42,6 @@ tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
libc.workspace = true
reqwest.workspace = true
tokio-tungstenite.workspace = true
urlencoding = "2"
-28
View File
@@ -66,23 +66,6 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
}
}
/// Derive a structured session's sidebar state from its ACP projection.
pub fn decide_structured(
turn_active: bool,
pending_approval: bool,
turn_failed: bool,
) -> ActivityState {
if turn_failed {
ActivityState::Error
} else if pending_approval {
ActivityState::AwaitingInput
} else if turn_active {
ActivityState::Active
} else {
ActivityState::Idle
}
}
/// How long a session's pane must be continuously focused (a client's pane,
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
/// operator has looked, so the rail stops asking for attention.
@@ -153,17 +136,6 @@ mod tests {
);
}
#[test]
fn structured_activity_follows_lifecycle_with_approval_precedence() {
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
assert_eq!(
decide_structured(true, true, false),
ActivityState::AwaitingInput
);
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
}
#[test]
fn report_is_in_force_until_exactly_its_expiry() {
let ttl = Duration::from_secs(300);
+62
View File
@@ -0,0 +1,62 @@
//! Tells a daemon restart from a host reboot (#164).
//!
//! `/proc/sys/kernel/random/boot_id` is fixed for the life of the kernel and
//! changes on every boot. [`SessionManager::recover`] compares it against the
//! value it stored on the previous run: a difference means every session from
//! before is provably dead, so the tmux probe can be skipped and those
//! sessions registered dormant directly instead of relying on tmux reporting
//! an empty list.
use std::path::Path;
/// Where the daemon reads the running kernel's boot id. Behind a trait so
/// tests can simulate a changed (or unreadable) boot id without rebooting the
/// machine.
pub trait BootIdSource: std::fmt::Debug + Send + Sync + 'static {
/// Current boot id, or `None` if it can't be determined (non-Linux,
/// restricted `/proc`, missing or empty file).
fn current(&self) -> Option<String>;
}
/// Production source: `/proc/sys/kernel/random/boot_id`.
#[derive(Debug, Default)]
pub struct ProcBootId;
impl BootIdSource for ProcBootId {
fn current(&self) -> Option<String> {
read_boot_id(Path::new("/proc/sys/kernel/random/boot_id"))
}
}
fn read_boot_id(path: &Path) -> Option<String> {
let contents = std::fs::read_to_string(path).ok()?;
let trimmed = contents.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_trimmed_contents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("boot_id");
std::fs::write(&path, "abc-123\n").unwrap();
assert_eq!(read_boot_id(&path).as_deref(), Some("abc-123"));
}
#[test]
fn missing_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read_boot_id(&dir.path().join("missing")), None);
}
#[test]
fn empty_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("boot_id");
std::fs::write(&path, "\n").unwrap();
assert_eq!(read_boot_id(&path), None);
}
}
+19 -34
View File
@@ -51,23 +51,17 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
// First-start hook auto-install (#106). Best-effort, before serving: a
// session spawned right away should already carry the hook.
if config.hooks_auto_install {
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
let agent = target.label();
match outcome {
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!(agent, "installed activity hook (#106)");
}
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!(agent, "updated activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!(
agent,
"hooks auto-install failed; run `maestro hooks install` manually"
);
}
_ => {}
match crate::hooks::auto_install(&config.home_dir) {
crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!("installed claude-code activity hook (#106)");
}
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!("updated claude-code activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
}
_ => {}
}
}
// No worktree dir to pre-create: without an override the worktrees live
@@ -89,7 +83,11 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
config.spawn_history_path(),
config.spawn_history_days,
));
))
.with_boot_id(
config.boot_id_path(),
std::sync::Arc::new(crate::boot_id::ProcBootId),
);
// Spawned sessions learn where to report back (#103). Derived from the
// configured bind address; set before any spawn can happen.
manager.set_daemon_url(format!("http://{}", config.bind_addr));
@@ -106,7 +104,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
projects.start_background_refresh();
let router = local_api::router(local_api::state_for(
manager.clone(),
manager,
config.hostname.clone(),
config.home_dir.clone(),
config.spawn_presets.clone(),
@@ -118,22 +116,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "API listening");
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a
// shutdown, and both must reach the adapter children below (#134). The
// unit's `KillMode=process` signals only the daemon, so nothing else
// would end them.
let shutdown_manager = manager.clone();
let shutdown = async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received — shutting down"),
_ = sigterm.recv() => tracing::info!("SIGTERM received — shutting down"),
}
// Axum waits for upgraded WebSockets to drain. Structured children
// must be settled before that wait, while the signal path still owns
// the shutdown order (#179).
shutdown_manager.shutdown().await;
let shutdown = async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("ctrl-c received — shutting down");
};
axum::serve(listener, router)
+6
View File
@@ -112,6 +112,12 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl")
}
/// Where recovery records the kernel boot id it saw last (#164):
/// `<state_dir>/boot_id`.
pub fn boot_id_path(&self) -> PathBuf {
self.state_dir.join("boot_id")
}
}
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
+37 -107
View File
@@ -1,53 +1,37 @@
//! First-start hook auto-install (#106).
//!
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
//! file already carries exactly what this build ships (and `[hooks]
//! .auto_install` hasn't opted out — checked by the caller), the same
//! installer `maestro hooks install` uses writes it. That covers upgrades as
//! well as first starts: an install from a maestro that had fewer events —
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
//! settings — is rewritten rather than left half-wired. Best-effort: a
//! failure is warned about and never blocks startup, and one target failing
//! does not stop the others. The same global hook serves every session on the
//! machine, so nothing here targets repositories or worktrees.
//! The daemon runs [`auto_install`] once at startup: unless the user's
//! `~/.claude/settings.json` already carries exactly what this build ships
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
//! same installer `maestro hooks install` uses writes it. That covers
//! upgrades as well as first starts: an install from a maestro that had fewer
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
//! a failure is warned about and never blocks startup. The same global hook
//! serves every Claude Code session on the machine, so nothing here targets
//! repositories or worktrees.
use std::path::Path;
use maestro_hooks::Target;
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
/// any whose install is already current. Returns what happened per target —
/// an older install being brought up to date is
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
Target::ALL
.iter()
.map(|&target| (target, auto_install_target(target, home_dir)))
.collect()
}
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
/// Install the Claude Code activity hook for `home_dir` unless the current
/// one is already there or the home dir is unresolvable (`""`). Returns what
/// happened; an older install being brought up to date is
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
if home_dir.is_empty() {
return AutoInstallOutcome::Skipped;
}
let home = Path::new(home_dir);
let settings = target.settings_path(home);
let script = maestro_hooks::script_path(home);
if maestro_hooks::is_current(target, &settings, &script) {
let settings = maestro_hooks::settings_path(Path::new(home_dir));
let script = maestro_hooks::script_path(Path::new(home_dir));
if maestro_hooks::is_current(&settings, &script) {
return AutoInstallOutcome::AlreadyPresent;
}
let stale = maestro_hooks::is_installed(&settings);
match maestro_hooks::install_target(target, home) {
match maestro_hooks::install_for_home(Path::new(home_dir)) {
Ok(_) if stale => AutoInstallOutcome::Upgraded,
Ok(_) => AutoInstallOutcome::Installed,
Err(e) => {
tracing::warn!(
target = target.label(),
error = %e,
"hooks auto-install failed (non-fatal)"
);
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
AutoInstallOutcome::Failed
}
}
@@ -68,50 +52,15 @@ pub enum AutoInstallOutcome {
mod tests {
use super::*;
/// The outcome recorded for one target.
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
outcomes
.iter()
.find(|(t, _)| *t == target)
.map(|(_, o)| *o)
.expect("every target is reported")
}
/// Everything happens under a temp `$HOME`; the developer's own
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
/// written.
/// `~/.claude/settings.json` is never read or written.
#[test]
fn installs_when_settings_carry_no_maestro_hook() {
let dir = tempfile::tempdir().unwrap();
let outcomes = auto_install(dir.path().to_str().unwrap());
for target in Target::ALL {
assert_eq!(
outcome(&outcomes, target),
AutoInstallOutcome::Installed,
"{}",
target.label()
);
assert!(maestro_hooks::is_installed(
&target.settings_path(dir.path())
));
}
}
/// Every agent maestro knows about is hooked, not just Claude Code: a
/// codex session reports activity and its first prompt the same way.
#[test]
fn codex_gets_its_own_hook_file() {
let dir = tempfile::tempdir().unwrap();
auto_install(dir.path().to_str().unwrap());
let hooks = dir.path().join(".codex/hooks.json");
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
let events = root["hooks"].as_object().unwrap();
assert!(events.contains_key("PermissionRequest"));
assert!(
!events.contains_key("Notification"),
"codex has no Notification"
);
let outcome = auto_install(dir.path().to_str().unwrap());
assert_eq!(outcome, AutoInstallOutcome::Installed);
let settings = dir.path().join(".claude/settings.json");
assert!(maestro_hooks::is_installed(&settings));
}
#[test]
@@ -120,22 +69,14 @@ mod tests {
let home = dir.path().to_str().unwrap();
auto_install(home);
// A current install is left exactly as it is.
let before: Vec<String> = Target::ALL
.iter()
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
.collect();
let outcomes = auto_install(home);
for (i, target) in Target::ALL.iter().enumerate() {
assert_eq!(
outcome(&outcomes, *target),
AutoInstallOutcome::AlreadyPresent
);
assert_eq!(
before[i],
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
"already-installed settings must not be rewritten"
);
}
let settings = dir.path().join(".claude/settings.json");
let before = std::fs::read_to_string(&settings).unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
assert_eq!(
before,
std::fs::read_to_string(&settings).unwrap(),
"already-installed settings must not be rewritten"
);
}
/// An install from an older maestro — present, but missing an event this
@@ -146,7 +87,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
auto_install(home);
let settings = Target::ClaudeCode.settings_path(dir.path());
let settings = dir.path().join(".claude/settings.json");
let script = maestro_hooks::script_path(dir.path());
let mut root: serde_json::Value =
@@ -155,23 +96,12 @@ mod tests {
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
let outcomes = auto_install(home);
assert_eq!(
outcome(&outcomes, Target::ClaudeCode),
AutoInstallOutcome::Upgraded
);
assert!(maestro_hooks::is_current(
Target::ClaudeCode,
&settings,
&script
));
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
assert!(maestro_hooks::is_current(&settings, &script));
}
#[test]
fn unresolvable_home_is_skipped() {
let outcomes = auto_install("");
for target in Target::ALL {
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
}
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
}
}
+1 -1
View File
@@ -12,6 +12,7 @@ pub mod attach;
/// Naming a session after its first prompt (#131).
#[cfg(feature = "haiku-autoname")]
pub mod autoname;
pub mod boot_id;
pub mod branches;
pub mod cli;
pub mod config;
@@ -21,7 +22,6 @@ pub mod hooks;
pub mod local_api;
pub mod panes;
pub mod proc;
pub mod projection;
pub mod projects;
pub mod pty;
pub mod release;
+19 -303
View File
@@ -12,8 +12,7 @@
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post};
@@ -22,14 +21,12 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
use crate::sidecar::EventCursor;
use crate::session::SessionManager;
#[derive(Debug, Clone)]
pub struct LocalApiState {
@@ -67,20 +64,7 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::NAMES, get(names))
.route(routes::INFO, get(daemon_info))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws))
.route(routes::patterns::STRUCTURED, any(structured_ws))
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
.route(
routes::patterns::STRUCTURED_ATTACHMENT,
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
)
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
.route(
routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval),
);
.route(routes::patterns::ATTACH, any(attach_ws));
#[cfg(feature = "haiku-autoname")]
{
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
@@ -186,8 +170,6 @@ fn session_error_status(code: &str) -> StatusCode {
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
"not_terminal" => StatusCode::CONFLICT,
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -315,16 +297,21 @@ async fn rename_session(
}
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
/// session's state. Structured sessions acknowledge and ignore these reports
/// because their activity comes from ACP. Unknown session is 404; an unknown
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
/// The report holds above the daemon's inference for a TTL, then inference
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
/// forever. Unknown session is 404; an unknown state value is rejected by the
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
async fn report_activity(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<ReportActivityRequest>,
) -> Response {
match s.manager.report_activity(id, body.state, body.detail).await {
match s
.manager
.report_activity(id, body.state, body.detail, body.session_id)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
@@ -385,197 +372,6 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response()
}
#[derive(Debug, Deserialize)]
struct ReplayQuery {
since: Option<u64>,
before: Option<u64>,
limit: Option<usize>,
}
const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500;
async fn structured_replay(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<ReplayQuery>,
) -> Response {
if query.since.is_some() && query.before.is_some() {
return error_response(
StatusCode::BAD_REQUEST,
"invalid_cursor",
"use either since or before, not both",
);
}
let cursor = match query.before {
Some(before) => EventCursor::Before(Some(before)),
None => EventCursor::Since(query.since.unwrap_or(0)),
};
match s
.manager
.read_acp_projection_replay(
id,
cursor,
query
.limit
.unwrap_or(100)
.clamp(1, MAX_STRUCTURED_REPLAY_LIMIT),
)
.await
{
Ok(Some(events)) => Json(events).into_response(),
// A terminal session, or a structured session whose adapter is no
// longer live, cannot provide a coherent replay continuation.
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
#[derive(Deserialize)]
struct AttachmentQuery {
name: String,
}
async fn structured_attachment(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(query): Query<AttachmentQuery>,
bytes: Bytes,
) -> Response {
let name = query.name;
if name.is_empty()
|| name.len() > 180
|| name == "."
|| name == ".."
|| name
.chars()
.any(|c| c.is_control() || c == '/' || c == '\\')
{
return error_response(
StatusCode::BAD_REQUEST,
"invalid_filename",
"use a filename without directory separators, up to 180 bytes",
);
}
match s.manager.store_acp_attachment(id, &name, &bytes).await {
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_prompt(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>,
) -> Response {
match s.manager.queue_acp_prompt(id, body.prompt).await {
Ok(QueueAcpPrompt::Queued) => StatusCode::NO_CONTENT.into_response(),
Ok(QueueAcpPrompt::InFlight) => error_response(
StatusCode::CONFLICT,
"turn_in_flight",
"the structured session already has an active turn",
),
Ok(QueueAcpPrompt::Unavailable) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Ok(QueueAcpPrompt::Failed(message)) => {
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
}
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
match s.manager.cancel_acp_turn(id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_config(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<StructuredConfigRequest>,
) -> Response {
match s
.manager
.set_acp_config(id, body.config_id, body.value)
.await
{
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
Ok(SetAcpConfig::InvalidValue) => error_response(
StatusCode::BAD_REQUEST,
"invalid_config_value",
"configuration values must be strings or booleans",
),
Ok(SetAcpConfig::Unavailable) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Ok(SetAcpConfig::Failed(message)) => {
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
}
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
async fn structured_approval(
State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>,
Json(decision): Json<StructuredApprovalDecision>,
) -> Response {
let decision = match decision {
StructuredApprovalDecision::AllowOnce => crate::session::ApprovalDecision::AllowOnce,
StructuredApprovalDecision::AllowForSession => {
crate::session::ApprovalDecision::AllowForSession
}
StructuredApprovalDecision::Deny => crate::session::ApprovalDecision::Deny,
};
match s.manager.resolve_acp_approval(id, &nonce, decision).await {
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
/// scanner starts with its cache cold; wire
@@ -613,23 +409,12 @@ async fn attach_ws(
State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
Ok(subscription) => subscription,
Err(SessionError::NotFound(_)) => {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
}
Err(error) => {
let info = error.to_error_info();
return Err(error_response(
session_error_status(&info.code),
&info.code,
&info.message,
));
}
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
};
let manager = s.manager.clone();
Ok(ws.on_upgrade(move |socket| async move {
@@ -637,75 +422,6 @@ async fn attach_ws(
}))
}
#[allow(clippy::result_large_err)]
async fn structured_ws(
Path(id): Path<Uuid>,
State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
let Some((snapshot, state, rx)) = s
.manager
.subscribe_acp_projection(id, EventCursor::Since(0))
.await
.map_err(|e| {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
})?
else {
return Err(error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
));
};
Ok(ws.on_upgrade(move |socket| async move {
let _ = pump_structured(socket, snapshot, state, rx).await;
}))
}
async fn pump_structured(
socket: axum::extract::ws::WebSocket,
snapshot: maestro_protocol::TranscriptSnapshot,
state: maestro_protocol::ReducedState,
mut rx: tokio::sync::broadcast::Receiver<crate::projection::ProjectionUpdate>,
) -> Result<(), axum::Error> {
use axum::extract::ws::Message;
use futures_util::{SinkExt, StreamExt};
let (mut sink, mut stream) = socket.split();
for event in [
StructuredEvent::TranscriptSnapshot { snapshot },
StructuredEvent::ReducedState { state },
] {
if sink
.send(Message::Text(
serde_json::to_string(&event).expect("structured event encode"),
))
.await
.is_err()
{
return Ok(());
}
}
loop {
tokio::select! {
update = rx.recv() => match update {
Ok(update) => {
let event = StructuredEvent::ProjectionUpdate { transcript: update.transcript, state: update.state };
if sink.send(Message::Text(serde_json::to_string(&event).expect("structured event encode"))).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
},
frame = stream.next() => match frame {
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {},
},
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
// ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -558
View File
@@ -5,23 +5,10 @@
//! original `cwd`, command, env, metadata, etc. Files are deleted on
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
//! advertised but with empty metadata (handled by the caller, not here).
//!
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
//! sidecar: every `session/update` notification and daemon approval row is one
//! JSON object, appended before it is broadcast. The log is the source of
//! truth for the transcript; `seq` is the line's 1-based position, so a
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
//! that does not parse — including the partial line a hard kill leaves at
//! the end — keeps its seq and is skipped on read. `delete` removes the log
//! with the sidecar; `load_all` never reads one as a sidecar.
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use maestro_acp::schema::SessionNotification;
use maestro_protocol::{Session, TranscriptRow};
use maestro_protocol::Session;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@@ -51,120 +38,6 @@ impl SidecarStore {
self.dir.join(format!("{id}.json"))
}
/// Where the per-session event log lives (#135).
pub fn events_path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.events.jsonl"))
}
/// Durable evidence that graceful daemon shutdown ended this structured
/// session and the next daemon may resume it.
pub fn recovery_path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.recover"))
}
/// Mark `id` as eligible for one structured-session recovery attempt.
pub fn mark_for_recovery(&self, id: Uuid) -> Result<(), SidecarError> {
std::fs::create_dir_all(&self.dir)?;
std::fs::write(self.recovery_path_for(id), [])?;
Ok(())
}
/// Consume the graceful-shutdown recovery marker for `id`.
pub fn take_recovery_marker(&self, id: Uuid) -> Result<bool, SidecarError> {
match std::fs::remove_file(self.recovery_path_for(id)) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
/// Open the event log for `id` for appending, creating it (and the
/// directory) when missing. One handle per live session: the caller
/// keeps it for the session's lifetime rather than reopening per
/// notification.
///
/// A log whose last line has no newline — what a hard kill mid-write
/// leaves behind — is terminated first, so the next append starts a
/// fresh line instead of gluing onto the fragment. The fragment keeps
/// the seq of its line and reads as malformed.
pub fn open_events(&self, id: Uuid) -> Result<EventLog, SidecarError> {
std::fs::create_dir_all(&self.dir)?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(self.events_path_for(id))?;
let mut lines = 0u64;
let mut last = None;
let mut reader = BufReader::new(&mut file);
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
lines += buf[..n].iter().filter(|&&b| b == b'\n').count() as u64;
last = Some(buf[n - 1]);
}
if last.is_some_and(|b| b != b'\n') {
file.write_all(b"\n")?;
lines += 1;
}
file.seek(SeekFrom::End(0))?;
Ok(EventLog {
file: Box::new(file),
last_seq: lines,
})
}
/// Read events from the log for `id`, at most `limit`, in ascending seq
/// order. A missing log is an empty log. Lines that fail to parse are
/// skipped but still consume a seq, so cursors stay stable.
pub fn read_events(
&self,
id: Uuid,
cursor: EventCursor,
limit: usize,
) -> Result<Vec<LoggedEvent>, SidecarError> {
let file = match File::open(self.events_path_for(id)) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
if limit == 0 {
return Ok(Vec::new());
}
let mut out: VecDeque<LoggedEvent> = VecDeque::new();
let mut seq = 0u64;
for line in BufReader::new(file).split(b'\n') {
let line = line?;
seq += 1;
let keep = match cursor {
EventCursor::Since(since) => seq > since,
EventCursor::Before(Some(before)) => seq < before,
EventCursor::Before(None) => true,
};
if !keep {
if matches!(cursor, EventCursor::Before(Some(_))) {
break;
}
continue;
}
let Ok(event) = PersistedEvent::decode(&line) else {
continue;
};
out.push_back(LoggedEvent { seq, event });
match cursor {
EventCursor::Since(_) if out.len() >= limit => break,
EventCursor::Before(_) if out.len() > limit => {
out.pop_front();
}
_ => {}
}
}
Ok(out.into())
}
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
/// if it does not exist.
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
@@ -186,13 +59,13 @@ impl SidecarStore {
}
}
/// Delete the sidecar, event log, and recovery marker for `id`. No-op for
/// whichever does not exist.
/// Delete the sidecar for `id`. No-op if it does not exist.
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
remove_if_present(&self.events_path_for(id))?;
remove_if_present(&self.dir.join(format!("{id}.approvals.jsonl")))?;
remove_if_present(&self.recovery_path_for(id))?;
remove_if_present(&self.path_for(id))
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Read every sidecar JSON in the directory. Files that fail to parse are
@@ -208,8 +81,6 @@ impl SidecarStore {
for entry in entries {
let entry = entry?;
let path = entry.path();
// `.events.jsonl` logs (#135) sit in the same directory and are
// never sidecars.
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
@@ -224,425 +95,3 @@ impl SidecarStore {
Ok(out)
}
}
fn remove_if_present(path: &Path) -> Result<(), SidecarError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Which slice of a session's event log to read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventCursor {
/// Forward: events with `seq` strictly greater than the cursor, the
/// first `limit` of them. `Since(0)` is the whole log from the start.
/// What a client catching up after a reconnect asks for.
Since(u64),
/// Backward: events with `seq` strictly less than the cursor, the last
/// `limit` of them, still returned in ascending order. `None` means the
/// tail of the log, for a first paint. The next page passes the smallest
/// seq it received.
Before(Option<u64>),
}
/// One line of a session's event log.
#[derive(Debug, Clone, PartialEq)]
pub struct LoggedEvent {
/// 1-based line position in the log; the cursor for [`EventCursor`].
pub seq: u64,
pub event: PersistedEvent,
}
/// A single entry in the structured event history.
///
/// ACP notifications retain their original JSON representation, so existing
/// event logs remain readable. Daemon-generated rows use an envelope so they
/// can share the same cursor sequence without pretending to be ACP traffic.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "maestroEvent", rename_all = "snake_case")]
pub enum PersistedEvent {
User {
row: TranscriptRow,
},
Approval {
row: TranscriptRow,
},
Turn {
row: TranscriptRow,
},
#[serde(skip)]
Notification(Box<SessionNotification>),
}
impl PersistedEvent {
fn decode(line: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice::<SessionNotification>(line)
.map(|notification| Self::Notification(Box::new(notification)))
.or_else(|_| serde_json::from_slice(line))
}
}
impl From<SessionNotification> for PersistedEvent {
fn from(notification: SessionNotification) -> Self {
Self::Notification(Box::new(notification))
}
}
impl From<&SessionNotification> for PersistedEvent {
fn from(notification: &SessionNotification) -> Self {
Self::Notification(Box::new(notification.clone()))
}
}
impl From<&PersistedEvent> for PersistedEvent {
fn from(event: &PersistedEvent) -> Self {
event.clone()
}
}
/// What an [`EventLog`] appends onto: something seekable by length and
/// truncatable back to one, so a failed append can be rolled off the end
/// again. A real [`File`] in production; an in-memory fault double in tests
/// (#184) — the same [`EventLog::append`] rollback logic runs over both, so
/// the behavior under test is the production behavior, not a stand-in for
/// it.
pub(crate) trait LogSink: Write + Send {
fn len(&mut self) -> std::io::Result<u64>;
fn truncate(&mut self, len: u64) -> std::io::Result<()>;
}
impl LogSink for File {
fn len(&mut self) -> std::io::Result<u64> {
Ok(self.metadata()?.len())
}
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
self.set_len(len)
}
}
/// Outcome of a failed [`EventLog::append`].
#[derive(Debug, thiserror::Error)]
pub enum AppendError {
/// The write or flush failed, but the log was rolled back to exactly the
/// byte it was at before the call: `last_seq` and the file agree, and
/// this same [`EventLog`] is safe to keep appending to. The event itself
/// was never persisted or broadcast.
#[error("event append failed, rolled back cleanly: {0}")]
Failed(#[source] SidecarError),
/// The append failed *and* the rollback did not restore the log to its
/// prior length, so the file's line count and `last_seq` may now
/// disagree. The caller must not append to this [`EventLog`] again —
/// treat the session as dead rather than promise consistency it cannot
/// keep.
#[error("event append failed ({cause}) and rollback did not restore consistency ({rollback})")]
Fatal {
cause: SidecarError,
rollback: SidecarError,
},
}
impl From<SidecarError> for AppendError {
fn from(error: SidecarError) -> Self {
AppendError::Failed(error)
}
}
/// An open, append-only handle on one session's event log. Obtained from
/// [`SidecarStore::open_events`]; held for the session's lifetime.
pub struct EventLog {
file: Box<dyn LogSink>,
last_seq: u64,
}
impl std::fmt::Debug for EventLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventLog")
.field("last_seq", &self.last_seq)
.finish_non_exhaustive()
}
}
impl EventLog {
/// Wrap an arbitrary sink as an [`EventLog`], for tests that need to
/// inject a deterministic append failure (#184) — a partial write, a
/// flush that fails after the bytes are already down, or a rollback that
/// cannot itself complete.
#[cfg(test)]
pub(crate) fn for_test(file: impl LogSink + 'static, last_seq: u64) -> Self {
Self {
file: Box::new(file),
last_seq,
}
}
/// The seq of the last line, `0` for an empty log.
pub fn last_seq(&self) -> u64 {
self.last_seq
}
/// Append one notification as a line and return its seq. The line is
/// handed to the kernel in a single write before this returns, so a
/// caller that broadcasts afterwards never announces an event the log
/// would not replay — short of the machine losing power.
///
/// A `write_all` that errors partway can leave an unterminated prefix on
/// disk; a `flush` that errors after a *complete* `write_all` leaves
/// ambiguity about whether that full, well-formed line should count —
/// counting it would silently shift every later seq by one, since
/// `last_seq` was never bumped for it. Both cases are rolled back to the
/// exact pre-append length before returning, so `last_seq` and the file
/// never disagree and the same [`EventLog`] can be appended to again
/// immediately. If the rollback itself fails, the log's consistency can
/// no longer be promised and [`AppendError::Fatal`] is returned instead.
pub fn append(&mut self, event: impl Into<PersistedEvent>) -> Result<u64, AppendError> {
let event = event.into();
let mut line = match &event {
PersistedEvent::Notification(notification) => serde_json::to_vec(notification)
.map_err(|e| AppendError::from(SidecarError::from(e)))?,
_ => {
serde_json::to_vec(&event).map_err(|e| AppendError::from(SidecarError::from(e)))?
}
};
line.push(b'\n');
let pre_len = self
.file
.len()
.map_err(|e| AppendError::from(SidecarError::from(e)))?;
if let Err(cause) = self.file.write_all(&line) {
return Err(self.rollback_after(pre_len, cause));
}
if let Err(cause) = self.file.flush() {
return Err(self.rollback_after(pre_len, cause));
}
self.last_seq += 1;
Ok(self.last_seq)
}
fn rollback_after(&mut self, pre_len: u64, cause: std::io::Error) -> AppendError {
match self.file.truncate(pre_len) {
Ok(()) => AppendError::Failed(SidecarError::Io(cause)),
Err(rollback) => AppendError::Fatal {
cause: SidecarError::Io(cause),
rollback: SidecarError::Io(rollback),
},
}
}
}
/// Bytes shared between a [`FaultyLogSink`] moved into an [`EventLog`] and
/// the test that planted it, so the test can inspect what actually ended up
/// on the "disk" after a fault without reaching into `EventLog`'s private
/// `file` field.
#[cfg(test)]
#[derive(Clone, Default)]
pub(crate) struct SharedBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
#[cfg(test)]
impl SharedBuf {
pub(crate) fn snapshot(&self) -> Vec<u8> {
self.0.lock().unwrap().clone()
}
}
/// An in-memory [`LogSink`] double that injects one deterministic fault — a
/// short write, a flush that fails after a full write, or a rollback that
/// cannot itself complete — then behaves like a healthy sink from then on.
/// Lets a test prove the *same* [`EventLog`] recovers, rather than one with a
/// handle swapped in from a healthy disk, which production never does
/// (#184).
#[cfg(test)]
#[derive(Default)]
pub(crate) struct FaultyLogSink {
pub(crate) buf: SharedBuf,
/// Accept this many bytes on the next `write` call, then fail the
/// *following* call — a short write followed by an error, exactly what
/// `write_all` sees from a real fd that dies mid-line. `Write::write`
/// must not report an error on a call that made partial progress, so
/// the fault is delivered one call later, via `pending_write_error`.
pub(crate) fail_write_after: Option<usize>,
pub(crate) pending_write_error: bool,
pub(crate) fail_flush_once: bool,
pub(crate) fail_truncate: bool,
}
#[cfg(test)]
impl Write for FaultyLogSink {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
if self.pending_write_error {
self.pending_write_error = false;
return Err(std::io::Error::other("fault: write failed mid-line"));
}
if let Some(allowed) = self.fail_write_after.take() {
let allowed = allowed.min(data.len());
if allowed == 0 {
// No progress was made, so it's fine to report the error
// from this same call.
return Err(std::io::Error::other("fault: write failed mid-line"));
}
self.buf
.0
.lock()
.unwrap()
.extend_from_slice(&data[..allowed]);
self.pending_write_error = true;
return Ok(allowed);
}
self.buf.0.lock().unwrap().extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if self.fail_flush_once {
self.fail_flush_once = false;
return Err(std::io::Error::other(
"fault: flush failed after a full write",
));
}
Ok(())
}
}
#[cfg(test)]
impl LogSink for FaultyLogSink {
fn len(&mut self) -> std::io::Result<u64> {
Ok(self.buf.0.lock().unwrap().len() as u64)
}
fn truncate(&mut self, len: u64) -> std::io::Result<()> {
if self.fail_truncate {
return Err(std::io::Error::other("fault: rollback truncate failed"));
}
self.buf.0.lock().unwrap().truncate(len as usize);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_event(id: &str) -> PersistedEvent {
PersistedEvent::Approval {
row: TranscriptRow {
id: id.to_owned(),
kind: maestro_protocol::TranscriptRowKind::Approval,
data: serde_json::json!({"tool": "bash"}),
},
}
}
/// Replays `bytes` as a real on-disk log via the production read path,
/// so "live and durable agree" is checked with the same code a
/// reconnecting client would hit, not a hand-rolled line splitter.
fn replay(bytes: &[u8]) -> Vec<LoggedEvent> {
let dir = tempfile::tempdir().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
std::fs::create_dir_all(store.dir()).unwrap();
std::fs::write(store.events_path_for(id), bytes).unwrap();
store.read_events(id, EventCursor::Since(0), 100).unwrap()
}
#[test]
fn append_rolls_back_a_partial_write_and_the_same_instance_recovers() {
let buf = SharedBuf::default();
let writer = FaultyLogSink {
buf: buf.clone(),
fail_write_after: Some(5),
..Default::default()
};
let mut log = EventLog::for_test(writer, 0);
let err = log.append(sample_event("r1")).unwrap_err();
assert!(
matches!(err, AppendError::Failed(_)),
"a repairable rollback must not be reported fatal"
);
assert_eq!(log.last_seq(), 0, "cursor must stay put on a failed append");
assert!(
buf.snapshot().is_empty(),
"the partial prefix a failed write_all leaves behind must be truncated away"
);
let seq = log
.append(sample_event("r1"))
.expect("the same instance must be able to append again after a clean rollback");
assert_eq!(seq, 1);
assert_eq!(log.last_seq(), 1);
let bytes = buf.snapshot();
assert_eq!(
bytes.iter().filter(|&&b| b == b'\n').count(),
1,
"exactly one well-formed line, not a partial prefix glued to the recovered append"
);
let replayed = replay(&bytes);
assert_eq!(replayed.len(), 1);
assert_eq!(replayed[0].seq, log.last_seq());
let PersistedEvent::Approval { row } = &replayed[0].event else {
panic!("approval event");
};
assert_eq!(row.id, "r1");
}
#[test]
fn append_rolls_back_when_flush_fails_after_a_full_write() {
let buf = SharedBuf::default();
let writer = FaultyLogSink {
buf: buf.clone(),
fail_flush_once: true,
..Default::default()
};
let mut log = EventLog::for_test(writer, 0);
let err = log.append(sample_event("r1")).unwrap_err();
assert!(matches!(err, AppendError::Failed(_)));
assert_eq!(log.last_seq(), 0);
assert!(
buf.snapshot().is_empty(),
"a full-but-unflushed line must not survive uncounted by last_seq — it would \
silently shift every later seq by one"
);
let seq = log
.append(sample_event("r1"))
.expect("the same instance must recover once flush stops failing");
assert_eq!(seq, 1);
let bytes = buf.snapshot();
let replayed = replay(&bytes);
assert_eq!(replayed.len(), 1);
assert_eq!(replayed[0].seq, log.last_seq());
}
#[test]
fn append_reports_fatal_when_rollback_cannot_restore_consistency() {
let buf = SharedBuf::default();
let writer = FaultyLogSink {
buf,
fail_write_after: Some(3),
fail_truncate: true,
..Default::default()
};
let mut log = EventLog::for_test(writer, 0);
let err = log.append(sample_event("r1")).unwrap_err();
assert!(
matches!(err, AppendError::Fatal { .. }),
"an unrepairable rollback must be surfaced as fatal, never silently treated as \
a clean recoverable failure"
);
assert_eq!(
log.last_seq(),
0,
"a fatal append must not advance the cursor either"
);
}
}
+12 -40
View File
@@ -111,33 +111,16 @@ impl WorktreeManager {
// Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?;
let (branch, path) = if create_branch {
// Worktrees are commonly abandoned without deleting their branch,
// so a random handle can collide with one of its own past runs.
// Unlike the `add_existing` arm below, the suffix goes on the
// branch name itself — the path is derived from it anyway.
let mut candidate = branch.to_string();
let mut path = self.target_path(base_repo, &candidate);
let mut n = 1;
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
n += 1;
candidate = format!("{branch}-{n}");
path = self.target_path(base_repo, &candidate);
}
(candidate, path)
} else {
// Existing branch: the name must stay exact, so only the
// directory — anything occupying it, ours or the operator's — is
// sidestepped with a numeric suffix (#77). The daemon never
// adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
(branch.to_string(), path)
};
// Anything occupying the target — a tree we left behind, one the
// operator made, an unrelated directory — is sidestepped with a
// numeric suffix on the directory; the branch stays whatever was
// requested (#77). The daemon never adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
@@ -146,10 +129,10 @@ impl WorktreeManager {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(&branch).arg(&path);
cmd.arg("-b").arg(branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(&branch);
cmd.arg(&path).arg(branch);
}
let out = cmd.output()?;
check(out)?;
@@ -283,17 +266,6 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf())
}
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["show-ref", "--verify", "--quiet"])
.arg(format!("refs/heads/{branch}"))
.status()
.is_ok_and(|s| s.success())
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
File diff suppressed because it is too large Load Diff
+85 -7
View File
@@ -18,6 +18,7 @@ use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -25,15 +26,17 @@ struct Fixture {
async fn fixture(ttl: Duration) -> Fixture {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
SidecarStore::new(state_dir.path().join("sessions")),
sidecar.clone(),
WorktreeManager::new(None),
state_dir.path().join("fifos"),
)
.with_activity_report_ttl(ttl);
Fixture {
manager,
sidecar,
_tmux: tmux,
_state_dir: state_dir,
}
@@ -42,13 +45,11 @@ async fn fixture(ttl: Duration) -> Fixture {
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -69,7 +70,7 @@ async fn report_on_unknown_session_is_not_found() {
let fx = fixture(Duration::from_secs(300)).await;
let err = fx
.manager
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
.report_activity(Uuid::new_v4(), ActivityState::Idle, None, None)
.await
.unwrap_err();
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
@@ -85,6 +86,7 @@ async fn report_sets_error_state_and_detail() {
id,
ActivityState::Error,
Some("cargo build failed: link error".into()),
None,
)
.await
.unwrap();
@@ -103,7 +105,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Error, Some("first failure".into()))
.report_activity(id, ActivityState::Error, Some("first failure".into()), None)
.await
.unwrap();
fx.manager
@@ -111,6 +113,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
id,
ActivityState::AwaitingInput,
Some("needs approval".into()),
None,
)
.await
.unwrap();
@@ -127,7 +130,7 @@ async fn detail_is_capped_server_side() {
let long = "x".repeat(10_000);
fx.manager
.report_activity(id, ActivityState::Error, Some(long))
.report_activity(id, ActivityState::Error, Some(long), None)
.await
.unwrap();
@@ -146,7 +149,12 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
fx.manager.start_liveness_watcher(Duration::from_millis(50));
fx.manager
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
.report_activity(
id,
ActivityState::Error,
Some("agent died mid-run".into()),
None,
)
.await
.unwrap();
@@ -164,3 +172,73 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
})
.await;
}
#[tokio::test]
async fn a_reported_session_id_is_recorded_on_the_session() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
}
/// #161: a session id does not change mid-session, so once set it is never
/// overwritten by a later report — even one that names a different id.
#[tokio::test]
async fn a_reported_session_id_is_written_once_and_never_replaced() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
.await
.unwrap();
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-second".into()))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
}
#[tokio::test]
async fn a_reported_session_id_survives_recovery_from_the_sidecar() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
.await
.unwrap();
let stored = fx
.sidecar
.read(id)
.expect("sidecar read")
.expect("sidecar present");
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-abc123"));
// The sidecar holding the id is only half the claim: a restarted daemon
// has to carry it back onto the recovered session, which is what a resume
// reads. Build a parallel manager over the same tmux server and state
// dirs and recover through it.
let revived = SessionManager::new(
Arc::new(fx._tmux.tmux.clone()),
fx.sidecar.clone(),
WorktreeManager::new(None),
fx._state_dir.path().join("fifos"),
);
revived.recover().await.expect("recover");
let recovered = observed(&revived, id).await;
assert_eq!(
recovered.agent_session_id.as_deref(),
Some("conv-abc123"),
"the recovered session must carry the agent session id, not just the sidecar"
);
}
-2
View File
@@ -75,13 +75,11 @@ async fn create_bash_session(h: &Harness) -> Uuid {
let s = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd_path,
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
-2
View File
@@ -50,13 +50,11 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
};
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -19,7 +19,6 @@ use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -29,7 +28,6 @@ fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> Creat
branch: branch.into(),
create_branch: true,
}),
auto_approve: false,
}
}
@@ -1,16 +0,0 @@
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
+28 -766
View File
@@ -4,17 +4,12 @@ mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::Path;
use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::local_api;
use maestro_protocol::{
ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView,
WorktreeSpec,
};
use tempfile::TempDir;
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -91,646 +86,6 @@ fn ws_url(addr: SocketAddr, path: &str) -> String {
format!("ws://{addr}{path}")
}
struct FakeAcp {
dir: TempDir,
}
impl FakeAcp {
fn new() -> Self {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let adapter = bin.join("claude-code-acp");
std::fs::copy(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"),
&adapter,
)
.unwrap();
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap();
Self { dir }
}
fn env(&self) -> HashMap<String, String> {
HashMap::from([
(
"PATH".into(),
format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap()
),
),
(
"FAKE_ACP_LOG".into(),
self.dir.path().join("agent.log").display().to_string(),
),
])
}
fn pid(&self) -> u32 {
std::fs::read_to_string(self.dir.path().join("agent.log"))
.unwrap_or_default()
.lines()
.filter_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
.next_back()
.expect("fake ACP started")
}
fn log(&self) -> String {
std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default()
}
}
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
structured_session_with_env(h, fake.env()).await
}
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
h.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: "/tmp".into(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
.id
}
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
tokio::time::timeout(Duration::from_secs(2), async {
loop {
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
if sessions
.iter()
.find(|session| session.id == id)
.is_some_and(|session| session.activity == expected)
{
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}"));
}
#[tokio::test]
async fn structured_ws_sends_snapshot_then_live_projection() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.unwrap();
let first = ws.next().await.unwrap().unwrap().into_text().unwrap();
let second = ws.next().await.unwrap().unwrap().into_text().unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first).unwrap()["type"],
"transcript_snapshot"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&second).unwrap()["type"],
"reduced_state"
);
let response = reqwest::Client::new()
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "hello"}))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
let live = tokio::time::timeout(Duration::from_secs(1), ws.next())
.await
.expect("projection update arrives on the subscribed stream")
.unwrap()
.unwrap()
.into_text()
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&live).unwrap()["type"],
"projection_update"
);
}
#[tokio::test]
async fn structured_attachments_preserve_bytes_and_reject_paths() {
let h = spawn("attachments").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let endpoint = url(
h.addr,
&format!("/api/sessions/{id}/structured/attachments"),
);
let bytes = vec![0, 255, 10, 13];
let response = client
.post(&endpoint)
.query(&[("name", "sample.bin")])
.body(bytes.clone())
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.unwrap();
let path = body["path"].as_str().unwrap();
assert!(Path::new(path).is_absolute());
assert_eq!(std::fs::read(path).unwrap(), bytes);
for name in ["../escape", "a/b", "a\\b", "..", ""] {
let response = client
.post(&endpoint)
.query(&[("name", name)])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 400, "{name}");
}
let response = client
.post(&endpoint)
.query(&[("name", "large.bin")])
.body(vec![0; 20 * 1024 * 1024 + 1])
.send()
.await
.unwrap();
assert_eq!(response.status(), 413);
let response = client
.post(url(
h.addr,
&format!("/api/sessions/{}/structured/attachments", Uuid::new_v4()),
))
.query(&[("name", "file.txt")])
.body("text")
.send()
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test]
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into());
let id = structured_session_with_env(&h, env).await;
let client = reqwest::Client::new();
let first = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "first"}))
.send()
.await
.unwrap();
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
let second = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "second"}))
.send()
.await
.unwrap();
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = second.json().await.unwrap();
assert_eq!(error.error.code, "turn_in_flight");
let cancel = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/cancel"),
))
.send()
.await
.unwrap();
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
let next = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "next"}))
.send()
.await
.unwrap();
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if fake.log().matches("recv session/prompt").count() == 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both prompt commands reach ACP");
let log = fake.log();
let first_prompt = log.find("recv session/prompt").unwrap();
let cancel = log.find("recv session/cancel").unwrap();
let second_prompt = log.rfind("recv session/prompt").unwrap();
assert!(first_prompt < cancel && cancel < second_prompt, "{log}");
}
#[tokio::test]
async fn structured_approval_is_secret_single_use_and_race_safe() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
let id = structured_session_with_env(&h, env).await;
let client = reqwest::Client::new();
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.unwrap();
ws.next().await.unwrap().unwrap();
ws.next().await.unwrap().unwrap();
assert_eq!(
client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt")
))
.json(&serde_json::json!({"prompt":"needs permission"}))
.send()
.await
.unwrap()
.status(),
reqwest::StatusCode::NO_CONTENT
);
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let event: serde_json::Value =
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
.unwrap();
if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() {
assert_eq!(event["transcript"]["row"]["kind"], "approval");
assert!(event["transcript"]["row"]["data"].get("nonce").is_none());
break nonce.to_owned();
}
}
})
.await
.unwrap();
wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await;
assert!(
!fake.log().contains(&nonce),
"daemon nonce leaked to ACP agent"
);
let path = format!("/api/sessions/{id}/structured/approvals/{nonce}");
let (a, b) = tokio::join!(
client
.post(url(h.addr, &path))
.json(&serde_json::json!("allow_once"))
.send(),
client
.post(url(h.addr, &path))
.json(&serde_json::json!("deny"))
.send(),
);
assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
assert_eq!(
client
.post(url(h.addr, &path))
.json(&serde_json::json!("deny"))
.send()
.await
.unwrap()
.status(),
reqwest::StatusCode::NO_CONTENT
);
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if fake.log().contains("permission response") {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let log = fake.log();
assert!(
log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""),
"{log}"
);
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let event: serde_json::Value =
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
.unwrap();
if event["state"]["pending_approval"].is_null() {
break;
}
}
})
.await
.expect("resolved approval clears the reduced state");
wait_for_session_activity(&h, id, ActivityState::Idle).await;
assert_eq!(
client
.post(url(
h.addr,
&format!(
"/api/sessions/{}/structured/approvals/{nonce}",
Uuid::new_v4()
)
))
.json(&serde_json::json!("deny"))
.send()
.await
.unwrap()
.status(),
reqwest::StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn structured_prompt_failure_clears_the_turn() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let mut env = fake.env();
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
let id = structured_session_with_env(&h, env).await;
let client = reqwest::Client::new();
let path = format!("/api/sessions/{id}/structured/prompt");
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.unwrap();
ws.next().await.unwrap().unwrap();
ws.next().await.unwrap().unwrap();
let first = client
.post(url(h.addr, &path))
.json(&serde_json::json!({"prompt":"fails"}))
.send()
.await
.unwrap();
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
let mut saw_active_turn = false;
tokio::time::timeout(Duration::from_secs(1), async {
while let Some(Ok(message)) = ws.next().await {
let event: serde_json::Value =
serde_json::from_str(&message.into_text().unwrap()).unwrap();
let turn = &event["state"]["turn"];
if turn["responding"] == true {
saw_active_turn = true;
}
if saw_active_turn && turn["responding"] == false && turn["thinking"] == false {
return;
}
}
panic!("structured websocket closed before the failed turn cleared");
})
.await
.expect("failed turn clears promptly");
wait_for_session_activity(&h, id, ActivityState::Error).await;
let second = client
.post(url(h.addr, &path))
.json(&serde_json::json!({"prompt":"available"}))
.send()
.await
.unwrap();
assert_eq!(second.status(), reqwest::StatusCode::NO_CONTENT);
assert!(second.bytes().await.unwrap().is_empty());
}
#[tokio::test]
async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
let h = spawn("naps-laptop").await;
let client = reqwest::Client::new();
let prompt = |id| {
client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "hello"}))
};
let unknown = prompt(Uuid::new_v4()).send().await.unwrap();
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
let error: ApiError = unknown.json().await.unwrap();
assert_eq!(error.error.code, "not_found");
let terminal = h
.manager
.create(CreateSessionRequest {
view: SessionView::Terminal,
cwd: "/tmp".into(),
command: "sh".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
let non_structured = prompt(terminal.id).send().await.unwrap();
assert_eq!(non_structured.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = non_structured.json().await.unwrap();
assert_eq!(error.error.code, "not_structured");
let fake = FakeAcp::new();
let dead = structured_session(&h, &fake).await;
h.manager.shutdown().await;
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
let unavailable = prompt(dead).send().await.unwrap();
assert_eq!(unavailable.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = unavailable.json().await.unwrap();
assert_eq!(error.error.code, "not_structured");
}
#[tokio::test]
async fn structured_replay_honors_cursors_and_limit() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let events: Vec<serde_json::Value> = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?since=0&limit=1"),
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
if !events.is_empty() {
return events;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0]["cursor"], 1);
assert!(events[0].get("notification").is_none());
let invalid = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?since=0&before=2"),
))
.send()
.await
.unwrap();
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn structured_replay_pages_projected_events_in_cursor_order() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let client = reqwest::Client::new();
unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) };
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let events: Vec<serde_json::Value> = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?before=99&limit=2"),
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
if events.len() == 2 {
return events;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(
events
.iter()
.map(|event| event["cursor"].as_u64().unwrap())
.collect::<Vec<_>>(),
vec![1, 2]
);
let boundary: Vec<serde_json::Value> = client
.get(url(
h.addr,
&format!("/api/sessions/{id}/structured/replay?before=2&limit=10"),
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(boundary.len(), 1);
assert_eq!(boundary[0]["cursor"], 1);
}
#[tokio::test]
async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
let h = spawn("naps-laptop").await;
let client = reqwest::Client::new();
let unknown = client
.get(url(
h.addr,
&format!("/api/sessions/{}/structured/replay", Uuid::new_v4()),
))
.send()
.await
.unwrap();
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
let terminal = h
.manager
.create(CreateSessionRequest {
view: SessionView::Terminal,
cwd: "/tmp".into(),
command: "sh".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
let response = client
.get(url(
h.addr,
&format!("/api/sessions/{}/structured/replay", terminal.id),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let fake = FakeAcp::new();
let dead = structured_session(&h, &fake).await;
h.manager.shutdown().await;
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
let response = client
.get(url(
h.addr,
&format!("/api/sessions/{dead}/structured/replay"),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = response.json().await.unwrap();
assert_eq!(error.error.code, "not_structured");
}
#[tokio::test]
async fn structured_ws_rejects_a_dead_session() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
h.manager.shutdown().await;
assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead));
let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
.await
.expect_err("dead structured session must not upgrade");
let message = err.to_string();
assert!(
message.contains("409") || message.to_lowercase().contains("conflict"),
"{message}"
);
}
#[tokio::test]
async fn info_returns_daemon_identity() {
let h = spawn("naps-laptop").await;
@@ -819,13 +174,11 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty());
let req = CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
};
let created: Session = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
@@ -1037,13 +390,11 @@ async fn patch_session_renames_it() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1081,13 +432,11 @@ async fn post_focus_stamps_last_focused_at() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1116,13 +465,11 @@ async fn delete_owned_session_kills_it() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1139,13 +486,11 @@ async fn delete_owned_session_kills_it() {
async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -1181,6 +526,33 @@ async fn post_activity_reports_error_with_detail() {
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
}
/// #161: a reported `session_id` lands on the session as `agent_session_id`.
#[tokio::test]
async fn post_activity_records_the_reported_session_id() {
let h = spawn("naps-laptop").await;
let id = spawn_via_manager(&h).await;
let resp = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({
"state": "Active",
"session_id": "conv-abc123"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let s = sessions.iter().find(|s| s.id == id).unwrap();
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
}
#[tokio::test]
async fn post_activity_to_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
@@ -1250,7 +622,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -1260,7 +631,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1304,7 +674,6 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -1314,7 +683,6 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.unwrap();
@@ -1339,13 +707,11 @@ async fn attach_ws_streams_pty_bytes() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -1376,39 +742,6 @@ async fn attach_ws_streams_pty_bytes() {
assert!(saw_hello, "PTY did not echo through the attach WS");
}
#[tokio::test]
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
let h = spawn("naps-laptop").await;
let fake = FakeAcp::new();
let id = structured_session(&h, &fake).await;
let response = reqwest::Client::new()
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
.json(&maestro_protocol::SendInputRequest {
text: "ignored".into(),
submit: false,
})
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let error: ApiError = response.json().await.unwrap();
assert_eq!(error.error.code, "not_terminal");
assert_eq!(error.error.message, "session is not a terminal session");
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
.await
.expect_err("structured session must not upgrade to a PTY socket");
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
panic!("expected an HTTP handshake rejection, got {error}");
};
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
let body = response.body().as_deref().expect("ApiError response body");
let error: ApiError = serde_json::from_slice(body).unwrap();
assert_eq!(error.error.code, "not_terminal");
assert_eq!(error.error.message, "session is not a terminal session");
}
#[tokio::test]
async fn attach_ws_for_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
@@ -1442,13 +775,11 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
let created = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1476,73 +807,6 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
}
}
#[tokio::test]
async fn events_ws_streams_structured_activity_without_hook_overrides() {
let h = spawn("konishi").await;
let fake = FakeAcp::new();
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into());
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
.await
.expect("events ws");
assert!(matches!(
next_event(&mut ws).await,
maestro_protocol::CatalogEvent::Snapshot { .. }
));
let id = structured_session_with_env(&h, env).await;
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
panic!("expected structured SessionAdded")
};
assert_eq!(session.activity, ActivityState::Idle);
let client = reqwest::Client::new();
let prompt = client
.post(url(
h.addr,
&format!("/api/sessions/{id}/structured/prompt"),
))
.json(&serde_json::json!({"prompt": "hello"}))
.send()
.await
.unwrap();
assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT);
let report = client
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
.json(&serde_json::json!({"state": "Error"}))
.send()
.await
.unwrap();
assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT);
wait_for_session_activity(&h, id, ActivityState::Active).await;
let mut activities = Vec::new();
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let maestro_protocol::CatalogEvent::SessionUpdated { session } =
next_event(&mut ws).await
{
activities.push(session.activity);
if session.activity == ActivityState::Idle {
return;
}
}
}
})
.await
.expect("structured turn returns to idle");
assert!(
activities.contains(&ActivityState::Active),
"{activities:?}"
);
assert!(
!activities.contains(&ActivityState::Error),
"{activities:?}"
);
h.manager.kill(id, false).await.unwrap();
}
/// Each subscriber has its own lag domain now that the publisher task is gone:
/// two clients connected at different times both get their own Snapshot.
#[tokio::test]
@@ -1555,13 +819,11 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
h.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
-2
View File
@@ -51,13 +51,11 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
};
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
metadata,
worktree: None,
auto_approve: false,
})
.await
.unwrap()
@@ -64,13 +64,11 @@ impl Fx {
fn request(&self, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: self.tmp.path().to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
}
+410 -52
View File
@@ -9,6 +9,7 @@ use std::sync::Arc;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
@@ -22,13 +23,11 @@ async fn create_then_kill_round_trip() {
let session = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -65,13 +64,11 @@ async fn create_appends_to_spawn_history() {
let session = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -96,13 +93,11 @@ async fn create_without_history_logs_nothing() {
let cwd = TempDir::new().unwrap();
fx.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -120,13 +115,11 @@ async fn recover_adopts_existing_tmux_sessions() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -148,6 +141,356 @@ async fn recover_adopts_existing_tmux_sessions() {
assert_eq!(snap[0].command, "sleep 30");
}
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
/// adopt while every sidecar is still on disk. Those sessions come back as
/// `Dormant` — a record of what was running — rather than vanishing from the
/// API and leaving the operator with nothing.
#[tokio::test]
async fn recover_registers_orphaned_sidecars_as_dormant() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let mut metadata = HashMap::new();
metadata.insert(
maestro_protocol::NAME_KEY.to_string(),
serde_json::Value::String("after-reboot".into()),
);
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata,
worktree: None,
})
.await
.expect("create");
// The reboot: the tmux server is gone, the sidecar is untouched.
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx);
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 0, "no live tmux session left to adopt");
let snap = revived.snapshot().await;
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Dormant);
assert_eq!(snap[0].command, "sleep 30");
assert_eq!(snap[0].cwd, s.cwd);
assert_eq!(snap[0].created_at, s.created_at);
assert_eq!(
snap[0]
.metadata
.get(maestro_protocol::NAME_KEY)
.and_then(|v| v.as_str()),
Some("after-reboot"),
);
}
/// #162: a session tmux still holds is adopted `Running` by the first pass and
/// must not be re-registered as dormant by the second — the sidecar is on disk
/// for both.
#[tokio::test]
async fn recover_does_not_mark_adopted_sessions_dormant() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let live = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create live");
let orphan = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 31".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create orphan");
// Only the second session loses its pane.
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
let revived = revive(&fx);
assert_eq!(revived.recover().await.expect("recover"), 1);
let mut snap = revived.snapshot().await;
snap.sort_by_key(|s| s.command.clone());
assert_eq!(snap.len(), 2);
assert_eq!(snap[0].id, live.id);
assert_eq!(snap[0].status, SessionStatus::Running);
assert_eq!(snap[1].id, orphan.id);
assert_eq!(snap[1].status, SessionStatus::Dormant);
}
/// #163: a sidecar whose `cwd` no longer exists is pruned instead of
/// registered dormant — the worktree it pointed at is gone, so there is
/// nothing left to resume.
#[tokio::test]
async fn recover_deletes_sidecar_when_cwd_gone() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let cwd_path = cwd.path().to_path_buf();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd_path.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
drop(cwd);
std::fs::remove_dir_all(&cwd_path).ok();
assert!(!cwd_path.exists());
let revived = revive(&fx);
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 0);
assert!(!revived.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
}
/// #163: a sidecar whose `cwd` still exists is left alone regardless of age
/// — it is registered dormant like any other orphan.
#[tokio::test]
async fn recover_keeps_dormant_sidecar_when_cwd_exists() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx);
revived.recover().await.expect("recover");
assert!(revived.has(s.id).await);
assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
let snap = revived.snapshot().await;
assert_eq!(snap[0].status, SessionStatus::Dormant);
}
/// #164: a fake [`maestro_daemon::boot_id::BootIdSource`] lets tests simulate
/// a changed (or unreadable) boot id without rebooting the machine.
#[derive(Debug)]
struct FakeBootId(Option<String>);
impl maestro_daemon::boot_id::BootIdSource for FakeBootId {
fn current(&self) -> Option<String> {
self.0.clone()
}
}
fn revive_with_boot_id(
fx: &DaemonFixture,
boot_id_path: PathBuf,
current: Option<&str>,
) -> maestro_daemon::SessionManager {
maestro_daemon::SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
maestro_daemon::worktree::WorktreeManager::new(None),
fx.fifo_dir(),
)
.with_boot_id(
boot_id_path,
Arc::new(FakeBootId(current.map(String::from))),
)
}
/// #164: a changed boot id means every session from the previous boot is
/// already dead, so recovery must not trust tmux to say so — it skips the
/// probe outright. Proven by leaving the tmux session alive: if the probe
/// ran, it would come back `Running`, not `Dormant`.
#[tokio::test]
async fn recover_skips_tmux_probe_when_boot_id_changed() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let boot_id_path = fx.state_dir.path().join("boot_id");
std::fs::write(&boot_id_path, "previous-boot").unwrap();
// tmux session is still alive — a probe would find it if one ran.
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 0, "reboot detected: nothing adopted live");
let snap = revived.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Dormant);
// The current boot id is now the stored one, for the next comparison.
assert_eq!(
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
"current-boot"
);
}
/// #164: an unchanged boot id is a plain restart — recovery probes tmux as
/// before and adopts the still-live session `Running`.
#[tokio::test]
async fn recover_probes_tmux_when_boot_id_unchanged() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let boot_id_path = fx.state_dir.path().join("boot_id");
std::fs::write(&boot_id_path, "same-boot").unwrap();
let revived = revive_with_boot_id(&fx, boot_id_path, Some("same-boot"));
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 1);
let snap = revived.snapshot().await;
assert_eq!(snap[0].id, s.id);
assert_eq!(snap[0].status, SessionStatus::Running);
}
/// #164: no stored boot id (first run ever, or an older build that never
/// wrote one) means there is nothing to compare against — falls back to
/// today's probe-based recovery rather than assuming a reboot.
#[tokio::test]
async fn recover_falls_back_to_probe_when_no_stored_boot_id() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
fx.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let boot_id_path = fx.state_dir.path().join("boot_id");
assert!(!boot_id_path.exists());
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 1, "no stored boot id; falls back to probing tmux");
assert_eq!(
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
"current-boot"
);
}
/// #164: an unreadable current boot id (non-Linux, restricted `/proc`) falls
/// back to today's behaviour rather than failing startup or assuming a
/// reboot.
#[tokio::test]
async fn recover_falls_back_to_probe_when_boot_id_unreadable() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
fx.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let boot_id_path = fx.state_dir.path().join("boot_id");
std::fs::write(&boot_id_path, "stored-boot").unwrap();
let revived = revive_with_boot_id(&fx, boot_id_path, None);
let adopted = revived.recover().await.expect("recover");
assert_eq!(adopted, 1, "boot id unreadable; falls back to probing tmux");
}
/// #162: dismissing a dormant session is the one thing that can be done to it
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
/// does not resurrect it.
#[tokio::test]
async fn dormant_session_can_be_dismissed() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
fx.tmux.tmux.kill_server().expect("kill tmux server");
let revived = revive(&fx);
revived.recover().await.expect("recover");
revived.kill(s.id, false).await.expect("dismiss");
assert!(!revived.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
}
/// A second `SessionManager` over the same dirs and the same tmux server —
/// what a daemon restart looks like from inside a test.
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
maestro_daemon::SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
maestro_daemon::worktree::WorktreeManager::new(None),
fx.fifo_dir(),
)
}
/// Regression for NAP-253: the very first bytes a freshly-spawned command
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
/// separate tmux invocation that ran after `new-session` + an async FIFO
@@ -161,7 +504,6 @@ async fn create_captures_initial_output_in_replay_buffer() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// The shell prints the marker before doing anything else and
// then sleeps so the session stays alive. Before NAP-253 this
@@ -170,7 +512,6 @@ async fn create_captures_initial_output_in_replay_buffer() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -210,7 +551,6 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// Print a line, then switch to the alternate screen the way a
// full-screen program does on startup. Only the second marker is
@@ -220,7 +560,6 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -270,7 +609,6 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// Alternate screen, then button-event tracking (?1002) with SGR
// coordinates (?1006) — what tmux and agent TUIs actually request.
@@ -279,7 +617,6 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -325,13 +662,11 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -389,13 +724,11 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.unwrap();
@@ -441,13 +774,11 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -508,7 +839,6 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
fx.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -518,7 +848,6 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
branch: "feat/indicator".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -602,7 +931,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -612,7 +940,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
branch: "feat/keep".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -641,7 +968,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -651,7 +977,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
branch: "feat/guarded".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -706,7 +1031,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -716,7 +1040,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
branch: "feat/pushed".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -757,7 +1080,6 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -767,7 +1089,6 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/forced".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -804,7 +1125,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -814,7 +1134,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
branch: "feat/clear".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -846,7 +1165,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -856,7 +1174,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
branch: "feat/rusty".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -889,7 +1206,6 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -899,7 +1215,6 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
branch: "feat/exited".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -952,7 +1267,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -962,7 +1276,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
branch: "feat/clean".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create");
@@ -992,6 +1305,65 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
);
}
/// #172: `mark_session_dead` clears the worktree off the in-memory record
/// when a pristine tree settles, but the local `session` clone it writes to
/// the sidecar and broadcasts must be cleared the same way — otherwise the
/// sidecar keeps naming a tree the daemon just removed, and a recovery from
/// that sidecar advertises a branch and path that no longer exist.
#[tokio::test]
async fn natural_exit_of_a_pristine_worktree_clears_it_from_the_sidecar() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("sidecar");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/sidecar".into(),
create_branch: true,
}),
})
.await
.expect("create");
// Push everything so the branch has no unpushed commits: fully pristine.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
wait_for_async(Duration::from_secs(3), || async {
fx.manager.is_settled(s.id).await == Some(true)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
);
let sidecar = SidecarStore::new(fx.sidecar_dir())
.read(s.id)
.expect("sidecar read")
.expect("sidecar exists");
assert!(
sidecar.worktree.is_none(),
"the sidecar must not keep naming a worktree the daemon already removed"
);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
@@ -1014,13 +1386,11 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1050,7 +1420,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let first = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1060,7 +1429,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/one".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create first");
@@ -1069,7 +1437,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let second = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: inner_worktree.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1079,7 +1446,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
branch: "feat/two".into(),
create_branch: true,
}),
auto_approve: false,
})
.await
.expect("create second");
@@ -1109,13 +1475,11 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1149,13 +1513,11 @@ async fn a_freshly_created_session_has_never_been_focused() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1176,13 +1538,11 @@ async fn set_focused_true_stamps_last_focused_at() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
@@ -1226,13 +1586,11 @@ async fn attaching_stamps_last_focused_at() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("create");
+3 -284
View File
@@ -6,8 +6,8 @@ use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use maestro_daemon::sidecar::{PersistedEvent, SidecarStore};
use maestro_protocol::{Session, SessionStatus, SessionView, TranscriptRow, TranscriptRowKind};
use maestro_daemon::sidecar::SidecarStore;
use maestro_protocol::{Session, SessionStatus};
use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
@@ -32,8 +32,7 @@ fn sample(id: Uuid) -> Session {
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
view: SessionView::Terminal,
agent_session_id: Some("conv-abc123".into()),
}
}
@@ -68,30 +67,15 @@ fn delete_removes_file_and_is_idempotent() {
let s = sample(id);
store.write(&s).unwrap();
store.mark_for_recovery(id).unwrap();
assert!(store.path_for(id).exists());
assert!(store.recovery_path_for(id).exists());
store.delete(id).unwrap();
assert!(!store.path_for(id).exists());
assert!(!store.recovery_path_for(id).exists());
// second delete is a no-op
store.delete(id).unwrap();
}
#[test]
fn recovery_marker_is_consumed_once() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
assert!(!store.take_recovery_marker(id).unwrap());
store.mark_for_recovery(id).unwrap();
assert!(store.take_recovery_marker(id).unwrap());
assert!(!store.take_recovery_marker(id).unwrap());
}
#[test]
fn load_all_recovers_every_sidecar_and_skips_garbage() {
let dir = TempDir::new().unwrap();
@@ -120,37 +104,6 @@ fn load_all_returns_empty_when_directory_missing() {
assert!(store.load_all().unwrap().is_empty());
}
#[test]
fn sidecar_missing_view_key_reads_as_terminal() {
// A pre-#133 sidecar on disk has no `view` field at all.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut value = serde_json::to_value(sample(id)).unwrap();
value.as_object_mut().unwrap().remove("view");
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
let loaded = store.read(id).expect("read").expect("present");
assert_eq!(loaded.view, SessionView::Terminal);
}
#[test]
fn sidecar_structured_view_round_trips() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut s = sample(id);
s.view = SessionView::Structured;
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
assert_eq!(
loaded.as_ref().map(|s| s.view),
Some(SessionView::Structured)
);
}
#[test]
fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap();
@@ -161,237 +114,3 @@ fn write_creates_parent_directory() {
assert!(nested.exists());
assert!(store.path_for(s.id).exists());
}
// ---- per-session event log (#135) ----
use maestro_acp::schema::{ContentBlock, SessionNotification, SessionUpdate, TextContent};
use maestro_daemon::sidecar::EventCursor;
fn note(text: &str) -> SessionNotification {
SessionNotification::new(
"acp-1",
SessionUpdate::AgentMessageChunk(maestro_acp::schema::ContentChunk::new(
ContentBlock::Text(TextContent::new(text)),
)),
)
}
fn text_of(n: &SessionNotification) -> &str {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => &t.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
let maestro_daemon::sidecar::PersistedEvent::Notification(notification) = &event.event else {
panic!("expected ACP notification")
};
text_of(notification)
}
#[test]
fn append_assigns_line_position_as_seq_and_reads_back() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).expect("open");
assert_eq!(log.last_seq(), 0);
assert_eq!(log.append(note("a")).unwrap(), 1);
assert_eq!(log.append(note("b")).unwrap(), 2);
assert_eq!(log.append(note("c")).unwrap(), 3);
assert_eq!(log.last_seq(), 3);
let path = store.events_path_for(id);
assert!(path.ends_with(format!("{id}.events.jsonl")), "{path:?}");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "one object per line:\n{raw}");
for line in raw.lines() {
let v: Value = serde_json::from_str(line).expect("each line is one JSON object");
assert_eq!(v["sessionId"], "acp-1");
}
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
assert_eq!(
all.iter().map(event_text).collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
}
#[test]
fn since_reads_forward_and_before_reads_backward_with_limit() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
for i in 1..=6 {
log.append(note(&format!("n{i}"))).unwrap();
}
// `since` is exclusive: what a client that already holds seq 2 needs.
let fwd = store.read_events(id, EventCursor::Since(2), 3).unwrap();
assert_eq!(fwd.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
// Past the end: nothing, not an error.
assert!(store
.read_events(id, EventCursor::Since(6), 3)
.unwrap()
.is_empty());
// `before` is exclusive and pages backward: the `limit` events closest
// to the cursor, still in ascending seq order.
let back = store
.read_events(id, EventCursor::Before(Some(5)), 2)
.unwrap();
assert_eq!(back.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4]);
// No cursor: the tail of the log, for a first paint.
let tail = store.read_events(id, EventCursor::Before(None), 2).unwrap();
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![5, 6]);
// Paging past the start runs out cleanly.
let start = store
.read_events(id, EventCursor::Before(Some(2)), 10)
.unwrap();
assert_eq!(start.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
assert!(store
.read_events(id, EventCursor::Before(Some(1)), 10)
.unwrap()
.is_empty());
}
#[test]
fn missing_log_reads_as_empty() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
assert!(store
.read_events(id, EventCursor::Since(0), 10)
.unwrap()
.is_empty());
assert!(store
.read_events(id, EventCursor::Before(None), 10)
.unwrap()
.is_empty());
}
#[test]
fn malformed_lines_are_skipped_but_keep_their_seq() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(note("first")).unwrap();
drop(log);
// Garbage in the middle and a line that is JSON but not a notification.
let path = store.events_path_for(id);
let mut raw = std::fs::read_to_string(&path).unwrap();
raw.push_str("{not json\n");
raw.push_str("{\"unrelated\": true}\n");
std::fs::write(&path, raw).unwrap();
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 3);
assert_eq!(log.append(note("fourth")).unwrap(), 4);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
assert_eq!(event_text(&all[1]), "fourth");
}
#[test]
fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
// A hard kill mid-write leaves a partial line with no newline.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(note("whole")).unwrap();
drop(log);
let path = store.events_path_for(id);
let mut raw = std::fs::read(&path).unwrap();
raw.extend_from_slice(br#"{"sessionId":"acp-1","update":{"sessionUpd"#);
std::fs::write(&path, &raw).unwrap();
// Reads: the partial line is skipped, nothing else is lost.
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
// Reopening terminates the partial line so the next append does not
// glue onto it; the partial keeps a seq of its own.
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 2);
assert_eq!(log.append(note("after")).unwrap(), 3);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
assert_eq!(event_text(&all[1]), "after");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "{raw}");
}
#[test]
fn delete_removes_the_event_log_and_load_all_ignores_it() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
store.write(&sample(id)).unwrap();
let mut log = store.open_events(id).unwrap();
log.append(note("x")).unwrap();
drop(log);
let legacy_approvals = dir.path().join(format!("{id}.approvals.jsonl"));
std::fs::write(&legacy_approvals, b"obsolete").unwrap();
assert!(store.events_path_for(id).exists());
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1, "the .jsonl must not be read as a sidecar");
store.delete(id).unwrap();
assert!(!store.path_for(id).exists());
assert!(!store.events_path_for(id).exists());
assert!(!legacy_approvals.exists());
// Idempotent with the log already gone.
store.delete(id).unwrap();
}
#[test]
fn approval_rows_share_event_cursors_with_legacy_acp_lines() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let legacy = serde_json::to_vec(&note("before")).unwrap();
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(store.events_path_for(id), [legacy, b"\n".to_vec()].concat()).unwrap();
let mut log = store.open_events(id).unwrap();
assert_eq!(
log.append(PersistedEvent::Approval {
row: TranscriptRow {
id: "approval:secret".into(),
kind: TranscriptRowKind::Approval,
data: serde_json::json!({"tool": "bash"}),
},
})
.unwrap(),
2
);
assert_eq!(log.append(note("after")).unwrap(), 3);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(
all.iter().map(|event| event.seq).collect::<Vec<_>>(),
[1, 2, 3]
);
assert!(matches!(all[1].event, PersistedEvent::Approval { .. }));
assert_eq!(event_text(&all[0]), "before");
assert_eq!(event_text(&all[2]), "after");
let since = store.read_events(id, EventCursor::Since(1), 1).unwrap();
assert_eq!(since[0].seq, 2);
let before = store
.read_events(id, EventCursor::Before(Some(3)), 1)
.unwrap();
assert_eq!(before[0].seq, 2);
}
+5 -17
View File
@@ -405,17 +405,13 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
);
}
/// `git worktree add -b` alone fails outright when the branch already exists,
/// even with the target directory free — a random handle commonly collides
/// with one an earlier, already-removed worktree left behind. `add_new`
/// sidesteps it on the branch name itself instead of erroring (#77).
#[test]
fn add_new_suffixes_branch_name_on_branch_collision() {
fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// pre-create the branch, unused by any worktree.
// pre-create the branch
let status = Command::new("git")
.arg("-C")
.arg(&repo)
@@ -427,17 +423,9 @@ fn add_new_suffixes_branch_name_on_branch_collision() {
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr
.add_new(&repo, "feat/dup")
.expect("sidesteps the branch collision");
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.exists());
// The pre-existing branch is untouched; the new worktree checked out the
// suffixed one instead of erroring or adopting `feat/dup`.
let branches = worktree_branches(&repo);
assert!(branches.contains(&"feat/dup-2".to_string()), "{branches:?}");
assert!(!branches.contains(&"feat/dup".to_string()), "{branches:?}");
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
-1
View File
@@ -13,7 +13,6 @@ path = "src/lib.rs"
[dependencies]
chrono.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
maestro-testing.workspace = true
serde.workspace = true
-8
View File
@@ -87,14 +87,6 @@ impl Daemon {
}
}
/// Send the daemon the same SIGTERM systemd uses, without waiting for its
/// graceful HTTP shutdown to finish.
pub fn send_sigterm(&self) {
let child = self.child.as_ref().expect("daemon is running");
let result = unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGTERM) };
assert_eq!(result, 0, "send SIGTERM to daemon");
}
/// Re-spawn the daemon on the same port + state. The previous tmux
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
/// its sessions.
+25 -213
View File
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use maestro_e2e::util::wait_for_async;
use maestro_e2e::{Daemon, TestClient};
use maestro_protocol::{CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use tempfile::TempDir;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -27,71 +27,14 @@ async fn bring_up() -> (Daemon, TestClient) {
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
}
}
struct FakeAcp {
_dir: TempDir,
env: HashMap<String, String>,
}
fn fake_acp(log: &std::path::Path) -> FakeAcp {
let dir = tempfile::tempdir().expect("fake adapter dir");
let adapter = dir.path().join("claude-code-acp");
std::fs::copy(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../maestro-acp/tests/fake_acp_agent.py"
),
&adapter,
)
.expect("copy fake adapter");
let mut permissions = std::fs::metadata(&adapter)
.expect("adapter metadata")
.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&adapter, permissions).expect("make fake adapter executable");
let env = HashMap::from([
(
"PATH".into(),
format!(
"{}:{}",
dir.path().display(),
std::env::var("PATH").unwrap_or_default()
),
),
("FAKE_ACP_LOG".into(), log.to_string_lossy().into_owned()),
]);
FakeAcp { _dir: dir, env }
}
fn fake_acp_pid(log: &std::path::Path) -> Option<u32> {
std::fs::read_to_string(log).ok()?.lines().find_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
}
fn pid_alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|stat| !stat.contains(") Z "))
.unwrap_or(false)
}
/// A worktree spec built through its wire shape. The daemon and clients ship
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
/// object carries only the fields the lifecycle actually honours.
@@ -106,13 +49,11 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: base.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base, branch, create_branch)),
auto_approve: false,
}
}
@@ -202,13 +143,11 @@ async fn kill_removes_clean_worktree() {
init_repo(base.path());
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -257,13 +196,11 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
.join("unsaved");
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
auto_approve: false,
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -317,13 +254,11 @@ async fn spawn_on_existing_branch_checks_it_out() {
run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
auto_approve: false,
};
let s = client
@@ -388,11 +323,10 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill");
}
/// #66/#77: a second spawn onto the same branch used to be rejected outright;
/// since the branch-collision suffixing landed, it succeeds instead — the
/// second worktree lands on `<branch>-2` and the first stays untouched.
/// #66: a second spawn onto a branch that already has a worktree is rejected
/// with a structured worktree error; the first worktree stays intact.
#[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_gets_suffixed() {
async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
@@ -402,33 +336,28 @@ async fn second_spawn_on_same_branch_gets_suffixed() {
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await
.expect("first spawn");
let second = client
let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await
.expect("second spawn suffixes instead of rejecting");
.await;
match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
assert_eq!(
info.code, "worktree_failed",
"expected worktree error: {info:?}"
);
}
other => panic!("expected rejection for duplicate branch, got {other:?}"),
}
let suffixed = base.path().join("worktrees").join("feature").join("dup-2");
assert_eq!(
second.cwd, suffixed,
"second worktree must land on the suffixed path"
);
assert!(suffixed.is_dir());
// Both registrations exist: the original branch, untouched, and the
// suffixed one the second spawn created.
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup"),
"original branch must stay registered: {registrations:?}"
);
assert!(
registrations
.iter()
.any(|l| l.as_str() == "branch refs/heads/feature/dup-2"),
"suffixed branch must be registered too: {registrations:?}"
);
// Exactly one registration, one directory, still usable.
// Count branch records only: the worktree path now contains the branch
// name too (`worktrees/feature/dup`), so a substring match over every line
// would count the same tree twice.
let holders = git_worktree_list(base.path())
.iter()
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
.count();
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
assert!(
base.path()
.join("worktrees")
@@ -439,8 +368,7 @@ async fn second_spawn_on_same_branch_gets_suffixed() {
);
let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill first");
client.kill_session(second.id).await.expect("kill second");
client.kill_session(first.id).await.expect("kill");
}
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
@@ -569,57 +497,6 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
);
}
/// A random handle can reuse the name of a branch left behind by an earlier,
/// already-removed worktree — `git worktree add -b` refuses outright in that
/// case, even though the directory is free. The daemon suffixes the branch
/// itself (not just the directory) and both land on `-2`.
#[tokio::test(flavor = "multi_thread")]
async fn colliding_branch_name_gets_suffixed_too() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// No worktree directory in the way, but the branch already exists.
run_git(&["branch", "handle"], base.path());
let s = client
.spawn_session(&worktree_req(base.path(), "handle", true))
.await
.expect("spawn onto a taken branch name");
let suffixed = base.path().join("worktrees").join("handle-2");
assert_eq!(
s.cwd, suffixed,
"the directory must follow the suffixed branch name"
);
assert!(suffixed.is_dir());
let registrations = git_worktree_list(base.path());
assert!(
registrations.iter().any(|l| l.contains("handle-2")),
"the -2 branch's worktree must be registered: {registrations:?}"
);
let branches = String::from_utf8(
std::process::Command::new("git")
.arg("-C")
.arg(base.path())
.args(["branch", "--list"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
branches.contains("handle-2"),
"the branch itself must be named handle-2: {branches}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
/// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone.
@@ -826,13 +703,11 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let _dirty = client
.spawn_session(&CreateSessionRequest {
view: Default::default(),
cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
auto_approve: false,
})
.await
.expect("spawn dirty");
@@ -876,13 +751,11 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(cwd, branch, true)),
auto_approve: false,
}
}
@@ -1061,67 +934,6 @@ async fn daemon_restart_recovers_session() {
client.kill_session(s.id).await.expect("kill");
}
#[tokio::test(flavor = "multi_thread")]
async fn sigterm_stops_structured_child_before_events_ws_drains() {
let (mut daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let log = cwd.path().join("fake-acp.log");
let fake = fake_acp(&log);
let session = client
.spawn_session(&CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.path().to_path_buf(),
command: "claude".into(),
env: fake.env.clone(),
metadata: HashMap::new(),
worktree: None,
auto_approve: false,
})
.await
.expect("spawn structured session");
let ws_url = daemon.client().events_url().expect("events URL");
let (_events_ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.expect("events WS handshake");
wait_for_async("fake ACP starts", Duration::from_secs(5), || {
let log = log.clone();
async move { fake_acp_pid(&log).is_some() }
})
.await;
let pid = fake_acp_pid(&log).expect("fake ACP pid");
daemon.send_sigterm();
wait_for_async(
"fake ACP exits after SIGTERM",
Duration::from_secs(5),
|| async { !pid_alive(pid) },
)
.await;
let sidecar_path = daemon
.state_dir()
.join("sessions")
.join(format!("{}.json", session.id));
wait_for_async(
"dead structured session sidecar",
Duration::from_secs(5),
|| {
let sidecar_path = sidecar_path.clone();
async move {
std::fs::read(&sidecar_path)
.ok()
.and_then(|json| {
serde_json::from_slice::<maestro_protocol::Session>(&json).ok()
})
.is_some_and(|saved| saved.status == SessionStatus::Dead)
}
},
)
.await;
daemon.stop();
}
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_spawns_each_get_unique_ids() {
let (_daemon, client) = bring_up().await;
+65 -144
View File
@@ -1,4 +1,4 @@
//! Agent activity hook (#106).
//! Claude Code activity hook (#106).
//!
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
//! the daemon's first-start auto-install:
@@ -6,40 +6,28 @@
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
//! posts an [`ActivityState`](maestro-protocol) to
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
//! global hook runs in every agent session on the machine, so it exits
//! global hook runs in every Claude Code session on the machine, so it exits
//! 0 and silently when either #103 env var is unset, and it never lets a
//! daemon failure escape (short curl timeout, status ignored).
//! * [`install`] — idempotent registration of that script in one [`Target`]'s
//! hook file. Only entries maestro itself wrote (identified by
//! [`HOOK_MARKER`]) are touched; every other hook and every other field in
//! the file is preserved verbatim.
//!
//! One script serves every [`Target`]: Claude Code and Codex read the same
//! `{"hooks": {Event: [{"hooks": [{"type": "command", "command": …}]}]}}`
//! shape and hand the hook the same JSON on stdin. Only the file's location
//! and the event set differ.
//! * [`install`] — idempotent registration of that script in
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
//! in the file is preserved verbatim.
use std::path::Path;
/// Marker substring every maestro-installed hook command carries. The
/// installer rewrites or removes exactly these entries — nothing else in the
/// user's settings file.
///
/// It still says `claude` although the script now serves every [`Target`]:
/// the marker is how an install from an older maestro is recognised, and
/// renaming it would orphan every entry already on disk.
pub const HOOK_MARKER: &str = "maestro-claude-hook";
/// File name of the installed script (carries [`HOOK_MARKER`]). One script
/// for every [`Target`] — see the note on [`HOOK_MARKER`] for the name.
/// File name of the installed script (carries [`HOOK_MARKER`]).
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
/// Hook events installed only with the `haiku-autoname` feature (#131), for
/// every [`Target`] — Claude Code and Codex both spell the event
/// `UserPromptSubmit` and both put the submitted text in the payload's
/// `prompt`. The script forwards the whole payload to
/// `/api/sessions/:id/prompt`, which names a still-provisionally-named
/// session after its first one.
/// Hook events installed only with the `haiku-autoname` feature (#131).
/// `UserPromptSubmit` carries the prompt the user just submitted; the script
/// forwards the whole payload to `/api/sessions/:id/prompt`, which names a
/// still-provisionally-named session after its first one.
#[cfg(feature = "haiku-autoname")]
pub const PROMPT_EVENTS: [(&str, &str); 1] = [("UserPromptSubmit", "Prompt")];
@@ -55,7 +43,7 @@ pub const PROMPT_EVENTS: [(&str, &str); 0] = [];
/// Claude Code's own name for "the turn ended on an API error"; there is no
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
/// `Error`.
pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
pub const HOOK_EVENTS: [(&str, &str); 5] = [
// Tool starting: the agent is demonstrably producing work, even if the
// tool itself runs for minutes.
("PreToolUse", "Active"),
@@ -70,86 +58,17 @@ pub const CLAUDE_EVENTS: [(&str, &str); 5] = [
("StopFailure", "Error"),
];
/// Codex hook event → `ActivityState` spelling posted to the daemon.
///
/// Codex's event set is close to Claude Code's but not identical: a pending
/// approval arrives as `PermissionRequest` rather than `Notification`, and
/// there is no `StopFailure`, so a codex session never reports `Error`.
pub const CODEX_EVENTS: [(&str, &str); 4] = [
("PreToolUse", "Active"),
("PostToolUse", "Active"),
// Codex is blocked on the operator approving a tool call.
("PermissionRequest", "AwaitingInput"),
("Stop", "AwaitingInput"),
];
/// An agent whose hook file maestro writes into.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// `~/.claude/settings.json`.
ClaudeCode,
/// `~/.codex/hooks.json`. Codex asks the operator to trust a hook it has
/// not seen before, so an install here does nothing until they approve it
/// once inside codex — maestro never writes that approval itself.
Codex,
}
impl Target {
/// Every target an install covers. Both files are written whether or not
/// the agent is on the machine: an unused hook file costs nothing, and
/// the alternative is a session spawned after the daemon started missing
/// the hook because its agent arrived later.
pub const ALL: [Target; 2] = [Target::ClaudeCode, Target::Codex];
/// How the target is named in logs and CLI output.
pub fn label(self) -> &'static str {
match self {
Target::ClaudeCode => "claude-code",
Target::Codex => "codex",
}
}
/// The file this target's hook entries are registered in, under `home`.
pub fn settings_path(self, home: &Path) -> std::path::PathBuf {
match self {
Target::ClaudeCode => home.join(".claude/settings.json"),
Target::Codex => home.join(".codex/hooks.json"),
}
}
/// The activity events this target emits.
pub fn activity_events(self) -> &'static [(&'static str, &'static str)] {
match self {
Target::ClaudeCode => &CLAUDE_EVENTS,
Target::Codex => &CODEX_EVENTS,
}
}
/// Every event installed for this target: its activity events plus, with
/// `haiku-autoname` on, the prompt event both agents spell the same way.
pub fn events(self) -> impl Iterator<Item = (&'static str, &'static str)> {
self.activity_events()
.iter()
.chain(PROMPT_EVENTS.iter())
.copied()
}
}
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
# Installed by maestro (#106): report agent activity to its daemon.
# One script for every agent maestro installs hooks into; the state to post
# is chosen by the caller, so the two event sets differ only in what is
# registered where. Exits silently unless running inside a session maestro
# spawned (#103 env).
# Installed by maestro (#106): report Claude Code activity to its daemon.
# Exits silently unless running inside a session maestro spawned (#103 env).
# Event -> ActivityState mapping:
# PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (claude code: permission / idle)
# PermissionRequest -> AwaitingInput (codex: waiting on an approval)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (claude code: turn ended on an error)
# Prompt -> the payload is forwarded to the prompt route (#131)
# PreToolUse -> Active (tool started; agent is working)
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (permission / idle notice)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (turn ended on an API error)
# Prompt -> the payload is forwarded to the prompt route (#131)
state="$1"
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
[ -n "$MAESTRO_URL" ] || exit 0
@@ -165,15 +84,23 @@ if [ "$state" = "Prompt" ]; then
>/dev/null 2>&1 || true
exit 0
fi
body="{\"state\":\"$state\"}"
fields="\"state\":\"$state\""
# StopFailure payloads carry the outcome in "error". Only a plain enum
# spelling is reported as the detail — anything else (quotes, backslashes,
# prose) would risk breaking the JSON body, and the spelling already names
# the failure.
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
if [ -n "$detail" ]; then
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
fields="$fields,\"detail\":\"$detail\""
fi
# Every Claude Code hook payload carries its own conversation id (#161).
# Constrained to a safe charset for the same reason as "detail" above: it is
# spliced into the JSON body unescaped.
session_id=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
if [ -n "$session_id" ]; then
fields="$fields,\"session_id\":\"$session_id\""
fi
body="{$fields}"
curl -fsS --connect-timeout 1 --max-time 2 \
-H 'Content-Type: application/json' \
-d "$body" \
@@ -196,29 +123,34 @@ pub fn script_path(home: &Path) -> std::path::PathBuf {
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
}
/// Whether the file at `settings_path` already carries a maestro hook
/// (#106). Presence only: an install from an older release answers `true`
/// even when it is missing events this build ships — see [`is_current`].
/// The user-level Claude Code settings file for a given home directory.
pub fn settings_path(home: &Path) -> std::path::PathBuf {
home.join(".claude/settings.json")
}
/// Whether `settings_path` already carries a maestro hook (#106). Presence
/// only: an install from an older release answers `true` even when it is
/// missing events this build ships — see [`is_current`].
pub fn is_installed(settings_path: &Path) -> bool {
settings_json(settings_path)
.map(|root| find_maestro_commands(&root).next().is_some())
.unwrap_or(false)
}
/// Whether what is on disk is what this build ships for `target`: the script
/// at `script_path` byte-for-byte, and exactly one settings entry per one of
/// the target's [`Target::events`], pointing at that script.
/// Whether what is on disk is what this build ships: the script at
/// `script_path` byte-for-byte, and exactly one settings entry per event in
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
///
/// This is the upgrade question, asked per [`Target`]. A maestro that gains
/// an event — #131 added `UserPromptSubmit` long after #106 had installed
/// itself — leaves every existing user with a present but incomplete
/// install, which [`is_installed`] cannot tell apart from a current one.
pub fn is_current(target: Target, settings_path: &Path, script_path: &Path) -> bool {
/// This is the upgrade question. A maestro that gains an event — #131 added
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
/// existing user with a present but incomplete install, which
/// [`is_installed`] cannot tell apart from a current one.
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
return false;
}
settings_json(settings_path)
.map(|root| maestro_entries(&root) == expected_entries(target, script_path))
.map(|root| maestro_entries(&root) == expected_entries(script_path))
.unwrap_or(false)
}
@@ -249,13 +181,14 @@ fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
found
}
/// What [`install`] writes for `target` and `script_path`, in the same shape.
fn expected_entries(target: Target, script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = target
.events()
/// What [`install`] writes for `script_path`, in the same shape.
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = HOOK_EVENTS
.iter()
.chain(PROMPT_EVENTS.iter())
.map(|(event, state)| {
(
event.to_string(),
(*event).to_string(),
format!("{} {state}", script_path.display()),
)
})
@@ -282,40 +215,28 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
Ok(())
}
/// Install the full hook setup for one target under `home`: write the script
/// under `.local/share/maestro/` and register it in the target's hook file.
/// Install the full hook setup for `home`: write the script under
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
/// Idempotent; returns whether anything changed on disk.
pub fn install_target(target: Target, home: &Path) -> Result<bool, HooksError> {
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
let script = script_path(home);
let settings = target.settings_path(home);
let settings = settings_path(home);
// A script left over from an older release counts as a change even when
// the settings entries pointing at it are already right.
let changed = !is_current(target, &settings, &script);
let changed = !is_current(&settings, &script);
write_script(&script)?;
install(target, &settings, &script)?;
install(&settings, &script)?;
Ok(changed)
}
/// Install for every [`Target::ALL`], reporting each one separately. One
/// target failing — an unwritable file, unparseable JSON — never stops the
/// others: an agent whose hook could not be written is one agent without
/// activity reporting, not a failed install.
pub fn install_for_home(home: &Path) -> Vec<(Target, Result<bool, HooksError>)> {
Target::ALL
.iter()
.map(|&target| (target, install_target(target, home)))
.collect()
}
/// Register the hook command for every one of `target`'s [`Target::events`]
/// in the settings file at `settings_path`. Idempotent: existing maestro
/// entries are updated in place wherever they sit, stale ones removed,
/// duplicates folded; every non-maestro entry and every unrelated top-level
/// field survives.
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
/// settings file at `settings_path`. Idempotent: existing maestro entries are
/// updated in place wherever they sit, stale ones removed, duplicates folded;
/// every non-maestro entry and every unrelated top-level field survives.
///
/// Never inspects or writes anywhere but `settings_path` — callers pass the
/// user's real home or a temp one in tests, never a repository.
pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
let mut root = settings_json(settings_path)?;
// Strip every maestro entry wherever it currently sits, so an older
@@ -323,7 +244,7 @@ pub fn install(target: Target, settings_path: &Path, script_path: &Path) -> Resu
strip_maestro_entries(&mut root);
let hooks = ensure_object(&mut root, "hooks");
for (event, state) in target.events() {
for (event, state) in HOOK_EVENTS.iter().chain(PROMPT_EVENTS.iter()).copied() {
let groups = ensure_array(hooks, event);
let mut group = serde_json::Map::new();
group.insert(
+67 -136
View File
@@ -1,24 +1,15 @@
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
//! and the settings-file installer. Everything happens under a temp `$HOME`;
//! the developer's own `~/.claude/settings.json` and `~/.codex/hooks.json`
//! are never touched.
//! the developer's own `~/.claude/settings.json` is never touched.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use super::*;
/// How many commands one install writes for a target: its activity events,
/// plus the prompt event when `haiku-autoname` is on (#131).
fn installed_events(target: Target) -> usize {
target.events().count()
}
/// The Claude Code hook file under a temp home. Most installer tests only
/// need one target, and this is the one they were written against.
fn settings_path(home: &Path) -> std::path::PathBuf {
Target::ClaudeCode.settings_path(home)
}
/// How many commands one install writes: every activity event, plus the
/// prompt event when `haiku-autoname` is on (#131).
const INSTALLED_EVENTS: usize = HOOK_EVENTS.len() + PROMPT_EVENTS.len();
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
let mut cmd = Command::new("/bin/sh");
@@ -79,7 +70,7 @@ fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a
/// exit 0, no output, stdin drained.
#[test]
fn script_exits_silently_without_maestro_env() {
for (event, _) in CLAUDE_EVENTS {
for (event, _) in HOOK_EVENTS {
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
@@ -112,7 +103,7 @@ fn script_posts_mapped_state_to_the_activity_endpoint() {
/// Every event maps to the state the contract specifies.
#[test]
fn event_state_mapping_matches_the_contract() {
let map: std::collections::HashMap<&str, &str> = CLAUDE_EVENTS.into_iter().collect();
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
assert_eq!(map["PreToolUse"], "Active");
assert_eq!(map["PostToolUse"], "Active");
assert_eq!(map["Notification"], "AwaitingInput");
@@ -137,6 +128,41 @@ fn script_reports_the_error_spelling_as_detail() {
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
}
/// #161: the payload's own `session_id` — the agent's conversation id — is
/// parsed out and forwarded in the activity body, so the daemon can persist
/// it on the session.
#[test]
fn script_forwards_the_payloads_session_id_in_the_activity_body() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["Active"],
&owned_envs(&session_envs(&url)),
br#"{"session_id":"conv-abc123","hook_event_name":"PreToolUse"}"#,
)
});
assert!(request.contains(r#""state":"Active""#), "{request}");
assert!(
request.contains(r#""session_id":"conv-abc123""#),
"{request}"
);
}
/// A payload without a `session_id` (or one with an unexpected shape) must
/// never produce a broken body — no `session_id` field is posted.
#[test]
fn script_posts_no_session_id_when_the_payload_has_none() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["AwaitingInput"],
&owned_envs(&session_envs(&url)),
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
)
});
assert!(!request.contains("session_id"), "{request}");
}
/// An `error` value that is not a plain enum spelling (or a payload without
/// one at all) must never produce a broken body — no detail is posted.
#[test]
@@ -212,10 +238,10 @@ fn fresh_install_registers_every_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
write_script(&script_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
let cmds = installed_commands(&settings_path(home));
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode));
assert_eq!(cmds.len(), INSTALLED_EVENTS);
let joined = cmds.join("\n");
assert_eq!(joined.matches("Active").count(), 2);
assert!(joined.contains("Error"));
@@ -230,15 +256,15 @@ fn fresh_install_registers_every_event() {
fn install_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
let once = std::fs::read_to_string(settings_path(home)).unwrap();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
assert_eq!(once, twice);
assert_eq!(
installed_commands(&settings_path(home)).len(),
installed_events(Target::ClaudeCode)
INSTALLED_EVENTS
);
}
@@ -266,12 +292,12 @@ fn install_updates_an_older_maestro_hook_in_place() {
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(Target::ClaudeCode, &settings, &new_script).unwrap();
install(&settings, &new_script).unwrap();
let cmds = installed_commands(&settings);
assert_eq!(
cmds.len(),
installed_events(Target::ClaudeCode),
INSTALLED_EVENTS,
"old entry replaced, not kept: {cmds:?}"
);
assert!(!cmds.iter().any(|c| c == old_script));
@@ -307,7 +333,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
.path()
.join(".local/share/maestro")
.join(SCRIPT_FILE_NAME);
install(Target::ClaudeCode, &settings, &new_script).unwrap();
install(&settings, &new_script).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
@@ -320,7 +346,7 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
let cmds = installed_commands(&settings);
assert_eq!(
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
installed_events(Target::ClaudeCode),
INSTALLED_EVENTS,
"{cmds:?}"
);
}
@@ -330,15 +356,9 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
fn is_installed_tracks_reality() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
for target in Target::ALL {
assert!(!is_installed(&target.settings_path(home)));
}
for (_, result) in install_for_home(home) {
result.unwrap();
}
for target in Target::ALL {
assert!(is_installed(&target.settings_path(home)));
}
assert!(!is_installed(&settings_path(home)));
install_for_home(home).unwrap();
assert!(is_installed(&settings_path(home)));
}
/// The full home-level install leaves the script executable.
@@ -346,9 +366,7 @@ fn is_installed_tracks_reality() {
fn install_for_home_writes_an_executable_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
for (_, result) in install_for_home(home) {
result.unwrap();
}
install_for_home(home).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -367,7 +385,7 @@ fn install_for_home_writes_an_executable_script() {
fn install_registers_the_prompt_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -384,7 +402,7 @@ fn install_registers_the_prompt_event() {
fn install_registers_no_prompt_event_without_the_feature() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
install(&settings_path(home), &script_path(home)).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
@@ -453,12 +471,8 @@ fn the_script_posts_a_prompt_payload_unchanged() {
fn is_current_rejects_an_install_missing_an_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_target(Target::ClaudeCode, home).unwrap();
assert!(is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
install_for_home(home).unwrap();
assert!(is_current(&settings_path(home), &script_path(home)));
// Drop one event, as an older install would have left the file.
let mut root: serde_json::Value =
@@ -471,11 +485,7 @@ fn is_current_rejects_an_install_missing_an_event() {
.unwrap();
assert!(is_installed(&settings_path(home)));
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
assert!(!is_current(&settings_path(home), &script_path(home)));
}
/// A settings file that is complete but points at a script from an older
@@ -484,13 +494,9 @@ fn is_current_rejects_an_install_missing_an_event() {
fn is_current_rejects_a_stale_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_target(Target::ClaudeCode, home).unwrap();
install_for_home(home).unwrap();
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
assert!(!is_current(&settings_path(home), &script_path(home)));
}
/// Nothing installed at all is not current.
@@ -498,93 +504,18 @@ fn is_current_rejects_a_stale_script() {
fn is_current_rejects_a_missing_install() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!is_current(
Target::ClaudeCode,
&settings_path(home),
&script_path(home)
));
assert!(!is_current(&settings_path(home), &script_path(home)));
}
/// `install_target` reports a stale script as a change, even when the
/// `install_for_home` reports a stale script as a change, even when the
/// settings entries pointing at it already read correctly.
#[test]
fn install_target_reports_a_stale_script_as_a_change() {
fn install_for_home_reports_a_stale_script_as_a_change() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(install_target(Target::ClaudeCode, home).unwrap());
assert!(!install_target(Target::ClaudeCode, home).unwrap());
assert!(install_for_home(home).unwrap());
assert!(!install_for_home(home).unwrap());
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(install_target(Target::ClaudeCode, home).unwrap());
}
/// Codex is hooked in its own file, with its own event set: the two agents
/// share the script but not the events, so neither file carries the other's
/// spellings (#131 follow-up — a codex session was never named because
/// nothing installed its `UserPromptSubmit` hook).
#[test]
fn each_target_gets_its_own_file_and_events() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
for (_, result) in install_for_home(home) {
result.unwrap();
}
let claude = events_in(&Target::ClaudeCode.settings_path(home));
let codex = events_in(&Target::Codex.settings_path(home));
assert_eq!(
Target::Codex.settings_path(home),
home.join(".codex/hooks.json")
);
// Claude Code's failure signal has no codex equivalent, and codex's
// approval notice is spelled differently.
assert!(claude.contains(&"StopFailure".to_string()));
assert!(claude.contains(&"Notification".to_string()));
assert!(!codex.contains(&"StopFailure".to_string()));
assert!(!codex.contains(&"Notification".to_string()));
assert!(codex.contains(&"PermissionRequest".to_string()));
// Both report work and both report a finished turn.
for event in ["PreToolUse", "PostToolUse", "Stop"] {
assert!(claude.contains(&event.to_string()), "claude {event}");
assert!(codex.contains(&event.to_string()), "codex {event}");
}
assert_eq!(
installed_commands(&Target::Codex.settings_path(home)).len(),
installed_events(Target::Codex)
);
}
/// #131: a codex session names itself the same way a Claude Code one does —
/// same event spelling, same script, same `Prompt` argument.
#[cfg(feature = "haiku-autoname")]
#[test]
fn codex_reports_its_first_prompt_too() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_target(Target::Codex, home).unwrap();
let root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(Target::Codex.settings_path(home)).unwrap())
.unwrap();
let cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.to_string();
assert!(cmd.ends_with(" Prompt"), "{cmd}");
assert!(cmd.contains(SCRIPT_FILE_NAME));
}
/// Every event name a settings file registers a maestro hook under.
fn events_in(settings: &Path) -> Vec<String> {
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
.unwrap()
.get("hooks")
.and_then(|h| h.as_object())
.into_iter()
.flatten()
.map(|(event, _)| event.clone())
.collect()
assert!(install_for_home(home).unwrap());
}
+1 -57
View File
@@ -17,7 +17,7 @@ use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
SetFocusRequest, StructuredApprovalDecision, StructuredPromptRequest,
SetFocusRequest,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -105,11 +105,6 @@ impl DaemonClient {
Ok(routes::attach_url(&self.base, id)?)
}
/// `WS /api/structured/:id` URL for this daemon.
pub fn structured_url(&self, id: Uuid) -> Result<String, ClientError> {
Ok(routes::structured_url(&self.base, id)?)
}
// -- typed calls --------------------------------------------------------
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
@@ -184,57 +179,6 @@ impl DaemonClient {
Ok(())
}
/// Send one prompt to a structured session. The daemon rejects a second
/// prompt while the preceding ACP turn is still active.
pub async fn send_structured_prompt(&self, id: Uuid, prompt: &str) -> Result<(), ClientError> {
let req = StructuredPromptRequest {
prompt: prompt.to_string(),
};
let resp = self
.send(
Method::POST,
&routes::structured_prompt(id),
&[],
Some(&req),
)
.await?;
check(resp).await?;
Ok(())
}
/// Cancel the structured session's active ACP turn.
pub async fn cancel_structured_turn(&self, id: Uuid) -> Result<(), ClientError> {
let resp = self
.send(
Method::POST,
&routes::structured_cancel(id),
&[],
None::<&()>,
)
.await?;
check(resp).await?;
Ok(())
}
/// Resolve the currently pending structured-session permission request.
pub async fn resolve_structured_approval(
&self,
id: Uuid,
nonce: &str,
decision: StructuredApprovalDecision,
) -> Result<(), ClientError> {
let resp = self
.send(
Method::POST,
&routes::structured_approval(id, nonce),
&[],
Some(&decision),
)
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
+72 -625
View File
@@ -166,32 +166,45 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur
}
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through, and quoting/escaping is honoured
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
/// substitution, an unterminated quote) falls back to the naive first word
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
match resolve(command) {
Some(r) => match r.words.get(r.program_index) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there
// rather than rendering an empty fragment.
None => command.trim().to_string(),
},
None => legacy_first_word(command),
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
}
}
/// Program name (already basenamed and lowercased) -> the tool id its mark
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
/// recognition is deliberately narrow, so an unrecognised program has no tool.
/// `claude-code-acp` — the daemon's actual structured-session child — is
/// deliberately absent: the daemon launches it directly and ignores
/// `req.command`, so it is never itself a valid user-facing launch form.
const BY_PROGRAM: [(&str, &str); 17] = [
("claude", "claude"),
("claude-code", "claude"),
@@ -213,500 +226,29 @@ const BY_PROGRAM: [(&str, &str); 17] = [
];
/// The tool id a command line's icon stands for (`claude`, `codex`,
/// `opencode`, ...), or `None` when the program is not one we have a mark for
/// — including a command line [`resolve`] would only guess at. The command
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
/// tools the web's `splitCommand().tool` does.
/// `opencode`, ...), or `None` when the program is not one we have a mark for.
/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name
/// the same tools the web's `splitCommand().tool` does.
pub fn command_tool(command: &str) -> Option<&'static str> {
let r = resolve(command)?;
let program = basename(r.words.get(r.program_index)?).to_lowercase();
let program = command_program(command).to_lowercase();
BY_PROGRAM
.iter()
.find(|(name, _)| *name == program)
.map(|(_, tool)| *tool)
}
/// Whether a command line resolves to an agent Maestro can run as a
/// structured (ACP) session — Claude Code or Codex, the two adapters
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
/// control on this, and the daemon rejects a `Structured` request for
/// anything else, so the answer has to come from one place.
pub fn supports_structured_view(command: &str) -> bool {
matches!(command_tool(command), Some("claude" | "codex"))
}
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
/// of the word that is the actual program (past every assignment/wrapper it
/// resolved through).
struct Resolution {
words: Vec<String>,
program_index: usize,
}
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
/// wrapper forms (interleaved in any order/count) to find the program. An
/// assignment run is only consumed at the very start of the line or right
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
/// as the program, they don't setenv from an assignment-shaped one (#199).
/// `None` when [`tokenize`] can't safely make sense of the line at all.
fn resolve(command: &str) -> Option<Resolution> {
let words = tokenize(command)?;
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
let mut i = 0;
let mut assignment_allowed = true;
loop {
if assignment_allowed {
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
i += 1;
}
}
let Some(word) = words.get(i) else { break };
let wrapper = basename(&word.text).to_lowercase();
let Some(w) = wrapper_resolver(&wrapper) else {
break;
};
match (w.resolve)(&refs, i) {
Some(next) if next > i => {
i = next;
assignment_allowed = w.accepts_assignment;
}
// Not a form of the wrapper we recognise (an unknown option, or
// — for `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it):
// the wrapper word itself is the program, not a launcher.
_ => break,
}
}
Some(Resolution {
words: words.into_iter().map(|w| w.text).collect(),
program_index: i,
})
}
/// The naive "first whitespace word, basenamed" reading used only when
/// [`tokenize`] rejects the line outright — still something sane to show,
/// never a wrapper/tool match to act on.
fn legacy_first_word(command: &str) -> String {
match command.split_whitespace().next() {
Some(word) => basename(word).to_string(),
None => command.trim().to_string(),
}
}
/// One token from [`tokenize`]: its dequoted text, and — when both the name
/// and `=` were entirely unquoted/unescaped in the source — the byte length
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
#[derive(Debug, Clone, PartialEq, Eq)]
struct Word {
text: String,
plain_eq: Option<usize>,
}
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
/// `None` the moment it needs real shell grammar this resolver doesn't
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
/// separator, like `;` — #199), or an unterminated quote/escape. Never
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
/// backslash immediately before a newline, quoted or not, is a line
/// continuation: removed entirely rather than embedding the newline or
/// ending the word (#199).
fn tokenize(command: &str) -> Option<Vec<Word>> {
let mut words = Vec::new();
let mut current = String::new();
let mut has_current = false;
let mut plain_eq: Option<usize> = None;
let mut assignment_name_is_plain = true;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
match c {
' ' | '\t' | '\r' => {
if has_current {
words.push(Word {
text: std::mem::take(&mut current),
plain_eq: plain_eq.take(),
});
has_current = false;
assignment_name_is_plain = true;
}
}
'\n' => return None,
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
'$' if chars.peek() == Some(&'(') => return None,
'\'' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('\'') => break,
Some(c2) => current.push(c2),
None => return None, // unterminated single quote
}
}
}
'"' => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
current.push('\\');
current.push(c2);
}
None => return None, // trailing backslash
},
Some(c2) => current.push(c2),
None => return None, // unterminated double quote
}
}
}
'\\' => match chars.next() {
Some('\n') => {} // line continuation, removed entirely
Some(c2) => {
has_current = true;
if plain_eq.is_none() {
assignment_name_is_plain = false;
}
current.push(c2);
}
None => return None, // trailing backslash
},
other => {
has_current = true;
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
plain_eq = Some(current.len());
}
current.push(other);
}
}
}
if has_current {
words.push(Word {
text: current,
plain_eq,
});
}
Some(words)
}
/// A wrapper's known `-`/`--` options: which take no value, and which
/// consume either a following word or a `--flag=value` as theirs. A
/// `-`-prefixed word this table doesn't know halts resolution rather than
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
/// returning `None`.
struct OptionTable {
flags: &'static [&'static str],
valued: &'static [&'static str],
/// Whether this wrapper's short options may be combined (`-iv`) or take
/// an attached value (`-uroot`), the way real getopt-based tools do
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
/// `dlx`) don't get this — guessing at bundling they don't actually
/// support would resolve through options they'd reject outright.
bundles_short_options: bool,
}
impl OptionTable {
/// Consume this table's options starting at `words[i]`, returning the
/// index just past them (which may equal `i` if there are none) — or
/// `None` on an option this table doesn't recognise, or a valued option
/// missing its operand.
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
loop {
let Some(&word) = words.get(i) else {
return Some(i);
};
if word == "--" {
return Some(i + 1);
}
if word == "-" || !word.starts_with('-') {
return Some(i);
}
if let Some((flag, value)) = word.split_once('=') {
return if self.valued.contains(&flag) && !value.is_empty() {
i += 1;
continue;
} else {
None
};
}
if self.flags.contains(&word) {
i += 1;
continue;
}
if self.valued.contains(&word) {
let value = words.get(i + 1)?;
if value.is_empty() || value.starts_with('-') {
return None;
}
i += 2;
continue;
}
if self.bundles_short_options
&& !word.starts_with("--")
&& word.len() > 2
&& self.matches_short_cluster(word)
{
i += 1;
continue;
}
return None;
}
}
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
/// option with its value attached) — getopt's traditional short-option
/// forms. `false` the moment a character isn't a short option this
/// table knows, or a valued one ends the word with no attached value
/// left to be its operand (#199: rejected rather than guessed to come
/// from the next word instead).
fn matches_short_cluster(&self, word: &str) -> bool {
if !word.is_ascii() {
return false;
}
let bytes = word.as_bytes();
let mut buf = [b'-', 0];
let mut idx = 1;
while idx < bytes.len() {
buf[1] = bytes[idx];
let short = std::str::from_utf8(&buf).expect("ascii buffer");
if self.flags.contains(&short) {
idx += 1;
continue;
}
if self.valued.contains(&short) {
return idx + 1 < bytes.len();
}
return false;
}
true
}
}
const NO_OPTIONS: OptionTable = OptionTable {
flags: &[],
valued: &[],
bundles_short_options: false,
};
const ENV_OPTS: OptionTable = OptionTable {
flags: &[
"-i",
"--ignore-environment",
"-0",
"--null",
"-v",
"--debug",
],
valued: &["-u", "--unset", "-C", "--chdir"],
bundles_short_options: true,
};
const SUDO_OPTS: OptionTable = OptionTable {
flags: &[
"-i",
"--login",
"-n",
"--non-interactive",
"-E",
"--preserve-env",
"-H",
"--set-home",
"-A",
"--askpass",
"-b",
"--background",
"-k",
"--reset-timestamp",
"-K",
"--remove-timestamp",
"-S",
"--stdin",
],
valued: &["-u", "--user", "-g", "--group"],
bundles_short_options: true,
};
/// `-v`/`-V` only print what would run — they never execute it — so
/// resolving through them would claim a program runs that `command` never
/// launches (#199). Only `-p`, which still executes normally, is safe to
/// look through.
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
flags: &["-p"],
valued: &[],
bundles_short_options: true,
};
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
/// `/usr/bin/time`, a different program a bare `time` word doesn't
/// necessarily name. Assuming them here would be guessing which `time`
/// resolves (#199).
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
let mut next = i + 1;
if words.get(next) == Some(&"-p") {
next += 1;
}
if words.get(next) == Some(&"--") {
next += 1;
}
match words.get(next) {
Some(word) if word.starts_with('-') => None,
_ => Some(next),
}
}
/// `-c`/`--call` embeds a whole shell command string rather than naming the
/// program positionally — resolving past it would mean guessing at a
/// program buried inside that string, so it's left unrecognised and
/// resolution stops at `npx` (#199).
const NPX_OPTS: OptionTable = OptionTable {
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
valued: &["-p", "--package", "--shell"],
bundles_short_options: false,
};
const PNPM_OPTS: OptionTable = OptionTable {
flags: &[
"--silent",
"-s",
"--stream",
"--aggregate-output",
"--no-progress",
],
// `--package`/`--package=value` is documented in this pre-`dlx`
// position (`pnpm --package=x dlx y`), alongside the identical flag
// `dlx` itself also accepts (`PNPM_DLX_OPTS`).
valued: &["-C", "--dir", "--filter", "--package"],
bundles_short_options: false,
};
const YARN_OPTS: OptionTable = OptionTable {
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
valued: &["--cwd"],
bundles_short_options: false,
};
const PNPM_DLX_OPTS: OptionTable = OptionTable {
flags: &["-s", "--silent"],
valued: &["--package"],
bundles_short_options: false,
};
const YARN_DLX_OPTS: OptionTable = OptionTable {
flags: &["-q", "--quiet"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
const BUN_X_OPTS: OptionTable = OptionTable {
flags: &["--bun", "--no-install", "--verbose", "--silent"],
valued: &["-p", "--package"],
bundles_short_options: false,
};
/// A resolver for one wrapper form: given the tokenized command and the
/// index of the wrapper word itself, the index of the word after it (past
/// its options and any subcommand it requires), or `None` if this occurrence
/// isn't a form we resolve through.
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
/// One recognised wrapper form: how to walk past it, and whether an
/// assignment word is a valid follow-on once it's been walked past.
struct Wrapper {
resolve: WrapperResolver,
/// `true` only for wrappers that themselves interpret a following
/// `FOO=bar` as an environment assignment for the command they launch
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
/// grammar for a brand new simple command (`time`). Every other wrapper
/// here is a plain executable that execs its next argument as the
/// program outright — an assignment-shaped one names a program literally
/// called `FOO=1`, not an env var (#199).
accepts_assignment: bool,
}
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
"time" => (resolve_time, true),
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
"pnpm" => (
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
false,
),
"yarn" => (
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
false,
),
"bun" => (resolve_bun, false),
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
"uvx" => (|_words, i| Some(i + 1), false),
_ => return None,
};
Some(Wrapper {
resolve,
accepts_assignment,
})
}
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
/// — without it, the next word is a `package.json` script name, not a
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
/// itself takes launcher-specific options too, consumed before the executable.
fn resolve_dlx(
opts: &OptionTable,
dlx_opts: &OptionTable,
words: &[&str],
i: usize,
) -> Option<usize> {
let j = opts.consume(words, i + 1)?;
if words.get(j) != Some(&"dlx") {
return None;
}
dlx_opts.consume(words, j + 1)
}
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
/// the executable.
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
if words.get(i + 1) != Some(&"x") {
return None;
}
BUN_X_OPTS.consume(words, i + 2)
}
/// `FOO=bar`, the shell's own prefix for one command's environment —
/// recognised only when the `=` came from plain, unquoted/unescaped source
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
/// an assignment.
fn is_env_assignment(word: &Word) -> bool {
let Some(eq) = word.plain_eq else {
return false;
};
let name = &word.text[..eq];
let mut chars = name.chars();
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
}
fn basename(word: &str) -> &str {
@@ -726,7 +268,6 @@ mod tests {
fn session(cwd: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd),
command: "bash".into(),
@@ -743,7 +284,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}
}
@@ -955,132 +496,38 @@ mod tests {
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
}
/// The #199 resolver contract: every case in the shared parity fixture,
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
/// resolver regression in either language fails its own suite.
#[test]
fn resolves_the_shared_command_resolution_fixture() {
#[derive(serde::Deserialize)]
struct Case {
command: String,
program: String,
tool: Option<String>,
}
#[derive(serde::Deserialize)]
struct Fixture {
cases: Vec<Case>,
}
let raw = include_str!("../../../fixtures/command-resolution.json");
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
assert!(!fixture.cases.is_empty());
for case in &fixture.cases {
assert_eq!(
command_program(&case.command),
case.program,
"program for {:?}",
case.command
);
assert_eq!(
command_tool(&case.command),
case.tool.as_deref(),
"tool for {:?}",
case.command
);
}
}
fn word(text: &str) -> Word {
Word {
text: text.into(),
plain_eq: None,
}
}
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
#[test]
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
assert_eq!(
tokenize("claude --resume"),
Some(vec![word("claude"), word("--resume")])
);
assert_eq!(
tokenize("\"/opt/my agent/claude\" --resume"),
Some(vec![word("/opt/my agent/claude"), word("--resume")])
);
assert_eq!(
tokenize("FOO=\"bar baz\" claude"),
Some(vec![
Word {
text: "FOO=bar baz".into(),
plain_eq: Some(3),
},
word("claude"),
])
);
assert_eq!(
tokenize("claude --name=$HOME"),
Some(vec![
word("claude"),
Word {
text: "--name=$HOME".into(),
plain_eq: Some(6),
},
]),
"a bare $ is kept literal, never expanded"
);
}
/// Real shell grammar this resolver doesn't attempt — pipelines, list
/// operators, subshells/substitution, an unescaped newline, an
/// unterminated quote — is rejected outright rather than guessed
/// through (#199).
#[test]
fn tokenize_rejects_ambiguous_shell_grammar() {
for command in [
"claude | tee log.txt",
"claude; echo done",
"claude && echo done",
"echo hi $(claude)",
"claude \"unterminated",
"claude 'unterminated",
"claude \\",
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
] {
assert_eq!(tokenize(command), None, "{command:?}");
}
}
/// A backslash immediately before a newline is a line continuation:
/// removed entirely, joining what it split rather than embedding a
/// literal newline or ending the command (#199).
#[test]
fn tokenize_treats_backslash_newline_as_a_continuation() {
assert_eq!(
tokenize("clau\\\nde --resume"),
Some(vec![word("claude"), word("--resume")])
);
}
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
/// quoting or escaping the `=` itself disqualifies the word, even
/// though the dequoted text looks identical (#199).
#[test]
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
assert_eq!(command_program("hx src/main.rs"), "hx");
}
#[test]
fn structured_view_support_follows_the_resolved_program() {
assert!(supports_structured_view("claude --resume"));
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
assert!(supports_structured_view("npx claude-code"));
assert!(!supports_structured_view("opencode"));
assert!(!supports_structured_view("bash -l"));
assert!(!supports_structured_view("claude-code-acp"));
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
/// tools the web's `splitCommand().tool` does.
#[test]
fn command_tool_names_the_mark_the_card_shows() {
assert_eq!(command_tool("claude --resume"), Some("claude"));
assert_eq!(command_tool("npx opencode"), Some("opencode"));
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
assert_eq!(command_tool("CLAUDE"), Some("claude"));
}
/// Narrow on purpose: an unrecognised program has no mark, so the
/// candidate has no `tool` field to match rather than a wrong one.
#[test]
fn command_tool_is_absent_for_an_unrecognised_program() {
assert_eq!(command_tool("hx src/main.rs"), None);
assert_eq!(command_tool(""), None);
}
}
+79 -263
View File
@@ -116,33 +116,13 @@ pub struct Session {
/// sorts a never-focused session after every session that has one.
#[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>,
/// Whether this session's process is a plain terminal (PTY bytes through
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
/// spawn time and never changes for the life of the session. Defaults to
/// `Terminal` on the wire so every existing client and payload from a
/// daemon that predates the field keeps working untouched.
#[serde(default)]
pub view: SessionView,
/// The ACP session id the selected provider adapter handed back from
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
/// one, and only once its handshake succeeded; a later respawn passes it
/// to `session/load` so the conversation continues (#135). Distinct from
/// the hook-reported Claude Code conversation id (#161): that one is what
/// `claude --resume` takes, this one is what the ACP adapter takes, and
/// the two are minted by different processes with different lifetimes.
/// The conversation id the agent running in this session reported for
/// itself (#161), parsed from a hook's `session_id` field. Written once,
/// from the first activity report that carries one, and left alone after
/// — an agent's own session id does not change mid-session. `None` for
/// sessions that never run an agent (plain shells) or predate the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>,
}
/// A session's process shape (#133): a plain terminal, or a structured agent
/// driven over ACP rows. Chosen at spawn and immutable for the session's
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
/// written before this field existed meant a terminal session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SessionView {
#[default]
Terminal,
Structured,
pub agent_session_id: Option<String>,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -150,15 +130,43 @@ fn epoch() -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
/// Lifecycle of a session as the daemon knows it.
///
/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling
/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the
/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is
/// one this client must not treat as having a live pane to type into.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum SessionStatus {
Running,
Dead,
/// Known from its sidecar, with no tmux session behind it (#162). The
/// daemon registers these at recovery time for every sidecar tmux had
/// nothing to adopt — after a host reboot, that is every session there
/// was. There is no pane and no PTY bridge: a dormant session is a record
/// of what was running, listed so the operator still has it, and the only
/// thing that can be done to it is dismissing it.
Dormant,
}
/// Session activity, orthogonal to [`SessionStatus`] (NAP-340). Terminal
/// sessions use pane/process inference and agent reports; structured sessions
/// use their ACP turn lifecycle (docs/SIDEBAR.md).
impl<'de> Deserialize<'de> for SessionStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"Running" => Self::Running,
"Dormant" => Self::Dormant,
// Known or not, any other spelling degrades to the inert variant.
_ => Self::Dead,
})
}
}
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
/// inspection — never by parsing the terminal byte stream.
///
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
@@ -166,14 +174,15 @@ pub enum SessionStatus {
/// forgiven.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
pub enum ActivityState {
/// Producing output, the foreground process is busy, or an ACP turn is active.
/// Producing output, or the foreground process is busy.
Active,
/// Foreground process alive but quiescent. The forward-compatible default.
#[default]
Idle,
/// Blocked on terminal input or waiting for an ACP approval.
/// Blocked on a `read()` from the pty — waiting on the user.
AwaitingInput,
/// An agent reported a failure, or a structured session/turn failed.
/// The agent running in the session reported a failure (#102). Never
/// inferred by the daemon; only set when an agent reports it (#105).
Error,
}
@@ -210,6 +219,11 @@ pub struct ReportActivityRequest {
/// The daemon caps its length server-side.
#[serde(default)]
pub detail: Option<String>,
/// The agent's own conversation id (#161), forwarded from the hook
/// payload's `session_id` field. `None` for hook events that don't carry
/// one, or hooks predating this field.
#[serde(default)]
pub session_id: Option<String>,
}
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
@@ -378,15 +392,6 @@ pub struct CreateSessionRequest {
pub metadata: HashMap<String, Value>,
#[serde(default)]
pub worktree: Option<WorktreeSpec>,
/// The process shape to spawn (#133). Defaulted so every existing client
/// and `curl` against the API keeps requesting a `Terminal` session
/// untouched.
#[serde(default)]
pub view: SessionView,
/// When `true`, the daemon auto-approves every ACP permission request
/// for this structured session.
#[serde(default)]
pub auto_approve: bool,
}
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -524,13 +529,11 @@ impl ApiError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset {
pub id: String,
#[serde(default)]
pub label: String,
pub command: String,
/// When `true`, the daemon auto-approves every ACP permission request
/// for structured sessions spawned from this preset instead of surfacing
/// them to the client.
#[serde(default)]
pub auto_approve: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_command: Option<String>,
}
/// Build provenance of the running daemon: the commit it was compiled
@@ -763,150 +766,6 @@ pub enum CatalogEvent {
},
}
// ---------------------------------------------------------------------------
// Structured-session projections
// ---------------------------------------------------------------------------
/// One client-neutral item in a structured session transcript.
///
/// `data` preserves the ACP payload without committing clients to an ACP
/// dependency. Rendering choices, including markdown and tool-card treatment,
/// stay with the client.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptRow {
/// Stable within one session, so later deltas can address this row.
pub id: String,
pub kind: TranscriptRowKind,
pub data: Value,
}
/// The semantic shape of a [`TranscriptRow`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptRowKind {
User,
AgentText,
AgentThought,
ToolCall,
ToolDiff,
Approval,
Plan,
Notice,
}
/// A complete transcript at a durable event-log position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptSnapshot {
pub cursor: u64,
#[serde(default)]
pub rows: Vec<TranscriptRow>,
}
/// One durable, client-neutral replay step from a structured session.
///
/// Results are ordered by increasing `cursor`. `since` returns the first
/// matching steps; `before` returns the final matching steps, still increasing.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuredReplayEvent {
/// Durable ACP-log position represented by this projected step.
pub cursor: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript: Option<TranscriptDelta>,
pub state: ReducedState,
}
/// A change to a transcript row.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TranscriptDelta {
Append { row: TranscriptRow },
Patch { row: TranscriptRow },
Remove { id: String },
}
/// Per-turn indicators derived from the ACP stream.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnState {
#[serde(default)]
pub responding: bool,
#[serde(default)]
pub thinking: bool,
#[serde(default)]
pub active_tool_calls: Vec<String>,
/// Wall-clock start of the active prompt. This lets reconnecting clients
/// keep one continuous elapsed-time display instead of restarting at zero.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
/// Context-window usage when the active prompt began. ACP exposes context
/// occupancy, not provider billing-token accounting, so clients must label
/// deltas as context tokens rather than tokens billed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_context_tokens: Option<u64>,
}
/// A whole-session view derived from ACP notifications.
///
/// Values whose shape ACP owns are carried as JSON so this wire protocol stays
/// forward-compatible when ACP adds fields or variants.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ReducedState {
#[serde(default)]
pub turn: TurnState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_approval: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_options: Option<Value>,
}
/// Server messages sent over a structured-session WebSocket.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StructuredEvent {
TranscriptSnapshot {
snapshot: TranscriptSnapshot,
},
ReducedState {
state: ReducedState,
},
ProjectionUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
transcript: Option<TranscriptDelta>,
state: ReducedState,
},
}
/// HTTP body for `POST /api/sessions/:id/structured/prompt`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StructuredPromptRequest {
pub prompt: String,
}
/// HTTP body for `POST /api/sessions/:id/structured/config`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuredConfigRequest {
pub config_id: String,
pub value: Value,
}
/// Operator decision for a pending ACP permission request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StructuredApprovalDecision {
AllowOnce,
AllowForSession,
Deny,
}
// ---------------------------------------------------------------------------
// Client ↔ Daemon control messages (text frames on the attach WS)
// ---------------------------------------------------------------------------
@@ -1046,8 +905,7 @@ mod tests {
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
last_focused_at: None,
view: SessionView::Terminal,
acp_session_id: None,
agent_session_id: None,
}
}
@@ -1217,6 +1075,7 @@ mod tests {
let full = ReportActivityRequest {
state: ActivityState::AwaitingInput,
detail: Some("waiting on permission".into()),
session_id: Some("conv-abc123".into()),
};
assert_eq!(full, round_trip(&full));
@@ -1245,6 +1104,33 @@ mod tests {
serde_json::to_value(SessionStatus::Dead).unwrap(),
serde_json::json!("Dead")
);
assert_eq!(
serde_json::to_value(SessionStatus::Dormant).unwrap(),
serde_json::json!("Dormant")
);
}
// #162: a session with no tmux behind it still rides the wire whole.
#[test]
fn dormant_session_round_trips() {
let mut s = sample_session();
s.status = SessionStatus::Dormant;
assert_eq!(s, round_trip(&s));
}
// #162: a `SessionStatus` this client does not know must degrade to the
// inert variant, not fail the whole `Session` the way a derived
// `Deserialize` would.
#[test]
fn unknown_session_status_degrades_to_dead() {
assert_eq!(
serde_json::from_value::<SessionStatus>(serde_json::json!("Hibernating")).unwrap(),
SessionStatus::Dead
);
let mut value = serde_json::to_value(sample_session()).unwrap();
value["status"] = serde_json::json!("Hibernating");
let s: Session = serde_json::from_value(value).expect("session survives unknown status");
assert_eq!(s.status, SessionStatus::Dead);
}
#[test]
@@ -1259,8 +1145,6 @@ mod tests {
branch: "feat/foo".into(),
create_branch: true,
}),
view: SessionView::Terminal,
auto_approve: false,
};
assert_eq!(req, round_trip(&req));
}
@@ -1275,74 +1159,6 @@ mod tests {
assert!(req.worktree.is_none());
assert!(req.env.is_empty());
assert!(req.metadata.is_empty());
assert_eq!(req.view, SessionView::Terminal);
}
#[test]
fn session_view_defaults_to_terminal_on_legacy_payload() {
// A pre-#133 daemon or client omits `view`; it must deserialize to
// `Terminal` rather than failing, and `Structured` round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.view, SessionView::Terminal);
let mut structured = sample_session();
structured.view = SessionView::Structured;
assert_eq!(structured, round_trip(&structured));
}
#[test]
fn structured_projection_types_round_trip_and_default() {
let row = TranscriptRow {
id: "tool:read-1".into(),
kind: TranscriptRowKind::ToolCall,
data: serde_json::json!({"name": "vendor.read", "title": "Read"}),
};
let snapshot = TranscriptSnapshot {
cursor: 7,
rows: vec![row.clone()],
};
assert_eq!(snapshot, round_trip(&snapshot));
assert_eq!(
TranscriptDelta::Patch { row: row.clone() },
round_trip(&TranscriptDelta::Patch { row })
);
let state: ReducedState = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(state, ReducedState::default());
assert_eq!(
serde_json::to_value(TranscriptRowKind::AgentThought).unwrap(),
serde_json::json!("agent_thought")
);
}
#[test]
fn acp_session_id_defaults_to_none_and_round_trips() {
// A payload from before #134 carries no `acp_session_id`; it must
// read as `None`, stay off the wire while unset, and round-trip
// once the adapter has handed one back.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.acp_session_id, None);
let serialized = serde_json::to_value(&s).unwrap();
assert!(serialized.get("acp_session_id").is_none());
let mut structured = sample_session();
structured.view = SessionView::Structured;
structured.acp_session_id = Some("sess_01HZX".into());
assert_eq!(structured, round_trip(&structured));
}
/// A client that predates the new/existing discriminator sends no
-87
View File
@@ -71,41 +71,6 @@ pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
}
/// `WS /api/structured/:id` — transcript/state snapshots and live deltas.
pub fn structured(id: Uuid) -> String {
format!("{API_PREFIX}/structured/{id}")
}
/// `GET /api/sessions/:id/structured/replay` — cursor-bounded ACP history.
pub fn structured_replay(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/replay")
}
/// `POST /api/sessions/:id/structured/prompt` — send an ACP prompt.
pub fn structured_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/prompt")
}
/// `POST /api/sessions/:id/structured/attachments` — upload a file.
pub fn structured_attachment(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/attachments")
}
/// `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn.
pub fn structured_cancel(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/cancel")
}
/// `POST /api/sessions/:id/structured/config` — change an ACP session option.
pub fn structured_config(id: Uuid) -> String {
format!("{SESSIONS}/{id}/structured/config")
}
/// `POST /api/sessions/:id/structured/approvals/:nonce` — answer ACP permission.
pub fn structured_approval(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
@@ -115,13 +80,6 @@ pub mod patterns {
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
pub const ATTACH: &str = "/api/attach/:id";
pub const STRUCTURED: &str = "/api/structured/:id";
pub const STRUCTURED_REPLAY: &str = "/api/sessions/:id/structured/replay";
pub const STRUCTURED_PROMPT: &str = "/api/sessions/:id/structured/prompt";
pub const STRUCTURED_ATTACHMENT: &str = "/api/sessions/:id/structured/attachments";
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
}
/// A daemon base URL that could not be turned into a WebSocket base.
@@ -171,11 +129,6 @@ pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
}
/// Full `WS /api/structured/:id` URL for a daemon reachable at `http_base`.
pub fn structured_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, structured(id)))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -225,10 +178,6 @@ mod tests {
events_url("http://host:7070/").unwrap(),
"ws://host:7070/api/events"
);
assert_eq!(
structured_url("http://host:7070/", id()).unwrap(),
"ws://host:7070/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
}
#[test]
@@ -257,22 +206,6 @@ mod tests {
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
structured(id()),
"/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
structured_replay(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/replay"
);
assert_eq!(
structured_prompt(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/prompt"
);
assert_eq!(
structured_cancel(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/structured/cancel"
);
assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(),
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -307,22 +240,6 @@ mod tests {
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED.trim_end_matches(":id"),
structured(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED_REPLAY.trim_end_matches(":id/structured/replay"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED_PROMPT.trim_end_matches(":id/structured/prompt"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::STRUCTURED_CANCEL.trim_end_matches(":id/structured/cancel"),
session(id()).trim_end_matches(&id().to_string())
);
}
#[test]
@@ -341,9 +258,5 @@ mod tests {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
}
assert!(attach(id()).starts_with(API_PREFIX));
assert!(structured(id()).starts_with(API_PREFIX));
assert!(structured_replay(id()).starts_with(API_PREFIX));
assert!(structured_prompt(id()).starts_with(API_PREFIX));
assert!(structured_cancel(id()).starts_with(API_PREFIX));
}
}
+1 -2
View File
@@ -31,7 +31,6 @@ use uuid::Uuid;
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
Session {
view: Default::default(),
id,
cwd: cwd.into(),
command: command.into(),
@@ -48,7 +47,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}
}
-1
View File
@@ -21,7 +21,6 @@ thiserror.workspace = true
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
tokio-tungstenite.workspace = true
tracing.workspace = true
unicode-width.workspace = true
uuid.workspace = true
vt100.workspace = true
+19 -501
View File
@@ -11,17 +11,13 @@
//! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::{
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, SessionView,
StructuredApprovalDecision,
};
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
use ratatui::Terminal;
use uuid::Uuid;
@@ -42,8 +38,6 @@ use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::structured_pane::StructuredPane;
use crate::structured_task::{StructuredHandle, StructuredTask};
use crate::text_input::TextInput;
use crate::TuiError;
@@ -55,42 +49,11 @@ enum OverlayDecision {
Pass,
}
fn copy_to_terminal_clipboard(text: &str) -> std::io::Result<()> {
let encoded = base64_encode(text.as_bytes());
let mut output = std::io::stdout();
write!(output, "\x1b]52;c;{encoded}\x07")?;
output.flush()
}
fn base64_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
output.push(TABLE[((bits >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((bits >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((bits >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(bits & 0x3f) as usize] as char
} else {
'='
});
}
output
}
pub struct App {
cfg: TuiConfig,
home_dir: String,
catalog: CatalogStore,
emulators: SessionEmulators,
structured: HashMap<Uuid, StructuredHandle>,
state: AppState,
help: HelpOverlay,
chord: ChordState,
@@ -200,18 +163,6 @@ pub enum Effect {
id: Uuid,
bytes: Vec<u8>,
},
StructuredPrompt {
id: Uuid,
prompt: String,
},
StructuredCancel {
id: Uuid,
},
StructuredApproval {
id: Uuid,
nonce: String,
decision: StructuredApprovalDecision,
},
/// Fetch presets / projects / names for a freshly opened form (#64).
LoadDraft,
/// Fast-forward spawn (#67): resolve every default from the daemon and
@@ -263,7 +214,6 @@ impl App {
home_dir: std::env::var("HOME").unwrap_or_default(),
catalog,
emulators: SessionEmulators::default(),
structured: HashMap::new(),
state: AppState::new(),
draft: None,
help,
@@ -371,15 +321,6 @@ impl App {
return Ok(false);
}
if self.handle_structured_mouse(&key) {
return Ok(false);
}
if let Some(effects) = self.handle_structured_key(&key) {
self.perform(effects).await;
return Ok(false);
}
if let Some(effect) = self.handle_pane_scroll(&key) {
self.perform(effect.into_iter().collect()).await;
return Ok(false);
@@ -458,61 +399,6 @@ impl App {
true
}
/// Transcript blocks are pointer targets: click selects the whole block,
/// gives it the selection surface, and copies its complete text.
fn handle_structured_mouse(&mut self, key: &InputKey) -> bool {
let InputKey::Mouse {
kind,
region: Region::Pane,
row,
..
} = key
else {
return false;
};
if !matches!(kind, MouseKind::Click | MouseKind::Hover) {
return false;
}
let Some(sid) = self.state.active_session() else {
return false;
};
let Some(handle) = self.structured.get(&sid) else {
return false;
};
let Some(pane) = self.region.pane else {
return false;
};
let offset = usize::from(row.saturating_sub(pane.y));
let transcript_height = pane.height.saturating_sub(pane.height.min(3));
if offset >= usize::from(transcript_height) {
return true;
}
let mut state = handle.state.lock().expect("structured state mutex");
let rendered_line = state.scroll.saturating_add(offset);
if !state.select_rendered_line(rendered_line) {
return true;
}
let Some(id) = state.selected.as_deref() else {
return true;
};
let Some(transcript) = state.rows.iter().find(|item| item.id == id) else {
return true;
};
if *kind == MouseKind::Hover {
return true;
}
let text = crate::structured_pane::copy_text(transcript);
drop(state);
if text.is_empty() {
return true;
}
match copy_to_terminal_clipboard(&text) {
Ok(()) => self.state.toast("copied transcript block"),
Err(error) => self.state.toast(format!("copy failed: {error}")),
}
true
}
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -533,44 +419,6 @@ impl App {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
}
}
Effect::StructuredPrompt { id, prompt } => {
if let Err(error) = self.daemon.send_structured_prompt(id, &prompt).await {
if let Some(handle) = self.structured.get(&id) {
handle
.state
.lock()
.expect("structured state mutex")
.prompt_failed(prompt, error.to_string());
}
}
}
Effect::StructuredCancel { id } => {
if let Err(error) = self.daemon.cancel_structured_turn(id).await {
if let Some(handle) = self.structured.get(&id) {
handle.state.lock().expect("structured state mutex").error =
Some(error.to_string());
}
}
}
Effect::StructuredApproval {
id,
nonce,
decision,
} => {
if let Err(error) = self
.daemon
.resolve_structured_approval(id, &nonce, decision)
.await
{
if let Some(handle) = self.structured.get(&id) {
handle
.state
.lock()
.expect("structured state mutex")
.approval_failed(&nonce, error.to_string());
}
}
}
}
}
}
@@ -715,13 +563,13 @@ impl App {
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
// Escape hatch (#156): the literal byte, not the bar.
Action::SendLiteralCtrlK => {
effects.extend(self.forward_literal_to_terminal(&InputKey::ctrl_char('k')));
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
}
// Escape hatch (#158): the leader's own byte, built from the
// configured leader rather than a hardcoded key.
Action::SendLiteralLeader => {
let leader = self.cfg.leader.clone();
effects.extend(self.forward_literal_to_terminal(&leader));
effects.extend(self.forward_to_session(&leader));
}
Action::OpenSpawnModal => {
self.open_spawn_modal();
@@ -739,7 +587,7 @@ impl App {
}
// A previewed session is already attached: committing
// to it keeps that stream rather than reopening one.
if !self.is_attached(sid) {
if self.emulators.get(sid).is_none() {
effects.push(Effect::Attach(sid));
}
}
@@ -747,9 +595,6 @@ impl App {
// preview budget.
self.preview_lru.retain(|s| *s != sid);
self.state.attach_to_focused(sid);
if self.session_view(sid) == SessionView::Structured {
self.state.mode = Mode::Normal;
}
}
}
Action::KillHighlighted => {
@@ -818,7 +663,7 @@ impl App {
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
let fresh = !self.is_attached(sid);
let fresh = self.emulators.get(sid).is_none();
if fresh {
self.preview_lru.retain(|s| *s != sid);
self.preview_lru.push(sid);
@@ -887,27 +732,15 @@ impl App {
/// Open a stream for `sid` if one isn't already running.
fn attach(&mut self, sid: Uuid) {
self.attach_with_view(sid, self.session_view(sid));
}
fn attach_with_view(&mut self, sid: Uuid, view: SessionView) {
if self.is_attached(sid) {
if self.emulators.get(sid).is_some() {
return;
}
match view {
SessionView::Terminal => match self.daemon.attach_url(sid) {
Ok(ws_url) => {
let handle = SessionTask::spawn(ws_url, 24, 80);
self.emulators.insert(sid, handle);
}
Err(e) => self.state.toast(format!("attach failed: {e}")),
},
SessionView::Structured => match self.daemon.structured_url(sid) {
Ok(ws_url) => {
self.structured.insert(sid, StructuredTask::spawn(ws_url));
}
Err(e) => self.state.toast(format!("attach failed: {e}")),
},
match self.daemon.attach_url(sid) {
Ok(ws_url) => {
let handle = SessionTask::spawn(ws_url, 24, 80);
self.emulators.insert(sid, handle);
}
Err(e) => self.state.toast(format!("attach failed: {e}")),
}
}
@@ -915,26 +748,9 @@ impl App {
/// dropping its vt100 parser.
fn release_session(&mut self, sid: Uuid) {
self.emulators.remove(sid);
if let Some(handle) = self.structured.remove(&sid) {
handle.end();
}
self.last_sent_size.remove(&sid);
}
fn session_view(&self, sid: Uuid) -> SessionView {
self.catalog
.snapshot()
.sessions
.iter()
.find(|session| session.id == sid)
.map(|session| session.view)
.unwrap_or_default()
}
fn is_attached(&self, sid: Uuid) -> bool {
self.emulators.get(sid).is_some() || self.structured.contains_key(&sid)
}
/// Return the session under the sidebar highlight, falling back to the
/// first listed session so the user can attach without arrowing first.
fn pick_sidebar_target(&self) -> Option<Uuid> {
@@ -1001,16 +817,6 @@ impl App {
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_session()?;
if let Some(handle) = self.structured.get(&sid) {
let mut state = handle.state.lock().expect("structured state mutex");
if matches!(key, InputKey::Enter) {
return state
.begin_prompt()
.map(|prompt| Effect::StructuredPrompt { id: sid, prompt });
}
state.edit_composer(key);
return None;
}
let handle = self.emulators.get(sid)?;
let bytes = encode_key(key)?;
handle
@@ -1021,86 +827,6 @@ impl App {
Some(Effect::Forward { id: sid, bytes })
}
fn forward_literal_to_terminal(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_session()?;
if self.session_view(sid) == SessionView::Structured {
return None;
}
self.forward_to_session(key)
}
fn handle_structured_key(&mut self, key: &InputKey) -> Option<Vec<Effect>> {
if self.state.focus != Focus::Pane || self.state.preview.is_some() {
return None;
}
let sid = self.state.active_session()?;
let handle = self.structured.get(&sid)?;
let mut state = handle.state.lock().expect("structured state mutex");
if self.state.mode == Mode::Insert {
if matches!(key, InputKey::Esc) && state.turn_active() {
return Some(vec![Effect::StructuredCancel { id: sid }]);
}
return None;
}
let effects = match key {
InputKey::Char {
c: 'j',
ctrl: false,
alt: false,
..
}
| InputKey::Down => {
state.select(1);
Vec::new()
}
InputKey::Char {
c: 'k',
ctrl: false,
alt: false,
..
}
| InputKey::Up => {
state.select(-1);
Vec::new()
}
InputKey::Enter => {
state.toggle_selected();
Vec::new()
}
InputKey::Esc => {
if state.turn_active() {
vec![Effect::StructuredCancel { id: sid }]
} else {
Vec::new()
}
}
InputKey::Char {
c: key @ ('a' | 'A' | 'd'),
ctrl: false,
alt: false,
..
} => state
.approval_decision(*key)
.map(|(nonce, decision)| Effect::StructuredApproval {
id: sid,
nonce,
decision,
})
.into_iter()
.collect(),
InputKey::PageUp => {
state.scroll_by(self.pane_page_rows() as isize);
Vec::new()
}
InputKey::PageDown => {
state.scroll_by(-(self.pane_page_rows() as isize));
Vec::new()
}
_ => return None,
};
Some(effects)
}
fn pane_page_rows(&self) -> usize {
self.region
.pane
@@ -1113,14 +839,6 @@ impl App {
let Some(sid) = self.state.active_session() else {
return;
};
if let Some(handle) = self.structured.get(&sid) {
handle
.state
.lock()
.expect("structured state mutex")
.scroll_by(rows);
return;
}
let Some(handle) = self.emulators.get(sid) else {
return;
};
@@ -1154,14 +872,6 @@ impl App {
_ => return None,
};
let sid = self.state.active_session()?;
if let Some(handle) = self.structured.get(&sid) {
handle
.state
.lock()
.expect("structured state mutex")
.scroll_by(lines);
return Some(None);
}
let handle = self.emulators.get(sid)?;
let pane = self.region.pane?;
let parser = handle.screen.lock().expect("vt100 parser mutex");
@@ -1584,10 +1294,7 @@ impl App {
}
self.draft = None;
self.state.complete_spawn(sid);
if session.view == SessionView::Structured {
self.state.mode = Mode::Normal;
}
self.attach_with_view(sid, session.view);
self.attach(sid);
}
Err(e) => {
// A failed POST belongs in the form it came from (#64), not a
@@ -1648,11 +1355,6 @@ impl App {
self.emulators
.get(sid)
.map(|h| (sid, h.revision(), h.state()))
.or_else(|| {
self.structured
.get(&sid)
.map(|h| (sid, h.revision(), h.connection()))
})
});
FrameKey {
catalog: self.catalog.version(),
@@ -1714,17 +1416,6 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
})
});
let structured_pane = shown_sid.and_then(|sid| {
let handle = self.structured.get(&sid)?;
let session = snap.sessions.iter().find(|session| session.id == sid)?;
Some(StructuredPane {
handle: handle.clone(),
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
mode: self.state.mode,
session_status: session.status,
activity_detail: session.activity_detail.clone(),
})
});
let pane_title = match shown_sid {
Some(sid) => {
let name = snap
@@ -1748,7 +1439,6 @@ impl App {
home_dir: &self.home_dir,
pane_title: &pane_title,
pane: pane.as_ref(),
structured_pane: structured_pane.as_ref(),
state: &self.state,
draft: self.draft.as_ref(),
help: &self.help,
@@ -1995,12 +1685,6 @@ async fn fetch_branches(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_payload_uses_standard_base64() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode("✓".as_bytes()), "4pyT");
}
use crate::action::Action;
use crate::draft::DraftField;
use crate::keymap::Mode;
@@ -2198,7 +1882,6 @@ mod tests {
sessions: ids
.iter()
.map(|id| Session {
view: Default::default(),
id: *id,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -2215,7 +1898,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
})
.collect(),
});
@@ -2381,7 +2064,6 @@ mod tests {
let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -2398,7 +2080,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -2410,7 +2092,6 @@ mod tests {
use chrono::Utc;
use maestro_protocol::{Session, SessionStatus};
Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: cwd.into(),
@@ -2427,7 +2108,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}
}
@@ -3099,13 +2780,11 @@ mod tests {
);
app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest {
view: Default::default(),
cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
app.submit_spawn(req).await;
@@ -3122,56 +2801,6 @@ mod tests {
);
}
#[tokio::test]
async fn create_response_selects_transport_before_catalog_event() {
use axum::routing::post;
use axum::Json;
async fn spawn_session(
Json(req): Json<CreateSessionRequest>,
) -> Json<maestro_protocol::Session> {
let id = match req.view {
SessionView::Terminal => Uuid::from_u128(187),
SessionView::Structured => Uuid::from_u128(188),
};
let mut session = test_session(id, req.cwd.to_str().unwrap());
session.view = req.view;
session.command = req.command;
Json(session)
}
let router =
axum::Router::new().route(maestro_protocol::routes::SESSIONS, post(spawn_session));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let mut app = make_app_at(&base);
let request = |view| CreateSessionRequest {
view,
cwd: std::path::PathBuf::from("/srv/work"),
command: "codex".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
auto_approve: false,
};
let structured_id = Uuid::from_u128(188);
app.state.mode = Mode::Insert;
app.submit_spawn(request(SessionView::Structured)).await;
assert!(app.catalog.snapshot().sessions.is_empty());
assert!(app.structured.contains_key(&structured_id));
assert!(app.emulators.get(structured_id).is_none());
assert_eq!(app.state.mode, Mode::Normal);
let terminal_id = Uuid::from_u128(187);
app.submit_spawn(request(SessionView::Terminal)).await;
assert!(app.catalog.snapshot().sessions.is_empty());
assert!(app.emulators.get(terminal_id).is_some());
assert!(!app.structured.contains_key(&terminal_id));
}
/// The full data load against a live daemon stub: presets replace the
/// built-ins, projects land sorted, and the first generated name seeds
/// the field.
@@ -3569,7 +3198,6 @@ mod tests {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
view: Default::default(),
id: Uuid::from_u128(n),
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -3589,7 +3217,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: focused,
acp_session_id: None,
agent_session_id: None,
};
let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3851,7 +3479,6 @@ mod tests {
let sid = Uuid::from_u128(7);
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -3869,7 +3496,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}],
});
app.dispatch(Action::KillHighlighted);
@@ -3889,7 +3516,6 @@ mod tests {
};
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -3906,7 +3532,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}],
});
}
@@ -4064,112 +3690,4 @@ mod tests {
assert!(app.command_bar.is_none());
assert!(app.state.overlay().is_none());
}
fn structured_app(sid: Uuid) -> App {
let mut app = app_with_highlighted_session(sid);
let mut session = app.catalog.snapshot().sessions[0].clone();
session.view = SessionView::Structured;
app.catalog
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
app.structured.insert(
sid,
StructuredHandle::test_handle(crate::structured_task::StructuredState::default()),
);
app.state.attach_to_focused(sid);
app
}
#[tokio::test]
async fn session_view_selects_the_structured_connection_and_cleanup_stops_it() {
let sid = Uuid::from_u128(141);
let mut app = app_with_highlighted_session(sid);
let mut session = app.catalog.snapshot().sessions[0].clone();
session.view = SessionView::Structured;
app.catalog
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
app.attach(sid);
assert!(app.structured.contains_key(&sid));
assert!(app.emulators.get(sid).is_none());
let handle = app.structured.get(&sid).unwrap().clone();
app.release_session(sid);
assert_eq!(
handle.connection(),
crate::session_task::SessionState::Ended
);
}
#[test]
fn structured_normal_mode_routes_navigation_expansion_and_approval_by_row() {
use maestro_protocol::{ReducedState, TranscriptRow, TranscriptRowKind};
use serde_json::json;
let sid = Uuid::from_u128(141);
let mut app = structured_app(sid);
app.dispatch(Action::AttachHighlighted);
assert_eq!(app.state.focus, Focus::Pane);
assert_eq!(app.state.mode, Mode::Normal);
let handle = app.structured.get(&sid).unwrap().clone();
{
let mut state = handle.state.lock().unwrap();
state.rows = vec![
TranscriptRow {
id: "text".into(),
kind: TranscriptRowKind::AgentText,
data: json!({"content":{"type":"text","text":"hello"}}),
},
TranscriptRow {
id: "approval".into(),
kind: TranscriptRowKind::Approval,
data: json!({"options":[{"kind":"allow_once"}]}),
},
];
state.selected = Some("text".into());
state.reduced = ReducedState {
pending_approval: Some(json!({"nonce":"n"})),
..ReducedState::default()
};
}
assert_eq!(
app.handle_structured_key(&InputKey::char('j')),
Some(vec![])
);
assert_eq!(
app.handle_structured_key(&InputKey::char('a')),
Some(vec![Effect::StructuredApproval {
id: sid,
nonce: "n".into(),
decision: StructuredApprovalDecision::AllowOnce,
}])
);
assert_eq!(
handle.state.lock().unwrap().selected.as_deref(),
Some("approval")
);
}
#[test]
fn structured_insert_mode_edits_submits_and_cancels_without_pty_bytes() {
let sid = Uuid::from_u128(141);
let mut app = structured_app(sid);
assert!(app.emulators.get(sid).is_none());
app.forward_to_session(&InputKey::char('h'));
app.forward_to_session(&InputKey::char('i'));
assert_eq!(
app.forward_to_session(&InputKey::Enter),
Some(Effect::StructuredPrompt {
id: sid,
prompt: "hi".into(),
})
);
assert_eq!(
app.handle_structured_key(&InputKey::Esc),
Some(vec![Effect::StructuredCancel { id: sid }])
);
assert_eq!(
app.forward_literal_to_terminal(&app.cfg.leader.clone()),
None
);
}
}
+1 -2
View File
@@ -207,7 +207,6 @@ mod tests {
fn session(id: u128, command: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(id),
command: command.into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -224,7 +223,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}
}
+22 -4
View File
@@ -194,7 +194,9 @@ pub enum BarOutcome {
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
let mut ordered: Vec<&Session> = sessions.iter().collect();
ordered.sort_by(|a, b| {
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead);
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant
// (#162) — sorts after the sessions you could actually attach to.
let dead = |s: &Session| u8::from(s.status != SessionStatus::Running);
dead(a)
.cmp(&dead(b))
.then_with(|| {
@@ -319,7 +321,7 @@ impl CommandBar {
.map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id),
dead: s.status == SessionStatus::Dead,
dead: s.status != SessionStatus::Running,
chords: Vec::new(),
})
.collect();
@@ -793,7 +795,6 @@ mod tests {
fn session(n: u128, command: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(n),
cwd: PathBuf::from("/home/me/dev/api"),
command: command.to_string(),
@@ -810,7 +811,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}
}
@@ -891,6 +892,23 @@ mod tests {
assert!(bar.rows()[1].dead);
}
/// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by
/// which way the pane went away — a dormant session sorts with the dead.
#[test]
fn dormant_sessions_sort_after_live_ones_however_recent() {
let mut dormant = named(1, "dormant");
dormant.status = SessionStatus::Dormant;
dormant.last_focused_at = Some(at(99));
let live = named(2, "live");
let bar = CommandBar::new(&[dormant, live], &[], "/home/me");
assert_eq!(
ids(bar.rows()),
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
);
assert!(bar.rows()[1].dead);
}
#[test]
fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new(
+16 -382
View File
@@ -41,10 +41,8 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
use maestro_protocol::{
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
@@ -88,11 +86,9 @@ pub enum DraftField {
/// apply immediately, mirroring the web form's per-click choice.
WorktreeChoice,
Name,
/// Disclosure collapsing the optional view and worktree controls below.
/// Disclosure collapsing the worktree/branch controls below (#93).
/// Closed by default; Enter / Space toggles.
Advanced,
/// ACP-backed transcript instead of a terminal. Claude Code or Codex.
StructuredToggle,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
/// New-vs-existing branch source; Left / Right switches.
@@ -255,14 +251,6 @@ pub struct DraftPane {
/// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target.
pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Supported only when the resolved
/// command is Claude Code or Codex, and on by default whenever it is —
/// until the operator works the toggle themselves.
pub structured_view: bool,
/// Set once the operator toggles structured view by hand. From then on a
/// command change never re-enables what they turned off, the same guard
/// `name_touched` gives the name field.
pub structured_view_touched: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>,
@@ -303,7 +291,7 @@ impl Default for DraftPane {
// while its data loads, and stay usable when the daemon reports none.
let mut presets = builtin_presets();
presets.push(custom_preset());
let mut draft = Self {
Self {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets,
selected_preset: 0,
@@ -323,8 +311,6 @@ impl Default for DraftPane {
name_touched: false,
worktree_enabled: true,
advanced_collapsed: true,
structured_view: false,
structured_view_touched: false,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
@@ -343,11 +329,7 @@ impl Default for DraftPane {
// straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker,
home_dir: String::new(),
};
// The built-in tile the form opens on decides whether structured view
// starts ticked, exactly as a later preset list would.
draft.sync_structured_view();
draft
}
}
}
@@ -366,7 +348,6 @@ impl DraftPane {
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
self.sync_structured_view();
}
/// Land a seeded command on the tile that runs it: its own preset when the
@@ -682,28 +663,6 @@ impl DraftPane {
}
}
fn supports_structured_view(&self) -> bool {
supports_structured_view(self.command())
}
/// Re-resolve the toggle after the command changed: an unsupported
/// command forces it off, a supported one turns it back on unless the
/// operator has already made the choice themselves.
fn sync_structured_view(&mut self) {
if !self.supports_structured_view() {
self.structured_view = false;
} else if !self.structured_view_touched {
self.structured_view = true;
}
}
pub fn toggle_structured_view(&mut self) {
if self.supports_structured_view() {
self.structured_view = !self.structured_view;
self.structured_view_touched = true;
}
}
/// The chosen working directory: a picked project row, else the raw path
/// input when it holds something.
pub fn cwd(&self) -> Option<PathBuf> {
@@ -734,22 +693,12 @@ impl DraftPane {
);
}
let worktree = self.build_worktree()?;
let auto_approve = self
.presets
.get(self.selected_preset)
.is_some_and(|p| p.auto_approve);
Ok(CreateSessionRequest {
view: if self.structured_view && self.supports_structured_view() {
SessionView::Structured
} else {
SessionView::Terminal
},
cwd,
command: command.to_string(),
env: Default::default(),
metadata,
worktree,
auto_approve,
})
}
@@ -804,9 +753,6 @@ impl DraftPane {
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
if self.supports_structured_view() {
order.push(DraftField::StructuredToggle);
}
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
@@ -848,7 +794,6 @@ impl DraftPane {
}
self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
self.sync_structured_view();
}
/// Step-one boxes surviving the type-to-filter, in display order. A
@@ -1055,9 +1000,9 @@ impl DraftPane {
// Two fields keep their own Enter instead: `DirPicker`, whose
// highlighted row isn't the picked one until a first Enter chooses
// it (the next Enter, now that it's active, falls through to
// submit); and the disclosure/checkbox fields, which activate on
// Enter like buttons rather than submitting, matching the web
// form controls.
// submit); and the disclosure/toggle pair (`Advanced`,
// `WorktreeToggle`), which activate on Enter like a button rather
// than submitting, same as the web form's own `<button>` controls.
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
let is_other_row = self.selection() >= self.visible_groups().len();
let choose_only =
@@ -1077,10 +1022,6 @@ impl DraftPane {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
self.toggle_structured_view();
DraftInputResult::Consumed
}
InputKey::Enter => DraftInputResult::Submit,
InputKey::Char {
c: ' ',
@@ -1098,14 +1039,6 @@ impl DraftPane {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Char {
c: ' ',
ctrl: false,
..
} if self.focused_field == DraftField::StructuredToggle => {
self.toggle_structured_view();
DraftInputResult::Consumed
}
InputKey::Up if self.focused_field == DraftField::DirPicker => {
self.move_selection(-1);
DraftInputResult::Consumed
@@ -1149,10 +1082,7 @@ impl DraftPane {
other => {
match self.focused_field {
DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => {
self.custom_command.handle_key(other);
self.sync_structured_view();
}
DraftField::CustomCommand => self.custom_command.handle_key(other),
// Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match.
DraftField::DirPicker => {
@@ -1190,9 +1120,9 @@ impl DraftPane {
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
// Optional view and worktree controls stay behind the "Advanced
// options" disclosure (#93), regardless of what they'd otherwise show.
// The worktree choice step, the worktree toggle and its body all stay
// behind the "Advanced options" disclosure (#93): collapsed hides
// them, regardless of what they'd otherwise show.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice =
@@ -1211,7 +1141,6 @@ impl DraftPane {
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
+ u16::from(show_custom)
+ u16::from(show_raw)
+ u16::from(show_structured)
+ u16::from(show_wt_toggle)
+ u16::from(show_wt_choice) * wt_choice_capacity
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
@@ -1231,9 +1160,6 @@ impl DraftPane {
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_structured {
constraints.push(Constraint::Length(1));
}
if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
@@ -1432,24 +1358,6 @@ impl DraftPane {
advanced_row,
);
if show_structured {
let row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "view:")),
Span::styled(
if self.structured_view {
"[x] structured view"
} else {
"[ ] structured view"
},
field_style(DraftField::StructuredToggle),
),
])),
row,
);
}
if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
@@ -1797,7 +1705,7 @@ mod tests {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
resume_command: None,
}]);
assert_eq!(
draft.command(),
@@ -1820,13 +1728,13 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
resume_command: None,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
resume_command: None,
},
]);
assert_eq!(draft.command(), "aider");
@@ -1846,7 +1754,7 @@ mod tests {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
resume_command: None,
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
@@ -1912,280 +1820,6 @@ mod tests {
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
}
#[test]
fn structured_view_is_default_on_and_visible_for_supported_agents() {
let mut draft = DraftPane {
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
SpawnPreset {
id: "opencode".into(),
label: "OpenCode".into(),
command: "opencode".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view, "on by default for a supported agent");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("[x] structured view"), "{rendered}");
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
draft.structured_view,
"Codex keeps the structured selection"
);
draft.cycle_preset(1);
assert_eq!(draft.command(), "opencode");
assert!(
!draft.structured_view,
"a hidden value must not stay checked"
);
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(!rendered.contains("structured view"), "{rendered}");
}
#[test]
fn structured_view_request_is_guarded_against_a_stale_command() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
structured_view: true,
..DraftPane::default()
};
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
draft.selected_preset = 1;
assert_eq!(draft.command(), "opencode");
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// A custom command typed into the free-text field, run through the same
/// resolver the daemon/web gate uses (`command_tool`, #199): a path, an
/// env prefix, or a wrapper still resolves to Claude Code or Codex, so
/// the toggle stays offered and the request carries `Structured`; an
/// unrelated program does not.
#[test]
fn structured_view_follows_command_resolution_for_custom_commands() {
let cases: &[(&str, bool)] = &[
("claude", true),
("claude --resume", true),
("CLAUDE", true),
("/usr/local/bin/claude -p hi", true),
("./bin/claude", true),
("FOO=1 BAR=2 claude", true),
("npx claude-code", true),
("npx --yes claude-code", true),
("sudo claude", true),
("sudo -u root claude", true),
("env -i claude", true),
("nohup claude", true),
("time claude", true),
("command claude", true),
("pnpm --silent dlx claude-code", true),
("yarn --silent dlx claude-code", true),
("bun x claude-code", true),
("\"/opt/my agent/claude\" --resume", true),
("codex", true),
("codex --full-auto", true),
("CODEX=1 codex", true),
("/usr/local/bin/codex exec", true),
("npx codex", true),
("sudo -u root codex", true),
("env -i codex", true),
("command codex", true),
("pnpm --silent dlx codex", true),
("\"/opt/my agent/codex\" exec", true),
("bash", false),
("opencode", false),
("/usr/local/bin/bash -l", false),
// A package-manager wrapper without `dlx`/`x` runs a
// `package.json` script, not the package (#199) — must not be
// mistaken for a Claude launch.
("pnpm claude", false),
("yarn claude", false),
// The daemon's structured-session child, never a valid
// user-facing launch form (#199).
("claude-code-acp", false),
];
for &(command, supported) in cases {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value(command);
draft.sync_structured_view();
assert_eq!(
draft.field_order().contains(&DraftField::StructuredToggle),
supported,
"toggle visibility for {command:?}"
);
assert_eq!(
draft.structured_view, supported,
"default selection for {command:?}"
);
let view = draft.build_request().unwrap().view;
assert_eq!(
view,
if supported {
SessionView::Structured
} else {
SessionView::Terminal
},
"request view for {command:?}"
);
draft.toggle_structured_view();
assert!(
!draft.structured_view,
"the toggle turns the default off for {command:?}"
);
}
}
/// A configured preset (the spawn-form "alias" for a command) is gated
/// the same way as free text: a preset whose command resolves to Claude
/// Code or Codex through a wrapper still offers structured view.
#[test]
fn structured_view_follows_command_resolution_for_presets() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude-wrapped".into(),
label: "Claude (npx)".into(),
command: "npx claude-code --resume".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex-wrapped".into(),
label: "Codex (npx)".into(),
command: "npx codex exec".into(),
auto_approve: false,
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
auto_approve: false,
},
]);
assert_eq!(draft.command(), "npx claude-code --resume");
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching between supported presets preserves the selection.
draft.cycle_preset(1);
assert_eq!(draft.command(), "npx codex exec");
assert!(draft.structured_view);
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
// Switching to a preset that does not resolve to an ACP agent clears the
// stale selection instead of letting an invalid request through.
draft.cycle_preset(1);
assert_eq!(draft.command(), "aider");
assert!(!draft.structured_view, "stale selection must clear");
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// The default only applies until the operator has an opinion: once they
/// untick the box, moving between supported commands leaves it unticked
/// instead of turning it back on under them.
#[test]
fn an_unticked_structured_view_survives_a_command_change() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
auto_approve: false,
},
SpawnPreset {
id: "codex".into(),
label: "Codex".into(),
command: "codex".into(),
auto_approve: false,
},
]);
assert!(draft.structured_view);
draft.toggle_structured_view();
assert!(!draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "codex");
assert!(
!draft.structured_view,
"the operator's choice outranks the default"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
/// Editing the custom-command text away from a supported agent clears an armed
/// selection immediately, matching the preset-switch case above — an
/// invalid `Structured` request can never reach `build_request`.
#[test]
fn structured_view_clears_when_custom_command_edited_away_from_supported_agent() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
advanced_collapsed: false,
focused_field: DraftField::CustomCommand,
..DraftPane::default()
};
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("codex");
draft.sync_structured_view();
assert!(draft.structured_view);
typed(&mut draft, " -p");
assert_eq!(draft.command(), "codex -p");
assert!(draft.structured_view, "still Codex, selection survives");
for _ in 0.."codex -p".chars().count() {
draft.on_input(&InputKey::Backspace);
}
typed(&mut draft, "bash");
assert_eq!(draft.command(), "bash");
assert!(
!draft.structured_view,
"editing the program away from Codex clears the stale selection"
);
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
#[test]
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
let mut draft = DraftPane {
+1 -3
View File
@@ -108,7 +108,6 @@ impl FastForwardDefaults {
return None;
}
Some(CreateSessionRequest {
view: Default::default(),
cwd: project.path.clone(),
command: command.to_string(),
env: Default::default(),
@@ -121,7 +120,6 @@ impl FastForwardDefaults {
branch: handle.to_string(),
create_branch: true,
}),
auto_approve: false,
})
}
@@ -185,7 +183,7 @@ mod tests {
id: id.into(),
label: id.into(),
command: command.into(),
auto_approve: false,
resume_command: None,
}
}
-7
View File
@@ -55,7 +55,6 @@ pub enum MouseKind {
Click,
DblClick,
Drag,
Hover,
ScrollUp,
ScrollDown,
}
@@ -149,7 +148,6 @@ impl InputKey {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::Hover => "hover",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
@@ -180,7 +178,6 @@ impl InputKey {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"hover" => MouseKind::Hover,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())),
@@ -300,7 +297,6 @@ where
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::Moved => MouseKind::Hover,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
@@ -685,9 +681,6 @@ mod tests {
let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:hover(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
}
#[test]
-2
View File
@@ -23,8 +23,6 @@ pub mod render;
pub mod session_task;
pub mod sidebar;
pub mod state;
pub mod structured_pane;
pub mod structured_task;
pub mod terminal;
pub mod text_input;
+6 -30
View File
@@ -18,7 +18,6 @@ use crate::palette;
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::structured_pane::StructuredPane;
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
@@ -81,7 +80,6 @@ pub struct RenderInputs<'a> {
pub home_dir: &'a str,
pub pane_title: &'a str,
pub pane: Option<&'a Pane>,
pub structured_pane: Option<&'a StructuredPane>,
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
@@ -149,22 +147,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
} else if let Some(p) = inputs.structured_pane {
p.render(f, pane_area);
}
// `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the active session pane.
// chrome; every remaining cell belongs to the emulator.
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
// always see the current mode + key hints.
render_status_bar(
f,
status_area,
inputs.state,
inputs.link,
inputs.structured_pane.is_some(),
);
render_status_bar(f, status_area, inputs.state, inputs.link);
match inputs.state.overlay() {
Some(OverlayKind::Spawn) => {
@@ -263,13 +253,7 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
/// Render the always-visible status bar at the bottom of the frame. Surfaces
/// mode + focused panel + the most useful key hints so the user is never
/// guessing what state they're in.
fn render_status_bar(
f: &mut Frame,
area: Rect,
state: &AppState,
link: &LinkStatus,
structured: bool,
) {
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
// The daemon indicator is pinned right and sized to its own text, so a
// narrow terminal truncates the key hints rather than the release.
let label = link_label(link);
@@ -287,13 +271,9 @@ fn render_status_bar(
Focus::Sidebar => "sidebar",
Focus::Pane => "pane",
};
let hint = match (structured && state.focus == Focus::Pane, state.mode) {
(true, Mode::Normal) => "j/k: rows • enter: expand • i: compose • esc: cancel",
(true, Mode::Insert) => "enter: send • esc: cancel • ^a: sidebar",
(false, Mode::Normal) => {
"j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit"
}
(false, Mode::Insert) => "^a: return to sidebar",
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
Mode::Insert => "^a: return to sidebar",
};
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
let line = ratatui::text::Line::from(vec![
@@ -455,7 +435,6 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: None,
state: &state,
help: &help,
@@ -553,7 +532,6 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: Some(&draft),
state: &state,
help: &help,
@@ -609,7 +587,6 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: None,
state: &state,
help: &help,
@@ -652,7 +629,6 @@ mod tests {
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: None,
state: &state,
help: &help,
+1 -1
View File
@@ -221,7 +221,7 @@ impl SessionTask {
}
}
pub(crate) fn jitter(base: Duration) -> Duration {
fn jitter(base: Duration) -> Duration {
let ms = base.as_millis() as u64;
let now_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+41 -4
View File
@@ -268,6 +268,8 @@ impl Sidebar {
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
// #162: known from its sidecar, nothing running behind it.
(SessionStatus::Dormant, _) => "◌",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
@@ -322,6 +324,7 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let dormant = s.status == SessionStatus::Dormant;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused);
let base = card_base(fill, focused);
@@ -361,6 +364,9 @@ impl Sidebar {
if dead {
tail.push(Span::styled(" exited", muted));
}
if dormant {
tail.push(Span::styled(" dormant", muted));
}
// docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
@@ -484,7 +490,6 @@ mod tests {
let mut metadata = HashMap::new();
metadata.insert("name".into(), serde_json::Value::String(name.into()));
Session {
view: Default::default(),
id: Uuid::from_u128(id),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -501,7 +506,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
}
}
@@ -1070,6 +1075,39 @@ mod tests {
);
}
/// #162, docs/SIDEBAR.md: a dormant session is known but not running —
/// its own glyph on line 1 and the muted `dormant` fragment on line 2, so
/// it never reads as something that exited.
#[test]
fn dormant_session_reads_as_known_but_not_running() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dormant;
s.activity_since = Utc::now() - Duration::seconds(5);
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
pane_session: None,
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
assert!(
all[idx].contains('\u{25cc}'),
"expected the dormant glyph in: {:?}",
all[idx]
);
assert!(
all[idx + 1].contains("dormant"),
"expected dormant fragment in: {:?}",
all[idx + 1]
);
assert!(
!all[idx + 1].contains("exited"),
"a dormant session never reads as exited: {:?}",
all[idx + 1]
);
}
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
@@ -1252,7 +1290,6 @@ mod tests {
#[test]
fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session {
view: Default::default(),
id: Uuid::from_u128(1),
command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -1269,7 +1306,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
agent_session_id: None,
};
let sb = Sidebar {
sessions: vec![s],
File diff suppressed because it is too large Load Diff
-773
View File
@@ -1,773 +0,0 @@
//! Live projection state for one structured session.
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::StreamExt;
use maestro_protocol::{
ReducedState, StructuredApprovalDecision, StructuredEvent, TranscriptDelta, TranscriptRow,
TranscriptRowKind,
};
use tokio::sync::watch;
use tokio_tungstenite::tungstenite::{Error as WsError, Message};
use crate::input::InputKey;
use crate::session_task::SessionState;
use crate::text_input::TextInput;
/// Renderer and input state built directly from the daemon's two projections.
#[derive(Debug, Clone)]
pub struct StructuredState {
pub rows: Vec<TranscriptRow>,
pub reduced: ReducedState,
pub selected: Option<String>,
pub expanded: HashSet<String>,
pub scroll: usize,
/// Row id per rendered line from the last render pass, parallel to that
/// pass's `lines`. Lets the next render translate `scroll` — a raw index
/// into that now-stale layout — back into a row identity before mapping
/// it onto the freshly rebuilt layout, so growth/shrinkage above the
/// viewport shifts indices without shifting what's on screen.
pub(crate) line_rows: Vec<Option<String>>,
pub follow_selection: bool,
pub composer: TextInput,
pub error: Option<String>,
submitting: bool,
submission_generation: u64,
reconcile_submission_on_snapshot: Option<u64>,
approval_submitting: bool,
}
impl Default for StructuredState {
fn default() -> Self {
Self {
rows: Vec::new(),
reduced: ReducedState::default(),
selected: None,
expanded: HashSet::new(),
scroll: 0,
line_rows: Vec::new(),
follow_selection: true,
composer: TextInput::default(),
error: None,
submitting: false,
submission_generation: 0,
reconcile_submission_on_snapshot: None,
approval_submitting: false,
}
}
}
impl StructuredState {
pub fn apply(&mut self, event: StructuredEvent) {
match event {
StructuredEvent::TranscriptSnapshot { snapshot } => {
let selected = self.selected.clone();
self.rows = snapshot.rows;
self.selected = selected
.filter(|id| self.rows.iter().any(|row| &row.id == id))
.or_else(|| {
self.rows
.iter()
.rev()
.find(|row| row.kind != TranscriptRowKind::Plan)
.map(|row| row.id.clone())
});
self.expanded
.retain(|id| self.rows.iter().any(|row| &row.id == id));
}
StructuredEvent::ReducedState { state } => {
let reconcile_submission = self
.reconcile_submission_on_snapshot
.take()
.is_some_and(|generation| {
self.submitting && generation == self.submission_generation
});
self.apply_reduced(state, reconcile_submission);
}
StructuredEvent::ProjectionUpdate { transcript, state } => {
if let Some(delta) = transcript {
self.apply_delta(delta);
}
self.apply_reduced(state, false);
}
}
}
fn apply_delta(&mut self, delta: TranscriptDelta) {
match delta {
TranscriptDelta::Append { row } => {
let followed_tail = self.follow_selection
&& self.selected.as_ref().is_some_and(|id| {
self.rows
.iter()
.rev()
.find(|old| old.kind != TranscriptRowKind::Plan)
.is_some_and(|old| &old.id == id)
});
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
self.rows[index] = row.clone();
} else {
self.rows.push(row.clone());
}
if row.kind != TranscriptRowKind::Plan && (self.selected.is_none() || followed_tail)
{
self.selected = Some(row.id);
}
}
TranscriptDelta::Patch { row } => {
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
self.rows[index] = row.clone();
} else {
self.rows.push(row.clone());
}
}
TranscriptDelta::Remove { id } => {
let removed = self.rows.iter().position(|row| row.id == id);
self.rows.retain(|row| row.id != id);
self.expanded.remove(&id);
if self.selected.as_deref() == Some(&id) {
self.selected = removed.and_then(|index| {
let split = index.min(self.rows.len());
self.rows[split..]
.iter()
.find(|row| row.kind != TranscriptRowKind::Plan)
.or_else(|| {
self.rows[..split]
.iter()
.rev()
.find(|row| row.kind != TranscriptRowKind::Plan)
})
.map(|row| row.id.clone())
});
}
}
}
}
fn apply_reduced(&mut self, state: ReducedState, reconcile_submission: bool) {
let previous_nonce = self.pending_nonce().map(str::to_owned);
if reconcile_submission || state.turn.responding || state.turn.thinking {
self.submitting = false;
}
self.reduced = state;
let next_nonce = self.pending_nonce().map(str::to_owned);
if next_nonce != previous_nonce {
self.approval_submitting = false;
if next_nonce.is_some() {
self.selected = self
.rows
.iter()
.rev()
.find(|row| row.kind == TranscriptRowKind::Approval)
.map(|row| row.id.clone());
self.follow_selection = true;
}
}
}
fn prepare_snapshot_reconciliation(&mut self) {
self.reconcile_submission_on_snapshot =
self.submitting.then_some(self.submission_generation);
}
pub fn select(&mut self, delta: isize) {
let selectable: Vec<&TranscriptRow> = self
.rows
.iter()
.filter(|row| row.kind != TranscriptRowKind::Plan)
.collect();
if selectable.is_empty() {
self.selected = None;
return;
}
let current = self
.selected
.as_ref()
.and_then(|id| selectable.iter().position(|row| &row.id == id))
.unwrap_or(selectable.len() - 1);
let next = if delta >= 0 {
current
.saturating_add(delta as usize)
.min(selectable.len() - 1)
} else {
current.saturating_sub(delta.unsigned_abs())
};
self.selected = Some(selectable[next].id.clone());
self.follow_selection = true;
}
/// Select the transcript block under a rendered transcript line. Pointer
/// input uses the same row identity map as scroll reconciliation, so a
/// wrapped block behaves as one target.
pub fn select_rendered_line(&mut self, line: usize) -> bool {
let Some(id) = self.line_rows.get(line).and_then(Clone::clone) else {
return false;
};
self.selected = Some(id);
self.follow_selection = false;
true
}
pub fn toggle_selected(&mut self) {
let Some(id) = self.selected.clone() else {
return;
};
let expandable = self.rows.iter().any(|row| {
row.id == id
&& matches!(
row.kind,
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff
)
});
if !expandable {
return;
}
if !self.expanded.insert(id.clone()) {
self.expanded.remove(&id);
}
self.follow_selection = true;
}
pub fn scroll_by(&mut self, lines: isize) {
self.scroll = if lines >= 0 {
self.scroll.saturating_sub(lines as usize)
} else {
self.scroll.saturating_add(lines.unsigned_abs())
};
self.follow_selection = false;
}
pub fn edit_composer(&mut self, key: &InputKey) {
if !self.turn_active() {
self.composer.handle_key(key);
self.error = None;
}
}
pub fn begin_prompt(&mut self) -> Option<String> {
if self.turn_active() || self.composer.value().trim().is_empty() {
return None;
}
let prompt = self.composer.value().to_string();
self.composer = TextInput::default();
self.submission_generation = self.submission_generation.wrapping_add(1);
self.submitting = true;
self.error = None;
Some(prompt)
}
pub fn prompt_failed(&mut self, prompt: String, error: String) {
if self.composer.value().is_empty() {
self.composer = TextInput::with_value(prompt);
}
self.submitting = false;
self.error = Some(error);
}
pub fn turn_active(&self) -> bool {
self.submitting || self.reduced.turn.responding || self.reduced.turn.thinking
}
pub fn pending_nonce(&self) -> Option<&str> {
self.reduced
.pending_approval
.as_ref()?
.get("nonce")?
.as_str()
}
pub fn approval_decision(&mut self, key: char) -> Option<(String, StructuredApprovalDecision)> {
if self.approval_submitting {
return None;
}
let selected = self.selected.as_deref()?;
let pending_row = self
.rows
.iter()
.rev()
.find(|row| row.kind == TranscriptRowKind::Approval)?;
if pending_row.id != selected {
return None;
}
let decision = match key {
'a' => StructuredApprovalDecision::AllowOnce,
'A' => StructuredApprovalDecision::AllowForSession,
'd' => StructuredApprovalDecision::Deny,
_ => return None,
};
let offered = offered_decisions(&pending_row.data);
if !offered.contains(&decision) {
return None;
}
let nonce = self.pending_nonce()?.to_string();
self.approval_submitting = true;
self.error = None;
Some((nonce, decision))
}
pub fn approval_failed(&mut self, nonce: &str, error: String) {
if self.pending_nonce() != Some(nonce) {
return;
}
self.approval_submitting = false;
self.error = Some(error);
}
}
pub fn offered_decisions(data: &serde_json::Value) -> Vec<StructuredApprovalDecision> {
let mut out = Vec::new();
for kind in data
.get("options")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|option| option.get("kind").and_then(serde_json::Value::as_str))
{
match kind {
"allow_once" if !out.contains(&StructuredApprovalDecision::AllowOnce) => {
out.push(StructuredApprovalDecision::AllowOnce)
}
"allow_always" if !out.contains(&StructuredApprovalDecision::AllowForSession) => {
out.push(StructuredApprovalDecision::AllowForSession)
}
"reject_once" | "reject_always" if !out.contains(&StructuredApprovalDecision::Deny) => {
out.push(StructuredApprovalDecision::Deny)
}
_ => {}
}
}
out
}
/// Cheap shared handle for a structured projection connection.
#[derive(Clone)]
pub struct StructuredHandle {
pub state: Arc<Mutex<StructuredState>>,
connection: Arc<AtomicU8>,
revision: Arc<AtomicU64>,
stop: watch::Sender<bool>,
}
impl std::fmt::Debug for StructuredHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StructuredHandle")
.field("connection", &self.connection())
.field("revision", &self.revision())
.finish_non_exhaustive()
}
}
impl StructuredHandle {
pub fn connection(&self) -> SessionState {
match self.connection.load(Ordering::Acquire) {
0 => SessionState::Connecting,
1 => SessionState::Live,
2 => SessionState::Reconnecting,
_ => SessionState::Ended,
}
}
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
pub fn end(&self) {
self.connection
.store(SessionState::Ended as u8, Ordering::Release);
let _ = self.stop.send(true);
}
#[cfg(test)]
pub(crate) fn test_handle(state: StructuredState) -> Self {
let (stop, _) = watch::channel(false);
Self {
state: Arc::new(Mutex::new(state)),
connection: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
stop,
}
}
}
#[derive(Debug)]
pub struct StructuredTask;
impl StructuredTask {
pub fn spawn(ws_url: String) -> StructuredHandle {
let state = Arc::new(Mutex::new(StructuredState::default()));
let connection = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
let revision = Arc::new(AtomicU64::new(0));
let (stop, mut stopped) = watch::channel(false);
let handle = StructuredHandle {
state: state.clone(),
connection: connection.clone(),
revision: revision.clone(),
stop,
};
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
loop {
if *stopped.borrow() {
return;
}
state
.lock()
.expect("structured state mutex")
.prepare_snapshot_reconciliation();
connection.store(SessionState::Connecting as u8, Ordering::Release);
let connected = tokio::select! {
result = tokio_tungstenite::connect_async(&ws_url) => result,
_ = stopped.changed() => return,
};
let (mut ws, _) = match connected {
Ok(value) => value,
Err(error) => {
if terminal_handshake_error(&error) {
state.lock().expect("structured state mutex").error =
Some(format!("cannot open structured session: {error}"));
connection.store(SessionState::Ended as u8, Ordering::Release);
revision.fetch_add(1, Ordering::Release);
return;
}
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
revision.fetch_add(1, Ordering::Release);
tokio::select! {
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
_ = stopped.changed() => return,
}
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
continue;
}
};
backoff = Duration::from_secs(1);
connection.store(SessionState::Live as u8, Ordering::Release);
revision.fetch_add(1, Ordering::Release);
loop {
let frame = tokio::select! {
frame = ws.next() => frame,
_ = stopped.changed() => return,
};
match frame {
Some(Ok(Message::Text(text))) => {
if let Ok(event) = serde_json::from_str::<StructuredEvent>(&text) {
state.lock().expect("structured state mutex").apply(event);
revision.fetch_add(1, Ordering::Release);
}
}
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
_ => {}
}
}
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
revision.fetch_add(1, Ordering::Release);
tokio::select! {
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
_ = stopped.changed() => return,
}
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
}
});
handle
}
}
fn terminal_handshake_error(error: &WsError) -> bool {
matches!(error, WsError::Http(response) if response.status().is_client_error())
}
#[cfg(test)]
mod tests {
use super::*;
use maestro_protocol::{TranscriptSnapshot, TurnState};
use serde_json::json;
fn row(id: &str, kind: TranscriptRowKind, data: serde_json::Value) -> TranscriptRow {
TranscriptRow {
id: id.into(),
kind,
data,
}
}
#[test]
fn snapshots_replace_and_deltas_address_rows_by_id() {
let mut state = StructuredState::default();
state.apply(StructuredEvent::TranscriptSnapshot {
snapshot: TranscriptSnapshot {
cursor: 2,
rows: vec![row("a", TranscriptRowKind::AgentText, json!({}))],
},
});
state.apply(StructuredEvent::ProjectionUpdate {
transcript: Some(TranscriptDelta::Patch {
row: row("a", TranscriptRowKind::AgentText, json!({"n": 2})),
}),
state: ReducedState::default(),
});
state.apply(StructuredEvent::ProjectionUpdate {
transcript: Some(TranscriptDelta::Append {
row: row("b", TranscriptRowKind::Notice, json!({})),
}),
state: ReducedState::default(),
});
assert_eq!(state.rows.len(), 2);
assert_eq!(state.rows[0].data["n"], 2);
state.apply(StructuredEvent::TranscriptSnapshot {
snapshot: TranscriptSnapshot {
cursor: 5,
rows: vec![row("b", TranscriptRowKind::Notice, json!({"fresh": true}))],
},
});
assert_eq!(state.rows.len(), 1);
assert_eq!(state.rows[0].data["fresh"], true);
}
#[test]
fn selection_survives_patch_and_moves_after_remove() {
let mut state = StructuredState {
rows: vec![
row("a", TranscriptRowKind::Notice, json!({})),
row("b", TranscriptRowKind::ToolCall, json!({})),
row("plan", TranscriptRowKind::Plan, json!({})),
],
selected: Some("a".into()),
..StructuredState::default()
};
state.select(1);
assert_eq!(state.selected.as_deref(), Some("b"));
state.toggle_selected();
assert!(state.expanded.contains("b"));
state.apply_delta(TranscriptDelta::Remove { id: "b".into() });
assert_eq!(state.selected.as_deref(), Some("a"));
state.apply_delta(TranscriptDelta::Remove { id: "a".into() });
assert_eq!(
state.selected, None,
"collapsed plan rows are not selectable"
);
}
#[test]
fn approval_keys_only_offer_server_backed_decisions() {
let mut state = StructuredState::default();
state.rows.push(row(
"approval:1",
TranscriptRowKind::Approval,
json!({"options":[
{"kind":"allow_once"}, {"kind":"reject_always"}
]}),
));
state.selected = Some("approval:1".into());
state.reduced.pending_approval = Some(json!({"nonce":"secret"}));
assert_eq!(
state.approval_decision('A'),
None,
"allow-for-session was not offered"
);
assert_eq!(
state.approval_decision('a'),
Some(("secret".into(), StructuredApprovalDecision::AllowOnce))
);
}
#[test]
fn resolved_approval_ignores_its_late_failure() {
let mut state = StructuredState::default();
state.rows.push(row(
"approval:1",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.selected = Some("approval:1".into());
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
assert!(state.approval_decision('a').is_some());
state.apply(StructuredEvent::ProjectionUpdate {
transcript: None,
state: ReducedState::default(),
});
state.approval_failed("old", "response lost".into());
assert_eq!(state.error, None);
assert!(!state.approval_submitting);
}
#[test]
fn superseded_approval_ignores_its_late_failure() {
let mut state = StructuredState::default();
state.rows.push(row(
"approval:1",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.selected = Some("approval:1".into());
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
assert!(state.approval_decision('a').is_some());
state.rows.push(row(
"approval:2",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.apply(StructuredEvent::ProjectionUpdate {
transcript: None,
state: ReducedState {
pending_approval: Some(json!({"nonce":"new"})),
..ReducedState::default()
},
});
assert_eq!(
state.approval_decision('a'),
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
);
state.approval_failed("old", "response lost".into());
assert_eq!(state.error, None);
assert!(state.approval_submitting);
}
#[test]
fn matching_approval_surfaces_its_failure() {
let mut state = StructuredState::default();
state.rows.push(row(
"approval:1",
TranscriptRowKind::Approval,
json!({"options":[{"kind":"allow_once"}]}),
));
state.selected = Some("approval:1".into());
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
assert!(state.approval_decision('a').is_some());
state.approval_failed("current", "request failed".into());
assert_eq!(state.error.as_deref(), Some("request failed"));
assert!(!state.approval_submitting);
}
#[test]
fn composer_is_gated_by_the_projected_turn_lifecycle() {
let mut state = StructuredState::default();
state.edit_composer(&InputKey::char('h'));
state.edit_composer(&InputKey::char('i'));
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
assert!(state.turn_active());
state.prompt_failed("hi".into(), "nope".into());
assert_eq!(state.composer.value(), "hi");
state.reduced.turn = TurnState {
responding: true,
..TurnState::default()
};
state.edit_composer(&InputKey::char('x'));
assert_eq!(state.composer.value(), "hi");
}
#[test]
fn selecting_a_rendered_line_selects_its_whole_block() {
let mut state = StructuredState {
line_rows: vec![
Some("one".into()),
Some("one".into()),
None,
Some("two".into()),
],
..Default::default()
};
assert!(state.select_rendered_line(1));
assert_eq!(state.selected.as_deref(), Some("one"));
assert!(!state.follow_selection);
assert!(!state.select_rendered_line(2));
assert_eq!(state.selected.as_deref(), Some("one"));
}
#[test]
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
let mut state = StructuredState::default();
state.edit_composer(&InputKey::char('h'));
state.edit_composer(&InputKey::char('i'));
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
state.apply(StructuredEvent::ProjectionUpdate {
transcript: None,
state: ReducedState::default(),
});
assert!(
state.turn_active(),
"an ordinary idle update must not reopen the composer before turn begin"
);
state.prepare_snapshot_reconciliation();
state.apply(StructuredEvent::TranscriptSnapshot {
snapshot: TranscriptSnapshot {
cursor: 0,
rows: Vec::new(),
},
});
state.apply(StructuredEvent::ReducedState {
state: ReducedState::default(),
});
assert!(
!state.turn_active(),
"a reconnect snapshot must settle a submission whose whole turn was missed"
);
}
#[test]
fn stale_handshake_state_does_not_settle_a_new_submission() {
let mut state = StructuredState::default();
state.prepare_snapshot_reconciliation();
state.apply(StructuredEvent::TranscriptSnapshot {
snapshot: TranscriptSnapshot {
cursor: 0,
rows: Vec::new(),
},
});
state.edit_composer(&InputKey::char('h'));
state.edit_composer(&InputKey::char('i'));
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
state.apply(StructuredEvent::ReducedState {
state: ReducedState::default(),
});
assert!(
state.turn_active(),
"state captured before this submission must not reopen the composer"
);
}
#[test]
fn failed_submission_reconciliation_does_not_settle_its_successor() {
let mut state = StructuredState::default();
state.edit_composer(&InputKey::char('a'));
assert_eq!(state.begin_prompt().as_deref(), Some("a"));
state.prepare_snapshot_reconciliation();
state.prompt_failed("a".into(), "nope".into());
state.composer = TextInput::with_value("b");
assert_eq!(state.begin_prompt().as_deref(), Some("b"));
state.apply(StructuredEvent::TranscriptSnapshot {
snapshot: TranscriptSnapshot {
cursor: 0,
rows: Vec::new(),
},
});
state.apply(StructuredEvent::ReducedState {
state: ReducedState::default(),
});
assert!(
state.turn_active(),
"a reconnect for the failed submission must not settle its successor"
);
}
}
@@ -1,79 +0,0 @@
//! The TUI's structured actions use the shared typed daemon client; assert the
//! exact route and bare approval decision body at the HTTP boundary.
use std::sync::{Arc, Mutex};
use axum::extract::{Path, State};
use axum::routing::post;
use axum::{Json, Router};
use maestro_protocol::{DaemonClient, StructuredApprovalDecision, StructuredPromptRequest};
use uuid::Uuid;
#[derive(Debug, Default)]
struct Seen {
prompt: Option<String>,
cancelled: bool,
approval: Option<(String, StructuredApprovalDecision)>,
}
type Shared = Arc<Mutex<Seen>>;
async fn prompt(
State(seen): State<Shared>,
Path(_id): Path<Uuid>,
Json(body): Json<StructuredPromptRequest>,
) {
seen.lock().unwrap().prompt = Some(body.prompt);
}
async fn cancel(State(seen): State<Shared>, Path(_id): Path<Uuid>) {
seen.lock().unwrap().cancelled = true;
}
async fn approval(
State(seen): State<Shared>,
Path((_id, nonce)): Path<(Uuid, String)>,
Json(decision): Json<StructuredApprovalDecision>,
) {
seen.lock().unwrap().approval = Some((nonce, decision));
}
#[tokio::test(flavor = "multi_thread")]
async fn prompt_cancel_and_approval_use_structured_api_contract() {
let seen = Shared::default();
let app = Router::new()
.route("/api/sessions/:id/structured/prompt", post(prompt))
.route("/api/sessions/:id/structured/cancel", post(cancel))
.route(
"/api/sessions/:id/structured/approvals/:nonce",
post(approval),
)
.with_state(seen.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let client = DaemonClient::new(format!("http://{addr}"));
let id = Uuid::from_u128(141);
client.send_structured_prompt(id, "ship it").await.unwrap();
client.cancel_structured_turn(id).await.unwrap();
client
.resolve_structured_approval(
id,
"one-use-nonce",
StructuredApprovalDecision::AllowForSession,
)
.await
.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen.prompt.as_deref(), Some("ship it"));
assert!(seen.cancelled);
assert_eq!(
seen.approval,
Some((
"one-use-nonce".into(),
StructuredApprovalDecision::AllowForSession
))
);
}
@@ -1,107 +0,0 @@
//! Projection socket coverage: snapshots replace state on reconnect and live
//! deltas patch rows without client-side event folding.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::{Message, WebSocketUpgrade};
use axum::extract::State;
use axum::response::Response;
use axum::routing::any;
use axum::Router;
use maestro_protocol::{
ReducedState, StructuredEvent, TranscriptDelta, TranscriptRow, TranscriptRowKind,
TranscriptSnapshot,
};
use maestro_tui::session_task::SessionState;
use maestro_tui::structured_task::StructuredTask;
use serde_json::json;
async fn structured_handler(
State(connects): State<Arc<AtomicUsize>>,
ws: WebSocketUpgrade,
) -> Response {
ws.on_upgrade(move |mut socket| async move {
let attempt = connects.fetch_add(1, Ordering::SeqCst);
let snapshot = if attempt == 0 {
TranscriptSnapshot {
cursor: 1,
rows: vec![row("old", json!({"content":{"type":"text","text":"old"}}))],
}
} else {
TranscriptSnapshot {
cursor: 3,
rows: vec![row("live", json!({"content":{"type":"text","text":"one"}}))],
}
};
send(
&mut socket,
StructuredEvent::TranscriptSnapshot { snapshot },
)
.await;
if attempt == 0 {
let _ = socket.close().await;
return;
}
send(
&mut socket,
StructuredEvent::ProjectionUpdate {
transcript: Some(TranscriptDelta::Patch {
row: row("live", json!({"content":{"type":"text","text":"two"}})),
}),
state: ReducedState {
model: Some("test-model".into()),
..ReducedState::default()
},
},
)
.await;
tokio::time::sleep(Duration::from_secs(3)).await;
})
}
fn row(id: &str, data: serde_json::Value) -> TranscriptRow {
TranscriptRow {
id: id.into(),
kind: TranscriptRowKind::AgentText,
data,
}
}
async fn send(socket: &mut axum::extract::ws::WebSocket, event: StructuredEvent) {
socket
.send(Message::Text(serde_json::to_string(&event).unwrap()))
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn reconnect_snapshot_replaces_then_delta_patches_projection() {
let connects = Arc::new(AtomicUsize::new(0));
let app = Router::new()
.route("/api/structured/x", any(structured_handler))
.with_state(connects.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let handle = StructuredTask::spawn(format!("ws://{addr}/api/structured/x"));
for _ in 0..250 {
tokio::time::sleep(Duration::from_millis(10)).await;
let state = handle.state.lock().unwrap().clone();
if state.reduced.model.as_deref() == Some("test-model") {
assert_eq!(state.rows.len(), 1);
assert_eq!(state.rows[0].id, "live");
assert_eq!(state.rows[0].data["content"]["text"], "two");
assert!(connects.load(Ordering::SeqCst) >= 2);
handle.end();
assert_eq!(handle.connection(), SessionState::Ended);
return;
}
}
panic!(
"structured projection never reached reconnect delta; connection={:?}",
handle.connection()
);
}
-3
View File
@@ -29,6 +29,3 @@ tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true, default-features = false }
maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false }
maestro-tui = { path = "../maestro-tui", optional = true }
[dev-dependencies]
tempfile.workspace = true
-102
View File
@@ -1,102 +0,0 @@
//! Process-level routing for the umbrella `maestro` binary (#10): each
//! enabled top-level subcommand must reach its own crate's runtime dispatch,
//! not just parse successfully.
//!
//! A malformed `--config` file makes every crate's dispatch fail identically
//! at config load, before any subcommand touches tmux, the network, or a
//! terminal. That lets the assertions tell "clap parsed it but the handler
//! never ran" (a usage error, exit 2) apart from "it dispatched into the real
//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer,
//! exit 1).
//!
//! The absence tests below only compile when the matching feature is off, so
//! they run under CI's per-feature-subset matrix (`--no-default-features
//! --features cli|daemon|tui`) rather than the default all-features build.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_maestro"))
}
fn bad_config() -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("create temp config file");
write!(f, "this is not valid toml {{{{{{").expect("write temp config");
f
}
fn run(args: &[&str]) -> Output {
Command::new(bin())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}"))
}
/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the
/// config crate's error surfacing through — never clap's own usage-error
/// page, which would mean the subcommand never reached its handler.
fn assert_dispatched(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}");
assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}");
assert!(stderr.contains("toml decode"), "stderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
}
/// Never reached a handler: clap rejected the subcommand outright because the
/// feature that defines it is off. Only used by the `not(feature = "...")`
/// tests below, which themselves only exist in a single-feature build.
#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))]
fn assert_unrecognized(out: &Output, name: &str) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}");
assert!(
stderr.contains("unrecognized subcommand"),
"stderr:\n{stderr}"
);
assert!(stderr.contains(name), "stderr:\n{stderr}");
}
#[cfg(feature = "cli")]
#[test]
fn list_dispatches_into_maestro_cli() {
let cfg = bad_config();
let out = run(&["list", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "cli"))]
#[test]
fn list_is_unrecognized_without_cli_feature() {
assert_unrecognized(&run(&["list"]), "list");
}
#[cfg(feature = "daemon")]
#[test]
fn daemon_dispatches_into_maestro_daemon() {
let cfg = bad_config();
let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "daemon"))]
#[test]
fn daemon_is_unrecognized_without_daemon_feature() {
assert_unrecognized(&run(&["daemon"]), "daemon");
}
#[cfg(feature = "tui")]
#[test]
fn tui_dispatches_into_maestro_tui() {
let cfg = bad_config();
let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]);
assert_dispatched(&out);
}
#[cfg(not(feature = "tui"))]
#[test]
fn tui_is_unrecognized_without_tui_feature() {
assert_unrecognized(&run(&["tui"]), "tui");
}
+3 -3
View File
@@ -123,9 +123,9 @@ client opening `/api/attach/:id`, and a client reporting focus explicitly at
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
**A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Dead sessions sort after live
ones regardless of recency: a session you cannot attach to is not where you
just were.
and among those, most recently created first. Sessions with no live pane —
`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
session you cannot attach to is not where you just were.
Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list.
+4 -42
View File
@@ -18,14 +18,10 @@ wizard, and cleans up — only the unified `maestro` binary ends up persisted in
**Updating is the same command.** Re-running `./install.sh` detects an existing
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
of `start`. No `--update` flag needed. **The restart keeps your terminal sessions:** the unit sets
of `start`. No `--update` flag needed. **The restart keeps your sessions:** the unit sets
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
every tmux-backed session) survives, and the new daemon re-adopts them on
start. Structured sessions (an agent run as a daemon child over ACP) are not
tmux panes, so shutdown ends their active turn; the transcript survives and
the next daemon attempts one resume (`docs/RESTART.md#structured-sessions`). A
unit written before this default lacks the line; re-run `./install.sh` to
refresh it.
every session) survives, and the new daemon re-adopts them on start. A unit
written before this default lacks the line; re-run `./install.sh` to refresh it.
Inside the script, `cargo install --git https://git.naps.pt/naps62/maestro …`
uses your already-configured git credentials for this host (HTTPS helper or
@@ -98,10 +94,7 @@ running a local daemon, for a host that only talks to another machine's.
The daemon unit carries the same `KillMode=process` as the wizard-written one,
so `systemctl --user restart maestro-daemon` leaves the tmux server and every
terminal session alive. Structured sessions end their active turn on
shutdown and resume once on the next daemon: ACP state comes back via
`session/load`, while the event log only replays Maestro's own transcript
projection (`docs/RESTART.md#structured-sessions`).
session alive.
## Thin builds for single-role hosts
@@ -195,37 +188,6 @@ An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`.
## Structured sessions: ACP adapters
A session spawned with `view: Structured` runs Claude Code or Codex as a child
of the daemon over the Agent Client Protocol instead of in a tmux pane. The
resolved session command selects the adapter binary, which must be on the
session's `PATH` when the spawn request sets one, or the daemon's `PATH`
otherwise. Install the adapter for each provider you use:
```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
npm install -g --prefix ~/.local @agentclientprotocol/codex-acp
```
That puts `claude-code-acp` and `codex-acp` in `~/.local/bin`, which must be on
the applicable `PATH` (a systemd user unit does not read your shell profile; set
`Environment=PATH=…` in the unit or install somewhere already on it). The
Claude adapter needs a logged-in Claude Code (`claude /login`); Codex uses the
Codex CLI's or adapter's configured authentication.
When the adapter is missing the spawn still creates the session, marked dead
with the provider-specific install command as its activity detail, so clients
say what to run. The adapter is a child of the daemon: it ends with a `DELETE
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
itself. A daemon restart ends an in-flight structured turn, but preserves its
worktree, session record, and event-log transcript. The replacement daemon
starts a new adapter, resumes it with `session/load`, and replays the
transcript. Terminal sessions keep surviving restarts through tmux. See
`docs/STRUCTURED-VIEW.md` for the full contract — row kinds, projections, the
handshake, and the approval and recovery rules — and `docs/RESTART.md` for
what a client observes across the restart.
## The browser UI
The daemon serves the SPA off its own listener. Open
+23 -35
View File
@@ -2,16 +2,10 @@
Maestro is upgraded in place: the operator reinstalls the binary and systemd
restarts the daemon under every browser tab and TUI that is attached to it.
tmux keeps **terminal** sessions alive across that (see `docs/INSTALL.md`),
but clients lose their sockets either way. This document is the contract for
what a client does about it — what the daemon publishes, how a client tells a
restart from a blip, and what the user sees while it happens.
A **structured** session (`docs/STRUCTURED-VIEW.md`) has no tmux pane behind
it to survive on: its agent is a child of the daemon process itself, so a
restart takes it down with the daemon. See
[Structured sessions](#structured-sessions) below for exactly what that
costs and what it doesn't.
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
lose their sockets. This document is the contract for what a client does about
it — what the daemon publishes, how a client tells a restart from a blip, and
what the user sees while it happens.
## What the daemon publishes
@@ -74,34 +68,28 @@ resolves, so a blocked client is never a trapped one. It clears itself the
moment a probe succeeds — there is no "retry" button, because the client is
already retrying.
## Structured sessions
## What survives, and what comes back dormant
Everything above describes a **client's** view of a restart, and it holds for
a structured session exactly as written — the same three verdicts, the same
status indicator, the same overlay. What differs is what survives on the
**daemon** side, because a structured session's agent is a daemon child, not
a tmux pane:
A daemon restart leaves tmux alone, so recovery re-adopts every
`maestro-<uuid>` session tmux still holds and the operator sees no change
beyond the reconnect above.
- A restart **ends the active turn**. There is no adapter process left to
finish it, and no tmux pane keeping it alive underneath.
- The **transcript survives**, in the per-session event log
(`docs/STRUCTURED-VIEW.md#persistence-recovery-and-dead-sessions`) — a
graceful shutdown marks the session for one resume attempt. Recovery is
two separate operations, not one: the next daemon calls `session/load` so
the adapter restores its own ACP session, but whatever history the
adapter emits during that call is discarded, not replayed into anything —
the daemon already has that history on disk. Separately, the daemon
rebuilds its own projection by folding the persisted event log back
through it. An ungraceful death (a crash, `kill -9`) leaves no resume
marker, and the session stays dead permanently.
- **Pending approvals and their nonces do not survive.** Both live only in
the old daemon's memory; any ACP permission request still mid-flight when
the daemon exits is answered `Cancelled` on the way down, and a resumed
session starts with no pending approval at all.
A **host reboot** is different: it kills the tmux server, so there is nothing
to adopt while every session's sidecar is still on disk. Recovery makes a
second pass over those sidecars and registers each as a `Dormant` session
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
session has no pane and no PTY bridge: it cannot be attached to, typed into or
resized, and neither watcher pass touches it. It is a record of what was
running, and the only thing left to do with one is dismiss it, which removes
the sidecar so the next recovery does not bring it back.
`docs/STRUCTURED-VIEW.md` is the full contract for all of this — the event
log's shape, the recovery marker, and exactly which kind of session death is
resumable.
`SessionStatus` is a wire type, so from this build forward it deserializes
leniently on both clients: a spelling the client does not know reads as `Dead`
rather than failing the whole session. That leniency lives in the client's own
binary, so it covers spellings added *after* this build, not this one — a CLI
or TUI still running an older build fails to parse a `Dormant` session, and
with it the whole session list, until it is upgraded. Clients render a dormant session as known-but-not-running —
see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks.
## Timing
+18 -34
View File
@@ -56,7 +56,7 @@ itself is not.
Line 2 reads left to right:
```
[<duration>] [<detail>] <command> [<diff stat> | exited]
[<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
```
Its minimum content is therefore just the command.
@@ -104,8 +104,14 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
client ever lets it wrap.
pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
neither client ever lets it wrap.
`dormant` (#162) is the counterpart of `exited` for a session the daemon knows
only from its sidecar: recovery found no tmux session behind it, so it has no
pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
observed ending — so the two fragments never both show. The TUI marks the same
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
@@ -209,7 +215,7 @@ the cursor's fill while the pane holds the keyboard, and the web's ring on a
highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to.
The duration text inherits its state's colour; the exited and command
The duration text inherits its state's colour; the exited, dormant and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
take the `active` and `error` roles of the same surface.
@@ -231,24 +237,7 @@ cutover points land within one tick.
## Precedence
### Activity source by session kind
The daemon supplies one `activity` field to both clients, but its source depends
on the session kind:
| Session kind | Source |
|---|---|
| Terminal | The existing agent-report override and five-minute TTL, falling back to PTY quiescence, foreground process and `/proc` input-block inference. |
| Structured | ACP state directly: a pending approval is `AwaitingInput`; otherwise an in-flight turn is `Active`; no turn is `Idle`. A failed turn or dead structured session is `Error`, and the next turn clears a failed-turn error. |
Structured sessions never enter the terminal inference path and ignore activity
reports inherited through agent hooks. They have no PTY whose output or blocked
read could describe their state. This is a second source, not a replacement for
the terminal source.
### Terminal report override
For terminal sessions, an agent-reported `ActivityState` (#105) overrides the daemon's inference for
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
When the TTL expires, inference resumes and the session's reported detail —
if any — clears with the report. Clients never blend the two: whichever source
@@ -267,10 +256,6 @@ Two rulings specific to a reported `Error` (#114):
process changes do not clear it — only the agent itself may withdraw what
the agent itself reported. Re-reporting `Error` extends the TTL rather than
stacking reports.
- **Only Claude Code reports `Error`.** Codex has no counterpart to
`StopFailure` in its hook event set, so a codex turn that ends on an API
error reports `AwaitingInput` and its card carries no error mark. A card
without the mark means "nothing reported a failure", not "nothing failed".
If a client receives an `ActivityState` variant it does not know, it must not
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
@@ -298,10 +283,10 @@ session is genuinely `Active` again — new work, not merely a refocus. Only
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
Unfocusing and refocusing alone never re-arms it.
This applies uniformly to `AwaitingInput` from terminal inference, an agent
report (#105), or a structured session's pending approval, since the rail draws
them identically. It does not touch `Error`; terminal reports keep their TTL,
while a structured failed-turn error clears when the next turn starts.
This applies uniformly to `AwaitingInput` from either source — the daemon's
own inference or an agent's report (#105) — since the rail draws them
identically. It does not touch `Error`; that state's own TTL (below) is
unrelated and unaffected by focus.
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
@@ -324,10 +309,9 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list.
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
by default — and the same chord back): an attached terminal owns every plain
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
uses `ctrl-w h`.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
`ctrl-w h`.
Everything else is the same key on both clients:
-448
View File
@@ -1,448 +0,0 @@
# STRUCTURED-VIEW.md — the structured session contract
The contract for a structured session (`view: Structured`, #133): an agent run
as a daemon child over the Agent Client Protocol (ACP) instead of in a tmux
pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
read this before changing anything that renders a structured session — both
the web and TUI renderers are written against it.
It covers the eight transcript row kinds, what the daemon decides versus what
a client decides, the two projections and their delta/cursor semantics, the
ACP handshake and the capabilities it declines, the adapter's install
command, and the approval nonce rules. **Non-goal: the detached runner.** It
does not exist yet, and nothing here describes it.
## What a structured session is
A terminal session's agent runs inside a tmux pane; a structured session's
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
daemon is the ACP **client**; the provider adapter process is the ACP
**agent**. It translates the provider's events into ACP `session/update`
notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
daemon's side of that connection;
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two
projections below.
The child is a real child of the daemon process: same process group, and on
Linux marked with `PR_SET_PDEATHSIG` so it dies with the daemon even if the
daemon exits without cleaning up; other platforms have no such signal and
depend on an orderly shutdown/drop to terminate it. The ordinary path on
every platform is `AcpSession::terminate` (`session/cancel`, a short grace,
`SIGTERM`, then `SIGKILL`), called from both `DELETE /api/sessions/:id` and
daemon shutdown.
`docs/WORKTREES.md`'s kill-guard rule — an unforced kill refused while the
session's worktree holds unsaved work — applies to a structured session
exactly as it does to a terminal one; only what gets terminated differs (an
ACP handle instead of a tmux pane).
## Choosing the view
`view` is fixed at spawn and never changes. Every client picks it the same
way, from the resolved command alone
(`maestro_protocol::display::supports_structured_view`): a command that
resolves to Claude Code or Codex spawns **Structured** by default, anything
else spawns **Terminal**. The operator can override the default — the web and
TUI spawn forms show the checkbox ticked and let it be unticked, and
`maestro spawn` takes `--terminal` (or `--structured`, which sends the request
even for a command the CLI does not recognise and lets the daemon refuse it).
Once the operator works the toggle, a later command change never re-ticks it
under them; it can still be forced off when the new command has no adapter.
The daemon is the last word: a `Structured` request for a command with no
adapter is refused there, whichever client sent it.
## The adapter
The resolved structured command selects a provider adapter. Its binary is
resolved by walking the session's `PATH` when the spawn request sets one, or
the daemon's `PATH` otherwise, and taking the first executable with that name.
| Command | Adapter binary | Install command | Provider detail |
| --- | --- | --- | --- |
| `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
The selected install command is baked into `AcpError::AdapterMissing`. When an
adapter cannot be found, spawn still creates a **Dead** session with that
command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
caveat). Provider authentication failures surface through the adapter's stderr
tail in the same field.
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
slow machine to boot an adapter and its provider process, never long enough
to hang a spawn indefinitely. Any handshake failure — missing adapter, early
exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
closes and reaps the child itself before returning the error.
## The ACP handshake
`initialize` is where the daemon states what kind of client it is. It
declares, deliberately, almost nothing:
| Capability | Declared | Why |
| --- | --- | --- |
| `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
| `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
| `terminal` | `false` | Providers run commands through their own tool surface. |
Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because neither supported
adapter needs it, not because it was weighed and refused.
The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below.
Before #139 it was a stub that declined every request; that stub is gone now
that a live implementation exists.
`session/new` opens a fresh ACP session on every ordinary spawn. A respawn
after a daemon restart instead sets `SpawnConfig::load_session(id)` and runs
`session/load` with the ACP session id the original handshake returned
(`Session::acp_session_id` on the wire) — the adapter must advertise
`agent_capabilities.load_session`, or the handshake fails outright naming
that. Whatever history the adapter streams back while `session/load` is in
flight is dropped on arrival, deliberately: the daemon's own event log
already holds that history, and forwarding it too would duplicate every row
in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
transcript is written in. Every row has a stable `id` (so later deltas can
address it), a `kind`, and a `data: Value` that preserves the underlying ACP
payload — clients depend on ACP's *shape* without depending on ACP as a
crate.
| Kind | Carries | Row identity |
| --- | --- | --- |
| `User` | The prompt accepted by Maestro's structured prompt endpoint, persisted before ACP dispatch. An adapter `user_message_chunk` is also supported for imported ACP history. | `user:<uuid>` for a daemon-recorded prompt; `user:<message_id>` or `user:<seq>` for an ACP chunk. |
| `AgentText` | An `agent_message_chunk` — the agent's visible reply, streamed. | `agent:<message_id>`, or `agent:<seq>` from the first chunk when no id is supplied. Later chunks for the same id—or consecutive anonymous text chunks—are appended into `data.content.text` in place. An empty anonymous chunk starts a fresh row, matching the turn marker emitted by adapters that omit message ids. |
| `AgentThought` | An `agent_thought_chunk` — the model's reasoning stream. | `thought:<message_id>` / first anonymous `thought:<seq>`, coalesced the same way as `AgentText`. |
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
`agent-client-protocol` dependency's schema already deserializes (it
currently folds `available_commands_update` and `session_info_update`
there) but the projection's `match` doesn't fold into one of the other
seven kinds. It is not a wire-level forward-compatibility seam:
`SessionUpdate` is an internally-tagged enum with no unknown-variant
fallback, so a `session/update` whose `sessionUpdate` tag this build's
dependency has never heard of — including a variant gated behind an
unstable ACP feature this build doesn't enable, which is compiled out of
the enum entirely — fails to deserialize before the projection ever runs,
rather than becoming a `Notice`. A genuinely new ACP variant needs the
`agent-client-protocol` dependency upgraded (and, typically, the projection's
`match` taught about it) before it can appear on this transcript at all — see
`projection.rs`'s test asserting that an unrecognised `sessionUpdate` tag is a
deserialization error, not a `Notice`. A client that does not know what to do
with a `Notice` can still render it as an opaque, collapsed line — that
remains a reasonable floor, not a client bug.
Rendering is entirely a client concern. The projection's job stops at
folding ACP into these eight shapes; markdown formatting, diff rendering,
tool-card treatment, and how a `Notice` is presented are all renderer
choices, not daemon ones.
## Daemon decisions vs. client decisions
**The daemon decides:**
- Which ACP `session/update` becomes which row kind, and under what id — the
fold in `projection.rs` is the one place this logic lives, so the web and
TUI renderers can never disagree about it.
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
or a failure ends it (`TurnState.responding` / `.thinking` /
`.active_tool_calls` / `.started_at` are derived, not renderer-computed).
Ending a turn appends a durable `maestroTurnMeta` footer with its elapsed
time, outcome, model, and context-window usage/delta. ACP does not expose
provider billing-token counts, so these are explicitly context tokens.
- Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce.
- What happens to an ACP update variant the dependency decodes but nobody has
taught the projection to special-case (falls back to `Notice` rather than
erroring). A variant the dependency itself can't decode is a deserialization
error, not a `Notice` — see [The eight transcript row
kinds](#the-eight-transcript-row-kinds).
- Persistence and replay: what is written to the event log, in what order,
and how a restart resumes it.
**A client decides:**
- How each row kind is drawn — markdown for `AgentText`/`AgentThought`, a
diff view for `ToolDiff`, a tool card for `ToolCall`, and a floor
presentation for `Notice`. `Plan` is the exception: both clients omit it
from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
source for composer `/` autocomplete at any whitespace-delimited token under
the caret. Completing a command replaces only that token. The client omits
those state-only notices, along with `session_info_update`, from the visible
transcript.
- How live `responding`, `thinking`, tool activity, elapsed time, and context
usage are drawn. The web client keeps these in a compact strip above the
composer and renders each durable turn footer inline with the transcript.
- How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
never the UI that solicits it.
- Scrolling, collapsing, and every other presentation-only behaviour.
## The two projections
`Projection` (daemon-only) is folded once from the durable ACP stream and
exposes two independent views, both defined in `maestro-protocol`:
- **`TranscriptSnapshot { cursor, rows }`** — the complete ordered list of
transcript rows built so far, plus the log position (`cursor`) it reflects.
It is intentionally always the *whole* transcript, not a windowed one: a
tool row patched much later must still find its original entry.
- **`ReducedState`** — one whole-session snapshot folded over every event
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
always full-history, never a delta — a client that only wants "is a turn
active right now" never has to replay anything to answer that.
Changes to the transcript travel as `TranscriptDelta`, tagged `type`:
`Append { row }` (a new row), `Patch { row }` (an existing row, addressed by
`id`, replaced wholesale), and `Remove { id }`. `Remove` is defined on the
wire for forward compatibility; nothing in the current projection emits it —
every row, once appended, is only ever patched or left alone.
### Cursor and replay semantics
The event log's own line position is the cursor: 1-based, shared by ACP
notifications and daemon-generated rows alike (`sidecar.rs`'s
`PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor
sequence with raw ACP JSON without pretending to be ACP traffic; the envelope
also carries durable `Turn` footers). Two ways to
read it:
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
a `ReducedState` covering the whole log as it stands, then streams
`ProjectionUpdate { transcript, state }` for everything that happens next.
There is no client-supplied resume cursor on this socket: every connection
gets the full snapshot, not a gap-filled continuation, so a client never
needs the replay endpoint just to obtain the current transcript. Use
replay instead for cursor-bounded pagination through the durable log —
history from before this connection, fetched a page at a time.
- **`GET /api/sessions/:id/structured/replay`** — cursor-bounded pagination
over the durable log, returning one `StructuredReplayEvent` (`cursor`,
`transcript`, `state`) per matching line. `?since=<n>` returns the first
page after `n` (`since=0` selects from the beginning, i.e. the first
page); a client pages forward from there using the cursor of the last
event returned. `?before=<n>` returns the page immediately before `n`,
still in ascending order — passing both is a `400`. `?limit` defaults to
100 and is clamped to 500. Each call re-folds
the entire log from scratch to compute the state at every returned cursor,
so a page reflects exactly the *durable* state at its cursor, not an
approximation of it — at the cost of redoing the whole fold per request.
That durable state is not always identical to what a live client saw at
that moment, though: turn lifecycle transitions are broadcast live without
a durable event backing them (see [Prompt and cancel](#prompt-and-cancel-bounded-liveness)),
and a restored `Approval` row carries no pending nonce, because the nonce
never reached disk (see [Approvals](#approvals)) — replay is exact and
reconstructible, not a perfect record of everything the live view showed.
A session that is not a live structured session (terminal, or a dead
adapter) answers `409 not_structured` from either route rather than a
coherent-looking but stale reply.
## Approvals
An ACP `session/request_permission` callback becomes an `Approval` row plus a
pending decision, entirely on the daemon side:
- **Generation.** The daemon mints the nonce itself — a fresh
`Uuid::new_v4()` string per request — rather than using anything ACP's own
request carries. ACP permission callbacks have no request id of their own
that would double as one.
- **Live-only placement.** The nonce lives in exactly one place: the
in-memory `pending_approvals` map keyed by nonce, and its mirror in
`ReducedState.pending_approval.nonce` on the live wire. It is never part of
the `Approval` row's `data` — the row is `RequestPermissionRequest`
serialized *without* the nonce.
- **Secrecy from the agent and from durable history.** The adapter itself
never learns the nonce — it is a daemon-to-client secret, never sent back
over the ACP connection. It is likewise never written to the event log:
restoring an `Approval` row from disk (`Projection::restore_row`)
produces a historical row with no `pending_approval` in state, because the
nonce that would make it "pending" never made it to disk in the first
place. A restart therefore cannot resume a pending approval — see
[Persistence](#persistence-recovery-and-dead-sessions).
- **One live approval at a time.** A new permission request mints its own
nonce, then cancels whatever was already pending (`cancel_pending_approvals`)
before installing itself as the new pending approval — ACP serializes
requests through one connection, so this only ever fires if an adapter
nonetheless overlaps two. Either way, the prior pending request is
cancelled/cleared before the new one is installed, so the newest request
wins.
- **Single-use and duplicate resolution.**
`POST /api/sessions/:id/structured/approvals/:nonce` **always answers
`204`** for a known, live structured session — whether the nonce is the
one currently pending, one already consumed by an earlier answer, or one
that never existed. Only an unknown or non-structured *session* answers
otherwise (`404` / `409`). This is deliberate: two clients racing to answer the same
approval must not turn a harmless duplicate click into a client-visible
error. Whichever `POST` reaches the daemon first removes the nonce from
`pending_approvals` and is the one whose decision actually reaches the
adapter; every later `POST` for that nonce is a no-op `204`.
- **Cancellation vs. denial.** A turn cancel or a session shutdown resolves
any pending approval with ACP's `Cancelled` outcome, not a `Deny` decision
— the adapter should read those as "the operation was withdrawn," not "the
operator refused it."
- **Decision mapping.** `AllowOnce` and `AllowForSession` select the ACP
option of kind `AllowOnce` / `AllowAlways` from the request's own option
list. `Deny` prefers a one-shot refusal (`RejectOnce`) and falls back to a
permanent one (`RejectAlways`) only when that is the sole rejection option
offered; if neither is present, it answers `Cancelled`.
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
drop, or clipboard. Pasted text of at least 4,000 characters or 40 lines becomes
a UTF-8 `.txt` attachment. Shorter pastes stay in the composer. Attachments can
be removed before sending; failed sends retain the draft and attachments.
Pastes also stay inline when all ten attachment slots are occupied or the text
exceeds the 20 MiB file limit.
On send, `POST /api/sessions/:id/structured/attachments?name=<filename>` uploads
each file as raw bytes (20 MiB maximum) and returns its absolute `path`.
Only live structured sessions accept uploads. Filenames are limited to 180
bytes and cannot contain directory separators or control characters. Files get unique names and private
file permissions in `<id>.attachments` beside the session sidecar. They persist
across daemon restarts; removing a composer chip does not delete uploaded data.
There is currently no automatic attachment cleanup.
The prompt includes each filename and host path so the adapter can read it
through its own file tools, including images when the provider supports them.
These references are persisted in the ordinary user transcript row. Attachment
bytes are not embedded in ACP messages or transcript history.
`POST /api/sessions/:id/structured/prompt` and `.../cancel` are serialized
against each other through one per-session `operation` lock
(`AcpEventStream::operation`), because ACP permission callbacks carry no
per-request id — the daemon's own notion of "the active turn" is the only
thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn."
- A prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing — one active turn per session, never a hidden queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon
cannot simply forget it: ACP has not confirmed the cancel yet, and a new
prompt claiming "the active turn" while the old one is still outstanding
would make the next permission callback ambiguous between the two.
- So a cancelled-but-still-dispatched turn must **settle within
`CANCEL_GRACE` (500ms)** — either ACP acknowledges the prompt (however it
resolves) or that grace period runs out. If it settles in time, the next
prompt proceeds normally. If it does not, the daemon does not guess: it
marks the whole structured session dead (`fail_cancelled_acp_turn`) rather
than risk a callback resolving against the wrong turn. This is the bounded
half of "bounded liveness" — a structured session's prompt/cancel dance
either resolves promptly or the session is declared unusable, never left
in an indefinite ambiguous state.
## Persistence, recovery, and dead sessions
A structured session's durable state is three files in the sidecar
directory: the sidecar itself (`<id>.json`), plus two next to it:
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
for a daemon-generated row. Both share the same 1-based line-position cursor. A
line a hard kill left half-written is terminated before the next append, so
it keeps its position and is skipped as malformed rather than corrupting
what follows. An append that fails *while the daemon is running* — a
`write_all` that errors after only a prefix reached disk, or a `flush` that
errors after a complete write — is rolled back to the exact byte length the
file was at before the call, so `last_seq` and the file's line count never
disagree and the same log handle can be appended to again immediately (#184).
If that rollback itself cannot restore the prior length, the append is
fatal: the log's consistency can no longer be promised, so the caller kills
the session instead of appending to it again.
- **`<id>.recover`** — an empty marker file. Its only meaning is "the daemon
that wrote this shut down gracefully while this session was running, and
the next daemon may attempt to resume it." It is consumed (deleted) the
moment a recovery attempt reads it — one recovery attempt per graceful
shutdown, never a standing flag.
Three ways a structured session ends up `Dead`, each with different
persistence consequences:
1. **The adapter exits, or a notification append to the event log fails,
while the daemon keeps running.** (An approval-row append failure is
narrower: as long as the log rolled back cleanly, it only cancels the
pending approval and rolls the unpersisted `Approval` row back out of the
live transcript, without marking the session dead — a snapshot taken after
the failure must never show a row durable replay could not reproduce
(#184). If the rollback itself did not restore the log's prior length,
this narrower path does not apply either: the session is marked dead the
same as any other append failure, since the log can no longer promise
`last_seq` matches its file.)
`mark_session_dead` applies the same natural-exit worktree rule
as a terminal session's own pane exiting (`docs/WORKTREES.md`): a pristine
worktree is removed, one holding unsaved work is left on disk. No recovery
marker is written. This death is permanent — a later daemon restart's
`recover()` finds the sidecar, sees no marker, and leaves the session
`Dead` without attempting to resume it.
2. **A cancelled turn fails to settle within `CANCEL_GRACE`.** Same
consequence as (1): the session is marked dead through the same path, no
marker, no future resumption.
3. **Graceful daemon shutdown**, while the session was still `Running`.
`mark_session_dead_after_shutdown` marks the session dead **without**
applying the worktree exit rule — the tree is kept exactly as it is,
pristine or not, because there is nothing "natural" about this exit — and
writes the `.recover` marker. The sidecar on disk therefore says `Dead`,
but is eligible for resumption.
Recovery, on the next daemon's `recover()`: for every `Structured` sidecar
not already adopted, the marker is consumed. When one was present and the
sidecar carries a stored `acp_session_id`, the daemon spawns a fresh adapter
with `SpawnConfig::load_session(acp_session_id)`, replays the
entire durable event log into a new `Projection` (rebuilding the transcript
and reduced state exactly as they were, with any restored `Approval` row
landing as history — never as a re-armed pending approval, since its nonce
never reached disk), and only then reopens the log for further appends. What
the adapter streams back while its own `session/load` is in flight is
discarded rather than appended, so the replayed history is not duplicated
(see [The ACP handshake](#the-acp-handshake)). Success flips the session back
to `Running`/`Idle`; failure (no stored `acp_session_id`, or the handshake
itself failing) leaves it `Dead` with the failure reason as
`activity_detail` — the same "tell the operator what to fix" pattern as a
missing adapter binary.
Pending approvals and their nonces never survive any of this. They live only
in the in-memory `pending_approvals` map and the live `ReducedState`, both
gone the instant the daemon that held them exits. Whatever ACP permission
request was mid-flight when the old daemon died was already answered
`Cancelled` on that daemon's own shutdown path, so a resumed session always
starts with no pending approval.
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
now that structured sessions exist alongside terminal ones.
+4 -12
View File
@@ -35,18 +35,10 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
back to a short hash of the branch.
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
a free one.
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
itself, and the directory follows since it is derived from the branch — a
random handle can otherwise reuse the name of a branch some earlier, already
removed worktree left behind, and `git worktree add -b` refuses outright even
though the directory is free.
For an *existing* branch (`create_branch: false`) the name must stay exact, so
only the directory is suffixed — a second worktree of the same branch is not
what happens here, because git refuses to check the same branch out twice and
the branch picker already marks branches that are checked out elsewhere.
a free one. The suffix names the **directory only**. The branch is whatever was
requested; a second worktree of the same branch is not what happens here, because
git refuses to check the same branch out twice and the branch picker already
marks branches that are checked out elsewhere.
### The `worktree_root` override
-120
View File
@@ -1,120 +0,0 @@
{
"_description": "the parity fixture for #199's command resolver — every case here is asserted identically by crates/maestro-protocol/src/display.rs (command_program/command_tool) and web/src/toolIcon.ts (splitCommand). `program` is the resolved, basenamed program; `tool` is the icon id it maps to, or null when unrecognised/ambiguous.",
"cases": [
{ "command": "claude", "program": "claude", "tool": "claude" },
{ "command": "claude --resume", "program": "claude", "tool": "claude" },
{ "command": "CLAUDE", "program": "CLAUDE", "tool": "claude" },
{ "command": "/usr/local/bin/claude -p hi", "program": "claude", "tool": "claude" },
{ "command": "./bin/claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=\"bar baz\" claude", "program": "claude", "tool": "claude" },
{ "command": "FOO='bar baz' claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=bar\\ baz claude", "program": "claude", "tool": "claude" },
{ "command": "'/usr/local/bin/claude'", "program": "claude", "tool": "claude" },
{ "command": "\"/opt/my agent/claude\" --resume", "program": "claude", "tool": "claude" },
{ "command": "env -i claude", "program": "claude", "tool": "claude" },
{ "command": "env --ignore-environment claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo --user=root claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -- claude --resume", "program": "claude", "tool": "claude" },
{ "command": "nohup claude", "program": "claude", "tool": "claude" },
{ "command": "time claude", "program": "claude", "tool": "claude" },
{ "command": "time -p claude", "program": "claude", "tool": "claude" },
{ "command": "time -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -p -- claude", "program": "claude", "tool": "claude" },
{ "command": "time -pp claude", "program": "time", "tool": null },
{ "command": "time -p -p claude", "program": "time", "tool": null },
{ "command": "time -o /tmp/t.log claude", "program": "time", "tool": null },
{ "command": "command claude", "program": "claude", "tool": "claude" },
{ "command": "command -v claude", "program": "command", "tool": null },
{ "command": "command -V claude", "program": "command", "tool": null },
{ "command": "npx --yes claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "npx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm -C ./pkg dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx -s claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --silent claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn --silent dlx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx -q claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --quiet claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "uvx claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --bun claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x -p @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --package @anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --package=@anthropic-ai/claude-code claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --no-install claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun x --verbose claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bunx --silent claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "bun --bun x claude-code", "program": "bun", "tool": null },
{ "command": "pnpm dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "pnpm dlx --package= claude-code", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx --package", "program": "pnpm", "tool": null },
{ "command": "pnpm dlx -c \"claude-code\"", "program": "pnpm", "tool": null },
{ "command": "pnpm --package=typescript dlx tsc", "program": "tsc", "tool": null },
{ "command": "pnpm dlx --unknown-flag claude-code", "program": "pnpm", "tool": null },
{ "command": "yarn dlx --package foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package=foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx -p foo claude-code", "program": "claude-code", "tool": "claude" },
{ "command": "yarn dlx --package= claude-code", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p", "program": "yarn", "tool": null },
{ "command": "yarn dlx -p --quiet claude-code", "program": "yarn", "tool": null },
{ "command": "bunx --package= claude-code", "program": "bunx", "tool": null },
{ "command": "bunx --package", "program": "bunx", "tool": null },
{ "command": "bun x -p --silent claude-code", "program": "bun", "tool": null },
{ "command": "sudo env -i claude", "program": "claude", "tool": "claude" },
{ "command": "FOO=1 sudo BAR=2 claude", "program": "claude", "tool": "claude" },
{ "command": "env -iv claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -nE claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -uroot claude", "program": "claude", "tool": "claude" },
{ "command": "sudo -u", "program": "sudo", "tool": null },
{ "command": "env --unset= claude", "program": "env", "tool": null },
{ "command": "sudo --user= claude", "program": "sudo", "tool": null },
{ "command": "sudo --user \"\" claude", "program": "sudo", "tool": null },
{ "command": "sudo -nu claude", "program": "sudo", "tool": null },
{ "command": "env -S 'python3 -u' script.py", "program": "env", "tool": null },
{ "command": "npx -c \"claude --version\"", "program": "npx", "tool": null },
{ "command": "npx --call \"claude --version\"", "program": "npx", "tool": null },
{ "command": "command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "npx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "pnpm dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "yarn dlx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bun x FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "bunx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "uvx FOO=1 claude-code", "program": "FOO=1", "tool": null },
{ "command": "sudo time FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "nohup env FOO=1 claude", "program": "claude", "tool": "claude" },
{ "command": "sudo command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "command nohup FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "time command FOO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO=1\" claude", "program": "FOO=1", "tool": null },
{ "command": "FOO\\=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"O\"O=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\\OO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "\"FOO\"=1 claude", "program": "FOO=1", "tool": null },
{ "command": "F\"\"OO=1 claude", "program": "FOO=1", "tool": null },
{ "command": "claude\nclaude-code", "program": "claude", "tool": null },
{ "command": "clau\\\nde --resume", "program": "claude", "tool": "claude" },
{ "command": "npx opencode", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm dlx opencode run", "program": "opencode", "tool": "opencode" },
{ "command": "pnpm claude", "program": "pnpm", "tool": null },
{ "command": "yarn claude", "program": "yarn", "tool": null },
{ "command": "claude-code-acp", "program": "claude-code-acp", "tool": null },
{ "command": "hx src/main.rs", "program": "hx", "tool": null },
{ "command": "bash", "program": "bash", "tool": "shell" },
{ "command": "opencode", "program": "opencode", "tool": "opencode" },
{ "command": "sudo", "program": "sudo", "tool": null },
{ "command": "", "program": "", "tool": null },
{ "command": "npx --unknown-flag claude-code", "program": "npx", "tool": null },
{ "command": "claude | tee log.txt", "program": "claude", "tool": null },
{ "command": "claude; echo done", "program": "claude;", "tool": null },
{ "command": "claude && echo done", "program": "claude", "tool": null },
{ "command": "echo hi $(claude)", "program": "echo", "tool": null },
{ "command": "claude \"unterminated", "program": "claude", "tool": null }
]
}
+1 -1
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
hash = "sha256-hOqiAbnjKsoQnV8lgto5HspDsLIb+48sNLkShIjCZPQ=";
};
buildPhase = ''
+1 -14
View File
@@ -11,8 +11,7 @@
"@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"happy-dom": "^20.11.6",
@@ -1223,18 +1222,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+2 -3
View File
@@ -6,7 +6,7 @@
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "vite",
"build": "tsc -b tsconfig.build.json && vite build",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
@@ -16,8 +16,7 @@
"@fontsource/jetbrains-mono": "^5.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
"marked": "^18.0.11"
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"happy-dom": "^20.11.6",
-10
View File
@@ -20,9 +20,6 @@ importers:
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
marked:
specifier: ^18.0.11
version: 18.0.11
devDependencies:
happy-dom:
specifier: ^20.11.6
@@ -427,11 +424,6 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.11:
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
engines: {node: '>= 20'}
hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -872,8 +864,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.11: {}
ms@2.1.3: {}
nanoid@3.3.12: {}
+1 -68
View File
@@ -11,9 +11,6 @@ import type {
Projects,
RecentDirs,
Session,
StructuredApprovalDecision,
StructuredConfigRequest,
StructuredPromptRequest,
Uuid,
} from "./types.js";
@@ -94,21 +91,6 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
);
}
/** Change one option advertised by a live ACP session. */
export async function setStructuredConfig(
id: Uuid,
req: StructuredConfigRequest,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredConfig(id), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(req),
}),
);
}
/** Report whether this client's pane is the one currently showing `id` — not
* merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough
@@ -170,53 +152,4 @@ export async function fetchBranches(path: string): Promise<Branches> {
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** Send a prompt to a structured session's ACP turn (#138). `409
* turn_in_flight` when one is already active — the caller reflects that from
* `ReducedState.turn` instead of racing this call. */
export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<void> {
await jsonOrError(
await fetch(routes.structuredPrompt(id), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt } satisfies StructuredPromptRequest),
}),
);
}
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(routes.structuredCancel(id), { method: "POST", credentials: "same-origin" }),
);
}
/** Answer a pending ACP permission request (#139). Always resolves once the
* session itself is a live structured session — a nonce that is unknown or
* already consumed still answers `204` (docs/STRUCTURED-VIEW.md), so two
* clients racing the same approval never see this reject. */
export async function resolveStructuredApproval(
id: Uuid,
nonce: string,
decision: StructuredApprovalDecision,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredApproval(id, nonce), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(decision),
}),
);
}
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
export { eventsWsUrl } from "./routes.js";
+65 -35
View File
@@ -4,15 +4,24 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
export type { AttachInfo } from "./paneTitle.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
@@ -29,7 +38,25 @@ const webglPolicy = new WebglPolicy();
* session ending) tears the socket down — so switching away and back is
* instant, with scrollback already in place.
*/
export interface AttachConnection extends PaneConnection {}
export interface AttachConnection {
readonly sessionId: Uuid;
/** The `.attach` element (header + terminal body) to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit to the pane, focus, sync size.
* `focus: false` mounts without taking the keyboard — what a sidebar
* preview needs, so j/k keeps working while the pane is on screen. */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
* handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */
sendInput(data: string): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
export interface AttachOptions {
/** Invoked when the user clicks the view's explicit "disconnect" control. */
@@ -43,6 +70,23 @@ export interface AttachOptions {
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
/** What the header shows about the session (name / command). Optional — falls
* back to a short session id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
}
/** Header echoes the sidebar: the program becomes its tool icon when we have a
* mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
@@ -52,19 +96,21 @@ export function createAttachConnection(
el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = paneTitleHtml(sessionId, info);
const titleAttr = paneTitleAttr(sessionId, info);
const titleHtml = info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
<div class="attach-body"></div>
`;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
@@ -175,6 +221,15 @@ export function createAttachConnection(
}
}
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
@@ -324,9 +379,6 @@ export function createAttachConnection(
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
@@ -346,7 +398,6 @@ export function createAttachConnection(
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
@@ -367,20 +418,10 @@ export function createAttachConnection(
return;
}
if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
term.write(new Uint8Array(ev.data));
} else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer".
void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
}
};
@@ -429,12 +470,6 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
@@ -478,10 +513,6 @@ export function createAttachConnection(
// The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take.
sendInput,
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() {
if (disposed) return;
disposed = true;
@@ -494,7 +525,6 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
+13
View File
@@ -414,6 +414,19 @@ describe("session cards (#107)", () => {
expect(line2?.textContent).toContain("exited");
});
// #162: a session recovered with no tmux behind it is known but not running,
// and never reads as something that exited.
it("shows dormant on line 2 for dormant sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000118",
status: "Dormant",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("dormant");
expect(line2?.textContent).not.toContain("exited");
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019",
+8
View File
@@ -53,6 +53,10 @@ export function requestOpenCommandBar(): void {
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
@@ -109,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead";
// #162: known from its sidecar with nothing running behind it — a distinct
// fragment, since it never exited.
const dormant = s.status === "Dormant";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now);
@@ -123,6 +130,7 @@ function metaLine(s: Session, now: number): string {
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
dormant ? `<span class="card-flag">dormant</span>` : "",
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
+14
View File
@@ -99,6 +99,20 @@ describe("empty-query ordering", () => {
]);
});
// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which
// way the pane went away.
it("sorts dormant sessions after live ones, like dead ones", () => {
const dormantRecent = session("dormant", {
status: "Dormant",
last_focused_at: "2026-06-01T00:00:00Z",
});
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([
"live",
"dormant",
]);
});
it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
+5 -3
View File
@@ -142,8 +142,10 @@ function createdAt(s: Session): number {
* matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0;
const deadB = b.status === "Dead" ? 1 : 0;
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
// — sorts after the sessions you could actually attach to.
const deadA = a.status === "Running" ? 0 : 1;
const deadB = b.status === "Running" ? 0 : 1;
if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a);
@@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
row.dataset.kind = candidate.kind;
if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead");
if (candidate.session.status !== "Running") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") {
+14 -12
View File
@@ -133,28 +133,31 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
const plain = vi.fn();
const chorded = vi.fn();
const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
{ leader: DEFAULT_LEADER },
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
);
const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled();
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1);
});
it("leaves a plain Escape for the pane instead of binding it", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
it("keeps pane commands live inside the terminal's textarea", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, textarea, "Escape")).toBe(false);
const e = key("Escape");
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
});
});
@@ -170,7 +173,6 @@ describe("the leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
});
+8 -2
View File
@@ -93,8 +93,7 @@ const SPECS: readonly SpecSource[] = [
id: "focus.toggle",
label: "Move the keyboard between the session list and the pane",
group: "Session list",
keys: [`${DEFAULT_LEADER} h`],
leaderKeys: (leader) => [`${leader} h`],
keys: ["ctrl-shift-h"],
scope: "global",
},
{
@@ -146,6 +145,13 @@ const SPECS: readonly SpecSource[] = [
keys: ["tab"],
scope: "list",
},
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{
id: "help.toggle",
label: "Show or hide this help",
+6 -262
View File
@@ -2,84 +2,28 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
// the pool reaches the focused connection with them.
const sent: { id: string; data: string }[] = [];
const disposed: string[] = [];
const mounts: { id: string; focus: boolean | undefined }[] = [];
function fakeConn(kind: string, id: string) {
const el = document.createElement("div");
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
el.dataset.kind = kind;
el.dataset.session = id;
return {
vi.mock("./attach.js", () => ({
createAttachConnection: (id: string) => ({
sessionId: id,
kind,
el,
onMount(opts?: { focus?: boolean }) {
mounts.push({ id, focus: opts?.focus });
},
el: document.createElement("div"),
onMount() {},
onUnmount() {},
focusTerminal() {},
sendInput(data: string) {
sent.push({ id, data });
},
updateInfo() {},
dispose() {
disposed.push(`${kind}:${id}`);
},
};
}
vi.mock("./attach.js", () => ({
createAttachConnection: (id: string) => fakeConn("attach", id),
}));
vi.mock("./structured.js", () => ({
createStructuredConnection: (id: string) => fakeConn("structured", id),
dispose() {},
}),
}));
afterEach(() => {
vi.unstubAllGlobals();
sent.length = 0;
disposed.length = 0;
mounts.length = 0;
});
/** Minimal fixture for the `CatalogState` a `CatalogStore` broadcasts. */
function session(id: string, view?: "Terminal" | "Structured"): Session {
return {
id,
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
view,
};
}
/** A `CatalogStore` an id can be pushed into on demand, mirroring
* events.test.ts's own injectable-socket pattern. */
class FakeCatalogSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
close(): void {}
push(sessions: Session[]): void {
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
}
}
function fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } {
const socket = new FakeCatalogSocket();
const store = new CatalogStore({ socket: () => socket as unknown as WebSocket });
store.start();
return { store, socket };
}
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
@@ -181,203 +125,3 @@ describe("ConnectionPool.sendToFocused", () => {
expect(sent).toEqual([]);
});
});
// A deep link or restored layout can `mount()` a session before the catalog
// snapshot names its view — the daemon defaults an absent `view` to
// `"Terminal"`, but so does an *unknown* session, and only one of those is
// actually resolved (#140 review).
describe("ConnectionPool: view resolution races", () => {
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane); // no info: deep-link-before-snapshot race
expect(disposed).toEqual([]);
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// The fresh connection landed in the same pane nothing re-mounted it into.
expect(pane.children).toHaveLength(1);
});
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
pool.mount(A, document.createElement("div"));
pool.mount(B, document.createElement("div"));
pool.unmount(A);
pool.unmount(B);
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// B's own element is still parked — A's repair didn't wipe its siblings
// out of the shared hidden cache.
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
});
it("does not replace a connection whose resolved view already matches", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual([]);
});
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
mockFetch();
const pool = new ConnectionPool();
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
pool.mount(A, pane); // e.g. a re-render before the catalog answers again
expect(disposed).toEqual([]);
});
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
// `A` is absent from this snapshot — main.ts's own subscription handles
// closing the pane; the pool must not race it by "fixing" the view first.
socket.push([session(B, "Terminal")]);
expect(disposed).toEqual([]);
});
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
expect(document.activeElement).toBe(pane.firstElementChild);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
// docs/PREVIEW.md: previewing a session already open in the current tab
// moves the keyboard into the sidebar without ever calling setFocus, so
// `focusedId` keeps naming the pane the operator was in before — but the
// DOM disagrees, and the DOM is what must decide.
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(document.activeElement).toBe(sidebarInput);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
sidebarInput.remove();
});
it("counts focus on a descendant of the pane's element, not just the element itself", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
const textarea = document.createElement("textarea");
(pane.firstElementChild as HTMLElement).appendChild(textarea);
textarea.focus();
expect(document.activeElement).toBe(textarea);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("treats document.body (nothing meaningfully focused) as no active descendant", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(document.activeElement as HTMLElement | null)?.blur();
expect(document.activeElement).toBe(document.body);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
});
it("does not steal focus for a merely-visible pane that was never actually focused", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const paneA = document.createElement("div");
const paneB = document.createElement("div");
pool.mount(A, paneA);
pool.mount(B, paneB);
pool.setFocus(B); // B is the real keyboard focus; A is only visible
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
});
it("does not focus a parked (unmounted) connection it repairs", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane);
pool.setFocus(A);
pool.unmount(A); // parked — no longer visible, `focusedId` still names it
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
});
});
+18 -111
View File
@@ -1,10 +1,10 @@
import { createAttachConnection } from "./attach.js";
import type { AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import { createStructuredConnection } from "./structured.js";
import {
createAttachConnection,
type AttachConnection,
type AttachInfo,
} from "./attach.js";
import { setSessionFocus } from "./api.js";
import type { CatalogState, CatalogStore } from "./events.js";
import { sessionName, type SessionView, type Uuid } from "./types.js";
import type { Uuid } from "./types.js";
/**
* How many *parked* (off-screen but still-connected) sessions to keep warm
@@ -16,15 +16,6 @@ import { sessionName, type SessionView, type Uuid } from "./types.js";
*/
const MAX_PARKED = 6;
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
* meaningful once the session itself is known; an altogether-unknown
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
* state callers must check for separately, not "Terminal". */
function resolveView(view: SessionView | undefined): SessionView {
return view ?? "Terminal";
}
export interface PoolOptions {
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
onFocusChange?: (id: Uuid | null) => void;
@@ -36,9 +27,6 @@ export interface PoolOptions {
onClosed?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number) => boolean;
/** Threaded through to a structured connection so its rail can read live
* worktree/git-status data (#140). Terminal connections don't need it. */
store?: CatalogStore;
}
/**
@@ -52,19 +40,12 @@ export interface PoolOptions {
*/
export class ConnectionPool {
/** All live connections. Insertion order doubles as LRU: oldest first. */
private readonly cache = new Map<Uuid, PaneConnection>();
/** The view each cached connection was actually created for — a deep link
* or a restored layout can `mount()` a session before the catalog
* snapshot names its view, defaulting it to `"Terminal"`; this is what
* lets a later reconciliation notice the resolved view disagrees (#140
* review). */
private readonly connViews = new Map<Uuid, SessionView>();
private readonly cache = new Map<Uuid, AttachConnection>();
/** Sessions currently mounted into a visible pane (never evicted). */
private readonly mounted = new Set<Uuid>();
private readonly hidden: HTMLElement;
private focusedId: Uuid | null = null;
private readonly opts: PoolOptions;
private readonly unsubscribeStore?: () => void;
constructor(opts: PoolOptions = {}) {
this.opts = opts;
@@ -73,11 +54,6 @@ export class ConnectionPool {
this.hidden.className = "conn-cache";
this.hidden.setAttribute("aria-hidden", "true");
document.body.appendChild(this.hidden);
// A session `mount()`ed before its catalog entry exists gets a
// `"Terminal"` connection by default; once the snapshot actually names
// the view, fix any connection that guessed wrong — nothing else
// re-`mount()`s an already-open pane just because the catalog updated.
this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state));
}
/** The focused session, or null when nothing is focused (welcome pane). */
@@ -101,19 +77,18 @@ export class ConnectionPool {
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
let conn = this.cache.get(id);
if (conn) {
// `info === undefined` means "session not known yet", not "Terminal" —
// never downgrade a resolved connection just because a later caller
// couldn't resolve the session. Only a *resolved* view that disagrees
// with what this connection was built for forces a replace.
if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) {
conn = this.replace(id, info);
} else {
this.cache.delete(id);
this.cache.set(id, conn); // LRU refresh
if (info) conn.updateInfo(info);
}
this.cache.delete(id);
this.cache.set(id, conn);
} else {
conn = this.create(id, info);
conn = createAttachConnection(
id,
{
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
onClosed: (sid) => this.opts.onClosed?.(sid),
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
},
info,
);
this.cache.set(id, conn);
}
if (conn.el.parentElement !== pane) {
@@ -126,71 +101,6 @@ export class ConnectionPool {
this.evict();
}
private create(id: Uuid, info?: AttachInfo): PaneConnection {
const view = resolveView(info?.view);
this.connViews.set(id, view);
const connOpts = {
onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid),
onClosed: (sid: Uuid) => this.opts.onClosed?.(sid),
shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false,
};
return view === "Structured"
? createStructuredConnection(id, connOpts, info, this.opts.store)
: createAttachConnection(id, connOpts, info);
}
/** Dispose whatever is cached for `id` and cache a freshly created
* connection in its place. Does not touch the DOM or `mounted` — callers
* place the new element themselves. */
private replace(id: Uuid, info?: AttachInfo): PaneConnection {
this.cache.get(id)?.dispose();
const fresh = this.create(id, info);
this.cache.set(id, fresh);
return fresh;
}
/** Proactively fix any cached connection whose view no longer matches the
* catalog — the only path that repairs a pane nothing will `mount()`
* again on its own (parked, or mounted but not currently being
* re-rendered by anything else). A session absent from `state` (not yet
* known, or removed) is left alone — that's `main.ts`'s
* `closePane`/eviction concern, not a view mismatch. */
private reconcileViews(state: CatalogState): void {
for (const id of [...this.cache.keys()]) {
const session = state.sessions.get(id);
if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue;
const existing = this.cache.get(id)!;
const parent = existing.el.parentElement;
const wasMounted = this.mounted.has(id);
// Capture before anything below touches the DOM: dispose()/el.remove()
// detach `existing.el`, and once a focused descendant is detached the
// browser moves `document.activeElement` to `<body>` — too late to
// tell whether the keyboard was actually in this pane.
const hadDomFocus = existing.el.contains(document.activeElement);
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
const fresh = this.replace(id, info);
existing.el.remove(); // don't rely on dispose() to clear the DOM
if (parent && parent !== this.hidden) {
parent.replaceChildren(fresh.el);
} else {
// Parked: `this.hidden` holds every parked connection's element as
// siblings — append, never replaceChildren, or they'd all vanish.
this.hidden.appendChild(fresh.el);
}
if (wasMounted) {
// `focusedId` names the session the pool was last told to focus —
// but a same-tab sidebar preview moves the keyboard into the
// sidebar without clearing it (multiview.ts skips `setFocus` while
// previewing), so `focusedId` alone can point at a pane the
// keyboard already left. Only re-focus when the DOM agrees it
// never did.
fresh.onMount({ focus: this.focusedId === id && hadDomFocus });
} else {
fresh.onUnmount();
}
}
}
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
unmount(id: Uuid): void {
if (!this.mounted.delete(id)) return;
@@ -267,16 +177,13 @@ export class ConnectionPool {
if (!conn) return;
conn.dispose();
this.cache.delete(id);
this.connViews.delete(id);
this.mounted.delete(id);
if (this.focusedId === id) this.setFocus(null);
}
dispose(): void {
this.unsubscribeStore?.();
for (const conn of this.cache.values()) conn.dispose();
this.cache.clear();
this.connViews.clear();
this.mounted.clear();
this.hidden.remove();
}
-25
View File
@@ -130,31 +130,6 @@ describe("DaemonLink", () => {
expect(link.status().state).toBe("online");
});
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual();
const reload = vi.fn();
-11
View File
@@ -148,17 +148,6 @@ export class DaemonLink {
void this.probe();
}
/**
* Recheck liveness after the browser resumes from the background. Mobile
* browsers may suspend both WebSocket events and timers while a PWA is
* hidden, leaving a stale `down` state after the daemon is already back.
*/
recover(): void {
if (this.state === "updating") return;
this.noteDisconnected();
void this.probe();
}
/**
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
*
-15
View File
@@ -132,21 +132,6 @@ describe("CatalogStore reducer", () => {
});
describe("CatalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.start();
store.reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets);
-17
View File
@@ -59,23 +59,6 @@ export class CatalogStore {
this.connect();
}
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// A close event from the superseded socket must not schedule a second
// connection alongside the new one.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number {
return this.backoffMs;
-33
View File
@@ -1,33 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
closeForceKillDialog,
openForceKillDialog,
openKillConfirmDialog,
} from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
closeForceKillDialog();
document.body.innerHTML = "";
});
it("focuses the kill button, so Enter confirms", () => {
const el = openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
});
it("focuses the force button too", () => {
const el = openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
});
});
+3 -3
View File
@@ -132,9 +132,9 @@ function mount(p: KillDialogSpec): HTMLElement {
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
// The acting button takes the focus in both prompts: the operator already
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
confirm.focus();
// Cancel takes the focus in both prompts: a kill is never one keystroke
// away from whatever asked for the dialog.
cancel.focus();
return el;
}
-18
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
emptyLayout,
equalizeRatios,
leaves,
locateSession,
openSessions,
@@ -45,23 +44,6 @@ describe("splitLeaf", () => {
});
});
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1");
-11
View File
@@ -118,17 +118,6 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null;
}
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab {
const tab: Tab = {
id: freshId("t"),
+12 -22
View File
@@ -35,6 +35,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
@@ -107,19 +108,6 @@ void link.start();
store.start();
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after a
// transient lost connection has already recovered.
function recoverFromBackground(): void {
if (document.visibilityState !== "visible") return;
link.recover();
store.reconnect();
}
document.addEventListener("visibilitychange", recoverFromBackground);
window.addEventListener("pageshow", recoverFromBackground);
window.addEventListener("online", recoverFromBackground);
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) {
@@ -185,7 +173,6 @@ const pool = new ConnectionPool({
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
store,
});
// Session death used to reach the UI on two uncoordinated channels — the row
@@ -223,7 +210,7 @@ function scheduleSave(): void {
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
return s ? { name: sessionName(s), command: s.command, view: s.view } : undefined;
return s ? { name: sessionName(s), command: s.command } : undefined;
}
/** Drive the shell's responsive layout off whether any pane is open. */
@@ -453,11 +440,10 @@ const spawnKeys = new CommandSet(
}),
);
// Leader-h moves the keyboard between the session list and the pane, the web
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
// is already claimed by the browser (history), so nothing unmodified — or
// modified with a common browser chord — could get the focus back out of it.
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
//
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off
@@ -542,8 +528,8 @@ window.addEventListener(
);
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
// above) hands it back the other way.
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
@@ -551,6 +537,10 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
-4
View File
@@ -10,7 +10,6 @@ import {
activeTab,
addTab,
closeTab,
equalizeRatios,
findLeaf,
freshId,
type Dir,
@@ -458,7 +457,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render();
}
@@ -544,7 +542,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
}
render();
opts.onOpenSessionsChange?.();
@@ -818,7 +815,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render();
}
-29
View File
@@ -1,29 +0,0 @@
// Shared shape a mounted pane connection satisfies, whatever session view is
// behind it — what ConnectionPool needs to mount/park/dispose a pane without
// caring whether it holds an xterm attach (attach.ts) or a structured
// transcript (structured.ts).
import type { Uuid } from "./types.js";
import type { AttachInfo } from "./paneTitle.js";
export interface PaneConnection {
readonly sessionId: Uuid;
/** The element to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit/focus/sync as the connection needs.
* `focus: false` mounts without taking the keyboard (a sidebar preview). */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the hidden cache: keep streaming, stop driving focus/sizing. */
onUnmount(): void;
/** Put the keyboard into this pane's primary input — the terminal for an
* attach connection, the composer for a structured one. */
focusTerminal(): void;
/** Raw bytes into a PTY, bypassing normal key handling (the leader's
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
* has no PTY, so this is a no-op there. */
sendInput(data: string): void;
/** Refresh the pane header after the catalog renames a live session. */
updateInfo(info: AttachInfo): void;
/** Fully disconnect and remove the element. */
dispose(): void;
}
-28
View File
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { paneTitleAttr, paneTitleHtml } from "./paneTitle.js";
describe("paneTitleHtml", () => {
it("falls back to a short session id when there is no info yet", () => {
expect(paneTitleHtml("abcdef12-3456-7890-abcd-ef1234567890")).toContain("abcdef12");
});
it("shows the name alone when command matches it", () => {
const html = paneTitleHtml("s1", { name: "claude", command: "claude" });
expect(html).toContain("claude");
expect(html).not.toContain("at-cmd");
});
it("escapes a name that looks like markup", () => {
const html = paneTitleHtml("s1", { name: "<img onerror=x>", command: "claude" });
expect(html).not.toContain("<img");
});
});
describe("paneTitleAttr", () => {
it("escapes the attribute form too", () => {
const attr = paneTitleAttr("s1", { name: '"quoted"', command: "claude" });
expect(attr).not.toContain('"quoted"');
expect(attr).toContain("&quot;quoted&quot;");
});
});
-36
View File
@@ -1,36 +0,0 @@
// Pane header title: name/command echoing the sidebar card, shared by the
// xterm attach view (attach.ts) and the structured transcript view
// (structured.ts) so both panes read identically.
import { escapeHtml } from "./html.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { SessionView, Uuid } from "./types.js";
/** What a pane header shows about its session. Falls back to a short session
* id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
/** Which connection kind to mount (#140). Absent/`"Terminal"` mounts the
* xterm attach; `"Structured"` mounts the transcript view. */
view?: SessionView;
}
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function paneTitleHtml(sessionId: Uuid, info?: AttachInfo): string {
return info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
}
/** Attribute-safe (`title="..."`) form of the same identity. */
export function paneTitleAttr(sessionId: Uuid, info?: AttachInfo): string {
return escapeHtml(info ? `${info.name} · ${info.command} (${sessionId})` : sessionId);
}
-34
View File
@@ -37,36 +37,6 @@ export function sessionFocus(id: Uuid): string {
return `${session(id)}/focus`;
}
/** `WS /api/structured/:id` — transcript/state snapshots and live deltas. */
export function structured(id: Uuid): string {
return `${API_PREFIX}/structured/${encodeURIComponent(id)}`;
}
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
export function structuredPrompt(id: Uuid): string {
return `${session(id)}/structured/prompt`;
}
export function structuredAttachment(id: Uuid): string {
return `${session(id)}/structured/attachments`;
}
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`;
}
/** `POST /api/sessions/:id/structured/config` — change an ACP session option. */
export function structuredConfig(id: Uuid): string {
return `${session(id)}/structured/config`;
}
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
* request. */
export function structuredApproval(id: Uuid, nonce: string): string {
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
@@ -85,7 +55,3 @@ export function eventsWsUrl(): string {
export function attachWsUrl(id: Uuid): string {
return `${wsBase()}${attach(id)}`;
}
export function structuredWsUrl(id: Uuid): string {
return `${wsBase()}${structured(id)}`;
}
-24
View File
@@ -6,8 +6,6 @@ const base: SpawnFormValues = {
command: "claude",
cwd: "/home/user/project",
name: "amber-fox",
structuredView: false,
autoApprove: false,
worktreeEnabled: false,
worktreeBranch: "",
worktreeBranchMode: "new",
@@ -27,28 +25,6 @@ describe("buildSpawnRequest", () => {
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
});
it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
"requests structured view for resolved %s commands",
(command) => {
expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
req: {
cwd: base.cwd,
command,
metadata: { name: "amber-fox" },
view: "Structured",
},
});
},
);
it("does not request structured view for unsupported commands", () => {
expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
});
});
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
const res = buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },

Some files were not shown because too many files have changed in this diff Show More