aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/restore.rs
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 /crates/dinggerd/src/restore.rs
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.
Diffstat (limited to 'crates/dinggerd/src/restore.rs')
-rw-r--r--crates/dinggerd/src/restore.rs46
1 files changed, 33 insertions, 13 deletions
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]