Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **`LaunchVerb::Attach` carries a `RemoteCommand` rather than an
`Option<String>`, and the change is a break for anyone driving
`devlaunch-core` directly.** The field meant two opposite things depending on
who filled it: `dl <ws> -- <command>` put words in it whose quoting the host's
shell had already stripped, and the dotfiles pass put a script in it that it
had composed itself, `&&` and `$(...)` included. Nothing in the type could tell
those apart, so the join that turns the first into a command line lived in `dl`
and the second survived only by nobody joining it. That is the shape 0.37.0
fixed one instance of.

`RemoteCommand::Argv(NonEmpty<String>)` and `RemoteCommand::Script(String)` are
the two senses, and `RemoteCommand::line` is now the only place either becomes a
command line. A caller passing argv writes `Argv` and a caller passing a script
writes `Script`; there is no longer a reading to get wrong.

Two things follow from holding words instead of a line. The program a session
manager is told about is now exact for argv, where it had to be guessed at by
splitting on whitespace. And `UnquotableCommand` carries the composed line, so
the refusal of a command holding a NUL names `echo '<NUL>hi'` where it used to
name `echo <NUL>hi`: there is no single string the caller gave any more, and the
line is the thing that could not be made into a shell word. The refusal itself
is unchanged.

Nothing about what reaches a workspace changes. Every payload assertion in the
suite is untouched.

- **A version that is already published is refused before it can be merged.**
Two branches bumped to the same number and git merged it without a conflict,
because the same version line on both sides is one edit; the second merge then
published nothing and said so in the words an ordinary push gets.
`scripts/version_untaken.py` runs on the pull request, where the two versions
still differ, and `publish.yml` now separates a re-run over the commit it
published from a push moving the version onto somebody else's tag.

## [0.37.0] - 2026-09-09

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ once under the `api` section and once under the module that owns them.

**What it still does not reach, and it is not one type.** A type `api` never re-exports but a
promised signature hands back is reachable from outside and classified as binary surface. Counted
on the checked-in files rather than guessed at, that is **37 types owning close to six hundred rows** in
on the checked-in files rather than guessed at, that is **38 types owning close to six hundred rows** in
`public-api.rest.txt`, and the command that lists them needs no toolchain:

**A falling count is not automatically a win, and it is worth knowing which kind you are looking
Expand All @@ -93,10 +93,10 @@ file that moves is the one this page calls freely regenerated.
section used to give on its own, which made six hundred rows read as one.

So a diff in `public-api.rest.txt` is routine for a row whose subject nothing promised names, and a
contract change for a row whose subject is one of the 39. `--print-residual` is how you tell the two
contract change for a row whose subject is one of the 38. `--print-residual` is how you tell the two
apart, and `test/test_public_api_snapshots_doc.py` diffs the count of types in this paragraph
against it, so the sentence goes red rather than stale. The row total is left round on purpose: it
moves whenever anything is added to any one of the 39 and says nothing about the scale of the
moves whenever anything is added to any one of the 38 and says nothing about the scale of the
limit, where the count of types moves only when the residual really grows.

The `-ss` flag also omits blanket and auto-trait impls from both files,
Expand Down
20 changes: 16 additions & 4 deletions rust/aid/src/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
//! containers itself is an `aid` that builds one `dl` would have reused, which is
//! the drift `aid.py` was rewritten to end.

use dl::NonEmpty;

/// How one coding agent is started inside the workspace.
///
/// Split three ways because not every part of the line belongs everywhere: `env`
Expand Down Expand Up @@ -737,7 +739,7 @@ pub(crate) fn build_agent_command(
agent: &str,
prompt: &str,
remote_control: Option<&str>,
) -> Option<Vec<String>> {
) -> Option<NonEmpty<String>> {
let (_, started) = AGENTS.iter().find(|(name, _)| *name == agent)?;
// No prompt to be interactive about: start the agent's plain session, without
// the flags that only make sense alongside one.
Expand Down Expand Up @@ -765,7 +767,13 @@ pub(crate) fn build_agent_command(
.map(|(name, value)| format!("{name}={value}"))
.collect();
line.extend(words.into_iter().map(str::to_owned));
Some(line)
// `NonEmpty` rather than the `Vec`, because an empty tail is not a smaller
// answer but a different one: `build_dl_args` would emit `[<spec>, "--"]` and
// dl reads a separator with nothing after it as a plain interactive attach, so
// an agent that was asked for would arrive as a shell. Unreachable while every
// row has a command -- which `every_agent_in_the_table_composes_into_argv_dl_can_carry`
// is what holds -- and now unrepresentable rather than merely untaken.
NonEmpty::of(line)
}

/// The dl command line that does the work.
Expand Down Expand Up @@ -802,7 +810,7 @@ pub(crate) fn build_dl_args(parsed: &AidArgs) -> Option<Vec<String>> {
RemoteControl::On => Some(parsed.spec.as_str()),
RemoteControl::Off => None,
};
args.extend(build_agent_command(agent, prompt, session)?);
args.extend(build_agent_command(agent, prompt, session)?.iter().cloned());
}
Task::Retired => {}
}
Expand Down Expand Up @@ -1070,7 +1078,11 @@ mod tests {

/// The agent's argv, for a name the table has.
fn agent_argv(agent: &str, prompt: &str, remote_control: Option<&str>) -> Vec<String> {
build_agent_command(agent, prompt, remote_control).expect("a known agent")
build_agent_command(agent, prompt, remote_control)
.expect("a known agent")
.iter()
.cloned()
.collect()
}

#[test]
Expand Down
4 changes: 2 additions & 2 deletions rust/devlaunch-core/public-api.api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub fn devlaunch_core::flows::launch::LaunchNotice::fmt(&self, &mut core::fmt::F
impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::LaunchNotice
pub enum devlaunch_core::api::LaunchVerb
pub devlaunch_core::api::LaunchVerb::Attach
pub devlaunch_core::api::LaunchVerb::Attach::command: core::option::Option<alloc::string::String>
pub devlaunch_core::api::LaunchVerb::Attach::command: core::option::Option<devlaunch_core::flows::launch::RemoteCommand>
pub devlaunch_core::api::LaunchVerb::Code
pub devlaunch_core::api::LaunchVerb::Dotfiles
pub devlaunch_core::api::LaunchVerb::Recreate
Expand Down Expand Up @@ -593,7 +593,7 @@ pub fn devlaunch_core::flows::launch::LaunchNotice::fmt(&self, &mut core::fmt::F
impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::LaunchNotice
pub enum devlaunch_core::flows::launch::LaunchVerb
pub devlaunch_core::flows::launch::LaunchVerb::Attach
pub devlaunch_core::flows::launch::LaunchVerb::Attach::command: core::option::Option<alloc::string::String>
pub devlaunch_core::flows::launch::LaunchVerb::Attach::command: core::option::Option<devlaunch_core::flows::launch::RemoteCommand>
pub devlaunch_core::flows::launch::LaunchVerb::Code
pub devlaunch_core::flows::launch::LaunchVerb::Dotfiles
pub devlaunch_core::flows::launch::LaunchVerb::Recreate
Expand Down
13 changes: 13 additions & 0 deletions rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,19 @@ pub fn devlaunch_core::flows::launch::Plan::eq(&self, &devlaunch_core::flows::la
impl core::fmt::Debug for devlaunch_core::flows::launch::Plan
pub fn devlaunch_core::flows::launch::Plan::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::Plan
pub enum devlaunch_core::flows::launch::RemoteCommand
pub devlaunch_core::flows::launch::RemoteCommand::Argv(devlaunch_core::domain::workspace_state::NonEmpty<alloc::string::String>)
pub devlaunch_core::flows::launch::RemoteCommand::Script(alloc::string::String)
impl devlaunch_core::flows::launch::RemoteCommand
pub fn devlaunch_core::flows::launch::RemoteCommand::line(&self) -> alloc::borrow::Cow<'_, str>
impl core::clone::Clone for devlaunch_core::flows::launch::RemoteCommand
pub fn devlaunch_core::flows::launch::RemoteCommand::clone(&self) -> devlaunch_core::flows::launch::RemoteCommand
impl core::cmp::Eq for devlaunch_core::flows::launch::RemoteCommand
impl core::cmp::PartialEq for devlaunch_core::flows::launch::RemoteCommand
pub fn devlaunch_core::flows::launch::RemoteCommand::eq(&self, &devlaunch_core::flows::launch::RemoteCommand) -> bool
impl core::fmt::Debug for devlaunch_core::flows::launch::RemoteCommand
pub fn devlaunch_core::flows::launch::RemoteCommand::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::RemoteCommand
pub enum devlaunch_core::flows::launch::Resolution
pub devlaunch_core::flows::launch::Resolution::Cold
pub devlaunch_core::flows::launch::Resolution::Cold::workspace: devlaunch_core::domain::workspace_id::WorkspaceId
Expand Down
12 changes: 11 additions & 1 deletion rust/devlaunch-core/src/clients/herdr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ pub(crate) fn agent_in(command: &str) -> Option<&'static str> {
let program = command
.split_whitespace()
.find(|word| !is_assignment(word))?;
agent_named(program)
}

/// The agent one already-split program word names, by its last path component.
///
/// Split out from [`agent_in`] so a caller holding real argv can skip the
/// whitespace guess entirely: `RemoteCommand::Argv` knows where its words end, and
/// splitting a word that legitimately contains a space would be a worse answer
/// than the one it already has.
pub(crate) fn agent_named(program: &str) -> Option<&'static str> {
let name = program.rsplit('/').next()?;
AGENT_NAMES.iter().copied().find(|known| *known == name)
}
Expand All @@ -75,7 +85,7 @@ pub(crate) fn agent_in(command: &str) -> Option<&'static str> {
/// `FOO=bar claude` runs claude. The name half must be non-empty for the same
/// reason the shell requires it: `=x` is a program named `=x`, however unlikely,
/// and not an assignment.
fn is_assignment(word: &str) -> bool {
pub(crate) fn is_assignment(word: &str) -> bool {
match word.split_once('=') {
Some((name, _)) => !name.is_empty(),
None => false,
Expand Down
Loading
Loading