merge: builtin profile default startup

This commit is contained in:
Keisuke Hirata 2026-05-30 04:36:59 +09:00
commit 4df68978aa
No known key found for this signature in database
14 changed files with 450 additions and 500 deletions

1
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
use crate::{PodManifest, PodManifestConfig, ResolveError, paths};
const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1";
const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default";
/// Registry source for discovered profiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@ -221,6 +222,24 @@ impl ProfileRegistry {
self.default = Some(default);
}
fn set_builtin_default_if_available(&mut self) {
if self.default.is_some() {
return;
}
if self
.select_named(
Some(ProfileRegistrySource::Builtin),
BUILTIN_DEFAULT_PROFILE_NAME,
)
.is_ok()
{
self.default = Some(ProfileDefault {
source: Some(ProfileRegistrySource::Builtin),
name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(),
});
}
}
fn mark_default_flags(&mut self) {
let Some(default) = self.default.clone() else {
return;
@ -282,6 +301,7 @@ impl ProfileDiscovery {
if let Some(path) = &self.project_config {
load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?;
}
registry.set_builtin_default_if_available();
registry.mark_default_flags();
Ok(registry)
}
@ -323,12 +343,14 @@ pub struct ResolvedProfile {
#[derive(Debug, Clone)]
pub struct NixProfileResolver {
nix_bin: PathBuf,
workspace_base: Option<PathBuf>,
}
impl Default for NixProfileResolver {
fn default() -> Self {
Self {
nix_bin: PathBuf::from("nix"),
workspace_base: None,
}
}
}
@ -341,9 +363,15 @@ impl NixProfileResolver {
pub fn with_nix_bin(nix_bin: impl Into<PathBuf>) -> Self {
Self {
nix_bin: nix_bin.into(),
workspace_base: None,
}
}
pub fn with_workspace_base(mut self, workspace_base: impl Into<PathBuf>) -> Self {
self.workspace_base = Some(workspace_base.into());
self
}
pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> {
match selector {
ProfileSelector::Path { path } => self.resolve_path(
@ -351,6 +379,7 @@ impl NixProfileResolver {
ProfileSource::Path {
path: absolutize(path)?,
},
None,
),
ProfileSelector::Named { .. } | ProfileSelector::Default => {
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
@ -359,6 +388,11 @@ impl NixProfileResolver {
})?;
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
let entry = registry.select(selector)?.clone();
let artifact_base = if entry.source == ProfileRegistrySource::Builtin {
Some(absolutize(self.workspace_base.as_deref().unwrap_or(&cwd))?)
} else {
None
};
self.resolve_path(
&entry.path,
ProfileSource::Registry {
@ -366,6 +400,7 @@ impl NixProfileResolver {
name: entry.name,
path: absolutize(&entry.path)?,
},
artifact_base,
)
}
}
@ -375,15 +410,17 @@ impl NixProfileResolver {
&self,
path: &Path,
source: ProfileSource,
manifest_base_override: Option<PathBuf>,
) -> Result<ResolvedProfile, ProfileError> {
let absolute_path = absolutize(path)?;
let base_dir = absolute_path
let file_base_dir = absolute_path
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| ProfileError::InvalidPath {
path: absolute_path.clone(),
message: "profile path has no parent directory".to_string(),
})?;
let base_dir = manifest_base_override.unwrap_or(file_base_dir);
let output = Command::new(&self.nix_bin)
.arg("eval")
@ -948,6 +985,56 @@ mod tests {
);
}
#[test]
fn builtin_default_profile_is_registered_as_default() {
let registry = ProfileDiscovery::with_sources(paths::builtin_profiles_dir(), None, None)
.discover()
.unwrap();
let default = registry.default_entry().unwrap();
assert_eq!(default.source, ProfileRegistrySource::Builtin);
assert_eq!(default.name, BUILTIN_DEFAULT_PROFILE_NAME);
assert!(default.is_default);
assert!(default.path.ends_with("resources/nix/profiles/default.nix"));
}
#[cfg(unix)]
#[test]
fn builtin_profile_relative_paths_resolve_against_workspace_base() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let profile_path = tmp.path().join("default.nix");
std::fs::write(&profile_path, "{}").unwrap();
let nix_bin = tmp.path().join("fake-nix");
std::fs::write(
&nix_bin,
r#"#!/bin/sh
cat <<'JSON'
{"profile":{"format":"insomnia.nix-profile.v1","name":"default"},"manifest":{"pod":{"name":"default"},"model":{"scheme":"anthropic","model_id":"claude-sonnet-4-20250514"},"scope":{"allow":[{"target":".","permission":"write"}]}}}
JSON
"#,
)
.unwrap();
let mut perms = std::fs::metadata(&nix_bin).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&nix_bin, perms).unwrap();
let resolved = NixProfileResolver::with_nix_bin(&nix_bin)
.resolve_path(
&profile_path,
ProfileSource::Registry {
source: ProfileRegistrySource::Builtin,
name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(),
path: profile_path.clone(),
},
Some(workspace.clone()),
)
.unwrap();
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
}
#[test]
fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() {
let tmp = TempDir::new().unwrap();

View File

@ -1,16 +1,17 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::Parser;
use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths};
use pod::{Pod, PodController, PodFactory, PromptLoader};
use manifest::{
NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, ScopeConfig, paths,
};
use pod::{Pod, PodController, PromptLoader};
use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
#[derive(Debug, Parser)]
#[command(
name = "insomnia-pod",
about = "Spawn a Pod process from manifest layers or a single manifest file"
about = "Spawn a Pod process from a Nix profile or a single manifest file"
)]
struct Cli {
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
@ -19,7 +20,7 @@ struct Cli {
#[arg(
long,
value_name = "PROFILE",
conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"]
conflicts_with_all = ["manifest", "project", "pod", "session", "adopt"]
)]
profile: Option<String>,
@ -29,21 +30,34 @@ struct Cli {
#[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])]
profile_pod_name: Option<String>,
/// Manifest TOML to use directly, without loading user, project, or
/// overlay layers.
#[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])]
/// Manifest TOML to use directly as a one-file compatibility/debug input.
/// This bypasses profile discovery but still applies builtin defaults and
/// the same required-field validation boundary.
#[arg(long, value_name = "PATH", conflicts_with_all = ["project"])]
manifest: Option<PathBuf>,
/// Start the project-manifest walk from this directory. When
/// omitted, the factory walks up from the current working
/// directory looking for `.insomnia/manifest.toml`.
/// Deprecated manifest-cascade project root flag. Ambient project/user
/// manifest discovery has been removed; configure/select a profile instead.
#[arg(long, value_name = "PATH")]
project: Option<PathBuf>,
/// Inline TOML string applied as the highest-priority overlay
/// layer. Example: `--overlay 'pod.name = "dbg"'`.
#[arg(long, value_name = "TOML")]
overlay: Option<String>,
/// Internal typed pod-name override for session restore launched by the TUI.
#[arg(long, value_name = "NAME", requires = "session", hide = true)]
session_pod_name: 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
/// `<data_dir>/sessions/` (see `manifest::paths`).
@ -82,52 +96,65 @@ struct Cli {
}
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,
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,
) -> Result<(PodManifest, PromptLoader), String>
where
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);
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())?
};
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
apply_session_restore_overrides(&mut manifest_and_loader.0, cli)?;
Ok(manifest_and_loader)
}
if let Some(path) = &cli.manifest {
if user_manifest.is_some() {
return Err(format!(
"--manifest cannot be used when {} is set",
paths::USER_MANIFEST_ENV
));
fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
if let Some(pod_name) = cli.session_pod_name.as_deref() {
manifest.pod.name = pod_name.to_string();
}
return load_single_manifest(path, cli.pod.as_deref());
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 factory = build_factory_with_user_manifest_path(cli, user_manifest)?;
factory
.resolve()
.map_err(|e| format!("failed to resolve manifest cascade: {e}"))
fn load_spawn_config_json(config_json: &str) -> Result<(PodManifest, PromptLoader), String> {
let config = serde_json::from_str::<PodManifestConfig>(config_json)
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
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(
selector: &ProfileSelector,
pod_name_override: Option<&str>,
) -> Result<(PodManifest, PromptLoader), String> {
let resolver = NixProfileResolver::new();
let cwd = std::env::current_dir()
.map_err(|e| format!("failed to resolve current directory for profile: {e}"))?;
let resolver = NixProfileResolver::new().with_workspace_base(cwd);
let mut resolved = resolver.resolve(selector).map_err(|e| {
format!(
"failed to resolve profile {}: {e}",
@ -146,78 +173,32 @@ fn load_single_manifest(
) -> Result<(PodManifest, PromptLoader), String> {
let toml = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
let manifest = match pod_name_override {
Some(pod_name) => match PodManifest::from_toml(&toml) {
Ok(mut manifest) => {
manifest.pod.name = pod_name.to_string();
manifest
}
Err(_) => {
let base = PodManifestConfig::from_toml(&toml)
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?;
let overlay = PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name))
.expect("pod name overlay TOML is generated");
PodManifest::try_from(base.merge(overlay)).map_err(|e| {
let absolute_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("failed to resolve current directory: {e}"))?
.join(path)
};
let base_dir = absolute_path.parent().ok_or_else(|| {
format!(
"failed to resolve manifest {} with --pod: {e}",
path.display()
"manifest path {} has no parent directory",
absolute_path.display()
)
})?
})?;
let mut config = PodManifestConfig::builtin_defaults().merge(
PodManifestConfig::from_toml(&toml)
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?
.resolve_paths(base_dir),
);
if let Some(pod_name) = pod_name_override {
config.pod.name = Some(pod_name.to_string());
}
},
None => PodManifest::from_toml(&toml)
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?,
};
let manifest = PodManifest::try_from(config)
.map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?;
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 build_factory_with_user_manifest_path(
cli: &Cli,
user_manifest: Option<PathBuf>,
) -> Result<PodFactory, String> {
let mut factory = PodFactory::new();
factory = match user_manifest {
Some(path) => factory
.with_user_manifest(path)
.map_err(|e| format!("failed to load user manifest: {e}"))?,
None => factory
.with_user_manifest_auto()
.map_err(|e| format!("failed to auto-load user manifest: {e}"))?,
};
factory = match &cli.project {
Some(path) => factory
.with_project_manifest_from(path)
.map_err(|e| format!("failed to load project manifest: {e}"))?,
None => factory
.with_project_manifest_auto()
.map_err(|e| format!("failed to auto-load project manifest: {e}"))?,
};
if let Some(overlay) = cli.overlay.as_deref() {
factory = factory
.with_overlay_toml(overlay)
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?;
}
if let Some(pod_name) = cli.pod.as_deref() {
factory = factory
.with_overlay_toml(&pod_name_overlay_toml(pod_name))
.map_err(|e| format!("failed to apply --pod overlay: {e}"))?;
}
Ok(factory)
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
@ -419,7 +400,7 @@ permission = "write"
}
#[test]
fn manifest_conflicts_with_project_and_overlay() {
fn manifest_conflicts_with_project() {
let project_err = Cli::try_parse_from([
"insomnia-pod",
"--manifest",
@ -429,35 +410,31 @@ permission = "write"
])
.unwrap_err();
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]
fn manifest_conflicts_with_user_manifest_env_when_env_is_non_empty() {
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 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 err = resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml")))
.unwrap_err();
let (manifest, loader) = resolve_manifest(&cli).unwrap();
assert!(err.contains("--manifest cannot be used"));
assert!(err.contains(paths::USER_MANIFEST_ENV));
assert_eq!(manifest.pod.name, "single");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
}
#[test]
fn profile_ignores_non_empty_user_manifest_env() {
fn profile_uses_selected_profile() {
let tmp = TempDir::new().unwrap();
let profile = tmp.path().join("profile.nix");
let cli = Cli::try_parse_from([
@ -470,10 +447,8 @@ permission = "write"
.unwrap();
let mut called = false;
let (manifest, loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
&cli,
Some(OsString::from("non-existent-user-manifest.toml")),
|selector, pod_name| {
let (manifest, loader) =
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
called = true;
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
assert_eq!(pod_name, Some("from-profile-name"));
@ -483,8 +458,7 @@ permission = "write"
manifest.pod.name = pod_name.to_string();
}
Ok((manifest, PromptLoader::builtins_only()))
},
)
})
.unwrap();
assert!(called);
@ -506,10 +480,8 @@ permission = "write"
.unwrap();
let mut called = false;
let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
&cli,
None,
|selector, pod_name| {
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
called = true;
assert_eq!(
selector,
@ -524,8 +496,7 @@ permission = "write"
manifest.pod.name = pod_name.to_string();
}
Ok((manifest, PromptLoader::builtins_only()))
},
)
})
.unwrap();
assert!(called);
@ -533,42 +504,35 @@ permission = "write"
}
#[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()])
let cli = Cli::try_parse_from(["insomnia-pod"]).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, None);
let manifest =
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
.unwrap();
Ok((manifest, PromptLoader::builtins_only()))
})
.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());
assert!(called);
assert_eq!(manifest.pod.name, "from-default-profile");
}
#[test]
fn user_manifest_env_overrides_auto_user_manifest_path() {
let tmp = TempDir::new().unwrap();
let user_manifest = tmp.path().join("custom-user.toml");
write(&user_manifest, &manifest_toml("from-env", tmp.path()));
let no_project_root = tmp.path().join("no-project");
std::fs::create_dir_all(&no_project_root).unwrap();
let cli = Cli::try_parse_from([
"insomnia-pod",
"--project",
no_project_root.to_str().unwrap(),
])
.unwrap();
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(
&cli,
Some(user_manifest.as_os_str().to_os_string()),
)
.unwrap();
assert_eq!(manifest.pod.name, "from-env");
fn project_flag_no_longer_enables_ambient_manifest_cascade() {
let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap();
let err = resolve_manifest_with_profile_loader(&cli, |_, _| {
panic!("default profile loader must not run when deprecated --project is present")
})
.unwrap_err();
assert!(err.contains("--project is no longer supported"));
}
#[test]
@ -594,7 +558,7 @@ permission = "write"
])
.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");
}
@ -605,7 +569,17 @@ permission = "write"
let manifest = tmp.path().join("manifest.toml");
write(
&manifest,
&manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""),
r#"
[pod]
[model]
scheme = "anthropic"
model_id = "test-model"
[[scope.allow]]
target = "."
permission = "write"
"#,
);
let cli = Cli::try_parse_from([
"insomnia-pod",
@ -616,9 +590,35 @@ permission = "write"
])
.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.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]
@ -674,7 +674,7 @@ permission = "write"
])
.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!(loader.user_dir().is_none());

View File

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

View File

@ -1,29 +1,23 @@
//! Inline-viewport "spawn Pod and attach" UX.
//!
//! Rendered at the user's current cursor position when `insomnia` is invoked
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
//! to seed manifest defaults and `.insomnia/profiles.toml` to discover profile
//! choices, prompts for the Pod's name, and on confirmation
//! launches the `insomnia-pod` binary as an independent process with a freshly built
//! overlay (name + cwd scope when no project manifest exists). Once
//! the process reports its socket via the `INSOMNIA-READY` stderr line,
//! the dialog hands control back so main can switch the terminal to
//! alternate-screen mode.
//! with no positional argument. Discovers `.insomnia/profiles.toml` profile
//! 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
//! independent process. Once the process reports its socket via the
//! `INSOMNIA-READY` stderr line, the dialog hands control back so main can
//! switch the terminal to alternate-screen mode.
//!
//! The viewport's last frame stays in the terminal's scrollback so the
//! user has a record of what was spawned (or why a spawn failed).
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::{SpawnConfig, spawn_pod};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{
PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
user_manifest_path, user_manifest_path_from_env,
};
use manifest::{ProfileDiscovery, ScopeConfig};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@ -97,7 +91,11 @@ pub async fn run(
profile: Option<String>,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut profile_choices = defaults.profile_choices;
let mut profile_choices = if resume_from.is_some() {
Vec::new()
} else {
defaults.profile_choices
};
let profile_index = initial_profile_index(
&mut profile_choices,
profile.as_deref(),
@ -106,7 +104,6 @@ pub async fn run(
let mut form = Form {
cwd: defaults.cwd.clone(),
cascade_has_scope: defaults.cascade_has_scope,
scope_origin: defaults.scope_origin,
name_cursor: defaults.default_name.chars().count(),
name: defaults.default_name,
@ -155,7 +152,6 @@ pub async fn run(
if let Some(id) = form.resume_from {
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
// out of the name field — subsequent frames are passive status
@ -165,7 +161,7 @@ pub async fn run(
form.message = Some(("starting pod...".to_string(), MessageKind::Progress));
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) => {
form.message = Some((
format!("ready: {} attaching...", ready.pod_name),
@ -186,15 +182,14 @@ pub async fn run(
/// 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
/// with the usual TUI cwd-scope fallback.
/// from the default profile.
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut form = form_for_pod_name(pod_name, defaults);
let overlay_toml = build_overlay_toml(&form);
let mut terminal = make_inline_terminal()?;
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) => {
form.message = Some((
format!("ready: {} attaching...", ready.pod_name),
@ -215,7 +210,6 @@ pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError>
struct SpawnDefaults {
cwd: PathBuf,
cascade_has_scope: bool,
scope_origin: ScopeOrigin,
default_name: String,
default_profile_index: usize,
@ -232,42 +226,6 @@ struct ProfileChoice {
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
// Run the same merge pod itself uses, then read what's missing off the
// result. We only look at `scope.allow` here — `pod.name` is an
// instance-level identifier and is supplied by the dialog or `--pod`.
// TUI must pre-read the same user manifest path that the pod CLI will use,
// including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall
// back to the auto-discovered path.
let user_layer = user_manifest_path_for_spawn(
std::env::var_os(manifest::paths::USER_MANIFEST_ENV),
user_manifest_path(),
)
.filter(|p| p.is_file())
.and_then(|p| load_layer(&p).ok());
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
let mut cascade = PodManifestConfig::builtin_defaults();
for layer in [user_layer.as_ref(), project_layer.as_ref()]
.into_iter()
.flatten()
{
cascade = cascade.merge(layer.clone());
}
let cascade_has_scope = !cascade.scope.allow.is_empty();
let scope_origin = match (
project_layer
.as_ref()
.is_some_and(|l| !l.scope.allow.is_empty()),
user_layer
.as_ref()
.is_some_and(|l| !l.scope.allow.is_empty()),
) {
(true, _) => ScopeOrigin::FromProject,
(false, true) => ScopeOrigin::FromUser,
(false, false) => ScopeOrigin::CwdDefault,
};
let default_name = cwd
.file_name()
.and_then(|s| s.to_str())
@ -279,8 +237,7 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
Ok(SpawnDefaults {
cwd,
cascade_has_scope,
scope_origin,
scope_origin: ScopeOrigin::FromProfile,
default_name,
default_profile_index,
profile_choices,
@ -288,16 +245,11 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
}
fn profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
let mut choices = vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}];
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
return (choices, 0);
return (Vec::new(), 0);
};
let mut choices = Vec::new();
for entry in registry.entries() {
let mut label = entry.qualified_name();
if entry.is_default {
@ -343,17 +295,9 @@ fn initial_profile_index(
choices.len() - 1
}
fn user_manifest_path_for_spawn(
env_value: Option<OsString>,
default_user_manifest: Option<PathBuf>,
) -> Option<PathBuf> {
user_manifest_path_from_env(env_value).or(default_user_manifest)
}
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
Form {
cwd: defaults.cwd,
cascade_has_scope: defaults.cascade_has_scope,
scope_origin: defaults.scope_origin,
name_cursor: pod_name.chars().count(),
name: pod_name,
@ -362,11 +306,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
resume_from: None,
resume_by_pod_name: true,
resume_scope: None,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_choices: Vec::new(),
profile_index: 0,
}
}
@ -439,15 +379,12 @@ fn sanitise_default_name(s: &str) -> String {
async fn wait_for_ready(
terminal: &mut InlineTerminal,
form: &mut Form,
overlay_toml: &str,
) -> Result<SpawnReady, SpawnError> {
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
let config = SpawnConfig {
pod_name: form.name.clone(),
profile: form.selected_profile_selector(),
overlay_toml: overlay_toml.to_string(),
cwd,
resume_scope: form.resume_scope.clone(),
cwd: form.cwd.clone(),
resume_from: form.resume_from,
resume_by_pod_name: form.resume_by_pod_name,
};
@ -462,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> {
let store_dir = manifest::paths::sessions_dir().ok_or_else(|| {
io::Error::new(
@ -519,17 +426,11 @@ enum MessageKind {
}
enum ScopeOrigin {
FromUser,
FromProject,
CwdDefault,
FromProfile,
}
struct Form {
cwd: PathBuf,
/// True when at least one cascade layer (user or project manifest)
/// already declares `scope.allow`. Drives whether the overlay
/// should add a cwd-write rule.
cascade_has_scope: bool,
/// Display label for the scope row in the dialog.
scope_origin: ScopeOrigin,
name: String,
@ -553,8 +454,8 @@ struct Form {
/// resolves name-keyed state before falling back to fresh creation.
resume_by_pod_name: bool,
/// Scope snapshot recovered from the source session log. Set only for
/// resume runs, and serialized into the overlay instead of cwd-default
/// scope so resume does not silently broaden access.
/// resume runs and passed through a typed internal restore flag so resume
/// does not silently broaden access.
resume_scope: Option<ScopeConfig>,
/// Optional Nix profile choices passed to `insomnia-pod --profile` for
/// fresh spawns. This is not used for resume/attach flows because those must
@ -648,7 +549,7 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
let layout = Layout::vertical([
Constraint::Length(1), // title
Constraint::Length(1), // name field
Constraint::Length(1), // context (manifest or scope default)
Constraint::Length(1), // context (profile or scope default)
Constraint::Length(1), // hint
Constraint::Length(1), // message
Constraint::Length(1), // spacer
@ -715,25 +616,22 @@ fn context_line(form: &Form) -> Line<'_> {
]);
}
match form.scope_origin {
ScopeOrigin::FromProject => Line::from(vec![
Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled("from project manifest", Style::default().fg(Color::Green)),
]),
ScopeOrigin::FromUser => Line::from(vec![
Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled("from user manifest", Style::default().fg(Color::Green)),
]),
ScopeOrigin::CwdDefault => Line::from(vec![
if form.resume_scope.is_some() {
return 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),
"from restored session snapshot",
Style::default().fg(Color::Green),
),
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
]);
}
match form.scope_origin {
ScopeOrigin::FromProfile => Line::from(vec![
Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled("from selected profile", Style::default().fg(Color::Green)),
]),
}
}
@ -762,15 +660,10 @@ fn message_line(form: &Form) -> Line<'_> {
mod tests {
use super::*;
fn form(name: &str, cascade_has_scope: bool) -> Form {
fn form(name: &str) -> Form {
Form {
cwd: PathBuf::from("/work/example"),
cascade_has_scope,
scope_origin: if cascade_has_scope {
ScopeOrigin::FromProject
} else {
ScopeOrigin::CwdDefault
},
scope_origin: ScopeOrigin::FromProfile,
name: name.to_string(),
name_cursor: name.chars().count(),
message: None,
@ -778,11 +671,7 @@ mod tests {
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_choices: Vec::new(),
profile_index: 0,
}
}
@ -791,15 +680,10 @@ mod tests {
fn pod_name_form_restores_or_creates_by_pod_name() {
let defaults = SpawnDefaults {
cwd: PathBuf::from("/work/example"),
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProject,
scope_origin: ScopeOrigin::FromProfile,
default_name: "ignored".to_string(),
default_profile_index: 0,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_choices: Vec::new(),
};
let f = form_for_pod_name("agent".to_string(), defaults);
@ -816,29 +700,8 @@ mod tests {
}
#[test]
fn overlay_adds_scope_default_when_cascade_lacks_scope() {
let f = form("agent-1", false);
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);
fn resume_scope_snapshot_stays_on_form_for_typed_restore_flag() {
let mut f = form("agent-r");
f.resume_from = Some(session_store::new_segment_id());
f.resume_scope = Some(ScopeConfig {
allow: vec![manifest::ScopeRule {
@ -852,52 +715,10 @@ mod tests {
recursive: 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-r"));
assert_eq!(parsed["scope"]["allow"].as_array().unwrap().len(), 1);
let deny = parsed["scope"]["deny"].as_array().unwrap();
assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child"));
}
#[test]
fn cascade_merge_detects_scope_from_any_layer() {
let user = PodManifestConfig::from_toml(
r#"
[[scope.allow]]
target = "/from-user"
permission = "write"
"#,
)
.unwrap();
let mut cascade = PodManifestConfig::builtin_defaults();
cascade = cascade.merge(user);
assert!(!cascade.scope.allow.is_empty());
let empty_cascade = PodManifestConfig::builtin_defaults();
assert!(empty_cascade.scope.allow.is_empty());
}
#[test]
fn user_manifest_path_for_spawn_prefers_non_empty_env_override() {
assert_eq!(
user_manifest_path_for_spawn(
Some(OsString::from("/tmp/override.toml")),
Some(PathBuf::from("/default/manifest.toml")),
),
Some(PathBuf::from("/tmp/override.toml")),
);
}
#[test]
fn user_manifest_path_for_spawn_treats_empty_env_as_unset() {
assert_eq!(
user_manifest_path_for_spawn(
Some(OsString::from("")),
Some(PathBuf::from("/default/manifest.toml")),
),
Some(PathBuf::from("/default/manifest.toml")),
);
let scope = f.resume_scope.as_ref().unwrap();
assert_eq!(scope.allow[0].target, PathBuf::from("/work/example"));
assert_eq!(scope.deny[0].target, PathBuf::from("/work/example/child"));
}
#[test]
@ -925,7 +746,7 @@ coder = "profiles/coder.nix"
}
#[test]
fn profile_choices_include_no_profile_source_labels_and_default_marker() {
fn profile_choices_include_builtin_and_project_default_marker() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let insomnia = project.join(".insomnia");
@ -942,22 +763,17 @@ description = "Project coder"
.unwrap();
let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(choices[0].selector, None);
assert_eq!(choices[0].label, "manifest cascade");
assert_eq!(choices[0].selector.as_deref(), Some("builtin:default"));
assert_eq!(choices[0].label, "builtin:default");
assert_eq!(default_index, 1);
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
assert_eq!(choices[1].label, "project:coder (default) — Project coder");
}
#[test]
fn profile_cycle_selects_profiles_and_can_opt_out_of_default() {
let mut form = form("coder", true);
fn profile_cycle_selects_only_discovered_profiles() {
let mut form = form("coder");
form.profile_choices = vec![
ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
},
ProfileChoice {
selector: Some("project:coder".to_string()),
label: "project:coder (default)".to_string(),
@ -969,7 +785,7 @@ description = "Project coder"
is_default: false,
},
];
form.profile_index = 1;
form.profile_index = 0;
assert_eq!(
form.selected_profile_selector().as_deref(),
@ -981,7 +797,10 @@ description = "Project coder"
Some("user:reviewer")
);
form.cycle_profile_next();
assert_eq!(form.selected_profile_selector(), None);
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("project:coder")
);
form.cycle_profile_prev();
assert_eq!(
form.selected_profile_selector().as_deref(),
@ -991,20 +810,16 @@ description = "Project coder"
#[test]
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
let mut choices = vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}];
let mut choices = Vec::new();
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
assert_eq!(selected, 1);
assert_eq!(choices[1].selector.as_deref(), Some("coder"));
assert_eq!(choices[1].label, "coder");
assert_eq!(selected, 0);
assert_eq!(choices[0].selector.as_deref(), Some("coder"));
assert_eq!(choices[0].label, "coder");
}
#[test]
fn name_input_handles_insert_backspace_and_cursor() {
let mut f = form("", false);
let mut f = form("");
for c in "abc".chars() {
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`
### 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` の定数値
2. **ユーザー manifest**`<config_dir>/manifest.toml``manifest::paths` で解決)
3. **プロジェクト manifest**`.insomnia/manifest.toml`cwd から上方向に探索)
4. **プログラマティック overlay** — CLI / GUI / spawn 時のインライン指定
`insomnia-pod --manifest <PATH>` は explicit one-file compatibility/debug input で、指定 TOML 1 枚だけに builtin defaults を merge し、`PodManifestConfig -> PodManifest` の required validation を通す。
マージ規則: スカラーは上層が置換、Map はキー単位マージ、`scope.allow` / `scope.deny` は union。全パスは絶対パスのみ
`PodFactory` の user/project/overlay API は低レベル構成部品として残るが、CLI の通常起動 path では generic TOML overlay を公開しない。
### Instruction とプロンプト資産

View File

@ -61,7 +61,7 @@ path = "profiles/coder.nix"
description = "Project coding assistant"
```
Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `<config_dir>/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. Later defaults override earlier defaults, so a project default wins over a user default. Unqualified defaults resolve within the declaring source by default. Unqualified ambiguous names fail closed:
Relative registry paths are resolved against the `profiles.toml` file that declares them. Discovery checks bundled builtin profiles, then the user registry at `<config_dir>/profiles.toml`, then the nearest project registry at `.insomnia/profiles.toml`. The bundled `builtin:default` profile is the fallback default when no user/project registry declares another default. Later defaults override earlier defaults, so a project default wins over a user default, and either wins over the builtin default. Unqualified defaults resolve within the declaring source by default. Unqualified ambiguous names fail closed:
```sh
insomnia --profile coder # fails if both user:coder and project:coder exist
@ -69,7 +69,13 @@ insomnia --profile project:coder # source-qualified selection
insomnia --profile default # selected registry default
```
The fresh-spawn TUI also uses discovery. If a default profile is configured, the new Pod dialog shows a selectable source-qualified profile row such as `profile: project:coder (default)`. `Tab`/`Down` cycles forward through discovered profiles, `Shift-Tab`/`Up` cycles backward, and the `manifest cascade` choice opts out of a default profile for that spawn. Passing `insomnia --profile <selector>` opens the same new Pod dialog with that selector selected and leaves Pod-name editing unchanged.
The fresh-spawn TUI also uses discovery. The new Pod dialog defaults to the selected registry default, normally `builtin:default` unless a user/project registry overrides it. `Tab`/`Down` cycles forward through discovered profiles and `Shift-Tab`/`Up` cycles backward; there is no ambient manifest-cascade opt-out. Passing `insomnia --profile <selector>` opens the same new Pod dialog with that selector selected and leaves Pod-name editing unchanged.
## One-file manifests
`insomnia-pod --manifest <PATH>` remains as an explicit compatibility/debug path. It reads exactly that TOML file, resolves relative paths against the file's parent directory, merges builtin defaults, and validates through the same `PodManifestConfig -> PodManifest` boundary as profile artifacts. It does not load user or project `manifest.toml` files and conflicts with `--profile`.
Ambient user/project `manifest.toml` cascade startup has been removed. Normal fresh spawns use profile discovery/default selection, with `profiles.toml` acting only as a profile registry/default selector.
## Artifact contract
@ -79,7 +85,9 @@ A profile should evaluate to one of:
- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; config = { ... }; }`
- a raw manifest/config object for debug/test paths.
The `manifest`/`config` object uses the same field names as the existing manifest config. Relative paths are resolved against the directory containing the profile file, then builtin defaults are merged and validation produces the runtime `PodManifest`.
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:

View File

@ -3,17 +3,17 @@
# ============================================================================
# Pod の宣言的設定 (`PodManifest` / `PodManifestConfig`)。
#
# カスケード層は下から順に
# 1. builtin defaults (`manifest::defaults`)
# 2. user manifest (`<config_dir>/manifest.toml`)
# 3. project manifest (cwd から上方向に探す `.insomnia/manifest.toml`)
# 4. programmatic overlay (呼び出し側が差し込む)
# 上の層が同名フィールドを上書き、scope rule と skills.directories は
# 累積マージ、tool_output.per_tool は key 単位でマージ。
# このファイル形式は低レベル runtime manifest。通常起動は profile discovery/default
# (`profiles.toml` と bundled builtin profile) から manifest を生成する。
# `insomnia-pod --manifest <path>` の one-file compatibility/debug mode では、
# 指定した TOML 1 枚に builtin defaults を merge し、required validation を行う。
# user/project `manifest.toml` を暗黙に merge する通常起動 cascade は使わない。
#
# パス解決: 相対パスは「その層の manifest ファイルが置かれているディレクトリ」
# を base に絶対パスへ解決される (overlay 層は cwd)。マージは絶対化済みの
# 値同士で行われる。
# `PodManifestConfig` の merge 規則: 上の層が同名フィールドを上書き、scope rule と
# skills.directories は累積マージ、tool_output.per_tool は key 単位でマージ。
#
# パス解決: `--manifest <path>` では相対パスはその manifest ファイルの親ディレクトリ
# を base に絶対パスへ解決される。profile artifact でも同じ validation 境界を通る。
#
# 凡例:
# - 必須 … 値が無いと resolve エラー

View File

@ -43,11 +43,11 @@ The Nix package does not put user configuration, sessions, sockets, or other mut
| Purpose | Override | `INSOMNIA_HOME` fallback | XDG / default fallback |
| --- | --- | --- | --- |
| User config (`manifest.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` |
| User config (`profiles.toml`, prompt overrides, model/provider overrides) | `INSOMNIA_CONFIG_DIR` | `$INSOMNIA_HOME/config` | `$XDG_CONFIG_HOME/insomnia`, then `$HOME/.config/insomnia` |
| Persistent data (`sessions/`, Pod metadata) | `INSOMNIA_DATA_DIR` | `$INSOMNIA_HOME` | `$HOME/.insomnia` |
| Runtime state (sockets, lock files, live registry) | `INSOMNIA_RUNTIME_DIR` | `$INSOMNIA_HOME/run` | `$XDG_RUNTIME_DIR/insomnia`, then `$HOME/.insomnia/run` |
`INSOMNIA_USER_MANIFEST=<path>` can still be used to select an explicit user manifest for the Pod CLI cascade path. Project manifests are still discovered from `.insomnia/manifest.toml` under the current workspace unless a CLI mode documents otherwise.
Normal fresh startup is profile-based. The package ships a builtin default profile, user/project `profiles.toml` files may select or define profiles, and `insomnia-pod --manifest <PATH>` remains a one-file compatibility/debug input. `INSOMNIA_USER_MANIFEST` and ambient `.insomnia/manifest.toml` discovery are not part of normal Pod/TUI startup.
## Validation

View File

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

View File

@ -337,42 +337,33 @@ import-map 形式のプレフィックスで指定する:
## `insomnia-pod` CLI
`insomnia-pod` は通常、builtin default → user manifest → project manifest → overlay の cascade で manifest を解決して起動する
`insomnia-pod` の通常起動は profile discovery/default から runtime manifest を作る。user/project `manifest.toml` の ambient cascade は通常起動では使わない
```
insomnia-pod [--project <path>] [--overlay <toml>] [-s/--store <path>] [--session <uuid>]
insomnia-pod [--profile <selector>] [--profile-pod-name <name>] [-s/--store <path>] [--session <uuid>]
```
| フラグ | 説明 |
|---|---|
| `--project <path>` | プロジェクト manifest 探索の起点。省略時は cwd から上方向に `.insomnia/manifest.toml` を探索 |
| `--overlay <toml>` | 最上層の overlay を inline TOML 文字列で渡す(例: `--overlay 'worker.instruction = "$user/foo"'` |
| `--profile <selector>` | builtin/user/project profile registry から Nix profile を選択。省略時は registry default通常は `builtin:default` |
| `--profile-pod-name <name>` | profile 由来 manifest の `pod.name` を fresh spawn 用に上書き |
| `-s, --store <path>` | セッション永続化ディレクトリ(デフォルト: `<data_dir>/sessions/`、`manifest::paths` で解決) |
| `--session <uuid>` | 既存 session id から Pod を復元し、同じ jsonl に後続 turn を追記する |
user manifest は CLI フラグではなく、以下の規則で解決する。
| 入力 | 挙動 |
|---|---|
| `INSOMNIA_USER_MANIFEST=<path>` | 指定 path を user manifest として読む。ファイル不在や parse error は起動エラー |
| `INSOMNIA_USER_MANIFEST=` | 空文字列は未指定扱い |
| env 未指定 | `manifest::paths::user_manifest_path()` で自動探索し、存在すれば読む |
単一ファイルだけで起動したい場合は cascade を使わず、`--manifest` を指定する。
単一ファイルだけで起動したい場合は `--manifest` を指定する。
```
insomnia-pod --manifest <path> [-s/--store <path>] [--session <uuid>]
```
`--manifest` は指定 TOML 1 枚だけを `PodManifest::from_toml` で読み、user / project / overlay layer は一切読まない。したがって `--project`、`--overlay`、非空の `INSOMNIA_USER_MANIFEST` とは併用不可。
`--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 を引き継がせるために使うもので、通常の手動起動では使わない。
Pod の作業ディレクトリは `insomnia-pod` 起動時の cwd が直接使われる。別ディレクトリで
動かしたい場合は `cd <path> && insomnia-pod ...` のように外側で `cd` してから起動する。
引数無しで起動すると、cwd + `manifest::paths` の自動解決だけで動く最小構成になる
overlay 無し、プロジェクトに `.insomnia/manifest.toml` があればそれを使う)。
引数無しで起動すると、profile registry default通常は bundled `builtin:default`)で起動する。
---

View File

@ -0,0 +1,40 @@
let
insomnia = import ../profile-lib.nix {};
in
insomnia.mkProfile {
name = "default";
description = "Bundled default Insomnia coding profile";
manifest = insomnia.mkManifest {
pod.name = "insomnia";
scope.allow = [
{ target = "."; permission = "write"; recursive = true; }
];
session.record_event_trace = true;
worker.reasoning = "high";
model.ref = "codex-oauth/gpt-5.5";
compaction = {
threshold = 200000;
request_threshold = 240000;
worker_context_max_tokens = 100000;
};
memory = {
extract_threshold = 50000;
consolidation_threshold_files = 5;
consolidation_threshold_bytes = 50000;
};
web = {
enabled = true;
search = {
provider = "brave";
api_key_env = "BRAVE_SEARCH_API_KEY";
};
};
};
}