aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-23 15:08:53 +0300
committerIgor Soarez <igor@soarez.org>2026-10-23 15:08:53 +0300
commit7520141a55e8876824dcb4e2cf5e8e3027efa59d (patch)
tree02022949d5b9bd41c5421ae7134ca8816f4b045b
parenta7d34521c7f874beb44e0314017ab28979550495 (diff)
restore Claude Code agents in their own sessions
`dingger install claude` adds a SessionStart hook to Claude's settings (every other key and hook kept, in order; the original backed up; written through a symlink; installing again changes nothing). The hook is `dingger hook claude`: it reports the session's transcript for its tmux pane, prints nothing (Claude adds SessionStart output to the conversation) and always exits 0. Claude's state still comes from its screen. restore types `claude --resume <id>` in the directory the session ran in (elsewhere, Claude moves the conversation there). it resumes an agent only when its session file exists: a Claude session that never got a message has no transcript, and `--resume` fails ("No conversation found"). the snapshot now keeps an agent's command line too, so such a pane gets it retyped instead of nothing. - the hook receiver takes a report without a state: only the session. - a report that arrives after the watcher listed the panes is not dropped as "agent gone": Claude reports once, as it starts. - serde_json preserve_order (indexmap is already a dependency, via toml). e2e step 18: install against a settings file like a real one, a fake claude running the exact installed hook command, resume by id, retype without a transcript, the resumed agent reporting again. a server that restore starts now gets the test's plain shells: it got the user's login shell, whose profile put the real claude first, which started (stuck at its trust dialog) during development. every test restore now checks that no real pi or claude runs in a test pane.
-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