aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock8
-rw-r--r--Cargo.toml3
-rw-r--r--README.md15
-rw-r--r--ROADMAP.md6
-rw-r--r--crates/dinggerd/src/hooks.rs90
-rw-r--r--crates/dinggerd/src/integrations.rs166
-rw-r--r--crates/dinggerd/src/main.rs10
-rw-r--r--crates/dinggerd/src/restore.rs46
-rw-r--r--crates/dinggerd/src/snapshot.rs9
-rw-r--r--crates/dinggerd/src/watcher.rs7
-rwxr-xr-xtests/e2e.sh108
11 files changed, 415 insertions, 53 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 86c34fe..4281ace 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -145,6 +145,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -432,6 +438,8 @@ version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
+ "foldhash",
+ "indexmap",
"itoa",
"memchr",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index b565ce2..b89f9a8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,8 @@ edition = "2021"
[workspace.dependencies]
regex = "1"
serde = { version = "1", features = ["derive"] }
-serde_json = "1"
+# preserve_order: `dingger install claude` keeps settings.json in its order.
+serde_json = { version = "1", features = ["preserve_order"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
diff --git a/README.md b/README.md
index 475f659..9a67c2d 100644
--- a/README.md
+++ b/README.md
@@ -64,9 +64,12 @@ than a poll is still a completion.
to preview, `--list` for saved snapshots, `--session` for some). Sessions
that exist are left alone, except a bare one of the same name (what a
terminal creates on startup), which is replaced with its clients moved
- over. Agents are resumed in their own sessions (pi: `pi --session
- <file>`, reported by the pi extension); agents that were mid-turn are
- listed. Other panes get the command line that was running typed at the
+ over. Agents are resumed in their own sessions: pi with `pi --session
+ <file>` (reported by the pi extension), Claude Code with `claude --resume
+ <id>` (reported by its SessionStart hook, `dingger install claude`), in
+ the directory they ran in. A session that never got a message has nothing
+ to resume, so its command line is typed instead. Agents that were
+ mid-turn are listed. Other panes get the command line that was running typed at the
prompt, not run: exactly as typed with the zsh hook (`dingger install
zsh`, then source the file it names from your `.zshrc`), otherwise the
program's arguments. An agent or program that exits within a minute
@@ -89,6 +92,10 @@ than a poll is still a completion.
1. **Agent hooks** (authoritative): `dingger install pi` installs a pi
extension that reports working/blocked/idle over
`~/.local/state/dingger/agent.sock`. Restart running pi agents to load it.
+ `dingger install claude` adds a SessionStart hook to
+ `~/.claude/settings.json` (keeping everything else; the original is
+ backed up to `settings.json.bak-dingger`) that reports only Claude's
+ session, for restore; its state comes from screen matching.
2. **Screen matching** (fallback): `capture-pane` text through herdr's
manifests, for agents without hooks.
@@ -102,6 +109,8 @@ dingger watch # the daemon (run by launchd, see below)
dingger status # agents in priority order
dingger focus [--pane ID] # jump to an agent (default: the top waiter)
dingger install pi # install the pi state-reporting extension
+dingger install claude # report Claude Code sessions, for restore
+dingger install zsh # record typed command lines, for restore
dingger manifests update # refresh manifests from herdr's catalog
```
diff --git a/ROADMAP.md b/ROADMAP.md
index 4aa76df..72efca8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,9 +2,9 @@
In priority order.
-1. ~~**Restore after a crash or shutdown.**~~ Done for pi (see the README).
- Next: other agents' session references (claude's session id through its
- hooks, `claude --resume <id>`; codex, ...).
+1. ~~**Restore after a crash or shutdown.**~~ Done for pi and Claude Code
+ (see the README). Next, when used: other agents' session references
+ (codex, ...).
2. **Raise the right window.** Jumping to an agent activates the terminal
app; with several windows (several Ghostty windows, say) it should raise
the exact window that holds the tmux client, not whichever was last used.
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index 191309a..c467c98 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -8,6 +8,8 @@
//!
//! A report may carry the agent's session reference (`agent_session_path`
//! or `agent_session_id`, herdr's names), which restore uses to resume it.
+//! A report without a state only records the session (Claude's
+//! SessionStart hook, `dingger hook claude`); state stays with the screen.
//!
//! Reports are state-change *events*: the watcher applies every one in
//! order, so a turn that starts and finishes between two polls is still a
@@ -24,7 +26,7 @@ use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::{Condvar, Mutex, MutexGuard, OnceLock};
-use std::time::Duration;
+use std::time::{Duration, Instant};
use detect_core::AgentState;
use serde::Deserialize;
@@ -45,6 +47,8 @@ struct PaneReports {
pending: Vec<AgentState>,
/// The agent's latest session reference.
session: Option<String>,
+ /// When the last report arrived.
+ at: Option<Instant>,
}
fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> {
@@ -78,12 +82,17 @@ pub fn wait_for_wake(timeout: Duration) {
/// Wake a running watcher (used by `dingger focus` and tmux focus hooks).
pub fn poke() -> Result<(), String> {
+ send("{\"method\":\"poke\"}")
+}
+
+/// Send one JSON message to the running watcher.
+pub fn send(message: &str) -> Result<(), String> {
use std::io::Write;
let mut stream = UnixStream::connect(socket_path())
.map_err(|err| format!("dingger watch is not running: {err}"))?;
stream
- .write_all(b"{\"method\":\"poke\"}\n")
- .map_err(|err| format!("poke failed: {err}"))
+ .write_all(format!("{message}\n").as_bytes())
+ .map_err(|err| format!("send failed: {err}"))
}
#[derive(Deserialize)]
@@ -96,7 +105,8 @@ struct HookMessage {
#[derive(Deserialize)]
struct HookParams {
pane_id: String,
- state: String,
+ #[serde(default)]
+ state: Option<String>,
#[serde(default)]
agent_session_path: Option<String>,
#[serde(default)]
@@ -117,30 +127,38 @@ fn handle_stream(stream: UnixStream) {
let (true, Some(params)) = (message.method == "pane.report_agent", message.params) else {
continue;
};
- let state = match params.state.as_str() {
- "working" => AgentState::Working,
- "blocked" => AgentState::Blocked,
- "idle" => AgentState::Idle,
- other => {
+ let state = match params.state.as_deref() {
+ None => None,
+ Some("working") => Some(AgentState::Working),
+ Some("blocked") => Some(AgentState::Blocked),
+ Some("idle") => Some(AgentState::Idle),
+ Some(other) => {
debug!(state = other, "unknown hook state");
continue;
}
};
- info!(pane = %params.pane_id, ?state, "hook report");
- {
- let mut reports = reports();
- let pane = reports.entry(params.pane_id).or_default();
- pane.last = Some(state);
- pane.pending.push(state);
- let session = params.agent_session_path.or(params.agent_session_id);
- if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) {
- pane.session = Some(session);
- }
+ match state {
+ Some(state) => info!(pane = %params.pane_id, ?state, "hook report"),
+ None => info!(pane = %params.pane_id, "hook session report"),
}
+ record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id));
wake();
}
}
+fn record(pane_id: String, state: Option<AgentState>, session: Option<String>) {
+ let mut reports = reports();
+ let pane = reports.entry(pane_id).or_default();
+ pane.at = Some(Instant::now());
+ if let Some(state) = state {
+ pane.last = Some(state);
+ pane.pending.push(state);
+ }
+ if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) {
+ pane.session = Some(session);
+ }
+}
+
/// Start listening; reports accumulate for the watcher to consume.
pub fn spawn_listener() -> Result<(), String> {
let path = socket_path();
@@ -186,12 +204,40 @@ pub fn session(pane_id: &str) -> Option<String> {
reports().get(pane_id).and_then(|pane| pane.session.clone())
}
-/// Drop a pane's reports (the agent exited).
-pub fn drop_reports(pane_id: &str) {
- reports().remove(pane_id);
+/// Drop a pane's reports: the agent was not running when the panes were
+/// listed at `listed`. A report since then is kept: the agent started
+/// after the listing (Claude reports its session once, as it starts).
+pub fn drop_reports(pane_id: &str, listed: Instant) {
+ let mut reports = reports();
+ if reports.get(pane_id).is_some_and(|pane| pane.at.is_none_or(|at| at < listed)) {
+ reports.remove(pane_id);
+ }
}
/// Drop reports for panes that no longer exist.
pub fn forget(pane_ids_to_keep: &[String]) {
reports().retain(|pane_id, _| pane_ids_to_keep.contains(pane_id));
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_session_report_records_only_the_session() {
+ record("%t1".into(), None, Some("/p/s.jsonl".into()));
+ assert_eq!(session("%t1").as_deref(), Some("/p/s.jsonl"));
+ assert_eq!(take_observations("%t1"), None, "state stays with the screen");
+ assert_eq!(last_state("%t1"), None);
+ }
+
+ #[test]
+ fn a_report_after_the_pane_listing_is_not_dropped() {
+ let listed = Instant::now();
+ record("%t2".into(), None, Some("/p/s.jsonl".into()));
+ drop_reports("%t2", listed);
+ assert!(session("%t2").is_some(), "the agent started after the listing");
+ drop_reports("%t2", Instant::now());
+ assert_eq!(session("%t2"), None, "listed since, without the agent: dropped");
+ }
+}
diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs
index d7cbd34..b3daf1d 100644
--- a/crates/dinggerd/src/integrations.rs
+++ b/crates/dinggerd/src/integrations.rs
@@ -2,9 +2,11 @@
//! state (herdr's integration model), so detection does not rely on screen
//! scraping for the agents that support it.
-use std::path::PathBuf;
+use std::io::Read;
+use std::path::{Path, PathBuf};
use detect_core::Agent;
+use serde_json::{json, Value};
/// The embedded pi extension, adapted from herdr's `herdr-agent-state.ts`
/// (Apache-2.0, Copyright herdr contributors).
@@ -63,6 +65,7 @@ pub fn install(agent: &str) -> Result<(), String> {
.ok_or_else(|| format!("unknown agent: {agent}"))?;
match parsed {
Agent::Pi => install_pi(),
+ Agent::Claude => install_claude(),
other => Err(format!(
"no hook integration for {} yet — screen detection is used",
detect_core::agent_label(other)
@@ -82,3 +85,164 @@ fn install_pi() -> Result<(), String> {
);
Ok(())
}
+
+/// Claude Code's user settings, where its hooks are configured.
+fn claude_settings_path() -> Result<PathBuf, String> {
+ std::env::var_os("CLAUDE_CONFIG_DIR")
+ .map(PathBuf::from)
+ .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
+ .map(|dir| dir.join("settings.json"))
+ .ok_or_else(|| "cannot determine Claude's config dir (no HOME)".to_string())
+}
+
+fn is_dingger_claude_hook(hook: &Value) -> bool {
+ hook.get("command")
+ .and_then(Value::as_str)
+ .is_some_and(|command| command.contains("dingger") && command.ends_with(" hook claude"))
+}
+
+/// `settings` with dingger's SessionStart hook running `command`, replacing
+/// an earlier one; every other key and hook is kept, in order.
+pub fn with_claude_hook(mut settings: Value, command: &str) -> Result<Value, String> {
+ let root = settings.as_object_mut().ok_or("settings.json is not a JSON object")?;
+ let hooks = root.entry("hooks").or_insert_with(|| json!({}));
+ let hooks = hooks.as_object_mut().ok_or("settings.json: \"hooks\" is not an object")?;
+ let groups = hooks.entry("SessionStart").or_insert_with(|| json!([]));
+ let groups = groups.as_array_mut().ok_or("settings.json: \"hooks.SessionStart\" is not a list")?;
+ groups.retain_mut(|group| {
+ let Some(list) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
+ return true;
+ };
+ let before = list.len();
+ list.retain(|hook| !is_dingger_claude_hook(hook));
+ // Drop a group only if it held nothing but dingger's hook.
+ !(list.is_empty() && before > 0)
+ });
+ groups.push(json!({"hooks": [{"type": "command", "command": command, "timeout": 10}]}));
+ Ok(settings)
+}
+
+/// Add the SessionStart hook to Claude's settings: Claude then reports each
+/// session's transcript (`dingger hook claude`), for restore to resume with
+/// `claude --resume <id>`. Claude's state still comes from its screen.
+fn install_claude() -> Result<(), String> {
+ let path = claude_settings_path()?;
+ // Write through a symlink to the file it points to.
+ let path = std::fs::canonicalize(&path).unwrap_or(path);
+ let original = match std::fs::read_to_string(&path) {
+ Ok(text) => Some(text),
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
+ Err(err) => return Err(format!("cannot read {}: {err}", path.display())),
+ };
+ let settings: Value = match &original {
+ Some(text) if !text.trim().is_empty() => serde_json::from_str(text)
+ .map_err(|err| format!("{} is not valid JSON ({err}); not changing it", path.display()))?,
+ _ => json!({}),
+ };
+ let exe = std::env::current_exe().map_err(|err| format!("cannot find the dingger binary: {err}"))?;
+ let command = format!("{} hook claude", crate::snapshot::shell_quote(&exe.to_string_lossy()));
+ let updated = with_claude_hook(settings.clone(), &command)?;
+ let mut text = serde_json::to_string_pretty(&updated).map_err(|err| err.to_string())?;
+ text.push('\n');
+ if original.as_deref() == Some(text.as_str()) {
+ println!("already installed in {}", path.display());
+ return Ok(());
+ }
+ if let Some(original) = &original {
+ let backup = path.with_extension("json.bak-dingger");
+ if !backup.exists() {
+ std::fs::write(&backup, original).map_err(|err| format!("backup failed: {err}"))?;
+ println!("backed up {} to {}", path.display(), backup.display());
+ }
+ }
+ write_replacing(&path, &text)?;
+ tracing::info!(path = %path.display(), "installed claude hook");
+ println!(
+ "installed in {}: a SessionStart hook running\n {command}\nnew Claude sessions report their session for restore; running ones report \
+ on their next start, /clear, or /resume",
+ path.display()
+ );
+ Ok(())
+}
+
+/// Replace `path` with `text` atomically, keeping its permissions.
+fn write_replacing(path: &Path, text: &str) -> Result<(), String> {
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir).map_err(|err| format!("mkdir failed: {err}"))?;
+ }
+ let temporary = path.with_extension(format!("json.dingger-{}", std::process::id()));
+ std::fs::write(&temporary, text).map_err(|err| format!("write failed: {err}"))?;
+ if let Ok(metadata) = std::fs::metadata(path) {
+ let _ = std::fs::set_permissions(&temporary, metadata.permissions());
+ }
+ std::fs::rename(&temporary, path).map_err(|err| {
+ let _ = std::fs::remove_file(&temporary);
+ format!("write failed: {err}")
+ })
+}
+
+/// `dingger hook <agent>`, run by the agent. Never fails it: exits 0 and
+/// prints nothing (Claude adds a SessionStart hook's output to the
+/// conversation).
+pub fn run_hook(agent: &str) {
+ if agent != "claude" {
+ return;
+ }
+ let mut input = String::new();
+ let _ = std::io::stdin().read_to_string(&mut input);
+ let pane = std::env::var("TMUX_PANE").ok();
+ if let Some(report) = claude_report(&input, pane.as_deref()) {
+ if let Err(err) = crate::hooks::send(&report) {
+ tracing::debug!(%err, "claude hook: no report");
+ }
+ }
+}
+
+/// The report for Claude's SessionStart hook input: the pane and the
+/// session's transcript, no state. `None` outside tmux, for other events,
+/// and for subagents.
+pub fn claude_report(input: &str, pane: Option<&str>) -> Option<String> {
+ let pane = pane.filter(|pane| pane.starts_with('%'))?;
+ let input: Value = serde_json::from_str(input).ok()?;
+ if input.get("hook_event_name")?.as_str()? != "SessionStart" || input.get("agent_id").is_some() {
+ return None;
+ }
+ let transcript = input.get("transcript_path")?.as_str().filter(|path| !path.is_empty())?;
+ let params = json!({"pane_id": pane, "agent": "claude", "agent_session_path": transcript});
+ Some(json!({"method": "pane.report_agent", "params": params}).to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn claude_hook_is_added_beside_other_hooks_and_keys_in_order() {
+ let settings: Value = serde_json::from_str(
+ r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]}],"Stop":[]},"theme":"auto"}"#,
+ )
+ .unwrap();
+ let once = with_claude_hook(settings, "/bin/dingger hook claude").unwrap();
+ let twice = with_claude_hook(once.clone(), "/new/dingger hook claude").unwrap();
+ assert_eq!(
+ twice.to_string(),
+ r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]},{"hooks":[{"type":"command","command":"/new/dingger hook claude","timeout":10}]}],"Stop":[]},"theme":"auto"}"#,
+ "replaced, not added again; nothing else changes"
+ );
+ assert_eq!(with_claude_hook(json!({}), "d hook claude").unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], "d hook claude");
+ assert!(with_claude_hook(json!([]), "d hook claude").is_err());
+ }
+
+ #[test]
+ fn claude_report_is_the_session_start_transcript() {
+ let start = r#"{"session_id":"abc","transcript_path":"/p/abc.jsonl","hook_event_name":"SessionStart","source":"startup"}"#;
+ assert_eq!(
+ claude_report(start, Some("%7")).as_deref(),
+ Some(r#"{"method":"pane.report_agent","params":{"pane_id":"%7","agent":"claude","agent_session_path":"/p/abc.jsonl"}}"#)
+ );
+ assert_eq!(claude_report(start, None), None, "outside tmux");
+ assert_eq!(claude_report(&start.replace("SessionStart", "Stop"), Some("%7")), None);
+ assert_eq!(claude_report(&start.replace("\"source\"", "\"agent_id\":\"x\",\"source\""), Some("%7")), None, "a subagent");
+ assert_eq!(claude_report("not json", Some("%7")), None);
+ }
+}
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index fced7c2..9606d5b 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -82,7 +82,13 @@ enum Command {
/// Install integrations: an agent's hook extension (state and session
/// reports), or `zsh` (command lines for restore to retype).
Install {
- /// `pi`, or `zsh`.
+ /// `pi`, `claude`, or `zsh`.
+ agent: String,
+ },
+ /// An agent's hook, run by the agent (`dingger install` sets it up).
+ #[command(hide = true)]
+ Hook {
+ /// `claude`.
agent: String,
},
}
@@ -164,6 +170,8 @@ fn main() {
std::process::exit(1);
}
}
+ // Never fails the agent: exits 0, prints nothing.
+ Command::Hook { agent } => integrations::run_hook(&agent),
}
}
diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs
index 1dca955..23d166e 100644
--- a/crates/dinggerd/src/restore.rs
+++ b/crates/dinggerd/src/restore.rs
@@ -10,8 +10,9 @@
//! pane. Everything is addressed by tmux id, so names with `.` or `:` work.
//!
//! Then, once each new shell shows its prompt: agents with a session
-//! reference are resumed (`pi --session <file>`, run), and every other
-//! saved command line is typed but not run, for the user to press Enter.
+//! reference are resumed (`pi --session <file>`, `claude --resume <id>`,
+//! run), and every other saved command line is typed but not run, for the
+//! user to press Enter.
use std::time::{Duration, Instant};
@@ -48,10 +49,20 @@ pub enum Typed {
Retype(String),
}
-/// The command that resumes an agent's session.
+/// The command that resumes an agent's session, from its session file.
+/// `None` when the file does not exist: a session that never got a message
+/// has nothing to resume (`claude --resume` would fail), so its command
+/// line is retyped instead, like any other.
fn resume_command(agent: &str, session: &str) -> Option<String> {
+ let file = std::path::Path::new(session);
+ if !file.is_file() {
+ return None;
+ }
match agent {
"pi" => Some(format!("pi --session {}", shell_quote(session))),
+ // By id, the transcript's name. It must run in the directory the
+ // session ran in (the pane's), or Claude moves the conversation.
+ "claude" => file.file_stem()?.to_str().map(|id| format!("claude --resume {}", shell_quote(id))),
_ => None,
}
}
@@ -657,18 +668,27 @@ mod tests {
assert_eq!(typed_for(&pane), Some(Typed::Retype("nvim 'my file.sh'".into())));
pane.command = Some("echo one\necho two".into());
assert_eq!(typed_for(&pane), None, "a newline would run it");
- pane.agent = Some(AgentSnap {
- agent: "pi".into(),
- name: "x".into(),
- state: "idle".into(),
- session: Some("/s/a b.jsonl".into()),
- });
- assert_eq!(typed_for(&pane), Some(Typed::Resume("pi --session '/s/a b.jsonl'".into())));
- if let Some(agent) = pane.agent.as_mut() {
- agent.session = None;
- }
+ let dir = std::env::temp_dir().join(format!("dingger-typed-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let file = |name: &str| dir.join(name).to_string_lossy().into_owned();
+ std::fs::write(file("a b.jsonl"), "").unwrap();
+ std::fs::write(file("4f1c-9e.jsonl"), "").unwrap();
+ let agent = |agent: &str, session: Option<String>| {
+ Some(AgentSnap { agent: agent.into(), name: "x".into(), state: "idle".into(), session })
+ };
pane.command = Some("pi --model x".into());
+ pane.agent = agent("pi", Some(file("a b.jsonl")));
+ assert_eq!(typed_for(&pane), Some(Typed::Resume(format!("pi --session '{}'", file("a b.jsonl")))));
+ pane.agent = agent("pi", None);
assert_eq!(typed_for(&pane), Some(Typed::Retype("pi --model x".into())), "no session: retyped");
+ pane.agent = agent("pi", Some(file("never-written.jsonl")));
+ assert_eq!(typed_for(&pane), Some(Typed::Retype("pi --model x".into())), "no session file: retyped");
+ pane.command = Some("claude --model opus".into());
+ pane.agent = agent("claude", Some(file("4f1c-9e.jsonl")));
+ assert_eq!(typed_for(&pane), Some(Typed::Resume("claude --resume 4f1c-9e".into())), "by id");
+ pane.agent = agent("claude", Some(file("no-messages.jsonl")));
+ assert_eq!(typed_for(&pane), Some(Typed::Retype("claude --model opus".into())), "no transcript: retyped");
+ std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
diff --git a/crates/dinggerd/src/snapshot.rs b/crates/dinggerd/src/snapshot.rs
index 07316e8..d4ee321 100644
--- a/crates/dinggerd/src/snapshot.rs
+++ b/crates/dinggerd/src/snapshot.rs
@@ -97,8 +97,8 @@ pub struct PaneSnap {
/// The agent running in the pane, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentSnap>,
- /// The command line running in the pane (not for agents with a
- /// session): retyped on restore, not run.
+ /// The command line running in the pane: retyped on restore, not run
+ /// (an agent's, when its session cannot be resumed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
@@ -249,8 +249,9 @@ pub fn capture(now_ms: u64) -> Option<Snapshot> {
let command = if f[20] == "1" || leader.is_none() || at_prompt {
None // the hook's text may be stale at a prompt
} else if let Some(agent) = &agent {
- // Resumed by session; otherwise retyped as it was started.
- agent.session.is_none().then(|| hook_command.unwrap_or_else(|| agent.agent.clone()))
+ // Kept even with a session: restore retypes it when the session
+ // cannot be resumed (it never got a message, or its file is gone).
+ Some(hook_command.unwrap_or_else(|| agent.agent.clone()))
} else {
hook_command.or_else(|| leader.and_then(command_line))
};
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index 91fa332..346bfa8 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -235,6 +235,7 @@ impl Watcher {
}
fn tick(&mut self) -> Result<(), String> {
+ let listed = std::time::Instant::now();
let panes = tmux::list_panes()?;
let now = unix_now();
@@ -244,7 +245,7 @@ impl Watcher {
hooks::forget(&panes.iter().map(|pane| pane.pane_id.clone()).collect::<Vec<_>>());
for pane in &panes {
- let Some(observations) = observe(pane) else {
+ let Some(observations) = observe(pane, listed) else {
debug!(target = %pane.display_target, "observation skipped (viewer screen)");
continue;
};
@@ -393,13 +394,13 @@ impl Watcher {
/// The pane's raw agent states this tick, in order, or `None` when the
/// screen is a viewer (transcript, picker) that says nothing about state.
-fn observe(pane: &tmux::Pane) -> Option<Vec<AgentState>> {
+fn observe(pane: &tmux::Pane, listed: std::time::Instant) -> Option<Vec<AgentState>> {
if pane.dead {
return Some(vec![AgentState::Idle]);
}
let Some(agent) = pane.agent else {
// The agent is gone; its last hook report must not outlive it.
- hooks::drop_reports(&pane.pane_id);
+ hooks::drop_reports(&pane.pane_id, listed);
return Some(vec![AgentState::Unknown]);
};
// Agent-reported state is authoritative; it survives restarts through
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 26914f1..c4bcabb 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -361,8 +361,18 @@ STATE2="$DIR/state2"
# tmux gives new windows the environment of the client that creates them:
# both the test and restore run with the fake pi first on PATH.
r() { env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" "$@"; }
+# A server restore starts gets the same plain shells as the test's own: the
+# user's login shell would read their profile, which puts the real agents
+# first on PATH.
+printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$DIR/tmux-test.conf"
renv() { env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
- DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG=/dev/null "$@"; }
+ DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" "$@"; }
+# Every pi or claude in a test pane must be a fake (they run from $DIR).
+real_agents() {
+ for tty in $(r list-panes -a -F '#{pane_tty}' 2>/dev/null | sed 's|/dev/||'); do
+ ps -o comm=,args= -t "$tty" | awk -v dir="$DIR/" '($1 ~ /(^|\/)(pi|claude)$/ || $2 ~ /^(pi|claude)$/) && index($2, dir) != 1'
+ done
+}
W="$DIR/w"; mkdir -p "$W/one" "$W/two" "$W/three"
# A fake pi named pi (the daemon finds agents by process name) that records
# its arguments.
@@ -417,7 +427,7 @@ fingerprint() {
BEFORE=$(fingerprint)
# Started directly, not through a function: $! must be the daemon itself.
env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
- DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG=/dev/null \
+ DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" \
"$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 &
DAEMON2_PID=$!
latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; }
@@ -436,6 +446,8 @@ AGENT=$(r display -p -t '=work:agent' '#{pane_id}')
GONE=$(r display -p -t '=work:gone' '#{pane_id}')
expect "the fake pi agents are detected" "r show -pv -t $AGENT @agent 2>/dev/null; r show -pv -t $GONE @agent 2>/dev/null" "pi
pi" 80
+# Session files exist: restore resumes only sessions with one.
+mkdir -p "$W/sessions" && touch "$W/sessions/a b.jsonl" "$W/sessions/gone.jsonl"
hook2 "$AGENT" working "$W/sessions/a b.jsonl"
hook2 "$GONE" idle "$W/sessions/gone.jsonl"
r set-option -p -t '=work:hooked' @dingger_cmd 'slp 200000 | cat'
@@ -473,6 +485,7 @@ cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null
expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0
expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl
--session $W/sessions/gone.jsonl"
+expect "no real agent runs in a test pane" "real_agents" ""
expect "both agents run" "r display -p -t '=work:agent' '#{pane_current_command}'; r display -p -t '=work:gone' '#{pane_current_command}'" "pi
pi"
expect "restore reports them" "grep -c '^resumed 2 of 2 agents' $DIR/restore.out" 1
@@ -508,6 +521,8 @@ expect "a new offer: all four sessions, tmux is not running" \
"python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80
mbr --restore work
expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE"
+expect "with the test's shell, not the user's login shell" "r display -p -t '=work:agent' '#{pane_current_command}'" bash
+expect "no real agent runs in a test pane (no server)" "real_agents" ""
expect "the offer shrinks to what is still missing" \
"python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \
"['0', 'keep', 'viewer']" 80
@@ -524,6 +539,95 @@ echo "17. kill during a write"
expect "20 kills right after done: hook and display state agree" \
"\"$ROOT/tests/kill-race.sh\" \"$DINGGER\" 20 | grep -o '^[^:]*: [0-9]* of' | sed 's/.*: //'" "0 of" 1
+# --- 18. claude: install its hook; restore resumes its session ---------------
+echo "18. claude"
+CC="$DIR/cc-config"; mkdir -p "$CC/real"
+cat >"$CC/real/settings.json" <<'JSON'
+{
+ "env": {
+ "TRACE_TO_LANGSMITH": "true"
+ },
+ "model": "sonnet",
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "^(startup|resume|clear|compact|fork)$",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash '/x/herdr-agent-state.sh' session",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+ },
+ "theme": "auto"
+}
+JSON
+cp "$CC/real/settings.json" "$CC/original.json"
+ln -s "$CC/real/settings.json" "$CC/settings.json"
+ccinstall() { CLAUDE_CONFIG_DIR="$CC" "$DINGGER" install claude; }
+ccinstall >"$DIR/ccinstall.out" 2>&1
+expect "install claude succeeds" "grep -c '^installed in' $DIR/ccinstall.out" 1
+expect "settings: the same keys in the same order, herdr's hook first, dingger's added" \
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(list(d), [h[\"command\"].rsplit(\"/\", 1)[-1] for g in d[\"hooks\"][\"SessionStart\"] for h in g[\"hooks\"]])' $CC/settings.json" \
+ "['env', 'model', 'hooks', 'theme'] [\"herdr-agent-state.sh' session\", 'dingger hook claude']"
+expect "everything else unchanged" \
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d[\"hooks\"][\"SessionStart\"].pop(); print(d == json.load(open(sys.argv[2])))' $CC/settings.json $CC/original.json" True
+expect "written through the symlink" "[ -L $CC/settings.json ] && cmp -s $CC/settings.json $CC/real/settings.json && echo yes" yes
+expect "the original is backed up" "cmp -s $CC/real/settings.json.bak-dingger $CC/original.json && echo same" same
+ccinstall >"$DIR/ccinstall2.out" 2>&1
+expect "installing again changes nothing" "cat $DIR/ccinstall2.out" "already installed in $(cd "$CC/real" && pwd -P)/settings.json"
+echo '{"model": ' >"$CC/broken.json"; cp "$CC/broken.json" "$CC/broken-copy.json"
+expect "invalid JSON is not touched" \
+ "CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; mkdir -p $CC/b; cp $CC/broken.json $CC/b/settings.json; CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; echo \$?; cmp -s $CC/b/settings.json $CC/broken-copy.json && echo unchanged" "1
+unchanged"
+# The exact command Claude would run, from the installed settings.
+HOOK_CMD=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"]["SessionStart"][-1]["hooks"][0]["command"])' "$CC/settings.json")
+expect "the hook prints nothing and exits 0, even with no daemon or bad input" \
+ "printf '{\"hook_event_name\":\"SessionStart\",\"transcript_path\":\"/x.jsonl\"}' | TMUX_PANE=%99 DINGGER_AGENT_SOCKET=$DIR/none.sock sh -c \"$HOOK_CMD\"; echo garbage | sh -c \"$HOOK_CMD\"; echo \"exit \$?\"" "exit 0"
+# A fake claude named claude: records its arguments, runs the SessionStart
+# hook with Claude's input (`claude <id>` starts session <id>, `claude
+# --resume <id>` resumes it), then waits.
+cp /bin/sleep "$DIR/rbin/real/claude" && codesign --force --sign - "$DIR/rbin/real/claude" 2>/dev/null
+cat >"$DIR/rbin/claude" <<EOF
+#!/bin/sh
+echo "\$*" >> $DIR/claude-args
+id=\${2:-\$1}
+printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' \
+ "\$id" "$W/claude/\$id.jsonl" "\$PWD" | DINGGER_AGENT_SOCKET=$DIR/hook2.sock sh -c "$HOOK_CMD"
+exec $DIR/rbin/real/claude 100000
+EOF
+chmod +x "$DIR/rbin/claude"
+# "talked" has a transcript; "fresh" never got a message, so it has none.
+mkdir -p "$W/claude" && touch "$W/claude/talked.jsonl"
+r new-session -d -s cc -n talked -c "$W/two"
+r new-window -d -t '=cc:' -n fresh -c "$W/three"
+r send-keys -t '=cc:talked' 'claude talked' Enter
+r send-keys -t '=cc:fresh' 'claude fresh' Enter
+r set-option -p -t '=cc:fresh' @dingger_cmd 'claude fresh'
+TALKED=$(r display -p -t '=cc:talked' '#{pane_id}')
+expect "claude is detected" "r show -pv -t '=cc:talked' @agent 2>/dev/null; r show -pv -t '=cc:fresh' @agent 2>/dev/null" "claude
+claude" 80
+expect "its hook's session is kept on its pane" "r show -pv -t $TALKED @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
+expect "snapshot: claude's session" "in_snapshot '\"session\": \"$W/claude/talked.jsonl\"'" True 80
+r kill-server
+expect "a new restore candidate" \
+ "python3 -c 'import json,sys; print(\"cc\" in [s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"]])' $STATE2/restore.json 2>/dev/null" True 80
+: >"$DIR/claude-args"
+renv "$DINGGER" restore --session cc >"$DIR/restore-cc.out" 2>&1
+expect "no real agent runs in a test pane (claude)" "real_agents" ""
+expect "claude resumed by id" "cat $DIR/claude-args" "--resume talked"
+expect "in its directory" "r display -p -t '=cc:talked' '#{pane_current_path}'" "$(cd "$W/two" && pwd -P)"
+expect "restore reports it" "grep -c '^resumed 1 of 1 agents' $DIR/restore-cc.out" 1
+expect "no transcript: its command line is typed, not run" \
+ "typed_line '=cc:fresh' | grep -o 'claude fresh\$'; r display -p -t '=cc:fresh' '#{pane_current_command}'" "claude fresh
+bash"
+expect "the resumed claude reports its session again" \
+ "r show -pv -t '=cc:talked' @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
+[ "$FAILS" -gt 0 ] && { echo "--- restore-cc:"; cat "$DIR/restore-cc.out"; }
+
# Every daemon this run started must stop with it.
kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null
expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0