From fd1716465e939bb042ede7e539b4fc3aa033c876 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 22:37:28 -0700 Subject: [PATCH 1/3] feat: add first-class Devin CLI support Session-Id: 01a0bd39-5328-7431-be1f-8c17026784d3 --- .../compact_qfdvu5sy4t0s_2026-09-20.json | 48 +++ .../compact_qfdvu5sy4t0s_2026-09-20.md | 21 ++ CHANGELOG.md | 4 +- Cargo.lock | 60 ++++ crates/broker/Cargo.toml | 1 + crates/broker/src/cli_mcp_args.rs | 17 + crates/broker/src/devin.rs | 306 ++++++++++++++++++ crates/broker/src/lib.rs | 1 + crates/broker/src/pty_worker.rs | 17 +- crates/broker/src/runtime/api.rs | 4 +- crates/broker/src/runtime/init.rs | 2 +- crates/broker/src/snippets.rs | 3 +- crates/broker/src/telemetry.rs | 3 + crates/broker/src/worker.rs | 43 ++- crates/broker/src/wrap.rs | 85 ++++- crates/relay-pty/src/detection.rs | 2 + crates/relay-pty/src/readiness.rs | 83 +++++ docs/harnesses/devin.md | 68 ++++ packages/cli/README.md | 1 + packages/cli/src/auto/composer.ts | 2 +- packages/cli/src/cli/agent-relay-mcp.ts | 4 +- packages/cli/src/cli/commands/fleet.ts | 2 +- .../cli/src/cli/lib/fleet-sidecar.test.ts | 9 +- packages/cli/src/cli/lib/fleet-sidecar.ts | 2 +- .../src/cli/telemetry/orchestrator-harness.ts | 1 + packages/cloud/src/permissions.ts | 1 + packages/config/src/cli-registry.generated.ts | 38 +++ packages/harnesses/README.md | 3 + packages/harnesses/src/define.test.ts | 5 +- packages/harnesses/src/index.ts | 2 + packages/harnesses/src/observability.test.ts | 2 +- packages/harnesses/src/observability.ts | 2 + packages/sdk-py/src/agent_relay/models.py | 19 ++ packages/sdk-py/src/agent_relay/types.py | 1 + packages/utils/cli-registry.yaml | 10 + packages/utils/src/model-commands.test.ts | 3 + packages/utils/src/model-commands.ts | 1 + 37 files changed, 844 insertions(+), 32 deletions(-) create mode 100644 .agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.json create mode 100644 .agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.md create mode 100644 crates/broker/src/devin.rs create mode 100644 docs/harnesses/devin.md diff --git a/.agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.json b/.agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.json new file mode 100644 index 0000000000..7d8ef703ad --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.json @@ -0,0 +1,48 @@ +{ + "id": "compact_gekdxmou0hhn", + "version": 1, + "type": "compacted", + "compactedAt": "2026-09-20T05:37:28.642Z", + "sourceTrajectories": [ + "traj_7j8hrargfgl8" + ], + "dateRange": { + "start": "2026-09-20T05:11:41.894Z", + "end": "2026-09-20T05:37:15.421Z" + }, + "summary": { + "totalDecisions": 2, + "totalEvents": 3, + "uniqueAgents": [ + "default" + ] + }, + "decisionGroups": [ + { + "category": "security", + "decisions": [ + { + "question": "Use private per-worker XDG config snapshots and delayed bracketed-paste submission for Devin", + "chosen": "Use private per-worker XDG config snapshots and delayed bracketed-paste submission for Devin", + "reasoning": "Installed Devin ignores --config for MCP file relocation; XDG isolation preserved authentication in an actual probe. One-write body+Enter parked text, while a later Enter submitted. Keep user trust and approvals intact.", + "fromTrajectory": "traj_7j8hrargfgl8" + } + ] + }, + { + "category": "other", + "decisions": [ + { + "question": "Actual Devin two-message E2E passed with approval boundaries preserved", + "chosen": "Actual Devin two-message E2E passed with approval boundaries preserved", + "reasoning": "Worker devin-e2e-mu9dgnex (agent ID 227298977995862016) on isolated node node_devin-e2e-mu9dgnex in rw_7ccfea89 sent initial DM 227300144622936064. Lead follow-up 227300225157185536 was injected into the idle PTY and read at 2026-09-20T05:31:36Z. Actual Devin sent follow-up reply 227301341519724544. Lead confirmed both. Each send_dm was approved once; inbox approval was cancelled to prove PTY delivery. Production broker untouched.", + "fromTrajectory": "traj_7j8hrargfgl8" + } + ] + } + ], + "keyLearnings": [], + "keyFindings": [], + "filesAffected": [], + "commits": [] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.md b/.agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.md new file mode 100644 index 0000000000..776520ee50 --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_qfdvu5sy4t0s_2026-09-20.md @@ -0,0 +1,21 @@ +# Trajectory Compaction: Sep 19, 2026 - Sep 19, 2026 + +## Summary +- Sessions: 1 +- Decisions: 2 +- Events: 3 +- Agents: default +- Files: 0 +- Commits: 0 + +## Security +- Use private per-worker XDG config snapshots and delayed bracketed-paste submission for Devin -> Use private per-worker XDG config snapshots and delayed bracketed-paste submission for Devin (traj_7j8hrargfgl8) + +## Other +- Actual Devin two-message E2E passed with approval boundaries preserved -> Actual Devin two-message E2E passed with approval boundaries preserved (traj_7j8hrargfgl8) + +## Key Learnings +- None + +## Key Findings +- None \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 86848eb076..853a074206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Patch] +## [Unreleased - Minor] ### Fixed @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Devin CLI is available through Relay PTY, fleet and MCP spawning with isolated worker MCP configuration, preserved approvals, and reliable initial and follow-up message submission. + - `agent-relay fleet nodes list --pretty` renders the fleet roster as a human-readable table; `agent-relay fleet nodes --pretty` is available as a shorter equivalent, while JSON remains the default. ## [12.3.1] - 2026-09-20 diff --git a/Cargo.lock b/Cargo.lock index 798bc879f1..9e6333a0eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "futures-util", "hostname", "httpmock", + "json5", "libc", "nix 0.30.1", "rand 0.8.5", @@ -1404,6 +1405,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -1686,6 +1698,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.6.5" @@ -2955,6 +3009,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.23" diff --git a/crates/broker/Cargo.toml b/crates/broker/Cargo.toml index dcf55ed180..767ac131b6 100644 --- a/crates/broker/Cargo.toml +++ b/crates/broker/Cargo.toml @@ -25,6 +25,7 @@ regex = "1.11" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +json5 = "0.4" sha2 = "0.10" shlex = "1.3" thiserror = "2.0" diff --git a/crates/broker/src/cli_mcp_args.rs b/crates/broker/src/cli_mcp_args.rs index 5ef60f57f9..3a43215ca3 100644 --- a/crates/broker/src/cli_mcp_args.rs +++ b/crates/broker/src/cli_mcp_args.rs @@ -391,6 +391,23 @@ mod tests { serde_json::from_str(&json).expect("parse output json") } + #[tokio::test] + async fn devin_defers_private_mcp_configuration_to_the_worker() { + let temp = tempfile::tempdir().unwrap(); + let output = compute_mcp_args_output(command("devin", temp.path())) + .await + .unwrap(); + assert!( + output.args.is_empty(), + "no trust or permission bypass flags" + ); + assert!(output.side_effect_files.is_empty()); + assert!( + !temp.path().join(".devin").exists(), + "never write shared project state" + ); + } + #[tokio::test] async fn claude_output_contains_resolved_mcp_config_json() { // The broker must render the same local executable it preflights, rather diff --git a/crates/broker/src/devin.rs b/crates/broker/src/devin.rs new file mode 100644 index 0000000000..6918e15f31 --- /dev/null +++ b/crates/broker/src/devin.rs @@ -0,0 +1,306 @@ +//! Devin keeps user MCP configuration under XDG_CONFIG_HOME, independently of +//! --config. Isolate that directory in the worker process, leaving HOME/data +//! paths (authentication, trust and sessions) intact. Never edit user files. +use crate::{ + pty::PtySession, + readiness::{cli_prompt_ready, is_devin_cli, GridReadinessSnapshot}, +}; +use anyhow::{Context, Result}; +use serde_json::Value; +use std::{fs, path::Path}; + +pub(crate) fn injection_bytes(cli: &str, text: &str) -> Vec { + if is_devin_cli(cli) { + format!("\x1b[200~{}\x1b[201~", text.replace('\x1b', "")).into_bytes() + } else { + text.as_bytes().to_vec() + } +} + +pub(crate) fn can_inject(cli: &str, pty: &PtySession) -> bool { + !is_devin_cli(cli) + || cli_prompt_ready( + cli, + GridReadinessSnapshot { + screen: &pty.screen_text(), + cursor: Some(pty.cursor_position()), + }, + ) +} + +fn copy_tree(source: &Path, target: &Path) -> Result<()> { + anyhow::ensure!( + !fs::symlink_metadata(source)?.file_type().is_symlink(), + "Devin configuration snapshot refuses symlinks inside the Devin config directory" + ); + if source.is_dir() { + fs::create_dir_all(target)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(target, fs::Permissions::from_mode(0o700))?; + } + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_tree(&entry.path(), &target.join(entry.file_name()))?; + } + } else { + anyhow::ensure!( + source.is_file(), + "Devin configuration contains a non-regular file" + ); + fs::copy(source, target)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(target, fs::Permissions::from_mode(0o600))?; + } + } + Ok(()) +} + +fn isolated_config(source: &Path, relay_config: &str) -> Result { + let state = tempfile::Builder::new() + .prefix("agent-relay-devin-") + .tempdir()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(state.path(), fs::Permissions::from_mode(0o700))?; + } + for path in [source.join("devin"), source.join("devin/mcp_config.json")] { + if let Ok(metadata) = fs::symlink_metadata(path) { + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "Devin configuration snapshot refuses symlinks inside the Devin config directory" + ); + } + } + let devin = state.path().join("devin"); + fs::create_dir(&devin)?; + if source.is_dir() { + // Keep unrelated XDG application configuration visible to child tools. + #[cfg(unix)] + for entry in fs::read_dir(source)? { + let entry = entry?; + if entry.file_name() != "devin" { + std::os::unix::fs::symlink(entry.path(), state.path().join(entry.file_name()))?; + } + } + let original = source.join("devin"); + if original.is_dir() { + for entry in fs::read_dir(&original)? { + let entry = entry?; + if entry.file_name() != "mcp_config.json" { + copy_tree(&entry.path(), &devin.join(entry.file_name()))?; + } + } + } + } + let original_mcp = source.join("devin/mcp_config.json"); + let mut config: Value = if original_mcp.exists() { + json5::from_str(&fs::read_to_string(&original_mcp)?) + .map_err(|_| anyhow::anyhow!("invalid Devin user MCP configuration"))? + } else { + serde_json::json!({}) + }; + let relay: Value = serde_json::from_str(relay_config)?; + let object = config + .as_object_mut() + .context("Devin MCP configuration must be an object")?; + let servers = object + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("Devin mcpServers must be an object")?; + servers.insert( + "agent-relay".into(), + relay["mcpServers"]["agent-relay"].clone(), + ); + // The entire directory is private and unpublished until this function + // returns; there is no shared-file read/modify/write race between workers. + let mut file = tempfile::NamedTempFile::new_in(&devin)?; + use std::io::Write; + file.write_all(&serde_json::to_vec_pretty(&config)?)?; + file.persist(devin.join("mcp_config.json"))?; + Ok(state) +} + +pub(crate) async fn prepare_worker_config(cli: &str) -> Result> { + if !is_devin_cli(cli) + || std::env::var("RELAY_AGENT_NAME").is_err() + || std::env::var("RELAY_SKIP_PROMPT").as_deref() == Ok("1") + || std::env::var("AGENT_RELAY_LOCAL_ONLY").as_deref() == Ok("1") + { + return Ok(None); + } + #[cfg(not(test))] + crate::snippets::validate_agent_relay_mcp_command().await?; + let source = std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| dirs::home_dir().map(|p| p.join(".config"))) + .context("cannot locate Devin user configuration")?; + // Project/local MCP has precedence over user MCP, including legacy + // entries in settings. Never launch with a different worker's identity. + let cwd = std::env::current_dir()?; + for dir in cwd.ancestors() { + for name in [ + "mcp_config.json", + "mcp_config.local.json", + "config.json", + "config.local.json", + ] { + let path = dir.join(".devin").join(name); + if path.exists() { + let value: Value = json5::from_str(&fs::read_to_string(path)?) + .map_err(|_| anyhow::anyhow!("invalid Devin project configuration"))?; + anyhow::ensure!(value["mcpServers"].get("agent-relay").is_none(), + "Devin project MCP defines agent-relay; remove that conflicting entry before spawning a Relay worker"); + } + } + } + let env = |key| std::env::var(key).ok(); + let config = crate::snippets::agent_relay_mcp_config_json_with_result( + env("RELAY_API_KEY").as_deref(), + env("RELAY_BASE_URL").as_deref(), + env("RELAY_AGENT_NAME").as_deref(), + env("RELAY_AGENT_TOKEN").as_deref(), + env("RELAY_WORKSPACES_JSON").as_deref(), + env("RELAY_DEFAULT_WORKSPACE").as_deref(), + None, + ); + let state = isolated_config(&source, &config)?; + // Called only inside the dedicated PTY/wrap worker, never the broker. + std::env::set_var("XDG_CONFIG_HOME", state.path()); + Ok(Some(state)) +} + +#[cfg(test)] +mod tests { + use super::*; + // Native PTY fixture models the observed Devin paste debounce: Enter in + // the paste burst is editor content; a later Enter submits the body. + #[cfg(unix)] + #[tokio::test] + async fn paste_burst_parks_but_delayed_enter_submits() { + use std::time::Duration; + let script = r#"import os,tty,select,time +tty.setraw(0) +os.write(1,b'READY') +data=os.read(0,65536) +while select.select([0],[],[],0.06)[0]: data+=os.read(0,65536) +if data.endswith(b'\r'): os.write(1,b'PARKED') +else: + if os.read(0,1)==b'\r': os.write(1,b'SUBMITTED') +time.sleep(0.3) +"#; + for delayed in [false, true] { + let (pty, mut rx) = PtySession::spawn( + "python3", + &["-u".into(), "-c".into(), script.into()], + 24, + 100, + ) + .unwrap(); + let drain = tokio::spawn(async move { while rx.recv().await.is_some() {} }); + for _ in 0..100 { + if pty.screen_text().contains("READY") { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(pty.screen_text().contains("READY")); + let mut body = injection_bytes("devin", "first line\nsecond line"); + if delayed { + let (ack, _) = pty + .submit_write_paced_with_followup_and_output_boundary( + body, + Duration::ZERO, + crate::wrap::injection_submit_followup_delay("devin").unwrap(), + b"\r".to_vec(), + ) + .unwrap(); + ack.await.unwrap().unwrap(); + } else { + body.push(b'\r'); + pty.submit_write(body).unwrap().await.unwrap().unwrap(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(pty + .screen_text() + .contains(if delayed { "SUBMITTED" } else { "PARKED" })); + pty.shutdown().unwrap(); + drain.abort(); + } + } + + #[test] + fn malformed_mcp_fails_without_echoing_values() { + let source = tempfile::tempdir().unwrap(); + fs::create_dir(source.path().join("devin")).unwrap(); + fs::write( + source.path().join("devin/mcp_config.json"), + "secret-sentinel invalid", + ) + .unwrap(); + let error = isolated_config(source.path(), "{}") + .unwrap_err() + .to_string(); + assert_eq!(error, "invalid Devin user MCP configuration"); + } + #[cfg(unix)] + #[test] + fn snapshot_refuses_symlink_traversal() { + let source = tempfile::tempdir().unwrap(); + fs::create_dir(source.path().join("devin")).unwrap(); + std::os::unix::fs::symlink(source.path(), source.path().join("devin/loop")).unwrap(); + assert!(isolated_config(source.path(), "{}").is_err()); + } + #[test] + fn workers_preserve_user_config_and_isolate_identity() { + let source = tempfile::tempdir().unwrap(); + fs::create_dir(source.path().join("devin")).unwrap(); + let settings = br#"{"permissions":{"deny":["Exec(sudo)"]}}"#; + fs::write(source.path().join("devin/config.json"), settings).unwrap(); + let mcp = "{ // user comment\n mcpServers: { filesystem: { command: 'filesystem' } } }"; + fs::write(source.path().join("devin/mcp_config.json"), mcp).unwrap(); + let a = isolated_config( + source.path(), + r#"{"mcpServers":{"agent-relay":{"env":{"RELAY_AGENT_NAME":"a"}}}}"#, + ) + .unwrap(); + let b = isolated_config( + source.path(), + r#"{"mcpServers":{"agent-relay":{"env":{"RELAY_AGENT_NAME":"b"}}}}"#, + ) + .unwrap(); + assert_ne!(a.path(), b.path()); + for (dir, name) in [(&a, "a"), (&b, "b")] { + let config: Value = serde_json::from_slice( + &fs::read(dir.path().join("devin/mcp_config.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + config["mcpServers"]["agent-relay"]["env"]["RELAY_AGENT_NAME"], + name + ); + assert_eq!(config["mcpServers"]["filesystem"]["command"], "filesystem"); + assert_eq!( + fs::read(dir.path().join("devin/config.json")).unwrap(), + settings + ); + } + assert_eq!( + fs::read_to_string(source.path().join("devin/mcp_config.json")).unwrap(), + mcp + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(a.path()).unwrap().permissions().mode() & 0o077, + 0 + ); + } + } +} diff --git a/crates/broker/src/lib.rs b/crates/broker/src/lib.rs index 76ca5ca7b2..04855823bc 100644 --- a/crates/broker/src/lib.rs +++ b/crates/broker/src/lib.rs @@ -6,6 +6,7 @@ // binary/library split; each annotated module has at least one genuinely // unused public-facing item that the compiler would otherwise warn about. +mod devin; pub mod fleet_wire; pub mod ids; pub mod protocol; diff --git a/crates/broker/src/pty_worker.rs b/crates/broker/src/pty_worker.rs index e69859b638..4d6471fae5 100644 --- a/crates/broker/src/pty_worker.rs +++ b/crates/broker/src/pty_worker.rs @@ -69,7 +69,10 @@ const DEFAULT_INJECT_RATE_MS: u64 = 5; /// bulk-input path accepts the same reminder and task immediately. fn default_inject_rate_ms(cli: &str) -> u64 { let cli = cli_basename(cli); - if cli.eq_ignore_ascii_case("codex") || cli.eq_ignore_ascii_case("codex.exe") { + if cli.eq_ignore_ascii_case("codex") + || cli.eq_ignore_ascii_case("codex.exe") + || crate::readiness::is_devin_cli(cli) + { 0 } else { DEFAULT_INJECT_RATE_MS @@ -811,6 +814,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> { let mut effective_args = inline_cli_args; effective_args.extend(cmd.args.clone()); + let _devin_state = crate::devin::prepare_worker_config(&resolved_cli).await?; let (init_rows, init_cols) = get_terminal_size().unwrap_or((24, 80)); let (pty, mut pty_rx) = PtySession::spawn(&resolved_cli, &effective_args, init_rows, init_cols)?; @@ -841,6 +845,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> { let mut child_exit_detected = false; let mut pty_auto = PtyAutoState::new(); + pty_auto.automatic_responses_disabled = crate::readiness::is_devin_cli(&resolved_cli); let idle_threshold = if cmd.idle_threshold_secs == 0 { None @@ -1823,6 +1828,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> { // Gated off while an interactive hold is active so queued deliveries // stay parked (not dropped) until the human releases the drive. _ = pending_injection_interval.tick() => { + if !crate::devin::can_inject(&resolved_cli, &pty) { continue; } if let Some(index) = next_injection_index( &pending_worker_injections, active_injection.is_some(), @@ -1910,6 +1916,13 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> { } } InjectionStage::Body => { + // Recheck after throttling/steer delay: never paste + // into a dialog that replaced the previously idle UI. + if !crate::devin::can_inject(&resolved_cli, &pty) { + inj.next_at = tokio::time::Instant::now() + Duration::from_millis(50); + active_injection = Some(inj); + continue; + } pty_auto.auto_suggestion_visible = false; let include_mcp_reminder = !suppress_multiline_mcp_reminder && mcp_reminder_throttle.should_include(Instant::now()); @@ -1991,7 +2004,7 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> { // Finalization (emit `delivery_injected`, queue echo // verification) still waits for this ack in the // injection-ack arm. - let mut bytes = injection.clone().into_bytes(); + let mut bytes = crate::devin::injection_bytes(&resolved_cli, &injection); let write = if let Some(delay) = injection_submit_followup_delay(&resolved_cli) { diff --git a/crates/broker/src/runtime/api.rs b/crates/broker/src/runtime/api.rs index 909919698e..01d65c7e2d 100644 --- a/crates/broker/src/runtime/api.rs +++ b/crates/broker/src/runtime/api.rs @@ -2979,7 +2979,7 @@ fn channel_in_list(channels: &[ChannelName], channel: &str) -> bool { /// One-line skill text prepended for CLI harnesses that need a minimal relay lifecycle hint. const RELAY_WORKER_ONE_LINER: &str = "\ Call mcp__agent-relay__add_agent(name, cli, task) to spawn a relay worker \ -(cli: \"claude\", \"codex\", \"gemini\", or \"opencode\"; add model for Claude tier, \ +(cli: \"claude\", \"codex\", \"gemini\", \"opencode\", or \"devin\"; add model for Claude tier, \ e.g. model: \"claude-opus-4-8\"), and mcp__agent-relay__remove_agent(name) to release when done."; /// Skill text prepended to the task for small/fast models (haiku, mini, flash) that need @@ -2992,7 +2992,7 @@ const SMALL_MODEL_RELAY_SKILL: &str = "\ ### Spawn a relay worker To delegate a task to a dedicated relay worker agent, call: mcp__agent-relay__add_agent(name: \"WorkerName\", cli: \"claude\", task: \"full task instructions\") -Required: name (unique string), cli (\"claude\", \"codex\", \"gemini\", or \"opencode\"), task (complete instructions). +Required: name (unique string), cli (\"claude\", \"codex\", \"gemini\", \"opencode\", or \"devin\"), task (complete instructions). To pin a Claude model: add model: \"claude-opus-4-8\" (Opus), \"claude-sonnet-4-6\" (Sonnet), or \"claude-haiku-4-5-20251001\" (Haiku). The relay worker will DM you \"ACK: \" when it starts and \"DONE: \" when complete. diff --git a/crates/broker/src/runtime/init.rs b/crates/broker/src/runtime/init.rs index 50fb0b5e6c..70338591cb 100644 --- a/crates/broker/src/runtime/init.rs +++ b/crates/broker/src/runtime/init.rs @@ -963,7 +963,7 @@ fn bracket_ipv6_host(host: &str) -> String { /// The harnesses the broker advertises `spawn:` capacity for when /// `AGENT_RELAY_NODE_HARNESSES` is unset. -const DEFAULT_NODE_HARNESSES: &[&str] = &["claude", "codex", "gemini", "opencode"]; +const DEFAULT_NODE_HARNESSES: &[&str] = &["claude", "codex", "gemini", "opencode", "devin"]; /// Build the node descriptor the broker registers as the `broker` provider. /// diff --git a/crates/broker/src/snippets.rs b/crates/broker/src/snippets.rs index 7b7b0a93cf..4f2f7af032 100644 --- a/crates/broker/src/snippets.rs +++ b/crates/broker/src/snippets.rs @@ -375,7 +375,7 @@ async fn probe_agent_relay_mcp_command_with_timeout( } #[cfg(not(test))] -async fn validate_agent_relay_mcp_command() -> io::Result<()> { +pub(crate) async fn validate_agent_relay_mcp_command() -> io::Result<()> { static PREFLIGHT_COMPLETE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); let command = required_agent_relay_mcp_command()?; @@ -1167,6 +1167,7 @@ pub async fn configure_agent_relay_mcp_with_result( })) || is_gemini || is_droid + || crate::readiness::is_devin_cli(&cli_lower) || is_grok || (is_opencode && !existing_args.iter().any(|a| a == "--agent")) || is_cursor; diff --git a/crates/broker/src/telemetry.rs b/crates/broker/src/telemetry.rs index 14d2b1e1b4..f6c0f097f9 100644 --- a/crates/broker/src/telemetry.rs +++ b/crates/broker/src/telemetry.rs @@ -395,6 +395,9 @@ pub(crate) fn infer_harness_from_command(command: &str) -> Option<&'static str> if base == "goose" || lower.contains("goose") { return Some("goose"); } + if relay_pty::readiness::is_devin_cli(command) { + return Some("devin"); + } if base == "droid" || lower.contains("droid") { return Some("droid"); } diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 8380794ab0..c4426c74de 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -1282,6 +1282,9 @@ impl WorkerRegistry { command.env("RELAY_AGENT_TYPE", "agent"); command.env("RELAY_STRICT_AGENT_NAME", "1"); } + if skip_relay_prompt { + command.env("RELAY_SKIP_PROMPT", "1"); + } // Local-only workers must not bootstrap a separate Relaycast session. if self.env_value("AGENT_RELAY_LOCAL_ONLY") == Some("1") { for key in [ @@ -2008,6 +2011,24 @@ fn apply_requested_session_reference( anyhow::bail!("session_ref must not be empty"); } + if crate::readiness::is_devin_cli(cli_lower) { + if let Some(existing) = + cli_flag_value(args, "--resume").or_else(|| cli_flag_value(args, "-r")) + { + anyhow::ensure!( + existing == session_id, + "session_ref conflicts with the Devin session argument" + ); + return Ok(()); + } + anyhow::ensure!( + !cli_flag_present(args, &["--resume", "-r", "--continue", "-c"]), + "session_ref requires an explicit Devin session id" + ); + harness_session_args.extend(["--resume".into(), session_id.into()]); + return Ok(()); + } + if cli_lower == "claude" || cli_lower.starts_with("claude:") { if let Some(existing) = cli_flag_value(args, "--resume").or_else(|| cli_flag_value(args, "-r")) @@ -2057,7 +2078,7 @@ fn apply_requested_session_reference( } } - anyhow::bail!("session_ref resume is supported only for Claude and Codex PTY harnesses"); + anyhow::bail!("session_ref resume is supported only for Claude, Codex and Devin PTY harnesses"); } fn codex_session_reference(args: &[String]) -> CodexSessionReference { @@ -2874,6 +2895,26 @@ sleep 30 .expect("release spawned worker"); } + #[test] + fn devin_session_reference_resumes_and_rejects_conflicting_flags() { + let mut args = Vec::new(); + let mut session = Vec::new(); + apply_requested_session_reference("devin", "session-1", &mut args, &mut session).unwrap(); + assert_eq!(session, ["--resume", "session-1"]); + for original in [ + vec!["--continue".into()], + vec!["--resume".into(), "other".into()], + ] { + assert!(apply_requested_session_reference( + "devin", + "session-1", + &mut original.clone(), + &mut Vec::new() + ) + .is_err()); + } + } + #[test] fn worker_registry_starts_empty() { let reg = make_registry(vec![]); diff --git a/crates/broker/src/wrap.rs b/crates/broker/src/wrap.rs index 217c9ba2ff..5a3637b24a 100644 --- a/crates/broker/src/wrap.rs +++ b/crates/broker/src/wrap.rs @@ -83,7 +83,7 @@ fn paste_submit_harness(cli: &str) -> bool { // Claude identity signal used by readiness and activity detection so a // wrapper cannot silently fall back to the broken body-plus-Enter burst. let lower = basename.to_ascii_lowercase(); - lower.contains("claude") || lower.contains("codex") + lower.contains("claude") || lower.contains("codex") || crate::readiness::is_devin_cli(cli) } pub(crate) fn injection_submit_followup_delay(cli: &str) -> Option { @@ -309,6 +309,7 @@ pub(crate) struct PtyAutoState { /// split (`pty_worker`); `run_wrap` leaves it `false` since that mode is /// itself a live human passthrough where auto-responses are wanted. pub(crate) interactive_hold: bool, + pub(crate) automatic_responses_disabled: bool, } impl PtyAutoState { @@ -341,6 +342,7 @@ impl PtyAutoState { last_output_time: Instant::now(), is_idle: false, interactive_hold: false, + automatic_responses_disabled: false, } } @@ -357,7 +359,7 @@ impl PtyAutoState { /// Supports full match (header + option) and partial-match timeout (5s fallback). /// Handles edge cases where prompt text fragments across reads. pub(crate) async fn handle_mcp_approval(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } if self.mcp_approved { @@ -399,7 +401,7 @@ impl PtyAutoState { /// Detect and approve bypass-permissions prompts in PTY output. pub(crate) async fn handle_bypass_permissions(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } let in_cooldown = self @@ -439,7 +441,7 @@ impl PtyAutoState { /// Detect and dismiss Codex model upgrade prompts by selecting "Use existing model". pub(crate) async fn handle_codex_model_prompt(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } if self.codex_model_prompt_handled { @@ -462,7 +464,7 @@ impl PtyAutoState { /// Detect and accept Codex's startup directory-trust prompt. /// "Yes, continue" is pre-selected as option 1, so Enter is sufficient. pub(crate) async fn handle_codex_trust(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold || self.codex_trust_handled { + if self.automatic_responses_disabled || self.interactive_hold || self.codex_trust_handled { return; } Self::append_buf(&mut self.codex_trust_buffer, text, 2500, 2000); @@ -483,7 +485,7 @@ impl PtyAutoState { /// Detect and auto-approve opencode/droid EXECUTE permission prompts. /// Selects "Yes, and always allow medium impact commands" (arrow down + Enter). pub(crate) async fn handle_opencode_permission(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } let in_cooldown = self @@ -519,7 +521,7 @@ impl PtyAutoState { /// Detect and auto-approve Gemini "Action Required" permission prompts. pub(crate) async fn handle_gemini_action(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } let in_cooldown = self @@ -545,7 +547,7 @@ impl PtyAutoState { /// Detect and auto-approve Gemini "Modify Trust Level" folder trust prompts. /// The menu shows "Trust this folder" pre-selected as option 1, so we just press Enter. pub(crate) async fn handle_gemini_trust(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } if !self.gemini_trust_handled { @@ -569,7 +571,7 @@ impl PtyAutoState { /// to open the trust menu. The existing `handle_gemini_trust` will then pick up the /// interactive "Modify Trust Level" prompt that appears in response. pub(crate) async fn handle_gemini_untrusted_banner(&mut self, text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } if !self.gemini_untrusted_handled { @@ -601,7 +603,7 @@ impl PtyAutoState { /// selected and sent a bare Enter — on Claude Code 2.1.259+ that confirmed /// `No, exit`, killing the worker while its roster row survived. pub(crate) async fn handle_claude_trust(&mut self, _text: &str, pty: &PtySession) { - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } if self.claude_trust_handled { @@ -660,7 +662,7 @@ impl PtyAutoState { pub(crate) fn try_auto_enter(&mut self, pty: &PtySession) { // Suppressed while a human drives: pressing Enter here would submit the // human's half-typed input. - if self.interactive_hold { + if self.automatic_responses_disabled || self.interactive_hold { return; } if let Some(injection_time) = self.last_injection_time { @@ -1336,6 +1338,20 @@ pub(crate) async fn run_wrap( // Spawner for child agents let mut spawner = Spawner::new(); + if crate::readiness::is_devin_cli(&resolved_cli) && !skip_prompt { + let token = default_workspace + .http_client + .register_agent_token(&default_workspace.self_name, Some("devin")) + .await?; + std::env::set_var("RELAY_AGENT_NAME", &default_workspace.self_name); + std::env::set_var("RELAY_AGENT_TOKEN", token); + std::env::set_var("RELAY_WORKSPACES_JSON", &child_workspaces_json); + if let Some(base) = child_base_url.as_deref() { + std::env::set_var("RELAY_BASE_URL", base); + } + } + let _devin_state = crate::devin::prepare_worker_config(&resolved_cli).await?; + // --- Spawn CLI in PTY --- let (pty, mut pty_rx) = PtySession::spawn( &resolved_cli, @@ -1408,6 +1424,7 @@ pub(crate) async fn run_wrap( const SUGGESTION_LOOKBEHIND_MAX: usize = 512; let mut pty_auto = PtyAutoState::new(); + pty_auto.automatic_responses_disabled = crate::readiness::is_devin_cli(&resolved_cli); let mut auto_enter_interval = tokio::time::interval(Duration::from_secs(2)); auto_enter_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); let mut pending_injection_interval = tokio::time::interval(Duration::from_millis(50)); @@ -2051,8 +2068,13 @@ pub(crate) async fn run_wrap( if should_block { continue; } + if !crate::devin::can_inject(&resolved_cli, &pty) { continue; } if let Some(pending) = pending_wrap_injections.pop_front() { tokio::time::sleep(throttle.delay()).await; + if !crate::devin::can_inject(&resolved_cli, &pty) { + pending_wrap_injections.push_front(pending); + continue; + } if pty_auto.auto_suggestion_visible { tracing::warn!( event_id = %pending.event_id, @@ -2079,7 +2101,7 @@ pub(crate) async fn run_wrap( pending.workspace_id.as_deref(), pending.workspace_alias.as_deref(), ); - let mut bytes = injection.as_bytes().to_vec(); + let mut bytes = crate::devin::injection_bytes(&resolved_cli, &injection); let write = if let Some(delay) = injection_submit_followup_delay(&resolved_cli) { // Claude needs Enter in a later PTY write to close its @@ -2286,6 +2308,10 @@ pub(crate) async fn run_wrap( // Re-inject retries for pv in retry_queue { tokio::time::sleep(throttle.delay()).await; + if !crate::devin::can_inject(&resolved_cli, &pty) { + pending_verifications.push_back(pv); + continue; + } // Retries consult the throttle like first injections: the // failed attempt usually already echoed the full block, so // a fresh one within the cooldown is redundant. @@ -2302,7 +2328,7 @@ pub(crate) async fn run_wrap( pv.workspace_id.as_deref(), pv.workspace_alias.as_deref(), ); - let mut bytes = injection.as_bytes().to_vec(); + let mut bytes = crate::devin::injection_bytes(&resolved_cli, &injection); let write = if let Some(delay) = injection_submit_followup_delay(&resolved_cli) { pty.submit_write_paced_with_followup_and_output_boundary( @@ -2432,6 +2458,30 @@ mod tests { use std::io; use std::time::{Duration, Instant}; + #[cfg(unix)] + #[tokio::test] + async fn devin_disables_generic_prompt_approvals_and_idle_enter() { + let (pty, _rx) = crate::pty::PtySession::spawn("sleep", &["10".into()], 24, 80).unwrap(); + let mut state = super::PtyAutoState::new(); + state.automatic_responses_disabled = true; + state + .handle_mcp_approval("Do you want to allow this MCP server?", &pty) + .await; + state + .handle_opencode_permission("EXECUTE Permission required Yes, allow", &pty) + .await; + state + .handle_codex_trust("Do you trust this directory?", &pty) + .await; + state.last_injection_time = Some(Instant::now() - Duration::from_secs(60)); + state.try_auto_enter(&pty); + assert!(state.mcp_detection_buffer.is_empty()); + assert!(state.opencode_perm_buffer.is_empty()); + assert!(state.codex_trust_buffer.is_empty()); + assert!(state.last_auto_enter_time.is_none()); + pty.shutdown().unwrap(); + } + #[test] fn paste_aware_harnesses_use_a_delayed_submit_followup() { let expected = Some(Duration::from_millis(250)); @@ -2459,6 +2509,15 @@ mod tests { expected ); assert_eq!(injection_submit_followup_delay("opencode"), None); + for cli in [ + "devin", + "/usr/bin/devin", + "Devin.EXE", + "devin.cmd", + "devin.bat", + ] { + assert_eq!(injection_submit_followup_delay(cli), expected); + } } #[test] diff --git a/crates/relay-pty/src/detection.rs b/crates/relay-pty/src/detection.rs index 7ddf019b51..5e83c8c581 100644 --- a/crates/relay-pty/src/detection.rs +++ b/crates/relay-pty/src/detection.rs @@ -12,6 +12,8 @@ impl ActivityDetector { vec!["⠋", "⠙", "⠹", "Tool:", "Read(", "Write(", "Edit("] } else if lower.contains("codex") { vec!["Thinking...", "Running:", "$ ", "function_call"] + } else if crate::readiness::is_devin_cli(cli) { + vec!["Thinking ·", "Guide Devin while it works"] } else if lower.contains("gemini") { vec!["Generating", "Action:", "Executing"] } else { diff --git a/crates/relay-pty/src/readiness.rs b/crates/relay-pty/src/readiness.rs index f11068c79c..0cad1e6ccc 100644 --- a/crates/relay-pty/src/readiness.rs +++ b/crates/relay-pty/src/readiness.rs @@ -25,6 +25,10 @@ pub fn detect_cli_ready( let clean = strip_ansi(output); let lower_cli = cli.to_lowercase(); + if is_devin_cli(cli) { + return devin_prompt_ready(grid); + } + if clean.contains("->pty:ready") { return true; } @@ -58,6 +62,9 @@ pub fn detect_cli_ready( /// Detect prompt visibility from the rendered grid. pub fn cli_prompt_ready(cli: &str, grid: GridReadinessSnapshot<'_>) -> bool { + if is_devin_cli(cli) { + return devin_prompt_ready(grid); + } let lower_cli = cli.to_lowercase(); let grid_snapshot = snapshot_for_grid(grid); @@ -76,6 +83,35 @@ pub fn cli_prompt_ready(cli: &str, grid: GridReadinessSnapshot<'_>) -> bool { set.evaluate(&grid_snapshot).is_some() } +/// Match an executable basename, including Windows launcher suffixes. +pub fn is_devin_cli(cli: &str) -> bool { + let base = cli + .rsplit(['/', '\\']) + .next() + .unwrap_or(cli) + .to_ascii_lowercase(); + matches!( + base.as_str(), + "devin" | "devin.exe" | "devin.cmd" | "devin.bat" + ) +} + +fn devin_prompt_ready(grid: GridReadinessSnapshot<'_>) -> bool { + let Some((row, _)) = grid.cursor else { + return false; + }; + // A trust choice also uses ❭. Require the actual idle composer, not the + // glyph, historical output volume, or the busy "Guide Devin" composer. + row > 0 + && grid + .screen + .lines() + .nth((row - 1) as usize) + .is_some_and(|line| { + line.trim() == "❭ Ask Devin to build features, fix bugs, or work on your code" + }) +} + fn claude_grid_ready(grid: GridReadinessSnapshot<'_>) -> bool { let has_welcome = grid.screen.contains("Welcome back") || grid.screen.contains("Welcome to ") @@ -122,6 +158,53 @@ fn snapshot_for_grid(grid: GridReadinessSnapshot<'_>) -> WaitSnapshot<'_> { mod tests { use super::*; + #[test] + fn devin_requires_live_idle_composer_for_all_executable_spellings() { + for cli in [ + "devin", + "/usr/local/bin/devin", + r"C:\tools\Devin.EXE", + "devin.cmd", + "devin.bat", + ] { + assert!(is_devin_cli(cli)); + let screen = "Devin CLI\n❭ Ask Devin to build features, fix bugs, or work on your code\nSWE-2 High"; + assert!(detect_cli_ready( + cli, + "", + 0, + GridReadinessSnapshot { + screen, + cursor: Some((2, 3)) + } + )); + for blocked in [ + "❭ 1 Yes, trust", + "❭ Guide Devin while it works", + "Loading...", + "❭ submitted text", + ] { + assert!(!detect_cli_ready( + cli, + "->pty:ready", + 99999, + GridReadinessSnapshot { + screen: blocked, + cursor: Some((1, 3)) + } + )); + } + assert!(!cli_prompt_ready( + cli, + GridReadinessSnapshot { + screen, + cursor: Some((3, 3)) + } + )); + } + assert!(!is_devin_cli("not-devin")); + } + #[test] fn versioned_claude_banner_with_real_composer_is_ready_without_greeting() { let screen = "Claude Code v2.1.263\nOpus 5 · Claude Max\n────────────────\n❯ \n────────────────\n⏵⏵ bypass permissions on"; diff --git a/docs/harnesses/devin.md b/docs/harnesses/devin.md new file mode 100644 index 0000000000..4e9dce4564 --- /dev/null +++ b/docs/harnesses/devin.md @@ -0,0 +1,68 @@ +# Devin CLI + +Relay supports `devin` as a PTY harness in local spawning, fleet spawning and +Agent Relay MCP. Install and authenticate Devin separately, then use: + +```sh +agent-relay local agent spawn devin --name reviewer --task 'Review the current diff' +``` + +The TypeScript harness export is `devin` from `@agent-relay/harnesses`. +Use `model` to pass Devin's `--model`; `/model ` switches a running session. +Available model names depend on the account: run `devin models list`. +An explicit Relay session reference resumes with `--resume `; conflicting +resume/continue arguments are rejected. Relay does not discover new Devin +session IDs from terminal output. + +## Permissions and readiness + +Verified against Devin `3000.10.31 (b98cc431)` on Linux. Its default permission +mode is `auto` (read-only tools); workspace trust is enabled by default. +Relay adds no approval, permission or trust bypass flags and disables generic +PTY auto-responders for Devin. Trust and tool approvals require an operator in +an attached terminal. Authenticate and trust the intended worktree before +unattended spawning. Trust is scoped to that directory. + +Readiness requires the live idle `❭ Ask Devin to build features, fix bugs, or +work on your code` composer at the cursor. Trust choices, busy composers and +output byte counts do not establish readiness. Messages remain queued while +an approval or other dialog occupies the composer. + +Both initial tasks and follow-up messages use bracketed paste, then a separate +Enter after 250 ms. In the installed CLI, a paste and Enter in one terminal +write left the prompt in the composer; a later Enter submitted it. Relay does +not send repeated recovery Enters that might accidentally approve a tool. + +## MCP configuration + +Devin's `--config` overrides settings, but does not relocate its user MCP file. +The broker's dedicated PTY worker creates a private temporary XDG configuration +snapshot, preserving existing Devin settings and unrelated MCP server entries, +and supplies a worker-specific `agent-relay` entry in +`$XDG_CONFIG_HOME/devin/mcp_config.json`. HOME and data directories remain +unchanged so authentication, workspace trust and session storage remain usable. +No credentials appear in command arguments. The source user files are never +rewritten; each worker receives its own snapshot. Snapshot files are private, +MCP writes are atomic, and normal worker exit deletes the temporary directory. +A forcibly killed wrapper can leave a private snapshot requiring cleanup. + +Malformed MCP files and symlinks inside the Devin configuration directory are +rejected. A project MCP entry named `agent-relay` is rejected rather than +silently overriding the worker's identity. Other project configuration remains +under Devin's normal loading and trust rules. Configuration changes made in a +running worker's user snapshot do not update the original user settings. + +The isolated configuration is installed by the broker PTY/wrap process; +`mcp-args --cli devin` alone does not configure a standalone Devin process. +Only Linux has been exercised end to end. Windows executable suffixes are +recognized for readiness and submission; native Windows configuration isolation +has not been validated. + +## Rollout + +Deploy the patched broker and CLI through the normal approved rollout. Nodes +using the default harness set advertise `spawn:devin`; nodes with an explicit +harness list must add `devin`. The executable and authenticated account must be +available on the selected node. No production broker restart is required to +validate a candidate: use a separately built broker, unique node ID and private +state directory on the same host. diff --git a/packages/cli/README.md b/packages/cli/README.md index 993f94cdb4..e6ef31c1e6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -38,6 +38,7 @@ agent-relay node workflow sync agent-relay node agent new claude # spawn + attach agent-relay node agent new codex --runtime native agent-relay node agent spawn opencode --runtime pty +agent-relay node agent spawn devin --name reviewer --task "Review the current diff" agent-relay node agent list agent-relay node agent list --status # + inbound delivery mode and pending-queue contents per agent agent-relay node agent attach --mode view diff --git a/packages/cli/src/auto/composer.ts b/packages/cli/src/auto/composer.ts index 8aa04864e8..810ca7ee19 100644 --- a/packages/cli/src/auto/composer.ts +++ b/packages/cli/src/auto/composer.ts @@ -21,7 +21,7 @@ export type OnboardingVariant = 'bare' | 'one-liner' | 'brief' | 'skill'; * Which CLI harness to use for an agent. * Extend as opencode model evals complete and confirm role fitness. */ -export type WorkerCli = 'claude' | 'codex' | 'opencode' | 'gemini' | 'droid'; +export type WorkerCli = 'claude' | 'codex' | 'opencode' | 'gemini' | 'droid' | 'devin'; /** * Roles from the choosing-swarm-patterns skill. diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 55b0f90f4d..0a00f7ef31 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -1272,7 +1272,7 @@ function registerAgentRelayTools( inputSchema: { name: z.string().describe('Worker agent name'), cli: z - .enum(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode']) + .enum(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode', 'devin']) .describe( 'Which AI CLI runs the worker: "codex agent" → codex, "gemini agent" → gemini, ' + '"claude/opus claude/sonnet claude agent" → claude (default).' @@ -1334,7 +1334,7 @@ function registerAgentRelayTools( inputSchema: { name: z.string().describe('Agent name'), cli: z - .enum(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode']) + .enum(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode', 'devin']) .optional() .describe('AI CLI to launch; mutually exclusive with persona'), persona: z diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index ef6ef0d1f1..1e43d3f96e 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -62,7 +62,7 @@ const SERVE_REPLACEMENT_MESSAGE = "'fleet serve' has been replaced. Run 'relay node up' (with an optional --config ); " + "for Cloud-managed nodes run 'relay cloud enroll --token ' first."; -const FLEET_CLIS = new Set(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode']); +const FLEET_CLIS = new Set(['claude', 'codex', 'gemini', 'aider', 'goose', 'grok', 'opencode', 'devin']); const CLOUD_SANDBOX_ID_PATTERN = /^sbx_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; diff --git a/packages/cli/src/cli/lib/fleet-sidecar.test.ts b/packages/cli/src/cli/lib/fleet-sidecar.test.ts index fc59fa7d6b..026458a966 100644 --- a/packages/cli/src/cli/lib/fleet-sidecar.test.ts +++ b/packages/cli/src/cli/lib/fleet-sidecar.test.ts @@ -32,7 +32,7 @@ import { describe('nodeCapacityHarnesses', () => { it('advertises the default harness set (matching the broker default) when there is no config', () => { - expect(nodeCapacityHarnesses(null)).toEqual(['claude', 'codex', 'gemini', 'opencode']); + expect(nodeCapacityHarnesses(null)).toEqual(['claude', 'codex', 'gemini', 'opencode', 'devin']); }); it('adds teams.json clis, de-duplicated and order-preserving', () => { @@ -43,7 +43,7 @@ describe('nodeCapacityHarnesses', () => { { name: 'b', cli: 'claude' }, ], }; - expect(nodeCapacityHarnesses(teams)).toEqual(['claude', 'codex', 'gemini', 'opencode', 'aider']); + expect(nodeCapacityHarnesses(teams)).toEqual(['claude', 'codex', 'gemini', 'opencode', 'devin', 'aider']); }); it('adds spawn: definitions from a discovered node config', () => { @@ -56,6 +56,7 @@ describe('nodeCapacityHarnesses', () => { 'codex', 'gemini', 'opencode', + 'devin', 'aider', ]); }); @@ -75,10 +76,10 @@ describe('resolveNodeCapacityHarnesses', () => { capabilities: { 'spawn:aider': spawn({ runtime: 'pty', command: 'aider' }) }, }); expect(resolveNodeCapacityHarnesses(undefined, null, definition)).toBe( - 'claude,codex,gemini,opencode,aider' + 'claude,codex,gemini,opencode,devin,aider' ); // A blank/whitespace value is treated as unset. - expect(resolveNodeCapacityHarnesses(' ', null)).toBe('claude,codex,gemini,opencode'); + expect(resolveNodeCapacityHarnesses(' ', null)).toBe('claude,codex,gemini,opencode,devin'); }); }); diff --git a/packages/cli/src/cli/lib/fleet-sidecar.ts b/packages/cli/src/cli/lib/fleet-sidecar.ts index 13398dbe0e..6f8715d07d 100644 --- a/packages/cli/src/cli/lib/fleet-sidecar.ts +++ b/packages/cli/src/cli/lib/fleet-sidecar.ts @@ -13,7 +13,7 @@ import type { CoreTeamsConfig } from '../commands/core.js'; // Mirrors the broker's built-in default (crates/broker init `DEFAULT_NODE_HARNESSES`); // the CLI overrides `AGENT_RELAY_NODE_HARNESSES`, so omitting one would drop the // broker's default capacity for it. -const DEFAULT_HARNESSES = ['claude', 'codex', 'gemini', 'opencode'] as const; +const DEFAULT_HARNESSES = ['claude', 'codex', 'gemini', 'opencode', 'devin'] as const; /** * The minimum a node config has to expose to contribute `spawn:` diff --git a/packages/cli/src/cli/telemetry/orchestrator-harness.ts b/packages/cli/src/cli/telemetry/orchestrator-harness.ts index c41ee93fc5..25d09d434f 100644 --- a/packages/cli/src/cli/telemetry/orchestrator-harness.ts +++ b/packages/cli/src/cli/telemetry/orchestrator-harness.ts @@ -65,6 +65,7 @@ const HARNESS_COMMAND_MATCHERS: ReadonlyArray<{ matches: ({ base, lower }) => base === 'opencode' || lower.includes('opencode'), }, { harness: 'goose', matches: ({ base, lower }) => base === 'goose' || lower.includes('goose') }, + { harness: 'devin', matches: ({ base }) => /^devin(?:\.(?:exe|cmd|bat))?$/i.test(base) }, { harness: 'droid', matches: ({ base, lower }) => base === 'droid' || lower.includes('droid') }, { harness: 'grok', matches: ({ base }) => base === 'grok' }, { harness: 'amp', matches: ({ base, normalized }) => base === 'amp' || normalized.includes('/amp') }, diff --git a/packages/cloud/src/permissions.ts b/packages/cloud/src/permissions.ts index 602f9f1efe..3efb619a2f 100644 --- a/packages/cloud/src/permissions.ts +++ b/packages/cloud/src/permissions.ts @@ -17,6 +17,7 @@ export type AgentCli = | 'grok' | 'opencode' | 'droid' + | 'devin' | 'cursor' | 'cursor-agent' | 'agent' diff --git a/packages/config/src/cli-registry.generated.ts b/packages/config/src/cli-registry.generated.ts index 7b7873ed26..38e6d61749 100644 --- a/packages/config/src/cli-registry.generated.ts +++ b/packages/config/src/cli-registry.generated.ts @@ -20,6 +20,8 @@ export const CLIVersions = { GEMINI: '0.39.1', /** Cursor v2026.02.27-e7d2ef6 */ CURSOR: '2026.02.27-e7d2ef6', + /** Devin v3000.10.31 */ + DEVIN: '3000.10.31', /** Droid v0.1.0 */ DROID: '0.1.0', /** OpenCode v1.2.24 */ @@ -40,6 +42,7 @@ export const CLIs = { CODEX: 'codex', GEMINI: 'gemini', CURSOR: 'cursor', + DEVIN: 'devin', DROID: 'droid', OPENCODE: 'opencode', GROK: 'grok', @@ -301,6 +304,16 @@ export const CursorModels = { export type CursorModel = (typeof CursorModels)[keyof typeof CursorModels]; +/** + * Devin model identifiers. + */ +export const DevinModels = { + /** SWE-1.6 */ + SWE_1_6: 'swe-1.6', +} as const; + +export type DevinModel = (typeof DevinModels)[keyof typeof DevinModels]; + /** * Droid model identifiers. */ @@ -587,6 +600,13 @@ export const CURSOR_MODEL_OPTIONS: ModelOption[] = [ { value: 'kimi-k2.5', label: 'Kimi K2.5' }, ]; +/** + * Devin model options for UI dropdowns. + */ +export const DEVIN_MODEL_OPTIONS: ModelOption[] = [ + { value: 'swe-1.6', label: 'SWE-1.6' }, +]; + /** * Droid model options for UI dropdowns. */ @@ -794,6 +814,13 @@ export const CURSOR_MODEL_METADATA: Record = { 'kimi-k2.5': { value: 'kimi-k2.5', label: 'Kimi K2.5' }, }; +/** + * Devin model metadata keyed by model id. + */ +export const DEVIN_MODEL_METADATA: Record = { + 'swe-1.6': { value: 'swe-1.6', label: 'SWE-1.6' }, +}; + /** * Droid model metadata keyed by model id. */ @@ -883,6 +910,7 @@ export const Models = { Codex: CodexModels, Gemini: GeminiModels, Cursor: CursorModels, + Devin: DevinModels, Droid: DroidModels, Opencode: OpencodeModels, Grok: GrokModels, @@ -905,6 +933,7 @@ export const ModelOptions = { Codex: CODEX_MODEL_OPTIONS, Gemini: GEMINI_MODEL_OPTIONS, Cursor: CURSOR_MODEL_OPTIONS, + Devin: DEVIN_MODEL_OPTIONS, Droid: DROID_MODEL_OPTIONS, Opencode: OPENCODE_MODEL_OPTIONS, Grok: GROK_MODEL_OPTIONS, @@ -918,6 +947,7 @@ export const ModelMetadata = { Codex: CODEX_MODEL_METADATA, Gemini: GEMINI_MODEL_METADATA, Cursor: CURSOR_MODEL_METADATA, + Devin: DEVIN_MODEL_METADATA, Droid: DROID_MODEL_METADATA, Opencode: OPENCODE_MODEL_METADATA, Grok: GROK_MODEL_METADATA, @@ -928,6 +958,7 @@ const MODEL_METADATA_BY_CLI: Record> = { codex: CODEX_MODEL_METADATA, gemini: GEMINI_MODEL_METADATA, cursor: CURSOR_MODEL_METADATA, + devin: DEVIN_MODEL_METADATA, droid: DROID_MODEL_METADATA, opencode: OPENCODE_MODEL_METADATA, grok: GROK_MODEL_METADATA, @@ -1022,6 +1053,13 @@ export const CLIRegistry = { install: 'Download from cursor.com', npmLink: undefined, }, + devin: { + name: 'Devin', + package: 'devin', + version: '3000.10.31', + install: 'Install Devin CLI from https://docs.devin.ai/cli', + npmLink: undefined, + }, droid: { name: 'Droid', package: 'droid', diff --git a/packages/harnesses/README.md b/packages/harnesses/README.md index 2656a5110e..31e9e025e2 100644 --- a/packages/harnesses/README.md +++ b/packages/harnesses/README.md @@ -25,8 +25,11 @@ Runtime selection is final before the session starts. Relay does not switch a ru | OpenCode | `@ai-sdk/harness-opencode@1.0.35` | yes | PTY; native is explicit and experimental | | Pi | `@ai-sdk/harness-pi@1.0.34` | no | explicit experimental native | | Deep Agents | `@ai-sdk/harness-deepagents@1.0.33` | no | explicit experimental native | +| Devin | none | yes | PTY with approval prompts preserved | | Other built-ins | none | yes | PTY | +See [Devin CLI setup and limitations](../../docs/harnesses/devin.md) for authentication, workspace trust, and isolated MCP configuration. + Pi and Deep Agents require `runtime: 'native'` while experimental. Deep Agents does not advertise manual compaction, and stopping its current adapter does not preserve in-memory conversation. Harness execution is `pty` or `native`. The broker may internally wrap native harnesses and attached app servers as `headless` processes, but `headless` is not an observability mode. Both execution paths publish the same normalized `AgentEvent` contract. diff --git a/packages/harnesses/src/define.test.ts b/packages/harnesses/src/define.test.ts index e3063dd656..c4a8fd4188 100644 --- a/packages/harnesses/src/define.test.ts +++ b/packages/harnesses/src/define.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { claude, codex, definePtyHarness, grok } from './index.js'; +import { devin, claude, codex, definePtyHarness, grok } from './index.js'; describe('harness factories (Phase C)', () => { + it('creates a first-class Devin PTY agent', () => { + expect(devin.new({ name: 'devin-worker' }).cli).toBe('devin'); + }); it('exposes the static definition shape for the runtime', () => { expect(claude.runtime).toBe('pty'); expect(claude.command).toBe('claude'); diff --git a/packages/harnesses/src/index.ts b/packages/harnesses/src/index.ts index 29cc5b839d..24d6b199fc 100644 --- a/packages/harnesses/src/index.ts +++ b/packages/harnesses/src/index.ts @@ -23,6 +23,8 @@ export const gemini: PtyHarness = definePtyHarness({ runtime: 'pty', command: 'g export const cursor: PtyHarness = definePtyHarness({ runtime: 'pty', command: 'cursor-agent' }); +export const devin: PtyHarness = definePtyHarness({ runtime: 'pty', command: 'devin' }); + export const droid: PtyHarness = definePtyHarness({ runtime: 'pty', command: 'droid' }); export const opencode = defineManagedHarness('opencode', { runtime: 'pty', command: 'opencode' }); diff --git a/packages/harnesses/src/observability.test.ts b/packages/harnesses/src/observability.test.ts index 82784e4e87..98b205f7ba 100644 --- a/packages/harnesses/src/observability.test.ts +++ b/packages/harnesses/src/observability.test.ts @@ -8,7 +8,7 @@ import { describe('PTY observability profiles', () => { it('declares every built-in PTY harness against one honest baseline', () => { - expect(Object.keys(PTY_OBSERVABILITY_PROFILES)).toHaveLength(9); + expect(Object.keys(PTY_OBSERVABILITY_PROFILES)).toHaveLength(10); for (const profile of Object.values(PTY_OBSERVABILITY_PROFILES)) { expect(profile.activities.starting).toEqual({ available: true, fidelities: ['exact'] }); expect(profile.activities.thinking).toEqual({ available: true, fidelities: ['inferred'] }); diff --git a/packages/harnesses/src/observability.ts b/packages/harnesses/src/observability.ts index 17709174c3..c04c830114 100644 --- a/packages/harnesses/src/observability.ts +++ b/packages/harnesses/src/observability.ts @@ -43,6 +43,7 @@ const PTY_HARNESSES = [ 'gemini', 'cursor-agent', 'droid', + 'devin', 'opencode', 'aider', 'goose', @@ -85,6 +86,7 @@ const PTY_ALIASES: Record = { cursor: 'cursor-agent', 'cursor-agent': 'cursor-agent', droid: 'droid', + devin: 'devin', opencode: 'opencode', aider: 'aider', goose: 'goose', diff --git a/packages/sdk-py/src/agent_relay/models.py b/packages/sdk-py/src/agent_relay/models.py index 57f70fac65..86bf0688a9 100644 --- a/packages/sdk-py/src/agent_relay/models.py +++ b/packages/sdk-py/src/agent_relay/models.py @@ -13,6 +13,7 @@ class CLIVersions: CODEX: Final[str] = "0.130.0" # Codex CLI GEMINI: Final[str] = "0.39.1" # Gemini CLI CURSOR: Final[str] = "2026.02.27-e7d2ef6" # Cursor + DEVIN: Final[str] = "3000.10.31" # Devin DROID: Final[str] = "0.1.0" # Droid OPENCODE: Final[str] = "1.2.24" # OpenCode GROK: Final[str] = "0.1.0" # Grok @@ -26,6 +27,7 @@ class CLIs: CODEX: Final[str] = "codex" GEMINI: Final[str] = "gemini" CURSOR: Final[str] = "cursor" + DEVIN: Final[str] = "devin" DROID: Final[str] = "droid" OPENCODE: Final[str] = "opencode" GROK: Final[str] = "grok" @@ -159,6 +161,11 @@ class CursorModels: KIMI_K2_5: Final[str] = "kimi-k2.5" # Kimi K2.5 +class DevinModels: + """Devin model identifiers.""" + SWE_1_6: Final[str] = "swe-1.6" # SWE-1.6 + + class DroidModels: """Droid model identifiers.""" OPUS_4_6_FAST: Final[str] = "opus-4.6-fast" # Opus 4.6 Fast Mode (12x) (default) @@ -354,6 +361,10 @@ class ModelOption(TypedDict): {"value": "kimi-k2.5", "label": "Kimi K2.5"}, ] +DEVIN_MODEL_OPTIONS: Final[List[ModelOption]] = [ + {"value": "swe-1.6", "label": "SWE-1.6"}, +] + DROID_MODEL_OPTIONS: Final[List[ModelOption]] = [ {"value": "opus-4.6-fast", "label": "Opus 4.6 Fast Mode (12x)"}, {"value": "opus-4.5", "label": "Opus 4.5 (2x)"}, @@ -424,6 +435,7 @@ class Models: Codex = CodexModels Gemini = GeminiModels Cursor = CursorModels + Devin = DevinModels Droid = DroidModels Opencode = OpencodeModels Grok = GrokModels @@ -435,6 +447,7 @@ class ModelOptions: Codex = CODEX_MODEL_OPTIONS Gemini = GEMINI_MODEL_OPTIONS Cursor = CURSOR_MODEL_OPTIONS + Devin = DEVIN_MODEL_OPTIONS Droid = DROID_MODEL_OPTIONS Opencode = OPENCODE_MODEL_OPTIONS Grok = GROK_MODEL_OPTIONS @@ -489,6 +502,12 @@ class SwarmPatterns: "version": "2026.02.27-e7d2ef6", "install": "Download from cursor.com", }, + "devin": { + "name": "Devin", + "package": "devin", + "version": "3000.10.31", + "install": "Install Devin CLI from https://docs.devin.ai/cli", + }, "droid": { "name": "Droid", "package": "droid", diff --git a/packages/sdk-py/src/agent_relay/types.py b/packages/sdk-py/src/agent_relay/types.py index 695241a433..e92a2fd169 100644 --- a/packages/sdk-py/src/agent_relay/types.py +++ b/packages/sdk-py/src/agent_relay/types.py @@ -41,6 +41,7 @@ "grok", "opencode", "droid", + "devin", "cursor", "cursor-agent", "agent", diff --git a/packages/utils/cli-registry.yaml b/packages/utils/cli-registry.yaml index 0d6b6e0a68..5cf2edf358 100644 --- a/packages/utils/cli-registry.yaml +++ b/packages/utils/cli-registry.yaml @@ -389,6 +389,16 @@ clis: id: 'kimi-k2.5' label: 'Kimi K2.5' + devin: + name: 'Devin' + package: 'devin' + version: '3000.10.31' + install: 'Install Devin CLI from https://docs.devin.ai/cli' + models: + swe_1_6: + id: 'swe-1.6' + label: 'SWE-1.6' + droid: name: 'Droid' package: 'droid' diff --git a/packages/utils/src/model-commands.test.ts b/packages/utils/src/model-commands.test.ts index 147006897c..4e9d3f58c8 100644 --- a/packages/utils/src/model-commands.test.ts +++ b/packages/utils/src/model-commands.test.ts @@ -7,6 +7,9 @@ import { } from './model-commands.js'; describe('Model Commands', () => { + it('uses Devin model names without rewriting them', () => { + expect(buildModelSwitchCommand('devin', 'swe-1.6')).toBe('/model swe-1.6\n'); + }); describe('isModelSwitchSupported', () => { it('returns true for claude', () => { expect(isModelSwitchSupported('claude')).toBe(true); diff --git a/packages/utils/src/model-commands.ts b/packages/utils/src/model-commands.ts index 46d37fb5c6..08c13b9aa8 100644 --- a/packages/utils/src/model-commands.ts +++ b/packages/utils/src/model-commands.ts @@ -48,6 +48,7 @@ const CLI_MODEL_COMMANDS: Record = { }, codex: { supported: false }, gemini: { supported: false }, + devin: { supported: true, buildCommand: (model: string) => `/model ${model}\n` }, droid: { supported: false }, opencode: { supported: false }, aider: { supported: false }, From 08e6f133f8a1471779b751440b81c9ed272aba43 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sat, 19 Sep 2026 22:56:02 -0700 Subject: [PATCH 2/3] fix: harden Devin readiness and delivery lifecycle Session-Id: 01a0bd39-5328-7431-be1f-8c17026784d3 --- .../compact_c0wgy00tu5ff_2026-09-20.json | 48 +++++++++ .../compact_c0wgy00tu5ff_2026-09-20.md | 21 ++++ crates/broker/src/relaycast/ws.rs | 27 +++++ crates/broker/src/worker.rs | 2 + crates/broker/src/wrap.rs | 76 +++++++++---- crates/relay-pty/src/readiness.rs | 101 ++++++++++++++---- docs/harnesses/devin.md | 6 +- 7 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 .agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.json create mode 100644 .agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.md diff --git a/.agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.json b/.agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.json new file mode 100644 index 0000000000..662cc00f63 --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.json @@ -0,0 +1,48 @@ +{ + "id": "compact_c0wgy00tu5ff", + "version": 1, + "type": "compacted", + "compactedAt": "2026-09-20T05:55:47.761Z", + "sourceTrajectories": [ + "traj_dcxgossuw06x" + ], + "dateRange": { + "start": "2026-09-20T05:41:06.719Z", + "end": "2026-09-20T05:55:47.571Z" + }, + "summary": { + "totalDecisions": 2, + "totalEvents": 2, + "uniqueAgents": [ + "default" + ] + }, + "decisionGroups": [ + { + "category": "security", + "decisions": [ + { + "question": "Handle Devin word-wrapped idle placeholders after confirming installed CLI at 40 columns; use cache-only wrap credential handoff", + "chosen": "Handle Devin word-wrapped idle placeholders after confirming installed CLI at 40 columns; use cache-only wrap credential handoff", + "reasoning": "Live 3000.10.31 output wraps the exact prompt onto an indented continuation row. SDK registration already short-circuits seeded credentials, but explicit cache-only access eliminates takeover fallback even on a missing cache entry.", + "fromTrajectory": "traj_dcxgossuw06x" + } + ] + }, + { + "category": "api", + "decisions": [ + { + "question": "Count wrap retries only when injectable; override inherited worker opt-out and restrict native Devin spelling to devin/devin.exe", + "chosen": "Count wrap retries only when injectable; override inherited worker opt-out and restrict native Devin spelling to devin/devin.exe", + "reasoning": "Validated retry budget was spent on busy deferrals and stale parent RELAY_SKIP_PROMPT could disable requested MCP. portable-pty directly launches programs, so unimplemented Windows batch launchers are no longer claimed as supported.", + "fromTrajectory": "traj_dcxgossuw06x" + } + ] + } + ], + "keyLearnings": [], + "keyFindings": [], + "filesAffected": [], + "commits": [] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.md b/.agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.md new file mode 100644 index 0000000000..0894ae4dda --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_c0wgy00tu5ff_2026-09-20.md @@ -0,0 +1,21 @@ +# Trajectory Compaction: Sep 19, 2026 - Sep 19, 2026 + +## Summary +- Sessions: 1 +- Decisions: 2 +- Events: 2 +- Agents: default +- Files: 0 +- Commits: 0 + +## Security +- Handle Devin word-wrapped idle placeholders after confirming installed CLI at 40 columns; use cache-only wrap credential handoff -> Handle Devin word-wrapped idle placeholders after confirming installed CLI at 40 columns; use cache-only wrap credential handoff (traj_dcxgossuw06x) + +## Api +- Count wrap retries only when injectable; override inherited worker opt-out and restrict native Devin spelling to devin/devin.exe -> Count wrap retries only when injectable; override inherited worker opt-out and restrict native Devin spelling to devin/devin.exe (traj_dcxgossuw06x) + +## Key Learnings +- None + +## Key Findings +- None \ No newline at end of file diff --git a/crates/broker/src/relaycast/ws.rs b/crates/broker/src/relaycast/ws.rs index b6633806aa..4ec900f7ac 100644 --- a/crates/broker/src/relaycast/ws.rs +++ b/crates/broker/src/relaycast/ws.rs @@ -243,6 +243,15 @@ impl RelaycastHttpClient { } } + /// Read an already-authenticated identity without registration, takeover, + /// or any network fallback. Used when handing a live session to a CLI. + pub(crate) fn cached_agent_token(&self, agent_name: &str) -> Option { + self.registration + .as_ref() + .as_ref() + .and_then(|registration| registration.cached_agent_token(agent_name)) + } + pub fn registration_block_remaining(&self, agent_name: &str) -> Option { self.registration .as_ref() @@ -4837,6 +4846,24 @@ mod tests { register.assert_hits(0); } + #[test] + fn cached_session_token_handoff_never_registers_or_takes_over() { + let server = MockServer::start(); + let network = server.mock(|_when, then| { + then.status(500); + }); + let client = + RelaycastHttpClient::new(Some(server.base_url()), "rk_test", "broker", "devin"); + assert_eq!(client.cached_agent_token("worker-a"), None); + client.seed_agent_token("worker-a", "at_existing_session"); + assert_eq!( + client.cached_agent_token("worker-a").as_deref(), + Some("at_existing_session") + ); + assert_eq!(client.cached_agent_token("unknown-worker"), None); + network.assert_hits(0); + } + /// Must-not-fire: a cached token short-circuits before the presence /// probe. This is the throughput path for warm brokers and it must not /// pay a round trip on every impersonation. diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index c4426c74de..85f2ed814f 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -1282,6 +1282,8 @@ impl WorkerRegistry { command.env("RELAY_AGENT_TYPE", "agent"); command.env("RELAY_STRICT_AGENT_NAME", "1"); } + // The per-worker option overrides a parent broker opt-out. + command.env_remove("RELAY_SKIP_PROMPT"); if skip_relay_prompt { command.env("RELAY_SKIP_PROMPT", "1"); } diff --git a/crates/broker/src/wrap.rs b/crates/broker/src/wrap.rs index 5a3637b24a..4f75ca0cb9 100644 --- a/crates/broker/src/wrap.rs +++ b/crates/broker/src/wrap.rs @@ -222,6 +222,20 @@ fn wrap_injection_timer_allowed(has_pending_write_ack: bool) -> bool { !has_pending_write_ack } +// Readiness deferrals must not spend the bounded delivery retry budget. +fn prepare_wrap_retry( + verification: &mut PendingVerification, + injectable: bool, + now: Instant, +) -> bool { + if !injectable { + verification.injected_at = now; + return false; + } + verification.attempts += 1; + true +} + /// Start echo verification after a PTY write ack without missing output that /// raced ahead of the ack select arm. Returns `true` when the echo was already /// present and the delivery was confirmed immediately. @@ -1341,8 +1355,8 @@ pub(crate) async fn run_wrap( if crate::readiness::is_devin_cli(&resolved_cli) && !skip_prompt { let token = default_workspace .http_client - .register_agent_token(&default_workspace.self_name, Some("devin")) - .await?; + .cached_agent_token(&default_workspace.self_name) + .context("Devin wrap requires the existing authenticated session token")?; std::env::set_var("RELAY_AGENT_NAME", &default_workspace.self_name); std::env::set_var("RELAY_AGENT_TOKEN", token); std::env::set_var("RELAY_WORKSPACES_JSON", &child_workspaces_json); @@ -2271,15 +2285,8 @@ pub(crate) async fn run_wrap( let mut i = 0; while i < pending_verifications.len() { if pending_verifications[i].injected_at.elapsed() >= VERIFICATION_WINDOW { - let mut pv = pending_verifications.remove(i).unwrap(); + let pv = pending_verifications.remove(i).unwrap(); if pv.attempts < pv.max_attempts { - pv.attempts += 1; - tracing::warn!( - event_id = %pv.event_id, - attempt = pv.attempts, - max = pv.max_attempts, - "wrap: echo verification timeout, retrying injection" - ); retry_queue.push(pv); } else { tracing::warn!( @@ -2306,12 +2313,18 @@ pub(crate) async fn run_wrap( } // Re-inject retries - for pv in retry_queue { + for mut pv in retry_queue { tokio::time::sleep(throttle.delay()).await; - if !crate::devin::can_inject(&resolved_cli, &pty) { + if !prepare_wrap_retry(&mut pv, crate::devin::can_inject(&resolved_cli, &pty), Instant::now()) { pending_verifications.push_back(pv); continue; } + tracing::warn!( + event_id = %pv.event_id, + attempt = pv.attempts, + max = pv.max_attempts, + "wrap: echo verification timeout, retrying injection" + ); // Retries consult the throttle like first injections: the // failed attempt usually already echoed the full block, so // a fresh one within the cooldown is redundant. @@ -2509,13 +2522,7 @@ mod tests { expected ); assert_eq!(injection_submit_followup_delay("opencode"), None); - for cli in [ - "devin", - "/usr/bin/devin", - "Devin.EXE", - "devin.cmd", - "devin.bat", - ] { + for cli in ["devin", "/usr/bin/devin", "Devin.EXE"] { assert_eq!(injection_submit_followup_delay(cli), expected); } } @@ -2555,6 +2562,37 @@ mod tests { assert!(!wrap_injection_timer_allowed(true)); } + #[test] + fn busy_devin_deferrals_preserve_retry_budget_and_reset_deadline() { + let mut verification = PendingVerification { + delivery_id: DeliveryId::new("delivery"), + event_id: EventId::new("event"), + expected_echo: String::new(), + output_boundary: 0, + injected_at: Instant::now(), + attempts: 1, + max_attempts: MAX_VERIFICATION_ATTEMPTS, + request_id: None, + workspace_id: None, + workspace_alias: None, + from: "sender".into(), + body: "task".into(), + target: MessageTarget::new("devin"), + }; + for _ in 0..20 { + let now = verification.injected_at + super::VERIFICATION_WINDOW; + assert!(!super::prepare_wrap_retry(&mut verification, false, now)); + assert_eq!(verification.attempts, 1); + assert_eq!(verification.injected_at, now); + } + assert!(super::prepare_wrap_retry( + &mut verification, + true, + Instant::now() + )); + assert_eq!(verification.attempts, 2); + } + #[test] fn echo_arriving_before_write_ack_is_confirmed_immediately() { let injection = "multiline task\nwith a delayed submit"; diff --git a/crates/relay-pty/src/readiness.rs b/crates/relay-pty/src/readiness.rs index 0cad1e6ccc..7ca4405853 100644 --- a/crates/relay-pty/src/readiness.rs +++ b/crates/relay-pty/src/readiness.rs @@ -83,33 +83,56 @@ pub fn cli_prompt_ready(cli: &str, grid: GridReadinessSnapshot<'_>) -> bool { set.evaluate(&grid_snapshot).is_some() } -/// Match an executable basename, including Windows launcher suffixes. +/// Match a native executable basename, including the Windows .exe suffix. pub fn is_devin_cli(cli: &str) -> bool { let base = cli .rsplit(['/', '\\']) .next() .unwrap_or(cli) .to_ascii_lowercase(); - matches!( - base.as_str(), - "devin" | "devin.exe" | "devin.cmd" | "devin.bat" - ) + matches!(base.as_str(), "devin" | "devin.exe") } fn devin_prompt_ready(grid: GridReadinessSnapshot<'_>) -> bool { let Some((row, _)) = grid.cursor else { return false; }; - // A trust choice also uses ❭. Require the actual idle composer, not the - // glyph, historical output volume, or the busy "Guide Devin" composer. - row > 0 - && grid - .screen - .lines() - .nth((row - 1) as usize) - .is_some_and(|line| { - line.trim() == "❭ Ask Devin to build features, fix bugs, or work on your code" - }) + // A trust choice also uses ❭. Require the exact idle placeholder across + // its visual rows, with the cursor inside that composer. Devin word-wraps + // continuation rows with two spaces at narrow terminal widths. + const IDLE: &str = "❭ Ask Devin to build features, fix bugs, or work on your code"; + let Some(cursor_row) = row.checked_sub(1).map(usize::from) else { + return false; + }; + let lines: Vec<_> = grid.screen.lines().collect(); + for start in 0..=cursor_row.min(lines.len().saturating_sub(1)) { + let Some(first) = lines.get(start) else { + continue; + }; + if !first.trim().starts_with("❭ ") { + continue; + } + let mut composer = String::new(); + for (end, line) in lines.iter().enumerate().skip(start) { + if end > start { + if !line.starts_with(" ") || line.trim().is_empty() { + break; + } + composer.push(' '); + } + composer.push_str(line.trim()); + if composer == IDLE { + if cursor_row <= end { + return true; + } + break; + } + if !IDLE.starts_with(&composer) { + break; + } + } + } + false } fn claude_grid_ready(grid: GridReadinessSnapshot<'_>) -> bool { @@ -160,13 +183,7 @@ mod tests { #[test] fn devin_requires_live_idle_composer_for_all_executable_spellings() { - for cli in [ - "devin", - "/usr/local/bin/devin", - r"C:\tools\Devin.EXE", - "devin.cmd", - "devin.bat", - ] { + for cli in ["devin", "/usr/local/bin/devin", r"C:\tools\Devin.EXE"] { assert!(is_devin_cli(cli)); let screen = "Devin CLI\n❭ Ask Devin to build features, fix bugs, or work on your code\nSWE-2 High"; assert!(detect_cli_ready( @@ -203,6 +220,46 @@ mod tests { )); } assert!(!is_devin_cli("not-devin")); + assert!(!is_devin_cli("devin.cmd")); + assert!(!is_devin_cli("devin.bat")); + } + + #[test] + fn devin_wrapped_idle_composer_requires_cursor_in_exact_placeholder() { + // Captured from Devin 3000.10.31 at 40 columns, including indentation. + let screen = "────────────────────────────────────────\n❭ Ask Devin to build features, fix \n bugs, or work on your code\n────────────────────────────────────────\nSWE-2 High"; + for row in [2, 3] { + let grid = GridReadinessSnapshot { + screen, + cursor: Some((row, 3)), + }; + assert!(cli_prompt_ready("devin", grid)); + assert!(detect_cli_ready("devin", "", 0, grid)); + } + for row in [0, 1, 4, 5, 99] { + assert!(!cli_prompt_ready( + "devin", + GridReadinessSnapshot { + screen, + cursor: Some((row, 3)) + } + )); + } + for blocked in [ + "❭ Guide Devin while it works\n bugs, or work on your code", + "❭ Ask Devin to build features, fix\n changed text", + "❭ Ask Devin to build features, fix\n\n bugs, or work on your code", + "❭ Ask Devin to build features, fix\nbugs, or work on your code", + "❭ 1 Yes, trust\n this workspace", + ] { + assert!(!cli_prompt_ready( + "devin", + GridReadinessSnapshot { + screen: blocked, + cursor: Some((1, 3)) + } + )); + } } #[test] diff --git a/docs/harnesses/devin.md b/docs/harnesses/devin.md index 4e9dce4564..505baa63c6 100644 --- a/docs/harnesses/devin.md +++ b/docs/harnesses/devin.md @@ -54,9 +54,9 @@ running worker's user snapshot do not update the original user settings. The isolated configuration is installed by the broker PTY/wrap process; `mcp-args --cli devin` alone does not configure a standalone Devin process. -Only Linux has been exercised end to end. Windows executable suffixes are -recognized for readiness and submission; native Windows configuration isolation -has not been validated. +Only Linux has been exercised end to end. The native Windows `devin.exe` spelling is +recognized for readiness and submission; `.cmd` and `.bat` launchers are not +supported. Native Windows configuration isolation has not been validated. ## Rollout From 8be1549b292a397f8429bc2944fcc8c87aa5458b Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sun, 20 Sep 2026 00:36:05 -0700 Subject: [PATCH 3/3] test: stabilize Devin PTY proof and update profile expectations Session-Id: 01a0bd39-5328-7431-be1f-8c17026784d3 --- .../compact_fiats1hqsz1w_2026-09-20.json | 37 +++++++++++++++++++ .../compact_fiats1hqsz1w_2026-09-20.md | 18 +++++++++ crates/broker/src/devin.rs | 25 ++++++++++--- .../pty-reference-profile.test.ts | 1 + 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 .agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.json create mode 100644 .agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.md diff --git a/.agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.json b/.agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.json new file mode 100644 index 0000000000..777d6057e8 --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.json @@ -0,0 +1,37 @@ +{ + "id": "compact_fiats1hqsz1w", + "version": 1, + "type": "compacted", + "compactedAt": "2026-09-20T07:35:59.621Z", + "sourceTrajectories": [ + "traj_wgktv31za7qa" + ], + "dateRange": { + "start": "2026-09-20T07:32:32.259Z", + "end": "2026-09-20T07:35:59.393Z" + }, + "summary": { + "totalDecisions": 1, + "totalEvents": 1, + "uniqueAgents": [ + "default" + ] + }, + "decisionGroups": [ + { + "category": "testing", + "decisions": [ + { + "question": "Fix CI regressions in test expectations and asynchronous PTY observation only", + "chosen": "Fix CI regressions in test expectations and asynchronous PTY observation only", + "reasoning": "All five JS failures point to omitted devin profile expectation. macOS fails fixture result assertion after a fixed 100ms sleep. Preserve 250ms submit behavior and both semantic assertions; await observed output up to 5s and keep fixture alive until shutdown.", + "fromTrajectory": "traj_wgktv31za7qa" + } + ] + } + ], + "keyLearnings": [], + "keyFindings": [], + "filesAffected": [], + "commits": [] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.md b/.agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.md new file mode 100644 index 0000000000..f6781b9433 --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_fiats1hqsz1w_2026-09-20.md @@ -0,0 +1,18 @@ +# Trajectory Compaction: Sep 20, 2026 - Sep 20, 2026 + +## Summary +- Sessions: 1 +- Decisions: 1 +- Events: 1 +- Agents: default +- Files: 0 +- Commits: 0 + +## Testing +- Fix CI regressions in test expectations and asynchronous PTY observation only -> Fix CI regressions in test expectations and asynchronous PTY observation only (traj_wgktv31za7qa) + +## Key Learnings +- None + +## Key Findings +- None \ No newline at end of file diff --git a/crates/broker/src/devin.rs b/crates/broker/src/devin.rs index 6918e15f31..7ee027891b 100644 --- a/crates/broker/src/devin.rs +++ b/crates/broker/src/devin.rs @@ -192,7 +192,7 @@ while select.select([0],[],[],0.06)[0]: data+=os.read(0,65536) if data.endswith(b'\r'): os.write(1,b'PARKED') else: if os.read(0,1)==b'\r': os.write(1,b'SUBMITTED') -time.sleep(0.3) +os.read(0,1) # Stay alive until the test shuts down the PTY. "#; for delayed in [false, true] { let (pty, mut rx) = PtySession::spawn( @@ -225,10 +225,25 @@ time.sleep(0.3) body.push(b'\r'); pty.submit_write(body).unwrap().await.unwrap().unwrap(); } - tokio::time::sleep(Duration::from_millis(100)).await; - assert!(pty - .screen_text() - .contains(if delayed { "SUBMITTED" } else { "PARKED" })); + let expected = if delayed { "SUBMITTED" } else { "PARKED" }; + // A write acknowledgement is not a child-output acknowledgement. + // Wait for the semantic result instead of assuming the reader and + // grid update finish within 100 ms on every CI platform. + let observed = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let screen = pty.screen_text(); + if screen.contains("SUBMITTED") || screen.contains("PARKED") { + break screen; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("PTY fixture did not publish its submission result"); + assert!( + observed.contains(expected), + "expected {expected}, got {observed:?}" + ); pty.shutdown().unwrap(); drain.abort(); } diff --git a/tests/integration/ai-sdk-harnesses/pty-reference-profile.test.ts b/tests/integration/ai-sdk-harnesses/pty-reference-profile.test.ts index 3704c83bce..e9d78fcc05 100644 --- a/tests/integration/ai-sdk-harnesses/pty-reference-profile.test.ts +++ b/tests/integration/ai-sdk-harnesses/pty-reference-profile.test.ts @@ -15,6 +15,7 @@ describe('PTY parity against the AI SDK reference profile', () => { 'claude', 'codex', 'cursor-agent', + 'devin', 'droid', 'gemini', 'goose',