fix: remove generic overlay startup path

This commit is contained in:
Keisuke Hirata 2026-05-30 04:34:27 +09:00
parent 625730cb0a
commit 20ac0c96a5
No known key found for this signature in database
10 changed files with 198 additions and 280 deletions

1
Cargo.lock generated
View File

@ -334,6 +334,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"manifest", "manifest",
"protocol", "protocol",
"serde_json",
"tokio", "tokio",
"uuid", "uuid",
] ]

View File

@ -7,5 +7,6 @@ license.workspace = true
[dependencies] [dependencies]
protocol = { workspace = true } protocol = { workspace = true }
manifest = { workspace = true } manifest = { 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,8 +1,8 @@
//! `insomnia-pod` バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ //! `insomnia-pod` バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ
//! ハンドシェイク。 //! ハンドシェイク。
//! //!
//! - 親プロセス (TUI / GUI / E2E) は overlay TOML を組み立ててこの関数に //! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
//! 渡す。pod はそれを受けて socket を bind し、stderr に //! 指定してこの関数に渡す。pod はそれを受けて socket を bind し、stderr に
//! `INSOMNIA-READY\t<name>\t<socket>` を吐く。 //! `INSOMNIA-READY\t<name>\t<socket>` を吐く。
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。 //! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
//! UI の進捗表示や E2E のログ収集はここで賄う。 //! UI の進捗表示や E2E のログ収集はここで賄う。
@ -28,12 +28,11 @@ pub struct SpawnConfig {
/// 名前との突き合わせに使う。 /// 名前との突き合わせに使う。
pub pod_name: String, pub pod_name: String,
/// Optional Nix profile selector. When present the child is launched with /// Optional Nix profile selector. When present the child is launched with
/// `--profile` and the TOML overlay is not passed; the Pod name is supplied /// `--profile`; the Pod name is supplied through `--profile-pod-name` so
/// through `--profile-pod-name` so profile evaluation stays separate from /// profile evaluation stays separate from `--pod` restore semantics.
/// manifest layer merging and from `--pod` restore semantics.
pub profile: Option<String>, pub profile: Option<String>,
/// `--overlay` で pod に渡す TOML 文字列。 /// Optional session-scope snapshot used when restoring by session id.
pub overlay_toml: String, pub resume_scope: Option<manifest::ScopeConfig>,
/// pod の current_dir。 /// pod の current_dir。
pub cwd: PathBuf, pub cwd: PathBuf,
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから /// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
@ -123,14 +122,22 @@ where
.arg(profile) .arg(profile)
.arg("--profile-pod-name") .arg("--profile-pod-name")
.arg(&config.pod_name); .arg(&config.pod_name);
} else {
command.arg("--overlay").arg(&config.overlay_toml);
} }
if config.resume_by_pod_name && config.profile.is_none() { if config.resume_by_pod_name && config.profile.is_none() {
command.arg("--pod").arg(&config.pod_name); command.arg("--pod").arg(&config.pod_name);
} }
if let Some(id) = config.resume_from { if let Some(id) = config.resume_from {
command.arg("--session").arg(id.to_string()); command
.arg("--session")
.arg(id.to_string())
.arg("--session-pod-name")
.arg(&config.pod_name);
if let Some(scope) = &config.resume_scope {
let scope_json = serde_json::to_string(scope).map_err(|e| {
SpawnError::PodLaunchFailed(io::Error::new(io::ErrorKind::InvalidInput, e))
})?;
command.arg("--resume-scope-json").arg(scope_json);
}
} }
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?; let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;

View File

@ -1,9 +1,10 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use clap::Parser; use clap::Parser;
use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths}; use manifest::{
NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, ScopeConfig, paths,
};
use pod::{Pod, PodController, PromptLoader}; use pod::{Pod, PodController, PromptLoader};
use session_store::{FsStore, PodMetadataStore, SegmentId, Store}; use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
@ -19,7 +20,7 @@ struct Cli {
#[arg( #[arg(
long, long,
value_name = "PROFILE", value_name = "PROFILE",
conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"] conflicts_with_all = ["manifest", "project", "pod", "session", "adopt"]
)] )]
profile: Option<String>, profile: Option<String>,
@ -32,7 +33,7 @@ struct Cli {
/// Manifest TOML to use directly as a one-file compatibility/debug input. /// Manifest TOML to use directly as a one-file compatibility/debug input.
/// This bypasses profile discovery but still applies builtin defaults and /// This bypasses profile discovery but still applies builtin defaults and
/// the same required-field validation boundary. /// the same required-field validation boundary.
#[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])] #[arg(long, value_name = "PATH", conflicts_with_all = ["project"])]
manifest: Option<PathBuf>, manifest: Option<PathBuf>,
/// Deprecated manifest-cascade project root flag. Ambient project/user /// Deprecated manifest-cascade project root flag. Ambient project/user
@ -40,11 +41,23 @@ struct Cli {
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
project: Option<PathBuf>, project: Option<PathBuf>,
/// Inline TOML override applied to the implicit default profile. This is /// Internal typed pod-name override for session restore launched by the TUI.
/// retained for launchers that need to supply restore/session-time values; #[arg(long, value_name = "NAME", requires = "session", hide = true)]
/// it does not re-enable user/project manifest discovery. session_pod_name: Option<String>,
#[arg(long, value_name = "TOML")]
overlay: Option<String>, /// Internal typed scope snapshot for session restore launched by the TUI.
#[arg(long, value_name = "JSON", requires = "session", hide = true)]
resume_scope_json: Option<String>,
/// Internal resolved manifest config for delegated child Pod spawning.
#[arg(
long,
value_name = "JSON",
requires = "adopt",
conflicts_with_all = ["profile", "manifest", "project", "pod", "session"],
hide = true
)]
spawn_config_json: Option<String>,
/// Directory for session persistence. Defaults to /// Directory for session persistence. Defaults to
/// `<data_dir>/sessions/` (see `manifest::paths`). /// `<data_dir>/sessions/` (see `manifest::paths`).
@ -83,45 +96,56 @@ struct Cli {
} }
fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> { fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> {
resolve_manifest_with_user_manifest_env(cli, std::env::var_os(paths::USER_MANIFEST_ENV)) resolve_manifest_with_profile_loader(cli, load_profile)
} }
fn resolve_manifest_with_user_manifest_env( fn resolve_manifest_with_profile_loader<F>(
cli: &Cli, cli: &Cli,
user_manifest_env: Option<OsString>,
) -> Result<(PodManifest, PromptLoader), String> {
resolve_manifest_with_user_manifest_env_and_profile_loader(cli, user_manifest_env, load_profile)
}
fn resolve_manifest_with_user_manifest_env_and_profile_loader<F>(
cli: &Cli,
_user_manifest_env: Option<OsString>,
load_profile_fn: F, load_profile_fn: F,
) -> Result<(PodManifest, PromptLoader), String> ) -> Result<(PodManifest, PromptLoader), String>
where where
F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>, F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
{ {
if let Some(profile) = &cli.profile { let mut manifest_and_loader = if let Some(config_json) = cli.spawn_config_json.as_deref() {
load_spawn_config_json(config_json)?
} else if let Some(profile) = &cli.profile {
let selector = ProfileSelector::parse_cli(profile); let selector = ProfileSelector::parse_cli(profile);
return load_profile_fn(&selector, cli.profile_pod_name.as_deref()); load_profile_fn(&selector, cli.profile_pod_name.as_deref())?
} } else if let Some(path) = &cli.manifest {
load_single_manifest(path, cli.pod.as_deref())?
} else {
if cli.project.is_some() {
return Err(
"--project is no longer supported; normal startup uses profile discovery/default, \
and --manifest <PATH> is the only one-file manifest mode"
.to_string(),
);
}
let selector = ProfileSelector::Default;
load_profile_fn(&selector, cli.pod.as_deref())?
};
if let Some(path) = &cli.manifest { apply_session_restore_overrides(&mut manifest_and_loader.0, cli)?;
return load_single_manifest(path, cli.pod.as_deref()); Ok(manifest_and_loader)
} }
if cli.project.is_some() { fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
return Err( if let Some(pod_name) = cli.session_pod_name.as_deref() {
"--project is no longer supported; normal startup uses profile discovery/default, \ manifest.pod.name = pod_name.to_string();
and --manifest <PATH> is the only one-file manifest mode"
.to_string(),
);
} }
if let Some(scope_json) = cli.resume_scope_json.as_deref() {
manifest.scope = serde_json::from_str::<ScopeConfig>(scope_json)
.map_err(|e| format!("failed to parse --resume-scope-json: {e}"))?;
}
Ok(())
}
let selector = ProfileSelector::Default; fn load_spawn_config_json(config_json: &str) -> Result<(PodManifest, PromptLoader), String> {
let (manifest, loader) = load_profile_fn(&selector, None)?; let config = serde_json::from_str::<PodManifestConfig>(config_json)
let manifest = apply_cli_overrides_to_manifest(manifest, cli)?; .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
Ok((manifest, loader)) let manifest = PodManifest::try_from(PodManifestConfig::builtin_defaults().merge(config))
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
Ok((manifest, PromptLoader::builtins_only()))
} }
fn load_profile( fn load_profile(
@ -168,53 +192,13 @@ fn load_single_manifest(
.resolve_paths(base_dir), .resolve_paths(base_dir),
); );
if let Some(pod_name) = pod_name_override { if let Some(pod_name) = pod_name_override {
config = config.merge( config.pod.name = Some(pod_name.to_string());
PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name))
.expect("pod name overlay TOML is generated"),
);
} }
let manifest = PodManifest::try_from(config) let manifest = PodManifest::try_from(config)
.map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?; .map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?;
Ok((manifest, PromptLoader::builtins_only())) Ok((manifest, PromptLoader::builtins_only()))
} }
fn pod_name_overlay_toml(pod_name: &str) -> String {
let mut pod = toml::value::Table::new();
pod.insert("name".into(), toml::Value::String(pod_name.to_string()));
let mut root = toml::value::Table::new();
root.insert("pod".into(), toml::Value::Table(pod));
toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail")
}
fn manifest_to_config(manifest: &PodManifest) -> Result<PodManifestConfig, String> {
let value = serde_json::to_value(manifest)
.map_err(|e| format!("failed to serialise resolved manifest for overlay: {e}"))?;
serde_json::from_value(value)
.map_err(|e| format!("failed to convert resolved manifest for overlay: {e}"))
}
fn apply_cli_overrides_to_manifest(
mut manifest: PodManifest,
cli: &Cli,
) -> Result<PodManifest, String> {
let profile = manifest.profile.clone();
if let Some(overlay) = cli.overlay.as_deref() {
let base_dir = std::env::current_dir()
.map_err(|e| format!("failed to resolve current directory for overlay: {e}"))?;
let overlay = PodManifestConfig::from_toml(overlay)
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?
.resolve_paths(&base_dir);
let config = manifest_to_config(&manifest)?.merge(overlay);
manifest = PodManifest::try_from(config)
.map_err(|e| format!("failed to resolve default profile overlay: {e}"))?;
manifest.profile = profile;
}
if let Some(pod_name) = cli.pod.as_deref() {
manifest.pod.name = pod_name.to_string();
}
Ok(manifest)
}
#[tokio::main] #[tokio::main]
async fn main() -> ExitCode { async fn main() -> ExitCode {
let cli = Cli::parse(); let cli = Cli::parse();
@ -416,7 +400,7 @@ permission = "write"
} }
#[test] #[test]
fn manifest_conflicts_with_project_and_overlay() { fn manifest_conflicts_with_project() {
let project_err = Cli::try_parse_from([ let project_err = Cli::try_parse_from([
"insomnia-pod", "insomnia-pod",
"--manifest", "--manifest",
@ -426,29 +410,23 @@ permission = "write"
]) ])
.unwrap_err(); .unwrap_err();
assert_eq!(project_err.kind(), clap::error::ErrorKind::ArgumentConflict); assert_eq!(project_err.kind(), clap::error::ErrorKind::ArgumentConflict);
let overlay_err = Cli::try_parse_from([
"insomnia-pod",
"--manifest",
"manifest.toml",
"--overlay",
"pod.name = 'x'",
])
.unwrap_err();
assert_eq!(overlay_err.kind(), clap::error::ErrorKind::ArgumentConflict);
} }
#[test] #[test]
fn manifest_ignores_non_empty_user_manifest_env() { fn overlay_flag_is_not_accepted() {
let err = Cli::try_parse_from(["insomnia-pod", "--overlay", "pod.name = 'x'"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn manifest_loads_single_file_without_user_or_workspace_prompt_loader() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let manifest = tmp.path().join("manifest.toml"); let manifest = tmp.path().join("manifest.toml");
write(&manifest, &manifest_toml("single", tmp.path())); write(&manifest, &manifest_toml("single", tmp.path()));
let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()]) let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()])
.unwrap(); .unwrap();
let (manifest, loader) = let (manifest, loader) = resolve_manifest(&cli).unwrap();
resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml")))
.unwrap();
assert_eq!(manifest.pod.name, "single"); assert_eq!(manifest.pod.name, "single");
assert!(loader.user_dir().is_none()); assert!(loader.user_dir().is_none());
@ -456,7 +434,7 @@ permission = "write"
} }
#[test] #[test]
fn profile_ignores_non_empty_user_manifest_env() { fn profile_uses_selected_profile() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let profile = tmp.path().join("profile.nix"); let profile = tmp.path().join("profile.nix");
let cli = Cli::try_parse_from([ let cli = Cli::try_parse_from([
@ -469,10 +447,8 @@ permission = "write"
.unwrap(); .unwrap();
let mut called = false; let mut called = false;
let (manifest, loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( let (manifest, loader) =
&cli, resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
Some(OsString::from("non-existent-user-manifest.toml")),
|selector, pod_name| {
called = true; called = true;
assert_eq!(selector, &ProfileSelector::path(profile.clone())); assert_eq!(selector, &ProfileSelector::path(profile.clone()));
assert_eq!(pod_name, Some("from-profile-name")); assert_eq!(pod_name, Some("from-profile-name"));
@ -482,9 +458,8 @@ permission = "write"
manifest.pod.name = pod_name.to_string(); manifest.pod.name = pod_name.to_string();
} }
Ok((manifest, PromptLoader::builtins_only())) Ok((manifest, PromptLoader::builtins_only()))
}, })
) .unwrap();
.unwrap();
assert!(called); assert!(called);
assert_eq!(manifest.pod.name, "from-profile-name"); assert_eq!(manifest.pod.name, "from-profile-name");
@ -505,10 +480,8 @@ permission = "write"
.unwrap(); .unwrap();
let mut called = false; let mut called = false;
let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( let (manifest, _loader) =
&cli, resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
None,
|selector, pod_name| {
called = true; called = true;
assert_eq!( assert_eq!(
selector, selector,
@ -523,40 +496,21 @@ permission = "write"
manifest.pod.name = pod_name.to_string(); manifest.pod.name = pod_name.to_string();
} }
Ok((manifest, PromptLoader::builtins_only())) Ok((manifest, PromptLoader::builtins_only()))
}, })
) .unwrap();
.unwrap();
assert!(called); assert!(called);
assert_eq!(manifest.pod.name, "from-profile-name"); assert_eq!(manifest.pod.name, "from-profile-name");
} }
#[test] #[test]
fn manifest_allows_empty_user_manifest_env() { fn normal_startup_uses_default_profile() {
let tmp = TempDir::new().unwrap();
let manifest = tmp.path().join("manifest.toml");
write(&manifest, &manifest_toml("single", tmp.path()));
let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()])
.unwrap();
let (manifest, loader) =
resolve_manifest_with_user_manifest_env(&cli, Some(OsString::new())).unwrap();
assert_eq!(manifest.pod.name, "single");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
}
#[test]
fn normal_startup_uses_default_profile_and_ignores_user_manifest_env() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let cli = Cli::try_parse_from(["insomnia-pod"]).unwrap(); let cli = Cli::try_parse_from(["insomnia-pod"]).unwrap();
let mut called = false; let mut called = false;
let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader( let (manifest, _loader) =
&cli, resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
Some(OsString::from("ignored-user-manifest.toml")),
|selector, pod_name| {
called = true; called = true;
assert_eq!(selector, &ProfileSelector::Default); assert_eq!(selector, &ProfileSelector::Default);
assert_eq!(pod_name, None); assert_eq!(pod_name, None);
@ -564,9 +518,8 @@ permission = "write"
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
.unwrap(); .unwrap();
Ok((manifest, PromptLoader::builtins_only())) Ok((manifest, PromptLoader::builtins_only()))
}, })
) .unwrap();
.unwrap();
assert!(called); assert!(called);
assert_eq!(manifest.pod.name, "from-default-profile"); assert_eq!(manifest.pod.name, "from-default-profile");
@ -575,7 +528,7 @@ permission = "write"
#[test] #[test]
fn project_flag_no_longer_enables_ambient_manifest_cascade() { fn project_flag_no_longer_enables_ambient_manifest_cascade() {
let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap(); let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap();
let err = resolve_manifest_with_user_manifest_env_and_profile_loader(&cli, None, |_, _| { let err = resolve_manifest_with_profile_loader(&cli, |_, _| {
panic!("default profile loader must not run when deprecated --project is present") panic!("default profile loader must not run when deprecated --project is present")
}) })
.unwrap_err(); .unwrap_err();
@ -605,7 +558,7 @@ permission = "write"
]) ])
.unwrap(); .unwrap();
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.pod.name, "from-flag"); assert_eq!(manifest.pod.name, "from-flag");
} }
@ -637,12 +590,37 @@ permission = "write"
]) ])
.unwrap(); .unwrap();
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.pod.name, "from-flag"); assert_eq!(manifest.pod.name, "from-flag");
assert_eq!(manifest.scope.allow[0].target, tmp.path()); assert_eq!(manifest.scope.allow[0].target, tmp.path());
} }
#[test]
fn pod_flag_with_no_manifest_creates_from_default_profile_with_typed_name() {
let tmp = TempDir::new().unwrap();
let cli = Cli::try_parse_from(["insomnia-pod", "--pod", "agent"]).unwrap();
let mut called = false;
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
called = true;
assert_eq!(selector, &ProfileSelector::Default);
assert_eq!(pod_name, Some("agent"));
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
.unwrap();
if let Some(pod_name) = pod_name {
manifest.pod.name = pod_name.to_string();
}
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
assert!(called);
assert_eq!(manifest.pod.name, "agent");
}
#[test] #[test]
fn profile_conflicts_with_manifest_and_restore_modes() { fn profile_conflicts_with_manifest_and_restore_modes() {
let segment_id = session_store::new_segment_id().to_string(); let segment_id = session_store::new_segment_id().to_string();
@ -696,7 +674,7 @@ permission = "write"
]) ])
.unwrap(); .unwrap();
let (manifest, loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); let (manifest, loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.pod.name, "single-file"); assert_eq!(manifest.pod.name, "single-file");
assert!(loader.user_dir().is_none()); assert!(loader.user_dir().is_none());

View File

@ -1,6 +1,6 @@
//! `SpawnPod` tool — launch a new Pod process as a child of this one. //! `SpawnPod` tool — launch a new Pod process as a child of this one.
//! //!
//! Wires pod-registry delegation, overlay-TOML 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 `insomnia-pod` binary 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
@ -116,8 +116,8 @@ pub struct SpawnPodTool {
/// no-op. /// no-op.
parent_socket: Option<PathBuf>, parent_socket: Option<PathBuf>,
/// Spawner's resolved provider config — copied into every spawned /// Spawner's resolved provider config — copied into every spawned
/// Pod's overlay TOML so the child does not need its own provider /// Pod's internal manifest config so the child does not need its own provider
/// configuration in the manifest cascade. Per-spawn override is /// configuration. Per-spawn override is
/// out of scope here (see `tickets/spawn-inherit-provider.md`). /// out of scope here (see `tickets/spawn-inherit-provider.md`).
spawner_model: ModelManifest, spawner_model: ModelManifest,
/// Spawner's runtime scope. After a successful spawn, the /// Spawner's runtime scope. After a successful spawn, the
@ -208,7 +208,7 @@ impl Tool for SpawnPodTool {
// it back — even if later steps (Method::Run delivery, record // it back — even if later steps (Method::Run delivery, record
// write) fail, the child is running and will release its own // write) fail, the child is running and will release its own
// entry on exit. // entry on exit.
let overlay_toml = match build_overlay_toml( let spawn_config_json = match build_spawn_config_json(
&input.name, &input.name,
&instruction, &instruction,
&scope_allow, &scope_allow,
@ -218,13 +218,13 @@ impl Tool for SpawnPodTool {
Err(e) => { Err(e) => {
self.release_reservation(&lock_path, &input.name); self.release_reservation(&lock_path, &input.name);
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"overlay serialisation: {e}" "spawn config serialisation: {e}"
))); )));
} }
}; };
let start_outcome = self let start_outcome = self
.exec_child(&input.name, &overlay_toml, &predicted_socket) .exec_child(&input.name, &spawn_config_json, &predicted_socket)
.await; .await;
if let Err(e) = start_outcome { if let Err(e) = start_outcome {
self.release_reservation(&lock_path, &input.name); self.release_reservation(&lock_path, &input.name);
@ -300,7 +300,7 @@ impl SpawnPodTool {
async fn exec_child( async fn exec_child(
&self, &self,
pod_name: &str, pod_name: &str,
overlay_toml: &str, spawn_config_json: &str,
predicted_socket: &Path, predicted_socket: &Path,
) -> Result<(), ToolError> { ) -> Result<(), ToolError> {
let pod_command = let pod_command =
@ -329,8 +329,8 @@ impl SpawnPodTool {
cmd.arg("--adopt") cmd.arg("--adopt")
.arg("--callback") .arg("--callback")
.arg(&self.callback_socket) .arg(&self.callback_socket)
.arg("--overlay") .arg("--spawn-config-json")
.arg(overlay_toml) .arg(spawn_config_json)
.current_dir(&self.spawner_pwd) .current_dir(&self.spawner_pwd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
@ -382,20 +382,21 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
.collect() .collect()
} }
/// Serialise the overlay TOML that gets handed to the child `insomnia-pod` /// Serialise the internal manifest config that gets handed to the child
/// binary via `--overlay`. `PodManifestConfig`'s `Serialize` impl is /// `insomnia-pod` binary via the hidden `--spawn-config-json` flag.
/// the single source of truth for the on-disk manifest format. /// `PodManifestConfig`'s `Serialize` impl is the single source of truth for the
/// internal handoff shape.
/// ///
/// The child's working directory is set separately via /// The child's working directory is set separately via
/// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is /// `Command::current_dir` (see [`SpawnPodTool::exec_child`]) — it is
/// not part of the manifest. /// not part of the manifest.
fn build_overlay_toml( fn build_spawn_config_json(
name: &str, name: &str,
instruction: &str, instruction: &str,
scope_allow: &[ScopeRule], scope_allow: &[ScopeRule],
model: &ModelManifest, model: &ModelManifest,
) -> Result<String, toml::ser::Error> { ) -> Result<String, serde_json::Error> {
let overlay = PodManifestConfig { let config = PodManifestConfig {
pod: PodMetaConfig { pod: PodMetaConfig {
name: Some(name.to_string()), name: Some(name.to_string()),
prompt_pack: None, prompt_pack: None,
@ -411,7 +412,7 @@ fn build_overlay_toml(
}, },
..Default::default() ..Default::default()
}; };
toml::to_string(&overlay) serde_json::to_string(&config)
} }
/// Tail of the spawned child's `stderr.log` to splice into a startup /// Tail of the spawned child's `stderr.log` to splice into a startup
@ -524,7 +525,7 @@ mod tests {
use manifest::{AuthRef, SchemeKind}; use manifest::{AuthRef, SchemeKind};
#[test] #[test]
fn overlay_inherits_inline_spawner_model() { fn spawn_config_inherits_inline_spawner_model() {
let model = ModelManifest { let model = ModelManifest {
scheme: Some(SchemeKind::Anthropic), scheme: Some(SchemeKind::Anthropic),
base_url: Some("https://example.test".into()), base_url: Some("https://example.test".into()),
@ -536,8 +537,9 @@ mod tests {
..Default::default() ..Default::default()
}; };
let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap(); let config_json =
let parsed = PodManifestConfig::from_toml(&toml_str).unwrap(); build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap();
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4")); assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4"));
@ -553,13 +555,14 @@ mod tests {
} }
#[test] #[test]
fn overlay_inherits_ref_spawner_model() { fn spawn_config_inherits_ref_spawner_model() {
let model = ModelManifest { let model = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()), ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default() ..Default::default()
}; };
let toml_str = build_overlay_toml("child", "$insomnia/default", &[], &model).unwrap(); let config_json =
let parsed = PodManifestConfig::from_toml(&toml_str).unwrap(); build_spawn_config_json("child", "$insomnia/default", &[], &model).unwrap();
let parsed: PodManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!( assert_eq!(
parsed.model.ref_.as_deref(), parsed.model.ref_.as_deref(),
Some("anthropic/claude-sonnet-4-6") Some("anthropic/claude-sonnet-4-6")

View File

@ -104,7 +104,6 @@ pub async fn run(
let mut form = Form { let mut form = Form {
cwd: defaults.cwd.clone(), cwd: defaults.cwd.clone(),
cascade_has_scope: defaults.cascade_has_scope,
scope_origin: defaults.scope_origin, scope_origin: defaults.scope_origin,
name_cursor: defaults.default_name.chars().count(), name_cursor: defaults.default_name.chars().count(),
name: defaults.default_name, name: defaults.default_name,
@ -153,7 +152,6 @@ pub async fn run(
if let Some(id) = form.resume_from { if let Some(id) = form.resume_from {
form.resume_scope = Some(load_resume_scope(id).await?); form.resume_scope = Some(load_resume_scope(id).await?);
} }
let overlay_toml = build_overlay_toml(&form);
// Phase 2: launch pod and wait for ready line. Drop the cursor // Phase 2: launch pod and wait for ready line. Drop the cursor
// out of the name field — subsequent frames are passive status // out of the name field — subsequent frames are passive status
@ -163,7 +161,7 @@ pub async fn run(
form.message = Some(("starting pod...".to_string(), MessageKind::Progress)); form.message = Some(("starting pod...".to_string(), MessageKind::Progress));
terminal.draw(|f| draw_form(f, &form))?; terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await { match wait_for_ready(&mut terminal, &mut form).await {
Ok(ready) => { Ok(ready) => {
form.message = Some(( form.message = Some((
format!("ready: {} attaching...", ready.pod_name), format!("ready: {} attaching...", ready.pod_name),
@ -184,15 +182,14 @@ pub async fn run(
/// Launch `insomnia-pod --pod <name>` without opening the name dialog. The child Pod /// Launch `insomnia-pod --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
/// with the usual TUI cwd-scope fallback. /// 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> {
let defaults = load_spawn_defaults()?; let defaults = load_spawn_defaults()?;
let mut form = form_for_pod_name(pod_name, defaults); let mut form = form_for_pod_name(pod_name, defaults);
let overlay_toml = build_overlay_toml(&form);
let mut terminal = make_inline_terminal()?; let mut terminal = make_inline_terminal()?;
terminal.draw(|f| draw_form(f, &form))?; terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await { match wait_for_ready(&mut terminal, &mut form).await {
Ok(ready) => { Ok(ready) => {
form.message = Some(( form.message = Some((
format!("ready: {} attaching...", ready.pod_name), format!("ready: {} attaching...", ready.pod_name),
@ -213,7 +210,6 @@ pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError>
struct SpawnDefaults { struct SpawnDefaults {
cwd: PathBuf, cwd: PathBuf,
cascade_has_scope: bool,
scope_origin: ScopeOrigin, scope_origin: ScopeOrigin,
default_name: String, default_name: String,
default_profile_index: usize, default_profile_index: usize,
@ -241,7 +237,6 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
Ok(SpawnDefaults { Ok(SpawnDefaults {
cwd, cwd,
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProfile, scope_origin: ScopeOrigin::FromProfile,
default_name, default_name,
default_profile_index, default_profile_index,
@ -303,7 +298,6 @@ fn initial_profile_index(
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
Form { Form {
cwd: defaults.cwd, cwd: defaults.cwd,
cascade_has_scope: defaults.cascade_has_scope,
scope_origin: defaults.scope_origin, scope_origin: defaults.scope_origin,
name_cursor: pod_name.chars().count(), name_cursor: pod_name.chars().count(),
name: pod_name, name: pod_name,
@ -385,15 +379,12 @@ fn sanitise_default_name(s: &str) -> String {
async fn wait_for_ready( async fn wait_for_ready(
terminal: &mut InlineTerminal, terminal: &mut InlineTerminal,
form: &mut Form, form: &mut Form,
overlay_toml: &str,
) -> Result<SpawnReady, SpawnError> { ) -> Result<SpawnReady, SpawnError> {
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
let config = SpawnConfig { let config = SpawnConfig {
pod_name: form.name.clone(), pod_name: form.name.clone(),
profile: form.selected_profile_selector(), profile: form.selected_profile_selector(),
overlay_toml: overlay_toml.to_string(), resume_scope: form.resume_scope.clone(),
cwd, cwd: form.cwd.clone(),
resume_from: form.resume_from, resume_from: form.resume_from,
resume_by_pod_name: form.resume_by_pod_name, resume_by_pod_name: form.resume_by_pod_name,
}; };
@ -408,36 +399,6 @@ async fn wait_for_ready(
}) })
} }
fn build_overlay_toml(form: &Form) -> String {
let mut root = toml::value::Table::new();
let mut pod = toml::value::Table::new();
pod.insert("name".into(), toml::Value::String(form.name.clone()));
root.insert("pod".into(), toml::Value::Table(pod));
if let Some(scope_config) = form.resume_scope.as_ref() {
root.insert(
"scope".into(),
toml::Value::try_from(scope_config).expect("scope serialisation cannot fail"),
);
} else if !form.cascade_has_scope {
let mut rule = toml::value::Table::new();
rule.insert(
"target".into(),
toml::Value::String(form.cwd.display().to_string()),
);
rule.insert("permission".into(), toml::Value::String("write".into()));
let mut scope = toml::value::Table::new();
scope.insert(
"allow".into(),
toml::Value::Array(vec![toml::Value::Table(rule)]),
);
root.insert("scope".into(), toml::Value::Table(scope));
}
toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail")
}
async fn load_resume_scope(segment_id: SegmentId) -> Result<ScopeConfig, SpawnError> { async fn load_resume_scope(segment_id: SegmentId) -> Result<ScopeConfig, SpawnError> {
let store_dir = manifest::paths::sessions_dir().ok_or_else(|| { let store_dir = manifest::paths::sessions_dir().ok_or_else(|| {
io::Error::new( io::Error::new(
@ -466,14 +427,10 @@ enum MessageKind {
enum ScopeOrigin { enum ScopeOrigin {
FromProfile, FromProfile,
CwdDefault,
} }
struct Form { struct Form {
cwd: PathBuf, cwd: PathBuf,
/// True when the launch source already supplies `scope.allow`.
/// Drives whether the compatibility overlay should add a cwd-write rule.
cascade_has_scope: bool,
/// Display label for the scope row in the dialog. /// Display label for the scope row in the dialog.
scope_origin: ScopeOrigin, scope_origin: ScopeOrigin,
name: String, name: String,
@ -497,8 +454,8 @@ struct Form {
/// 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,
/// Scope snapshot recovered from the source session log. Set only for /// Scope snapshot recovered from the source session log. Set only for
/// resume runs, and serialized into the overlay instead of cwd-default /// resume runs and passed through a typed internal restore flag so resume
/// scope so resume does not silently broaden access. /// does not silently broaden access.
resume_scope: Option<ScopeConfig>, resume_scope: Option<ScopeConfig>,
/// Optional Nix profile choices passed to `insomnia-pod --profile` for /// Optional Nix profile choices passed to `insomnia-pod --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
@ -659,20 +616,22 @@ fn context_line(form: &Form) -> Line<'_> {
]); ]);
} }
if form.resume_scope.is_some() {
return Line::from(vec![
Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled(
"from restored session snapshot",
Style::default().fg(Color::Green),
),
]);
}
match form.scope_origin { match form.scope_origin {
ScopeOrigin::FromProfile => Line::from(vec![ ScopeOrigin::FromProfile => Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)), Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled("from default profile", Style::default().fg(Color::Green)), Span::styled("from selected profile", Style::default().fg(Color::Green)),
]),
ScopeOrigin::CwdDefault => Line::from(vec![
Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled(
form.cwd.display().to_string(),
Style::default().fg(Color::Yellow),
),
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
]), ]),
} }
} }
@ -701,15 +660,10 @@ fn message_line(form: &Form) -> Line<'_> {
mod tests { mod tests {
use super::*; use super::*;
fn form(name: &str, cascade_has_scope: bool) -> Form { fn form(name: &str) -> Form {
Form { Form {
cwd: PathBuf::from("/work/example"), cwd: PathBuf::from("/work/example"),
cascade_has_scope, scope_origin: ScopeOrigin::FromProfile,
scope_origin: if cascade_has_scope {
ScopeOrigin::FromProfile
} else {
ScopeOrigin::CwdDefault
},
name: name.to_string(), name: name.to_string(),
name_cursor: name.chars().count(), name_cursor: name.chars().count(),
message: None, message: None,
@ -726,7 +680,6 @@ mod tests {
fn pod_name_form_restores_or_creates_by_pod_name() { fn pod_name_form_restores_or_creates_by_pod_name() {
let defaults = SpawnDefaults { let defaults = SpawnDefaults {
cwd: PathBuf::from("/work/example"), cwd: PathBuf::from("/work/example"),
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProfile, scope_origin: ScopeOrigin::FromProfile,
default_name: "ignored".to_string(), default_name: "ignored".to_string(),
default_profile_index: 0, default_profile_index: 0,
@ -747,29 +700,8 @@ mod tests {
} }
#[test] #[test]
fn overlay_adds_scope_default_when_cascade_lacks_scope() { fn resume_scope_snapshot_stays_on_form_for_typed_restore_flag() {
let f = form("agent-1", false); let mut f = form("agent-r");
let toml_str = build_overlay_toml(&f);
let parsed: toml::Value = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-1"));
let allow = parsed["scope"]["allow"].as_array().unwrap();
assert_eq!(allow.len(), 1);
assert_eq!(allow[0]["target"].as_str(), Some("/work/example"));
assert_eq!(allow[0]["permission"].as_str(), Some("write"));
}
#[test]
fn overlay_omits_scope_when_cascade_already_has_one() {
let f = form("agent-2", true);
let toml_str = build_overlay_toml(&f);
let parsed: toml::Value = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-2"));
assert!(parsed.get("scope").is_none());
}
#[test]
fn overlay_uses_resume_scope_snapshot() {
let mut f = form("agent-r", false);
f.resume_from = Some(session_store::new_segment_id()); f.resume_from = Some(session_store::new_segment_id());
f.resume_scope = Some(ScopeConfig { f.resume_scope = Some(ScopeConfig {
allow: vec![manifest::ScopeRule { allow: vec![manifest::ScopeRule {
@ -783,12 +715,10 @@ mod tests {
recursive: true, recursive: true,
}], }],
}); });
let toml_str = build_overlay_toml(&f);
let parsed: toml::Value = toml::from_str(&toml_str).unwrap(); let scope = f.resume_scope.as_ref().unwrap();
assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-r")); assert_eq!(scope.allow[0].target, PathBuf::from("/work/example"));
assert_eq!(parsed["scope"]["allow"].as_array().unwrap().len(), 1); assert_eq!(scope.deny[0].target, PathBuf::from("/work/example/child"));
let deny = parsed["scope"]["deny"].as_array().unwrap();
assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child"));
} }
#[test] #[test]
@ -841,8 +771,8 @@ description = "Project coder"
} }
#[test] #[test]
fn profile_cycle_selects_profiles_without_manifest_cascade_opt_out() { fn profile_cycle_selects_only_discovered_profiles() {
let mut form = form("coder", true); let mut form = form("coder");
form.profile_choices = vec![ form.profile_choices = vec![
ProfileChoice { ProfileChoice {
selector: Some("project:coder".to_string()), selector: Some("project:coder".to_string()),
@ -889,7 +819,7 @@ description = "Project coder"
#[test] #[test]
fn name_input_handles_insert_backspace_and_cursor() { fn name_input_handles_insert_backspace_and_cursor() {
let mut f = form("", false); let mut f = form("");
for c in "abc".chars() { for c in "abc".chars() {
f.insert_char(c); f.insert_char(c);
} }

View File

@ -89,16 +89,13 @@ permission = "write"
`[model]``ref = "<provider>/<model_id>"` でプロバイダ / モデルカタログを引く短縮形と、`scheme` / `model_id` / `auth` を直書きする inline 形式の両方を受ける。カタログは `resources/{providers,models}/builtin.toml` を builtin、`<config_dir>/{providers,models}.toml` を user override として解決する(`<config_dir>` の解決ルールは `manifest::paths` 参照)。詳細は `docs/pod-factory.md``crates/provider/README.md` `[model]``ref = "<provider>/<model_id>"` でプロバイダ / モデルカタログを引く短縮形と、`scheme` / `model_id` / `auth` を直書きする inline 形式の両方を受ける。カタログは `resources/{providers,models}/builtin.toml` を builtin、`<config_dir>/{providers,models}.toml` を user override として解決する(`<config_dir>` の解決ルールは `manifest::paths` 参照)。詳細は `docs/pod-factory.md``crates/provider/README.md`
### PodFactory: カスケード設定 ### Manifest / profile 入力
マニフェストを手書きせず、4 層のカスケードで `PodManifest` を組み立てる: 通常の Pod 起動は Nix profile discovery/default から `PodManifest` を生成する。bundled `builtin:default` が fallback default で、user/project `profiles.toml` は profile registry と default selection だけを担う。user/project `manifest.toml` の ambient cascade は通常起動では使わない。
1. **ビルトインデフォルト**`manifest::defaults` の定数値 `insomnia-pod --manifest <PATH>` は explicit one-file compatibility/debug input で、指定 TOML 1 枚だけに builtin defaults を merge し、`PodManifestConfig -> PodManifest` の required validation を通す。
2. **ユーザー manifest**`<config_dir>/manifest.toml``manifest::paths` で解決)
3. **プロジェクト manifest**`.insomnia/manifest.toml`cwd から上方向に探索)
4. **プログラマティック overlay** — CLI / GUI / spawn 時のインライン指定
マージ規則: スカラーは上層が置換、Map はキー単位マージ、`scope.allow` / `scope.deny` は union。全パスは絶対パスのみ `PodFactory` の user/project/overlay API は低レベル構成部品として残るが、CLI の通常起動 path では generic TOML overlay を公開しない。
### Instruction とプロンプト資産 ### Instruction とプロンプト資産

View File

@ -87,6 +87,8 @@ A profile should evaluate to one of:
The resolved artifact is deserialized into the same `PodManifestConfig -> PodManifest` boundary used by direct one-file manifests, so builtin defaults and required-field validation stay shared. Explicit profile paths and user/project registry profile artifacts resolve relative manifest paths against the profile file's directory. Builtin profile artifacts resolve manifest-relative paths against the launch workspace/current directory so the bundled default can grant `scope.allow target = "."` for the workspace rather than for `resources/nix/profiles`. The resolved artifact is deserialized into the same `PodManifestConfig -> PodManifest` boundary used by direct one-file manifests, so builtin defaults and required-field validation stay shared. Explicit profile paths and user/project registry profile artifacts resolve relative manifest paths against the profile file's directory. Builtin profile artifacts resolve manifest-relative paths against the launch workspace/current directory so the bundled default can grant `scope.allow target = "."` for the workspace rather than for `resources/nix/profiles`.
Profile and one-file manifest CLI paths currently use builtin prompt assets only. `$insomnia/...` instruction refs work; `$user/...` and `$workspace/...` prompt refs need a future explicit prompt-loader source design instead of reviving ambient manifest discovery.
Secret values must stay as typed references. `resources/nix/profile-lib.nix` emits secret references as JSON like: Secret values must stay as typed references. `resources/nix/profile-lib.nix` emits secret references as JSON like:
```json ```json

View File

@ -192,8 +192,8 @@ host_a (spawner) host_b (remote)
Pod A (pod binary + ssh のみ) Pod A (pod binary + ssh のみ)
├── ssh: session データを転送 ────────→ ファイル書き込み ├── ssh: session データを転送 ────────→ ファイル書き込み
├── ssh: overlay TOML を転送 ─────────→ ファイル書き込み ├── ssh: profile / one-file manifest 入力を転送 ─→ 必要ならファイル書き込み
├── ssh: `insomnia-pod --overlay ... &` ───────→ Pod プロセス起動、socket 作成 ├── ssh: `insomnia-pod --profile ... &` ───────→ Pod プロセス起動、socket 作成
├── ssh -L: socket を tunnel ─────────→ Pod B の unix socket ├── ssh -L: socket を tunnel ─────────→ Pod B の unix socket
└── localhost:tunnel に接続 ──────────→ Method::Run / Event stream └── localhost:tunnel に接続 ──────────→ Method::Run / Event stream
@ -203,14 +203,14 @@ host_a (spawner) host_b (remote)
### コマンドイメージ ### コマンドイメージ
```bash ```bash
# 1. session + overlay を転送 # 1. session + profile/manifest input を転送
ssh insomnia@host-b "mkdir -p ~/workspaces/task-123/store" ssh insomnia@host-b "mkdir -p ~/workspaces/task-123/store"
tar cz session/ | ssh insomnia@host-b "tar xz -C ~/workspaces/task-123/store" tar cz session/ | ssh insomnia@host-b "tar xz -C ~/workspaces/task-123/store"
echo "$OVERLAY" | ssh insomnia@host-b "cat > ~/workspaces/task-123/overlay.toml" scp profile.nix insomnia@host-b:~/workspaces/task-123/profile.nix
# 2. Pod を起動detach # 2. Pod を起動detach
ssh insomnia@host-b "insomnia-pod --store ~/workspaces/task-123/store \ ssh insomnia@host-b "insomnia-pod --store ~/workspaces/task-123/store \
--overlay ~/workspaces/task-123/overlay.toml &" --profile ~/workspaces/task-123/profile.nix &"
# 3. socket を tunnel で引っ張る # 3. socket を tunnel で引っ張る
ssh -L /tmp/pod-b.sock:/run/insomnia/task-123/pod.sock insomnia@host-b ssh -L /tmp/pod-b.sock:/run/insomnia/task-123/pod.sock insomnia@host-b

View File

@ -347,7 +347,6 @@ insomnia-pod [--profile <selector>] [--profile-pod-name <name>] [-s/--store <pat
|---|---| |---|---|
| `--profile <selector>` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default通常は `builtin:default` | | `--profile <selector>` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default通常は `builtin:default` |
| `--profile-pod-name <name>` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き | | `--profile-pod-name <name>` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き |
| `--overlay <toml>` | TUI/launcher compatibility 用の inline override。user/project manifest discovery は行わない |
| `-s, --store <path>` | セッション永続化ディレクトリ(デフォルト: `<data_dir>/sessions/`、`manifest::paths` で解決) | | `-s, --store <path>` | セッション永続化ディレクトリ(デフォルト: `<data_dir>/sessions/`、`manifest::paths` で解決) |
| `--session <uuid>` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する | | `--session <uuid>` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する |
@ -357,7 +356,7 @@ insomnia-pod [--profile <selector>] [--profile-pod-name <name>] [-s/--store <pat
insomnia-pod --manifest <path> [-s/--store <path>] [--session <uuid>] insomnia-pod --manifest <path> [-s/--store <path>] [--session <uuid>]
``` ```
`--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project`、`--overlay` とは併用不可。 `--manifest` は指定 TOML 1 枚だけを読み、builtin defaults を merge したうえで `PodManifestConfig -> PodManifest` の required validation を通す。user / project manifest layer は読まない。`--profile`、`--project` とは併用不可。
spawn 子 Pod 用の内部フラグとして `--adopt``--callback <path>` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。 spawn 子 Pod 用の内部フラグとして `--adopt``--callback <path>` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。