diff --git a/CHANGELOG.md b/CHANGELOG.md index ad64bfa9..a13a4e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.57.0] - 2026-09-28 + +### Added + +- **`aid resume` reopens an agent session in one command.** After a restart, `aid resume` + opens the workspace picker, starts the workspace, and starts the agent with its own + resume words: `claude --resume` and `codex resume` open their session pickers, and + `gemini --resume` reopens the latest session. The agent is started as a fresh `aid` + launch starts it, so full auto, Remote Control named after the workspace id, `--rm` + and `--no-remote` all still apply. `aid resume ` skips the workspace picker. + ## [0.56.0] - 2026-09-28 ### Added diff --git a/README.md b/README.md index 3a23d128..28088a9d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ one argument instead of a clone, a config file and a build command. [![GitHub pull-requests merged](https://badgen.net/github/merged-prs/blooop/devlaunch)](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged) [![GitHub release](https://img.shields.io/github/release/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/releases/) [![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/) -[![Conda](https://img.shields.io/badge/conda-v0.56.0-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch) +[![Conda](https://img.shields.io/badge/conda-v0.57.0-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch) [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/) [![Platform](https://img.shields.io/badge/platform-linux--64-blue)](https://github.com/blooop/devlaunch/releases) [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh) @@ -341,7 +341,7 @@ clone, and [docs/cleanup.md](docs/cleanup.md) says what it carries one past and ```bash $ dl --version -dl 0.56.0 +dl 0.57.0 ``` `--devcontainer ` picks a non-default `devcontainer.json`. A bare name means @@ -378,6 +378,7 @@ precedence order and what a profile does not change. ```bash aid [@branch|@] [prompt...] +aid resume [] ``` `aid` is a shortcut, not a second launcher. It rewrites its command line into a `dl` one, so @@ -440,6 +441,12 @@ typed. The agent's CLI has to be in the container already. `aid` runs it; it does not install it. +After a restart, `aid resume` is the way back into an agent session in one command. It +opens `dl`'s picker, starts the workspace you pick, and starts the agent with its own +resume picker, Remote Control and full auto included. `aid resume ` skips the +first picker. [docs/cli.md](docs/cli.md#aid-resume-back-into-a-session-after-a-restart) +has the words per agent. + ## What every workspace gets `dl` launches arbitrary repos, so none of this can depend on the image, and no repo has to add diff --git a/docs/cli.md b/docs/cli.md index 8c0af181..a54a59d0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -730,6 +730,48 @@ claude all take it offline immediately. The entry can sit in the claude.ai list roughly 4 hours after that before it clears, which is the web side timing out rather than anything still running on your machine. +## `aid resume`: back into a session after a restart + +```bash +aid resume [] +``` + +A machine that turns off ends every agent session and stops every container. The +conversation is not lost: the agent keeps it on disk inside the container, and a +stopped container keeps its disk. +`aid resume` is the way back in, in one command. With no workspace it opens the same +picker `dl` does. Then it starts the workspace and hands the agent its own resume +words, so for claude it is exactly + +```bash +dl -- CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 claude --dangerously-skip-permissions --remote-control= --resume +``` + +and claude's picker lists the sessions that were run in that workspace. Everything a +fresh `aid` launch does still happens: the same full-auto flag, the same Remote Control +name, `--no-remote` and `--rm` from either end of the line, and the agent flags. The +words per agent are these: + +| Agent | Words | What the agent does | +|---|---|---| +| `claude` | `--resume` | Opens its session picker | +| `codex` | `resume` | Opens its session picker | +| `gemini` | `--resume` | Reopens its latest session. Its picker is `/resume`, inside the session | + +The claude row was checked against `claude --help`. The codex and gemini rows follow +those CLIs' documentation and were not run. + +`resume` is a verb only in the first positional slot, the way `dl stop` is. After the +workspace it is prompt text like any other word, so `aid owner/repo resume the work` +still sends a prompt. A workspace that is itself called `resume` is `aid resume +resume`. Words after the workspace on a resume line are refused before anything +boots, because the three agents would each read them as something different: a +search term, a session id, a session index. + +The picker is asked for before the line is built, not handed to `dl -- ` to +open. The line names the Remote Control session after the workspace id, and the id is +not known until the pick is made. + ## `kill`: the workspace that will not answer `dl stop` asks devpod to stop a workspace, and it is the right thing to type diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a81d8643..1c171a8f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -13,10 +13,11 @@ dependencies = [ [[package]] name = "aid" -version = "0.56.0" +version = "0.57.0" dependencies = [ "devlaunch-test-support", "dl", + "libc", "portable-pty", "serde_json", "tempfile", @@ -437,7 +438,7 @@ dependencies = [ [[package]] name = "devlaunch-core" -version = "0.56.0" +version = "0.57.0" dependencies = [ "devlaunch-runner", "devlaunch-test-support", @@ -456,7 +457,7 @@ dependencies = [ [[package]] name = "devlaunch-runner" -version = "0.56.0" +version = "0.57.0" dependencies = [ "libc", "portable-pty", @@ -465,7 +466,7 @@ dependencies = [ [[package]] name = "devlaunch-test-support" -version = "0.56.0" +version = "0.57.0" dependencies = [ "devlaunch-runner", "serde", @@ -507,7 +508,7 @@ dependencies = [ [[package]] name = "dl" -version = "0.56.0" +version = "0.57.0" dependencies = [ "clap", "devlaunch-core", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e033b22b..66752025 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,7 +11,7 @@ members = [ # The single source of the version (docs/rust-rewrite-plan.md: cutover ships # 0.1.0, version read from Cargo.toml). [workspace.package] -version = "0.56.0" +version = "0.57.0" edition = "2024" license = "MIT" repository = "https://github.com/blooop/devlaunch" diff --git a/rust/aid/Cargo.toml b/rust/aid/Cargo.toml index 902bb26b..1d897d52 100644 --- a/rust/aid/Cargo.toml +++ b/rust/aid/Cargo.toml @@ -30,6 +30,8 @@ dl = { path = "../dl" } dev-build = ["dl/dev-build"] [dev-dependencies] +# `setsid` for the tests that need `aid` to run with no controlling terminal. +libc = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } # The harness crate, and only ever that: `KeepingCoverage`, so the boundary tests diff --git a/rust/aid/src/main.rs b/rust/aid/src/main.rs index b18d3746..e59439e3 100644 --- a/rust/aid/src/main.rs +++ b/rust/aid/src/main.rs @@ -106,9 +106,12 @@ fn run(argv: &[String]) -> i32 { // No arguments is the help *and* a failure, which is Python's pair of endings for // one body: somebody who typed `aid` asked for a workspace and named none, and // somebody who typed `aid --help` got what they asked for. - let asked_for_help = argv - .first() - .is_some_and(|word| word == "--help" || word == "-h"); + let is_help = |word: &String| word == "--help" || word == "-h"; + let asked_for_help = match argv { + [first, second, ..] if first == rewrite::RESUME_WORD => is_help(second), + [first, ..] => is_help(first), + [] => false, + }; if argv.is_empty() || asked_for_help { print!("{}", help()); return if argv.is_empty() { 1 } else { 0 }; @@ -139,7 +142,14 @@ fn run(argv: &[String]) -> i32 { remote_control: remote_control.as_deref(), }; let parsed = match rewrite::parse_aid_args(argv, environment) { - Ok(parsed) => parsed, + Ok(rewrite::Line::Ready(parsed)) => parsed, + // `aid resume` with no workspace. The pick comes before everything below, + // which is all about one named workspace, so from here on this line is an + // `aid resume ` like any other. + Ok(rewrite::Line::Unpicked(unpicked)) => match dl::pick_workspace() { + Ok(workspace_id) => unpicked.picked(workspace_id), + Err(code) => return code, + }, Err(refused) => { eprintln!("{}", refusal(&refused)); return 1; @@ -260,6 +270,11 @@ fn refusal(refused: &UsageError) -> String { dl::python_repr(value), rewrite::remote_control_values().join(", ") ), + UsageError::ResumeTakesNoPrompt { words } => format!( + "aid resume takes a workspace and nothing after it, not {}: the agent's own \ + picker chooses the session. Use aid resume [].", + dl::python_repr(words) + ), } } @@ -293,6 +308,14 @@ already running, and never rebuilt just because aid asked for it. Usage: aid [@branch] [prompt...] Open the workspace and start the agent aid [prompt...] Same, for an existing workspace or ./path + aid resume [] Reopen an earlier agent session in the + workspace. With no workspace, pick one + from dl's picker first + +`aid resume` starts the agent the way a fresh launch does (same agent flag, +full-auto, Remote Control named after the workspace) and hands it its own +resume words: claude and codex open their session picker, and gemini +reopens its latest session. With no prompt on a terminal, aid boots the workspace in the background and asks for the prompt while it does: type it free of shell quoting and press @@ -348,6 +371,8 @@ Examples: aid blooop/devlaunch@fix/42 fix the bug # Open the branch, hand over the prompt aid --gemini ./my-project explain this # Pick a different agent aid --no-remote blooop/devlaunch # Nothing but the session in front of you + aid resume # Pick a workspace, then a session in it + aid resume blooop/devlaunch@fix/42 # Pick a session in that workspace aid blooop/devlaunch@fix/42 fix the bug --rm # The line above, recalled, with the # workspace deleted once the agent is @@ -538,6 +563,14 @@ mod tests { assert!(help.contains("DEVLAUNCH_NO_TTY=1"), "{help}"); } + #[test] + fn the_help_names_resume_and_what_it_opens() { + let help = help(); + + assert!(help.contains("aid resume []"), "{help}"); + assert!(help.contains("from dl's picker"), "{help}"); + } + #[test] fn the_help_names_remote_control_and_what_it_needs() { let help = help(); diff --git a/rust/aid/src/rewrite.rs b/rust/aid/src/rewrite.rs index 1c54e5e3..b65d6916 100644 --- a/rust/aid/src/rewrite.rs +++ b/rust/aid/src/rewrite.rs @@ -34,8 +34,32 @@ struct Agent { /// line asking for Remote Control is a launch or a refusal, so the capability is /// stated once and consulted from both ends. remote_control: Option<&'static str>, + /// The words that reopen one of this agent's earlier sessions instead of + /// starting a new one: what `aid resume` appends in place of a prompt. + /// + /// Words rather than a flag, because the agents do not agree on the shape. + /// claude takes a flag, and a bare `--resume` opens its session picker. codex + /// takes a subcommand, and a bare `resume` opens its picker too. gemini takes a + /// flag, and a bare `--resume` reopens the latest session, since gemini keeps + /// its picker inside the session (`/resume`). The words go last on the line, + /// because claude's `--resume [value]` takes an optional value and would read + /// any word after it as a search term. + /// + /// The claude row was read off `claude --help` (2.1.x). The codex and gemini + /// rows are those CLIs' documented spellings and were not run where this was + /// written, because neither was installed there. + resume: &'static [&'static str], } +/// The word that makes a line reopen an earlier session: `aid resume [workspace]`. +/// +/// A verb and not a flag, for dl's reason: `dl stop` is the verb with no +/// workspace, and a verb in the first slot is what the picker hangs off. Only the +/// first positional word is read this way, so `aid owner/repo resume the work` is +/// still a prompt, and `aid ./resume` still names a directory. The cost is dl's +/// cost too: a workspace called `resume` has to be written `aid resume resume`. +pub(crate) const RESUME_WORD: &str = "resume"; + /// The agent this build starts when nothing picks one. pub(crate) const DEFAULT_AGENT: &str = "claude"; @@ -122,6 +146,7 @@ const AGENTS: &[(&str, Agent)] = &[ ("IS_SANDBOX", "1"), ], remote_control: Some("--remote-control"), + resume: &["--resume"], }, ), ( @@ -131,6 +156,7 @@ const AGENTS: &[(&str, Agent)] = &[ prompt_flags: &[], env: &[], remote_control: None, + resume: &["resume"], }, ), ( @@ -140,6 +166,7 @@ const AGENTS: &[(&str, Agent)] = &[ prompt_flags: &["--prompt-interactive"], env: &[], remote_control: None, + resume: &["--resume"], }, ), ]; @@ -281,6 +308,13 @@ pub(crate) enum UsageError { /// who thinks they turned something off, and silently meaning *off* is a person /// who thinks they turned something on. UnknownRemoteControlInEnvironment { value: String }, + /// `aid resume ` with words after the workspace. + /// + /// Refused rather than handed on, because the words have no one place to go: + /// claude would read them as a search term for its picker, codex as a session + /// id, and gemini as a session index. A line that means three things by agent + /// is a line nobody can predict. + ResumeTakesNoPrompt { words: String }, } /// The variables aid reads, resolved by the caller. @@ -411,6 +445,17 @@ pub(crate) enum Task { /// had, and no later stage has to ask again. remote_control: RemoteControl, }, + /// Reopen one of the agent's earlier sessions in the workspace: `aid resume`. + /// + /// Its own arm and not an [`Task::Agent`] with a flag, because it has no prompt. + /// The agent's own picker is what chooses the session, so a prompt beside it + /// would be a field to ignore, and the interactive editor, which asks for a + /// prompt, must not open for it. + Resume { + agent: String, + /// As on [`Task::Agent`]: settled against the agent's table row. + remote_control: RemoteControl, + }, /// A line spelling a flag this build has retired ([`SUFFIX_RETIRED`]). /// /// Carries nothing: what it asks dl for is the *refusal*, which is dl's sentence @@ -427,7 +472,7 @@ impl AidArgs { /// caller needs when reporting an agent name the environment invented. pub(crate) fn agent(&self) -> Option<&str> { match &self.task { - Task::Agent { agent, .. } => Some(agent), + Task::Agent { agent, .. } | Task::Resume { agent, .. } => Some(agent), Task::Retired => None, } } @@ -458,6 +503,50 @@ impl AidArgs { } } +/// What the parse settled: a line that names its workspace, or one that asks for +/// the picker first. +/// +/// Two arms rather than an `Option` spec on [`AidArgs`], because only one line can +/// be missing its workspace — `aid resume` on its own — and everything downstream +/// of the parse (the pull request lookup, the banner, the boot, the dl line) needs +/// a workspace to act on. Keeping the gap out here is what lets [`AidArgs::spec`] +/// stay a `String` that is always there. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Line { + Ready(AidArgs), + /// `aid resume` with no workspace: pick one, then [`Unpicked::picked`]. + Unpicked(Unpicked), +} + +/// An `aid resume` line waiting for the workspace the picker will choose. +/// +/// Everything [`AidArgs`] holds except the spec, with the task's fields in place +/// of a [`Task`], because the only task a line with no workspace can reach is +/// [`Task::Resume`]. Private fields and one way out, so the only thing that can be +/// done with it is to name the workspace. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Unpicked { + dl_options: Vec, + spec_options: Vec, + agent: String, + remote_control: RemoteControl, +} + +impl Unpicked { + /// The same line, for the workspace that was picked. + pub(crate) fn picked(self, spec: String) -> AidArgs { + AidArgs { + spec, + dl_options: self.dl_options, + spec_options: self.spec_options, + task: Task::Resume { + agent: self.agent, + remote_control: self.remote_control, + }, + } + } +} + /// The dl command line that boots the workspace without attaching to it. /// /// `[
…, , "up"]` — dl's own warm-up verb, which is idempotent @@ -629,10 +718,15 @@ fn peel_suffix(argv: &[String]) -> Option> { /// A `--rm` can also arrive *before* the spec (`aid --rm owner/repo fix it`), which /// is the same request written the other way round: an unrecognised leading flag is /// passed through to dl, and dl takes it in either position. +/// +/// [`RESUME_WORD`] in the spec's place makes it a resume line, and the next +/// positional word is the spec. With none, the answer is [`Line::Unpicked`] and +/// the caller asks the picker, unless the line spells a retired flag: that is +/// [`UsageError::NoWorkspace`], as it is on any other line with no spec. pub(crate) fn parse_aid_args( argv: &[String], environment: Environment<'_>, -) -> Result { +) -> Result { // Resolved before anything else, and kept even for a line that turns out to // start no agent: a `DEVLAUNCH_AID_AGENT` naming an agent that does not exist // is broken regardless of what this particular line asked for. Same for a @@ -645,6 +739,7 @@ pub(crate) fn parse_aid_args( }; let mut dl_options: Vec = Vec::new(); let mut spec: Option = None; + let mut resuming = false; let mut at = 0; while at < line.len() { let word = line[at].as_str(); @@ -680,13 +775,29 @@ pub(crate) fn parse_aid_args( at += 1; continue; } + // Once, so that `aid resume resume` still reaches a workspace called that. + if word == RESUME_WORD && !resuming { + resuming = true; + at += 1; + continue; + } spec = Some(word.to_owned()); at += 1; break; } - let Some(spec) = spec else { + // The words after the spec, which are a prompt everywhere but a resume line. + let rest = &line[at.min(line.len())..]; + if resuming && !rest.is_empty() { + return Err(UsageError::ResumeTakesNoPrompt { + words: rest.join(" "), + }); + } + // dl is what refuses a retired spelling, and it needs a workspace to be asked + // about, so a resume line carrying one goes no further than `aid --stop` does. + let retired = names_a_retired_spelling(&trailing); + if spec.is_none() && (!resuming || retired) { return Err(UsageError::NoWorkspace); - }; + } // A run at the end of the line was typed after everything before it, so it wins // for the same reason the last of two leading flags does. This is the position // the off switch is actually typed in: appending to a recalled line is the cheap @@ -699,22 +810,34 @@ pub(crate) fn parse_aid_args( // looks like. Settled before the task is built, so the `RemoteControl` the task // carries is one an agent row supplied the flag for. let remote_control = remote_control.settle(&agent)?; - let prompt = line[at.min(line.len())..].join(" "); - let retired = names_a_retired_spelling(&trailing); - Ok(AidArgs { + let Some(spec) = spec else { + return Ok(Line::Unpicked(Unpicked { + dl_options, + spec_options: trailing, + agent, + remote_control, + })); + }; + let task = if retired { + Task::Retired + } else if resuming { + Task::Resume { + agent, + remote_control, + } + } else { + Task::Agent { + agent, + prompt: rest.join(" "), + remote_control, + } + }; + Ok(Line::Ready(AidArgs { spec, dl_options, spec_options: trailing, - task: if retired { - Task::Retired - } else { - Task::Agent { - agent, - prompt, - remote_control, - } - }, - }) + task, + })) } /// Which agent `--gemini` and friends name. @@ -753,6 +876,35 @@ pub(crate) fn build_agent_command( agent: &str, prompt: &str, remote_control: Option<&str>, +) -> Option> { + agent_line(agent, Opening::Prompt(prompt), remote_control) +} + +/// The argv that reopens one of the agent's earlier sessions inside the workspace. +/// +/// [`build_agent_command`]'s line with the agent's [`Agent::resume`] words where +/// the prompt would go: the same variables, the same full-auto flag and the same +/// Remote Control name, so a resumed session is started exactly as the one it +/// resumes was. +pub(crate) fn build_resume_command( + agent: &str, + remote_control: Option<&str>, +) -> Option> { + agent_line(agent, Opening::Resume, remote_control) +} + +/// How a session begins: with a prompt (empty for none), or by reopening one. +#[derive(Clone, Copy)] +enum Opening<'a> { + Prompt(&'a str), + Resume, +} + +/// The one builder behind [`build_agent_command`] and [`build_resume_command`]. +fn agent_line( + agent: &str, + opening: Opening<'_>, + remote_control: Option<&str>, ) -> Option> { let (_, started) = AGENTS.iter().find(|(name, _)| *name == agent)?; // No prompt to be interactive about: start the agent's plain session, without @@ -765,9 +917,13 @@ pub(crate) fn build_agent_command( if let Some(named_session) = &named_session { words.push(named_session.as_str()); } - if !prompt.is_empty() { - words.extend(started.prompt_flags.iter().copied()); - words.push(prompt); + match opening { + Opening::Prompt("") => {} + Opening::Prompt(prompt) => { + words.extend(started.prompt_flags.iter().copied()); + words.push(prompt); + } + Opening::Resume => words.extend(started.resume.iter().copied()), } // Assignments prefixing a command set the variables for that command only, so // the agent is the one process that sees them and nothing in the login shell dl @@ -820,36 +976,36 @@ pub(crate) fn build_dl_args( // Behind the spec and ahead of the verb flags, which is where dl reads them as // modifiers rather than as the workspace's name. args.extend(parsed.spec_options.iter().cloned()); - match &parsed.task { + // The session is named after the workspace, so the list on claude.ai reads as + // the workspaces they opened, and by its id rather than the spec as typed, + // because the id is a name another agent can message. Claude Code's + // `SendMessage` refuses any `to` holding a `/` ("to must be a bare teammate + // name"), which every `owner/repo` spec does, and the id is the name `dl --ls`, + // devpod and the hostname already use. + // + // The spec is the fallback only for a spec no id can be found for, which the + // launch below then refuses itself. A resumed session takes the same name, so + // it is listed on claude.ai where the session it resumes was. + let session = |remote_control: &RemoteControl| match remote_control { + RemoteControl::On => { + Some(workspace_id_of(&parsed.spec).unwrap_or_else(|| parsed.spec.clone())) + } + RemoteControl::Off => None, + }; + let command = match &parsed.task { Task::Agent { agent, prompt, remote_control, - } => { - args.push("--".to_owned()); - // The session is named after the workspace, so the list on claude.ai - // reads as the workspaces they opened, and by its id rather than the - // spec as typed, because the id is a name another agent can message. - // Claude Code's `SendMessage` refuses any `to` holding a `/` ("to must - // be a bare teammate name"), which every `owner/repo` spec does, and - // the id is the name `dl --ls`, devpod and the hostname already use. - // - // The spec is the fallback only for a spec no id can be found for, - // which the launch below then refuses itself. - let session = match remote_control { - RemoteControl::On => { - Some(workspace_id_of(&parsed.spec).unwrap_or_else(|| parsed.spec.clone())) - } - RemoteControl::Off => None, - }; - args.extend( - build_agent_command(agent, prompt, session.as_deref())? - .iter() - .cloned(), - ); - } - Task::Retired => {} - } + } => build_agent_command(agent, prompt, session(remote_control).as_deref())?, + Task::Resume { + agent, + remote_control, + } => build_resume_command(agent, session(remote_control).as_deref())?, + Task::Retired => return Some(args), + }; + args.push("--".to_owned()); + args.extend(command.iter().cloned()); Some(args) } @@ -873,6 +1029,19 @@ mod tests { argv.iter().map(|word| (*word).to_owned()).collect() } + /// [`super::parse_aid_args`] for a line that names its workspace, which is + /// every line these tests parse but the ones that ask for the picker. Those + /// call [`super::parse_aid_args`] by its full path. + fn parse_aid_args( + argv: &[String], + environment: Environment<'_>, + ) -> Result { + super::parse_aid_args(argv, environment).map(|line| match line { + Line::Ready(parsed) => parsed, + Line::Unpicked(unpicked) => panic!("{argv:?} asked for the picker: {unpicked:?}"), + }) + } + fn parsed(argv: &[&str]) -> AidArgs { parse_aid_args(&words(argv), Environment::default()).expect("a usable command line") } @@ -893,12 +1062,13 @@ mod tests { } } - /// The prompt an agent line carries. Panics on a retired-spelling line, which - /// carries none. + /// The prompt an agent line carries. Panics on a retired-spelling line or a + /// resume line, which carry none. fn prompt(parsed: &AidArgs) -> &str { match &parsed.task { Task::Agent { prompt, .. } => prompt, Task::Retired => panic!("a retired-spelling line has no prompt"), + Task::Resume { .. } => panic!("a resume line has no prompt"), } } @@ -906,7 +1076,9 @@ mod tests { /// which starts no session to drive. fn remote_control(parsed: &AidArgs) -> RemoteControl { match &parsed.task { - Task::Agent { remote_control, .. } => *remote_control, + Task::Agent { remote_control, .. } | Task::Resume { remote_control, .. } => { + *remote_control + } Task::Retired => panic!("a retired-spelling line starts no agent"), } } @@ -2217,4 +2389,142 @@ mod tests { "{built:?}" ); } + + // ------------------------------------------------------- aid resume + + /// A resume line that names no workspace, waiting for the picker. + fn unpicked(argv: &[&str]) -> Unpicked { + match super::parse_aid_args(&words(argv), Environment::default()) { + Ok(Line::Unpicked(unpicked)) => unpicked, + other => panic!("{argv:?} did not ask for the picker: {other:?}"), + } + } + + #[test] + fn resume_alone_asks_for_the_picker_and_then_resumes_claude_in_the_pick() { + // The whole feature, end to end on the aid side: the picked id becomes the + // spec, and the line is a fresh launch's with `--resume` where the prompt + // would be. `--resume` is last because claude reads a word after it as a + // search term. + let parsed = unpicked(&["resume"]).picked("devlaunch-main-3j1t".to_owned()); + + assert_eq!( + build_dl_args(&parsed, &id_of).expect("a known agent"), + [ + "devlaunch-main-3j1t", + "--", + "CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1", + "IS_SANDBOX=1", + "claude", + "--dangerously-skip-permissions", + "--remote-control=ws-id", + "--resume", + ] + ); + } + + #[test] + fn resume_with_a_workspace_needs_no_picker() { + let parsed = parsed(&["resume", "owner/repo@branch"]); + + assert_eq!(parsed.spec, "owner/repo@branch"); + assert_eq!( + parsed.task, + Task::Resume { + agent: "claude".to_owned(), + remote_control: RemoteControl::On, + } + ); + } + + #[test] + fn every_agent_resumes_with_its_own_words_at_the_end_of_the_line() { + for (name, agent) in AGENTS { + let flag = format!("--{name}"); + let built = + build_dl_args(&parsed(&[&flag, "resume", "ws"]), &id_of).expect("a known agent"); + + assert!(!agent.resume.is_empty(), "{name} has no way to resume"); + assert!(built.ends_with(&words(agent.resume)), "{name}: {built:?}"); + // Still full auto: a resumed session is started as a fresh one is. + for word in agent.command { + assert!(built.iter().any(|built| built == word), "{name}: {built:?}"); + } + } + } + + #[test] + fn the_agent_flag_and_the_off_switch_work_on_either_side_of_resume() { + for argv in [["--codex", "resume", "ws"], ["resume", "--codex", "ws"]] { + assert_eq!(parsed(&argv).agent(), Some("codex"), "{argv:?}"); + } + for argv in [ + ["--no-remote", "resume", "ws"], + ["resume", "ws", "--no-remote"], + ] { + let built = build_dl_args(&parsed(&argv), &id_of).expect("a known agent"); + assert!( + !built + .iter() + .any(|word| word.starts_with("--remote-control")), + "{argv:?}: {built:?}" + ); + assert_eq!(built.last().map(String::as_str), Some("--resume")); + } + } + + #[test] + fn words_after_the_workspace_on_a_resume_line_are_refused() { + assert_eq!( + super::parse_aid_args( + &words(&["resume", "owner/repo", "fix", "it"]), + Environment::default() + ), + Err(UsageError::ResumeTakesNoPrompt { + words: "fix it".to_owned() + }) + ); + } + + #[test] + fn resume_is_a_verb_only_in_the_first_slot() { + // After the spec it is prompt text, as every word there is. + assert_eq!( + prompt(&parsed(&["owner/repo", "resume", "the", "work"])), + "resume the work" + ); + // And said twice, the second is a workspace called `resume`. + let twice = parsed(&["resume", "resume"]); + assert_eq!(twice.spec, "resume"); + assert!(matches!(twice.task, Task::Resume { .. })); + } + + #[test] + fn rm_rides_a_resume_line_from_either_end() { + let leading = unpicked(&["--rm", "resume"]).picked("ws".to_owned()); + let trailing = parsed(&["resume", "ws", "--rm"]); + + for built in [leading, trailing].map(|line| build_dl_args(&line, &id_of)) { + let built = built.expect("a known agent"); + let separator = built.iter().position(|word| word == "--").expect("a --"); + assert!(built[..separator].contains(&"--rm".to_owned()), "{built:?}"); + assert_eq!(built.last().map(String::as_str), Some("--resume")); + } + } + + #[test] + fn a_retired_spelling_on_a_resume_line_with_no_workspace_asks_for_one_before_any_picker() { + for retired in ["--stop", "--autorm"] { + assert_eq!( + super::parse_aid_args(&words(&["resume", retired]), Environment::default()), + Err(UsageError::NoWorkspace), + "{retired}" + ); + } + } + + #[test] + fn a_retired_spelling_on_a_resume_line_with_a_workspace_is_left_for_dl_to_refuse() { + assert_eq!(parsed(&["resume", "ws", "--autorm"]).task, Task::Retired); + } } diff --git a/rust/aid/tests/interactive.rs b/rust/aid/tests/interactive.rs index d0c09bd0..413a2c53 100644 --- a/rust/aid/tests/interactive.rs +++ b/rust/aid/tests/interactive.rs @@ -413,6 +413,67 @@ fn an_empty_enter_is_the_plain_session_it_always_was() { ); } +/// The session line `aid resume` hands dl for [`MAIN`]: the agent's own line with +/// `--resume` as its last word. +fn resumed_session() -> String { + format!( + "devpod ssh {MAIN} --log-output json --command bash -lc 'CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 claude \ + --dangerously-skip-permissions --remote-control={MAIN} --resume'" + ) +} + +#[test] +fn a_resume_line_on_a_terminal_opens_no_editor() { + // A resume line has no prompt, and an empty prompt on a terminal is exactly + // what opens the editor for an agent line. Only the real gate on a real pty + // says which of the two a resume line is. + let world = World::with(&["--warm"]); + let session = PtyAid::spawn(&world, &["resume", MAIN], &[]); + session.expect("aid -> dl"); + let seen = Arc::clone(&session.seen); + assert_eq!(session.wait(), 0); + + let whole = String::from_utf8_lossy(&seen.lock().expect("the pty buffer")).into_owned(); + assert!( + !whole.contains("Type the prompt") && !whole.contains(BANNER), + "a resume line opened the editor; the pty said:\n{whole}" + ); + assert_eq!( + world.devpod_calls().last().expect("a session"), + &resumed_session() + ); +} + +#[test] +fn a_resume_with_no_workspace_resumes_the_row_the_picker_took() { + // `aid resume` alone asks dl's picker, and the id that comes back is the one + // the line is then built around. The row is taken through skim, whose answer is + // a label that dl maps back to an id, so only a pick made on a terminal proves + // the id that reaches the session is the row's. + let world = World::with(&["--warm"]); + let mut session = PtyAid::spawn(&world, &["resume"], &[("TERM", "xterm-256color")]); + session.expect("Select workspace (type to filter):"); + session.expect("blooop | devlaunch | main"); + session + .writer + .write_all(b"\r") + .and_then(|()| session.writer.flush()) + .expect("taking the row on the pty"); + session.expect("aid -> dl"); + let seen = Arc::clone(&session.seen); + assert_eq!(session.wait(), 0); + + let whole = String::from_utf8_lossy(&seen.lock().expect("the pty buffer")).into_owned(); + assert!( + whole.contains(&format!("Picked blooop | devlaunch | main -> {MAIN}")), + "the pick named no row; the pty said:\n{whole:?}" + ); + assert_eq!( + world.devpod_calls().last().expect("a session"), + &resumed_session() + ); +} + #[test] fn the_boot_runs_while_the_prompt_is_still_being_typed() { // The overlap itself: a stopped workspace's `devpod up` is on the shim's log diff --git a/rust/aid/tests/rewrite.rs b/rust/aid/tests/rewrite.rs index 7da1bbc0..a27d2eb4 100644 --- a/rust/aid/tests/rewrite.rs +++ b/rust/aid/tests/rewrite.rs @@ -77,6 +77,29 @@ impl World { /// `DEVLAUNCH_AID_AGENT` holding undecodable bytes is the case the reader used /// to report as unset, and it cannot be written as one. fn aid_with(&self, args: &[&str], extra: &[(&str, &OsStr)]) -> Run { + let mut command = self.command(args, extra); + Run::of(&command.output().expect("the aid binary runs"), &self.root) + } + + /// `aid` in a session of its own with stdin closed: no controlling terminal, so + /// a developer's terminal can never be handed a real picker. + fn aid_detached(&self, args: &[&str]) -> Run { + use std::os::unix::process::CommandExt as _; + let mut command = self.command(args, &[]); + command.stdin(std::process::Stdio::null()); + // SAFETY: `setsid` is async-signal-safe and touches no memory of ours. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + Run::of(&command.output().expect("the aid binary runs"), &self.root) + } + + fn command(&self, args: &[&str], extra: &[(&str, &OsStr)]) -> Command { let root = self.root.display().to_string(); let mut command = Command::new(env!("CARGO_BIN_EXE_aid")); command @@ -104,7 +127,7 @@ impl World { for (name, value) in extra { command.env(name, value); } - Run::of(&command.output().expect("the aid binary runs"), &self.root) + command } /// The devpod calls made so far, in order, with `devpod list` left out — the @@ -191,6 +214,26 @@ fn help_is_asked_for_by_flag_and_printed_by_accident() { } } +#[test] +fn resume_help_prints_aids_help_rather_than_opening_the_picker() { + let world = World::with(&["--warm"]); + + for flag in ["--help", "-h"] { + let run = world.aid(&["resume", flag]); + run.exited(0); + assert!( + run.out.contains("aid resume"), + "aid resume {flag} printed {:?}", + run.out + ); + assert!( + world.devpod_calls().is_empty(), + "aid resume {flag} asked devpod something: {:?}", + world.devpod_calls() + ); + } +} + #[test] fn the_version_is_dls_under_aids_name_with_dls_build_marker() { // Both halves come from `dl`, so `aid-next` and `dl-next` cannot disagree @@ -701,3 +744,80 @@ fn full_auto_rows(document: &str) -> Vec<(String, String)> { }) .collect() } + +#[test] +fn resume_reopens_a_session_in_the_named_workspace_through_dls_own_launch() { + // Newer than the Python build, so this one was not captured from it: `aid + // resume` is the Rust tree's own. What it pins is the one thing that makes it a + // resume and not a launch, observed where the agent is actually started: + // `--resume` is the last word of the payload, after the Remote Control name + // that makes the resumed session drivable from claude.ai as the first one was. + let world = World::with(&["--warm"]); + world.aid(&["resume", MAIN]).exited(0); + assert_eq!( + world.devpod_calls(), + [ + format!("devpod status {MAIN} --output json"), + format!( + "devpod ssh {MAIN} --log-output json --command bash -lc \ + 'CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 IS_SANDBOX=1 claude \ + --dangerously-skip-permissions --remote-control={MAIN} --resume'" + ), + ] + ); +} + +#[test] +fn resume_with_no_terminal_says_why_nothing_was_picked_and_what_to_type() { + let world = World::with(&["--warm"]); + let run = world.aid_detached(&["resume"]); + run.exited(1); + assert!( + run.err.contains( + "aid resume needs a terminal to pick a workspace. Name one instead: aid resume " + ), + "{:?}", + run.err + ); + assert!( + world.devpod_calls().is_empty(), + "{:?}", + world.devpod_calls() + ); +} + +#[test] +fn resume_with_no_workspaces_says_how_to_make_one() { + let world = World::with(&["--no-workspaces"]); + let run = world.aid_detached(&["resume"]); + run.exited(1); + assert!( + run.err + .contains("No workspaces found. Create one with: dl owner/repo or dl ./path"), + "{:?}", + run.err + ); + assert!( + world.devpod_calls().is_empty(), + "{:?}", + world.devpod_calls() + ); +} + +#[test] +fn a_resume_line_with_a_prompt_never_reaches_dl() { + let world = World::with(&["--warm"]); + let run = world.aid(&["resume", MAIN, "fix", "it"]); + run.exited(1); + assert!( + run.err + .contains("aid resume takes a workspace and nothing after it"), + "{}", + run.err + ); + assert!( + world.devpod_calls().is_empty(), + "{:?}", + world.devpod_calls() + ); +} diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index d876bf06..99d4e061 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -798,6 +798,7 @@ pub struct devlaunch_core::domain::workspace_state::NonEmpty impl devlaunch_core::domain::workspace_state::NonEmpty pub fn devlaunch_core::domain::workspace_state::NonEmpty::describe(&self) -> alloc::string::String impl devlaunch_core::domain::workspace_state::NonEmpty +pub fn devlaunch_core::domain::workspace_state::NonEmpty::first(&self) -> &T pub fn devlaunch_core::domain::workspace_state::NonEmpty::iter(&self) -> impl core::iter::traits::iterator::Iterator pub fn devlaunch_core::domain::workspace_state::NonEmpty::of(impl core::iter::traits::collect::IntoIterator) -> core::option::Option impl core::clone::Clone for devlaunch_core::domain::workspace_state::NonEmpty diff --git a/rust/devlaunch-core/src/domain/workspace_state.rs b/rust/devlaunch-core/src/domain/workspace_state.rs index 29ba2c12..9f548ed5 100644 --- a/rust/devlaunch-core/src/domain/workspace_state.rs +++ b/rust/devlaunch-core/src/domain/workspace_state.rs @@ -372,6 +372,11 @@ impl NonEmpty { }) } + /// The item the sequence always holds. + pub fn first(&self) -> &T { + &self.first + } + pub(crate) fn len(&self) -> usize { 1 + self.rest.len() } diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index 6be58ba3..04290c07 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -1682,6 +1682,46 @@ fn render_select<'r>( } } +/// One workspace from the picker, for a caller that builds its own command line +/// around the answer: `aid resume` with no workspace named. +/// +/// The same picker, the same listing and the same `Picked -> ` line as +/// [`render_select`], and one thing left out: the help a pick that never came ends +/// on. That help is dl's, and somebody who typed `aid resume` did not ask for dl's +/// grammar. An empty list keeps dl's sentence, which says how to make a workspace. +/// A run with no terminal gets one of its own in place of the invitation, which +/// would ask for a pick nobody can make: it says a terminal is missing and how to +/// name the workspace instead. +pub(crate) fn pick_one(runner: &dyn Runner) -> Result { + // The picker reads each row's clone under the cache for its columns. With no + // cache there is nothing to draw, and the refusal is every other command's. + let cache = + crate::session::cache_dir().map_err(|_| refuse_startup(&StartupError::NoHomeDirectory))?; + let mut context = CommandContext::new(runner); + let workspaces = context + .workspaces() + .map_err(|refused| refuse_listing(&refused))?; + match select::pick(&workspaces, select::Arity::One, &cache) { + select::Pick::Chose(chosen) => { + for line in render::picked("resume", &chosen) { + eprintln!("{line}"); + } + Ok(chosen.first().workspace_id.clone()) + } + select::Pick::Quit => Err(Ending::Refused), + select::Pick::NoWorkspaces => { + eprintln!("No workspaces found. Create one with: dl owner/repo or dl ./path"); + Err(Ending::Refused) + } + select::Pick::NoTerminal => { + eprintln!( + "aid resume needs a terminal to pick a workspace. Name one instead: aid resume " + ); + Err(Ending::Refused) + } + } +} + /// Python's ending for a selector that chose nothing: the help, and exit 1. fn no_pick() -> Ending { let _ = ::command().print_help(); diff --git a/rust/dl/src/lib.rs b/rust/dl/src/lib.rs index 9efecc14..dbb25174 100644 --- a/rust/dl/src/lib.rs +++ b/rust/dl/src/lib.rs @@ -396,6 +396,23 @@ pub fn workspace_id_of(spec: &str) -> Option { target::workspace_id_of(&ProcessRunner, spec) } +/// The id of one workspace the user picks from dl's own picker, or the exit code +/// to end on. +/// +/// **Public for `aid resume`, which needs the id before it can build its line.** +/// `dl -- ` with no workspace already opens this picker, and a plain +/// `aid resume` could have ridden on that. It does not, because the agent line +/// names Claude Code's Remote Control session after the workspace id +/// ([`workspace_id_of`]), and inside dl's picker the line is already built. So aid +/// asks for the pick first and builds a line for a named workspace, which is the +/// path every other aid launch takes. +/// +/// A pick that never came (Esc, an empty list, no terminal) is `Err(1)`, with the +/// reason on stderr where there is one. +pub fn pick_workspace() -> Result { + commands::pick_one(&ProcessRunner).map_err(commands::Ending::code) +} + /// What `spec` states it is called, without resolving anything. /// /// Split out from [`name_before_launch`] because it is the whole of the decision