Compare commits

..

13 Commits

47 changed files with 2039 additions and 583 deletions

View File

@ -1,3 +1,9 @@
[scope]
allow = [
{ target = ".", permission = "write", recursive = true },
{ target = "/home/hare/ghq", permission = "read", recursive = true },
]
[memory]
extract_threshold = 50000

View File

@ -10,6 +10,8 @@ requires: []
この Workflow は常駐 scheduler ではない。ユーザーが `/auto-maintain` を明示的に呼んだ時だけ動く。
この Workflow は親 Pod / orchestrator 専用である。実装 Pod に `/auto-maintain` を渡してはならない。実装 Pod には、親 Pod が選んだ ticket、既に用意した child worktree、許可された write scope、禁止事項を具体的に渡す。
## 基本方針
- main workspace は制御面として扱う。
@ -69,7 +71,7 @@ TODO と tickets の不整合を見つけた場合は、勝手に ticket を作
## Worktree 作成
実装差分を隔離する必要がある場合、`/worktree-workflow` の手順を使う。要点は以下。
実装差分を隔離する必要がある場合、親 Pod が `/worktree-workflow` の手順を使う。実装 Pod に worktree 作成を任せてはならない。要点は以下。
```bash
git worktree add .worktree/<task-name> -b <task-name>
@ -85,7 +87,7 @@ git -C .worktree/<task-name> sparse-checkout set --no-cone \
## 実装 Pod の spawn
実装 Pod を使う場合は、対象 ticket、child worktree path、write scope、禁止事項を明示する。
実装 Pod を使う場合は、対象 ticket、既に作成済みの child worktree path、write scope、禁止事項を明示する。実装 Pod は `/auto-maintain``/worktree-workflow` を実行せず、与えられた worktree 内で実装・確認・報告だけを行う。
推奨 scope:

View File

@ -10,6 +10,8 @@ Git worktree を使って、実装差分を main workspace から分離して進
この Workflow は `.claude/skills/worktree-workflow/SKILL.md` の運用を insomnia 向けに移植したもの。insomnia では Pod の write scope が排他的に委譲されるため、子 worktree に `.insomnia` を置かず、親 Pod が main workspace 側の管理ファイルを書ける状態を保つ。
この Workflow は親 Pod / orchestrator が worktree を用意するための手順である。実装 Pod にこの Workflow を渡して worktree を作らせてはならない。実装 Pod は親 Pod から既存 child worktree を受け取り、その中で実装・build・test・報告だけを行う。
## 原則
- 1 セッション / 1 ticket / 1 task につき 1 worktree を作る。

View File

@ -4,21 +4,27 @@
- プロンプトはすべて resources/promptsに集約している。管理効率の工場と同時に、ユーザーがオーバーライドする形式でもある。
- E2E(実プロセスをスポーンさせてのテスト)は未設計。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
### LLM コンテキストの加工原則
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
- **許される**: 既存 history から純粋に再現可能な変換器pruning、compaction による要約、tool result の content 切り詰め、prompt cache anchor の付与等)。同じ history を入力すれば同じ結果が出る決定的な加工で、history そのものを書き換えるわけでもなく、外から新しい情報を持ち込まない。
- **禁止**: Pod の現在状態(受信した notification、active な内部キュー、time-of-day、外部イベント等に基づいて、history に commit せずに context だけに新規 input を差し込むこと。これをやると LLM はそれに反応して history を変化させる一方、トリガーは worker.history に残らないため、次ターン以降「自分がなぜその発言/tool call をしたか」の根拠が消える。resume 時にはさらに露骨に再現不能になる。prompt cache の prefix も毎回ズレる。
- **許される**: 既存 history から純粋に再現可能な変換器pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。同じ history を入力すれば同じ結果が出る決定的な加工で、history そのものを書き換えるわけでもなく、外から新しい情報を持ち込まない。
- **禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / PodEvent / `<system-reminder>` 系はこの原則で扱う(→ `tickets/notify-history-persist.md`)。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
---
Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと
## Git操作
外部の参考プロジェクトはghqでローカルでReadする運用をしている。
Gitはpush以外のすべての操作が許可されている。
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
外部の参考プロジェクトはghqでgetしており、必要に応じて`~/ghq`からReadすること。
---

11
Cargo.lock generated
View File

@ -328,6 +328,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "client"
version = "0.1.0"
dependencies = [
"manifest",
"protocol",
"tokio",
"uuid",
]
[[package]]
name = "cmake"
version = "0.1.57"
@ -3621,6 +3631,7 @@ dependencies = [
name = "tui"
version = "0.1.0"
dependencies = [
"client",
"crossterm 0.28.1",
"manifest",
"pod-registry",

View File

@ -1,6 +1,7 @@
[workspace]
resolver = "2"
members = [
"crates/client",
"crates/daemon",
"crates/llm-worker",
"crates/llm-worker-macros",
@ -22,6 +23,7 @@ license = "MIT"
[workspace.dependencies]
# Internal crates
client = { path = "crates/client" }
llm-worker = { path = "crates/llm-worker", version = "0.2" }
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
manifest = { path = "crates/manifest" }

View File

@ -1,7 +1,9 @@
- Workflow / Skills
- 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
- 半自動開発運用 Workflow → [tickets/auto-maintain-workflow.md](tickets/auto-maintain-workflow.md)
- パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- Workflow を memory crate から独立させる → [tickets/workflow-crate-extraction.md](tickets/workflow-crate-extraction.md)
- Prompt / Workflow 評価メトリクスと改善 Offer → [tickets/prompt-eval-metrics.md](tickets/prompt-eval-metrics.md)
- Permission: allow-all 既定 policy への整理 → [tickets/permission-default-policy.md](tickets/permission-default-policy.md)
- Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)
- Pod: 空応答ターン (Submit 後 AI 応答ゼロで Pause/Cancel) を自動巻き戻し → [tickets/pod-empty-turn-rollback.md](tickets/pod-empty-turn-rollback.md)
- Pod: 任意ターンからの Fork複数ターン巻き戻しを汎用化 → [tickets/pod-session-fork.md](tickets/pod-session-fork.md)
@ -13,14 +15,14 @@
- ストリーム途中失敗時の継続 → [tickets/llm-worker-stream-continuation.md](tickets/llm-worker-stream-continuation.md)
- llm-worker: Anthropic projection で assistant ターン内ブロックを 1 message に束ねる → [tickets/anthropic-assistant-burst-bundling.md](tickets/anthropic-assistant-burst-bundling.md)
- ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
- Client crate の切り出しTUI/GUI/E2E 共通の protocol クライアント) → [tickets/client-crate.md](tickets/client-crate.md)
- E2E テストハーネス(`tests/e2e/`、opt-in → [tickets/e2e-harness.md](tickets/e2e-harness.md)
- TUI 拡充
- Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md)
- 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md)
- セッションコンテキスト長 / ウィンドウ占有率の常時表示 → [tickets/tui-context-usage-indicator.md](tickets/tui-context-usage-indicator.md)
- Compaction 進行中のライブ表示 → [tickets/tui-compact-progress.md](tickets/tui-compact-progress.md)
- Assistant 応答の Markdown スタイル表示 → [tickets/tui-assistant-markdown.md](tickets/tui-assistant-markdown.md)
- Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md)
- Prune: 保護境界を turn 数から末尾 token budget に置き換え → [tickets/prune-token-budget.md](tickets/prune-token-budget.md)
- メモリ機構

11
crates/client/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "client"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
protocol = { workspace = true }
manifest = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
uuid = { workspace = true }

15
crates/client/src/lib.rs Normal file
View File

@ -0,0 +1,15 @@
//! Pod プロトコルを喋るクライアント。
//!
//! - [`PodClient`]: 既存 pod の Unix ソケットへ接続して `Method` を送り、
//! `Event` を受け取る低レベル接続。
//! - [`spawn`]: pod バイナリをサブプロセスとして起動し、`INSOMNIA-READY`
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
//! ない呼び出し側 (=既存 pod に attach する場合) は使わなくてよい。
//!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
mod pod_client;
pub mod spawn;
pub use pod_client::PodClient;
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};

294
crates/client/src/spawn.rs Normal file
View File

@ -0,0 +1,294 @@
//! pod バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ
//! ハンドシェイク。
//!
//! - 親プロセス (TUI / GUI / E2E) は overlay TOML を組み立ててこの関数に
//! 渡す。pod はそれを受けて socket を bind し、stderr に
//! `INSOMNIA-READY\t<name>\t<socket>` を吐く。
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
//! UI の進捗表示や E2E のログ収集はここで賄う。
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
//! ライフサイクルから切り離した detached pod を作る。ready 後の lifecycle
//! 管理は runtime ディレクトリ / socket を介して行う。
use std::io;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use uuid::Uuid;
const READY_PREFIX: &str = "INSOMNIA-READY\t";
const READY_TIMEOUT: Duration = Duration::from_secs(20);
/// `spawn_pod` の入力。
pub struct SpawnConfig {
/// `pod.name` として使う識別子。runtime ディレクトリ
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。
pub pod_name: String,
/// `--overlay` で pod に渡す TOML 文字列。
pub overlay_toml: String,
/// pod の current_dir。
pub cwd: PathBuf,
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
/// resume させる。
pub resume_from: Option<Uuid>,
}
pub struct SpawnReady {
pub pod_name: String,
pub socket_path: PathBuf,
}
#[derive(Debug)]
pub enum SpawnError {
Io(io::Error),
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
RuntimeDirUnavailable,
PodLaunchFailed(io::Error),
PodExitedEarly { stderr_tail: String },
Timeout,
}
impl std::fmt::Display for SpawnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "io error: {e}"),
Self::RuntimeDirUnavailable => write!(
f,
"could not resolve runtime directory (set INSOMNIA_HOME, INSOMNIA_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
),
Self::PodLaunchFailed(e) => write!(f, "failed to launch pod: {e}"),
Self::PodExitedEarly { stderr_tail } => {
if stderr_tail.is_empty() {
write!(f, "pod exited before becoming ready")
} else {
write!(f, "pod exited before becoming ready: {stderr_tail}")
}
}
Self::Timeout => write!(
f,
"pod did not become ready within {}s",
READY_TIMEOUT.as_secs()
),
}
}
}
impl std::error::Error for SpawnError {}
impl From<io::Error> for SpawnError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
/// pod を spawn し、`INSOMNIA-READY` ハンドシェイクが終わるまで待つ。
///
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
pub async fn spawn_pod<F>(
config: SpawnConfig,
mut progress: F,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
let pod_bin = resolve_pod_command();
let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name)
.ok_or(SpawnError::RuntimeDirUnavailable)?;
std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?;
let stderr_path = pod_runtime_dir.join("stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
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 let Some(id) = config.resume_from {
command.arg("--session").arg(id.to_string());
}
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
// Default `kill_on_drop = false` plus `process_group(0)` makes this
// a detached Pod once startup succeeds: dropping the handle does not
// terminate it, and terminal-generated signals for the parent's
// process group do not hit the Pod. Runtime state/socket files are
// the source of truth after that point.
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
Ok(ready) => ready,
Err(e) => {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(e);
}
};
tokio::spawn(async move {
let _ = child.wait().await;
});
Ok(ready)
}
async fn wait_for_ready_file<F>(
progress: &mut F,
stderr_path: &Path,
child: &mut tokio::process::Child,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
let mut tail = StderrTail::new();
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
let mut offset = 0usize;
loop {
let content = match tokio::fs::read_to_string(stderr_path).await {
Ok(content) => content,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(SpawnError::Io(e)),
};
if content.len() > offset {
for line in content[offset..].lines() {
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
let mut parts = rest.splitn(2, '\t');
let pod_name = parts.next().unwrap_or("").to_string();
let socket_str = parts.next().unwrap_or("").to_string();
if pod_name.is_empty() || socket_str.is_empty() {
return Err(SpawnError::PodExitedEarly {
stderr_tail: format!("malformed ready line: {line}"),
});
}
let socket_path = PathBuf::from(socket_str);
wait_for_socket(
&socket_path,
deadline,
child,
stderr_path,
&mut tail,
&mut offset,
)
.await?;
return Ok(SpawnReady {
pod_name,
socket_path,
});
}
tail.push(line);
progress(line);
}
offset = content.len();
}
if tokio::time::Instant::now() >= deadline {
return Err(SpawnError::Timeout);
}
tokio::select! {
status = child.wait() => {
let _ = status;
// Pod は exit 直前に最終 stderr 行を flush することがある。
// child.wait() が解決した後に再読みして、原因行を取りこ
// ぼさず PodExitedEarly に載せる。
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
return Err(SpawnError::PodExitedEarly {
stderr_tail: tail.into_string(),
});
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
}
}
}
async fn wait_for_socket(
socket_path: &Path,
deadline: tokio::time::Instant,
child: &mut tokio::process::Child,
stderr_path: &Path,
tail: &mut StderrTail,
offset: &mut usize,
) -> Result<(), SpawnError> {
loop {
match tokio::net::UnixStream::connect(socket_path).await {
Ok(_) => return Ok(()),
Err(e)
if e.kind() == io::ErrorKind::NotFound
|| e.kind() == io::ErrorKind::ConnectionRefused => {}
Err(e) => return Err(SpawnError::Io(e)),
}
if tokio::time::Instant::now() >= deadline {
return Err(SpawnError::Timeout);
}
tokio::select! {
status = child.wait() => {
let _ = status;
drain_stderr_into_tail(stderr_path, tail, offset).await;
return Err(SpawnError::PodExitedEarly {
stderr_tail: tail.as_string(),
});
}
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
}
}
}
async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offset: &mut usize) {
let Ok(content) = tokio::fs::read_to_string(stderr_path).await else {
return;
};
if content.len() <= *offset {
return;
}
for line in content[*offset..].lines() {
if !line.starts_with(READY_PREFIX) {
tail.push(line);
}
}
*offset = content.len();
}
/// Resolves the binary used to launch a child Pod. Must point at a
/// `pod`-compatible executable — the parent reads the child's stderr
/// directly looking for `INSOMNIA-READY`, so any wrapper that emits
/// extra lines on stderr will pollute that handshake.
///
/// `INSOMNIA_POD_COMMAND` overrides the lookup (used by tests to inject
/// a mock binary). Otherwise we defer to `PATH` — missing binary
/// surfaces as the spawn `io::Error`.
fn resolve_pod_command() -> PathBuf {
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND")
&& !cmd.is_empty()
{
return PathBuf::from(cmd);
}
PathBuf::from("pod")
}
struct StderrTail {
lines: std::collections::VecDeque<String>,
}
impl StderrTail {
fn new() -> Self {
Self {
lines: std::collections::VecDeque::with_capacity(8),
}
}
fn push(&mut self, line: &str) {
if self.lines.len() == 8 {
self.lines.pop_front();
}
self.lines.push_back(line.to_string());
}
fn as_string(&self) -> String {
self.lines.iter().cloned().collect::<Vec<_>>().join(" | ")
}
fn into_string(self) -> String {
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
}
}

View File

@ -52,6 +52,11 @@ pub enum PreToolAction {
Continue,
/// Skip this tool call (do not execute).
Skip,
/// Do not execute the tool call; commit this synthetic result instead.
///
/// This preserves provider-visible `tool_use` / `tool_result` pairing
/// without aborting the whole turn.
SyntheticResult(ToolResult),
/// Abort the entire run.
Abort(String),
/// Pause execution (can be resumed later).

View File

@ -14,6 +14,10 @@ use crate::llm_client::{
use super::AnthropicScheme;
fn is_false(value: &bool) -> bool {
!*value
}
/// Anthropic API request body
#[derive(Debug, Serialize)]
pub(crate) struct AnthropicRequest {
@ -104,6 +108,8 @@ pub(crate) enum AnthropicContentPart {
ToolResult {
tool_use_id: String,
content: String,
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
@ -141,10 +147,11 @@ impl AnthropicContentPart {
}
}
fn tool_result(tool_use_id: String, content: String) -> Self {
fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self {
Self::ToolResult {
tool_use_id,
content,
is_error,
cache_control: None,
}
}
@ -321,6 +328,7 @@ impl AnthropicScheme {
call_id,
summary,
content,
is_error,
..
} => {
flush_pending(
@ -333,8 +341,10 @@ impl AnthropicScheme {
Some(c) => format!("{summary}\n{c}"),
None => summary.clone(),
};
pending_user
.push((i, AnthropicContentPart::tool_result(call_id.clone(), text)));
pending_user.push((
i,
AnthropicContentPart::tool_result(call_id.clone(), text, *is_error),
));
}
Item::Reasoning {
@ -355,13 +365,10 @@ impl AnthropicScheme {
// 素の reasoning text。Anthropic に投げる意味も
// round-trip の根拠も無いので drop。
if let Some(sig) = signature.clone() {
pending_assistant.push((
i,
AnthropicContentPart::thinking(text.clone(), sig),
));
} else if let Some(data) = encrypted_content.clone() {
pending_assistant
.push((i, AnthropicContentPart::redacted_thinking(data)));
.push((i, AnthropicContentPart::thinking(text.clone(), sig)));
} else if let Some(data) = encrypted_content.clone() {
pending_assistant.push((i, AnthropicContentPart::redacted_thinking(data)));
}
// どちらも None なら何も pend せず、本 item は無視。
}
@ -828,7 +835,9 @@ mod tests {
assert_eq!(thinking_parts.len(), 1);
match thinking_parts[0] {
AnthropicContentPart::Thinking {
thinking, signature, ..
thinking,
signature,
..
} => {
assert_eq!(thinking, "step-by-step");
assert_eq!(signature, "SIG-A");

View File

@ -9,6 +9,10 @@
use serde::{Deserialize, Serialize};
fn is_false(value: &bool) -> bool {
!*value
}
// ============================================================================
// Item - The core unit of conversation
// ============================================================================
@ -79,6 +83,9 @@ pub enum Item {
/// Detailed output (removed by pruning when old enough)
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
/// Whether the tool result represents an execution error.
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
},
/// Reasoning/thinking item
@ -198,11 +205,27 @@ impl Item {
/// Create a tool result item with summary only (no content).
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
Self::tool_result_item(call_id, summary, None, false)
}
/// Create an error tool result item with summary only (no content).
pub fn tool_result_error(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
Self::tool_result_item(call_id, summary, None, true)
}
/// Create a tool result item with summary, optional content, and error flag.
pub fn tool_result_item(
call_id: impl Into<String>,
summary: impl Into<String>,
content: Option<String>,
is_error: bool,
) -> Self {
Self::ToolResult {
id: None,
call_id: call_id.into(),
summary: summary.into(),
content: None,
content,
is_error,
}
}
@ -212,12 +235,7 @@ impl Item {
summary: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self::ToolResult {
id: None,
call_id: call_id.into(),
summary: summary.into(),
content: Some(content.into()),
}
Self::tool_result_item(call_id, summary, Some(content.into()), false)
}
// ========================================================================

View File

@ -275,7 +275,7 @@ pub struct ToolCall {
///
/// Intermediate representation between tool execution and history.
/// Carries `summary` + optional `content` from [`ToolOutput`].
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolResult {
/// Corresponding tool call ID
pub tool_use_id: String,

View File

@ -742,8 +742,9 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
// Map from tool call ID to (ToolCall, Meta, Tool)
// Retained because it's needed for PostToolCall hooks
let mut call_info_map = HashMap::new();
let mut synthetic_results = Vec::new();
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort)
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new();
for mut tool_call in tool_calls {
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
@ -758,6 +759,15 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
PreToolAction::Skip => {
continue;
}
PreToolAction::SyntheticResult(result) => {
let tool_call = info.call;
call_info_map.insert(
tool_call.id.clone(),
(tool_call, info.meta.clone(), info.tool.clone()),
);
synthetic_results.push(result);
continue;
}
PreToolAction::Abort(reason) => {
self.last_run_interrupted = true;
return Err(WorkerError::Aborted(reason));
@ -809,6 +819,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
return Err(WorkerError::Cancelled);
}
};
results.extend(synthetic_results);
// Phase 3: Apply post_tool_call interceptor
for tool_result in &mut results {
@ -1124,16 +1135,12 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
}
Ok(ToolExecutionResult::Completed(results)) => {
for result in results {
if let Some(ref content) = result.content {
self.history.push(Item::tool_result_with_content(
&result.tool_use_id,
&result.summary,
content,
));
} else {
self.history
.push(Item::tool_result(&result.tool_use_id, &result.summary));
}
self.history.push(Item::tool_result_item(
&result.tool_use_id,
&result.summary,
result.content,
result.is_error,
));
}
Ok(None)
}

View File

@ -12,7 +12,7 @@ use llm_worker::interceptor::{
Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
};
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
mod common;
use common::MockLlmClient;
@ -268,3 +268,59 @@ async fn test_post_tool_call_modification() {
"Result should be modified"
);
}
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
#[tokio::test]
async fn test_before_tool_call_synthetic_result_committed() {
let events = vec![
Event::tool_use_start(0, "call_1", "blocked_tool"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![
events,
vec![
Event::text_block_start(0),
Event::text_delta(0, "Denied."),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let mut worker = Worker::new(client);
let blocked_tool = SlowTool::new("blocked_tool", 10);
let blocked_clone = blocked_tool.clone();
worker.register_tool(blocked_tool.definition());
struct SyntheticPolicy;
#[async_trait]
impl Interceptor for SyntheticPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
PreToolAction::SyntheticResult(ToolResult::error(
info.call.id.clone(),
"permission denied",
))
}
}
worker.set_interceptor(SyntheticPolicy);
let result = worker.run("Test synthetic result").await.unwrap();
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
assert!(result.worker.history().iter().any(|item| matches!(
item,
llm_worker::Item::ToolResult {
call_id,
summary,
is_error: true,
..
} if call_id == "call_1" && summary == "permission denied"
)));
}

View File

@ -16,7 +16,7 @@ use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::{
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SkillsConfig,
ToolOutputLimits, WorkerManifest,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
@ -36,6 +36,10 @@ pub struct PodManifestConfig {
pub worker: WorkerManifestConfig,
#[serde(default)]
pub scope: ScopeConfig,
/// Optional `[permissions]` section. `None` means the permission layer
/// is disabled; `Some` requires `default_action` during final resolve.
#[serde(default)]
pub permissions: Option<PermissionConfigPartial>,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
@ -87,6 +91,14 @@ pub struct ToolOutputLimitsPartial {
pub per_tool: HashMap<String, usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionConfigPartial {
#[serde(default)]
pub default_action: Option<crate::ToolPermissionAction>,
#[serde(default, rename = "rule")]
pub rules: Vec<ToolPermissionRule>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactionConfigPartial {
#[serde(default)]
@ -204,6 +216,11 @@ impl PodManifestConfig {
model: self.model.merge(upper.model),
worker: self.worker.merge(upper.worker),
scope: merge_scope(self.scope, upper.scope),
permissions: merge_option(
self.permissions,
upper.permissions,
PermissionConfigPartial::merge,
),
compaction: merge_option(
self.compaction,
upper.compaction,
@ -280,6 +297,16 @@ impl ToolOutputLimitsPartial {
}
}
impl PermissionConfigPartial {
fn merge(mut self, upper: Self) -> Self {
self.rules.extend(upper.rules);
Self {
default_action: upper.default_action.or(self.default_action),
rules: self.rules,
}
}
}
impl CompactionConfigPartial {
fn merge(self, upper: Self) -> Self {
Self {
@ -400,6 +427,18 @@ impl TryFrom<PodManifestConfig> for PodManifest {
ensure_absolute("scope.deny.target", &rule.target)?;
}
let permissions = cfg
.permissions
.map(|p| {
Ok(ToolPermissionConfig {
default_action: p
.default_action
.ok_or(ResolveError::MissingField("permissions.default_action"))?,
rules: p.rules,
})
})
.transpose()?;
let compaction = cfg
.compaction
.map(|c| -> Result<CompactionConfig, ResolveError> {
@ -438,6 +477,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
model: cfg.model,
worker,
scope: cfg.scope,
permissions,
compaction,
memory: cfg.memory,
skills: cfg.skills,
@ -482,6 +522,7 @@ mod tests {
}],
deny: Vec::new(),
},
permissions: None,
compaction: None,
memory: None,
skills: None,
@ -493,6 +534,51 @@ mod tests {
let manifest: PodManifest = minimal_valid().try_into().unwrap();
assert_eq!(manifest.pod.name, "test");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert!(manifest.permissions.is_none());
}
#[test]
fn resolve_permissions_requires_default_action_when_present() {
let mut cfg = minimal_valid();
cfg.permissions = Some(PermissionConfigPartial {
default_action: None,
rules: Vec::new(),
});
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::MissingField("permissions.default_action")
));
}
#[test]
fn resolve_permissions_preserves_actions_and_rule_order() {
let mut cfg = minimal_valid();
cfg.permissions = Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Ask),
rules: vec![
ToolPermissionRule {
tool: "Bash".into(),
pattern: "rm *".into(),
action: crate::ToolPermissionAction::Deny,
},
ToolPermissionRule {
tool: "Read".into(),
pattern: "*".into(),
action: crate::ToolPermissionAction::Allow,
},
],
});
let manifest: PodManifest = cfg.try_into().unwrap();
let permissions = manifest.permissions.unwrap();
assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
assert_eq!(permissions.rules.len(), 2);
assert_eq!(permissions.rules[0].tool, "Bash");
assert_eq!(permissions.rules[1].tool, "Read");
}
#[test]
@ -694,6 +780,42 @@ mod tests {
assert_eq!(merged.scope.deny.len(), 1);
}
#[test]
fn merge_permissions_accumulates_rules_and_upper_default_wins() {
let lower = PodManifestConfig {
permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Allow),
rules: vec![ToolPermissionRule {
tool: "Bash".into(),
pattern: "git *".into(),
action: crate::ToolPermissionAction::Allow,
}],
}),
..Default::default()
};
let upper = PodManifestConfig {
permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Deny),
rules: vec![ToolPermissionRule {
tool: "Bash".into(),
pattern: "rm *".into(),
action: crate::ToolPermissionAction::Deny,
}],
}),
..Default::default()
};
let merged = lower.merge(upper).permissions.unwrap();
assert_eq!(
merged.default_action,
Some(crate::ToolPermissionAction::Deny)
);
assert_eq!(merged.rules.len(), 2);
assert_eq!(merged.rules[0].pattern, "git *");
assert_eq!(merged.rules[1].pattern, "rm *");
}
#[test]
fn merge_tool_output_per_tool_keywise() {
let lower = PodManifestConfig {

View File

@ -7,8 +7,8 @@ mod scope;
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
pub use config::{
CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError,
ToolOutputLimitsPartial, WorkerManifestConfig,
CompactionConfigPartial, PermissionConfigPartial, PodManifestConfig, PodMetaConfig,
ResolveError, ToolOutputLimitsPartial, WorkerManifestConfig,
};
pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
@ -35,6 +35,10 @@ pub struct PodManifest {
pub model: ModelManifest,
pub worker: WorkerManifest,
pub scope: ScopeConfig,
/// Optional manifest-level tool permission policy. Absent means the
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem opt-in. Presence of `[memory]` in TOML enables
@ -239,6 +243,38 @@ pub struct ScopeConfig {
pub deny: Vec<ScopeRule>,
}
/// Manifest-level pattern-based tool permission policy.
///
/// Presence of `[permissions]` enables this layer. Rules are evaluated
/// in declaration order; if none match, [`Self::default_action`] is used.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolPermissionConfig {
pub default_action: ToolPermissionAction,
#[serde(default, rename = "rule")]
pub rules: Vec<ToolPermissionRule>,
}
/// One `[[permissions.rule]]` entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolPermissionRule {
/// Tool registration name. Matching is case-insensitive at runtime so
/// manifests may use either `Bash` or `bash`.
pub tool: String,
/// Glob-like pattern matched against the tool's permission target
/// (for built-in tools, commonly `command`, `file_path`, or `pattern`).
pub pattern: String,
pub action: ToolPermissionAction,
}
/// Tool permission decision.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolPermissionAction {
Allow,
Deny,
Ask,
}
/// Context compaction configuration.
///
/// Controls Prune (content removal from old tool results) and Compact

View File

@ -11,6 +11,7 @@ pub mod workflow;
mod factory;
mod interrupt_and_run;
mod permission;
mod pod;
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};

View File

@ -0,0 +1,189 @@
use async_trait::async_trait;
use llm_worker::interceptor::PreToolAction;
use llm_worker::llm_client::client::LlmClient;
use llm_worker::tool::ToolResult;
use manifest::{ToolPermissionAction, ToolPermissionConfig};
use serde_json::Value;
use session_store::Store;
use crate::Pod;
use crate::hook::{Hook, PreToolCall, ToolCallSummary};
/// Built-in manifest permission policy for `PreToolCall`.
///
/// This hook is registered by Pod before user hooks, so manifest-level deny
/// rules fail closed before user extension code can approve a call.
pub(crate) struct PermissionHook {
config: ToolPermissionConfig,
}
impl PermissionHook {
pub(crate) fn new(config: ToolPermissionConfig) -> Self {
Self { config }
}
fn action_for(&self, input: &ToolCallSummary) -> ToolPermissionAction {
let target = permission_target(&input.arguments);
self.config
.rules
.iter()
.find(|rule| {
rule.tool.eq_ignore_ascii_case(&input.tool_name)
&& wildcard_match(&rule.pattern, &target)
})
.map(|rule| rule.action)
.unwrap_or(self.config.default_action)
}
}
impl<C: LlmClient, St: Store> Pod<C, St> {
pub(crate) fn apply_permissions_from_manifest(&mut self) {
let Some(permissions) = self.manifest().permissions.clone() else {
return;
};
self.add_pre_tool_call_hook(PermissionHook::new(permissions));
}
}
#[async_trait]
impl Hook<PreToolCall> for PermissionHook {
async fn call(&self, input: &ToolCallSummary) -> PreToolAction {
match self.action_for(input) {
ToolPermissionAction::Allow => PreToolAction::Continue,
ToolPermissionAction::Deny => PreToolAction::SyntheticResult(permission_denied(input)),
ToolPermissionAction::Ask => {
PreToolAction::SyntheticResult(permission_ask_unsupported(input))
}
}
}
}
fn permission_denied(input: &ToolCallSummary) -> ToolResult {
ToolResult::error(
input.call_id.clone(),
format!(
"permission denied: tool `{}` arguments matched the manifest permission policy",
input.tool_name
),
)
}
fn permission_ask_unsupported(input: &ToolCallSummary) -> ToolResult {
ToolResult::error(
input.call_id.clone(),
format!(
"permission ask unsupported: tool `{}` requires approval, but this runtime has no permission approval protocol; denied fail-closed",
input.tool_name
),
)
}
fn permission_target(arguments: &Value) -> String {
if let Value::Object(map) = arguments {
for key in ["command", "file_path", "path", "pattern"] {
if let Some(value) = map.get(key).and_then(Value::as_str) {
return value.to_string();
}
}
}
serde_json::to_string(arguments).unwrap_or_else(|_| arguments.to_string())
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let pattern = pattern.as_bytes();
let text = text.as_bytes();
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<usize> = None;
let mut star_text = 0usize;
while ti < text.len() {
if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
pi += 1;
ti += 1;
} else if pi < pattern.len() && pattern[pi] == b'*' {
star = Some(pi);
pi += 1;
star_text = ti;
} else if let Some(star_pi) = star {
pi = star_pi + 1;
star_text += 1;
ti = star_text;
} else {
return false;
}
}
while pi < pattern.len() && pattern[pi] == b'*' {
pi += 1;
}
pi == pattern.len()
}
#[cfg(test)]
mod tests {
use super::*;
use manifest::ToolPermissionRule;
fn summary(tool_name: &str, arguments: Value) -> ToolCallSummary {
ToolCallSummary {
call_id: "call_1".into(),
tool_name: tool_name.into(),
arguments,
}
}
#[test]
fn first_matching_rule_wins_by_declaration_order() {
let hook = PermissionHook::new(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: vec![
ToolPermissionRule {
tool: "bash".into(),
pattern: "git *".into(),
action: ToolPermissionAction::Allow,
},
ToolPermissionRule {
tool: "Bash".into(),
pattern: "git reset *".into(),
action: ToolPermissionAction::Deny,
},
],
});
let input = summary("Bash", serde_json::json!({ "command": "git reset --hard" }));
assert_eq!(hook.action_for(&input), ToolPermissionAction::Allow);
}
#[test]
fn default_action_applies_when_no_rule_matches() {
let hook = PermissionHook::new(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: Vec::new(),
});
let input = summary("Read", serde_json::json!({ "file_path": "/tmp/a.txt" }));
assert_eq!(hook.action_for(&input), ToolPermissionAction::Deny);
}
#[test]
fn target_prefers_known_builtin_argument_fields() {
assert_eq!(
permission_target(&serde_json::json!({ "command": "rm -rf target" })),
"rm -rf target"
);
assert_eq!(
permission_target(&serde_json::json!({ "file_path": "/tmp/.env" })),
"/tmp/.env"
);
}
#[test]
fn wildcard_supports_star_and_question() {
assert!(wildcard_match("rm *", "rm -rf target"));
assert!(wildcard_match("file?.rs", "file1.rs"));
assert!(!wildcard_match("rm *", "git status"));
}
}

View File

@ -333,6 +333,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
memory_task: None,
user_segments: Vec::new(),
};
pod.apply_permissions_from_manifest();
pod.apply_prune_from_manifest();
Ok(pod)
}
@ -2291,6 +2292,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
memory_task: None,
user_segments: Vec::new(),
};
pod.apply_permissions_from_manifest();
pod.apply_prune_from_manifest();
drain_skill_shadows(&pod, skill_shadows);
Ok(pod)
@ -2361,6 +2363,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
memory_task: None,
user_segments: Vec::new(),
};
pod.apply_permissions_from_manifest();
pod.apply_prune_from_manifest();
drain_skill_shadows(&pod, skill_shadows);
Ok(pod)
@ -2497,6 +2500,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
memory_task: None,
user_segments: state.user_segments,
};
pod.apply_permissions_from_manifest();
pod.apply_prune_from_manifest();
drain_skill_shadows(&pod, skill_shadows);
Ok(pod)

View File

@ -15,6 +15,10 @@
use llm_worker::llm_client::types::{ContentPart, Item, Role};
use serde::{Deserialize, Serialize};
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedItem {
@ -32,6 +36,8 @@ pub enum LoggedItem {
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
},
Reasoning {
text: String,
@ -86,11 +92,13 @@ impl From<&Item> for LoggedItem {
call_id,
summary,
content,
is_error,
..
} => Self::ToolResult {
call_id: call_id.clone(),
summary: summary.clone(),
content: content.clone(),
is_error: *is_error,
},
Item::Reasoning {
text,
@ -138,11 +146,13 @@ impl From<LoggedItem> for Item {
call_id,
summary,
content,
is_error,
} => Item::ToolResult {
id: None,
call_id,
summary,
content,
is_error,
},
LoggedItem::Reasoning {
text,
@ -347,6 +357,18 @@ mod tests {
}
}
#[test]
fn round_trip_tool_result_error_flag() {
let original = Item::tool_result_error("call_1", "permission denied");
let logged: LoggedItem = (&original).into();
let value = serde_json::to_value(&logged).unwrap();
assert_eq!(value["is_error"], true);
match Item::from(logged) {
Item::ToolResult { is_error, .. } => assert!(is_error),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn message_serialization_uses_kind_tag() {
let logged: LoggedItem = (&Item::assistant_message("hi")).into();

View File

@ -16,6 +16,47 @@ pub enum ToolsError {
#[error("path is outside allowed scope: {}", .0.display())]
OutOfScope(PathBuf),
#[error(
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Pod {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
.path.display(),
.target.display()
)]
SymlinkOutOfScope {
path: PathBuf,
target: PathBuf,
required_permission: &'static str,
},
#[error(
"broken symlink while resolving {}: {} -> {} (target does not exist); recreate the symlink with an absolute target or a correct relative target",
.path.display(),
.link.display(),
.target.display()
)]
BrokenSymlink {
path: PathBuf,
link: PathBuf,
target: PathBuf,
},
#[error(
"path resolves through a symlink to a directory, not a file: {} -> {}",
.path.display(),
.target.display()
)]
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
#[error(
"{tool} does not follow symlink directories: {} -> {}; use the resolved target path directly, or add the target to read scope and reference it without the symlink",
.path.display(),
.target.display()
)]
SymlinkDirectoryNotTraversed {
tool: &'static str,
path: PathBuf,
target: PathBuf,
},
#[error("path is read-only in this scope: {}", .0.display())]
ReadOnly(PathBuf),
@ -73,6 +114,10 @@ impl From<ToolsError> for ToolError {
match err {
RelativePath(_)
| OutOfScope(_)
| SymlinkOutOfScope { .. }
| BrokenSymlink { .. }
| SymlinkTargetIsDirectory { .. }
| SymlinkDirectoryNotTraversed { .. }
| ReadOnly(_)
| IsDirectory(_)
| NotRead(_)

View File

@ -10,7 +10,7 @@ use manifest::Scope;
use serde::Deserialize;
use crate::error::ToolsError;
use crate::scoped_fs::ScopedFs;
use crate::scoped_fs::{ScopedFs, direct_symlink};
const DESCRIPTION: &str = "Recursively find files matching a glob pattern \
(e.g. \"**/*.rs\"). Results are sorted by modification time, newest first, \
@ -98,8 +98,52 @@ fn run_glob(base: &Path, pattern: &str, scope: &Scope) -> Result<Vec<PathBuf>, T
if !base.is_absolute() {
return Err(ToolsError::RelativePath(base.to_path_buf()));
}
if !base.exists() {
return Err(ToolsError::NotFound(base.to_path_buf()));
let symlink = direct_symlink(base);
if !scope.is_readable(base) {
return Err(if let Some(info) = symlink.as_ref() {
let link_parent_readable = info
.link_path
.parent()
.map(|parent| scope.is_readable(parent))
.unwrap_or(false);
if info.target_exists && link_parent_readable {
ToolsError::SymlinkOutOfScope {
path: base.to_path_buf(),
target: info.resolved_path.clone(),
required_permission: "read",
}
} else {
ToolsError::OutOfScope(base.to_path_buf())
}
} else {
ToolsError::OutOfScope(base.to_path_buf())
});
}
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(ToolsError::BrokenSymlink {
path: base.to_path_buf(),
link: info.link_path.clone(),
target: info.target_path.clone(),
});
}
}
let base_meta = std::fs::metadata(base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ToolsError::NotFound(base.to_path_buf()),
_ => ToolsError::io(base, e),
})?;
if !base_meta.is_dir() {
return Err(ToolsError::InvalidArgument(format!(
"glob search path is not a directory: {}",
base.display()
)));
}
if let Some(info) = symlink.as_ref() {
return Err(ToolsError::SymlinkDirectoryNotTraversed {
tool: "Glob",
path: base.to_path_buf(),
target: info.resolved_path.clone(),
});
}
let glob = globset::Glob::new(pattern)
@ -296,4 +340,34 @@ mod tests {
assert!(body.contains(".hidden.rs"));
assert!(body.contains("visible.rs"));
}
#[cfg(unix)]
#[tokio::test]
async fn glob_reports_scope_inside_symlink_directory_is_not_traversed() {
use std::os::unix::fs::symlink;
let (dir, fs) = setup();
let target = dir.path().join("target-dir");
touch(&target.join("visible.rs"), "");
let link = dir.path().join("external-project");
symlink(&target, &link).unwrap();
let def = glob_tool(fs);
let (_, tool) = def();
let inp = serde_json::json!({
"path": link.to_str().unwrap(),
"pattern": "**/*.rs",
});
let err = tool.execute(&inp.to_string()).await.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Glob does not follow symlink directories"),
"{msg}"
);
assert!(msg.contains(&link.display().to_string()), "{msg}");
assert!(
msg.contains(&target.canonicalize().unwrap().display().to_string()),
"{msg}"
);
}
}

View File

@ -15,7 +15,7 @@ use manifest::Scope;
use serde::Deserialize;
use crate::error::ToolsError;
use crate::scoped_fs::ScopedFs;
use crate::scoped_fs::{ScopedFs, direct_symlink};
const DESCRIPTION: &str = "Recursive regex search across files, powered by \
ripgrep. Supports file filtering (`glob`, `type`), context lines, multiline \
@ -255,8 +255,52 @@ fn run_grep(default_base: PathBuf, p: GrepParams, scope: &Scope) -> Result<GrepR
if !base.is_absolute() {
return Err(ToolsError::RelativePath(base));
}
if !base.exists() {
return Err(ToolsError::NotFound(base));
let symlink = direct_symlink(&base);
if !scope.is_readable(&base) {
return Err(if let Some(info) = symlink.as_ref() {
let link_parent_readable = info
.link_path
.parent()
.map(|parent| scope.is_readable(parent))
.unwrap_or(false);
if info.target_exists && link_parent_readable {
ToolsError::SymlinkOutOfScope {
path: base.clone(),
target: info.resolved_path.clone(),
required_permission: "read",
}
} else {
ToolsError::OutOfScope(base.clone())
}
} else {
ToolsError::OutOfScope(base.clone())
});
}
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(ToolsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.target_path.clone(),
});
}
}
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ToolsError::NotFound(base.clone()),
_ => ToolsError::io(&base, e),
})?;
if !base_meta.is_dir() {
return Err(ToolsError::InvalidArgument(format!(
"grep search path is not a directory: {}",
base.display()
)));
}
if let Some(info) = symlink.as_ref() {
return Err(ToolsError::SymlinkDirectoryNotTraversed {
tool: "Grep",
path: base.clone(),
target: info.resolved_path.clone(),
});
}
let mut wb = WalkBuilder::new(&base);

View File

@ -42,6 +42,23 @@ pub struct WriteOutcome {
pub created: bool,
}
/// First symlink encountered while resolving a path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymlinkInfo {
/// The symlink path as it appears in the original path chain.
pub link_path: PathBuf,
/// The symlink target resolved relative to the symlink's parent when the
/// link stores a relative target.
pub target_path: PathBuf,
/// Best-effort resolved form of the full requested path after replacing
/// the symlink component with its target and rejoining any remaining tail.
/// Existing targets are canonicalized; broken targets are left absolute.
pub resolved_path: PathBuf,
/// Whether the symlink target itself exists. A missing target is a broken
/// symlink even when the symlink lives inside an allowed scope.
pub target_exists: bool,
}
impl ScopedFs {
/// Create a new [`ScopedFs`] wrapping `scope` and `pwd` in a fresh
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
@ -92,15 +109,34 @@ impl ScopedFs {
if !path.is_absolute() {
return Err(ToolsError::RelativePath(path.to_path_buf()));
}
if !self.inner.scope.load().is_readable(path) {
return Err(ToolsError::OutOfScope(path.to_path_buf()));
let symlink = first_symlink(path);
let scope = self.inner.scope.load();
if !scope.is_readable(path) {
return Err(symlink_out_of_scope_or_plain(
path,
symlink.as_ref(),
"read",
&scope,
));
}
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(broken_symlink_error(path, info));
}
}
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
_ => ToolsError::io(path, e),
})?;
if meta.is_dir() {
return Err(ToolsError::IsDirectory(path.to_path_buf()));
return Err(if let Some(info) = symlink.as_ref() {
ToolsError::SymlinkTargetIsDirectory {
path: path.to_path_buf(),
target: info.resolved_path.clone(),
}
} else {
ToolsError::IsDirectory(path.to_path_buf())
});
}
std::fs::read(path).map_err(|e| ToolsError::io(path, e))
}
@ -125,28 +161,50 @@ impl ScopedFs {
if !path.is_absolute() {
return Err(ToolsError::RelativePath(path.to_path_buf()));
}
let symlink = first_symlink(path);
let scope = self.inner.scope.load();
if !scope.is_writable(path) {
return Err(if scope.is_readable(path) {
ToolsError::ReadOnly(path.to_path_buf())
} else {
ToolsError::OutOfScope(path.to_path_buf())
symlink_out_of_scope_or_plain(path, symlink.as_ref(), "write", &scope)
});
}
drop(scope);
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(broken_symlink_error(path, info));
}
}
// Reject existing directory targets.
match std::fs::metadata(path) {
Ok(meta) if meta.is_dir() => {
return Err(ToolsError::IsDirectory(path.to_path_buf()));
return Err(if let Some(info) = symlink.as_ref() {
ToolsError::SymlinkTargetIsDirectory {
path: path.to_path_buf(),
target: info.resolved_path.clone(),
}
} else {
ToolsError::IsDirectory(path.to_path_buf())
});
}
_ => {}
}
let existed = path.exists();
let write_target = if existed {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let parent = path.parent().ok_or_else(|| {
ToolsError::InvalidArgument(format!("path has no parent directory: {}", path.display()))
let parent = write_target.parent().ok_or_else(|| {
ToolsError::InvalidArgument(format!(
"path has no parent directory: {}",
write_target.display()
))
})?;
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| ToolsError::io(parent, e))?;
@ -160,12 +218,12 @@ impl ScopedFs {
let mut tmp = tempfile::NamedTempFile::new_in(tmp_parent)
.map_err(|e| ToolsError::io(tmp_parent, e))?;
tmp.write_all(content)
.map_err(|e| ToolsError::io(path, e))?;
.map_err(|e| ToolsError::io(&write_target, e))?;
tmp.as_file()
.sync_all()
.map_err(|e| ToolsError::io(path, e))?;
tmp.persist(path)
.map_err(|e| ToolsError::io(path, e.error))?;
.map_err(|e| ToolsError::io(&write_target, e))?;
tmp.persist(&write_target)
.map_err(|e| ToolsError::io(&write_target, e.error))?;
Ok(WriteOutcome {
bytes_written: content.len(),
@ -174,6 +232,93 @@ impl ScopedFs {
}
}
/// Return the first symlink component in `path`, if one exists.
///
/// The function only inspects existing path components. It intentionally uses
/// `symlink_metadata` so the symlink itself can be diagnosed before any later
/// `metadata` call follows it and collapses the reason into `NotFound` or
/// `OutOfScope`.
pub fn first_symlink(path: &Path) -> Option<SymlinkInfo> {
if !path.is_absolute() {
return None;
}
let mut cur = PathBuf::new();
let mut components = path.components().peekable();
while let Some(component) = components.next() {
cur.push(component.as_os_str());
let meta = std::fs::symlink_metadata(&cur).ok()?;
if !meta.file_type().is_symlink() {
continue;
}
let raw_target = std::fs::read_link(&cur).ok()?;
let target_path = if raw_target.is_absolute() {
raw_target
} else {
cur.parent()
.unwrap_or_else(|| Path::new("/"))
.join(raw_target)
};
let target_exists = target_path.exists();
let mut resolved_path = target_path
.canonicalize()
.unwrap_or_else(|_| target_path.clone());
for remaining in components {
resolved_path.push(remaining.as_os_str());
}
return Some(SymlinkInfo {
link_path: cur,
target_path,
resolved_path,
target_exists,
});
}
None
}
pub fn direct_symlink(path: &Path) -> Option<SymlinkInfo> {
let meta = std::fs::symlink_metadata(path).ok()?;
if meta.file_type().is_symlink() {
first_symlink(path)
} else {
None
}
}
fn symlink_out_of_scope_or_plain(
path: &Path,
symlink: Option<&SymlinkInfo>,
required_permission: &'static str,
scope: &Scope,
) -> ToolsError {
if let Some(info) = symlink {
let link_parent_readable = info
.link_path
.parent()
.map(|parent| scope.is_readable(parent))
.unwrap_or(false);
if info.target_exists && link_parent_readable {
return ToolsError::SymlinkOutOfScope {
path: path.to_path_buf(),
target: info.resolved_path.clone(),
required_permission,
};
}
}
ToolsError::OutOfScope(path.to_path_buf())
}
fn broken_symlink_error(path: &Path, info: &SymlinkInfo) -> ToolsError {
ToolsError::BrokenSymlink {
path: path.to_path_buf(),
link: info.link_path.clone(),
target: info.target_path.clone(),
}
}
// =============================================================================
// Tests
// =============================================================================
@ -241,6 +386,90 @@ mod tests {
assert!(matches!(err, ToolsError::OutOfScope(_)));
}
#[cfg(unix)]
#[test]
fn read_bytes_reports_broken_symlink_target() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let fs = make_fs(&dir);
let link = dir.path().join("external-project");
let target = dir.path().join("missing-target");
symlink(&target, &link).unwrap();
let err = fs.read_bytes(&link).unwrap_err();
assert!(
matches!(
err,
ToolsError::BrokenSymlink { ref path, link: ref err_link, target: ref err_target }
if path == &link && err_link == &link && err_target == &target
),
"expected broken symlink diagnostic, got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn read_bytes_reports_symlink_target_outside_scope() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let target = outside.path().join("target.txt");
fs::write(&target, b"secret").unwrap();
let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap();
let fs = make_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err();
assert!(
matches!(
err,
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected symlink out-of-scope diagnostic, got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn read_bytes_allows_symlink_file_when_target_is_inside_scope() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.txt");
fs::write(&target, b"visible").unwrap();
let link = dir.path().join("link.txt");
symlink(&target, &link).unwrap();
let fs = make_fs(&dir);
assert_eq!(fs.read_bytes(&link).unwrap(), b"visible");
}
#[cfg(unix)]
#[test]
fn read_bytes_reports_symlink_to_directory_as_wrong_file_type() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let target_dir = dir.path().join("target-dir");
fs::create_dir(&target_dir).unwrap();
let link = dir.path().join("dir-link");
symlink(&target_dir, &link).unwrap();
let fs = make_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err();
assert!(
matches!(
err,
ToolsError::SymlinkTargetIsDirectory { ref path, ref target }
if path == &link && target == &target_dir.canonicalize().unwrap()
),
"expected symlink directory type diagnostic, got {err:?}"
);
}
// -------------------------------------------------------------------------
// write
// -------------------------------------------------------------------------
@ -267,6 +496,53 @@ mod tests {
assert_eq!(fs::read(&file).unwrap(), b"new");
}
#[cfg(unix)]
#[test]
fn write_existing_symlink_file_updates_in_scope_target() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let fs = make_fs(&dir);
let target = dir.path().join("target.txt");
fs::write(&target, b"old").unwrap();
let link = dir.path().join("link.txt");
symlink(&target, &link).unwrap();
let out = fs.write(&link, b"new").unwrap();
assert!(!out.created);
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
}
#[cfg(unix)]
#[test]
fn write_reports_symlink_target_outside_scope() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let target = outside.path().join("target.txt");
fs::write(&target, b"secret").unwrap();
let link = dir.path().join("outside-repo.txt");
symlink(&target, &link).unwrap();
let fs = make_fs(&dir);
let err = fs.write(&link, b"new").unwrap_err();
assert!(
matches!(
err,
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected write symlink out-of-scope diagnostic, got {err:?}"
);
}
#[test]
fn write_rejects_out_of_scope() {
let dir = TempDir::new().unwrap();

View File

@ -102,9 +102,13 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
.await
.unwrap_err();
assert!(
format!("{read_err}").contains("outside allowed scope"),
format!("{read_err}").contains("outside allowed read scope"),
"symlink read escape not rejected: {read_err}"
);
assert!(
format!("{read_err}").contains(&outside_target.display().to_string()),
"symlink read diagnostic should include resolved target: {read_err}"
);
// Write through the symlink must be rejected for the same reason.
let write = reg.get("Write");
@ -120,13 +124,39 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("outside allowed scope"),
msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope"),
"symlink escape not rejected: {msg}"
);
assert!(
msg.contains("add the symlink target"),
"symlink escape diagnostic should include remediation: {msg}"
);
// Outside file must not have been touched.
assert_eq!(std::fs::read_to_string(&outside_target).unwrap(), "secret");
}
#[cfg(unix)]
#[tokio::test]
async fn broken_symlink_reports_target_and_repair_hint() {
use std::os::unix::fs::symlink;
let (dir, _spill, reg) = setup();
let link = dir.path().join("external-project");
let target = dir.path().join("missing-target");
symlink(&target, &link).unwrap();
let read = reg.get("Read");
let err = read
.execute(&json!({ "file_path": link.to_str().unwrap() }).to_string())
.await
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("broken symlink"), "{msg}");
assert!(msg.contains(&link.display().to_string()), "{msg}");
assert!(msg.contains(&target.display().to_string()), "{msg}");
assert!(msg.contains("correct relative target"), "{msg}");
}
#[tokio::test]
async fn empty_file_read_and_edit() {
let (dir, _spill, reg) = setup();

View File

@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
client = { workspace = true }
protocol = { workspace = true }
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
crossterm = "0.28"

View File

@ -568,9 +568,7 @@ impl App {
}
Event::ToolCallDone { id, arguments, .. } => {
self.current_tool = None;
let name = self
.find_tool_call_mut(&id)
.map(|b| b.name.clone());
let name = self.find_tool_call_mut(&id).map(|b| b.name.clone());
if let Some(name) = name.as_deref() {
self.task_store.apply_tool_call(name, &arguments);
}
@ -679,15 +677,47 @@ impl App {
self.assistant_streaming = false;
}
Event::CompactStart => {
self.blocks.push(Block::Compact(CompactEvent::Start));
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
}
Event::CompactDone { new_session_id } => {
self.blocks
.push(Block::Compact(CompactEvent::Done { new_session_id }));
if let Some(evt) = self.last_streaming_compact_mut() {
let elapsed_secs = match evt {
CompactEvent::Streaming { started_at } => {
Some(started_at.elapsed().as_secs())
}
_ => None,
};
*evt = CompactEvent::Done {
new_session_id,
elapsed_secs,
};
} else {
self.blocks.push(Block::Compact(CompactEvent::Done {
new_session_id,
elapsed_secs: None,
}));
}
}
Event::CompactFailed { error } => {
self.blocks
.push(Block::Compact(CompactEvent::Failed { error }));
if let Some(evt) = self.last_streaming_compact_mut() {
let elapsed_secs = match evt {
CompactEvent::Streaming { started_at } => {
Some(started_at.elapsed().as_secs())
}
_ => None,
};
*evt = CompactEvent::Failed {
error,
elapsed_secs,
};
} else {
self.blocks.push(Block::Compact(CompactEvent::Failed {
error,
elapsed_secs: None,
}));
}
}
Event::Alert(alert) => {
self.blocks.push(Block::Alert {
@ -719,6 +749,7 @@ impl App {
}
}
Event::Shutdown => {
self.mark_orphan_compacts_incomplete();
self.quit = true;
}
}
@ -775,6 +806,33 @@ impl App {
}
}
fn last_streaming_compact_mut(&mut self) -> Option<&mut CompactEvent> {
for b in self.blocks.iter_mut().rev() {
match b {
Block::Compact(evt) if matches!(evt, CompactEvent::Streaming { .. }) => {
return Some(evt);
}
Block::Compact(_) => return None,
_ => continue,
}
}
None
}
pub(crate) fn mark_orphan_compacts_incomplete(&mut self) {
for b in self.blocks.iter_mut().rev() {
if let Block::Compact(evt) = b {
if let CompactEvent::Streaming { started_at } = evt {
*evt = CompactEvent::Incomplete {
elapsed_secs: Some(started_at.elapsed().as_secs()),
};
} else {
break;
}
}
}
}
fn find_tool_call_mut(&mut self, id: &str) -> Option<&mut ToolCallBlock> {
for b in self.blocks.iter_mut().rev() {
if let Block::ToolCall(tc) = b
@ -1310,6 +1368,66 @@ mod completion_flow_tests {
));
}
#[test]
fn compact_done_replaces_live_block() {
let mut app = App::new("test".into());
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
app.handle_pod_event(Event::CompactStart);
app.handle_pod_event(Event::CompactDone { new_session_id: id });
assert_eq!(compact_block_count(&app), 1);
assert!(matches!(
app.blocks.as_slice(),
[Block::Compact(CompactEvent::Done {
new_session_id,
elapsed_secs: Some(_),
})] if *new_session_id == id
));
}
#[test]
fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::CompactStart);
app.handle_pod_event(Event::CompactFailed {
error: "provider 429".into(),
});
assert_eq!(compact_block_count(&app), 1);
assert!(matches!(
app.blocks.as_slice(),
[Block::Compact(CompactEvent::Failed {
error,
elapsed_secs: Some(_),
})] if error == "provider 429"
));
}
#[test]
fn shutdown_marks_live_compact_incomplete() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::CompactStart);
app.handle_pod_event(Event::Shutdown);
assert!(app.quit);
assert!(matches!(
app.blocks.as_slice(),
[Block::Compact(CompactEvent::Incomplete {
elapsed_secs: Some(_),
})]
));
}
fn compact_block_count(app: &App) -> usize {
app.blocks
.iter()
.filter(|block| matches!(block, Block::Compact(_)))
.count()
}
fn test_greeting() -> protocol::Greeting {
protocol::Greeting {
pod_name: "test".into(),

View File

@ -78,9 +78,21 @@ pub enum ThinkingState {
}
pub enum CompactEvent {
Start,
Done { new_session_id: uuid::Uuid },
Failed { error: String },
/// Live block: compaction worker is running. `started_at` powers the
/// `Compacting... (Xs)` live timer.
Streaming { started_at: Instant },
/// Compaction ended cleanly with `CompactDone`.
Done {
new_session_id: uuid::Uuid,
elapsed_secs: Option<u64>,
},
/// Compaction ended with `CompactFailed`.
Failed {
error: String,
elapsed_secs: Option<u64>,
},
/// The TUI stopped observing events before a terminal compact event.
Incomplete { elapsed_secs: Option<u64> },
}
pub struct ToolCallBlock {

View File

@ -1,7 +1,6 @@
mod app;
mod block;
mod cache;
mod client;
mod input;
mod markdown;
mod picker;
@ -28,8 +27,9 @@ use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use session_store::SessionId;
use client::PodClient;
use crate::app::App;
use crate::client::PodClient;
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady};
@ -330,6 +330,7 @@ async fn run_loop(
Some(ev) => app.handle_pod_event(ev),
None => {
app.connected = false;
app.mark_orphan_compacts_incomplete();
app.push_error("Connection lost");
}
}

View File

@ -14,9 +14,9 @@
use std::io;
use std::path::PathBuf;
use std::process::Stdio;
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,
@ -29,11 +29,8 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::SessionId;
use tokio::process::Command;
const READY_PREFIX: &str = "INSOMNIA-READY\t";
const VIEWPORT_LINES: u16 = 6;
const READY_TIMEOUT: Duration = Duration::from_secs(20);
pub struct SpawnReady {
pub pod_name: String,
@ -50,9 +47,7 @@ pub enum SpawnError {
Io(io::Error),
Store(session_store::StoreError),
MissingResumeScope { session_id: SessionId },
PodLaunchFailed(io::Error),
PodExitedEarly { stderr_tail: String },
Timeout,
Spawn(client::SpawnError),
}
impl std::fmt::Display for SpawnError {
@ -64,19 +59,7 @@ impl std::fmt::Display for SpawnError {
f,
"session {session_id} has no persisted scope snapshot; refusing resume without explicit scope"
),
Self::PodLaunchFailed(e) => write!(f, "failed to launch pod: {e}"),
Self::PodExitedEarly { stderr_tail } => {
if stderr_tail.is_empty() {
write!(f, "pod exited before becoming ready")
} else {
write!(f, "pod exited before becoming ready: {stderr_tail}")
}
}
Self::Timeout => write!(
f,
"pod did not become ready within {}s",
READY_TIMEOUT.as_secs()
),
Self::Spawn(e) => write!(f, "{e}"),
}
}
}
@ -95,6 +78,12 @@ impl From<session_store::StoreError> for SpawnError {
}
}
impl From<client::SpawnError> for SpawnError {
fn from(e: client::SpawnError) -> Self {
Self::Spawn(e)
}
}
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Source session for a resume run. `None` = fresh spawn (current
@ -283,169 +272,23 @@ async fn wait_for_ready(
form: &mut Form,
overlay_toml: &str,
) -> Result<SpawnReady, SpawnError> {
let pod_bin = resolve_pod_command();
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
let pod_runtime_dir = manifest::paths::pod_runtime_dir(&form.name).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not resolve runtime directory (set INSOMNIA_HOME, INSOMNIA_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)",
)
})?;
std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?;
let stderr_path = pod_runtime_dir.join("stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
let mut command = Command::new(&pod_bin);
command
.arg("--overlay")
.arg(overlay_toml)
.current_dir(&cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_file))
.process_group(0);
if let Some(id) = form.resume_from {
command.arg("--session").arg(id.to_string());
}
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
// Default `kill_on_drop = false` plus `process_group(0)` makes this
// a detached Pod for TUI lifecycle purposes once startup succeeds:
// dropping the handle does not terminate it, and terminal-generated
// signals for the TUI's process group do not hit the Pod. Runtime
// state/socket files are the source of truth after that point.
let ready = match wait_for_ready_file(terminal, form, &stderr_path, &mut child).await {
Ok(ready) => ready,
Err(e) => {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(e);
}
let config = SpawnConfig {
pod_name: form.name.clone(),
overlay_toml: overlay_toml.to_string(),
cwd,
resume_from: form.resume_from,
};
tokio::spawn(async move {
let _ = child.wait().await;
});
Ok(ready)
}
async fn wait_for_ready_file(
terminal: &mut InlineTerminal,
form: &mut Form,
stderr_path: &std::path::Path,
child: &mut tokio::process::Child,
) -> Result<SpawnReady, SpawnError> {
let mut tail = StderrTail::new();
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
let mut offset = 0usize;
loop {
let content = match tokio::fs::read_to_string(stderr_path).await {
Ok(content) => content,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(SpawnError::Io(e)),
};
if content.len() > offset {
for line in content[offset..].lines() {
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
let mut parts = rest.splitn(2, '\t');
let pod_name = parts.next().unwrap_or("").to_string();
let socket_str = parts.next().unwrap_or("").to_string();
if pod_name.is_empty() || socket_str.is_empty() {
return Err(SpawnError::PodExitedEarly {
stderr_tail: format!("malformed ready line: {line}"),
});
}
let socket_path = PathBuf::from(socket_str);
wait_for_socket(
&socket_path,
deadline,
child,
stderr_path,
&mut tail,
&mut offset,
)
.await?;
return Ok(SpawnReady {
pod_name,
socket_path,
});
}
tail.push(line);
form.message = Some((line.to_string(), MessageKind::Progress));
let _ = terminal.draw(|f| draw_form(f, form));
}
offset = content.len();
}
if tokio::time::Instant::now() >= deadline {
return Err(SpawnError::Timeout);
}
tokio::select! {
status = child.wait() => {
let _ = status;
// Pod は exit 直前に最終 stderr 行を flush することがある。
// child.wait() が解決した後に再読みして、原因行を取りこ
// ぼさず PodExitedEarly に載せる。
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
return Err(SpawnError::PodExitedEarly {
stderr_tail: tail.into_string(),
});
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
}
}
}
async fn wait_for_socket(
socket_path: &std::path::Path,
deadline: tokio::time::Instant,
child: &mut tokio::process::Child,
stderr_path: &std::path::Path,
tail: &mut StderrTail,
offset: &mut usize,
) -> Result<(), SpawnError> {
loop {
match tokio::net::UnixStream::connect(socket_path).await {
Ok(_) => return Ok(()),
Err(e)
if e.kind() == io::ErrorKind::NotFound
|| e.kind() == io::ErrorKind::ConnectionRefused => {}
Err(e) => return Err(SpawnError::Io(e)),
}
if tokio::time::Instant::now() >= deadline {
return Err(SpawnError::Timeout);
}
tokio::select! {
status = child.wait() => {
let _ = status;
drain_stderr_into_tail(stderr_path, tail, offset).await;
return Err(SpawnError::PodExitedEarly {
stderr_tail: tail.as_string(),
});
}
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
}
}
}
async fn drain_stderr_into_tail(
stderr_path: &std::path::Path,
tail: &mut StderrTail,
offset: &mut usize,
) {
let Ok(content) = tokio::fs::read_to_string(stderr_path).await else {
return;
};
if content.len() <= *offset {
return;
}
for line in content[*offset..].lines() {
if !line.starts_with(READY_PREFIX) {
tail.push(line);
}
}
*offset = content.len();
let ready = spawn_pod(config, |line| {
form.message = Some((line.to_string(), MessageKind::Progress));
let _ = terminal.draw(|f| draw_form(f, form));
})
.await?;
Ok(SpawnReady {
pod_name: ready.pod_name,
socket_path: ready.socket_path,
})
}
fn build_overlay_toml(form: &Form) -> String {
@ -496,47 +339,6 @@ async fn load_resume_scope(session_id: SessionId) -> Result<ScopeConfig, SpawnEr
})
}
/// Resolves the binary used to launch a child Pod. Must point at a
/// `pod`-compatible executable — the parent reads the child's stderr
/// directly looking for `INSOMNIA-READY`, so any wrapper that emits
/// extra lines on stderr will pollute that handshake.
///
/// `INSOMNIA_POD_COMMAND` overrides the lookup (used by tests to inject
/// a mock binary). Otherwise we defer to `PATH` — missing binary
/// surfaces as the spawn `io::Error`.
fn resolve_pod_command() -> PathBuf {
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") {
if !cmd.is_empty() {
return PathBuf::from(cmd);
}
}
PathBuf::from("pod")
}
struct StderrTail {
lines: std::collections::VecDeque<String>,
}
impl StderrTail {
fn new() -> Self {
Self {
lines: std::collections::VecDeque::with_capacity(8),
}
}
fn push(&mut self, line: &str) {
if self.lines.len() == 8 {
self.lines.pop_front();
}
self.lines.push_back(line.to_string());
}
fn as_string(&self) -> String {
self.lines.iter().cloned().collect::<Vec<_>>().join(" | ")
}
fn into_string(self) -> String {
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
}
}
enum MessageKind {
Info,
Ok,

View File

@ -1011,21 +1011,45 @@ fn fmt_elapsed(secs: u64) -> String {
fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, width: u16, mode: Mode) {
let (text, kind) = match evt {
CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn),
CompactEvent::Done { new_session_id } => {
CompactEvent::Streaming { started_at } => {
let secs = started_at.elapsed().as_secs();
(
format!("Compacting... ({})", fmt_elapsed(secs)),
MessageKind::NoticeWarn,
)
}
CompactEvent::Done {
new_session_id,
elapsed_secs,
} => {
let short = new_session_id
.to_string()
.chars()
.take(8)
.collect::<String>();
let elapsed = elapsed_suffix(*elapsed_secs);
(
format!("[compact] done (new session {short})"),
format!("[compact] done (new session {short}){elapsed}"),
MessageKind::NoticeWarn,
)
}
CompactEvent::Failed { error } => {
(format!("[compact error] {error}"), MessageKind::NoticeError)
CompactEvent::Failed {
error,
elapsed_secs,
} => {
let elapsed = elapsed_suffix(*elapsed_secs);
(
format!("[compact error] {error}{elapsed}"),
MessageKind::NoticeError,
)
}
CompactEvent::Incomplete { elapsed_secs } => match elapsed_secs {
Some(s) => (
format!("[compact] interrupted ({})", fmt_elapsed(*s)),
MessageKind::NoticeError,
),
None => ("[compact] interrupted".to_owned(), MessageKind::NoticeError),
},
};
match mode {
Mode::Overview => push_overview_line(lines, &text, width, kind, ""),
@ -1033,6 +1057,12 @@ fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, width: u16
}
}
fn elapsed_suffix(elapsed_secs: Option<u64>) -> String {
elapsed_secs
.map(|s| format!(" ({})", fmt_elapsed(s)))
.unwrap_or_default()
}
fn draw_separator(frame: &mut Frame, area: Rect) {
let line = "".repeat(area.width as usize);
frame.render_widget(

12
docs/file-ref-symlinks.md Normal file
View File

@ -0,0 +1,12 @@
# File references and symlinks
FileRef resolution and file tools follow symlinks only after the resolved target passes the Pod scope check. A symlink placed inside the workspace does not grant access to the target by itself.
Recommended external-reference workflow:
- Prefer adding the real external project path, such as a local `ghq` clone, to the Pod read scope when the Pod is started or spawned.
- If a workspace symlink is used, the symlink target still must be inside readable scope. For writes, the resolved target must be inside writable scope.
- If a relative symlink is broken, recreate it with the correct relative target from the symlink's parent directory, or use an absolute symlink.
- Directory traversal tools such as Glob and Grep do not follow symlink directories. Use the resolved target directory directly when it is in read scope.
This preserves symlink escape safety: access decisions are made on the canonicalized target whenever the target exists, and broken or out-of-scope symlinks are rejected with diagnostics that include the original path and target where possible.

View File

@ -34,6 +34,7 @@ overlay をマージして、検証済みの `PodManifest` と `PromptLoader`
| 配列スカラー(`worker.stop_sequences` 等) | 上層に値があれば配列ごと置換。追記マージはしない |
| マップ(`tool_output.per_tool` 等) | キー単位でマージ、同一キーは上層優先 |
| `scope.allow` / `scope.deny` | **union**(各層から全部足す)。上位層は `deny` で下位層の `allow` を必ず削れる |
| `permissions.rule` | **union**(下位層の rule → 上位層の rule の順に評価)。`permissions.default_action` は上位層があれば上書き |
各層をマージした結果(`PodManifestConfig`)を `TryFrom<PodManifestConfig>
for PodManifest` が必須フィールド検証と絶対パス検証をかけて `PodManifest`
@ -161,6 +162,19 @@ recursive = false
target = "/abs/path/to/project/secrets"
permission = "write"
[permissions]
default_action = "allow" # allow | deny | ask
[[permissions.rule]]
tool = "Bash"
pattern = "rm *"
action = "deny"
[[permissions.rule]]
tool = "Write"
pattern = "*.env"
action = "deny"
[compaction]
prune_protected_turns = 3
prune_min_savings = 4096
@ -201,6 +215,24 @@ scheme 側が吸収する。
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。
## `[permissions]` 設定
`[permissions]` が無い場合、ツール permission 層は無効で従来通り実行する。`[permissions]` を書く場合は `default_action = "allow" | "deny" | "ask"` が必須で、`[[permissions.rule]]` は宣言順に最初に一致した rule が採用される。一致しなければ `default_action` を使う。
```toml
[permissions]
default_action = "allow"
[[permissions.rule]]
tool = "Bash"
pattern = "rm *"
action = "deny"
```
`tool` は実行時に登録されているツール名(`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep` 等)に対して大小文字を無視して照合する。`pattern` は built-in tool では主に `command` / `file_path` / `path` / `pattern` 引数に対する `*` / `?` ワイルドカードとして評価される。
`allow` は通常実行、`deny` はその tool call を実行せず `is_error = true` の synthetic tool result を履歴へ追加してターンを継続する。`ask` は型として受け付けるが、承認 protocol は未実装のため現在は headless に待機せず fail-closedsynthetic error resultになる。
## instruction とプロンプト資産
### `worker.instruction` フィールド

56
tickets/client-crate.md Normal file
View File

@ -0,0 +1,56 @@
# Client crate の切り出し
## 背景
protocol を喋る socket クライアントは現在 `crates/tui/src/client.rs` に閉じている。今後の二つの方向で、TUI 内に閉じていることが障害になる:
- **GUI MVP** (`tickets/native-gui-mvp.md`): GUI バイナリも同じ protocol を喋る。チケット側の検討事項にも「socket client を別 crate に切り出して共有するか、GUI crate 内に閉じて持つか」が挙げられている (`native-gui-mvp.md:67`)。
- **E2E ハーネス** (`tickets/e2e-harness.md`): TUI バイナリを PTY で叩くのは脆く、GUI バイナリは MVP 中。E2E から protocol を直接喋る入口として client crate が要る。
TUI 内に置いたまま GUI と E2E から再利用しようとすると、TUI のレンダリング都合と client の責務が混ざる。先に切り出しておく。
## 方針
- `crates/client/` を新設し、socket への接続・`Method` 送信・`Event` 受信・graceful shutdown までの低レベル機能を移す。
- TUI / GUI / E2E は当 crate を依存先として呼び出す。Pod の subprocess 起動も client crate 側で扱うのが自然か、別 crate / 上位呼び出し側に残すかは設計で詰めるGUI が subprocess を直接 spawn する流儀との整合)。
- 移行は機能等価で、TUI に regression を起こさないこと。
## 検討事項
- crate 名: `client` で良いか、より具体的な名前にするか(`pod-client` 等。`feedback_crate_naming.md` の方針に従いプレフィックスは付けない)。
- subprocess spawn 責務: client crate に含めるか、呼び出し側に残すか。GUI MVP では「GUI から直接 pod を spawn」する流儀なので、spawn と connect を分離して両方公開しておくのが妥当そう。
- 公開 API の境界: 生 socket / `JsonLineReader` までを露出するか、もう一段抽象化したリクエスト/サブスクライブ API にするか。
- 非同期ランタイム: tokio 前提で良いかGUI の GPUI executor との統合は GUI 側で吸収する)。
- error 型: TUI の `client.rs` 内で持っている error をそのまま move するか、再設計するか。
## 要件
- `crates/client/` が新設され、現 `crates/tui/src/client.rs` 相当の機能を提供する。
- TUI は新 crate に依存して動作し、既存テスト・既存挙動が通る。
- API は GUI / E2E から呼べる粒度で公開されている(最低限: 接続、`Method` 送信、`Event` ストリーム購読、shutdown
- pod subprocess を spawn する経路をどこに置くかが決まり、必要なら本 crate からも呼べる。
## 完了条件
- 上記要件が満たされる。
- TUI が新 crate を使って従来通り動く(`cargo test -p tui` / TUI 手動起動で regression 無し)。
- E2E ハーネス(`tickets/e2e-harness.md`)が本 crate に依存して protocol を喋れる状態になる。
## 範囲外
- GUI バイナリ実装そのもの(`tickets/native-gui-mvp.md`)。
- protocol の拡張・互換破壊。
- TUI のリファクタリングを切り出し以上にやること(責務移動だけに留める)。
- daemon 層の導入。
## 依存 / 関連
- `tickets/native-gui-mvp.md`
- `tickets/e2e-harness.md`
- `crates/tui/src/client.rs`
- `crates/protocol/`
## Review
- 状態: Approve
- レビュー詳細: [./client-crate.review.md](./client-crate.review.md)
- 日付: 2026-05-09

View File

@ -0,0 +1,48 @@
# Review: Client crate の切り出し
## 前提・要件の確認
- `crates/client/` 新設、`crates/tui/src/client.rs` 相当の機能を提供 (`tickets/client-crate.md:28`)
- 満たされている。`crates/tui/src/client.rs` は `crates/client/src/pod_client.rs` へ純粋 rename されており (`PodClient::{connect, send, try_next_event, next_event}` の 4 API は完全に同一)、`Drop` で reader タスクが mpsc 切断を契機に終了する graceful shutdown 挙動も同じ。
- TUI が新 crate に依存して動作・既存テストが通る (`tickets/client-crate.md:29`)
- 満たされている。`crates/tui/Cargo.toml:8` で `client = { workspace = true }` を追加、`crates/tui/src/main.rs:30` で `use client::PodClient`、`crates/tui/src/spawn.rs:19` で `use client::{SpawnConfig, spawn_pod}`。`cargo test -p tui` 91 件 pass、ローカルで `cargo build --workspace` も完走。
- API は GUI / E2E から呼べる粒度で公開 (`tickets/client-crate.md:30`)
- 満たされている。`crates/client/src/lib.rs:14-15` で `PodClient``spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod}` が re-export されており、接続 / Method 送信 / Event 購読 (`next_event` / `try_next_event`) / shutdown (Drop) と subprocess spawn が独立して呼び出せる粒度で揃う。
- pod subprocess を spawn する経路の決定 (`tickets/client-crate.md:31`)
- 満たされている。client crate に同梱しつつ `spawn_pod``PodClient::connect` を分離。GUI が「自身で spawn せず attach する」「自身で spawn する」のいずれを選んでも一段の関数呼び出しで済む。
## 完了条件 (`tickets/client-crate.md:33-37`)
- 上記要件を満たす点は OK。
- TUI 既存挙動・テスト通過は OK (`spawn::tests::*` 6 件含めて 91 件 pass)。
- E2E ハーネス (`tickets/e2e-harness.md`) は本 crate に依存して protocol を喋れる状態 — 公開 API として `PodClient::connect(&Path) -> Result<Self, io::Error>``spawn_pod(SpawnConfig, FnMut(&str)) -> Result<SpawnReady, SpawnError>` が揃ったため、依存追加だけで E2E が protocol を直接駆動できる。OK。
## アーキテクチャ・スコープ
- 範囲外 (`tickets/client-crate.md:39-44`) を侵していない:
- GUI バイナリ実装には踏み込んでいない。
- protocol 互換破壊なし (`pod_client.rs` は `JsonLineReader/Writer` をそのまま使用)。
- TUI のリファクタは責務移動のみ。`Form` / `draw_form` / `build_overlay_toml` / `load_resume_scope` 等の UI / manifest 解決ロジックは TUI に残置。`crates/tui/src/spawn.rs:541-653` の単体テストも手付かず。
- daemon 層には触れていない。
- crate 命名: `client` (プレフィックス無し)。`feedback_crate_naming.md` 方針に準拠。
- 依存追加方法: `Cargo.toml``[workspace.dependencies]` への登録は workspace 規約上必須なため手動編集が正当 (`cargo add` の対象外)。`crates/client/Cargo.toml` も `protocol` / `manifest` / `tokio` / `uuid``workspace = true` 経由で取得しており方針通り。
- レイヤ整合: `client``protocol` / `manifest` / `tokio` のみに依存し、`session-store` や上位レイヤを引きずり込んでいない。`session_store::StoreError` 関連の error は TUI 側の `SpawnError::{Store, MissingResumeScope}` に残置されており、責務分割が綺麗に出来ている (`crates/tui/src/spawn.rs:46-85`)。
- `SpawnError` の Display 移送: 旧 TUI の `SpawnError` のうち `PodLaunchFailed` / `PodExitedEarly` / `Timeout` / runtime dir 解決失敗 / `Io``client::SpawnError` 側へ移動し、TUI 側は `Spawn(client::SpawnError)` でラップして `{e}` で透過表示。重複なくユーザー向けメッセージが保持されている。
- 公開 API 境界: 生 socket / `JsonLineReader` を露出せず、薄いラッパーに留めた。チケットの検討事項 (`tickets/client-crate.md:22`) に対する妥当な選択で、既存挙動を壊さない最小範囲。
## 指摘事項
### Non-blocking / Follow-up
- `crates/client/src/spawn.rs` に単体テストが無い (`INSOMNIA_POD_COMMAND` で mock pod を差し込めば ready / timeout / 早期 exit の各パスをテスト可能)。E2E ハーネス側で間接的にカバーされる予定なら不要だが、E2E チケット先行よりこの crate の自己完結テストが入っていると将来回帰検出が容易。
- `SpawnConfig::cwd``PathBuf` フィールドで強制される一方、TUI 側 `wait_for_ready` (`crates/tui/src/spawn.rs:275`) は `run()` の冒頭で取った cwd と独立にもう一度 `current_dir()` を呼んでいる。動作上は等価だが、責務切り出しの完了を機に `run()` で取得した cwd を form に持たせて一度だけ参照する形にできる (今回スコープ外でも可)。
- `crates/client/src/spawn.rs:21-22``READY_PREFIX` / `READY_TIMEOUT` は pod 側 (`crates/pod/`) の出力フォーマットと結合した contract だが、両者の同期は型/定数共有でなく目視確認のまま。protocol crate に const として置く方が長期的に安全だが、これも今回の責務切り出しスコープを超える。
### Nits
- `crates/client/src/lib.rs:5` の docstring "pod バイナリをサブプロセスとして起動し" は OK だが、`spawn_pod` が `kill_on_drop = false` + `process_group(0)` で detach する点を 1 行追加するとライフサイクル不変条件が見えやすい (本体側 `crates/client/src/spawn.rs:9-11` には記載されているのでそちらへの参照でもよい)。
- `SpawnReady` の同名構造体が `crates/tui/src/spawn.rs:35-38` にも残っており、`client::SpawnReady` を unwrap して TUI 側で再構築している (`crates/tui/src/spawn.rs:288-291`)。TUI 側の `SpawnReady` は外部公開されないため `pub use client::SpawnReady` に置換可能だが、TUI 内 API 表面の安定性に関わるので任意。
## 判断
Approve — 要件・完了条件・範囲外の規定に正しく収まっており、責務移動以上のリファクタは行われていない。新 crate は他 crate から再利用できる粒度で公開され、TUI 既存挙動は 91 件のテストで保たれている。Follow-up の指摘は本チケットの完了を妨げない。

86
tickets/e2e-harness.md Normal file
View File

@ -0,0 +1,86 @@
# E2E テストハーネス
## 背景
`CLAUDE.md:6` で明記している通り、現状「実プロセスをスポーンさせての E2E」は未設計である。crate 内 integration test は 121 ファイル / 969 ケースまで揃っているが、以下の領域は in-process では再現できず、複数チケットの完了条件が宙に浮いている。
- `pod-cli-manifest-flags`: `--manifest` / `INSOMNIA_USER_MANIFEST` / 併用 conflict など実 CLI 挙動
- `pod-persistent-state`: Pod プロセス**再起動後**の active session 復元、spawner 再起動後の `ListPods` 復元
- `pod-session-fork`: `pod_cli` から fork → 新 session で run まで通せる
- `pod-parent-turn-callback`: 実子 Pod を spawn した状態の親 history 反映
- `pod-empty-turn-rollback`: 「TUI / pod_cli いずれの経路でも」明記
- `permission-extension-point.review.md:21`: `[permissions]` を含む Pod 構築 → tool deny までの結合検証
- `llm-worker-stream-continuation`: SSE 途中切断 + 継続/中断と課金重複が無いことの確認
- `native-gui-mvp`: GUI から `pod` subprocess を起動 → socket 接続 → graceful shutdown
`crates/pod/tests/spawn_pod_test.rs` のように subprocess を `/bin/true` ですり替える擬似手法は既にあるが、これは「子 Pod が即終了する状況下での registry 書き込み」を見るためのもので、実 pod を立ち上げての protocol 往復はしていない。
## 方針
- ワークスペース直下 **`tests/e2e/`** に E2E 専用の crate を切る。E2E は単一の crate / バイナリの責務ではないため、既存 `crates/<x>/tests/` には置かない。
- 実 `pod` バイナリは `env!("CARGO_BIN_EXE_pod")` で取得。ファイルシステムは tmpdir に閉じ、`INSOMNIA_RUNTIME_DIR` / data dir / `INSOMNIA_USER_MANIFEST` 等を env で完全 sandbox 化する。
- protocol を喋る側は **`tickets/client-crate.md` で切り出す `client` crate** を直接利用する。TUI バイナリを PTY で叩く方針は採らないGUI MVP との整合と E2E 安定性の観点から)。
- CI 既定実行から外す。`--features e2e` か独立ジョブで opt-in。ローカルでは `cargo test -p e2e --features e2e` 相当で叩ける形にする。
## 詰めたい論点(実装前に決める)
### 1. LLM provider のスタブ手段が fixture HTTP 再生だけで充足するか
既存 `crates/llm-worker/tests/anthropic_fixtures.rs` 等は in-process loader として書かれており、HTTP サーバーとして再生する形にはなっていない。E2E では Pod プロセスが env で渡された URL に対して実 HTTP を叩く以上、**最低限「fixture を返す HTTP サーバー」** は必要。
ただし、それだけで充足するかは不明:
- **動的応答が要るシナリオ**: SSE を途中で能動的に切る (`llm-worker-stream-continuation`)、tool 呼び出しの結果に応じて分岐する応答、複数ターンに渡る会話の途中で挙動を変える、など。録画再生だけでは作りにくい。
- **provider 差**: Anthropic / OpenAI Responses / Gemini / Ollama / Codex OAuth で endpoint / 認証 / スキーマが違う。E2E で全 provider を回す必要は無いが、最低 1〜2 provider はハーネスを持たせるべきで、選定が要る。
- **OAuth 系**: Codex OAuth はトークン取得経路自体が外部依存。E2E では事前注入された token を読む形に倒すか、OAuth flow ごと canned server で模すか。
このチケットでは「fixture HTTP 再生」を出発点としつつ、**動的応答のための最小 canned server インターフェース**(テストケース側からハンドラを差し替えられる形)も同時に検討範囲に含める。両方が無いと上のシナリオが書けない。
### 2. provider URL の差し替え経路
各 provider の base URL を env で上書きできる前提が、現コードに揃っているか確認・整備する必要がある。揃っていなければ別チケットに切り出すか、本チケット内で minimal に対応するか決める。
### 3. fixture 形式
既存の in-process fixture (`tests/*_fixtures.rs`) と HTTP 再生用 fixture を同じソースから作るか、別管理にするか。共通化できるなら record/replay 経路を整備する。
### 4. 並列実行と env 干渉
`spawn_pod_test.rs` は env mutex で直列化している。E2E でも env (`INSOMNIA_*`)・runtime dir・socket path に依存する以上、テスト並列度の方針を決める(`--test-threads=1`、test-per-process、または env を引数にハンドオフして mutex 不要にする)。
### 5. 失敗時の診断
実プロセスが絡むためスタックトレースだけでは原因特定しにくい。pod の stderr / stdout、session log、runtime dir の中身をテスト失敗時に dump する仕組みを最初に入れておく。
## 要件
- `tests/e2e/` 以下に E2E 用 crate仮称 `e2e`)が存在し、`Cargo.toml` の `[features] e2e = []` で gate されている。
- `cargo test -p e2e --features e2e` で実 `pod` バイナリを spawn し、protocol 経由で 1 シナリオ(最小: spawn → 1 turn 実行 → graceful shutdownが通る。
- LLM provider のスタブが少なくとも 1 provider 分動き、上の最小シナリオが本物の HTTP 越しに完結する。
- env / tmpdir / socket path が tmpdir 内に閉じ、テスト間の干渉が無い。
- テスト失敗時に pod プロセスの stderr / 関連ファイルが artefact として確認できる。
- CI 既定パス (`cargo test --workspace`) では E2E が走らない。opt-in jobs でだけ走る。
- 上の論点 1〜5 が文書化されている(チケット内 or `docs/` 配下のいずれか)。
## 完了条件
- 上記要件を満たすハーネスが入り、最小シナリオ 1 本が通る。
- 後続シナリオpermission deny / cli-manifest-flags / spawn 親子 / resume / fork / stream-continuationを**書く側の手順書**が提示されているfixture 追加方法、シナリオ crate の追加方法、env のお作法)。
- 個別シナリオの実装は本チケットに含めない。後続チケットで切る。
## 範囲外
- 個別 E2E シナリオの実装permission deny / cli flags / spawn / resume / fork / stream-continuation。それぞれ後続チケット。
- 全 provider 分の HTTP スタブ(最初は 1 provider に絞る)。
- TUI バイナリを PTY で操作する経路。
- GUI バイナリの E2E`tickets/native-gui-mvp.md` 完了後に別途)。
- E2E を CI 既定で走らせる切替。
## 依存 / 関連
- `tickets/client-crate.md`protocol を喋る client crate を切り出す。E2E はここに依存して書く)
- `tickets/llm-worker-stream-continuation.md`(動的応答 canned server を必要とする最初のシナリオ)
- `tickets/permission-extension-point.review.md`(最初に書きたいシナリオ)
- `crates/pod/tests/spawn_pod_test.rs`env mutex 等の流儀を流用)
- `crates/llm-worker/tests/*_fixtures.rs`fixture 資産の出発点)
- `CLAUDE.md:6`E2E 未設計の宣言)

View File

@ -0,0 +1,31 @@
# Permission: allow-all 既定 policy への整理
## 背景
現在の tool permission は `[permissions]` セクションが無い場合に permission 層を無効化し、`[permissions]` がある場合だけ `default_action` を必須としている。
実行時の意味として、未指定時の挙動はほぼ `default_action = "allow"` と同じであり、`Option<ToolPermissionConfig>` による「無効」と allow-all policy が型上で分かれていることが仕様理解と実装の分岐を増やしている。
## 要件
- resolved manifest の permission は常に policy として存在する形に整理する。
- 既定 policy は allow-all とし、`default_action = "allow"` かつ rule なしと同等にする。
- manifest に `[permissions]` が無い既存ユーザー設定は従来通り全ツール実行可能にする。
- `default_action = "deny"` による allowlist 型運用と、`default_action = "allow"` + deny rule による blocklist 型運用を明確に維持する。
- merge/parse 用の partial config では、「その層が permissions に触れていない」ことを表現できるようにする。
## 方針
`PodManifest` のような resolve 後の型では `permissions: ToolPermissionConfig` を持ち、`ToolPermissionConfig::default()` を allow-all とする。
`PodManifestConfig` / partial 側では階層 manifest の merge semantics のために `Option<PermissionConfigPartial>` を残してよい。resolve 時に未指定を allow-all default policy へ畳み込む。
`[permissions]` セクションを書いた場合の `default_action` 必須制約は見直す。rule だけを書いた場合は `default_action = "allow"` と解釈できるようにするか、明示必須を維持する場合でも resolved 型上は allow-all default と矛盾しない形にする。
## 完了条件
- resolve 後の manifest から permission policy の `Option` が消えている。
- `[permissions]` 未指定時に allow-all policy が得られる。
- permission rule 評価と Pod への built-in hook 登録が、常在 policy 前提で単純化されている。
- manifest resolve / merge / permission hook のテストが新しい既定値をカバーしている。
- docs の `[permissions]` 説明が allow-all 既定であることを明記している。

View File

@ -1,105 +0,0 @@
# パーミッション: パターンベースのツール実行制御
## 背景
現状の `Scope` はディレクトリ単位の書き込み制約で、静的な境界線。
実際のエージェント運用では、ツール単位・引数パターン単位の動的な権限制御が必要になる。
OpenCode はパターンベースのルールtool × pattern → allow/deny/askを持ち、
`*.env` への書き込み拒否や `rm -rf` の実行拒否を宣言的に設定できる。
## 方針
Permission の評価点は `PreToolCall` Hook とする。マニフェストにルールを宣言し、
insomnia 層が built-in の `PreToolCall` Hook として登録してツール呼び出し時に評価する。
`deny` はターン全体の Cancel/Abort ではない。対象 tool call を実行せず、
permission denied を表す `is_error = true` の synthetic tool result を履歴に追加してターンを継続する。
これにより provider が要求する `tool_use` / `tool_result` の対応を壊さず、LLM は拒否結果を見て別手段の検討やユーザーへの説明に進める。
`ask``deny` の代替ではなく、ユーザー承認待ちを明示する action として扱う。承認されれば元の tool call を実行し、拒否されれば `deny` と同じ synthetic tool result に落とす。
```toml
[permissions]
default_action = "allow" # allow | deny | ask
[[permissions.rule]]
tool = "bash"
pattern = "rm *"
action = "deny"
[[permissions.rule]]
tool = "file_write"
pattern = "*.env"
action = "deny"
```
allowlist 型にしたい場合:
```toml
[permissions]
default_action = "deny"
[[permissions.rule]]
tool = "read"
pattern = "*"
action = "allow"
[[permissions.rule]]
tool = "grep"
pattern = "*"
action = "allow"
```
確認待ちを基本にしたい場合:
```toml
[permissions]
default_action = "ask"
[[permissions.rule]]
tool = "bash"
pattern = "rm *"
action = "deny"
```
評価順序:
1. `[permissions]` が無い場合、Permission 層は無効。従来通り実行する
2. `[permissions]` がある場合、`default_action` は必須
3. `[[permissions.rule]]` は宣言順に評価し、最初に `tool``pattern` が一致した rule の `action` を採用する
4. 一致する rule が無ければ `permissions.default_action` を採用する
## 設計ポイント
- 設計原則3: 新しい trait は作らない。`PreToolCall` Hook として実装
- 設計原則2: マニフェストに宣言した以上、insomnia 層が解決する
- Permission Hook は Pod が自動登録する built-in Hook とし、ユーザー追加 Hook より先に評価する
- `deny``PreToolAction::Abort` / 既存 `Skip` では表現しない。tool call 単位の拒否結果を履歴へ返すため、Worker 側に synthetic tool result を返せる action が必要
- `ask` アクションは Pod Protocol の拡張が必要Event に `PermissionRequest`、Method に `PermissionReply` を追加)
- `ask` を処理できない実行環境では暗黙に待機しない。設定時に validation error とするか、fail closed で `deny` 相当の synthetic tool result に落とす
- `Scope` との関係: Scope は書き込みの物理的境界、Permission はツール実行のポリシー。補完関係
- ルール評価はパターンマッチのみ。コンテキスト依存の判断はしない(シンプルに保つ)
## 段階的実装
1. **拡張ポイントの記録**(今): docs/pod.md の拡張ポイント表に追加
2. **deny/allow の実装**(ツール実装時): `default_action` と rule 評価を manifest に追加し、built-in `PreToolCall` Hook でパターン評価
3. **拒否 tool result の実装**: `deny` が turn Abort ではなく synthetic error tool result として履歴に入るよう Worker の pre-tool action を拡張
4. **ask の実装**Protocol 拡張時): Method/Event に Permission 関連メッセージを追加し、承認後に元 tool call を実行、拒否時は synthetic error tool result を返す
## 受け皿になる外部仕様
### Agent Skills `allowed-tools`
`tickets/agent-skills.md` で ingest した SKILL.md の frontmatter には agent-skills 仕様の experimental field である `allowed-tools` (例: `["Read", "Bash"]`) が含まれる場合がある。`crates/memory/src/skill.rs::parse_skill_md` 時点では `tracing::warn!` で受け流しているだけで、実効化していない。
本チケットの Permission 層が固まった時点で、Skill 由来 Workflow を実行中のみ当該 skill の `allowed-tools` リストに含まれるツールしか走れない形で反映する。スコープは「Workflow 実行中」相当 (Workflow の system message が context に乗っているターン) に限定する想定。skill 単位で local な permission 集合を持つので、グローバルな `[[permissions.rule]]` ルールとは独立に評価する。
実装上の足がかり:
- `WorkflowRecord` の出所は `WorkflowSource::Skill { dir }` で識別済み (`crates/memory/src/workflow.rs`)。`dir` は manifest `[skills] directories` に書かれた skill ルートそのもの
- 受け皿実装時に `SkillFrontmatter::allowed_tools` の保持先を `WorkflowRecord` に伸ばすか、別の SkillRecord registry を持つかは本チケット内で決める
- 現状の `tracing::warn!` は受け皿実装と同時に消す
## 依存チケット
- ~~[remove-hook-module.md](remove-hook-module.md)~~ — 完了。PreToolCall は Pod 層の `hook::Hook<PreToolCall>` として利用可能

View File

@ -38,6 +38,11 @@
- Pod が過去に辿った session / log の順序付き履歴をどこに持つべきか。
- runtime state と persistent state の境界。
- `history.json` / `status.json` / `spawned_pods.json` を永続正本として扱わない方針の確認。
- session log の `pod.scope` extension entry を撤廃するか(要検討)。
- 現状: session log に `PodScopeSnapshot` を extension として append し、session 復元時に scope も復元できる。
- Pod 単位永続化(`tickets/pod-persistent-state.md`が入ると、scope の正本を Pod state に持つほうが責務が明確で、session log との重複も解消できる。
- ただし scope は session 内で動的に変化し得るため、「最新 snapshot は Pod state 」「変化履歴は session log の event として残す」のような分割もあり得る。conversation timeline 上の scope 変化を session 単独で再現する必要があるかが論点。
- 撤廃の選択肢: (a) session log から完全に削除し Pod state を唯一の正本にする / (b) snapshot 保持責務だけ Pod state に寄せ、scope 変更 event は session log に残す / (c) 現状維持で Pod state は session への参照のみ。
- DB backend を想定した場合のテーブル / relation 相当の構造。
- append-only entry log
- lineage / origin

View File

@ -0,0 +1,148 @@
# Prompt / Workflow 評価メトリクスと改善 Offer
## 背景
mizchi の empirical-prompt-tuning は、agent-facing な指示Skill / slash command / prompt 等)を新規 subagent に実行させ、実行者の自己申告と指示側メトリクスを突き合わせて反復改善する手法である。insomnia では Workflow / Skill ingest / Knowledge / memory consolidation / usage metrics / Pod orchestration があるため、この手法を単なる「手順」ではなく、**agent-facing instruction の品質観測 pipeline** として扱える。
特に insomnia では以下をシステム側で観測できる。
- evaluator Pod の session id / history
- tool call / tool result
- usage tokens
- workflow / knowledge の明示 invoke 頻度(`tickets/memory-usage-metrics.md`
- `model_invokation` 常駐注入のコスト側指標
- Phase 1 / Phase 2 memory consolidation による recurring pattern 抽出
- Workflow 自動書き込み禁止に基づく improvement offer
したがって、`/empirical-prompt-tuning` 相当の Workflow は、評価実行を orchestration するだけでなく、評価結果を構造化 event として残し、将来的に memory consolidation / usage metrics / Workflow improvement offer / `model_invokation` 判断へ接続するべきである。
## 要件
### `/empirical-prompt-tuning` Workflow
`.insomnia/workflow/empirical-prompt-tuning.md` を追加し、Workflow / Skill / prompt / Knowledge を評価対象として扱える手順を用意する。
Workflow は少なくとも以下を明示する。
- 評価対象 target の固定
- kind: workflow / skill / prompt / knowledge
- slug または path
- git revision または content hash
- Iteration 0: description / body consistency check
- scenario set の作成
- median 1 件
- edge 1〜2 件
- requirements checklist 3〜7 項目
- `[critical]` 項目を最低 1 つ含める
- evaluator Pod は毎回新規に spawn し、同じ evaluator を再利用しない
- evaluator Pod は実装者ではなく評価者として動く
- evaluator report は以下の構造にする
- Deliverable
- Requirement achievement
- Trace: Understanding / Planning / Execution / Formatting
- Unclear points: Issue / Cause / General Fix Rule
- Discretionary fill-ins
- Retries
- 1 iteration 1 theme の最小修正を原則とする
- Workflow / prompt の実ファイル書き換えは人間承認後に限る
- Workflow 自動生成 / 自動更新は禁止し、必要な改善は offer として人間に戻す
### 評価 event schema
評価結果を、将来の system metrics / memory consolidation に流せる構造化 event として定義する。
最低限の field:
```text
eval_run_id
target_kind
target_slug_or_path
target_revision_or_hash
scenario_id
scenario_kind: median | edge | holdout
evaluator_pod_name
evaluator_session_id
started_at / ended_at
success: bool
accuracy: number
critical_passed: bool
tool_call_count
tool_call_count_by_tool
input_tokens
output_tokens
cache_read_tokens / cache_write_tokens if available
scope_error_count
file_search_count
escalation_count
unclear_points[]
phase: Understanding | Planning | Execution | Formatting
issue
cause
general_fix_rule
discretionary_fill_ins[]
retries
```
初期実装で全 field が機械取得できない場合は、取得可能なものと evaluator self-report 由来のものを分ける。未取得 field は空にしてよいが、schema 上は将来埋められる形にする。
### Metrics / memory consolidation との接続
本チケットでは、評価 event を memory / metrics pipeline に接続する設計を明文化し、可能な最小実装を入れる。
接続方針:
- evaluator self-report は Phase 1 の活動抽出対象になる
- repeated `General Fix Rule` は Phase 2 が recurring failure pattern として統合できる
- recurring pattern は即 Knowledge 化せず、使用頻度メトリクス gate を通す
- Workflow 改善は `.insomnia/workflow/*.md` へ自動書き込みせず、Notification / report / ticket などの offer に留める
- `model_invokation` ON 判断では、明示 invoke 頻度と常駐コストに加えて、eval success rate / unclear point count / description-body consistency を判断材料にする
### 評価指標の解釈
Claude Code 版の `tool_uses` を、insomnia では tool 種別ごとの偏りとして解釈する。
例:
- Glob / Grep が突出: references / 探索方針が prompt 内で弱い
- Read が突出: required context の入口が弱い
- scope error が出る: permission / worktree / escalation 境界が弱い
- SpawnPod / SendToPod が多い: orchestration の粒度や子 Pod 指示が曖昧
- ticket / git write に向かう: escalation criteria が弱い
定量指標は補助であり、Unclear points / Discretionary fill-ins / General Fix Rule を主指標とする。
### Failure pattern ledger
手書き台帳だけにせず、eval event から抽出可能な failure pattern として扱う。
- `General Fix Rule` を class-level pattern として正規化する
- 同じ pattern が複数 scenario / 複数 iteration / 複数 target で再発した場合、Phase 2 が decision / knowledge candidate / workflow improvement offer に統合できる
- 同じ pattern が 3 回以上再発した場合、局所 patch ではなく target prompt の構造変更を提案する
## 範囲外
- Workflow の自動書き換え
- Knowledge の即時自動作成
- `model_invokation` ON/OFF の完全自動切替
- evaluator Pod の永続ジョブキュー化
- prompt DSL 化
- LLM judge による主観的 A/B 比較の採用
- すべての metrics field の初期実装での完全自動取得
## 完了条件
- `.insomnia/workflow/empirical-prompt-tuning.md` が追加され、insomnia の evaluator Pod / metrics / memory consolidation 前提で記述されている
- Workflow は Iteration 0、scenario checklist、Trace、Issue / Cause / General Fix Rule、1 iteration 1 theme、人間承認 gate を明示している
- 評価 event schema が docs または ticket 内で定義されている
- eval event を memory consolidation / usage metrics / Workflow improvement offer / `model_invokation` 判断へ接続する方針が文書化されている
- 既存の Workflow 自動生成禁止・history に commit されない context input 禁止・memory consolidation 方針に反していない
- `/auto-maintain` または `/worktree-workflow` のどちらか 1 件を対象に、構造審査または小規模 evaluator Pod 試走を行い、結果を記録している
## 参照
- `docs/external/zenn-mizchi-empirical-prompt-tuning.md`
- `/home/hare/ghq/github.com/mizchi/skills/empirical-prompt-tuning/SKILL.md`(外部参照。取り込み時は必要最小限に一般化する)
- `docs/plan/workflow.md`
- `docs/plan/memory.md`
- `tickets/memory-usage-metrics.md`
- `tickets/auto-maintain-workflow.md`

View File

@ -1,88 +0,0 @@
# TUI: Assistant 応答の Markdown スタイル表示
## 背景
LLM の出力は実質 Markdown だが、TUI は `Block::AssistantText { text }`
`push_padded_lines` で 1 行ずつ素のテキストとして
`Style(MessageKind::Assistant)` に流しているだけで、`**強調**` /
`` `code` `` / `# 見出し` / `- list` 等の記号がそのまま見える状態になっている
`crates/tui/src/ui.rs:592-595, 640-648`)。スタイルが付かないため、
構造のあるアシスタント応答は読みにくい。
ratatui 0.30 の `Vec<Line<'static>>` で表現できる範囲のスタイル付けで
十分目的を満たせる。既存の `wrap_line_into``crates/tui/src/ui.rs:473-`)が
span 単位のラップを既に実装しているため、Markdown レンダラは
スタイル付きの `Vec<Line>` を返すだけでよく、ラップスクロールoverview
畳み込みの仕組みを変える必要はない。
## 方針
- `pulldown-cmark``tui` クレートの依存に追加し、Event ストリームを
既存の `MessageKind` / `ratatui::style::Style` 体系へ畳み込む小さな
自前レンダラを `crates/tui/src/markdown.rs` に置く。
- レンダラの公開面は `render(text: &str, base: Style) -> Vec<Line<'static>>`
程度の 1 関数。`Block::AssistantText` の `Mode::Detail` / `Mode::Normal`
描画から呼ぶ。`Mode::Overview` は現行通り 1 行畳み込みMarkdown 記号
含めて表示しても情報量はほぼ同じなので素のテキストでよい)。
- ストリーミング中の不完全要素(未閉鎖の `**` や開きっぱなしのフェンス)
は CommonMark の流儀テキスト扱いEOF で閉じる)に任せる。挙動が
破綻する場合だけ末尾要素を素のテキストにフォールバックする小さな
後処理を入れる余地を残す。
- `tui-markdown` クレートは採用しない。syntect 依存でビルドが肥大する
割にカスタマイズが効かず、本クレートの色味(`MessageKind`
パレット)との整合を握りにくいため。
## 対応する Markdown 要素
最小限の "対応できる範囲" を以下に限定する。CommonMark + GFM の一部。
- 強調: `**bold**` / `*italic*` / `~~strike~~`GFM
- インラインコード: `` `code` ``
- フェンスコードブロック: ` ```lang ` / ` ``` `(言語タグは無視、
ブロック全体を等幅・低彩度の背景/前景で塗る)
- 見出し: H1〜H4H5/H6 は H4 と同等)
- 箇条書きリスト: `-` / `*` / `+`、ネスト可(深さ分インデント)
- 順序リスト: `1.` / `1)`、ネスト可(番号は元の値で表示)
- 引用: `> ...`(ネスト可)
- 水平線: `---` / `***`
- リンク: `[text](url)``text` をリンク色で着色URL は表示しない)
## 範囲外
- 表GFM table
- 画像 `![alt](src)`(テキストとしても表示しない)
- HTML パススルー(タグはそのまま生テキストで出る)
- 数式(`$...$` / `$$...$$`
- コードブロックの syntax highlighting
- リンクのターミナルクリックOSC 8URL の自動表示
- `Thinking` 本文 / `SystemMessage` への適用
(同じ `markdown::render` を後で差せばよい。本チケットは
`Block::AssistantText` のみ)
- ライブストリーム最中の "途中要素のフォールバック" の作り込み
CommonMark のデフォルト挙動で破綻が見えたら別チケット)
## 完了条件
- アシスタント応答に含まれる上記要素が、それぞれ視認可能な
スタイルで描画される。
- ストリーミング中、テキストが追記されるたびに描画が更新され、
フェンスコードブロックの開きが先に着いて中身が後から流れる
ようなケースでも、テキスト全体の見た目が大きく崩れない。
- `Mode::Detail` / `Mode::Normal` で Markdown スタイルが、
`Mode::Overview` では従来通りの 1 行畳み込みが出る。
- 既存の `wrap_line_into` によるラップ・右パディング・スクロール
が引き続き機能する(行幅計算が乱れない)。
## 影響範囲
- `crates/tui/Cargo.toml`: `pulldown-cmark` を追加(`cargo add` 経由)。
- `crates/tui/src/markdown.rs`: 新設。`render(&str, Style) -> Vec<Line<'static>>`。
- `crates/tui/src/ui.rs`: `Block::AssistantText` 分岐で Markdown
レンダラを呼ぶ。`Mode::Overview` は現行のまま。
- `crates/tui/src/main.rs` または `lib.rs`: 新モジュールの宣言。
## Review
- 状態: Approve
- レビュー詳細: [./tui-assistant-markdown.review.md](./tui-assistant-markdown.review.md)
- 日付: 2026-05-05

View File

@ -1,49 +0,0 @@
# Review: TUI Assistant 応答の Markdown スタイル表示
## 前提・要件の確認
### 対応する Markdown 要素 (チケット「対応する Markdown 要素」セクション)
- 強調 `**bold**` / `*italic*` / `~~strike~~`: `Renderer::start``Tag::Strong/Emphasis/Strikethrough` で深さカウンタを増やし、`span_style` で `Modifier::BOLD/ITALIC/CROSSED_OUT` を付与 (`crates/tui/src/markdown.rs:219-221, 81-89`)。`Options::ENABLE_STRIKETHROUGH` も付いている (`crates/tui/src/markdown.rs:18`)。✓
- インラインコード: `Event::Code``in_inline_code` を立ててから `push_text` し、`span_style` で yellow on `Rgb(40,40,40)` を返す (`crates/tui/src/markdown.rs:145-149, 70-73`)。✓
- フェンスコードブロック: `Tag::CodeBlock``in_code_block=true`、`Text` イベント側で `\n` を実際に行分割しつつ等幅 (cyan) で塗る (`crates/tui/src/markdown.rs:131-140, 74-76`)。言語タグは `Tag::CodeBlock(_)` で破棄。✓
- 見出し H1〜H6: `Tag::Heading { level, .. }``self.heading` を立て、`span_style` で `heading_style` を返す。H5/H6 は H4 と同色 (`crates/tui/src/markdown.rs:175-178, 277-284`)。✓
- 箇条書きリスト (`-`/`*`/`+`、ネスト可): `Tag::List(None)` 経由で `list_stack` に積み、`LIST_INDENT` を `line_prefix` に push、`Tag::Item` で `• ` マーカー (`crates/tui/src/markdown.rs:183-211`)。テスト `nested_list_indents` で深さ 2 を確認。✓
- 順序リスト (`1.`/`1)`、ネスト可、開始番号尊重): `Tag::List(Some(n))``Some(n)` を積み、`Tag::Item` で `n.` マーカーを出して `n += 1`。`pulldown-cmark` 側でも `Start(List(Some(3)))` のように開始番号が来るのを probe で確認したので、`3. a / 4. b` のような表示は意図通りになる。✓
- 引用 (`> ...`、ネスト可): `Tag::BlockQuote(_)``│ ``line_prefix` に push、ネストすると `│ │ ` になる (`crates/tui/src/markdown.rs:212-218, 256-259`)。✓
- 水平線 (`---`/`***`): `Event::Rule``─` × 40 を DarkGray で出し、前後に blank を試みる (`crates/tui/src/markdown.rs:152-161`)。✓
- リンク `[text](url)`: `Tag::Link { .. }``in_link` を立て、`span_style` で cyan + underline。URL は表示しない。✓
### 範囲外項目の取り扱い
- 表 (GFM): `Options::ENABLE_TABLES` は付けていないので素通り。テーブル記号がそのまま見える形になるが、ストリーム自体は破綻しない。✓
- 画像 `![alt](src)`: `image_depth` カウンタで alt を含めて捨てる (`crates/tui/src/markdown.rs:97-102, 223, 264`)。テスト `image_alt_is_dropped` あり。✓
- HTML パススルー: チケットの「範囲外」では「タグはそのまま生テキストで出る」と書かれているが、実装では `Event::Html` / `InlineHtml` をハンドラの `_ => {}` で**完全に捨てている** (`crates/tui/src/markdown.rs:166`)。probe で `<div>hi</div>` 入りの入力に対し `Start(HtmlBlock) / Html / End(HtmlBlock)` 列が出ることを確認したが、これら 3 イベントはすべて未処理 = 表示されない。挙動としては「タグ含めて消える」になっている。チケットの記述とはわずかにズレるが、UX 上は無音で消える方が望ましいケースが多く、blocking にはしない。
- 数式 / syntax highlight / OSC 8 / Thinking 適用 / ライブストリーム途中要素フォールバック: 着手なし、チケット通り。✓
### 完了条件
- 「上記要素が視認可能なスタイルで描画される」: 上記の通り全要素にスタイルが付くことをコードと 14 ケースのユニットテストで確認。✓
- 「ストリーミング中、フェンスコードブロックの開きが先に着いて中身が後から流れるケースで全体の見た目が大きく崩れない」: probe で `before\n\n```rust\nlet x = 1;` (閉じ忘れ) を流すと `Start(Paragraph)/Text("before")/End(Paragraph)/Start(CodeBlock(Fenced))/Text("let x = 1;")/End(CodeBlock)` が出ることを確認。途中状態でも `End(CodeBlock)` が EOF で必ず付くため `in_code_block` は確実に閉じ、現状コードブロックを描画したまま自然に途切れる。fence-only (`` ```rust ``) は中身ゼロで blank 1 行分の領域だけ取る程度で破綻しない。`unfinished_emphasis_is_treated_as_text` のテストでも `**` 単体を素テキスト扱いできることが pulldown-cmark の出力から保証される。✓
- 「`Mode::Detail` / `Mode::Normal` で Markdown スタイル、`Mode::Overview` は従来通り」: `crates/tui/src/ui.rs:592-595``match mode``Overview` だけ従来の `push_overview_line` を保ち、それ以外を `markdown::render` に流している。✓
- 「`wrap_line_into` のラップ・右パディング・スクロールが乱れない」: `markdown::render``Line::from(spans)` を返すだけで line-level の `style.bg` を一切セットしない。よって `wrap_line_into``fill_to_width = line_style.bg.is_some()` は false のまま、右パディングは発生せず diff-style 行の挙動と干渉しない。char 幅は通常の Span をそのまま並べるだけなので `UnicodeWidthChar` 計算も従来同等。✓
## アーキテクチャ・スコープ
- 影響範囲はチケット通り `crates/tui/Cargo.toml` / `crates/tui/src/markdown.rs` (新設) / `crates/tui/src/ui.rs` の 1 行 / `crates/tui/src/main.rs``mod markdown;` 1 行のみ。`ui.rs` は 1 行差し替えに収まり (`crates/tui/src/ui.rs:594`)、レンダリングパイプライン (`compute_history` → `wrap_line_into` → スクロール) には触っていない。最小スコープが守られている。
- 公開面はチケット指定通り `pub fn render(text: &str, base: Style) -> Vec<Line<'static>>` の 1 関数のみ。`Renderer` 構造体は `pub` でない。過剰抽象化なし。
- 依存追加は `pulldown-cmark = { version = "0.13.3", default-features = false }` で、CommonMark コアのみを取り込む形。`tui-markdown` を避け、syntect 等の重量依存を持ち込んでいない (チケット方針通り)。
- 新規クレートは作っていないので命名ポリシー (insomnia- プレフィックス禁止) は対象外。
- `markdown` モジュールは `crates/tui/src/markdown.rs` の単一ファイルにまとまっており、`#[cfg(test)]` で 14 ケース同居。低レベル基盤クレート (`llm-worker` 等) を汚染していない、TUI レイヤ内に閉じる正しい配置。
## 指摘事項
### Non-blocking / Follow-up
- HTML 取り扱いがチケット記載 (「タグはそのまま生テキストで出る」) と実装 (完全に破棄) で食い違う。実装側の方が UX 的に望ましいので、チケット側の文面を「HTML はそのまま無視する」に直すか、レビュー記録のままにしておくかは判断に委ねる。`crates/tui/src/markdown.rs:162-166`。
- `span_style` 内で inline code / code block / heading が `self.base` を完全に無視している。Assistant の `kind_style` (`fg(White)`) しか base に来ない現状では実害ゼロだが、将来同じ `markdown::render``Thinking` (magenta + ITALIC) や `SystemMessage` (cyan) で使い回す際にコードブロックだけ palette から外れる。本チケットは Assistant のみが対象なので非ブロッキング。差すタイミングで「base を起点に code/heading の色相だけ寄せる」関数化を検討すると良い。`crates/tui/src/markdown.rs:70-94`。
- 空のリスト項目 (`- a\n-\n- c` のような) は `pending_marker``flush_line` で消費される結果、`• ` だけの行が出る。`TagEnd::Item` のコメントは「marker was never consumed」と書いてあるが、現実には `flush_line` (current 空 + pending_marker Some) のガード条件をすり抜けて消費される (`crates/tui/src/markdown.rs:104-116`)。挙動として「空項目は空のバレットを 1 行出す」になっているのは妥当だが、コメントの意図と挙動がやや不一致。pending_marker を消費するか落とすかは別チケットでも構わない範囲。
### Nits
- `RULE_WIDTH` が 40 固定。ターミナル幅に応じた可変化は本チケットの完了条件外なので OK だが、`wrap_line_into` 経由で右側に折り返されない (40 < width 前提) ことだけ将来確認が要る狭幅環境でも安全側 (はみ出さない) なので問題なし
- `pulldown_cmark::Options::ENABLE_STRIKETHROUGH` のみ有効。GFM のうち autolink / task list は今回対象外なので妥当。
## 判断
**Approve** — チケットの「対応する Markdown 要素」「範囲外」「完了条件」「影響範囲」のすべてに、コードとテストの両面で対応している。ストリーミング途中状態の堅牢性は CommonMark + pulldown-cmark 0.13 のセマンティクスに任せる方針が妥当に効いており、`wrap_line_into` との互換性も line-level style を空に保つことで担保できている。HTML 表示の文面ズレは非ブロッキング。

View File

@ -1,41 +0,0 @@
# TUI: Compaction 中の進行表示
## 背景
Pod で compact が走ると `Event::CompactStart` が 1 本流れ、その後 compact worker (要約 / auto-read 判定 / リファレンス選定) が走り終えるまで TUI には何も流れない。完了時に `Event::CompactDone` または `CompactFailed` が来てようやく次の block が積まれる。
実時間で数十秒〜分単位かかる区間が完全な無音になり、ユーザーからは「固まった」「Pod が落ちた」と区別がつかない。
現状の TUI 表示 (`crates/tui/src/app.rs:651-661`、`crates/tui/src/ui.rs:799-821`) は `CompactStart` / `CompactDone` / `CompactFailed` をそれぞれ独立した append-only な block として積んでいるだけで、進行中であることや経過時間は出ていない。
ThinkingBlock は同種の「LLM が応答するまでの待ち時間」を `ThinkingState::Streaming { started_at }` + ライブで `Thinking... (Xs)` 表示で扱っている (`crates/tui/src/block.rs:67-78`)。Compact も同じパターンに揃えたい。
## 要件
- compact 中であることがライブで分かる。経過秒数を 1 行で表示する (`Compacting... (Xs)` 程度)
- 完了 / 失敗時は進行表示が結果行に **置き換わる**。1 回の compact で block が 2 個積まれるのではなく、Start で積んだものを Done / Failed が更新する
- 完了行は新 session_id の short prefix と所要時間を含む
- 失敗行はエラー文と所要時間を含む
- 進行中のまま `Event::Shutdown` 等で取り残された場合は ThinkingState::Incomplete に相当する終端状態に落とす
- ステータスライン (`draw_status`) はこのチケットでは触らない。block 側のライブ表示のみ
## 完了条件
- compact が走っている間、TUI 末尾に `Compacting... (Xs)` が出て、s が秒単位で進む
- compact 成功で同じ行が `[compact] done (new session XXXXXXXX) (Ys)` 相当に更新される
- compact 失敗で同じ行が `[compact error] ... (Ys)` 相当に更新される
- 1 回の compact イベント列で `Block::Compact` は 1 個しか積まれない (履歴を遡っても重複しない)
- 既存テストが通る
## 範囲外
- 要約 worker の中身 (read_file ループ等) を TUI に流す仕組み
- ステータスラインへの `Compacting…` 表示
- session_id の TUI 表示全般 (旧 / 新 / 親 / 子セッション系譜の可視化は別チケット)
- compact がそもそも動いているかどうかの設定確認 UI
## 関連
- `docs/compaction.md` — compact のトリガーとイベント並びの全体像
- `crates/tui/src/block.rs``ThinkingBlock` / `ThinkingState` — 同型のライブ進行表示の前例
- `crates/tui/src/app.rs:714-746``last_streaming_thinking_mut` / `mark_orphan_thinking_incomplete` の参考実装

View File

@ -0,0 +1,68 @@
# Workflow を memory crate から独立させる
## 背景
`tickets/workflow-directory-layout.md` で Workflow の物理配置を `.insomnia/workflow/` に分離した。これにより Workflow は概念上 memory statesession-derived / generatedと別物として整理されたが、ソースコード上は依然として `crates/memory/` 配下に同居している:
- `crates/memory/src/workflow.rs``WorkflowRecord` / `WorkflowRegistry` / `WorkflowSource` / `load_workflows` / `WorkflowLoadError` / `WORKFLOW_DESCRIPTION_HARD_CAP` / `ResidentWorkflowEntry` / `ShadowedSkill`
- `crates/memory/src/schema/workflow.rs``WorkflowFrontmatter`
- `crates/memory/src/skill.rs`Skill → Workflow projection
- `crates/memory/src/linter/mod.rs::lint_workflow`(人間編集向けの workflow linter
- `crates/memory/src/error.rs::LintError::WorkflowWriteForbidden`
memory crate のドメインは「decisions / requests / summary / knowledge / staging / consolidation」に絞り、Workflow は独立した crate に出す。`tickets/internal-worker-workflow.md` で内部 Worker の Workflow 化が予定されており、bundled default や `internal_role` 追加の置き場として独立 crate がある方が自然。
## 要件
### crate の分離
`crates/workflow/` を新設し、上記の Workflow 関連型 / 関数 / スキーマ / Skill projection / human-edit linter を移す。
- 新 crate からは memory crate に依存しないか、`WorkspaceLayout` 経由で薄く依存するに留める
- `crates/memory/` から workflow 関連の `pub use` 再エクスポートは削除(呼び出し側が新 crate を直接 import する)
- Workflow 用の linter は memory crate の `Linter` を共有しないでよい場合は単独で持つ。共有が必要なら共通部分を別 crate例: `crates/lint-common/`)に切る判断を行う
### `WorkspaceLayout` の扱い
`workflow_dir()` / `workflow_path()` が memory crate に残るかは設計判断:
- memory crate に残し、workflow crate がそれを利用する形でよい
- 別 crate例: `crates/workspace-layout/`)に切り出す場合は memory / workflow 両方が参照する形にする
どちらでもよいが、結果として循環依存を生まないこと。
### 既存 use site の更新
- `crates/pod/``pod.rs` / `prompt/system.rs` / `workflow/mod.rs`
- `crates/tui/`
- その他 `memory::Workflow*` を import している箇所
これらが新 crate を import する形に書き換わる。
### Skill ingestion の所属
`SKILL.md` パーサと `WorkflowRecord` への projection は workflow crate に同居する。Skills は外部入力だが最終的に Workflow registry に流れるので、workflow crate を窓口にする方がレイヤとして自然。
### scope deny の整理
`crates/memory/src/scope.rs::deny_write_rules` は memory / knowledge / workflow の 3 ディレクトリを deny している。workflow crate 側で `.insomnia/workflow/` の deny を表明し、Pod 起動時に両方を合成する形にするか、あるいは scope deny は呼び出し側podで集約する形に再設計する。
## 範囲外
- Workflow の機能変更frontmatter schema 変更、resolver 改修等)
- bundled default Workflow 機構(`tickets/internal-worker-workflow.md` の対象)
- memory crate 内部の他モジュール再編
## 完了条件
- `crates/workflow/` crate が独立して存在し、`WorkflowRecord` / `WorkflowRegistry` / `load_workflows` / `WorkflowFrontmatter` / Skill projection / human-edit linter がそこに住む
- memory crate に workflow / skill 関連のソースが残っていないreexport も無し)
- 既存テストが新構造で通る
- 既存呼び出し側pod / tui 等)が新 crate を import する形に更新されている
- scope deny が memory / workflow を矛盾なく合成できる構成になっている
## 参照
- 直前: `tickets/workflow-directory-layout.md`git log
- 後続: `tickets/internal-worker-workflow.md`
- 関連: `docs/plan/workflow.md`