Compare commits

..

34 Commits

Author SHA1 Message Date
aba7809fb9
chore: workflowの改善 2026-05-30 05:35:54 +09:00
dda9910c30
close: builtin profile startup 2026-05-30 04:38:53 +09:00
123aa5b30b
review: approve builtin profile startup 2026-05-30 04:38:42 +09:00
4df68978aa
merge: builtin profile default startup 2026-05-30 04:36:59 +09:00
20ac0c96a5
fix: remove generic overlay startup path 2026-05-30 04:34:27 +09:00
625730cb0a
feat: use builtin profile by default 2026-05-30 03:57:40 +09:00
ffe1785262
ticket: builtin profile and manifest single-file mode 2026-05-30 03:34:33 +09:00
2242987804
close: remove profile aliases 2026-05-30 03:20:45 +09:00
4773be702a
fix: remove profile aliases 2026-05-30 03:20:34 +09:00
31620257cd
ticket: remove profile aliases 2026-05-30 03:16:02 +09:00
43cff6d410
close: profile registry config file 2026-05-30 03:11:11 +09:00
3f67c83d43
fix: move profile registry to profiles config 2026-05-30 03:10:46 +09:00
513f55415f
ticket: move profile registry out of manifest 2026-05-30 03:03:33 +09:00
3b60708cff
close: manifest profiles 2026-05-30 02:46:01 +09:00
c9627a13d4
merge: manifest profile discovery and picker 2026-05-30 02:44:53 +09:00
b9d8bb392e
review: approve manifest profile phase two 2026-05-30 02:44:48 +09:00
d77a86d550
fix: mark resolved default profile aliases 2026-05-30 02:43:46 +09:00
ba9e924c89
feat: add fresh spawn profile picker 2026-05-30 02:34:42 +09:00
45c94a6fbe
close: pod socket disconnect noise 2026-05-30 02:26:51 +09:00
ccb8f96118
merge: pod socket disconnect noise fix 2026-05-30 02:26:25 +09:00
8db3ff5de7
review: approve pod socket disconnect noise fix 2026-05-30 02:26:20 +09:00
d5d50a3214
fix: suppress pod socket peer disconnect noise 2026-05-30 02:20:33 +09:00
ee7147b355
feat: add manifest profile discovery 2026-05-30 02:18:42 +09:00
a17cd47bdd
ticket: add pod socket disconnect noise 2026-05-30 02:13:52 +09:00
0269b91f0a
Update AGENTS.md 2026-05-30 02:03:39 +09:00
06c778a725
ticket: plan manifest profile phase two 2026-05-30 01:59:42 +09:00
9e99764927
ticket: refresh manifest profiles review metadata 2026-05-30 01:53:53 +09:00
2fa496300a
merge: nix manifest profile foundation 2026-05-30 01:52:53 +09:00
c8996de94d
review: approve nix manifest profile foundation 2026-05-30 01:52:48 +09:00
c9a175af54
fix: ignore user manifest for profiles 2026-05-30 01:51:49 +09:00
5de31a9be2
feat: add nix manifest profile foundation 2026-05-30 01:41:06 +09:00
a25982b045
ticket: add control-only scope delegation event 2026-05-30 01:31:19 +09:00
b47364fb46
ticket: refresh manifest profiles metadata 2026-05-30 01:20:24 +09:00
feec991676
ticket: add mcp integration 2026-05-30 01:20:24 +09:00
59 changed files with 4148 additions and 538 deletions

View File

@ -6,8 +6,25 @@ allow = [
[session]
record_event_trace = true
[worker]
reasoning = "high"
[model]
ref = "codex-oauth/gpt-5.5"
[compaction]
compact_threshold = 200000
compact_request_threshold = 240000
compact_worker_max_input_tokens = 100000
[memory]
extract_threshold = 50000
consolidation_threshold_files = 5
consolidation_threshold_bytes = 50000
[web]
enabled = true
[web.search]
provider = "brave"
api_key_env = "BRAVE_SEARCH_API_KEY"

View File

@ -8,7 +8,7 @@ requires: []
insomnia を AI maintainer として運用するための半自動 loop。TODO / tickets から「今進められそうな作業」を選ぶだけでなく、課題の発見、設計判断の切り分け、次に人間へ戻すべき問いの整理までを扱う。
これは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきかを整理する。
これは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきか、下位 orchestrator にどの intent packet を渡すべきかを整理する。
参照:
@ -24,6 +24,7 @@ AI maintainer の目的は、コードを書くこと自体ではなく、プロ
- TODO / tickets / docs / git history を読んで現在地を把握する。
- 実装可能な ticket と、方針決定が必要な ticket を分ける。
- 小さく実装できる候補を提案する。
- 複数 ticket からなる作業群は、下位 orchestrator に任せる単位として整理する。
- 設計相談が必要な論点を人間に戻す。
- 運用上の問題や繰り返し発生する詰まりを report / ticket / workflow 改訂候補として整理する。
@ -65,7 +66,7 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
- 大きな設計判断が不要。
- scope を狭く切れる。
この場合は、人間に候補として提示する。人間が実行を許可したら `$user/multi-agent-workflow` に進む。
この場合は、人間に候補として提示する。人間が実行を許可したら `$user/multi-agent-workflow` に進む。複数 ticket や連続した作業群では、最上位 Pod が直接 coder を抱えず、下位 orchestrator に intent packet を渡して coder / reviewer sibling loop を管理させる。
### B. 方針決定が必要
@ -89,6 +90,7 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
- 同じ tool 問題が繰り返し出る。
- Workflow の指示が曖昧で実装 Pod が迷った。
- coder / reviewer / orchestrator の責務が混ざり、親 Pod が細かい code review に戻ってしまった。
- AI が過剰に Task tool を使うなど、運用上の癖が出た。
- 通知や Pod completion tracking など、開発基盤の不足が観測された。
@ -114,9 +116,12 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
- 「次に進めるなら X」を1つ推奨する。
- 理由を短く述べる。
- 実装委譲する場合の scope / test 方針を添える。
- 複数 ticket の作業群なら、下位 orchestrator に任せる単位として提示する。
5. 実行への接続
- 人間が「進めて」と言ったら `$user/multi-agent-workflow` に接続する。
- 単発 ticket か、下位 orchestrator に任せる ticket 群かを明示する。
- 下位 orchestrator に渡す intent / requirements / invariants / non-goals / escalation 条件を短くまとめる。
- worktree 作成は `$user/worktree-workflow` に従う。
## エスカレーション基準
@ -129,7 +134,7 @@ TODO と ticket の不整合を見つけたら、勝手に修正せず、まず
- 新 ticket の作成、既存 ticket の大幅変更、ticket 完了削除について合意がない。
- test 不能、再現不能、または作業範囲外の不具合に遭遇した。
- WorkItem / Thread / Lease / maintainer state など、まだ設計中の概念が必要になる。
- 下位 orchestrator に委譲するには intent / invariant / escalation 条件が曖昧すぎる。
## まだ固定しないもの

View File

@ -1,75 +1,159 @@
---
description: worktree と子 Pod を使って複数 ticket の実装・レビュー・修正・完了処理を並列に進める orchestration フロー
description: worktree と sibling の coder / reviewer Pod を使い、下位 orchestrator が複数 ticket の実装・外部レビュー・修正・完了準備を管理する orchestration フロー
model_invokation: true
user_invocable: true
requires: []
---
# Multi-agent Worktree Workflow
insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod review の標準フロー。これは **実装を並列に進めるためのフロー** であり、worktree の機械的作成手順は `$user/worktree-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
insomnia を insomnia で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
worktree の機械的作成手順は `$user/worktree-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
## 目的
- 実装差分を ticket ごとの child worktree に隔離する。
- 実装 Pod に narrow write scope を渡して並列実装させる。
- 親 Pod が diff / test / ticket 要件を review し、必要なら修正依頼する。
- approve 後に merge / ticket 完了処理 / main workspace での再検証を行う。
- coder Pod に narrow write scope を渡して実装させる。
- reviewer Pod を coder の子ではなく **同じ orchestrator 配下の sibling** として立て、外部レビューを行わせる。
- orchestrator は coder / reviewer のやり取り、修正 loop、validation、merge-ready dossier 作成に責任を持つ。
- 最上位 orchestrator は、コードを直接理解し切ることではなく、委譲した intent / 要件 / invariant に沿って下位 orchestrator が完了まで運んだかを acceptance する。
## 階層モデル
基本形は以下。
```text
最上位 orchestrator Pod
- 人間との会話相手
- intent / 要件 / invariant / escalation 条件を定義
- 複数の作業群を並列管理
- final merge / ticket close / main workspace validation を行う
- 原則として line-by-line code review を主業務にしない
下位 orchestrator Podarea / epic / ticket-group coordinator
- 連続した複数 ticket または大きめの ticket 群を完了状態まで運ぶ
- worktree / branch / coder / reviewer / validation / 修正 loop を管理する
- coder と reviewer を sibling として扱う
- 親には merge-ready dossier と残論点だけを返す
coder Pod
- 指定 worktree / branch に実装する
- ticket 外判断や設計衝突は orchestrator に戻す
- reviewer に直接反論・修正依頼を完結させず、orchestrator に報告する
reviewer Pod
- 原則 read-only
- ticket / intent packet / diff / validation 結果を読む
- 実際のコード変更が概念的に何を変えたかを説明する
- intent / 要件 / invariant に反する blocker を分類して返す
```
一段だけで足りる小さい ticket では、最上位 orchestrator が直接 coder / reviewer sibling を扱ってよい。複数 ticket や設計境界をまたぐ作業では、最上位の下に下位 orchestrator を挟む。
## 開始条件
以下が揃っている時に使う。
- 対象 ticket が決まっている。
- 対象 ticket または ticket 群が決まっている。
- ticket の背景・要件・完了条件から実装方針が概ね導ける。
- worktree 作成と git 書き込み操作について、人間の許可がある。
- main workspace の unrelated dirty changes を把握している。
- 下位 orchestrator に渡す intent / invariant / non-goals / escalation 条件を短く書ける。
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に人間へ戻す。
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に人間へ戻す。ただし下位 orchestrator に探索だけを委譲することはできる。
## 親 Pod / orchestrator の責務
## Intent packet
階層化すると人間の意図が劣化しやすい。下位 orchestrator や coder / reviewer へは、自然文の依頼だけでなく intent packet を渡す。
標準形:
```text
Intent:
- 何を実現するか。
Requirements:
- 完了時に満たすべき observable な要件。
Invariants:
- 壊してはいけない設計境界。
- 残してはいけない旧概念や互換層。
Non-goals:
- 今回やらないこと。
Escalate if:
- 親へ戻すべき判断条件。
Validation:
- 実行すべき format / build / test / doctor。
```
reviewer には coder の実装方針ではなく、この intent packet と diff を中心に読ませる。
## orchestrator の責務
下位 orchestrator を挟まない場合は、以下を最上位 orchestrator が行う。下位 orchestrator を挟む場合は、最上位は intent packet を渡し、以下の実務を下位に委譲する。
1. 状態確認
- `git status --short --branch`
- 対象 ticket
- 対象 ticket / ticket 群
- 関連 TODO / docs / 既存 worktree
2. worktree 作成
- `$user/worktree-workflow` に従い `./.worktree/<task-name>` を作る。
- `.insomnia` を sparse checkout で除外する。
3. 実装 Pod spawn
3. coder Pod spawn
- read scope: main workspace 全体。
- write scope: child worktree、または必要最小 directory。
- task には以下を明示する。
- child worktree path / branch
- 対象 ticket path
- intent packet
- Bash は必ず child worktree に `cd` すること
- main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.insomnia` は編集しないこと
- 範囲外事項
- 実行すべき build / test / format
- 完了報告項目
4. 監督
4. coder 完了確認
- `ReadPodOutput` で報告を読む。
- 通知が来ない場合でも、worktree の `git status` / `git diff` / test で完了状態を確認する。
- 必要なら `SendToPod` で修正依頼する。
- coder が止まった場合、worktree 状態を見て再 spawn / rollback / 親 escalation を判断する。
5. review
- ticket の背景・要件・完了条件・範囲外に照らして diff を確認する。
- build / test / `git diff --check` を確認する。
- 必要なら reviewer Pod を read-only で立てる。
5. reviewer Pod spawn
- reviewer は coder の子ではなく orchestrator 配下の sibling として立てる。
- 原則 read-only scope にする。
- reviewer に渡すもの:
- ticket / intent packet
- branch / commit / diff の読み方
- 実行済み validation
- blocker / non-blocker / acceptable の分類基準
- reviewer の主目的は「コードの詳細を親の代わりに説明し、intent / invariant 違反を見つけること」。
6. merge / lifecycle
- approve 後に main workspace へ merge する。
- `TODO.md` から該当行を削除し、`tickets/foo.md` を削除して完了 commit を作る。
6. review → 修正 loop
- reviewer finding を orchestrator が読む。
- blocker は coder に修正依頼する。
- reviewer の指摘を却下する場合は、orchestrator が理由を dossier に残す。
- 修正後は focused validation を実行し、必要なら reviewer に再確認させる。
- reviewer の blocker が未解決のまま親に提出しない。
7. merge-ready dossier 作成
- 親がコードを直接理解しなくても判断できるよう、変更の概念的説明と evidence をまとめる。
8. merge / lifecycle
- 最上位 orchestrator または人間の許可を持つ orchestrator が main workspace へ merge する。
- `TODO.md` から該当行を削除し、ticket を完了処理して commit する。
- main workspace で必要な test / `cargo check --workspace` / `cargo fmt --check` を再実行する。
## 実装 Pod の責務
## coder Pod の責務
- child worktree 内でのみ実装する。
- main workspace の管理ファイルを書かない。
- intent / requirements / invariants / non-goals を読んでから実装する。
- 指定された build / test / format を実行する。
- ticket 要件外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて報告する。
- ticket 要件外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて orchestrator に報告する。
- 完了時に以下を報告する。
- worktree path / branch
- commit hashcommit した場合)
@ -79,34 +163,54 @@ insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod re
- 未解決事項
- review に回せるか
## 実装 Pod の commit 方針
## reviewer Pod の責務
実装 Pod には child worktree 内での commit を許可してよい。
reviewer は coder の subordinate ではない。orchestrator 配下の sibling として、実装 diff を外部から読む。
- 原則 read-only で作業する。
- ticket / intent packet / diff / validation 結果を読む。
- 実際のコード変更が概念的に何を変えたかを説明する。
- 親や上位 orchestrator が line-by-line diff を読まずに判断できるよう、以下を整理する。
- 変更の概念モデル
- intent / requirements との対応
- invariant 違反の有無
- 旧概念・禁止語彙・不要な互換層の残存
- validation の妥当性
- blocker / non-blocker / follow-up
- reviewer は直接 merge しない。
- reviewer は coder に直接作業指示を出さず、orchestrator に finding を返す。
## commit 方針
coder Pod には child worktree 内での commit を許可してよい。
- commit は ticket 内で意味のある粒度にする。
- 例: `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`
- 実装 Pod は merge / push / branch deletion / worktree remove をしない。
- 実装 Pod は `TODO.md` / `tickets/` の完了処理 commit をしない。
- 親 Pod は review 時に commit 粒度も確認する。
- coder Pod は merge / push / branch deletion / worktree remove をしない。
- coder Pod は `TODO.md` / ticket の完了処理 commit をしない。
- orchestrator は review 時に commit 粒度も確認する。
- 必要な修正は、原則追加 commit として積む。履歴改変や squash は人間の明示指示がある時だけ行う。
## Review → 修正 → 完了の標準形
### Approve
1. 実装 Pod を停止し、scope を回収する。
2. 親 Pod が main workspace で `git merge --no-ff <branch>` する。
3. 親 Pod が `TODO.md``tickets/foo.md` を完了処理して commit する。
4. main workspace で検証コマンドを再実行する。
5. 変更内容・commit・検証結果・残 dirty changes を報告する。
1. coder Pod / reviewer Pod を停止し、scope を回収する。
2. orchestrator が merge-ready dossier を確認する。
3. 最上位 orchestrator が必要最小限の spot check を行う。
4. main workspace で `git merge --no-ff <branch>` する。
5. `TODO.md` と ticket を完了処理して commit する。
6. main workspace で検証コマンドを再実行する。
7. 変更内容・commit・検証結果・残 dirty changes を報告する。
### Request changes
1. blocking finding をファイル / 行 / 理由 / 修正方針つきで整理する。
2. 実装 Pod が生きていれば `SendToPod` で修正依頼する
3. 停止済みなら、同じ worktree / branch / scope で再 spawn するか、親 Pod が最小修正する。
1. reviewer finding または orchestrator finding を blocker / non-blocker に分ける。
2. blocker はファイル / 行 / 理由 / 修正方針つきで coder に戻す
3. coder が停止済みなら、同じ worktree / branch / scope で再 spawn する。
4. 修正後に focused test と必要な broader test を再実行する。
5. 再 review する。
5. 必要なら reviewer に再確認させる。
6. blocker が解消したら dossier を更新する。
### Non-blocking comments
@ -118,28 +222,75 @@ insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod re
- 1 ticket = 1 worktree = 1 branch を基本にする。
- 複数 Pod に同じ write scope を渡さない。
- parent は child の write scope 配下を直接編集しない。
- parent / orchestrator は coder の write scope 配下を直接編集しない。
- reviewer は read-only を基本にする。review artifact を書かせる場合は ticket artifacts など限定 scope にする。
- 依存関係がある ticket は、土台 branch を merge してから次 worktree を切る。
- parallel に走らせた Pod の完了通知は取りこぼしうるため、`ReadPodOutput` と worktree 状態で確認する。
## 完了報告の標準形
## merge-ready dossier の標準形
```text
完了:
- ticket: <path>
Status:
- completed / blocked / needs parent decision
Scope:
- ticket(s): <path>
- branch: <name>
- commits:
- <hash> <subject>
- 変更概要: ...
- 検証:
- cargo fmt --check
- cargo check --workspace
- cargo test ...
- review: approve / approve with comments / request changes
- 未解決事項: ...
- 残 dirty changes: ...
Intent check:
- invariant 1: ok / violated / not applicable, evidence ...
- invariant 2: ok / violated / not applicable, evidence ...
Implementation summary:
- 変更の概念的説明: ...
- 主要変更ファイル: ...
- compatibility / migration: ...
Coder loop:
- coder pod: <name>
- produced commits: ...
- unresolved coder notes: ...
External review:
- reviewer pod: <name>
- blockers found: ...
- blockers fixed: ...
- rejected findings and reasons: ...
- non-blocking follow-ups: ...
Validation:
- cargo fmt --check
- cargo check --workspace
- cargo test ...
- ./tickets.sh doctor
Parent decision needed:
- none / specific question
Residual risk:
- ...
Dirty state:
- ...
```
## 最上位 orchestrator の acceptance
最上位 orchestrator は、下位 orchestrator の成果を code review するのではなく acceptance する。
確認するもの:
- intent packet が保持されているか。
- reviewer が coder と独立した sibling として機能したか。
- blocker が未解決のまま握りつぶされていないか。
- 変更の概念的説明が要件と対応しているか。
- validation が ticket のリスクに対して十分か。
- escalation すべき判断を下位が勝手に決めていないか。
必要なら spot check するが、常態的な line-by-line review に戻らない。
## この Workflow で扱わないもの
以下は `$user/auto-maintain` または別の設計相談で扱う。
@ -148,3 +299,4 @@ insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod re
- 新規 ticket 作成判断。
- QA feedback / AI feedback を ticket / report / workflow に落とす判断。
- 長期 maintainer loop / WorkItemStore / LeaseStore の設計。
- reviewer Pod の品質評価を機械的に採点する仕組み。

View File

@ -1,26 +1,28 @@
---
description: insomnia プロジェクトで child git worktree を作成・管理するための機械的手順。実装 Pod に作らせず、親 Pod が main workspace で実行する。
model_invokation: false
description: insomnia プロジェクトで child git worktree を作成・管理するための機械的手順。coder Pod に作らせず、orchestrator Pod が main workspace で実行する。
model_invokation: true
user_invocable: true
requires: []
---
# Worktree Workflow
insomnia プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、実装委譲、review、merge の運用は `$user/multi-agent-workflow` 側で扱う。
insomnia プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、coder / reviewer sibling の起動、外部レビュー、merge の運用は `$user/multi-agent-workflow` 側で扱う。
insomnia では Pod の write scope が排他的に委譲されるため、child worktree に `.insomnia` を置かない。main workspace は orchestration / ticket / docs / memory / workflow 管理の場所として残し、child worktree はコード差分専用の作業面として扱う。
## 適用範囲
この Workflow は親 Pod / orchestrator が main workspace で実行する。
この Workflow は親 Pod / 下位 orchestrator が main workspace で実行する。
- 実装 Pod にこの Workflow を渡して worktree を作らせない。
- 実装 Pod は、親 Pod が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
- coder Pod にこの Workflow を渡して worktree を作らせない。
- coder Pod は、orchestrator が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
- reviewer Pod は、coder Pod の子ではなく orchestrator 配下の sibling として、原則 read-only で main workspace と child worktree を読む。
- ticket 作成、TODO 更新、review artifact、docs/report は main workspace 側で扱う。
## 原則
- 1 ticket / 1 実装 task につき 1 worktree を作る。
- 複数 ticket を下位 orchestrator に任せる場合も、実装差分は ticket / bounded task ごとに worktree を分ける。
- worktree path は `./.worktree/<task-name>`
- branch 名は原則 `<task-name>` と同じ kebab-case。
- child worktree には `.insomnia` を出さない。
@ -36,6 +38,7 @@ insomnia では Pod の write scope が排他的に委譲されるため、child
3. `git worktree add` を実行してよい許可があるか。
4. main workspace に混ぜてはいけない未保存差分がないか。
5. 同名 branch / worktree が既に存在しないか。
6. coder / reviewer を sibling として扱う orchestrator が誰か明確か。
同名 branch がある場合は、既存 branch を使うか、人間に確認する。`git worktree add -b` で上書きしない。
@ -62,18 +65,27 @@ test ! -e .worktree/<task-name>/.insomnia
失敗した場合は、worktree / branch / lock の状態を確認し、勝手に cleanup せず人間へ報告する。
## Pod へ渡す scope
## Pod へ渡す scope
Pod を使う場合、Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd <repo>/.worktree/<task-name> && ...` させる。
Pod を使う場合、Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd <repo>/.worktree/<task-name> && ...` させる。
推奨 scope:
coder Pod 推奨 scope:
```text
read: <repo>
write: <repo>/.worktree/<task-name>
```
より狭く切れる場合は、write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する。
reviewer Pod 推奨 scope:
```text
read: <repo>
read: <repo>/.worktree/<task-name> # main workspace の read に含まれるなら別指定不要
```
reviewer は原則 write scope を持たない。review artifact を書かせる必要がある場合だけ、ticket artifacts など限定 directory を write scope として渡す。
より狭く切れる場合は、coder の write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する。
## child worktree 内の禁止事項
@ -86,13 +98,22 @@ write: <repo>/.worktree/<task-name>
worktree 作成 Workflow としては、完了時に merge しない。merge、ticket 完了、TODO 削除は `$user/multi-agent-workflow` または人間の明示指示で行う。
実装 Pod へ渡す完了報告項目の標準形:
coder Pod へ渡す完了報告項目の標準形:
- worktree path
- branch 名
- commit hash実装 Pod に commit を許可した場合)
- commit hashcoder Pod に commit を許可した場合)
- 変更ファイル
- 実装概要
- 実行した build / test / format
- 未解決事項
- review に回せるか
reviewer Pod へ渡す完了報告項目の標準形:
- 読んだ ticket / intent packet / diff
- 実際のコード変更の概念的説明
- intent / requirements / invariant との対応
- blocker / non-blocker / follow-up
- validation の妥当性
- 親または上位 orchestrator が判断すべき残論点

View File

@ -1,8 +1,9 @@
全体設計が概ね固まり、随所の細かい仕様を詰めながら実装を進めている。
すでにシステムのドッグフーディングに成功しており、改善・機能追加のフェーズになっている。
随所の細かい仕様を詰めながら実装を進めている。
## このシステムに置ける設計要旨
- プロンプトはすべて resources/promptsに集約している。管理効率の工場と同時に、ユーザーがオーバーライドする形式でもある。
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
- E2E(実プロセスをスポーンさせてのテスト)は未設計。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
@ -28,7 +29,7 @@ Podの状態から純粋に再現可能で、且つ揮発性の無い操作で
## Git操作
workflowで明示されない限り、読み取り以外の操作は控えること。
示的に指示されない限り、読み取り以外の操作は控えること。
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。

2
Cargo.lock generated
View File

@ -334,6 +334,7 @@ version = "0.1.0"
dependencies = [
"manifest",
"protocol",
"serde_json",
"tokio",
"uuid",
]
@ -1732,6 +1733,7 @@ dependencies = [
"protocol",
"serde",
"serde_ignored",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"toml",

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 のログ収集はここで賄う。
@ -27,8 +27,12 @@ pub struct SpawnConfig {
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。
pub pod_name: String,
/// `--overlay` で pod に渡す TOML 文字列。
pub overlay_toml: String,
/// Optional Nix profile selector. When present the child is launched with
/// `--profile`; the Pod name is supplied through `--profile-pod-name` so
/// profile evaluation stays separate from `--pod` restore semantics.
pub profile: Option<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>` を付与し、当該セッションから
@ -107,18 +111,33 @@ where
let mut command = Command::new(&pod_bin);
command
.arg("--overlay")
.arg(&config.overlay_toml)
.current_dir(&config.cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_file))
.process_group(0);
if config.resume_by_pod_name {
if let Some(profile) = &config.profile {
command
.arg("--profile")
.arg(profile)
.arg("--profile-pod-name")
.arg(&config.pod_name);
}
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

@ -9,6 +9,7 @@ arc-swap = "1"
llm-worker = { workspace = true }
protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_ignored = "0.1.14"
thiserror = { workspace = true }
toml = { workspace = true }

View File

@ -676,6 +676,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
web: cfg.web,
memory: cfg.memory,
skills: cfg.skills,
profile: None,
})
}
}

View File

@ -3,6 +3,7 @@ mod config;
pub mod defaults;
mod model;
pub mod paths;
mod profile;
mod scope;
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
@ -15,6 +16,12 @@ pub use model::{
};
pub use paths::{
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
user_profiles_path,
};
pub use profile::{
NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,
ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource,
ResolvedProfile, resolve_profile_artifact,
};
pub use protocol::{Permission, ScopeRule};
pub use scope::{Scope, ScopeError, SharedScope};
@ -66,6 +73,11 @@ pub struct PodManifest {
/// there is no implicit `$config_dir/skills/` or builtin probe.
#[serde(default)]
pub skills: Option<SkillsConfig>,
/// Optional profile provenance for manifests produced by a Nix profile.
/// Stored only after profile resolution so Pod restore can prefer the
/// validated snapshot over ambient manifest cascade state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<profile::ProfileManifestSnapshot>,
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are

View File

@ -117,6 +117,14 @@ pub enum AuthRef {
/// ChatGPT OAuth`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
#[serde(rename = "codex_oauth")]
CodexOAuth,
/// Typed secret-store reference. The profile resolver preserves this
/// reference verbatim; secret-store lookup/decryption is intentionally a
/// later consumer-boundary concern.
#[serde(rename = "secret_ref")]
SecretRef {
#[serde(rename = "ref")]
ref_: String,
},
}
impl SchemeKind {

View File

@ -33,6 +33,9 @@ use std::path::PathBuf;
/// auto-discovered user manifest path.
pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
/// Environment variable that points at installed project resources.
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
/// `prompts/` などが置かれる。
pub fn config_dir() -> Option<PathBuf> {
@ -109,11 +112,45 @@ pub fn user_manifest_path_with_env_override() -> Option<PathBuf> {
user_manifest_path_from_env(std::env::var_os(USER_MANIFEST_ENV)).or_else(user_manifest_path)
}
/// `<config_dir>/profiles.toml` — user profile registry/default configuration.
///
/// This is application/profile selection configuration, not a Pod manifest
/// layer. It deliberately ignores [`USER_MANIFEST_ENV`].
pub fn user_profiles_path() -> Option<PathBuf> {
Some(config_dir()?.join("profiles.toml"))
}
/// `<config_dir>/prompts/` — user prompts ライブラリ。
pub fn user_prompts_dir() -> Option<PathBuf> {
Some(config_dir()?.join("prompts"))
}
/// Root resource directory used for bundled prompts/Nix support files.
pub fn resource_dir() -> Option<PathBuf> {
if let Some(p) = env_path(RESOURCE_DIR_ENV) {
return Some(p);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(prefix) = exe.parent().and_then(|bin| bin.parent()) {
let installed = prefix.join("share").join("insomnia").join("resources");
if installed.exists() {
return Some(installed);
}
}
}
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("resources"),
)
}
/// Bundled profile registry directory. Missing directories are treated as an
/// empty builtin registry by discovery.
pub fn builtin_profiles_dir() -> Option<PathBuf> {
Some(resource_dir()?.join("nix").join("profiles"))
}
/// `<config_dir>/prompts.toml` — user prompt pack。
pub fn user_pack_file() -> Option<PathBuf> {
Some(config_dir()?.join("prompts.toml"))
@ -192,6 +229,7 @@ mod tests {
"INSOMNIA_DATA_DIR",
"INSOMNIA_RUNTIME_DIR",
"INSOMNIA_USER_MANIFEST",
"INSOMNIA_RESOURCE_DIR",
"INSOMNIA_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
@ -355,6 +393,10 @@ mod tests {
user_manifest_path().unwrap(),
PathBuf::from("/sand/config/manifest.toml")
);
assert_eq!(
user_profiles_path().unwrap(),
PathBuf::from("/sand/config/profiles.toml")
);
assert_eq!(
user_prompts_dir().unwrap(),
PathBuf::from("/sand/config/prompts")

File diff suppressed because it is too large Load Diff

View File

@ -806,6 +806,7 @@ mod tests {
child("child-stale", &stale_socket),
child("child-pending", &pending_socket),
],
resolved_manifest_snapshot: None,
};
store.write(&parent).unwrap();
store
@ -816,6 +817,7 @@ mod tests {
active_child_segment,
)),
spawned_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();
store
@ -826,6 +828,7 @@ mod tests {
active_child_segment,
)),
spawned_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();
store
@ -833,6 +836,7 @@ mod tests {
pod_name: "child-pending".into(),
active: Some(PodActiveSegmentRef::pending_segment(pending_session_id)),
spawned_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();
store
@ -843,6 +847,7 @@ mod tests {
new_segment_id(),
)),
spawned_children: Vec::new(),
resolved_manifest_snapshot: None,
})
.unwrap();

View File

@ -1,4 +1,5 @@
use std::io;
use std::io::ErrorKind;
use std::path::PathBuf;
use protocol::stream::{JsonLineReader, JsonLineWriter};
@ -57,6 +58,16 @@ impl Drop for SocketServer {
}
}
fn is_peer_disconnect_read_error(error: &io::Error) -> bool {
matches!(
error.kind(),
ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::BrokenPipe
| ErrorKind::UnexpectedEof
)
}
async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
let (reader, writer) = stream.into_split();
let mut reader = JsonLineReader::new(reader);
@ -206,14 +217,48 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
let _ = handle.send(method).await;
}
Ok(None) => break,
Err(e) if is_peer_disconnect_read_error(&e) => break,
Err(e) => {
let _ = handle.send_event(Event::Error {
code: protocol::ErrorCode::Internal,
message: format!("invalid method: {e}"),
});
if writer
.write(&Event::Error {
code: protocol::ErrorCode::InvalidRequest,
message: format!("invalid method: {e}"),
})
.await
.is_err()
{
break;
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peer_disconnect_read_errors_are_connection_close() {
for kind in [
ErrorKind::ConnectionReset,
ErrorKind::ConnectionAborted,
ErrorKind::BrokenPipe,
ErrorKind::UnexpectedEof,
] {
let error = io::Error::new(kind, "peer disconnected");
assert!(
is_peer_disconnect_read_error(&error),
"{kind:?} should be treated as a normal peer disconnect"
);
}
}
#[test]
fn invalid_data_is_not_peer_disconnect() {
let error = io::Error::new(ErrorKind::InvalidData, "malformed method");
assert!(!is_peer_disconnect_read_error(&error));
}
}

View File

@ -1,33 +1,63 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::Parser;
use manifest::{PodManifest, PodManifestConfig, 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 {
/// Manifest TOML to use directly, without loading user, project, or
/// overlay layers.
#[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])]
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
/// discovered profile name, `default`, or a source-qualified name such as
/// `project:coder`.
#[arg(
long,
value_name = "PROFILE",
conflicts_with_all = ["manifest", "project", "pod", "session", "adopt"]
)]
profile: Option<String>,
/// Pod name override for a freshly-created profile Pod. This does not use
/// `--pod` restore semantics, so it must not attach/restore existing Pod
/// state by re-evaluating the profile source.
#[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])]
profile_pod_name: Option<String>,
/// 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`).
@ -66,29 +96,75 @@ 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> {
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
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
));
load_profile_fn: F,
) -> Result<(PodManifest, PromptLoader), String>
where
F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
{
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);
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(),
);
}
return load_single_manifest(path, cli.pod.as_deref());
}
let selector = ProfileSelector::Default;
load_profile_fn(&selector, cli.pod.as_deref())?
};
let factory = build_factory_with_user_manifest_path(cli, user_manifest)?;
factory
.resolve()
.map_err(|e| format!("failed to resolve manifest cascade: {e}"))
apply_session_restore_overrides(&mut manifest_and_loader.0, cli)?;
Ok(manifest_and_loader)
}
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();
}
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(())
}
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 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}",
selector.display_label()
)
})?;
if let Some(pod_name) = pod_name_override {
resolved.manifest.pod.name = pod_name.to_string();
}
Ok((resolved.manifest, PromptLoader::builtins_only()))
}
fn load_single_manifest(
@ -97,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| {
format!(
"failed to resolve manifest {} with --pod: {e}",
path.display()
)
})?
}
},
None => PodManifest::from_toml(&toml)
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?,
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!(
"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());
}
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();
@ -370,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",
@ -380,43 +410,23 @@ 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();
assert!(err.contains("--manifest cannot be used"));
assert!(err.contains(paths::USER_MANIFEST_ENV));
}
#[test]
fn manifest_allows_empty_user_manifest_env() {
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();
let (manifest, loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.pod.name, "single");
assert!(loader.user_dir().is_none());
@ -424,26 +434,105 @@ permission = "write"
}
#[test]
fn user_manifest_env_overrides_auto_user_manifest_path() {
fn profile_uses_selected_profile() {
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 profile = tmp.path().join("profile.nix");
let cli = Cli::try_parse_from([
"insomnia-pod",
"--project",
no_project_root.to_str().unwrap(),
"--profile",
profile.to_str().unwrap(),
"--profile-pod-name",
"from-profile-name",
])
.unwrap();
let mut called = false;
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(
&cli,
Some(user_manifest.as_os_str().to_os_string()),
)
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"));
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-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, "from-profile-name");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
}
#[test]
fn profile_accepts_source_qualified_discovered_name() {
let tmp = TempDir::new().unwrap();
let cli = Cli::try_parse_from([
"insomnia-pod",
"--profile",
"project:coder",
"--profile-pod-name",
"from-profile-name",
])
.unwrap();
let mut called = false;
assert_eq!(manifest.pod.name, "from-env");
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
called = true;
assert_eq!(
selector,
&ProfileSelector::source_named(
manifest::ProfileRegistrySource::Project,
"coder"
)
);
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-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, "from-profile-name");
}
#[test]
fn normal_startup_uses_default_profile() {
let tmp = TempDir::new().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();
assert!(called);
assert_eq!(manifest.pod.name, "from-default-profile");
}
#[test]
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]
@ -469,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");
}
@ -480,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",
@ -491,9 +590,74 @@ 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]
fn profile_conflicts_with_manifest_and_restore_modes() {
let segment_id = session_store::new_segment_id().to_string();
for args in [
vec!["insomnia-pod", "--profile", "p.nix", "--manifest", "m.toml"],
vec!["insomnia-pod", "--profile", "p.nix", "--pod", "agent"],
vec![
"insomnia-pod",
"--profile",
"p.nix",
"--session",
&segment_id,
],
] {
let err = Cli::try_parse_from(args).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
}
#[test]
fn profile_pod_name_requires_profile() {
let err = Cli::try_parse_from(["insomnia-pod", "--profile-pod-name", "agent"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn profile_pod_name_is_not_restore_pod_flag() {
let cli = Cli::try_parse_from([
"insomnia-pod",
"--profile",
"p.nix",
"--profile-pod-name",
"agent",
])
.unwrap();
assert_eq!(cli.profile_pod_name.as_deref(), Some("agent"));
assert!(cli.pod.is_none());
}
#[test]
@ -510,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

@ -917,27 +917,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
})
}
fn pod_metadata(&self, active: Option<PodActiveSegmentRef>) -> PodMetadata {
let mut metadata = PodMetadata::new(self.manifest.pod.name.clone(), active);
if self.manifest.profile.is_some() {
metadata.resolved_manifest_snapshot = serde_json::to_value(&self.manifest).ok();
}
metadata
}
fn write_pod_metadata_pending(&self) -> Result<(), StoreError> {
let Some(writer) = &self.pod_metadata_writer else {
return Ok(());
};
writer(PodMetadata::new(
self.manifest.pod.name.clone(),
Some(PodActiveSegmentRef::pending_segment(self.session_id())),
))
writer(self.pod_metadata(Some(PodActiveSegmentRef::pending_segment(
self.session_id(),
))))
}
fn write_pod_metadata_active(&self, loc: SegmentLocation) -> Result<(), StoreError> {
let Some(writer) = &self.pod_metadata_writer else {
return Ok(());
};
writer(PodMetadata::new(
self.manifest.pod.name.clone(),
Some(PodActiveSegmentRef::active_segment(
loc.session_id,
loc.segment_id,
)),
))
writer(self.pod_metadata(Some(PodActiveSegmentRef::active_segment(
loc.session_id,
loc.segment_id,
))))
}
/// Enable name-keyed Pod metadata write-through for Pods built through
@ -3945,6 +3949,15 @@ where
pod_name: pod_name.to_string(),
session_id: active.session_id,
})?;
let manifest = match metadata.resolved_manifest_snapshot {
Some(snapshot) => serde_json::from_value(snapshot).map_err(|source| {
PodError::PodMetadataManifestSnapshot {
pod_name: pod_name.to_string(),
source,
}
})?,
None => manifest,
};
Self::restore_from_manifest(active.session_id, segment_id, manifest, store, loader).await
}
@ -4618,6 +4631,13 @@ pub enum PodError {
pod_name: String,
session_id: SessionId,
},
#[error("pod metadata for {pod_name} contains an invalid resolved manifest snapshot: {source}")]
PodMetadataManifestSnapshot {
pod_name: String,
#[source]
source: serde_json::Error,
},
}
/// Bundle of resources that every high-level Pod constructor needs:

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

@ -1100,45 +1100,128 @@ async fn socket_pod_event_turn_ended_while_idle_auto_starts_turn() {
);
}
#[tokio::test]
async fn socket_invalid_method_returns_error() {
async fn socket_error_after_method_line(
handle: &PodHandle,
line: &[u8],
) -> (pod::ErrorCode, String) {
use protocol::stream::JsonLineReader;
use tokio::io::AsyncWriteExt;
use tokio::net::UnixStream;
let sock_path = handle.runtime_dir.socket_path();
let stream = UnixStream::connect(&sock_path).await.unwrap();
let (reader, mut writer) = stream.into_split();
let mut reader = JsonLineReader::new(reader);
writer.write_all(line).await.unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
loop {
tokio::select! {
event = reader.next::<Event>() => {
match event {
Ok(Some(Event::Error { code, message })) => return (code, message),
Ok(Some(_)) => {}
Ok(None) => panic!("socket closed before invalid-method error"),
Err(e) => panic!("socket read failed before invalid-method error: {e}"),
}
}
_ = tokio::time::sleep_until(deadline) => {
panic!("timed out waiting for invalid-method error")
}
}
}
}
#[tokio::test]
async fn socket_schema_invalid_method_returns_error() {
let client = MockClient::new(simple_text_events());
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let (code, message) = socket_error_after_method_line(&handle, b"{\"bad\":\"json\"}\n").await;
assert_eq!(code, pod::ErrorCode::InvalidRequest);
assert!(
message.contains("invalid method"),
"expected invalid-method diagnostic, got: {message}"
);
}
#[tokio::test]
async fn socket_malformed_method_returns_error() {
let client = MockClient::new(simple_text_events());
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let (code, message) = socket_error_after_method_line(&handle, b"{not-json}\n").await;
assert_eq!(code, pod::ErrorCode::InvalidRequest);
assert!(
message.contains("invalid method"),
"expected invalid-method diagnostic, got: {message}"
);
}
#[tokio::test]
async fn socket_peer_close_without_method_does_not_broadcast_error() {
use protocol::stream::JsonLineReader;
use tokio::net::UnixStream;
let client = MockClient::new(simple_text_events());
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut broadcast_rx = handle.subscribe();
let sock_path = handle.runtime_dir.socket_path();
let stream = UnixStream::connect(&sock_path).await.unwrap();
let (reader, mut writer) = stream.into_split();
let (reader, writer) = stream.into_split();
let mut reader = JsonLineReader::new(reader);
// Send garbage
writer.write_all(b"{\"bad\":\"json\"}\n").await.unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
let mut saw_error = false;
loop {
tokio::select! {
event = reader.next::<Event>() => {
match event {
Ok(Some(Event::Error { .. })) => {
saw_error = true;
break;
Ok(Some(Event::Snapshot { .. })) => break,
Ok(Some(_)) => {}
Ok(None) => panic!("socket closed before connect-time snapshot"),
Err(e) => panic!("socket read failed before connect-time snapshot: {e}"),
}
}
_ = tokio::time::sleep_until(deadline) => {
panic!("timed out waiting for connect-time snapshot")
}
}
}
drop(writer);
drop(reader);
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(200);
loop {
tokio::select! {
event = broadcast_rx.recv() => {
match event {
Ok(Event::Error { code, message }) => {
panic!("peer close without Method broadcast error {code:?}: {message}")
}
Ok(None) | Err(_) => break,
_ => {}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
panic!("broadcast receiver lagged while checking peer close: {n}")
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
_ = tokio::time::sleep_until(deadline) => break,
}
}
assert!(saw_error, "should see error for invalid method");
}
// ---------------------------------------------------------------------------

View File

@ -90,6 +90,9 @@ fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, Prov
.map_err(|e| ProviderError::Config(e.to_string()))?;
Ok(ResolvedAuth::Custom(Arc::new(provider)))
}
AuthRef::SecretRef { ref_ } => Err(ProviderError::Config(format!(
"secret store references are not implemented yet: {ref_}"
))),
}
}

View File

@ -67,6 +67,8 @@ pub struct PodMetadata {
pub active: Option<PodActiveSegmentRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_children: Vec<PodSpawnedChild>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_manifest_snapshot: Option<serde_json::Value>,
}
impl PodMetadata {
@ -76,6 +78,7 @@ impl PodMetadata {
pod_name: pod_name.into(),
active,
spawned_children: Vec::new(),
resolved_manifest_snapshot: None,
}
}
}
@ -117,3 +120,31 @@ pub(crate) fn validate_pod_name(pod_name: &str) -> Result<(), StoreError> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pod_metadata_manifest_snapshot_roundtrips() {
let mut metadata = PodMetadata::new(
"profile-pod",
Some(PodActiveSegmentRef::pending_segment(crate::new_session_id())),
);
metadata.resolved_manifest_snapshot = Some(serde_json::json!({
"pod": { "name": "profile-pod" },
"profile": {
"source": { "kind": "path", "path": "/profiles/coder.nix" }
}
}));
let json = serde_json::to_string(&metadata).unwrap();
let restored: PodMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(restored, metadata);
assert_eq!(
restored.resolved_manifest_snapshot.as_ref().unwrap()["profile"]["source"]["kind"],
"path"
);
}
}

View File

@ -60,7 +60,9 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
#[derive(Debug)]
enum Mode {
Spawn,
Spawn {
profile: Option<String>,
},
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
/// resumes from name-keyed state or creates a fresh same-name Pod.
@ -111,6 +113,7 @@ where
let mut multi = false;
let mut session: Option<SegmentId> = None;
let mut pod: Option<String> = None;
let mut profile: Option<String> = None;
let mut socket_override: Option<PathBuf> = None;
let mut socket_seen = false;
let mut positional: Option<String> = None;
@ -141,6 +144,13 @@ where
pod = Some(raw.clone());
i += 2;
}
"--profile" => {
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--profile"))?;
profile = Some(raw.clone());
i += 2;
}
"--socket" => {
socket_seen = true;
let raw = args
@ -187,6 +197,11 @@ where
"--multi and --socket are mutually exclusive",
));
}
if profile.is_some() {
return Err(ParseError::Conflict(
"--multi and --profile are mutually exclusive",
));
}
return Ok(Mode::Multi);
}
@ -205,6 +220,13 @@ where
"--pod and --resume are mutually exclusive",
));
}
if profile.is_some()
&& (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
{
return Err(ParseError::Conflict(
"--profile can only be used for fresh spawn",
));
}
if let Some(pod_name) = pod {
return Ok(Mode::PodName {
@ -224,7 +246,7 @@ where
socket_override,
});
}
Ok(Mode::Spawn)
Ok(Mode::Spawn { profile })
}
#[tokio::main]
@ -248,13 +270,13 @@ async fn main() -> ExitCode {
}
let result = match mode {
Mode::Spawn => run_spawn(None).await,
Mode::Spawn { profile } => run_spawn(None, profile).await,
Mode::PodName {
pod_name,
socket_override,
} => run_pod_name(pod_name, socket_override).await,
Mode::Resume => run_resume().await,
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
Mode::ResumeWithSession(id) => run_spawn(Some(id), None).await,
Mode::Multi => run_multi().await,
};
@ -449,8 +471,11 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
}
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from).await? {
async fn run_spawn(
resume_from: Option<SegmentId>,
profile: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from, profile).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
@ -1154,6 +1179,62 @@ mod tests {
);
}
#[test]
fn parse_profile_spawn_mode() {
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
Mode::Spawn { profile } => {
assert_eq!(profile, Some("/profiles/coder.nix".to_string()));
}
_ => panic!("expected Spawn mode"),
}
}
#[test]
fn parse_profile_rejects_resume_attach_modes() {
let segment_id = session_store::new_segment_id().to_string();
let cases = [
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"--resume".to_string(),
],
"--profile can only be used for fresh spawn",
),
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"--session".to_string(),
segment_id,
],
"--profile can only be used for fresh spawn",
),
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"--socket".to_string(),
"/tmp/insomnia/sock".to_string(),
],
"--profile can only be used for fresh spawn",
),
(
vec![
"--profile".to_string(),
"p.nix".to_string(),
"agent".to_string(),
],
"--profile can only be used for fresh spawn",
),
];
for (args, message) in cases {
let err = parse_args_from(args).unwrap_err();
assert_eq!(err.to_string(), message);
}
}
#[test]
fn parse_multi_mode() {
match parse_args_from(["--multi"]).unwrap() {

View File

@ -1,28 +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 defaults, 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::PathBuf;
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, 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};
@ -91,12 +86,24 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
/// passes `--session <id>` to the spawned `insomnia-pod` child.
pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnError> {
pub async fn run(
resume_from: Option<SegmentId>,
profile: Option<String>,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
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(),
defaults.default_profile_index,
);
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,
@ -105,6 +112,8 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
resume_from,
resume_by_pod_name: false,
resume_scope: None,
profile_choices,
profile_index,
};
let mut terminal = make_inline_terminal()?;
@ -135,13 +144,14 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
Some(Action::Right) => form.move_right(),
Some(Action::Home) => form.name_cursor = 0,
Some(Action::End) => form.name_cursor = form.name.chars().count(),
Some(Action::ProfileNext) => form.cycle_profile_next(),
Some(Action::ProfilePrev) => form.cycle_profile_prev(),
}
}
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
@ -151,7 +161,7 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
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),
@ -172,15 +182,14 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
/// 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),
@ -201,50 +210,22 @@ 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,
profile_choices: Vec<ProfileChoice>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileChoice {
selector: Option<String>,
label: String,
is_default: bool,
}
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())
@ -252,25 +233,71 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string());
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
Ok(SpawnDefaults {
cwd,
cascade_has_scope,
scope_origin,
scope_origin: ScopeOrigin::FromProfile,
default_name,
default_profile_index,
profile_choices,
})
}
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 profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
return (Vec::new(), 0);
};
let mut choices = Vec::new();
for entry in registry.entries() {
let mut label = entry.qualified_name();
if entry.is_default {
label.push_str(" (default)");
}
if let Some(description) = entry.description.as_deref() {
label.push_str("");
label.push_str(description);
}
choices.push(ProfileChoice {
selector: Some(entry.qualified_name()),
label,
is_default: entry.is_default,
});
}
let default_index = choices
.iter()
.position(|choice| choice.is_default)
.unwrap_or(0);
(choices, default_index)
}
fn initial_profile_index(
choices: &mut Vec<ProfileChoice>,
explicit_profile: Option<&str>,
default_index: usize,
) -> usize {
let Some(selector) = explicit_profile else {
return default_index.min(choices.len().saturating_sub(1));
};
if let Some(index) = choices
.iter()
.position(|choice| choice.selector.as_deref() == Some(selector))
{
return index;
}
choices.push(ProfileChoice {
selector: Some(selector.to_string()),
label: selector.to_string(),
is_default: false,
});
choices.len() - 1
}
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,
@ -279,6 +306,8 @@ 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::new(),
profile_index: 0,
}
}
@ -302,6 +331,8 @@ enum Action {
Right,
Home,
End,
ProfileNext,
ProfilePrev,
}
fn poll_event() -> io::Result<Option<Action>> {
@ -322,6 +353,8 @@ fn poll_event() -> io::Result<Option<Action>> {
KeyCode::Delete => Some(Action::Delete),
KeyCode::Left => Some(Action::Left),
KeyCode::Right => Some(Action::Right),
KeyCode::Up | KeyCode::BackTab => Some(Action::ProfilePrev),
KeyCode::Down | KeyCode::Tab => Some(Action::ProfileNext),
KeyCode::Home => Some(Action::Home),
KeyCode::End => Some(Action::End),
KeyCode::Char(c) if !ctrl && is_safe_name_char(c) => Some(Action::Char(c)),
@ -346,14 +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(),
overlay_toml: overlay_toml.to_string(),
cwd,
profile: form.selected_profile_selector(),
resume_scope: form.resume_scope.clone(),
cwd: form.cwd.clone(),
resume_from: form.resume_from,
resume_by_pod_name: form.resume_by_pod_name,
};
@ -368,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(
@ -425,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,
@ -459,9 +454,14 @@ 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
/// restore Pod state rather than re-evaluate a profile source.
profile_choices: Vec<ProfileChoice>,
profile_index: usize,
}
impl Form {
@ -504,6 +504,37 @@ impl Form {
}
}
fn selected_profile(&self) -> Option<&ProfileChoice> {
self.profile_choices
.get(self.profile_index)
.filter(|choice| choice.selector.is_some())
}
fn selected_profile_selector(&self) -> Option<String> {
self.selected_profile()
.and_then(|choice| choice.selector.clone())
}
fn cycle_profile_next(&mut self) {
if self.profile_choices.is_empty() {
return;
}
self.profile_index = (self.profile_index + 1) % self.profile_choices.len();
self.message = None;
}
fn cycle_profile_prev(&mut self) {
if self.profile_choices.is_empty() {
return;
}
self.profile_index = if self.profile_index == 0 {
self.profile_choices.len() - 1
} else {
self.profile_index - 1
};
self.message = None;
}
fn char_offset_to_byte(&self, char_off: usize) -> usize {
self.name
.char_indices()
@ -518,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
@ -573,37 +604,43 @@ fn name_line(form: &Form) -> Line<'_> {
}
fn context_line(form: &Form) -> Line<'_> {
match form.scope_origin {
ScopeOrigin::FromProject => Line::from(vec![
if let Some(profile) = form.profile_choices.get(form.profile_index) {
return 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![
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(profile.label.as_str(), Style::default().fg(Color::Green)),
Span::styled(
" (tab/down to change)",
Style::default().fg(Color::DarkGray),
),
]);
}
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)),
]),
}
}
fn hint_line() -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled("[enter]", Style::default().fg(Color::Green)),
Span::raw(" spawn "),
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
Span::raw(" cancel"),
])
Line::from(vec![Span::styled(
" enter spawn · tab/down next profile · shift-tab/up prev · esc cancel",
Style::default().fg(Color::DarkGray),
)])
}
fn message_line(form: &Form) -> Line<'_> {
@ -623,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,
@ -639,6 +671,8 @@ mod tests {
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
profile_choices: Vec::new(),
profile_index: 0,
}
}
@ -646,9 +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::new(),
};
let f = form_for_pod_name("agent".to_string(), defaults);
@ -665,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 {
@ -701,57 +715,111 @@ 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"));
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]
fn cascade_merge_detects_scope_from_any_layer() {
let user = PodManifestConfig::from_toml(
fn profile_choices_use_project_registry_default() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let insomnia = project.join(".insomnia");
std::fs::create_dir_all(&insomnia).unwrap();
std::fs::write(
insomnia.join("profiles.toml"),
r#"
[[scope.allow]]
target = "/from-user"
permission = "write"
default = "coder"
[profile]
coder = "profiles/coder.nix"
"#,
)
.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());
let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(default_index, 1);
let selected = &choices[default_index];
assert_eq!(selected.selector.as_deref(), Some("project:coder"));
assert_eq!(selected.label, "project:coder (default)");
assert!(selected.is_default);
}
#[test]
fn user_manifest_path_for_spawn_prefers_non_empty_env_override() {
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");
std::fs::create_dir_all(&insomnia).unwrap();
std::fs::write(
insomnia.join("profiles.toml"),
r#"
default = "coder"
[profile.coder]
path = "profiles/coder.nix"
description = "Project coder"
"#,
)
.unwrap();
let (choices, default_index) = profile_choices_for_cwd(&project);
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_only_discovered_profiles() {
let mut form = form("coder");
form.profile_choices = vec![
ProfileChoice {
selector: Some("project:coder".to_string()),
label: "project:coder (default)".to_string(),
is_default: true,
},
ProfileChoice {
selector: Some("user:reviewer".to_string()),
label: "user:reviewer".to_string(),
is_default: false,
},
];
form.profile_index = 0;
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")),
form.selected_profile_selector().as_deref(),
Some("project:coder")
);
form.cycle_profile_next();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("user:reviewer")
);
form.cycle_profile_next();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("project:coder")
);
form.cycle_profile_prev();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("user:reviewer")
);
}
#[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")),
);
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
let mut choices = Vec::new();
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
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 とプロンプト資産

98
docs/manifest-profiles.md Normal file
View File

@ -0,0 +1,98 @@
# Manifest profiles
Manifest profiles are the human-authored Nix entrypoint for generating an Insomnia runtime manifest. The Rust side evaluates a selected profile with `nix eval --json --file <path>`, deserializes the resulting JSON artifact, and validates it through the existing `PodManifest` pipeline.
This keeps composition/import/common logic in Nix. Insomnia does not add an implicit profile cascade or merge TOML profile layers into the selected runtime manifest.
## Minimal profile
```nix
let
insomnia = import ./resources/nix/profile-lib.nix {};
in
insomnia.mkProfile {
name = "coder";
description = "Example coding Pod";
manifest = insomnia.mkManifest {
pod.name = "coder";
model = {
scheme = "anthropic";
model_id = "claude-sonnet-4-20250514";
auth = insomnia.secrets.ref "llm.anthropic.default";
};
scope.allow = [
{ target = "."; permission = "write"; }
];
};
}
```
Run an explicit path with:
```sh
insomnia-pod --profile ./coder.nix
# or through the TUI fresh-spawn dialog
insomnia --profile ./coder.nix
```
`--profile` accepts an explicit path, `path:<path>`, a discovered profile name, `default`, or a source-qualified name such as `project:coder`, `user:coder`, or `builtin:coder`. Path-like values containing `/`, starting with `.`, or ending in `.nix` preserve the original explicit-path behavior.
`--profile` conflicts with `insomnia-pod --manifest` and with restore/session/adopt modes. Use `--profile-pod-name <name>` when a launcher needs a creation-time Pod name override without invoking `--pod` restore semantics. Profile evaluation is a creation-time path; Pod resume restores saved Pod state/resolved snapshots rather than re-evaluating the Nix source.
## Profile discovery
Profile discovery is separate from runtime manifest merging. User/project `profiles.toml` files may declare profile registry metadata, but those files are application/project UX configuration and are not merged into the Nix profile artifact.
Example project config at `.insomnia/profiles.toml`:
```toml
default = "coder"
[profile]
coder = "profiles/coder.nix"
reviewer = "profiles/reviewer.nix"
```
Table entries can carry descriptions:
```toml
[profile.coder]
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`. 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
insomnia --profile project:coder # source-qualified selection
insomnia --profile default # selected registry default
```
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
A profile should evaluate to one of:
- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; manifest = { ... }; }`
- `{ profile = { format = "insomnia.nix-profile.v1"; ... }; config = { ... }; }`
- a raw manifest/config object for debug/test paths.
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:
```json
{ "kind": "secret_ref", "ref": "llm.anthropic.default" }
```
The encrypted secret store is intentionally not implemented by this profile foundation; attempting to use a `secret_ref` as a live provider credential currently fails with a clear diagnostic at provider construction time.

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,67 @@
# Insomnia Nix profile helpers.
#
# A profile file can use:
#
# let insomnia = import ./path/to/profile-lib.nix {};
# in insomnia.mkProfile {
# name = "coder";
# manifest = insomnia.mkManifest { ... };
# }
#
# The output is consumed by `insomnia-pod --profile <path>` via
# `nix eval --json --file <path>`.
{ }:
let
profileFormat = "insomnia.nix-profile.v1";
optional = name: value:
if value == null then {} else { ${name} = value; };
secretRef = ref: {
kind = "secret_ref";
inherit ref;
};
mkManifest = manifest: manifest;
mkProfile =
{ name ? null
, description ? null
, manifest ? null
, config ? null
, ...
}@args:
let
resolvedManifest =
if manifest != null then manifest
else if config != null then config
else removeAttrs args [ "name" "description" "manifest" "config" ];
in
{
profile = ({ format = profileFormat; }
// optional "name" name
// optional "description" description);
manifest = resolvedManifest;
};
semanticPresets = {
# Skeleton for users to extend in their own Nix. Rust does not attach any
# hidden semantic meaning to these helpers; they only generate manifest JSON.
codingAssistant = { modelId ? "claude-sonnet-4-20250514", authRef ? null }:
{
model = {
scheme = "anthropic";
model_id = modelId;
} // (if authRef == null then {} else { auth = secretRef authRef; });
};
};
in
{
inherit profileFormat mkProfile mkManifest semanticPresets;
secrets = {
ref = secretRef;
};
}

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";
};
};
};
}

View File

@ -2,12 +2,12 @@
id: 20260527-000022-manifest-profiles
slug: manifest-profiles
title: Nix profile entrypoints that resolve to portable Pod manifests
status: open
status: closed
kind: feature
priority: P2
labels: [manifest, profiles, nix, tui]
created_at: 2026-05-27T00:00:22Z
updated_at: 2026-05-29T15:55:00Z
updated_at: 2026-05-29T17:45:59Z
assignee: null
legacy_ticket: null
---

View File

@ -0,0 +1,116 @@
---
id: 20260527-000022-manifest-profiles
slug: manifest-profiles
title: Nix profile entrypoints that resolve to portable Pod manifests
status: closed
kind: feature
priority: P2
labels: [manifest, profiles, nix, tui]
created_at: 2026-05-27T00:00:22Z
updated_at: 2026-05-29T17:45:59Z
assignee: null
legacy_ticket: null
---
## Migration reference
- legacy_ticket: null
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
# Nix profile entrypoints that resolve to portable Pod manifests
## Background
This work item was migrated from an unfinished TODO.md entry:
> 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
The current manifest cascade is good at configuration defaults by location: built-in defaults, user manifest, workspace manifest, and explicit overlays. That is less suitable for operational role selection. Users want to choose between profiles such as Orchestrator, Coder, Researcher, Reviewer, or cheap/fast variants, and they want those profiles to be portable as a pure artifact rather than assembled implicitly from several ambient layers.
Another problem is authoring ergonomics. The current manifest exposes many low-level numeric parameters that require implementation-specific intuition, such as compaction thresholds, pruning protection sizes, memory thresholds, and feature-specific token limits. Profiles should let users express high-level intent and reusable presets while the resolver produces the precise runtime manifest.
## Related work
- `work-items/open/20260529-145355-manifest-profile-encrypted-secrets/item.md`: profiles should integrate with explicit encrypted secret references so API keys/tokens are not limited to process environment variables.
## Design direction
Use Nix as the default human-authored profile format. A profile is a Nix expression that produces the final Pod manifest/configuration artifact through an Insomnia-provided `mkProfile` / `mkManifest` style library.
The profile itself is the source of truth. Commonality, imports, role presets, and any cascade-like behavior should be expressed in Nix by the profile author instead of being implemented as an additional ambient manifest cascade in Insomnia.
The runtime boundary should be:
```text
selected Nix profile + explicit startup inputs
=> deterministic resolved manifest/config snapshot
=> Pod runtime
```
Do not introduce a three-layer authoring model where Nix generates TOML profiles that then merge into TOML manifests. That would make manifest/profile/Nix ownership unclear and hard to operate. Rust should consume the resolved artifact, ideally as a typed JSON/config representation, and preserve a snapshot for Pod restore.
## Requirements
- Add a Nix-based profile entrypoint as the default path for new Pod creation.
- Provide an Insomnia Nix library with `mkProfile` / `mkManifest` helpers.
- The helper should produce a pure resolved manifest/config artifact that Rust can deserialize and validate.
- Profile authors may use Nix imports/functions to share common settings, implement their own cascade, or build role presets.
- Treat the resolved manifest/config as the runtime contract.
- Persist the selected profile identity/source and the resolved snapshot in Pod/session metadata.
- Pod resume should prefer the saved resolved snapshot, not silently re-evaluate the Nix profile.
- Re-evaluating a profile for an existing Pod must be explicit because it may change model, tools, permissions, or thresholds.
- Move role-oriented authoring into profiles.
- Support profiles for roles such as Orchestrator, Coder, Researcher, Reviewer, and cost/performance variants.
- Profiles should be able to select model/provider settings, prompts, tools, permissions, memory behavior, web/search behavior, workflows, skills, and context/compaction strategy.
- Prefer semantic presets in the Nix library for values that are difficult to tune by raw numbers, e.g. context budget, compaction behavior, retention, autonomy, and tool policy.
- Keep raw low-level numeric overrides available as an advanced escape hatch, not the primary user-facing interface.
- Shrink ambient cascade to discovery/default selection rather than runtime config merging.
- User/project configuration may provide profile registries, aliases, defaults, and UI preferences.
- User/project configuration should not be required as intermediate runtime override layers for model IDs, compaction thresholds, or other behavior controlled by the selected profile.
- Existing TOML manifest cascade can remain as compatibility/debug/test infrastructure, but it should not be the main profile design.
- Add profile discovery and selection UX.
- New Pod creation UI should show a selectable profile field such as `profile: coder (default)`.
- The profile picker should list built-in/user/project/explicit profiles with enough source/default information to avoid ambiguity.
- CLI/TUI should support explicit profile selection by name/source and by path/flakeref where appropriate.
- Ambiguous profile names should fail closed or require source-qualified selection rather than being implicitly merged.
- Keep secrets as references, not plaintext values.
- Nix profiles may refer to credentials using typed secret references, e.g. `secrets.ref "brave.search.default"`.
- Nix evaluation output, resolved config serialization, diagnostics, session logs, and model context must not contain plaintext secrets.
- Secret dereferencing/decryption happens in Rust at the consumer boundary.
- Define compatibility and fallback behavior.
- `--manifest` / TOML manifest loading may continue to work for compatibility, tests, fixtures, and low-level debugging.
- If Nix is unavailable, diagnostics should clearly say that profile resolution requires Nix and point to the manifest/resolved-config fallback path.
- Existing manifest behavior should not be broken until the Nix profile path is implemented and documented.
## Open design points
- Exact Nix entrypoint shape:
- flake output names, e.g. `insomniaProfiles.<name>` / `profiles.<name>`
- path-based profiles, e.g. `.insomnia/profiles/coder/profile.nix`
- whether both are supported initially
- Exact Rust-facing artifact:
- JSON resolved config vs TOML manifest snapshot vs a new typed `ResolvedPodConfig`
- whether `PodManifest` remains the final runtime type or becomes the legacy/compatibility representation
- Profile registry/default storage:
- where user-level profile aliases live
- where project-level defaults live
- how built-in profiles are exposed
- How much Nix support is external-command based initially vs embedded/library-integrated later.
- How profile summaries are generated for the new Pod UI without exposing low-level internals or secrets.
## Acceptance criteria
- A Nix profile can be selected when creating a new Pod and resolves to the complete runtime manifest/config for that Pod.
- Insomnia provides a documented `mkProfile` / `mkManifest` Nix helper for producing a valid resolved profile artifact.
- Profile authors can share common settings and implement cascade-like composition in Nix without relying on ambient user/project manifest merging.
- New Pod UI includes profile selection and displays the effective default, e.g. `profile: coder (default)`.
- CLI/TUI profile selection supports at least one explicit path/flakeref flow and one discovered-name/default flow.
- Resolved profile artifacts are validated with clear diagnostics before Pod creation.
- Pod/session metadata persists the selected profile identity/source and the resolved snapshot.
- Pod resume uses the persisted resolved snapshot unless the user explicitly asks to reload/re-resolve the profile.
- Secret references are preserved as references through Nix evaluation and resolved config; plaintext secrets are not written to config snapshots, logs, diagnostics, or model context.
- Existing TOML manifest path remains available as a compatibility/debug/test path during the migration.
- Documentation explains the new profile model, why ambient cascade is no longer the primary runtime config mechanism, and how users should structure reusable Nix profiles.
- Focused tests cover Nix profile resolution, validation errors, profile default/source selection, ambiguity handling, snapshot persistence, and no-plaintext secret serialization paths.
- `cargo fmt --check`
- Relevant manifest/profile/pod/tui tests pass.

View File

@ -0,0 +1,250 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:22Z -->
## Migrated
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
---
<!-- event: plan author: hare at: 2026-05-29T16:09:27Z -->
## Plan
Implementation will proceed through a child orchestrator Pod in a dedicated worktree as an experiment in nested Pod delegation.
Initial implementation target:
- Introduce Nix profile resolution as a new manifest source before the existing manifest cascade.
- Start with explicit path-based profiles; discovered-name/default selection and rich TUI picker can be staged after the core resolver if necessary.
- Provide a minimal bundled Nix helper that can produce a typed resolved manifest/config artifact.
- Keep existing TOML manifest loading as compatibility/debug/test infrastructure.
- Persist enough profile identity and resolved snapshot data for future restore semantics; do not silently re-evaluate profiles on resume.
- Secret values must remain references only; plaintext secrets are out of scope for the profile resolver.
The child orchestrator may split implementation among sub-Pods, but final merge/close remains parent-side.
---
<!-- event: review author: hare at: 2026-05-29T16:52:47Z status: approve -->
## Review: approve
Reviewed the nested Pod implementation from branch `work/nix-manifest-profiles`.
Result: approved after blocking fix.
Findings:
- Initial review found one blocking issue: `--profile` rejected non-empty `INSOMNIA_USER_MANIFEST`. That contradicted the profile design because a selected Nix profile is a standalone runtime config artifact and ambient user/project manifest cascade should not affect it.
- The child orchestrator fixed this in commit `c9a175a fix: ignore user manifest for profiles` and added `profile_ignores_non_empty_user_manifest_env` without invoking real Nix.
- Profile foundation is intentionally a vertical slice: explicit path-based Nix profile resolution, minimal Nix helper, CLI/TUI spawn entrypoints, resolved snapshot metadata, and restore-from-snapshot behavior. Rich discovery/default picker remains future work.
Validation run by reviewer:
- `cargo fmt --check`
- `cargo test -p manifest profile -- --nocapture`
- `cargo test -p pod --bin insomnia-pod profile -- --nocapture`
- `cargo check -p session-store -p manifest -p pod -p client -p tui -p provider`
- `cargo check -p pod -p tui`
- `git diff --check`
- Manual `nix eval --json --file` smoke check for `resources/nix/profile-lib.nix`
Non-blocking follow-up candidates:
- Hide or narrow `ResolvedProfile::raw_artifact` if future call sites might log/persist accidental raw Nix output.
- Add a timeout around `nix eval` so profile startup cannot hang indefinitely.
- Validate direct `client::SpawnConfig` construction that combines `profile_path` with `resume_from`; TUI currently avoids it.
- Build richer profile discovery/default selection and the full TUI profile picker.
---
<!-- event: plan author: hare at: 2026-05-29T16:59:41Z -->
## Plan
Continue implementation beyond the merged Nix profile foundation. The previous nested delegation stopped at a reviewable vertical slice; this phase should keep iterating until the work item acceptance criteria are materially closer to completion or a real blocker is found.
Phase 2 target:
- Add profile discovery/default selection instead of requiring only explicit `--profile <path>`.
- Support discovered-name selection with source disambiguation where needed, e.g. builtin/user/project/path semantics.
- Add or substantially advance the new Pod profile selection UX so a default can be displayed as `profile: coder (default)` and changed before spawn.
- Preserve the core design: selected Nix profile resolves to a standalone manifest/config artifact; ambient user/project manifests do not merge into it.
- Keep Pod resume using resolved snapshots rather than silent Nix re-evaluation.
- Keep secrets as references only.
The delegated orchestrator should not return merely because one slice is reviewable. It should internally review acceptance criteria, delegate sub-Pods as needed, and continue with the next reachable slice unless it hits a concrete design or technical blocker. Parent-side merge/close remains reserved for this parent Pod.
---
<!-- event: review author: hare at: 2026-05-29T17:44:47Z status: approve -->
## Review: approve
Reviewed phase 2 implementation from branch `work/nix-manifest-profiles-phase2`.
Result: approved after two parent-review fixes.
Implemented scope:
- Profile registry/discovery for builtin/user/project sources.
- `[profiles]` metadata in user/project manifests for discovery/default/alias only; it is not merged into the selected runtime manifest.
- `--profile` selector parsing for explicit paths, `path:<path>`, discovered names, `default`, and source-qualified names such as `project:coder`.
- Ambiguous unqualified discovered names fail closed.
- TUI fresh-spawn UI now shows a selectable `profile:` row, uses discovered choices, marks defaults, and includes `manifest cascade` as opt-out.
- SpawnConfig passes selected profiles to `insomnia-pod --profile`; resume/attach paths do not re-evaluate profiles.
- Docs and focused tests updated.
Parent review findings fixed by child orchestrator:
1. Unqualified alias targets initially resolved globally. Fixed so aliases declared in a source resolve unqualified targets within that declaring source by default.
2. Defaults pointing at aliases initially did not mark the resolved target entry as default, causing TUI to fall back to `manifest cascade`. Fixed by resolving the default through `select_named()` before setting `is_default` flags.
Validation run by parent reviewer:
- `cargo fmt --check`
- `cargo check`
- `cargo test -p manifest profile -- --nocapture`
- `cargo test -p tui spawn -- --nocapture`
- `cargo test -p pod profile -- --nocapture`
- `cargo test -p client spawn -- --nocapture`
- `git diff --check`
All passed. Full `cargo test` was run by the child orchestrator and failed only in the unrelated existing/flaky `llm-worker` parallel timing test class.
Remaining polish/follow-up candidates, not blockers for this work item:
- A richer popup-style profile picker instead of inline cycling.
- Actual bundled builtin profile files once default builtin semantics are decided.
- `nix eval` timeout/robustness follow-up.
- Encrypted secret store integration remains tracked by the related encrypted-secrets work item.
---
<!-- event: close author: hare at: 2026-05-29T17:45:59Z status: closed -->
## Closed
---
id: 20260527-000022-manifest-profiles
slug: manifest-profiles
title: Nix profile entrypoints that resolve to portable Pod manifests
status: closed
kind: feature
priority: P2
labels: [manifest, profiles, nix, tui]
created_at: 2026-05-27T00:00:22Z
updated_at: 2026-05-29T17:45:59Z
assignee: null
legacy_ticket: null
---
## Migration reference
- legacy_ticket: null
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
# Nix profile entrypoints that resolve to portable Pod manifests
## Background
This work item was migrated from an unfinished TODO.md entry:
> 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
The current manifest cascade is good at configuration defaults by location: built-in defaults, user manifest, workspace manifest, and explicit overlays. That is less suitable for operational role selection. Users want to choose between profiles such as Orchestrator, Coder, Researcher, Reviewer, or cheap/fast variants, and they want those profiles to be portable as a pure artifact rather than assembled implicitly from several ambient layers.
Another problem is authoring ergonomics. The current manifest exposes many low-level numeric parameters that require implementation-specific intuition, such as compaction thresholds, pruning protection sizes, memory thresholds, and feature-specific token limits. Profiles should let users express high-level intent and reusable presets while the resolver produces the precise runtime manifest.
## Related work
- `work-items/open/20260529-145355-manifest-profile-encrypted-secrets/item.md`: profiles should integrate with explicit encrypted secret references so API keys/tokens are not limited to process environment variables.
## Design direction
Use Nix as the default human-authored profile format. A profile is a Nix expression that produces the final Pod manifest/configuration artifact through an Insomnia-provided `mkProfile` / `mkManifest` style library.
The profile itself is the source of truth. Commonality, imports, role presets, and any cascade-like behavior should be expressed in Nix by the profile author instead of being implemented as an additional ambient manifest cascade in Insomnia.
The runtime boundary should be:
```text
selected Nix profile + explicit startup inputs
=> deterministic resolved manifest/config snapshot
=> Pod runtime
```
Do not introduce a three-layer authoring model where Nix generates TOML profiles that then merge into TOML manifests. That would make manifest/profile/Nix ownership unclear and hard to operate. Rust should consume the resolved artifact, ideally as a typed JSON/config representation, and preserve a snapshot for Pod restore.
## Requirements
- Add a Nix-based profile entrypoint as the default path for new Pod creation.
- Provide an Insomnia Nix library with `mkProfile` / `mkManifest` helpers.
- The helper should produce a pure resolved manifest/config artifact that Rust can deserialize and validate.
- Profile authors may use Nix imports/functions to share common settings, implement their own cascade, or build role presets.
- Treat the resolved manifest/config as the runtime contract.
- Persist the selected profile identity/source and the resolved snapshot in Pod/session metadata.
- Pod resume should prefer the saved resolved snapshot, not silently re-evaluate the Nix profile.
- Re-evaluating a profile for an existing Pod must be explicit because it may change model, tools, permissions, or thresholds.
- Move role-oriented authoring into profiles.
- Support profiles for roles such as Orchestrator, Coder, Researcher, Reviewer, and cost/performance variants.
- Profiles should be able to select model/provider settings, prompts, tools, permissions, memory behavior, web/search behavior, workflows, skills, and context/compaction strategy.
- Prefer semantic presets in the Nix library for values that are difficult to tune by raw numbers, e.g. context budget, compaction behavior, retention, autonomy, and tool policy.
- Keep raw low-level numeric overrides available as an advanced escape hatch, not the primary user-facing interface.
- Shrink ambient cascade to discovery/default selection rather than runtime config merging.
- User/project configuration may provide profile registries, aliases, defaults, and UI preferences.
- User/project configuration should not be required as intermediate runtime override layers for model IDs, compaction thresholds, or other behavior controlled by the selected profile.
- Existing TOML manifest cascade can remain as compatibility/debug/test infrastructure, but it should not be the main profile design.
- Add profile discovery and selection UX.
- New Pod creation UI should show a selectable profile field such as `profile: coder (default)`.
- The profile picker should list built-in/user/project/explicit profiles with enough source/default information to avoid ambiguity.
- CLI/TUI should support explicit profile selection by name/source and by path/flakeref where appropriate.
- Ambiguous profile names should fail closed or require source-qualified selection rather than being implicitly merged.
- Keep secrets as references, not plaintext values.
- Nix profiles may refer to credentials using typed secret references, e.g. `secrets.ref "brave.search.default"`.
- Nix evaluation output, resolved config serialization, diagnostics, session logs, and model context must not contain plaintext secrets.
- Secret dereferencing/decryption happens in Rust at the consumer boundary.
- Define compatibility and fallback behavior.
- `--manifest` / TOML manifest loading may continue to work for compatibility, tests, fixtures, and low-level debugging.
- If Nix is unavailable, diagnostics should clearly say that profile resolution requires Nix and point to the manifest/resolved-config fallback path.
- Existing manifest behavior should not be broken until the Nix profile path is implemented and documented.
## Open design points
- Exact Nix entrypoint shape:
- flake output names, e.g. `insomniaProfiles.<name>` / `profiles.<name>`
- path-based profiles, e.g. `.insomnia/profiles/coder/profile.nix`
- whether both are supported initially
- Exact Rust-facing artifact:
- JSON resolved config vs TOML manifest snapshot vs a new typed `ResolvedPodConfig`
- whether `PodManifest` remains the final runtime type or becomes the legacy/compatibility representation
- Profile registry/default storage:
- where user-level profile aliases live
- where project-level defaults live
- how built-in profiles are exposed
- How much Nix support is external-command based initially vs embedded/library-integrated later.
- How profile summaries are generated for the new Pod UI without exposing low-level internals or secrets.
## Acceptance criteria
- A Nix profile can be selected when creating a new Pod and resolves to the complete runtime manifest/config for that Pod.
- Insomnia provides a documented `mkProfile` / `mkManifest` Nix helper for producing a valid resolved profile artifact.
- Profile authors can share common settings and implement cascade-like composition in Nix without relying on ambient user/project manifest merging.
- New Pod UI includes profile selection and displays the effective default, e.g. `profile: coder (default)`.
- CLI/TUI profile selection supports at least one explicit path/flakeref flow and one discovered-name/default flow.
- Resolved profile artifacts are validated with clear diagnostics before Pod creation.
- Pod/session metadata persists the selected profile identity/source and the resolved snapshot.
- Pod resume uses the persisted resolved snapshot unless the user explicitly asks to reload/re-resolve the profile.
- Secret references are preserved as references through Nix evaluation and resolved config; plaintext secrets are not written to config snapshots, logs, diagnostics, or model context.
- Existing TOML manifest path remains available as a compatibility/debug/test path during the migration.
- Documentation explains the new profile model, why ambient cascade is no longer the primary runtime config mechanism, and how users should structure reusable Nix profiles.
- Focused tests cover Nix profile resolution, validation errors, profile default/source selection, ambiguity handling, snapshot persistence, and no-plaintext secret serialization paths.
- `cargo fmt --check`
- Relevant manifest/profile/pod/tui tests pass.
---

View File

@ -0,0 +1,60 @@
---
id: 20260529-171326-pod-socket-peer-disconnect-noise
slug: pod-socket-peer-disconnect-noise
title: Treat Pod socket peer disconnects as normal connection close
status: closed
kind: bug
priority: P1
labels: [pod, ipc, tui, noise]
created_at: 2026-05-29T17:13:26Z
updated_at: 2026-05-29T17:26:50Z
assignee: null
legacy_ticket: null
---
## Background
The active `insomnia` TUI session frequently shows notices like:
```text
[notice error] pod: [Internal] invalid method: Connection reset by peer (os error 104)
```
The message is misleading. The Pod IPC server reports every `reader.next::<Method>()` error as an `Event::Error` with `invalid method: ...`. A peer disconnect/reset from a short-lived socket client or status probe is a normal connection lifecycle event, not an invalid method sent by a client.
This becomes noisy during orchestration, multi-Pod polling, attach/picker refreshes, and diagnostic tooling because many clients connect briefly to read initial `Alert` / `Snapshot` traffic and then close. Depending on timing, the server can observe that close as `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, or EOF-like errors. Those must not be broadcast as user-visible Pod errors.
Likely code path:
- `crates/pod/src/ipc/server.rs`: `handle_connection`, `method = reader.next::<Method>()`
- `crates/protocol/src/stream.rs`: JSON line reader returns I/O errors
- TUI displays broadcast `Event::Error` through notice/error surfaces
## Requirements
- Classify normal peer disconnects while reading client `Method` values as connection close, not invalid method.
- At minimum handle `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and EOF-like cases appropriately.
- The handler should break/return for those cases without broadcasting `Event::Error`.
- Preserve diagnostics for genuinely invalid client input.
- Malformed JSON or schema-invalid `Method` payloads should still produce a clear error.
- Prefer sending the protocol error to the offending connection only if the current IPC shape makes that reasonable; do not unnecessarily broadcast connection-local parse errors to unrelated TUI clients.
- Keep normal socket behavior intact.
- Initial `Alert` and `Snapshot` delivery must still work.
- Long-lived TUI attach clients must still receive live events.
- Short-lived probe clients may connect, read enough state, and drop without generating user-visible errors.
- Avoid hiding real server failures.
- Only expected peer disconnect/read-close errors should be silenced.
- Unexpected I/O or parse errors should remain observable through logs or explicit error events as appropriate.
- Add focused tests.
- A client that connects and resets/closes without sending a Method should not create an `Event::Error` / notice.
- A malformed Method line should still be treated as invalid input.
- Existing controller/IPC tests should continue to pass.
## Acceptance criteria
- The `[Internal] invalid method: Connection reset by peer` notice no longer appears when short-lived Pod socket clients/probes disconnect.
- Normal disconnect/reset/abort/broken-pipe while reading a Method closes only that connection and is not broadcast to all clients.
- Invalid JSON or schema-invalid Method input still produces a clear diagnostic.
- Tests cover peer disconnect handling and invalid-method handling.
- `cargo fmt --check`
- Relevant pod/protocol/tui IPC tests pass.

View File

@ -0,0 +1,60 @@
---
id: 20260529-171326-pod-socket-peer-disconnect-noise
slug: pod-socket-peer-disconnect-noise
title: Treat Pod socket peer disconnects as normal connection close
status: closed
kind: bug
priority: P1
labels: [pod, ipc, tui, noise]
created_at: 2026-05-29T17:13:26Z
updated_at: 2026-05-29T17:26:50Z
assignee: null
legacy_ticket: null
---
## Background
The active `insomnia` TUI session frequently shows notices like:
```text
[notice error] pod: [Internal] invalid method: Connection reset by peer (os error 104)
```
The message is misleading. The Pod IPC server reports every `reader.next::<Method>()` error as an `Event::Error` with `invalid method: ...`. A peer disconnect/reset from a short-lived socket client or status probe is a normal connection lifecycle event, not an invalid method sent by a client.
This becomes noisy during orchestration, multi-Pod polling, attach/picker refreshes, and diagnostic tooling because many clients connect briefly to read initial `Alert` / `Snapshot` traffic and then close. Depending on timing, the server can observe that close as `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, or EOF-like errors. Those must not be broadcast as user-visible Pod errors.
Likely code path:
- `crates/pod/src/ipc/server.rs`: `handle_connection`, `method = reader.next::<Method>()`
- `crates/protocol/src/stream.rs`: JSON line reader returns I/O errors
- TUI displays broadcast `Event::Error` through notice/error surfaces
## Requirements
- Classify normal peer disconnects while reading client `Method` values as connection close, not invalid method.
- At minimum handle `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and EOF-like cases appropriately.
- The handler should break/return for those cases without broadcasting `Event::Error`.
- Preserve diagnostics for genuinely invalid client input.
- Malformed JSON or schema-invalid `Method` payloads should still produce a clear error.
- Prefer sending the protocol error to the offending connection only if the current IPC shape makes that reasonable; do not unnecessarily broadcast connection-local parse errors to unrelated TUI clients.
- Keep normal socket behavior intact.
- Initial `Alert` and `Snapshot` delivery must still work.
- Long-lived TUI attach clients must still receive live events.
- Short-lived probe clients may connect, read enough state, and drop without generating user-visible errors.
- Avoid hiding real server failures.
- Only expected peer disconnect/read-close errors should be silenced.
- Unexpected I/O or parse errors should remain observable through logs or explicit error events as appropriate.
- Add focused tests.
- A client that connects and resets/closes without sending a Method should not create an `Event::Error` / notice.
- A malformed Method line should still be treated as invalid input.
- Existing controller/IPC tests should continue to pass.
## Acceptance criteria
- The `[Internal] invalid method: Connection reset by peer` notice no longer appears when short-lived Pod socket clients/probes disconnect.
- Normal disconnect/reset/abort/broken-pipe while reading a Method closes only that connection and is not broadcast to all clients.
- Invalid JSON or schema-invalid Method input still produces a clear diagnostic.
- Tests cover peer disconnect handling and invalid-method handling.
- `cargo fmt --check`
- Relevant pod/protocol/tui IPC tests pass.

View File

@ -0,0 +1,108 @@
<!-- event: create author: tickets.sh at: 2026-05-29T17:13:26Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: hare at: 2026-05-29T17:26:19Z status: approve -->
## Review: approve
Reviewed implementation commit `d5d50a3 fix: suppress pod socket peer disconnect noise` from branch `work/pod-socket-peer-disconnect-noise`.
Result: approved.
The implementation correctly separates normal peer disconnect read errors from invalid method payloads in `crates/pod/src/ipc/server.rs`:
- `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and `UnexpectedEof` now close only the affected connection.
- Those peer disconnects no longer broadcast `Event::Error` to unrelated clients.
- malformed/schema-invalid Method lines still return a clear `InvalidRequest` error to the offending connection.
Focused tests cover both paths:
- `peer_disconnect_read_errors_are_connection_close`
- `invalid_data_is_not_peer_disconnect`
- `socket_schema_invalid_method_returns_error`
- `socket_malformed_method_returns_error`
- `socket_peer_close_without_method_does_not_broadcast_error`
Validation reported by implementer:
- `cargo fmt --check` passed
- `cargo test -p pod ipc::server -- --nocapture` passed
- `cargo test -p pod --test controller_test socket_ -- --nocapture` passed
Full `cargo test -p pod --test controller_test -- --nocapture` still has unrelated empty-turn rollback failures; the new socket tests passed within that run and the failing area is unrelated to this IPC disconnect change.
---
<!-- event: close author: hare at: 2026-05-29T17:26:50Z status: closed -->
## Closed
---
id: 20260529-171326-pod-socket-peer-disconnect-noise
slug: pod-socket-peer-disconnect-noise
title: Treat Pod socket peer disconnects as normal connection close
status: closed
kind: bug
priority: P1
labels: [pod, ipc, tui, noise]
created_at: 2026-05-29T17:13:26Z
updated_at: 2026-05-29T17:26:50Z
assignee: null
legacy_ticket: null
---
## Background
The active `insomnia` TUI session frequently shows notices like:
```text
[notice error] pod: [Internal] invalid method: Connection reset by peer (os error 104)
```
The message is misleading. The Pod IPC server reports every `reader.next::<Method>()` error as an `Event::Error` with `invalid method: ...`. A peer disconnect/reset from a short-lived socket client or status probe is a normal connection lifecycle event, not an invalid method sent by a client.
This becomes noisy during orchestration, multi-Pod polling, attach/picker refreshes, and diagnostic tooling because many clients connect briefly to read initial `Alert` / `Snapshot` traffic and then close. Depending on timing, the server can observe that close as `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, or EOF-like errors. Those must not be broadcast as user-visible Pod errors.
Likely code path:
- `crates/pod/src/ipc/server.rs`: `handle_connection`, `method = reader.next::<Method>()`
- `crates/protocol/src/stream.rs`: JSON line reader returns I/O errors
- TUI displays broadcast `Event::Error` through notice/error surfaces
## Requirements
- Classify normal peer disconnects while reading client `Method` values as connection close, not invalid method.
- At minimum handle `ConnectionReset`, `ConnectionAborted`, `BrokenPipe`, and EOF-like cases appropriately.
- The handler should break/return for those cases without broadcasting `Event::Error`.
- Preserve diagnostics for genuinely invalid client input.
- Malformed JSON or schema-invalid `Method` payloads should still produce a clear error.
- Prefer sending the protocol error to the offending connection only if the current IPC shape makes that reasonable; do not unnecessarily broadcast connection-local parse errors to unrelated TUI clients.
- Keep normal socket behavior intact.
- Initial `Alert` and `Snapshot` delivery must still work.
- Long-lived TUI attach clients must still receive live events.
- Short-lived probe clients may connect, read enough state, and drop without generating user-visible errors.
- Avoid hiding real server failures.
- Only expected peer disconnect/read-close errors should be silenced.
- Unexpected I/O or parse errors should remain observable through logs or explicit error events as appropriate.
- Add focused tests.
- A client that connects and resets/closes without sending a Method should not create an `Event::Error` / notice.
- A malformed Method line should still be treated as invalid input.
- Existing controller/IPC tests should continue to pass.
## Acceptance criteria
- The `[Internal] invalid method: Connection reset by peer` notice no longer appears when short-lived Pod socket clients/probes disconnect.
- Normal disconnect/reset/abort/broken-pipe while reading a Method closes only that connection and is not broadcast to all clients.
- Invalid JSON or schema-invalid Method input still produces a clear diagnostic.
- Tests cover peer disconnect handling and invalid-method handling.
- `cargo fmt --check`
- Relevant pod/protocol/tui IPC tests pass.
---

View File

@ -0,0 +1,69 @@
---
id: 20260529-180257-profile-registry-config-file
slug: profile-registry-config-file
title: Move profile registry out of manifest files
status: closed
kind: bug
priority: P1
labels: [manifest, profiles, config]
created_at: 2026-05-29T18:02:57Z
updated_at: 2026-05-29T18:11:10Z
assignee: null
legacy_ticket: null
---
## Background
The initial Nix manifest profiles implementation made profile discovery/default/alias metadata available from `[profiles]` sections inside `manifest.toml`. That is a design error.
A manifest is the Pod runtime configuration contract: model, scope, tools, memory, web, permissions, prompts, and other settings that are already selected for a Pod. Profile registry/default/alias metadata is application/project UX configuration used before a Pod manifest exists. Putting registry metadata inside `manifest.toml` makes the manifest both the runtime config and the profile selector config, contradicting the profile design.
This was caught immediately after merge, before compatibility needs exist. Do not keep backward compatibility for `[profiles]` in manifest files.
## Requirements
- Move profile registry/default/alias configuration out of manifest files.
- User registry file: `<config_dir>/profiles.toml`.
- Project registry file: closest `.insomnia/profiles.toml` found by walking up from the cwd.
- Builtin profile discovery can remain under bundled resources.
- Remove manifest-file profile registry parsing.
- `manifest.toml` must not be the source for profile registry/default/alias data.
- `INSOMNIA_USER_MANIFEST` must not affect profile registry discovery.
- `PodManifestConfig` / `PodManifest` must remain runtime config only.
- Use a top-level `profiles.toml` schema rather than nesting under `[profiles]`.
- Example:
```toml
default = "coder"
[profile]
coder = "profiles/coder.nix"
[alias]
work = "coder"
```
- The existing table form for entries may remain useful:
```toml
[profile.coder]
path = "profiles/coder.nix"
description = "Project coder"
```
- Keep existing profile selection semantics.
- `default`, unqualified names, source-qualified names, aliases, ambiguity handling, and default markers should continue to work.
- Unqualified alias/default targets remain source-local by default.
- TUI fresh-spawn profile row and manifest-cascade opt-out should continue to work.
- Update docs and tests.
- Documentation must describe `profiles.toml`, not `[profiles]` in `manifest.toml`.
- Tests should create `.insomnia/profiles.toml` / user `profiles.toml`, not `.insomnia/manifest.toml`, for registry behavior.
- Add coverage proving `[profiles]` in `manifest.toml` is ignored for discovery.
## Acceptance criteria
- User profile registry is discovered from `<config_dir>/profiles.toml`.
- Project profile registry is discovered from closest `.insomnia/profiles.toml`.
- `[profiles]` in user/project `manifest.toml` is not read for profile registry/default/alias discovery.
- `INSOMNIA_USER_MANIFEST` does not change profile registry discovery.
- Existing CLI/TUI selector behavior still works with `profiles.toml`.
- Docs no longer instruct users to put profile registry metadata in `manifest.toml`.
- Focused manifest/tui/pod/client profile tests pass.
- `cargo fmt --check`
- Relevant checks pass.

View File

@ -0,0 +1,69 @@
---
id: 20260529-180257-profile-registry-config-file
slug: profile-registry-config-file
title: Move profile registry out of manifest files
status: closed
kind: bug
priority: P1
labels: [manifest, profiles, config]
created_at: 2026-05-29T18:02:57Z
updated_at: 2026-05-29T18:11:09Z
assignee: null
legacy_ticket: null
---
## Background
The initial Nix manifest profiles implementation made profile discovery/default/alias metadata available from `[profiles]` sections inside `manifest.toml`. That is a design error.
A manifest is the Pod runtime configuration contract: model, scope, tools, memory, web, permissions, prompts, and other settings that are already selected for a Pod. Profile registry/default/alias metadata is application/project UX configuration used before a Pod manifest exists. Putting registry metadata inside `manifest.toml` makes the manifest both the runtime config and the profile selector config, contradicting the profile design.
This was caught immediately after merge, before compatibility needs exist. Do not keep backward compatibility for `[profiles]` in manifest files.
## Requirements
- Move profile registry/default/alias configuration out of manifest files.
- User registry file: `<config_dir>/profiles.toml`.
- Project registry file: closest `.insomnia/profiles.toml` found by walking up from the cwd.
- Builtin profile discovery can remain under bundled resources.
- Remove manifest-file profile registry parsing.
- `manifest.toml` must not be the source for profile registry/default/alias data.
- `INSOMNIA_USER_MANIFEST` must not affect profile registry discovery.
- `PodManifestConfig` / `PodManifest` must remain runtime config only.
- Use a top-level `profiles.toml` schema rather than nesting under `[profiles]`.
- Example:
```toml
default = "coder"
[profile]
coder = "profiles/coder.nix"
[alias]
work = "coder"
```
- The existing table form for entries may remain useful:
```toml
[profile.coder]
path = "profiles/coder.nix"
description = "Project coder"
```
- Keep existing profile selection semantics.
- `default`, unqualified names, source-qualified names, aliases, ambiguity handling, and default markers should continue to work.
- Unqualified alias/default targets remain source-local by default.
- TUI fresh-spawn profile row and manifest-cascade opt-out should continue to work.
- Update docs and tests.
- Documentation must describe `profiles.toml`, not `[profiles]` in `manifest.toml`.
- Tests should create `.insomnia/profiles.toml` / user `profiles.toml`, not `.insomnia/manifest.toml`, for registry behavior.
- Add coverage proving `[profiles]` in `manifest.toml` is ignored for discovery.
## Acceptance criteria
- User profile registry is discovered from `<config_dir>/profiles.toml`.
- Project profile registry is discovered from closest `.insomnia/profiles.toml`.
- `[profiles]` in user/project `manifest.toml` is not read for profile registry/default/alias discovery.
- `INSOMNIA_USER_MANIFEST` does not change profile registry discovery.
- Existing CLI/TUI selector behavior still works with `profiles.toml`.
- Docs no longer instruct users to put profile registry metadata in `manifest.toml`.
- Focused manifest/tui/pod/client profile tests pass.
- `cargo fmt --check`
- Relevant checks pass.

View File

@ -0,0 +1,84 @@
<!-- event: create author: tickets.sh at: 2026-05-29T18:02:57Z -->
## Created
Created by tickets.sh create.
---
<!-- event: close author: hare at: 2026-05-29T18:11:10Z status: closed -->
## Closed
---
id: 20260529-180257-profile-registry-config-file
slug: profile-registry-config-file
title: Move profile registry out of manifest files
status: closed
kind: bug
priority: P1
labels: [manifest, profiles, config]
created_at: 2026-05-29T18:02:57Z
updated_at: 2026-05-29T18:11:09Z
assignee: null
legacy_ticket: null
---
## Background
The initial Nix manifest profiles implementation made profile discovery/default/alias metadata available from `[profiles]` sections inside `manifest.toml`. That is a design error.
A manifest is the Pod runtime configuration contract: model, scope, tools, memory, web, permissions, prompts, and other settings that are already selected for a Pod. Profile registry/default/alias metadata is application/project UX configuration used before a Pod manifest exists. Putting registry metadata inside `manifest.toml` makes the manifest both the runtime config and the profile selector config, contradicting the profile design.
This was caught immediately after merge, before compatibility needs exist. Do not keep backward compatibility for `[profiles]` in manifest files.
## Requirements
- Move profile registry/default/alias configuration out of manifest files.
- User registry file: `<config_dir>/profiles.toml`.
- Project registry file: closest `.insomnia/profiles.toml` found by walking up from the cwd.
- Builtin profile discovery can remain under bundled resources.
- Remove manifest-file profile registry parsing.
- `manifest.toml` must not be the source for profile registry/default/alias data.
- `INSOMNIA_USER_MANIFEST` must not affect profile registry discovery.
- `PodManifestConfig` / `PodManifest` must remain runtime config only.
- Use a top-level `profiles.toml` schema rather than nesting under `[profiles]`.
- Example:
```toml
default = "coder"
[profile]
coder = "profiles/coder.nix"
[alias]
work = "coder"
```
- The existing table form for entries may remain useful:
```toml
[profile.coder]
path = "profiles/coder.nix"
description = "Project coder"
```
- Keep existing profile selection semantics.
- `default`, unqualified names, source-qualified names, aliases, ambiguity handling, and default markers should continue to work.
- Unqualified alias/default targets remain source-local by default.
- TUI fresh-spawn profile row and manifest-cascade opt-out should continue to work.
- Update docs and tests.
- Documentation must describe `profiles.toml`, not `[profiles]` in `manifest.toml`.
- Tests should create `.insomnia/profiles.toml` / user `profiles.toml`, not `.insomnia/manifest.toml`, for registry behavior.
- Add coverage proving `[profiles]` in `manifest.toml` is ignored for discovery.
## Acceptance criteria
- User profile registry is discovered from `<config_dir>/profiles.toml`.
- Project profile registry is discovered from closest `.insomnia/profiles.toml`.
- `[profiles]` in user/project `manifest.toml` is not read for profile registry/default/alias discovery.
- `INSOMNIA_USER_MANIFEST` does not change profile registry discovery.
- Existing CLI/TUI selector behavior still works with `profiles.toml`.
- Docs no longer instruct users to put profile registry metadata in `manifest.toml`.
- Focused manifest/tui/pod/client profile tests pass.
- `cargo fmt --check`
- Relevant checks pass.
---

View File

@ -0,0 +1,68 @@
---
id: 20260529-181528-remove-profile-aliases
slug: remove-profile-aliases
title: Remove profile aliases from profile registry
status: closed
kind: bug
priority: P1
labels: [profiles, config, simplification]
created_at: 2026-05-29T18:15:28Z
updated_at: 2026-05-29T18:20:44Z
assignee: null
legacy_ticket: null
---
## Background
The initial profile registry added `[alias]` as a convenience layer for redirecting one profile name to another. In practice this adds name-resolution complexity without a clear use case. It already caused bugs around source-local alias resolution and defaults pointing at aliases.
Profile selection should stay simple: a registry contains profile entries and an optional default that points directly at one profile entry. If users want a short or stable name, they can choose that name as the profile registry key.
There is no compatibility requirement for aliases because the feature has just landed and has not become a stable user-facing contract.
## Requirements
- Remove profile alias support from registry parsing and selection.
- Delete `ProfileAlias` and alias maps/resolution paths.
- Remove `[alias]` from the `profiles.toml` schema.
- Do not support alias-to-alias or alias-to-profile indirection.
- Keep profile registry semantics simple.
- Supported schema:
```toml
default = "coder"
[profile]
coder = "profiles/coder.nix"
researcher = "profiles/researcher.nix"
```
- Table form may remain:
```toml
[profile.coder]
path = "profiles/coder.nix"
description = "Coding profile"
```
- `default` must name a profile entry directly.
- Unqualified defaults resolve within the declaring source.
- Source-qualified defaults such as `user:coder` may remain if already implemented and useful.
- Keep existing selector behavior for real profiles.
- explicit path / `path:<path>`
- discovered unqualified names
- `default`
- source-qualified names such as `project:coder`
- ambiguity fail-closed
- TUI manifest-cascade opt-out
- Update docs and tests.
- Remove alias examples and alias-specific tests.
- Add/keep coverage proving default points directly at a profile entry.
- Diagnostics for a missing default target should mention the missing profile, not alias behavior.
## Acceptance criteria
- `profiles.toml` no longer accepts or documents `[alias]` as a supported feature.
- `ProfileRegistry` has no alias state or alias resolution path.
- Existing CLI/TUI profile selection works with direct profile entries and defaults.
- Ambiguous unqualified real profile names still fail closed.
- Docs describe only entries + default.
- Focused manifest/tui/pod/client profile tests pass.
- `cargo fmt --check`
- Relevant checks pass.

View File

@ -0,0 +1,68 @@
---
id: 20260529-181528-remove-profile-aliases
slug: remove-profile-aliases
title: Remove profile aliases from profile registry
status: closed
kind: bug
priority: P1
labels: [profiles, config, simplification]
created_at: 2026-05-29T18:15:28Z
updated_at: 2026-05-29T18:20:43Z
assignee: null
legacy_ticket: null
---
## Background
The initial profile registry added `[alias]` as a convenience layer for redirecting one profile name to another. In practice this adds name-resolution complexity without a clear use case. It already caused bugs around source-local alias resolution and defaults pointing at aliases.
Profile selection should stay simple: a registry contains profile entries and an optional default that points directly at one profile entry. If users want a short or stable name, they can choose that name as the profile registry key.
There is no compatibility requirement for aliases because the feature has just landed and has not become a stable user-facing contract.
## Requirements
- Remove profile alias support from registry parsing and selection.
- Delete `ProfileAlias` and alias maps/resolution paths.
- Remove `[alias]` from the `profiles.toml` schema.
- Do not support alias-to-alias or alias-to-profile indirection.
- Keep profile registry semantics simple.
- Supported schema:
```toml
default = "coder"
[profile]
coder = "profiles/coder.nix"
researcher = "profiles/researcher.nix"
```
- Table form may remain:
```toml
[profile.coder]
path = "profiles/coder.nix"
description = "Coding profile"
```
- `default` must name a profile entry directly.
- Unqualified defaults resolve within the declaring source.
- Source-qualified defaults such as `user:coder` may remain if already implemented and useful.
- Keep existing selector behavior for real profiles.
- explicit path / `path:<path>`
- discovered unqualified names
- `default`
- source-qualified names such as `project:coder`
- ambiguity fail-closed
- TUI manifest-cascade opt-out
- Update docs and tests.
- Remove alias examples and alias-specific tests.
- Add/keep coverage proving default points directly at a profile entry.
- Diagnostics for a missing default target should mention the missing profile, not alias behavior.
## Acceptance criteria
- `profiles.toml` no longer accepts or documents `[alias]` as a supported feature.
- `ProfileRegistry` has no alias state or alias resolution path.
- Existing CLI/TUI profile selection works with direct profile entries and defaults.
- Ambiguous unqualified real profile names still fail closed.
- Docs describe only entries + default.
- Focused manifest/tui/pod/client profile tests pass.
- `cargo fmt --check`
- Relevant checks pass.

View File

@ -0,0 +1,83 @@
<!-- event: create author: tickets.sh at: 2026-05-29T18:15:28Z -->
## Created
Created by tickets.sh create.
---
<!-- event: close author: hare at: 2026-05-29T18:20:44Z status: closed -->
## Closed
---
id: 20260529-181528-remove-profile-aliases
slug: remove-profile-aliases
title: Remove profile aliases from profile registry
status: closed
kind: bug
priority: P1
labels: [profiles, config, simplification]
created_at: 2026-05-29T18:15:28Z
updated_at: 2026-05-29T18:20:43Z
assignee: null
legacy_ticket: null
---
## Background
The initial profile registry added `[alias]` as a convenience layer for redirecting one profile name to another. In practice this adds name-resolution complexity without a clear use case. It already caused bugs around source-local alias resolution and defaults pointing at aliases.
Profile selection should stay simple: a registry contains profile entries and an optional default that points directly at one profile entry. If users want a short or stable name, they can choose that name as the profile registry key.
There is no compatibility requirement for aliases because the feature has just landed and has not become a stable user-facing contract.
## Requirements
- Remove profile alias support from registry parsing and selection.
- Delete `ProfileAlias` and alias maps/resolution paths.
- Remove `[alias]` from the `profiles.toml` schema.
- Do not support alias-to-alias or alias-to-profile indirection.
- Keep profile registry semantics simple.
- Supported schema:
```toml
default = "coder"
[profile]
coder = "profiles/coder.nix"
researcher = "profiles/researcher.nix"
```
- Table form may remain:
```toml
[profile.coder]
path = "profiles/coder.nix"
description = "Coding profile"
```
- `default` must name a profile entry directly.
- Unqualified defaults resolve within the declaring source.
- Source-qualified defaults such as `user:coder` may remain if already implemented and useful.
- Keep existing selector behavior for real profiles.
- explicit path / `path:<path>`
- discovered unqualified names
- `default`
- source-qualified names such as `project:coder`
- ambiguity fail-closed
- TUI manifest-cascade opt-out
- Update docs and tests.
- Remove alias examples and alias-specific tests.
- Add/keep coverage proving default points directly at a profile entry.
- Diagnostics for a missing default target should mention the missing profile, not alias behavior.
## Acceptance criteria
- `profiles.toml` no longer accepts or documents `[alias]` as a supported feature.
- `ProfileRegistry` has no alias state or alias resolution path.
- Existing CLI/TUI profile selection works with direct profile entries and defaults.
- Ambiguous unqualified real profile names still fail closed.
- Docs describe only entries + default.
- Focused manifest/tui/pod/client profile tests pass.
- `cargo fmt --check`
- Relevant checks pass.
---

View File

@ -0,0 +1,63 @@
---
id: 20260529-183318-builtin-profile-remove-manifest-cascade
slug: builtin-profile-remove-manifest-cascade
title: Add builtin Nix profile and remove manifest cascade mode
status: closed
kind: feature
priority: P1
labels: [profiles, manifest, nix, config]
created_at: 2026-05-29T18:33:18Z
updated_at: 2026-05-29T19:38:49Z
assignee: null
legacy_ticket: null
---
## Background
Manifest profiles are now the primary way to choose a Pod runtime configuration. The old ambient manifest cascade is no longer the desired model. A manifest is the resolved/low-level Pod runtime config, not an application profile selection mechanism, and it should not be implicitly assembled from user + project layers during normal startup.
The default dogfooding behavior currently expressed in this repository's `.insomnia/manifest.toml` should be converted to a bundled builtin Nix profile and registered as the default builtin profile. Users/projects can still override the selected default through `profiles.toml`, but the runtime config should come from the selected profile artifact.
The existing defaulting/required-field logic should remain shared: profile-produced artifacts and one-file manifests should both flow through `PodManifestConfig::builtin_defaults()` and `PodManifest::try_from(...)` so defaults and required-field validation are not duplicated.
## Requirements
- Add a builtin Nix profile equivalent to the current repository `.insomnia/manifest.toml` dogfooding config.
- Register it through builtin profile discovery.
- It should be selectable as a builtin profile and usable as the fallback default when no user/project profile default is configured.
- Preserve current behavior as closely as possible: model/provider, scope, worker language, compaction, memory settings, skills, web/tool settings, permissions, etc.
- Be careful with relative paths such as `.` from the current manifest. A builtin profile must not accidentally scope writes to `resources/nix/profiles`; it should resolve workspace-root-sensitive paths correctly or introduce an explicit resolver input for the startup cwd/project root.
- Remove ambient manifest cascade from normal startup.
- Do not implicitly merge user manifest + project manifest + overlay for normal Pod/TUI creation.
- Normal new Pod creation should choose a profile, defaulting through profile discovery.
- User/project `profiles.toml` remains a discovery/default registry only.
- Keep one-file manifest support as an explicit compatibility/debug path.
- `--manifest <PATH>` should load exactly that file plus builtin defaults/validation.
- It should not read user/project manifests.
- It should remain mutually exclusive with `--profile` and restore/attach modes as appropriate.
- Preserve shared default/required validation.
- `PodManifestConfig::builtin_defaults()` remains the common low-level default layer.
- `PodManifest::try_from(PodManifestConfig)` remains the common required-field/type validation boundary.
- Profile artifact resolution and one-file manifest loading must both use this common path.
- Update TUI/default behavior.
- Fresh-spawn UI should default to the builtin profile when no user/project profile default exists.
- `manifest cascade` wording should be removed or changed because cascade is no longer normal behavior.
- If an explicit one-file manifest fallback remains selectable, label it accurately.
- Update docs/tests.
- Documentation should describe builtin profile defaulting and explicit one-file manifest usage.
- Remove references to ambient manifest cascade as normal startup config.
- Tests should prove normal startup/profile discovery does not read user/project manifests as a cascade.
- Tests should prove `--manifest` is single-file only and still shares default/required validation.
## Acceptance criteria
- Builtin profile discovery lists the converted default profile.
- With no user/project `profiles.toml`, fresh Pod creation selects the builtin profile by default.
- The converted builtin profile produces a valid `PodManifest` through the same validation path as other profiles.
- Normal startup no longer reads/merges user/project `manifest.toml` files.
- `--manifest <PATH>` remains available as single-file explicit mode and does not cascade.
- Defaults and required-field errors are shared between profile and one-file manifest paths.
- TUI labels no longer describe the default opt-out as `manifest cascade`.
- Focused manifest/profile/pod/tui/client tests pass.
- `cargo fmt --check`
- Relevant checks pass.

View File

@ -0,0 +1,63 @@
---
id: 20260529-183318-builtin-profile-remove-manifest-cascade
slug: builtin-profile-remove-manifest-cascade
title: Add builtin Nix profile and remove manifest cascade mode
status: closed
kind: feature
priority: P1
labels: [profiles, manifest, nix, config]
created_at: 2026-05-29T18:33:18Z
updated_at: 2026-05-29T19:38:49Z
assignee: null
legacy_ticket: null
---
## Background
Manifest profiles are now the primary way to choose a Pod runtime configuration. The old ambient manifest cascade is no longer the desired model. A manifest is the resolved/low-level Pod runtime config, not an application profile selection mechanism, and it should not be implicitly assembled from user + project layers during normal startup.
The default dogfooding behavior currently expressed in this repository's `.insomnia/manifest.toml` should be converted to a bundled builtin Nix profile and registered as the default builtin profile. Users/projects can still override the selected default through `profiles.toml`, but the runtime config should come from the selected profile artifact.
The existing defaulting/required-field logic should remain shared: profile-produced artifacts and one-file manifests should both flow through `PodManifestConfig::builtin_defaults()` and `PodManifest::try_from(...)` so defaults and required-field validation are not duplicated.
## Requirements
- Add a builtin Nix profile equivalent to the current repository `.insomnia/manifest.toml` dogfooding config.
- Register it through builtin profile discovery.
- It should be selectable as a builtin profile and usable as the fallback default when no user/project profile default is configured.
- Preserve current behavior as closely as possible: model/provider, scope, worker language, compaction, memory settings, skills, web/tool settings, permissions, etc.
- Be careful with relative paths such as `.` from the current manifest. A builtin profile must not accidentally scope writes to `resources/nix/profiles`; it should resolve workspace-root-sensitive paths correctly or introduce an explicit resolver input for the startup cwd/project root.
- Remove ambient manifest cascade from normal startup.
- Do not implicitly merge user manifest + project manifest + overlay for normal Pod/TUI creation.
- Normal new Pod creation should choose a profile, defaulting through profile discovery.
- User/project `profiles.toml` remains a discovery/default registry only.
- Keep one-file manifest support as an explicit compatibility/debug path.
- `--manifest <PATH>` should load exactly that file plus builtin defaults/validation.
- It should not read user/project manifests.
- It should remain mutually exclusive with `--profile` and restore/attach modes as appropriate.
- Preserve shared default/required validation.
- `PodManifestConfig::builtin_defaults()` remains the common low-level default layer.
- `PodManifest::try_from(PodManifestConfig)` remains the common required-field/type validation boundary.
- Profile artifact resolution and one-file manifest loading must both use this common path.
- Update TUI/default behavior.
- Fresh-spawn UI should default to the builtin profile when no user/project profile default exists.
- `manifest cascade` wording should be removed or changed because cascade is no longer normal behavior.
- If an explicit one-file manifest fallback remains selectable, label it accurately.
- Update docs/tests.
- Documentation should describe builtin profile defaulting and explicit one-file manifest usage.
- Remove references to ambient manifest cascade as normal startup config.
- Tests should prove normal startup/profile discovery does not read user/project manifests as a cascade.
- Tests should prove `--manifest` is single-file only and still shares default/required validation.
## Acceptance criteria
- Builtin profile discovery lists the converted default profile.
- With no user/project `profiles.toml`, fresh Pod creation selects the builtin profile by default.
- The converted builtin profile produces a valid `PodManifest` through the same validation path as other profiles.
- Normal startup no longer reads/merges user/project `manifest.toml` files.
- `--manifest <PATH>` remains available as single-file explicit mode and does not cascade.
- Defaults and required-field errors are shared between profile and one-file manifest paths.
- TUI labels no longer describe the default opt-out as `manifest cascade`.
- Focused manifest/profile/pod/tui/client tests pass.
- `cargo fmt --check`
- Relevant checks pass.

View File

@ -0,0 +1,111 @@
<!-- event: create author: tickets.sh at: 2026-05-29T18:33:18Z -->
## Created
Created by tickets.sh create.
---
<!-- event: review author: insomnia at: 2026-05-29T19:37:45Z status: approve -->
## Review: approve
Reviewed merged implementation from branch `work/builtin-profile-remove-manifest-cascade` (`625730c`, follow-up `20ac0c9`, merged as `merge: builtin profile default startup`).
Approved after blocking fixes:
- `insomnia-pod --overlay` is no longer accepted as a user-facing generic TOML layer; SpawnPod now uses hidden typed `--spawn-config-json` and TUI restore uses typed `--session-pod-name` / `--resume-scope-json`.
- `insomnia-pod --pod <name>` fresh-create compatibility is explicit: absent Pod metadata resolves the default profile and applies the requested pod name as a typed override, with test coverage.
- TUI fresh spawn no longer has `cascade_has_scope`, `CwdDefault`, or cwd write-scope injection. Scope comes from the selected profile; session restore passes only the persisted scope snapshot.
- `resources/nix/profiles/default.nix` matches the current dogfooding manifest values and builtin profile resolution resolves `target = "."` against the launch workspace/current directory rather than the bundled profile directory.
- Profile and one-file manifest paths share `PodManifestConfig::builtin_defaults()` plus `PodManifest::try_from(...)` validation, and docs now describe prompt-loader limitations without reviving ambient manifest discovery.
Validation run in the implementation worktree:
- `cargo fmt --check`
- `cargo test -p manifest profile -- --nocapture`
- `cargo test -p pod profile -- --nocapture`
- `cargo test -p client spawn -- --nocapture`
- `cargo test -p tui spawn -- --nocapture`
- `nix eval --json --file resources/nix/profiles/default.nix >/dev/null`
- `cargo test -p pod --bin insomnia-pod -- --nocapture`
- `cargo test -p pod spawn_config -- --nocapture`
- `cargo test -p manifest -p pod -p tui --lib --bins`
- `./tickets.sh doctor`
- `git diff --check`
Known unrelated full integration failures in `cargo test -p manifest -p pod -p tui` remain in existing pod rollback integration tests and were not introduced by this ticket.
---
<!-- event: close author: hare at: 2026-05-29T19:38:49Z status: closed -->
## Closed
---
id: 20260529-183318-builtin-profile-remove-manifest-cascade
slug: builtin-profile-remove-manifest-cascade
title: Add builtin Nix profile and remove manifest cascade mode
status: closed
kind: feature
priority: P1
labels: [profiles, manifest, nix, config]
created_at: 2026-05-29T18:33:18Z
updated_at: 2026-05-29T19:38:49Z
assignee: null
legacy_ticket: null
---
## Background
Manifest profiles are now the primary way to choose a Pod runtime configuration. The old ambient manifest cascade is no longer the desired model. A manifest is the resolved/low-level Pod runtime config, not an application profile selection mechanism, and it should not be implicitly assembled from user + project layers during normal startup.
The default dogfooding behavior currently expressed in this repository's `.insomnia/manifest.toml` should be converted to a bundled builtin Nix profile and registered as the default builtin profile. Users/projects can still override the selected default through `profiles.toml`, but the runtime config should come from the selected profile artifact.
The existing defaulting/required-field logic should remain shared: profile-produced artifacts and one-file manifests should both flow through `PodManifestConfig::builtin_defaults()` and `PodManifest::try_from(...)` so defaults and required-field validation are not duplicated.
## Requirements
- Add a builtin Nix profile equivalent to the current repository `.insomnia/manifest.toml` dogfooding config.
- Register it through builtin profile discovery.
- It should be selectable as a builtin profile and usable as the fallback default when no user/project profile default is configured.
- Preserve current behavior as closely as possible: model/provider, scope, worker language, compaction, memory settings, skills, web/tool settings, permissions, etc.
- Be careful with relative paths such as `.` from the current manifest. A builtin profile must not accidentally scope writes to `resources/nix/profiles`; it should resolve workspace-root-sensitive paths correctly or introduce an explicit resolver input for the startup cwd/project root.
- Remove ambient manifest cascade from normal startup.
- Do not implicitly merge user manifest + project manifest + overlay for normal Pod/TUI creation.
- Normal new Pod creation should choose a profile, defaulting through profile discovery.
- User/project `profiles.toml` remains a discovery/default registry only.
- Keep one-file manifest support as an explicit compatibility/debug path.
- `--manifest <PATH>` should load exactly that file plus builtin defaults/validation.
- It should not read user/project manifests.
- It should remain mutually exclusive with `--profile` and restore/attach modes as appropriate.
- Preserve shared default/required validation.
- `PodManifestConfig::builtin_defaults()` remains the common low-level default layer.
- `PodManifest::try_from(PodManifestConfig)` remains the common required-field/type validation boundary.
- Profile artifact resolution and one-file manifest loading must both use this common path.
- Update TUI/default behavior.
- Fresh-spawn UI should default to the builtin profile when no user/project profile default exists.
- `manifest cascade` wording should be removed or changed because cascade is no longer normal behavior.
- If an explicit one-file manifest fallback remains selectable, label it accurately.
- Update docs/tests.
- Documentation should describe builtin profile defaulting and explicit one-file manifest usage.
- Remove references to ambient manifest cascade as normal startup config.
- Tests should prove normal startup/profile discovery does not read user/project manifests as a cascade.
- Tests should prove `--manifest` is single-file only and still shares default/required validation.
## Acceptance criteria
- Builtin profile discovery lists the converted default profile.
- With no user/project `profiles.toml`, fresh Pod creation selects the builtin profile by default.
- The converted builtin profile produces a valid `PodManifest` through the same validation path as other profiles.
- Normal startup no longer reads/merges user/project `manifest.toml` files.
- `--manifest <PATH>` remains available as single-file explicit mode and does not cascade.
- Defaults and required-field errors are shared between profile and one-file manifest paths.
- TUI labels no longer describe the default opt-out as `manifest cascade`.
- Focused manifest/profile/pod/tui/client tests pass.
- `cargo fmt --check`
- Relevant checks pass.
---

View File

@ -1,27 +0,0 @@
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:22Z -->
## Migrated
Migrated from TODO.md entry without a legacy ticket file. No legacy review file was present at migration time.
---
<!-- event: plan author: hare at: 2026-05-29T16:09:27Z -->
## Plan
Implementation will proceed through a child orchestrator Pod in a dedicated worktree as an experiment in nested Pod delegation.
Initial implementation target:
- Introduce Nix profile resolution as a new manifest source before the existing manifest cascade.
- Start with explicit path-based profiles; discovered-name/default selection and rich TUI picker can be staged after the core resolver if necessary.
- Provide a minimal bundled Nix helper that can produce a typed resolved manifest/config artifact.
- Keep existing TOML manifest loading as compatibility/debug/test infrastructure.
- Persist enough profile identity and resolved snapshot data for future restore semantics; do not silently re-evaluate profiles on resume.
- Secret values must remain references only; plaintext secrets are out of scope for the profile resolver.
The child orchestrator may split implementation among sub-Pods, but final merge/close remains parent-side.
---

View File

@ -0,0 +1,91 @@
---
id: 20260529-161928-mcp-integration
slug: mcp-integration
title: MCP integration as external tool/resource/prompt provider
status: open
kind: feature
priority: P2
labels: [mcp, tools, security, profiles]
created_at: 2026-05-29T16:19:28Z
updated_at: 2026-05-29T16:19:28Z
assignee: null
legacy_ticket: null
---
## Background
MCP (Model Context Protocol) is an open JSON-RPC based protocol for connecting AI applications to external systems. MCP servers can expose tools, resources, and prompts; clients/hosts can also expose capabilities such as roots, elicitation, sampling, logging, progress, and cancellation. Common transports are local stdio and remote Streamable HTTP.
Insomnia already has a built-in tool registry, manifest/profile-driven policy, scoped filesystem permissions, prompt/workflow assets, and bounded tool output. MCP should integrate with those existing safety and orchestration layers rather than bypass them.
MCP servers must be treated as untrusted external capability providers. Tool descriptions, annotations, resource content, and prompt templates returned by a server are data from an external system and must not implicitly weaken Insomnia scope, permission, prompt-context, or history-persistence rules.
## Requirements
- Add MCP server configuration through the manifest/profile system.
- Profiles should be able to declare named MCP servers.
- Server configuration should support local stdio as the first transport.
- Streamable HTTP can be a later phase, but the design should not preclude it.
- Server process commands, arguments, environment/credential references, and working directory must be explicit.
- Implement MCP client foundation.
- JSON-RPC 2.0 message handling.
- lifecycle initialization and capability negotiation.
- `notifications/initialized` after successful initialization.
- graceful shutdown and clear diagnostics for startup/protocol failures.
- Bridge MCP tools into Insomnia's tool system.
- Use `tools/list` to discover tools.
- Register discovered MCP tools under stable names that include the server namespace.
- Execute tool calls through `tools/call`.
- Preserve existing PreToolCall permission policy and manifest/profile tool controls.
- Bound tool result size and serialize results without allowing server-provided data to act as instructions.
- Support `notifications/tools/list_changed` by refreshing the registered tool list when safe.
- Treat MCP resources and prompts as explicit context sources, not hidden injections.
- Initial implementation may defer resources/prompts, but the design must specify that `resources/read` and `prompts/get` are explicit user/model-visible operations with permission/policy gates.
- Do not silently inject resource or prompt content into LLM context outside history.
- Connect filesystem roots to Insomnia scope.
- If MCP roots are supported, expose only authorized scope roots.
- A server must not learn or operate on paths outside the configured scope.
- Keep client-side MCP capabilities conservative.
- Sampling is powerful and should be disabled initially or require explicit approval because it lets an MCP server request LLM completions.
- Elicitation should require an approval/UI path before a server can request user input.
- Logging/progress notifications should be surfaced as diagnostics without polluting model context.
- Security and trust constraints.
- Tool descriptions and schemas from MCP servers are untrusted metadata.
- All MCP tool invocations remain subject to Insomnia tool permission policy.
- Server-provided content must not override system/developer instructions.
- Secrets are passed only through explicit env/secret references and must not be logged or exposed to model context.
- Remote MCP servers require an explicit future design for auth, TLS, redirects, private network policy, and output limits.
- UX and observability.
- Startup failures should identify the MCP server and failing phase.
- Tool list changes and server disconnects should be visible to the user/TUI.
- Provide enough diagnostics for debugging without printing secrets.
- Documentation.
- Explain MCP's trust model in Insomnia.
- Show examples for local stdio MCP servers.
- Document how MCP tool names, permissions, scope, and profiles interact.
## Suggested implementation phases
1. stdio MCP client foundation with mock-server tests.
2. `tools/list` / `tools/call` bridge into the existing tool registry and permission policy.
3. Manifest/profile configuration and CLI/Pod startup integration.
4. TUI diagnostics for server startup/disconnect/tool-list changes.
5. Resources/prompts support as explicit operations.
6. Streamable HTTP transport and auth design.
7. Sampling/elicitation only after an approval/resume protocol exists.
## Acceptance criteria
- A manifest/profile can configure at least one local stdio MCP server.
- Pod startup initializes the configured MCP server and reports clear diagnostics on failure.
- `tools/list` results are registered as Insomnia tools with namespaced stable names.
- Calling a registered MCP tool invokes `tools/call` and returns bounded structured output to the model.
- Existing tool permission policy applies to MCP tools before execution.
- MCP server metadata/content is treated as untrusted and does not bypass prompt-context or history-persistence rules.
- Secrets used by MCP server configuration are represented as explicit env/secret references and are not logged or serialized as plaintext.
- Filesystem roots, if exposed, are derived from authorized Insomnia scope only.
- Sampling and elicitation are disabled or fail closed unless an explicit approval path is implemented.
- Focused tests cover protocol lifecycle, tool discovery, tool call success/failure, permission denial, bounded output, server disconnect, and no-secret diagnostics.
- Documentation includes a local stdio MCP server example and security guidance.
- `cargo fmt --check`
- Relevant manifest/tools/pod/tui tests pass.

View File

@ -0,0 +1,7 @@
<!-- event: create author: tickets.sh at: 2026-05-29T16:19:28Z -->
## Created
Created by tickets.sh create.
---

View File

@ -0,0 +1,65 @@
---
id: 20260529-163047-pod-event-scope-subdelegation-control-only
slug: pod-event-scope-subdelegation-control-only
title: Keep scope sub-delegation PodEvent out of agent notifications
status: open
kind: bug
priority: P2
labels: [pod, events, orchestration, context]
created_at: 2026-05-29T16:30:47Z
updated_at: 2026-05-29T16:30:47Z
assignee: null
legacy_ticket: null
---
## Background
Nested Pod orchestration currently emits a visible notification when a child Pod sub-delegates scope to its own child, for example:
```text
pod `orchestrate-nix-manifest-profiles` sub-delegated scope to `manifest-profiles-audit-20260529`
```
This comes from `PodEvent::ScopeSubDelegated`. The event itself is useful as control-plane data: parent Pods need it to update spawned-child registry state, preserve delegated scope ownership, and propagate the child/grandchild relationship upward. However, it does not usually require the parent LLM to take action.
At the moment all `PodEvent` values are pushed into the notification buffer and can trigger `RunForNotification` when the receiving Pod is idle. That makes scope delegation a model-visible semantic notification, adds noise to history/context, and can cause unnecessary auto-kicked LLM turns during nested orchestration.
## Requirements
- Keep `PodEvent::ScopeSubDelegated` as a control-plane event.
- Existing registry side effects must still run.
- Scope ownership/reclaim behavior must not regress.
- Upward propagation to higher-level parents must still happen when needed.
- Do not expose scope sub-delegation as an agent notification.
- Do not push `ScopeSubDelegated` into the Pod notification buffer.
- Do not persist it as model-visible notification history.
- Do not trigger `PendingRun::RunForNotification` solely because scope was sub-delegated.
- Preserve agent-visible notifications for events that need orchestration attention.
- `TurnEnded` should remain agent-visible.
- `Errored` should remain agent-visible.
- `ShutDown` should remain agent-visible unless a later design explicitly separates it.
- Make the event visibility boundary explicit in code.
- Prefer a small helper such as `PodEvent::should_notify_agent()` or an equivalent visibility classification.
- Keep side effects and agent notification decisions separate so future control-plane events do not accidentally become model-visible.
- Keep context/history principles intact.
- Control-plane-only events must not be injected into LLM context without first becoming intentional history content.
- Avoid extra prompt-cache churn and token use for events that are not actionable by the model.
## Suggested implementation notes
Likely areas:
- `crates/protocol/src/lib.rs`: add an explicit visibility/helper on `PodEvent`.
- `crates/pod/src/controller.rs`: after `apply_event_side_effects`, only call `pod.push_pod_event_notify(event)` and set `PendingRun::RunForNotification` when the event is agent-visible.
- `crates/pod/src/ipc/event.rs`: keep `ScopeSubDelegated` side effects unchanged.
- `crates/pod/tests/controller_test.rs`: update/add coverage for control-only scope delegation and agent-visible lifecycle events.
## Acceptance criteria
- `ScopeSubDelegated` still updates/propagates spawned-child registry state exactly as before.
- `ScopeSubDelegated` no longer produces `[Notification] ... sub-delegated scope ...` in the parent Pod's agent-visible output/history.
- `ScopeSubDelegated` does not auto-kick an idle parent Pod into a model run.
- `TurnEnded`, `Errored`, and `ShutDown` still produce agent-visible notifications and can still wake an idle parent when appropriate.
- Tests cover both the control-only `ScopeSubDelegated` path and at least one agent-visible `PodEvent` path.
- `cargo fmt --check`
- Relevant pod/protocol tests pass.

View File

@ -0,0 +1,7 @@
<!-- event: create author: tickets.sh at: 2026-05-29T16:30:47Z -->
## Created
Created by tickets.sh create.
---