cli: spawn pods through insomnia pod

This commit is contained in:
Keisuke Hirata 2026-05-31 14:20:00 +09:00
parent de3f64f41d
commit 4f622b8e32
No known key found for this signature in database
12 changed files with 212 additions and 43 deletions

6
Cargo.lock generated
View File

@ -333,6 +333,7 @@ name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"manifest", "manifest",
"pod-command",
"protocol", "protocol",
"serde_json", "serde_json",
"tokio", "tokio",
@ -2338,6 +2339,7 @@ dependencies = [
"manifest", "manifest",
"memory", "memory",
"minijinja", "minijinja",
"pod-command",
"pod-registry", "pod-registry",
"pod-store", "pod-store",
"protocol", "protocol",
@ -2357,6 +2359,10 @@ dependencies = [
"workflow", "workflow",
] ]
[[package]]
name = "pod-command"
version = "0.1.0"
[[package]] [[package]]
name = "pod-registry" name = "pod-registry"
version = "0.1.0" version = "0.1.0"

View File

@ -8,6 +8,7 @@ members = [
"crates/session-store", "crates/session-store",
"crates/manifest", "crates/manifest",
"crates/pod", "crates/pod",
"crates/pod-command",
"crates/pod-store", "crates/pod-store",
"crates/protocol", "crates/protocol",
"crates/provider", "crates/provider",
@ -33,6 +34,7 @@ manifest = { path = "crates/manifest" }
lint-common = { path = "crates/lint-common" } lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" } memory = { path = "crates/memory" }
pod = { path = "crates/pod" } pod = { path = "crates/pod" }
pod-command = { path = "crates/pod-command" }
pod-registry = { path = "crates/pod-registry" } pod-registry = { path = "crates/pod-registry" }
pod-store = { path = "crates/pod-store" } pod-store = { path = "crates/pod-store" }
protocol = { path = "crates/protocol" } protocol = { path = "crates/protocol" }

View File

@ -7,6 +7,7 @@ license.workspace = true
[dependencies] [dependencies]
protocol = { workspace = true } protocol = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
pod-command = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
uuid = { workspace = true } uuid = { workspace = true }

View File

@ -1,4 +1,4 @@
//! `insomnia-pod` バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ //! Pod runtime command をサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ
//! ハンドシェイク。 //! ハンドシェイク。
//! //!
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を //! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
@ -15,6 +15,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::time::Duration; use std::time::Duration;
use pod_command::PodRuntimeCommand;
use tokio::process::Command; use tokio::process::Command;
use uuid::Uuid; use uuid::Uuid;
@ -99,7 +100,7 @@ pub async fn spawn_pod<F>(config: SpawnConfig, mut progress: F) -> Result<SpawnR
where where
F: FnMut(&str), F: FnMut(&str),
{ {
let pod_bin = resolve_pod_command(); let pod_command = PodRuntimeCommand::resolve().map_err(SpawnError::Io)?;
let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name) let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name)
.ok_or(SpawnError::RuntimeDirUnavailable)?; .ok_or(SpawnError::RuntimeDirUnavailable)?;
@ -107,8 +108,9 @@ where
let stderr_path = pod_runtime_dir.join("stderr.log"); let stderr_path = pod_runtime_dir.join("stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?; let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
let mut command = Command::new(&pod_bin); let mut command = Command::new(pod_command.program());
command command
.args(pod_command.prefix_args())
.current_dir(&config.cwd) .current_dir(&config.cwd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
@ -268,23 +270,6 @@ async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offse
*offset = content.len(); *offset = content.len();
} }
/// Resolves the binary used to launch a child Pod. Must point at a
/// `insomnia-pod`-compatible executable — the parent reads the child's stderr
/// directly looking for `INSOMNIA-READY`, so any wrapper that emits
/// extra lines on stderr will pollute that handshake.
///
/// `INSOMNIA_POD_COMMAND` overrides the lookup (used by tests to inject
/// a mock binary). Otherwise we defer to `PATH` — missing binary
/// surfaces as the spawn `io::Error`.
fn resolve_pod_command() -> PathBuf {
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND")
&& !cmd.is_empty()
{
return PathBuf::from(cmd);
}
PathBuf::from("insomnia-pod")
}
struct StderrTail { struct StderrTail {
lines: std::collections::VecDeque<String>, lines: std::collections::VecDeque<String>,
} }

View File

@ -0,0 +1,7 @@
[package]
name = "pod-command"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]

View File

@ -0,0 +1,170 @@
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
pub const POD_COMMAND_OVERRIDE_ENV: &str = "INSOMNIA_POD_COMMAND";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PodRuntimeCommand {
pub program: PathBuf,
pub prefix_args: Vec<OsString>,
}
impl PodRuntimeCommand {
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
Self {
program: program.into(),
prefix_args,
}
}
pub fn executable_only(program: impl Into<PathBuf>) -> Self {
Self::new(program, Vec::new())
}
pub fn for_current_exe() -> io::Result<Self> {
Ok(Self::for_executable(std::env::current_exe()?))
}
pub fn for_executable(program: impl Into<PathBuf>) -> Self {
let program = program.into();
let prefix_args = if is_legacy_pod_binary(&program) {
Vec::new()
} else {
vec![OsString::from("pod")]
};
Self::new(program, prefix_args)
}
/// Resolve the Pod runtime command used for subprocess launches.
///
/// `INSOMNIA_POD_COMMAND` is intentionally executable-only: its value is
/// used as the program path without shell parsing and without the unified
/// `pod` prefix arg. That keeps existing development/test overrides safe
/// while the default path moves to `current_exe() + ["pod"]`.
pub fn resolve() -> io::Result<Self> {
if let Some(command) = Self::from_override_env() {
return Ok(command);
}
Self::for_current_exe()
}
pub fn from_override_env() -> Option<Self> {
let raw = std::env::var_os(POD_COMMAND_OVERRIDE_ENV)?;
if raw.is_empty() {
return None;
}
Some(Self::executable_only(raw))
}
pub fn program(&self) -> &Path {
&self.program
}
pub fn prefix_args(&self) -> &[OsString] {
&self.prefix_args
}
pub fn argv_with<I, S>(&self, args: I) -> Vec<OsString>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut argv = self.prefix_args.clone();
argv.extend(args.into_iter().map(Into::into));
argv
}
}
impl fmt::Display for PodRuntimeCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.program.display())?;
for arg in &self.prefix_args {
write!(f, " {}", arg.to_string_lossy())?;
}
Ok(())
}
}
fn is_legacy_pod_binary(program: &Path) -> bool {
let Some(file_name) = program.file_name().and_then(OsStr::to_str) else {
return false;
};
let stem = file_name.strip_suffix(".exe").unwrap_or(file_name);
stem == "insomnia-pod"
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvRestore(Option<OsString>);
impl EnvRestore {
fn capture() -> Self {
Self(std::env::var_os(POD_COMMAND_OVERRIDE_ENV))
}
}
impl Drop for EnvRestore {
fn drop(&mut self) {
unsafe {
match &self.0 {
Some(value) => std::env::set_var(POD_COMMAND_OVERRIDE_ENV, value),
None => std::env::remove_var(POD_COMMAND_OVERRIDE_ENV),
}
}
}
}
#[test]
fn insomnia_binary_defaults_to_pod_prefix() {
let command = PodRuntimeCommand::for_executable("/opt/insomnia/bin/insomnia");
assert_eq!(command.program(), Path::new("/opt/insomnia/bin/insomnia"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn legacy_wrapper_keeps_executable_only_command() {
let command = PodRuntimeCommand::for_executable("/opt/insomnia/bin/insomnia-pod");
assert_eq!(
command.program(),
Path::new("/opt/insomnia/bin/insomnia-pod")
);
assert!(command.prefix_args().is_empty());
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["--pod", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn env_override_is_executable_only_and_not_shell_parsed() {
let _guard = ENV_LOCK.lock().unwrap();
let _restore = EnvRestore::capture();
unsafe {
std::env::set_var(POD_COMMAND_OVERRIDE_ENV, "/tmp/mock pod --flag");
}
let command = PodRuntimeCommand::resolve().unwrap();
assert_eq!(command.program(), Path::new("/tmp/mock pod --flag"));
assert!(command.prefix_args().is_empty());
}
}

View File

@ -17,6 +17,7 @@ pod-store = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
protocol = { workspace = true } protocol = { workspace = true }
provider = { workspace = true } provider = { workspace = true }
pod-command = { workspace = true }
pod-registry = { workspace = true } pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@ -16,6 +16,7 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{Permission, ScopeRule}; use manifest::{Permission, ScopeRule};
use pod_command::PodRuntimeCommand;
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore}; use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore};
use protocol::stream::JsonLineReader; use protocol::stream::JsonLineReader;
use protocol::{Event, PodStatus}; use protocol::{Event, PodStatus};
@ -328,8 +329,10 @@ where
pod_name: &str, pod_name: &str,
socket_path: &Path, socket_path: &Path,
) -> Result<(), PodDiscoveryError> { ) -> Result<(), PodDiscoveryError> {
let mut command = Command::new(resolve_pod_command()); let pod_command = PodRuntimeCommand::resolve().map_err(PodDiscoveryError::RestoreSpawn)?;
let mut command = Command::new(pod_command.program());
command command
.args(pod_command.prefix_args())
.arg("--pod") .arg("--pod")
.arg(pod_name) .arg(pod_name)
.arg("--require-pod-state") .arg("--require-pod-state")
@ -661,15 +664,6 @@ fn lookup_segment_lock(
pod_registry::lookup_segment(segment_id) pod_registry::lookup_segment(segment_id)
} }
fn resolve_pod_command() -> PathBuf {
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND")
&& !cmd.is_empty()
{
return PathBuf::from(cmd);
}
PathBuf::from("insomnia-pod")
}
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct PodNameInput { struct PodNameInput {
/// Pod name to restore. /// Pod name to restore.

View File

@ -2,7 +2,7 @@
//! //!
//! Wires pod-registry delegation, child manifest-config construction, subprocess //! Wires pod-registry delegation, child manifest-config construction, subprocess
//! launch, and socket handoff into a single `Tool` implementation. When //! launch, and socket handoff into a single `Tool` implementation. When
//! the LLM calls `SpawnPod`, a fresh `insomnia-pod` binary is exec'd in its own //! the LLM calls `SpawnPod`, a fresh Pod runtime command is exec'd in its own
//! process group, the pod-registry is updated atomically, and the child's //! process group, the pod-registry is updated atomically, and the child's
//! first turn is kicked off by handing its socket a `Method::Run`. //! first turn is kicked off by handing its socket a `Method::Run`.
@ -19,6 +19,7 @@ use manifest::{
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig, ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial, WorkerManifestConfig,
}; };
use pod_command::PodRuntimeCommand;
use serde::Deserialize; use serde::Deserialize;
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::process::Command; use tokio::process::Command;
@ -408,8 +409,9 @@ impl SpawnPodTool {
spawn_config_json: &str, spawn_config_json: &str,
predicted_socket: &Path, predicted_socket: &Path,
) -> Result<(), ToolError> { ) -> Result<(), ToolError> {
let pod_command = let pod_command = PodRuntimeCommand::resolve().map_err(|error| {
std::env::var("INSOMNIA_POD_COMMAND").unwrap_or_else(|_| "insomnia-pod".into()); ToolError::ExecutionFailed(format!("failed to resolve Pod runtime command: {error}"))
})?;
// Pre-create the child's runtime dir so we have a stable place to // Pre-create the child's runtime dir so we have a stable place to
// capture its stderr before it has had a chance to bind anything. // capture its stderr before it has had a chance to bind anything.
@ -430,8 +432,9 @@ impl SpawnPodTool {
ToolError::ExecutionFailed(format!("open {}: {e}", stderr_path.display())) ToolError::ExecutionFailed(format!("open {}: {e}", stderr_path.display()))
})?; })?;
let mut cmd = Command::new(&pod_command); let mut cmd = Command::new(pod_command.program());
cmd.arg("--adopt") cmd.args(pod_command.prefix_args())
.arg("--adopt")
.arg("--callback") .arg("--callback")
.arg(&self.callback_socket) .arg(&self.callback_socket)
.arg("--spawn-config-json") .arg("--spawn-config-json")
@ -488,7 +491,7 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
} }
/// Serialise the internal manifest config that gets handed to the child /// Serialise the internal manifest config that gets handed to the child
/// `insomnia-pod` binary via the hidden `--spawn-config-json` flag. /// Pod runtime process via the hidden `--spawn-config-json` flag.
/// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the /// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the
/// internal handoff shape. /// internal handoff shape.
/// ///

View File

@ -65,7 +65,7 @@ enum Mode {
profile: Option<String>, profile: Option<String>,
}, },
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if /// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process /// possible; otherwise launch the Pod runtime command with `--pod <name>` so it
/// resumes from name-keyed state or creates a fresh same-name Pod. /// resumes from name-keyed state or creates a fresh same-name Pod.
PodName { PodName {
pod_name: String, pod_name: String,

View File

@ -2,7 +2,7 @@
//! //!
//! Reads live Pod allocations from the runtime registry and stopped Pod state //! Reads live Pod allocations from the runtime registry and stopped Pod state
//! from the pod-store name-keyed metadata. Picking a live row attaches to //! from the pod-store name-keyed metadata. Picking a live row attaches to
//! its socket; picking a stopped row restores via `insomnia-pod --pod <name>`. //! its socket; picking a stopped row restores via the Pod runtime command.
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
@ -65,7 +65,7 @@ impl From<session_store::StoreError> for PickerError {
pub enum PickerOutcome { pub enum PickerOutcome {
/// User picked a Pod. `socket_override` is set for live rows when the /// User picked a Pod. `socket_override` is set for live rows when the
/// runtime registry knows the exact socket path; stopped rows leave it /// runtime registry knows the exact socket path; stopped rows leave it
/// empty so the caller restores with `insomnia-pod --pod <name>`. /// empty so the caller restores by spawning the Pod runtime command.
Picked { Picked {
pod_name: String, pod_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,

View File

@ -3,7 +3,7 @@
//! Rendered at the user's current cursor position when `insomnia` is invoked //! Rendered at the user's current cursor position when `insomnia` is invoked
//! with no positional argument. Discovers `.insomnia/profiles.toml` profile //! with no positional argument. Discovers `.insomnia/profiles.toml` profile
//! choices plus bundled profiles, defaults to the builtin profile, prompts for //! choices plus bundled profiles, defaults to the builtin profile, prompts for
//! the Pod's name, and on confirmation launches the `insomnia-pod` binary as an //! the Pod's name, and on confirmation launches the Pod runtime command as an
//! independent process. Once the process reports its socket via the //! independent process. Once the process reports its socket via the
//! `INSOMNIA-READY` stderr line, the dialog hands control back so main can //! `INSOMNIA-READY` stderr line, the dialog hands control back so main can
//! switch the terminal to alternate-screen mode. //! switch the terminal to alternate-screen mode.
@ -72,7 +72,7 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Source session for a resume run. `None` = fresh spawn (current /// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and /// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
/// passes `--session <id>` to the spawned `insomnia-pod` child. /// passes `--session <id>` to the spawned Pod runtime child.
pub async fn run( pub async fn run(
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
profile: Option<String>, profile: Option<String>,
@ -162,7 +162,7 @@ pub async fn run(
} }
} }
/// Launch `insomnia-pod --pod <name>` without opening the name dialog. The child Pod /// Launch a Pod runtime command with `--pod <name>` without opening the name dialog. The child Pod
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod /// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
/// from the default profile. /// from the default profile.
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> { pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
@ -415,7 +415,7 @@ struct Form {
/// When true, launch the child with `--pod <name>` so the pod process /// When true, launch the child with `--pod <name>` so the pod process
/// resolves name-keyed state before falling back to fresh creation. /// resolves name-keyed state before falling back to fresh creation.
resume_by_pod_name: bool, resume_by_pod_name: bool,
/// Optional profile choices passed to `insomnia-pod --profile` for /// Optional profile choices passed with `--profile` for
/// fresh spawns. This is not used for resume/attach flows because those must /// fresh spawns. This is not used for resume/attach flows because those must
/// restore Pod state rather than re-evaluate a profile source. /// restore Pod state rather than re-evaluate a profile source.
profile_choices: Vec<ProfileChoice>, profile_choices: Vec<ProfileChoice>,